(eblog)7、消息异步通知、细节调整

2,366 阅读7分钟

公众号:MarkerHub(关注获取更多项目资源)

eblog 代码仓库:github.com/markerhub/e…

eblog 项目视频: www.bilibili.com/video/BV1ri…


开发文档目录:

(eblog)1、项目架构搭建、首页页面初始化

(eblog)2、整合Redis,以及项目优雅的异常处理与返回结果封装

(eblog)3、用Redis的zset有序集合实现一个本周热议功能

(eblog)4、自定义 Freemaker 标签实现博客首页数据填充

(eblog)5、博客分类填充、登录注册逻辑

(eblog)6、博客发布收藏、用户中心的设置

(eblog)7、消息异步通知、细节调整

(eblog)8、博客搜索引擎开发、后台精选

(eblog)9、即时群聊开发,聊天记录等


前后端分离项目vueblog请点击这里:超详细!4小时开发一个SpringBoot+vue前后端分离博客项目!!


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 的知识:

上面是一个 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 命令,后期大家可以自行优化。 然后获取到列表后,然后就是获取所有的实体,然后批量更新阅读量。