先附上两个项目的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.页面展示
注册页面:
聊天页面:
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这种返回成功,出现异常等描述。
2.在每次调用controller方法后,返回的就是这个实体类。
3.在前端的js中,会有方法接收到这个实体类,然后判断发送成功与否。
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的简单使用
首先在maven中引入jar包。
上图中第n次推送消息的方法解析:
1.图中Objects.requireNonNull()方法可以提前抛出空指针异常(如果value为空指针,会抛出的空指针异常会定位到这个方法中),防止把这个异常带到更深的方法中难debug。
- 拿到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,直接在接口中命名
*的作用是如果要对redis进行模糊查询,需要在后面加上*
(可以用StringRedisTemplate中的keys()方法模糊查询)
每次存储的key都需要带上这些前缀,每次查也可以通过这些前缀去查
这里图中分别表示的是一个公共聊天室记录,一个单对单的聊天记录,两个用户的个人信息。其中第二个单对单的聊天记录由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
具体实现:
这里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查,在聊天记录里打印出来,现在的话就会造成发送成功但是不会显示,需要重新点一下头像才能刷新消息记录。
解决方案:待更新