在微服务环境下实现站内信

5,601 阅读10分钟

「Offer 驾到,掘友接招!我正在参与2022春招系列活动-经验复盘,点击查看 活动详情

同学们,在平时的系统使用过程当中,你一定注意过站内信这个功能吧。作为掘友的你,一定是更加的熟悉了,每当收获到掘友的点赞、关注、评价三连,相信您一定都会小小的激动下。

那么想不想知道这个功能是如何实现的呢?今天由我来带你探秘下,这个必备的站内信功能是如何实现的。

本文提到的代码是公司内部文件,所以不提供源码,只能提供实现思路,如果遇到问题,欢迎留言讨论!

WebSocket实现参考:juejin.cn/post/699575…

一、项目简介

本文涉及的代码,是一套基于SpringCloudAlibaba的微服务架构,前端使用vue实现,框架使用ant-design-vue实现。整个平台是一套后台服务,此次讲解的站内信服务,作为整个平台的一个微服务存在,可单独部署。

1.1 功能简介

那么此服务做了哪些事呢?

  • 页面消息实时推送(websocket)
  • 实现不同粒度的消息推送:个人、用户组、组织结构、地域等。
  • 系统公共公共消息推送,本业务环境下称之为公告。
  • 开放消息推送接口(rest接口,kafka)
  • 短信、邮件推送扩展
  • 网关请求拦截实现消息推送

通过上面提到的功能,实现针对不同人群、不同业务场景的隔离。保证系统正常消息业务实现,系统异常信息告警的即时推送。

1.2 架构简介

通过下图,给大家展示下整个站内信的组成和功能流转过程:

站内信.png

如上图所示,根据箭头的不同,有几个要点:

  • 站内信服务与普通业务服务一样,都注册于注册中心

  • 消息推送有两种方式:

    • rest接口
    • kafka
  • 网关除通过写拦截器进行请求拦截,针对指定路径的请求拦截后进行消息推送

  • 后台与前台的交互,使用WebSocket长连接的形式

  • 开放预留短信、邮件接口,接入配置即可使用

二、实现方案

2.1 技术选型

  • 前台

    框架采用ant-design-vue

    官方文档:www.antdv.com/docs/vue/in…

    我们的项目是后台项目,此框架提供了开箱即用的高质量 Vue 组件,同时其提炼自企业级中后台产品的交互语言和视觉风格,非常便于系统的快速开发。

  • 后台

    框架:SpingCloudAlibaba

    注册中心:nacos

    网关:SpringCloudGateway

  • 数据存储

    数据库:mysql

    此处使用mysql作为数据存储,包括配置信息、消息历史记录等。我们的业务场景较小,用户量较小,数据量也不多,如果数据量大的话,建议将消息存储放到Elasticsearch当中。

  • 中间件

    消息:kafka

  • 前后台通信组件:

    WebSocket + redis

    此处为何选择redis

    主要是使用其发布、订阅的功能,实现websocket在分布式环境下的session共享问题。

2.2 功能架构

  • 管理端根据业务需求分析,主要分为下面三个部分:

    站内信业务结构 (3).png

  • 消息推送业务分析

    • 常规服务推送消息

    未命名文件 (12).png

    • 网关拦截消息推送

    未命名文件 (13).png

2.3 重难点分析

2.3.1 分布式环境下的websocket

如果说只做站内信的话,那么websocket绝对是本文的重点。websocket是前后端通过长连接通信的常用解决方案,相比于定时轮询的方式,突出的就是一个时效性,对于消息的接收和推送是实时的。

简单实现只需要在前后端引入相应地依赖和方法就可以了,而我此处要提出的问题,是大多数首次接触长连接的同学需要重点考虑的问题:session共享

为什么会有session共享的问题?

如下图所示,当我只有一个服务端的时候,无论有多少客户端都是与这一个服务端建立连接,服务端发送消息只需要根据客户端的id(a,b,c)去发送到指定客户端就可以了

未命名文件 (15).png

当服务端由于访问压力过高,启动两个服务的时候,那么此时客户端连接就会出现如下的情况,此时服务B想要给客户端 a发送消息就会失败,因为在服务端B上,根本就没有持有客户端 a的session信息,那么必然是不能进行发送的:

未命名文件 (16).png

使用redis能够很好地解决这个问题

我们知道redis在处理sesison共享问题上有天然的优势,比如在JWT的场景下。此处我们也使用redis来解决这个问题,关于详细的介绍请看开篇提到的文章地址:WebSocket实现参考

此处需要使用redis的发布和订阅功能,当有新的客户端请求连接,会将它的id作为topic注册到redis当中,同时对该topic进行订阅,当服务端A处理完业务,想要给客户端a发送消息时,直接拿客户端a的topic去调用redis的publish方法,就会将消息推送到相应地客户端:

未命名文件 (17).png

本文所指是服务端对客户端通信,双向通信的话,服务端代码是不同的。

2.3.2 gateway配置

因为前后端所有的交互都是通过gateway来做的,所以websocket的通信一样如此,不同之处在于websocket需要单独的配置。

虽然websocket的服务端也是在一个springboot微服务上,但是并不是通过常规的接口请求进行连接,需要如下所示配置,gateway提供静态网关配置,和动态网关配置,推荐动态网关配置如下,修改无需重启服务:

[{
    "id": "websocket",
    "order": 2,
    "predicates": [{
        "args": {
            "pattern": "/websocket/server/**"
        },
        "name": "Path"
    }],
    "uri": "lb:ws://inbox-model"
}]

如上所示意思是:当网关捕获到路径"/websocket/server/**",就原样跳转到服务inbox-model,且是websocket形式"lb:ws://inbox-model"

2.3.3 扩展性设计

从文章前面的内容看到,本项目并非单纯的站内信功能,还有短信、邮件等等,那么必然说明这以后还有可扩展的可能,如微信、企业微信、钉钉等等。

那么如何设计才能使得后续的扩展修改最小呢?

一开始写代码时,我们能想到最直接的方式,那就是增加判断,if或者switch等等,但是每次的扩展必然要增加条件,可能会导致系统稳定性低,代码耦合强,从而可能引发线上事故。

此处突出了设计模式的重要性,本文使用最简也是最有效的设计模式【策略模式】

何处使用策略模式?

前面的功能架构图当中,我们发现:

  • 消息类型分为:站内信,短信,邮件
  • 推送范围分为:个人,组织机构,地域,用户组

以上两处都是策略模式的用武之地,下面我用示例代码给大家展示下:

定义抽象类:

public abstract class ISendMessageService {

/**
 * description: 发送

 * @return: com.botany.spore.core.result.Result
 * @author: weirx
 * @time: 2021/3/18 13:37
 * @param inboxMessageTemplateDOMap
 * @param personMap
 * @param inboxBusinessConfigDO
 * @param claimUser
 */
public abstract Result send(InboxMessageTemplateDO inboxMessageTemplateDOMap, Map<String, Object> personMap,
                            InboxBusinessConfigDO inboxBusinessConfigDO, String claimUser);

}

相信有同学想问,为什么不是接口卫视抽象类?因为我需要定义通用的公共方法,供每个实现类使用,这样做就能减少重复代码。

分别定义站内信、短信、邮件的实现:

image.png

那么我们在调用发消息时,如何使用?又如何确定我发送的是何种消息,走哪个实现类?看如下调用位置代码:

private void sendMessage(String messageType, Map<String, InboxMessageTemplateDO> templateDOMap,
                         Map<String, Object> personListByPushRange, InboxBusinessConfigDO inboxBusinessConfigDO, String username) {
    for (String msgType : messageType.split(Constants.COMMA)) {
        ISendMessageService sendMessageService =
                SendMessageTypeEnum.newInstance(SendMessageTypeEnum.getEnum(msgType));
        sendMessageService.send(templateDOMap.get(msgType), personListByPushRange, inboxBusinessConfigDO, username);
    }
}

仔细观察上述代码,发现我根据不同的消息类型获取了不同类型的枚举,那么枚举内部是什么?

/**
 * 站内信
 */
MESSAGE("sendMessageService", "站内信") {
    @Override
    public ISendMessageService create(String code) {
        return (ISendMessageService) ApplicationContextProvider.getBean(code);
    }
}

如上所示,根据不同的code会返回不同的bean实例。

到上面为止,一个简单的策略模式就完成了。假如我们后面扩展微信,那么只需要新增实现类,同时在枚举中添加新的枚举就好了,不需要修改整个发送消息业务代码。同理,推送范围也是如此。

2.4 成品展示

下面截取了几个成平图给大家看下:

站内信配置信息:

image.png

公告配置信息:

image.png

接收消息:

image.png

三、总结

3.1 设计优先

整个功能是由我和一个前端人员完成的,大概使用了半个多月的时间,我个人觉得还是比较快的,其中设计占据了一般的时间。

我记得我曾经的第一份工作中,西安交大的副校长和我说过一句话:软件开发,百分之五十对需求,百分之三十用来设计,百分之二十用来开发,虽然时间规划可能根据不同的环境而定,但是大概的理论是没有毛病的。

我要说的其实很简单,要想在开发过程中做到事半功倍,那么必然离不开好的需求分析,产品设计。

同样,好的代码架构设计也是很重要的,大到服务的划分,小到你一个策略模式的使用,都能够让你的代码逻辑变得清晰,可扩展,前期的付出为的是以后修改时的便捷

3.2 留有余地

回头来看,此功能的短信和邮件,并不是原本的需求,原本只是需要站内信的实现。

至于另外的是我在此处预留的扩展点。因为在做设计的时候,就可以预见,后期必然要增加短信、邮件甚至更多的扩展。当此类需求来的时候,我只用了1个小时就完成了原本两人天的功能。其余的时间才好用来提升自身啊。

这里要说的就是,我们在设计代码时,一定要多想一些可扩展的点,对某些设计一定要留有余地,后面即使你忘了,当你看到此处的代码,你会夸赞自己当初有多么的明智。

3.3 不足之处

此功能还有不足之处等待我去解决,我习惯使用// TODO 在代码中做标记,具体如下:

  • 多余的设计,起初我针对消息是要配置一个消息内容模板的,此处代码冗余进去了,但是还没有使用上,后面会去掉或者将其完善。

  • 关于站内信推送、短信推送目前都是循环去发送的,不是很友好,需要配合下游去调整。

  • 循环调用feign接口的位置,同样需要提供批量接口,等待处理。

  • 消息记录目前使用mysql存储,场景数据量不大,而且我做了定时任务去清除久远的历史消息,后面会优化成Elasticsearch存储。


WebSocket实现参考:juejin.cn/post/699575…

关于站内信的功能复盘就到此为止了,功能不复杂,但是过程还是挺有意思的,涉及公司源码,需要保密,所以本文只提供了实现的思路和重点,想要自己实现此功能的朋友们可以评论区留言,大家一起讨论、共同完善!!!