基于redis和websocket的聊天室

先附上两个项目的github地址,非常简单的实现
单机存储基于redis和websocket的聊天室: https://github.com/g992987642/redis-chat
线上地址: 单机存储基于redis和websocket的聊天室
redis中的发布/订阅功能在聊天室中的应用: https://github.com/g992987642/redis-chat-pubsub

本文主要介绍了在写聊天室的时候,项目的页面展示、需求的分析、遇到的坑或没接触过的知识点,都附上了从0开始学习的链接,非常简单易懂从上到下顺序为,跑一遍项目了解大概功能后带着问题来看效果更佳
1.页面展示
2.项目中使用的技术
3.聊天窗口的需求分析
4.项目中的自定义异常类和Springboot全局异常控制
5.项目中的统一请求响应格式封装类
6.项目中的WebSocket的应用(包括redis的中pub/sub的应用)
7.项目中的Fastjson的使用
8.项目中的Springboot操作redis与redis中key的命名规范
9.项目中的spring的定时任务的应用
10.项目中的Slf4j打印日志和lombok插件
11.项目中遇到的问题

1.页面展示

注册页面:

(O[P0X667NO`][EA]GBHLND.png

聊天页面:
HM8CY_I1GM41ET0QRF5_(Q3.png

2.用到的技术:

1.Springboot的IOC和SpringMVC的注解
2.spring整合redis的工具类Redistemplate,通过高度封装的Redistemplate来操作redis
3.redis(其中String数据结构的使用,pub/sub的应用)
4.websocket代替前端的轮询来接收新消息
5.Springboot中的全局异常控制和自定义异常类
6.Springboot的定时任务
7.lombok插件(针对实体类的get set toString 等方法的注解,可以减少冗余)
8.Slf4j日志打印插件(配合lombok效果更佳)
9.Fastjson插件的使用
10.自定义请求响应格式封装

学习中主要接触到的新知识点:

数据存储在redis中,上线通知、聊天用websocket。
记得使用统一的请求响应格式封装
既然用到了redis,主要是学习redis的应用,那么就写点关于redis功能相关的。
比如redis的增删改查,模糊查询,
Springboot的定时任务,全局异常控制

3.需求分析:

聊天的controller(主要是redis的操作)
1.左上角有自己的User对象信息 (获得自己的信息,从redis中查)
2.左下侧有已经上线的人的User对象(只需要头像和id,有websocket的连接就是上线的人,获得id去redis中查人的信息) (获得上线的人的信息)
3.存在的群组的信息 (获得群组的信息)
4.聊天框应分为与群组的聊天记录 (获得群组的聊天记录)
5.与单人的聊天记录 (获得单人的聊天记录)
6.发送和接收消息的功能 (发送对应人(包括群组)的方法)最重要!!!

发送和接收消息的功能分析:

发送消息时先判断对方在不在线,如果在线(全局存储session的map中是否有这个id存在),发送消息(发送消息需要调用websocket中由OnMessage修饰的方法,才能实时通知到收消息的人),把消息存到redis中(发送和存储应该是个原子操作?)
补充:发送的消息应该有时间戳,利用date()方法把时间加到每个消息加进消息的实体类中
如果不在线,可以抛出自定义异常,由@RestControllerAdvice修饰的统一异常处理器捕获之后return。

4.自定义异常类

与普通实体类无区别,只需要继承RuntimeException就行,里面有String对象用来描述异常。
关于@ControllerAdvice:

1.控制器增强 spring初始化的时候可以扫描到该注解,@ControllerAdvice注解内部使用@ExceptionHandler、@InitBinder、@ModelAttribute注解的方法应用到所有的 @RequestMapping注解的方法。非常简单,不过只有当使用@ExceptionHandler最有用,另外两个用处不大
2.可以利用该注解实现异常全局统一处理,不必在单个controller中去(try-catch)处理
3.RestControllerAdvice 与ControllerAdvice类似 参考RestController和Controller的区别

@ControllerAdvice学习的链接: https://blog.csdn.net/Colton_Null/article/details/84592748

踩过的坑

1.try -catch优先级高于@ControllerAdvice,有try -catch不会被全局异常处理器捕获,异常处理器只能捕获最终在controller层抛出的异常,(dao,service层的异常都会向上抛到controller层被捕获,但对例如 Interceptor(拦截器)层的异常、定时任务中的异常、异步方法中的异常,不会进行处理)
2.@RestControllerAdvice修饰的类和其他非bean文件放在一起可能不会生效,单独建一个包或者放到 已有bean的文件夹。

5.统一请求响应格式封装类

1.首先需要有这样的实体类,里面的data放了需要返回的具体数据,code是返回的状态码,msg是success/error这种返回成功,出现异常等描述。


S0RAYXDQ_9}RDU1IK`G37PY.png

2.在每次调用controller方法后,返回的就是这个实体类。

$6WPOGHIG@[A]45GH~Y1`35.png

3.在前端的js中,会有方法接收到这个实体类,然后判断发送成功与否。

)TG~0@{462EYCSBT@7`LRJG.png

6.WebSocket的使用

如果没有接触过WebSocket,我想下面几个链接应该会帮助到你。

websocket的各个方法详解:
https://blog.csdn.net/zilaike/article/details/78227810
WebSocket实现服务器端消息推送的结构:(这两个链接都是Springboot整合WebSocket的用法,有小的差异,可以互相印证)
https://blog.csdn.net/cwr452829537/article/details/91580331
https://www.cnblogs.com/bianzy/p/5822426.html
首先要注入ServerEndpointExporter(也就是再两篇文章一开始都创建的一个WebSocketConfig,然后在里面创建一个ServerEndpointExporter交给Spring管理),这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint。
要注意,如果使用独立的servlet容器,而不是直接使用springboot的内置容器,就不要注入ServerEndpointExporter因为它将由容器自己提供和管理, 否则就会报重复的endpoint错误。

1.为什么需要 WebSocket?

初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?
答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起,HTTP 协议做不到服务器主动向客户端推送信息。
websocket连接通知的实现 (websocket类中配置的url映射应该在类上面而不是方法上,用@ServerEndpoint(value = "/chat/{id}")类似的来映射)

2.WebSocket中的Session

介绍: Websocket中有一个session(不同于httpsession,这个是属于websocket的)
httpsession是用来保存用户的信息的,而这些信息也需要在用户登录的时候通过代码逻辑保存在session里面 。
session可以理解成服务端点与远程客户端点的一次会话,他是你使用了WebSocket后,WebSocket自带的一个容器,里面有getAsyncRemote()和getBasicRemote()两个方法(前者异步,后者同步),需要创建session对象之后调用这两个方法才能实现对这个session对象的对应用户的推送。(比如服务器包拥有用户A的session,需要拿这个session去给A发消息)

private void sendMessage(String message) throws IOException {
      this.session.getBasicRemote().sendText(message);
   }

3.如何在服务器端保存这些Session?

问题描述: Session对象建立在由@ServerEndpoint注解的类中。那么每个用户连接,都会建立起一个Session,怎么保存这些session跟用户一一对应?
解决方法:可以建立一个全局的map(必须要是static),每次创建一个websocket的时候,同时创建session,并且把这个session放到map中,key为session的userId。这样map中就包含着所有的session连接,也不会像set一样每次取session都要遍历(map可以通过key来快速找到对应的value,在session特别多的时候可以提高效率)。

4.Session是无法序列化的,考虑一下分布式的情况?

问题描述:我们了解到,session是无法序列化,也就是没有实现Seriazable接口,现在我们的session都是存储在单机上的,没法保存在数据库里,如果有多个服务器呢?
session的映射关系是一对一的,就是一台服务器对应一台客户机,比如A号机连着用户1,B号机连着用户2,用户1怎么给用户2发消息?在A号机里没有用户2的session对象,没法序列化也存不到数据库里去查这个,那咋办嘛。
解决方法:
用redis的发布/订阅功能,既然我们封装不了session,我们把UserId和要发的消息存到redis中,每台机子订阅这个频道,每次有新消息过来就存到这,(还记得我们的map吗,对应的key-value是UserId-Session)每个服务器都去查自己有没有这个ID,有的话就发给这个用户。(优化方案:可以每次消息过来先查询自己有没有这个ID,有的话就直接发送,就不用发布到频道了,节省时间)
参考链接:
订阅/发布的思路: https://blog.csdn.net/u011692924/article/details/81076263
订阅/发布已有的实现: https://gitee.com/xxssyyyyssxx/jfinal-websocket
如何在springboot中使用redis的订阅/发布功能: https://www.cnblogs.com/sxdcgaq8080/p/10953693.html

5.WebSocket与Servlet无关,怎么拿到Httpsession?

问题描述:写代码的过程中会遇到websocket获取不到httpsession的情况,因为websocket与servlet无关,所以取不到,怎么办?
解决方法:修改握手方法,一开始握手的时候就把HttpSession放到WebSocket对象的ServerEndpointConfig的map中。
下面链接是具体解决方案
https://www.cnblogs.com/hellxz/p/8063867.html

6.怎么使用WebSocket实现聊天功能?

websocket的方法的简单聊天室实现,只提供了思路,如果想看具体实现请转步文章顶部的Github。
@onopen(这个用户连接登录时)
需要在map中把Session对象加入进来
@onclose(用户关闭网页或者注销时)
需要移除map中对应的Session
@OnMessage(用户发送消息时)
需要把消息加到redis中
需要通过session.getBasicRemote().sendText()方法去把消息推送到对应的用户
@onerror
调用e.printStackTrace()

7.Fastjson的简单使用

222.png

首先在maven中引入jar包。

333.png

上图中第n次推送消息的方法解析:

1.图中Objects.requireNonNull()方法可以提前抛出空指针异常(如果value为空指针,会抛出的空指针异常会定位到这个方法中),防止把这个异常带到更深的方法中难debug。

  1. 拿到message对象和redis中的value后,想把message加到value中,需要先把value(状态是String)转成JSONArray,再调用JSONArray里的toJavaList方法转化java中的list集合,最后把message对象加到list中,最后把list用toJSONString()方法转换成字符串的形式重新放在value中。

举例:有多个聊天记录,比如发了一句“在干嘛”发送成功后,又发了一句“吃了吗”,此时redis里存在的是两个message的字符串对象,在后面继续append字符串是不现实的,只能先把这两个message的字符串转化成JAVA的message对象保存在list中,然后新来了个message,再把这个message保存在list中,最后再把这个list转化成JSON字符串后重新赋值给value,才算是保存成功了。

8.使用StringRedisTemplate对象操作redis

首先附上针对RedisTemplate方法操作解释,非常简单易懂:
RedisTemplate方法操作解释 : https://blog.csdn.net/qieyi28/article/details/84902209

针对redis中key的命名规范:因为redis中不像mysql中有字段可以知道这个数据列是干嘛的,这边用的又是key-value值的存储,需要对key命名的时候有一定的格式。
这里我用的是interface接口存储String字符串 ,因为字符串的变量都是static和final,直接在接口中命名

444.png

*的作用是如果要对redis进行模糊查询,需要在后面加上*

(可以用StringRedisTemplate中的keys()方法模糊查询)
每次存储的key都需要带上这些前缀,每次查也可以通过这些前缀去查

555.png

这里图中分别表示的是一个公共聊天室记录,一个单对单的聊天记录,两个用户的个人信息。其中第二个单对单的聊天记录由CHAT_FROM_+id+TO+id2组成

9.通过设置key的过期时间和Springboot的定时任务来删除key

这两个方法都可以来控制key的有效时间,有不同的应用场景。

1.设置key的过期时间

可以用redis的key过期时间来设置每个用户和会话的存在时间,这个比较简单,下面传参分别对应的是key,value,时间,单位。这个意思就是baike-100 的这个键值对存在600秒

stringRedisTemplate.opsForValue().set("baike", "100", 60 * 10, TimeUnit.SECONDS);

2.用Spring的定时任务删除key

@EnableScheduling 在配置类上使用,开启计划任务的支持(类上)
@Scheduled 来申明这是一个任务,包括cron,fixDelay,fixRate等类型(方法上,需先开启计划任务的支持)
@Scheduled中有个cron表达式 下面是学习链接
https://blog.csdn.net/Linweiqiang5/article/details/86741258
具体实现:

666.png

这里cron表达的就是每30分钟做个定时任务,删除注册时间超过20分钟的用户,以及会话信息,我们的公共聊天室只有一个,谁先说话,后面的id跟着就是谁的。
在项目中UserId用getTime()这个方法得来的,其中getTime()这个方法获得的是1970年01月1日0点零分以来的毫秒数,图中MINUTE_30这个值代表的就是30分钟的毫秒数。
最长用户可能有59分59秒的寿命,刚删过一次后注册,然后第二次删除他的注册时间只有29分29秒,不符合,等再下一次定时任务时回收。

10.Slf4j打印日志和lombok插件

这两个插件的使用和学习都非常简单,简单过一遍就能上手了。

slf4j日志打印的学习: https://blog.csdn.net/MengDiL_yl/article/details/86648197
注意在上面那张用Spring的定时任务删除key的图中,没有像log4j一样需要
private final Logger logger = LoggerFactory.getLogger(LoggerTest.class);
之后调用logger.info()
是因为引用了lombok包,他可以让你在实体类的时候少写get set方法,也可以与slf4j配合不用创建这个logger对象,直接log.info()就可以用了
lombok的学习: https://www.cnblogs.com/heyonggang/p/8638374.html

11.项目中遇到的问题:

注册时,如果浏览器卡一下,多点几次注册,会出现同用户名不同id的用户,(第二个注册的时候去查redis中的的keys没发现有这个id)
解决方案:需要在前端在点击按钮后,把按钮置灰几秒,防止连点。

订阅/通知版本中的问题

在controller调用redis的通知方法后,在Controller执行完毕return后才会执行监听器方法,前端是拿到controller的return的R之后去从redis查,在聊天记录里打印出来,现在的话就会造成发送成功但是不会显示,需要重新点一下头像才能刷新消息记录。
解决方案:待更新

感谢TyCoding的指导,他的GitHub地址:https://github.com/TyCoding
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268