(eblog)8、消息异步通知、细节调整
小Hub领读:
继续我们的eblog,今天来完成博客文章收藏,用户中心的设置!
项目名称:eblog
项目 Git 仓库:https://github.com/MarkerHub/eblog(给个 star 支持哈)
项目演示地址:https://markerhub.com:8082
前几篇项目讲解文章:
1、(eblog)Github 上最值得学习的 Springboot 开源博客项目!
2、(eblog)小 Hub 手把手教你如何从 0 搭建一个开源项目架构
3、(eblog)整合Redis,以及项目优雅的异常处理与返回结果封装
4、(eblog)用Redis的zset有序集合实现一个本周热议功能
5、(eblog)自定义Freemaker标签实现博客首页数据填充
1、细节调整
这一次作业,我们来修复一下bug,还有一些细节调整,因为博客的功能其实不多,业务逻辑也不复杂,后面我们还有搜索、群聊等功能,都是大模块。
文章收藏
文章收藏的js其实已经写好的了,只是有些条件没有触发而已,是什么条件呢,我们先来找到收藏的js先:
static/res/mods/jie.js
可以看到什么触发加载收藏的条件有两个:
是否有id为LAY_jieAdmin的元素
layui.cache.user.uid是否为-1
LAY_jieAdmin是为了限定只有文章详情页才加载这个js,而其他页面不需要;那layui.cache.user.uid是哪里设置的呢?大家还记得我们一开始给html分模块的时候吗,我们在layout.ftl宏中有一段js,原本uid的值就是-1,所以我们需要把登录之后的值附上去。
templates/inc/layout.ftl
<script>
layui.cache.page = 'jie';
layui.cache.user = {
username: '${profile.username!"游客"}'
,uid: ${profile.id!'-1'}
,avatar: '${profile.avatar!"/res/images/avatar/00.jpg"}'
,experience: 0
,sex: '${profile.sex!'未知'}'
};
layui.config({
version: "3.0.0"
,base: '/res/mods/'
}).extend({
fly: 'index'
}).use('fly').use('jie').use('user');
</script>
熟系freemarker语法的同学应该都懂${profile.id!'-1'}是啥意思了,!后面表示当值为空的默认值。 好,改好了之后刷新一下,你会发现有个弹窗提示"请求异常,请重试",我们暂时先不管,先把收藏功能搞定先,看看是不是收藏功能controller还没有导致的。
从上图可以看出,我已经把查看是否收藏功能的链接改了一下
/collection/find/
功能代码其实很简单,就从UserCollection表中查询是否有记录就行了,如果有表明已经收藏了,js会渲染出取消收藏的按钮,如果没有记录,就会渲染收藏的按钮。
com.example.controller.PostController
@ResponseBody
@PostMapping("/collection/find/")
public Result collectionFind(Long cid) {
int count = userCollectionService.count(new QueryWrapper<UserCollection>()
.eq("post_id", cid)
.eq("user_id", getProfileId()));
return Result.succ(MapUtil.of("collection", count > 0));
}
根据js,我直接返回的是data中放一个参数collection是否为true就行了。渲染效果如下:
然后点击按钮,发现有两个链接(我改了一下链接前缀):
/collection/add/
/collection/remove/
分别代表这收藏和取消收藏,所以我们分别写这两个controller,注意都是ajax请求来的。收藏的逻辑也比较简单,首先判断一下是否已经收藏过了,已经收藏就返回提示已经收藏,未收藏就添加一天记录即可。
com.example.controller.PostController
@ResponseBody
@PostMapping("/collection/add/")
public Result collectionAdd(Long cid) {
Post post = postService.getById(cid);
Assert.isTrue(post != null, "该帖子已被删除");
int count = userCollectionService.count(new QueryWrapper<UserCollection>()
.eq("post_id", cid)
.eq("user_id", getProfileId()));
if(count > 0) {
return Result.fail("你已经收藏");
}
UserCollection collection = new UserCollection();
collection.setUserId(getProfileId());
collection.setCreated(new Date());
collection.setModified(new Date());
collection.setPostId(post.getId());
collection.setPostUserId(post.getUserId());
userCollectionService.save(collection);
return Result.succ(MapUtil.of("collection", true));
}
com.example.controller.PostController
取消收藏的逻辑:删除一条记录即可
@ResponseBody
@PostMapping("/collection/remove/")
public Result collectionRemove(Long cid) {
Post post = postService.getById(Long.valueOf(cid));
Assert.isTrue(post != null, "该帖子已被删除");
boolean hasRemove = userCollectionService.remove(new QueryWrapper<UserCollection>()
.eq("post_id", cid)
.eq("user_id", getProfileId()));
return Result.succ(hasRemove);
}
ok,收藏设计到的3个方法已经开发完毕,点击文章详情页的收藏和取消收藏,都能正常执行代码!无bug~
消息未读
到了这时候,你发现,刷新页面之后,还是有弹窗提示,这是啥问题?浏览器打开F12,切换到Network标签,因为我们猜想应该是一些异步请求出了异常,所以触发了弹窗提示。接下来我们就要找到这个请求,Network下,我们再点击XHR,因为这表示是发起的异步请求的链接。从这里我们就看到了一个nums/的请求是404,具体的请求其实是:http://localhost:8080/message/nums/,
然后我们再全局搜索/message/nums找到发起这个异步请求的js地方:
所以我们确定了这个弹窗应该就是这引起的了。所以我们去写一下这个方法。这是新消息通知,我们之前在用户中心弄过一个我的消息,但是好像没有状态(已读和未读),所以我需要在UserMessage上添加一个status字段标识已读和未读。记得数据库要添加字段。
com.example.entity.UserMessage
/**
* 状态:0未读,1已读
*/
private Integer status;
然后我们再查下当前用户的状态未0的消息数量出来,就是新消息通知的数量了。
com.example.controller.IndexController
@ResponseBody
@PostMapping("/message/nums/")
public Object messageNums() throws IOException {
int count = userMessageService.count(new QueryWrapper<UserMessage>()
.eq("to_user_id", getProfileId())
.eq("status", 0)
);
return MapUtil.builder().put("status", 0).put("count", count).build();
}
返回值是啥我是根据js推算出来的,js需要啥结果我就返回啥结果。重新运行代码之后,我们发现弹窗没有了,页面展示效果如下:
消息通知
至此,新消息通知已经ok,接下来我们搞一个高大上一点的功能。我们刷微博简书头条等网站的时候,如果收到消息通知,一般来说不用我们刷新页面,而是实时给我们展示有消息来了,会突然有个新消息通知的图标提示我们,这是怎么做到的呢,结合我们之前学过的知识。我们可以找到几种方案来实现这个功能:
ajax定时加载刷新
websocket双工通讯
长链接
我们课程中有节课是专门讲解websocket的,接下来我们就使用这个技术来实现这个功能。
同学们可以去回顾一下websocket的知识:
https://gitee.com/lv-success/git-third/tree/master/course-15-websocket/springboot-websocket
上面是一个springboot集成ws的demo,接下来我们安装这个例子的步骤把ws集成到我们现有的项目里面。
第一步:导入jar包
pom.xml
<!-- ws -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
第二步:编写ws配置
com.example.config.WebSocketConfig
@Configuration
@EnableWebSocketMessageBroker//注解表示开启使用STOMP协议来传输基于代理的消息,Broker就是代理的意思。
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/***
* 注册 Stomp的端点
* addEndpoint:添加STOMP协议的端点。提供WebSocket或SockJS客户端访问的地址
* withSockJS:使用SockJS协议
* @param registry
*/
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket")
.withSockJS();
}
/**
* 配置消息代理
* 启动Broker,消息的发送的地址符合配置的前缀来的消息才发送到这个broker
*/
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/user/", "/topic");//推送消息前缀
registry.setApplicationDestinationPrefixes("/app");
}
}
我们来解析一下这是啥意思,首先@EnableWebSocketMessageBroker,springboot的手动配置大家还记得吧?这个也是开启ws消息代理,然后继承WebSocketMessageBrokerConfigurer重写registerStompEndpoints和configureMessageBroker方法,registerStompEndpoints方法是注册端点,addEndpoint("/websocket")表示注册一个端点叫websocket,那么前端就能通过这个链接连接到服务器实现双工通讯了。.withSockJS()意思是使用SockJs协议,回顾一下:
SockJs是解决浏览器不支持ws的情况
Stompjs是简化文本传输的格式
configureMessageBroker是配置消息代理,上面我们配置了/user,/topic都是需要消息代理的链接。前端/app链接前缀过来的消息都会进入消息代理。
有了这两步骤后,我们就可以使用ws了,我们先来写一下前端:
因为我们的消息通知是在头部的用户名称那里,所有的页面都有,所以我们把js写在layout.ftl上。
$(function () {
var elemUser = $('.fly-nav-user');
if(layui.cache.user.uid !== -1 && elemUser[0]){
var socket = new SockJS("/websocket");
stompClient = Stomp.over(socket);
stompClient.connect({},function (frame) {
//subscribe订阅
stompClient.subscribe('/user/' + ${profile.id} + '/messCount',function (res) {
showTips(res.body);
})
})
}
});
前面的if判断,我是根据收藏那里来写的,var socket = new SockJS("/websocket");表示建立端点链接,这样前端就会和后端建立ws双工通道,stompClient = Stomp.over(socket);表示切换成stomp文本传输协议传输内容。stompClient.connect表示建立连接触发的方法,这个方法里面有个stompClient.subscribe,差不多就表示订阅这个消息队列的意思,当后端往/user/{userId}/messCount里面发送消息时候,当前用户就能接收到消息,res.body就是返回的内容,然后就是showTips方法,这个方法其实就是渲染新消息通知的样式,我们从之前的新消息通知那里吧对应的js复制过来即可:
function showTips(count) {
var msg = $('<a class="fly-nav-msg" href="javascript:;">'+ count +'</a>');
var elemUser = $('.fly-nav-user');
elemUser.append(msg);
msg.on('click', function(){
location.href = '/center/message/';
});
layer.tips('你有 '+ count +' 条未读消息', msg, {
tips: 3
,tipsMore: true
,fixed: true
});
msg.on('mouseenter', function(){
layer.closeAll('tips');
})
}
ok,那前端我们已经可以连上ws实现双工通讯,并且监听了/user/{userId}/messCount这个队列,所以后端往这里面发送消息前端就能收到然后实现showTips方法。 那后端什么时候该发送消息给前端呢?
有人评论了作者文章,或者回复作者的评论
系统消息等
ok,我们先来写一个wsService,写一个发送消息数量给前端的方法。
com.example.service.WsService
void sendMessCountToUser(Long userId, Integer count);
他的实现类复杂嘛?其实不复杂,我们先来看下参数,userId,就是限定要给谁发送消息,count是消息数量,这里我们考虑多种情况,但count不为空时候,我们返回count数量的,当count为空时候,我们搜索userId所有未读的消息数量然后返回。
com.example.service.impl.WsServiceImpl
@Slf4j
@Service
public class WsServiceImpl implements WsService {
@Autowired
private SimpMessagingTemplate messagingTemplate ;
@Autowired
UserMessageService userMessageService;
/**
* 订阅链接为/user/{userId}/messCount的用户能收到消息
* /user为默认前缀
*
* @param userId
* @param count
*/
@Async
public void sendMessCountToUser(Long userId, Integer count) {
if(count == null) {
count = userMessageService.count(new QueryWrapper<UserMessage>()
.eq("status", 0)
.eq("to_user_id", userId));
}
this.messagingTemplate.convertAndSendToUser(userId.toString(),"/messCount", count);
log.info("ws发送消息成功------------> {}, 数量:{}", userId, count);
}
}
发送ws消息,用的是SimpMessagingTemplate,convertAndSendToUser方法会自动在前面添加前缀/user,然后是userId,加上后面的后缀/messCount,所以加起来的链接其实就是/user/{userId}/messCount,那么我们在需要发送消息的地方调用这个方法即可。 然后这里还有个内容要点,就是这里我用了一个@Async表示异步,从线程角度来说就是新起一个线程来执行这个方法,从而保证不影响调用方的事务和执行时间等。
那么我们来说下@Async的用法
异步@Async
其实这里我原本是想用队列来实现的,也能表示异步。本着让同学们接触到更多知识,我们这里就用了@Aysnc注解来实现,后面我们还是会用到MQ的,同学们别急。
使用这个注解我们需要开启异步配置。注解是@EnableAsync
com.example.config.AsyncConfig
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean
AsyncTaskExecutor asyncTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(100);
executor.setQueueCapacity(25);
executor.setMaxPoolSize(500);
return executor;
}
}
所以使用了@EnableAsync注解之后我们就可以使用@Aysnc注解来实现异步了,asyncTaskExecutor()其实就是我用来重写AsyncTaskExecutor用的,定义了最大线程组等信息。另外Async其实还可以配置很多信息,比如异步线程出错时候的处理(重试等),大家课后可以多查询一下资料,这注解我在工作中运用其实还比较多的。 好了,上面我们已经把发送ws消息的方法改成了异步方法,会起一个线程执行发送。我们现在需要调用的地方其实就是在评论那里。
com.example.controller.PostController#reply(Long, Long, String)
这样有人评论文章或者回复评论的时候,都能实时收到消息了,我们来演示一下效果:
至此,我们实现了传说中的实时通知功能!要膨胀了~
文章阅读量
接下来的任务,我们是要完善一下文章阅读量。之前访问文章,阅读量都没增加,现在我们来补上 这个一个bug。怎么做呢?是每访问一次我们就直接修改数据库?这里我们使用缓存在解决这个问题,每次访问,我们就直接缓存的阅读量增一,然后在某一时刻再同步到数据库中即可。访问文章时候,我们把缓存中的阅读量传到vo中,具体咋样的呢,我们找到之前写的com.example.controller.PostController#view方法,然后我加了这一句代码:
技术要把vo的viewCount值修改成缓存的数量。
com.example.service.impl.PostServiceImpl#setViewCount
@Override
public void setViewCount(Post post) {
// 从缓存中获取阅读数量
Integer viewCount = (Integer) redisUtil.hget("rank_post_" + post.getId(), "post:viewCount");
if(viewCount != null) {
post.setViewCount((Integer) viewCount + 1);
} else {
post.setViewCount(post.getViewCount() + 1);
}
// 设置新的阅读
redisUtil.hset("rank_post_" + post.getId(), "post:viewCount", post.getViewCount());
}
从代码中可以看到,我们先从缓存中获取ViewCount,然后设置post.setViewCount,最后再把加一之后的值同步到redis中。 ok,这一步还是比较简单的,接下来我们要起一个定时器,然后定时吧缓存中的阅读量同步到数据库中,实现数据同步。
com.example.schedules.ScheduledTasks
@Slf4j
@Component
public class ScheduledTasks {
@Autowired
RedisUtil redisUtil;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
PostService postService;
/**
* 阅读数量同步任务
* 每天2点同步
*/
// @Scheduled(cron = "0 0 2 * * ?")
@Scheduled(cron = "0 0/1 * * * *")//一分钟(测试用)
public void postViewCountSync() {
Set<String> keys = redisTemplate.keys("rank_post_*");
List<String> ids = new ArrayList<>();
for (String key : keys) {
String postId = key.substring("rank_post_".length());
if(redisUtil.hHasKey("rank_post_" + postId, "post:viewCount")){
ids.add(postId);
}
}
if(ids.isEmpty()) return;
List<Post> posts = postService.list(new QueryWrapper<Post>().in("id", ids));
Iterator<Post> it = posts.iterator();
List<String> syncKeys = new ArrayList<>();
while (it.hasNext()) {
Post post = it.next();
Object count =redisUtil.hget("rank_post_" + post.getId(), "post:viewCount");
if(count != null) {
post.setViewCount(Integer.valueOf(count.toString()));
syncKeys.add("rank_post_" + post.getId());
} else {
//不需要同步的
}
}
if(posts.isEmpty()) return;
boolean isSuccess = postService.updateBatchById(posts);
if(isSuccess) {
for(Post post : posts) {
// 删除缓存中的阅读数量,防止重复同步(根据实际情况来)
redisUtil.hdel("rank_post_" + post.getId(), "post:viewCount");
}
}
log.info("同步文章阅读成功 ------> {}", syncKeys);
}
}
为何获取所有需要同步阅读的列表,我们用了keys命令,实际上当redis的缓存越来越大的时候,我们是不能再使用这keys命令的,因为keys命令会检索所有的key,是个耗时的过程,而redis又是个单线程的中间件,会影响其他命令的执行。所以理论上我们需要用scan命令。考虑到这里博客只是个简单业务,redis不会很大,所以就直接用了keys命令,后期大家可以自行优化。 然后获取到列表后,然后就是获取所有的实体,然后批量更新阅读量。