仿牛客网社区开发——第5章 Kafka,构建TB级异步消息系统
阻塞队列#
- BlockingQueue 是一个队列接口,ArrayBlockingQueue 等是它的实现类
- 生产者和消费者共用一个阻塞队列,生产者使用 put 方法放入数据,消费者使用 take 方法取出数据
- 当队列已满的时候,生产者线程被阻塞;当队列为空的时候,消费者线程被阻塞
public class BlockingQueueTests {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
new Thread(new Producer(queue)).start();
new Thread(new Consumer(queue)).start();
new Thread(new Consumer(queue)).start();
new Thread(new Consumer(queue)).start();
}
}
class Producer implements Runnable {
private BlockingQueue<Integer> queue;
public Producer(BlockingQueue queue) {
this.queue = queue;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
Thread.sleep(20);
queue.put(i);
System.out.println(Thread.currentThread() + "生产:" + queue.size());
} catch (InterruptedException e) {
e.getMessage();
}
}
}
}
class Consumer implements Runnable {
private BlockingQueue<Integer> queue;
public Consumer(BlockingQueue queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(new Random().nextInt(1000));
queue.take();
System.out.println(Thread.currentThread() + "消费:" + queue.size());
} catch (InterruptedException e) {
e.getMessage();
}
}
}
}
运行结果如下(截取最开始和最后的部分):
不难发现,队列满时(容量为10),生产者线程被阻塞,消费者线程消费一个,容量变成 9 后,生产者线程又被唤醒再次生产;因为生产者方法中定义了只生产 100 个,全部生产完后,消费者再将其全部消费完,容量变为 0 后,程序并没有结束,即消费者线程都被阻塞。
这里会连续出现 10 的原因是,虽然 ArrayBlockingQueue 是线程安全的,但是因为在 take 和 sout 之间没有加锁,所以在这两个语句之间有可能被其他线程打断,造成打印不及时。
注意点:#
- 阻塞队列的概念、生产者消费者模式
- BlockingQueue 的各个实现类
Kafka 入门#
Kafka 把数据存放到硬盘里,对硬盘顺序读写,具有较高的性能,这也是高吞吐量的基础。
消息队列的两种模式(Kafka 采用发布订阅模式)
- 点对点模式:生产者把数据放到队列里,多个消费者从队列里取值,值被取出就从队列里消失,多个消费者取到的值不重复,每个数据只被一个消费者消费
- 发布订阅模式:生产者把数据放到指定位置,多个消费者可以从指定位置读取数据,多个消费者读到的数据可以重复,可以同时或先后读取数据
Kafka 术语#
Broker:Kafka 中的每一台服务器; Zookeeper:用来管理集群;
Topic:主题,生产者把消息发布到的位置或空间,即用来存放消息的位置; Partition:对主题位置的分区,增强服务器处理、并发能力;
Offset:消息在分区里存放的索引; Replica:数据副本,每一个分区有多个副本;
Leader Replica:主数据副本,可以处理请求,提供数据; Follower Replica:从副本,只负责从主副本备份数据,不做响应;
分布式的时候如果主数据副本挂了,Zookeeper 会从从数据副本里选择一个作为主数据副本。
Kafka 下载安装与配置#
官网下载地址:
https://www.apache.org/dyn/closer.cgi?path=/kafka/3.1.0/kafka_2.13-3.1.0.tgz
配置:
1、数据存放的位置:打开 zookeeper.properties,配置 dataDir=(自己设定的位置)
2、日志文件存放的位置:打开 server.properties,配置 log.dirs=(自己设定的位置)
官网快速入门:
https://kafka.apache.org/quickstart
先开启 zookeeper 集群,再开启 Kafka
注意:在 windows 的命令行里启动 Kafka 之后,当关闭命令行窗口时,就会强制关闭 Kafka。这种关闭方式为暴力关闭,很可能会导致 Kafka 无法完成对日志文件的解锁。届时,再次启动 Kafka 的时候,就会提示日志文件被锁,无法成功启动。因此需要使用相应的命令进行关闭!
具体命令#
首先需要进入到 Kafka 目录(直接在该文件夹目录上 cmd,或者 cd 到该目录)
D:\Java\Java\Kafka\kafka_2.12-2.2.0>
启动服务器(先启动 zookeeper 服务器,再启动 Kafka),不要直接点击关闭,使用关闭命令
bin\windows\zookeeper-server-start.bat config\zookeeper.properties
bin\windows\kafka-server-start.bat config\server.properties
创建主题(指定服务器,1 个副本,1 个分区,主题名为 test)
bin\windows\kafka-topics.bat --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test
查看当前服务器的主题
bin\windows\kafka-topics.bat --list --bootstrap-server localhost:9092
再开启一个终端,创建生产者,往指定主题上发消息
bin\windows\kafka-console-producer.bat --broker-list localhost:9092 --topic test
再开启一个终端来创建消费者,从头开始读
bin\windows\kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test --from-beginning
关闭 Kafka 服务器
bin\windows\kafka-server-stop.bat
关闭 zookeeper 服务器(经测试先关这个貌似会两个一起关了)
bin\windows\zookeeper-server-stop.bat
注意点:#
- 不能暴力关闭 Kafka(即直接关闭命令行窗口),需要用关闭命令
- 消息队列的两种模式与 Kafka 术语
- 要先进行配置(原来 2 个路径都是 Linux 下的路径)
Spring 整合 Kafka#
引入依赖 spring-kafka#
为了避免版本冲突,因此删除版本。父 pom 中已经声明相应版本
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
配置 Kafka#
配置 server、consumer
是否自动提交消费者读取的偏移量、自动提交的频率 /ms(具体原理不是很清楚,待将来有空学一下 Kafka 的相关视频)
# KafkaProperties
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=test-consumer-group
spring.kafka.consumer.enable-auto-commit=true
spring.kafka.consumer.auto-commit-interval=3000
也可以把 conf 文件夹中的 consumer.properties 中的 group.id 修改为 community-consumer-group,application.properties 中同样进行修改
访问 Kafka#
KafkaTemplate 是 Spring 整合的类
- 生产者:kafkaTemplate.send(topic, data);
- 消费者:通过在方法上注解监听器实现,一个或者多个需要监听的主题;如果主题上有消息就会调用该方法作处理,把消息包装成 ConsumerRecord 传入方法
生产者发消息需要主动调用,消费者处理消息是被动的,略有延迟
//@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class KafkaTests {
@Autowired
private KafkaProducer kafkaProducer;
@Test
public void testKafka() {
kafkaProducer.sendMessage("test", "你好");
kafkaProducer.sendMessage("test", "在吗");
try {
Thread.sleep(1000 * 10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Component
class KafkaProducer {
@Autowired
private KafkaTemplate kafkaTemplate;
public void sendMessage(String topic, String message) {
kafkaTemplate.send(topic, message);
}
}
@Component
class KafkaConsumer {
@KafkaListener(topics = {"test"})
public void handleMessage(ConsumerRecord record) {
System.out.println(record.value());
}
}
测试方法中睡眠一定时间,防止程序运行结束,消费者还没有监听到消息(有延迟)
测试途中发现,一开始疯狂输出 WARN 日志,原因是 zookeeper 和 Kafka 没启动
都启动后,运行正确但是没输出,经查看评论区,说是要在 consumer.properties 这个配置文件里面添加 listeners=PLAINTEXT://localhost:9092(添加完需重启服务器。另外我看其他有关 Kafka 使用的博客里都说一开始要加上这个配置,不知道为啥老师没提)
注意点:#
- 生产者与消费者类的代码编写方式,生产者类中注入 KafkaTemplate,需主动调用它来发送消息;消费者类的方法上加上 @KafkaListener 注解(并且声明要监听的主题),被动监听,一旦有消息(有一定延迟)就调用方法来处理,消息会封装成 ConsumerRecord 类,在方法参数上进行声明
- 测试时出现的 2 个问题(上面有)
发送系统通知#
封装事件:当事件被触发时,发送相关的数据#
- Event 是对事件发生时所需的数据进行封装,而不是就拼成字符串。拼一个事件对象,这个事件对象中包含所有的数据
- 所有的 set 方法都返回当前对象,便于连续调用 .set().set()
- 定义一个 Map 类型的成员变量,便于添加其它数据,更具有扩展性
- 生产的是事件,消费的也是事件。消费者最终要把事件转换成消息插到数据库里
- 另外注意,userId 是触发事件的用户(即当前用户,下一节中显示为“用户xxx评论/点赞/关注了……”),而 entityUserId 则是后面 Message 中的 toId
public class Event {
private String topic;
private int userId;
private int entityType;
private int entityId;
private int entityUserId;
private Map<String, Object> data = new HashMap<>();
public String getTopic() {
return topic;
}
public Event setTopic(String topic) {
this.topic = topic;
return this;
}
public int getUserId() {
return userId;
}
public Event setUserId(int userId) {
this.userId = userId;
return this;
}
public int getEntityType() {
return entityType;
}
public Event setEntityType(int entityType) {
this.entityType = entityType;
return this;
}
public int getEntityId() {
return entityId;
}
public Event setEntityId(int entityId) {
this.entityId = entityId;
return this;
}
public int getEntityUserId() {
return entityUserId;
}
public Event setEntityUserId(int entityUserId) {
this.entityUserId = entityUserId;
return this;
}
public Map<String, Object> getData() {
return data;
}
public Event setData(String key, Object value) {
this.data.put(key, value);
return this;
}
}
编写事件的生产者#
把 Event 对象转为 JSON 字符串发送到指定主题
@Component
public class EventProducer {
@Autowired
private KafkaTemplate kafkaTemplate;
// 处理事件
public void fireEvent(Event event) {
// 将事件发送到指定主题
kafkaTemplate.send(event.getTopic(), JSONObject.toJSONString(event));
}
}
编写事件的消费者#
- 事件对象是事件触发时封装的相关数据,事件的消费者在消费这个事件的时候得到的是原始的数据。最终要把这个数据转换成一个 Message 并存放到数据库中。Message 里包含了一些基础数据,也包含一些内容。最终这个内容要拼出页面上显示的话,而拼出这样的话需要知道用户的 id 和操作的目标的相关信息
- 三种事件处理方式类似,复用一个方法来处理多个主题(在 CommunityConstant 中再额外定义三个主题的常量)
- 把 JSON 字符串转换回 Event 对象,并创建 Message 对象,相应地赋值
- fromId 为 系统用户(在 CommunityConstant 中定义常量 SYSTEM_USER_ID=1),toId 为 entityUserId,conversationId 存储为系统通知主题,content 为 JSON 字符串
- 把 entityType、entityId 和 userId 存放到 Map 中,并把 Event 对象中的 content 依次存入 Map 。最终把 Map 转为 JSON 字符串存放到 content 字段里
@Component
public class EventConsumer implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(EventConsumer.class);
@Autowired
private MessageService messageService;
@KafkaListener(topics = {TOPIC_COMMENT, TOPIC_LIKE, TOPIC_FOLLOW})
public void handleEvent(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容为空!");
return;
}
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式错误!");
return;
}
// 发送站内通知
Message message = new Message();
message.setFromId(SYSTEM_USER_ID);
message.setToId(event.getEntityUserId());
message.setConversationId(event.getTopic());
message.setCreateTime(new Date());
Map<String, Object> content = new HashMap<>();
content.put("userId", event.getUserId());
content.put("entityType", event.getEntityType());
content.put("entityId", event.getEntityId());
if (!event.getData().isEmpty()) {
for (Map.Entry<String, Object> entry : event.getData().entrySet()) {
content.put(entry.getKey(), entry.getValue());
}
}
message.setContent(JSONObject.toJSONString(content));
messageService.addMessage(message);
}
}
触发事件#
本次功能的核心就是异步,发送完消息后,程序立刻向下执行,去处理页面的响应。后面在处理其它业务的同时,消息也在处理,即并发或者异步的,效率更高。比如有一个热帖,评论的频率很高,这里把 event 丢到队列里就不管了,处理的能力就提高了,可以攒着很多消息慢慢处理,起到一个缓冲的作用。
修改 CommentController、LikeController 和 FollowController,各自部分的代码着重看“// 触发xx事件”的部分
修改 CommentController#
- 查找评论的对象的作者需要先判断评论的类型。为帖子类型时,需要通过帖子 id 查到该帖子,进而得到作者;否则即评论类型,这里对老师代码做了改进,如果是针对二级评论的特定用户的回复,直接赋上 targetId(此时就是回复的目标用户);如果是针对一级评论的普通回复,则还是通过查找评论对象来得到该作者(这里需要添加通过评论的 id 来查找评论的 Mapper 和 Service 方法)
- setData 赋上帖子的 id,以便下个功能中添加至该帖子的链接
@PostMapping("/add/{discussPostId}")
public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
User user = hostHolder.getUser();
comment.setUserId(user.getId());
comment.setCreateTime(new Date());
commentService.addComment(comment);
// 触发评论事件
Event event = new Event()
.setTopic(TOPIC_COMMENT)
.setUserId(user.getId())
.setEntityType(comment.getEntityType())
.setEntityId(comment.getEntityId())
.setData("postId", discussPostId);
if(comment.getEntityType() == ENTITY_TYPE_POST) {
DiscussPost target = discussPostService.findDiscussPostById(discussPostId);
event.setEntityUserId(target.getUserId());
} else {
if(comment.getTargetId() != 0) {
event.setEntityUserId(comment.getTargetId());
} else {
Comment target = commentService.findCommentById(comment.getEntityId());
event.setEntityUserId(target.getUserId());
}
}
eventProducer.fireEvent(event);
return "redirect:/discuss/detail/" + discussPostId;
}
修改 LikeController#
无论用户对帖子点赞还是评论点赞,也需要添加至该帖子的链接。这里通过在前端传 postId 来得到帖子的 id,like 方法上增加该参数,同时前端 html 页面和 js 的 like 方法处也要作相应的修改
@PostMapping("/like")
@ResponseBody
public String like(int entityType, int entityId, int entityUserId, int postId) {
User user = hostHolder.getUser();
// 点赞
……
// 数量
……
// 状态
……
// 返回的结果
……
// 触发点赞事件
if (likeStatus == 1) {
Event event = new Event()
.setTopic(TOPIC_LIKE)
.setUserId(user.getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityUserId)
.setData("postId", postId);
eventProducer.fireEvent(event);
}
return CommunityUtil.getJSONString(0, null, map);
}
修改 FollowController#
- 目前只有关注用户的功能,暂且默认 entityId 就是用户 id
- 关注了某用户,不需要添加到任何帖子的链接,所以不用添加 postId
@PostMapping("/follow")
@ResponseBody
@LoginRequired
public String follow(int entityType, int entityId) {
User user = hostHolder.getUser();
followService.follow(user.getId(), entityType, entityId);
// 触发关注事件
Event event = new Event()
.setTopic(TOPIC_FOLLOW)
.setUserId(user.getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityId);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(0, "已关注!");
}
额外的修改#
在 ServiceLogAspect 中,此处记录访问业务方法的日志,需要加一个判空。因为添加了本次功能后,在非 Controller 层的方法中调用了 Service 层的方法(EventConsumer),不是用户通过请求访问的,这里得不到该对象,下面去调方法就会报空指针异常
@Before("pointcut()")
public void before(JoinPoint joinPoint) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if(attributes == null)
return;
……
logger.info(String.format("用户[%s],在[%s],访问了[%s]", ip, now, target));
}
注意点:#
- 对事件、Event对象的理解,清楚这个对象各个属性的含义,尤其是 userId 和 entityUserId
- 生产者与消费者在处理事件时,注意对象和 JSON 之间的转换;且消费者最终将其转换成 Message 存入数据库,注意各个属性的赋值
- 对于该功能核心,异步的理解,为什么效率高
- 修改 CommentController 和 LikeController 的触发事件时添加帖子 id,方便链接到帖子页面;同时 CommentController 中注意查找评论的对象的作者(判断其类型)
- 注意判空,尤其是 ServiceLogAspect 中的那个 before 方法,有可能不是用户通过请求访问了 Service 层的业务方法(EventConsumer 中调用了 MessageService 的方法),所以 ServletRequestAttributes 有可能为空
显示系统通知#
开发通知列表#
编写 MessageMapper 实现查询通知的方法#
- 每次页面显示最新的通知,并且显示每个主题所包含的通知数量和未读通知数量
- 三个方法都传入当前登录用户 id 和主题类型(作为conversation_id)
// 查询某个主题下最新的统治
Message selectLatestNotice(int userId, String topic);
// 查询某个主题所包含的通知数量
int selectNoticeCount(int userId, String topic);
// 查询未读的通知的数量
int selectNoticeUnreadCount(int userId, String topic);
编写对应的 xml 实现 sql 语句#
- 查找 id 最大值代表最新的消息,并且消息状态不等于 2(不查被删除的)。发送者为系统用户,接收者为当前登录用户,主题为当前主题
- 如果不传主题,就查询所有主题的未读数量
<select id="selectLatestNotice" resultType="com.zwc.community.entity.Message">
SELECT <include refid="selectFields"/>
FROM message
WHERE id IN (
SELECT max(id)
FROM message
WHERE status != 2
AND from_id = 1
AND to_id = #{userId}
AND conversation_id = #{topic}
)
</select>
<select id="selectNoticeCount" resultType="int">
SELECT count(id)
FROM message
WHERE status != 2
AND from_id = 1
AND to_id = #{userId}
AND conversation_id = #{topic}
</select>
<select id="selectNoticeUnreadCount" resultType="int">
SELECT count(id)
FROM message
WHERE status = 0
AND from_id = 1
AND to_id = #{userId}
<if test="topic!=null">
AND conversation_id = #{topic}
</if>
</select>
编写 MessageService 调用 Mapper 的方法#
public Message findLatestNotice(int userId, String topic) {
return messageMapper.selectLatestNotice(userId, topic);
}
public int findNoticeCount(int userId, String topic) {
return messageMapper.selectNoticeCount(userId, topic);
}
public int findNoticeUnreadCount(int userId, String topic) {
return messageMapper.selectNoticeUnreadCount(userId, topic);
}
编写 MessageController 接受查询系统通知的请求#
- 首先得到当前用户,查他的评论类通知信息,message 判空,不为空则进行后续处理,把 message 存入 messageVO 中
- 将 message 中的 content 的转义字符转换回原字符(" -> "),再将其从 JSON 格式转换为 Map 对象,把其中的各属性值依次存入 messageVO 中
- 查询到通知数量和未读通知数量也存入 messageVO 中,再把 messageVO 放入 model 中
- 点赞类和关注类相似,注意主题名的更换,以及关注类不需要 postId
- 最后把总的未读私信数量和未读通知数量放入 model 中,注意上面查询私信列表也要添加未读通知数量,页面需要显示
@GetMapping("/notice/list")
public String getNoticeList(Model model) {
User user = hostHolder.getUser();
// 查询评论类通知
Message message = messageService.findLatestNotice(user.getId(), TOPIC_COMMENT);
Map<String, Object> messageVO = new HashMap<>();
if(message != null) {
messageVO.put("message", message);
String content = HtmlUtils.htmlUnescape(message.getContent());
Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
messageVO.put("entityType", data.get("entityType"));
messageVO.put("entityId", data.get("entityId"));
messageVO.put("postId", data.get("postId"));
int count = messageService.findNoticeCount(user.getId(), TOPIC_COMMENT);
messageVO.put("count", count);
int unread = messageService.findNoticeUnreadCount(user.getId(), TOPIC_COMMENT);
messageVO.put("unread", unread);
}
model.addAttribute("commentNotice", messageVO);
// 查询点赞类通知
message = messageService.findLatestNotice(user.getId(), TOPIC_LIKE);
messageVO = new HashMap<>();
if(message != null) {
messageVO.put("message", message);
String content = HtmlUtils.htmlUnescape(message.getContent());
Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
messageVO.put("entityType", data.get("entityType"));
messageVO.put("entityId", data.get("entityId"));
messageVO.put("postId", data.get("postId"));
int count = messageService.findNoticeCount(user.getId(), TOPIC_LIKE);
messageVO.put("count", count);
int unread = messageService.findNoticeUnreadCount(user.getId(), TOPIC_LIKE);
messageVO.put("unread", unread);
}
model.addAttribute("likeNotice", messageVO);
// 查询关注类通知
message = messageService.findLatestNotice(user.getId(), TOPIC_FOLLOW);
messageVO = new HashMap<>();
if(message != null) {
messageVO.put("message", message);
String content = HtmlUtils.htmlUnescape(message.getContent());
Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
messageVO.put("entityType", data.get("entityType"));
messageVO.put("entityId", data.get("entityId"));
int count = messageService.findNoticeCount(user.getId(), TOPIC_FOLLOW);
messageVO.put("count", count);
int unread = messageService.findNoticeUnreadCount(user.getId(), TOPIC_FOLLOW);
messageVO.put("unread", unread);
}
model.addAttribute("followNotice", messageVO);
// 查询未读消息数量
int letterUnreadCount = messageService.findUnreadLetterCount(user.getId(), null);
model.addAttribute("letterUnreadCount", letterUnreadCount);
int noticeUnreadCount = messageService.findNoticeUnreadCount(user.getId(), null);
model.addAttribute("noticeUnreadCount", noticeUnreadCount);
return "/site/notice";
}
修改页面#
letter.html 中需要添加到系统通知的链接和其未读数量
<!-- 选项 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link position-relative active" th:href="@{/letter/list}">
朋友私信<span class="badge badge-danger" th:text="${letterUnreadCount}" th:if="${letterUnreadCount!=0}">3</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link position-relative" th:href="@{/notice/list}">
系统通知<span class="badge badge-danger" th:text="${noticeUnreadCount}" th:if="${noticeUnreadCount!=0}">27</span>
</a>
</li>
</ul>
notice.html 中类似,只不过 active 的标签不同
<!-- 选项 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link position-relative" th:href="@{/letter/list}">朋友私信
<span class="badge badge-danger" th:text="${letterUnreadCount}" th:if="${letterUnreadCount!=0}">3</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link position-relative active" th:href="@{/notice/list}">系统通知
<span class="badge badge-danger" th:text="${noticeUnreadCount}" th:if="${noticeUnreadCount!=0}">27</span>
</a>
</li>
</ul>
再修改每一类通知的 <li>,这里以评论类为例,点赞类和关注类基本相同
- 如果没有通知就不显示,未读消息数量为 0 也不显示
- 还需要添加到各个通知详情页面的链接
- 根据 entityType 决定评论的是帖子还是回复
<!--评论类通知-->
<li class="media pb-3 pt-3 mb-3 border-bottom position-relative" th:if="${commentNotice.message!=null}">
<span class="badge badge-danger" th:text="${commentNotice.unread!=0?commentNotice.unread:''}">3</span>
<img src="http://static.nowcoder.com/images/head/reply.png" class="mr-4 user-header" alt="通知图标">
<div class="media-body">
<h6 class="mt-0 mb-3">
<span>评论</span>
<span class="float-right text-muted font-size-12"
th:text="${#dates.format(commentNotice.message.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</span>
</h6>
<div>
<a th:href="@{/notice/detail/comment}">
用户
<i th:utext="${commentNotice.user.username}">nowcoder</i>
评论了你的
<b th:text="${commentNotice.entityType==1?'帖子':'回复'}">帖子</b> ...
</a>
<ul class="d-inline font-size-12 float-right">
<li class="d-inline ml-2">
<span class="text-primary">
共 <i th:text="${commentNotice.count}">3</i> 条会话
</span>
</li>
</ul>
</div>
</div>
</li>
2022年7月17日11:06:09更新:这里的 xxx.message!=null,当没有存入 message 时(即有一类或几类没有任何通知信息),判断是否为空会报错,解决办法见第 7 章的权限控制中的“处理遗留的小问题”
开发通知详情#
编写 MessageMapper 查询某个主题所包含的通知列表#
需要分页
// 查询某个主题所包含的通知列表
List<Message> selectNotices(int userId, String topic, int offset, int limit);
编写对应的 xml 实现 sql 语句#
按照时间倒序排序
<select id="selectNotices" resultType="com.zwc.community.entity.Message">
SELECT <include refid="selectFields"/>
FROM message
WHERE status != 2
AND from_id = 1
AND to_id = #{userId}
AND conversation_id = #{topic}
ORDER BY create_time DESC
LIMIT #{offset}, #{limit}
</select>
编写 MessageService 调用 Mapper 的方法#
public List<Message> findNotices(int userId, String topic, int offset, int limit) {
return messageMapper.selectNotices(userId, topic, offset, limit);
}
编写 MessageController 接受查询通知详情的请求#
- 主题通过路径传进来
- 得到当前用户、设置分页信息
- 将通知、内容(处理方式同通知列表方法)、通知作者(这里即系统用户,不写死,也去数据库中取值,以便系统用户的名字或头像作修改)放入 map,再将每一个 map 放入 noticeVoList,最终放入 model 中(这里 topic 也被 Spring 放入了 model 中,详细原因见之前的博客)
- 最后注意要设置已读
@GetMapping("/notice/detail/{topic}")
public String getNoticeDetail(@PathVariable("topic") String topic, Page page, Model model) {
User user = hostHolder.getUser();
page.setLimit(5);
page.setPath("/notice/detail/" + topic);
page.setRows(messageService.findNoticeCount(user.getId(), topic));
List<Message> noticeList = messageService.findNotices(user.getId(), topic, page.getOffset(), page.getLimit());
List<Map<String, Object>> noticeVoList = new ArrayList<>();
if (noticeList != null) {
for (Message notice : noticeList) {
Map<String, Object> map = new HashMap<>();
// 通知
map.put("notice", notice);
// 内容
String content = HtmlUtils.htmlUnescape(notice.getContent());
Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
map.put("user", userService.findUserById((Integer) data.get("userId")));
map.put("entityType", data.get("entityType"));
map.put("entityId", data.get("entityId"));
map.put("postId", data.get("postId"));
// 通知作者
map.put("fromUser", userService.findUserById(notice.getFromId()));
noticeVoList.add(map);
}
}
model.addAttribute("notices", noticeVoList);
// 设置已读
List<Integer> ids = getLetterIds(noticeList);
if (!ids.isEmpty()) {
messageService.readMessage(ids);
}
return "/site/notice-detail";
}
修改页面#
修改 notice-detail.html 页面
- 返回按钮添加 js 方法,触发单击事件,跳转页面
<div class="col-4 text-right">
<button type="button" class="btn btn-secondary btn-sm" onclick="back();">返回</button>
</div>
……
<script>
function back() {
location.href = CONTEXT_PATH + "/notice/list";
}
</script>
- 通知列表,遍历每一个通知;topic.equals('') 判断主题类型,选择显示哪一个 <span>
- 评论类和点赞类添加至帖子的页面,关注类添加至被关注的用户的页面
<!-- 通知列表 -->
<ul class="list-unstyled mt-4">
<li class="media pb-3 pt-3 mb-2" th:each="map:${notices}">
<img th:src="${map.fromUser.headerUrl}" class="mr-4 rounded-circle user-header" alt="系统图标">
<div class="toast show d-lg-block" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="mr-auto" th:utext="${map.fromUser.username}">落基山脉下的闲人</strong>
<small th:text="${#dates.format(map.notice.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-25 15:49:32</small>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="toast-body">
<span th:if="${topic.equals('comment')}">
用户
<i th:utext="${map.user.username}">nowcoder</i>
评论了你的
<b th:text="${map.entityType==1?'帖子':'回复'}">帖子</b>,
<a class="text-primary" th:href="@{|/discuss/detail/${map.postId}|}">点击查看</a> !
</span>
<span th:if="${topic.equals('like')}">
用户
<i th:utext="${map.user.username}">nowcoder</i>
点赞了你的
<b th:text="${map.entityType==1?'帖子':'回复'}">帖子</b>,
<a class="text-primary" th:href="@{|/discuss/detail/${map.postId}|}">点击查看</a> !
</span>
<span th:if="${topic.equals('follow')}">
用户
<i th:utext="${map.user.username}">nowcoder</i>
关注了你,
<a class="text-primary" th:href="@{|/user/profile/${map.user.id}|}">点击查看</a> !
</span>
</div>
</div>
</li>
</ul>
修改导航栏的消息数量,使用拦截器#
在每次访问的时候更新未读消息数量。在调 Controller 之后、模板之前
@Component
public class MessageInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Autowired
private MessageService messageService;
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
User user = hostHolder.getUser();
if(user != null && modelAndView != null) {
int letterUnreadCount = messageService.findUnreadLetterCount(user.getId(), null);
int noticeUnreadCount = messageService.findNoticeUnreadCount(user.getId(), null);
modelAndView.addObject("allUnreadCount", letterUnreadCount + noticeUnreadCount);
}
}
}
WebMvcConfig 类中添加拦截器,拦截非静态资源
registry.addInterceptor(messageInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.png", "/**/*.jpeg");
注意点:#
- 注意 content 字段的处理,先要将其中的转义字符转换回原来的双引号,再把内容从 JSON 格式转为 Map 对象,依次存入目标的 map 中
- 私信列表不要忘了添加查询未读通知数量。其它页面各个地方也需要相应地添加
- 头部的总未读消息数量的处理,用拦截器,查询到两个未读数量作相加
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)