「Offer 驾到,掘友接招!我正在参与2022春招系列活动-经验复盘,点击查看 活动详情。
同学们,在平时的系统使用过程当中,你一定注意过站内信这个功能吧。作为掘友的你,一定是更加的熟悉了,每当收获到掘友的点赞、关注、评价三连,相信您一定都会小小的激动下。
那么想不想知道这个功能是如何实现的呢?今天由我来带你探秘下,这个必备的站内信功能是如何实现的。
本文提到的代码是公司内部文件,所以不提供源码,只能提供实现思路,如果遇到问题,欢迎留言讨论!
WebSocket实现参考:juejin.cn/post/699575…
一、项目简介
本文涉及的代码,是一套基于SpringCloudAlibaba的微服务架构,前端使用vue实现,框架使用ant-design-vue实现。整个平台是一套后台服务,此次讲解的站内信
服务,作为整个平台的一个微服务存在,可单独部署。
1.1 功能简介
那么此服务做了哪些事呢?
- 页面消息实时推送(websocket)
- 实现不同粒度的消息推送:个人、用户组、组织结构、地域等。
- 系统公共公共消息推送,本业务环境下称之为公告。
- 开放消息推送接口(rest接口,kafka)
- 短信、邮件推送扩展
- 网关请求拦截实现消息推送
通过上面提到的功能,实现针对不同人群、不同业务场景的隔离。保证系统正常消息业务实现,系统异常信息告警的即时推送。
1.2 架构简介
通过下图,给大家展示下整个站内信的组成和功能流转过程:
如上图所示,根据箭头的不同,有几个要点:
-
站内信服务与普通业务服务一样,都注册于注册中心
-
消息推送有两种方式:
- 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 功能架构
-
管理端根据业务需求分析,主要分为下面三个部分:
-
消息推送业务分析
-
常规服务推送消息
-
网关拦截消息推送
-
2.3 重难点分析
2.3.1 分布式环境下的websocket
如果说只做站内信的话,那么websocket绝对是本文的重点。websocket是前后端通过长连接通信的常用解决方案,相比于定时轮询的方式,突出的就是一个时效性,对于消息的接收和推送是实时的。
简单实现只需要在前后端引入相应地依赖和方法就可以了,而我此处要提出的问题,是大多数首次接触长连接的同学需要重点考虑的问题:session共享
。
为什么会有session共享的问题?
如下图所示,当我只有一个服务端的时候,无论有多少客户端都是与这一个服务端建立连接,服务端发送消息只需要根据客户端的id(a,b,c)去发送到指定客户端就可以了
当服务端由于访问压力过高,启动两个服务的时候,那么此时客户端连接就会出现如下的情况,此时服务B
想要给客户端 a
发送消息就会失败,因为在服务端B
上,根本就没有持有客户端 a
的session信息,那么必然是不能进行发送的:
使用redis能够很好地解决这个问题
我们知道redis在处理sesison共享问题上有天然的优势,比如在JWT的场景下。此处我们也使用redis来解决这个问题,关于详细的介绍请看开篇提到的文章地址:WebSocket实现参考
此处需要使用redis的发布和订阅功能,当有新的客户端请求连接,会将它的id作为topic注册到redis当中,同时对该topic进行订阅,当服务端A
处理完业务,想要给客户端a
发送消息时,直接拿客户端a
的topic去调用redis的publish方法,就会将消息推送到相应地客户端:
本文所指是服务端对客户端通信,双向通信的话,服务端代码是不同的。
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);
}
相信有同学想问,为什么不是接口卫视抽象类?因为我需要定义通用的公共方法,供每个实现类使用,这样做就能减少重复代码。
分别定义站内信、短信、邮件的实现:
那么我们在调用发消息时,如何使用?又如何确定我发送的是何种消息,走哪个实现类?看如下调用位置代码:
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 成品展示
下面截取了几个成平图给大家看下:
站内信配置信息:
公告配置信息:
接收消息:
三、总结
3.1 设计优先
整个功能是由我和一个前端人员完成的,大概使用了半个多月的时间,我个人觉得还是比较快的,其中设计占据了一般的时间。
我记得我曾经的第一份工作中,西安交大的副校长
和我说过一句话:软件开发,百分之五十对需求,百分之三十用来设计,百分之二十用来开发
,虽然时间规划可能根据不同的环境而定,但是大概的理论是没有毛病的。
我要说的其实很简单,要想在开发过程中做到事半功倍,那么必然离不开好的需求分析,产品设计。
同样,好的代码架构设计也是很重要的,大到服务的划分,小到你一个策略模式的使用,都能够让你的代码逻辑变得清晰,可扩展,前期的付出为的是以后修改时的便捷
。
3.2 留有余地
回头来看,此功能的短信和邮件,并不是原本的需求,原本只是需要站内信的实现。
至于另外的是我在此处预留的扩展点。因为在做设计的时候,就可以预见,后期必然要增加短信、邮件甚至更多的扩展。当此类需求来的时候,我只用了1个小时就完成了原本两人天的功能。其余的时间才好用来提升自身啊。
这里要说的就是,我们在设计代码时,一定要多想一些可扩展的点,对某些设计一定要留有余地,后面即使你忘了,当你看到此处的代码,你会夸赞自己当初有多么的明智。
3.3 不足之处
此功能还有不足之处等待我去解决,我习惯使用// TODO
在代码中做标记,具体如下:
-
多余的设计,起初我针对消息是要配置一个消息内容模板的,此处代码冗余进去了,但是还没有使用上,后面会去掉或者将其完善。
-
关于站内信推送、短信推送目前都是循环去发送的,不是很友好,需要配合下游去调整。
-
循环调用feign接口的位置,同样需要提供批量接口,等待处理。
-
消息记录目前使用mysql存储,场景数据量不大,而且我做了定时任务去清除久远的历史消息,后面会优化成Elasticsearch存储。
WebSocket实现参考:juejin.cn/post/699575…
关于站内信的功能复盘就到此为止了,功能不复杂,但是过程还是挺有意思的,涉及公司源码,需要保密,所以本文只提供了实现的思路和重点,想要自己实现此功能的朋友们可以评论区留言,大家一起讨论、共同完善!!!