先附上兩個(gè)項(xiàng)目的github地址,非常簡單的實(shí)現(xiàn)
單機(jī)存儲(chǔ)基于redis和websocket的聊天室: https://github.com/g992987642/redis-chat
線上地址: 單機(jī)存儲(chǔ)基于redis和websocket的聊天室
redis中的發(fā)布/訂閱功能在聊天室中的應(yīng)用: https://github.com/g992987642/redis-chat-pubsub
本文主要介紹了在寫聊天室的時(shí)候,項(xiàng)目的頁面展示屿脐、需求的分析炊汤、遇到的坑或沒接觸過的知識(shí)點(diǎn)攒盈,都附上了從0開始學(xué)習(xí)的鏈接,非常簡單易懂從上到下順序?yàn)椋?strong>跑一遍項(xiàng)目了解大概功能后帶著問題來看效果更佳
1.頁面展示
2.項(xiàng)目中使用的技術(shù)
3.聊天窗口的需求分析
4.項(xiàng)目中的自定義異常類和Springboot全局異臣樱控制
5.項(xiàng)目中的統(tǒng)一請(qǐng)求響應(yīng)格式封裝類
6.項(xiàng)目中的WebSocket的應(yīng)用(包括redis的中pub/sub的應(yīng)用)
7.項(xiàng)目中的Fastjson的使用
8.項(xiàng)目中的Springboot操作redis與redis中key的命名規(guī)范
9.項(xiàng)目中的spring的定時(shí)任務(wù)的應(yīng)用
10.項(xiàng)目中的Slf4j打印日志和lombok插件
11.項(xiàng)目中遇到的問題
1.頁面展示
注冊(cè)頁面:
聊天頁面:
2.用到的技術(shù):
1.Springboot的IOC和SpringMVC的注解
2.spring整合redis的工具類Redistemplate粹污,通過高度封裝的Redistemplate來操作redis
3.redis(其中String數(shù)據(jù)結(jié)構(gòu)的使用,pub/sub的應(yīng)用)
4.websocket代替前端的輪詢來接收新消息
5.Springboot中的全局異呈琢浚控制和自定義異常類
6.Springboot的定時(shí)任務(wù)
7.lombok插件(針對(duì)實(shí)體類的get set toString 等方法的注解壮吩,可以減少冗余)
8.Slf4j日志打印插件(配合lombok效果更佳)
9.Fastjson插件的使用
10.自定義請(qǐng)求響應(yīng)格式封裝
學(xué)習(xí)中主要接觸到的新知識(shí)點(diǎn):
數(shù)據(jù)存儲(chǔ)在redis中,上線通知加缘、聊天用websocket鸭叙。
記得使用統(tǒng)一的請(qǐng)求響應(yīng)格式封裝
既然用到了redis,主要是學(xué)習(xí)redis的應(yīng)用拣宏,那么就寫點(diǎn)關(guān)于redis功能相關(guān)的沈贝。
比如redis的增刪改查,模糊查詢勋乾,
Springboot的定時(shí)任務(wù)宋下,全局異常控制
3.需求分析:
聊天的controller(主要是redis的操作)
1.左上角有自己的User對(duì)象信息 (獲得自己的信息辑莫,從redis中查)
2.左下側(cè)有已經(jīng)上線的人的User對(duì)象(只需要頭像和id学歧,有websocket的連接就是上線的人,獲得id去redis中查人的信息) (獲得上線的人的信息)
3.存在的群組的信息 (獲得群組的信息)
4.聊天框應(yīng)分為與群組的聊天記錄 (獲得群組的聊天記錄)
5.與單人的聊天記錄 (獲得單人的聊天記錄)
6.發(fā)送和接收消息的功能 (發(fā)送對(duì)應(yīng)人(包括群組)的方法)最重要8鞫帧VΡ俊!
發(fā)送和接收消息的功能分析:
發(fā)送消息時(shí)先判斷對(duì)方在不在線,如果在線(全局存儲(chǔ)session的map中是否有這個(gè)id存在)横浑,發(fā)送消息(發(fā)送消息需要調(diào)用websocket中由OnMessage修飾的方法剔桨,才能實(shí)時(shí)通知到收消息的人),把消息存到redis中(發(fā)送和存儲(chǔ)應(yīng)該是個(gè)原子操作徙融?)
補(bǔ)充:發(fā)送的消息應(yīng)該有時(shí)間戳洒缀,利用date()方法把時(shí)間加到每個(gè)消息加進(jìn)消息的實(shí)體類中
如果不在線,可以拋出自定義異常张咳,由@RestControllerAdvice修飾的統(tǒng)一異常處理器捕獲之后return帝洪。
4.自定義異常類
與普通實(shí)體類無區(qū)別,只需要繼承RuntimeException就行脚猾,里面有String對(duì)象用來描述異常葱峡。
關(guān)于@ControllerAdvice:
1.控制器增強(qiáng) spring初始化的時(shí)候可以掃描到該注解,@ControllerAdvice注解內(nèi)部使用@ExceptionHandler龙助、@InitBinder砰奕、@ModelAttribute注解的方法應(yīng)用到所有的 @RequestMapping注解的方法。非常簡單提鸟,不過只有當(dāng)使用@ExceptionHandler最有用军援,另外兩個(gè)用處不大
2.可以利用該注解實(shí)現(xiàn)異常全局統(tǒng)一處理,不必在單個(gè)controller中去(try-catch)處理
3.RestControllerAdvice 與ControllerAdvice類似 參考RestController和Controller的區(qū)別
@ControllerAdvice學(xué)習(xí)的鏈接: https://blog.csdn.net/Colton_Null/article/details/84592748
踩過的坑:
1.try -catch優(yōu)先級(jí)高于@ControllerAdvice称勋,有try -catch不會(huì)被全局異常處理器捕獲胸哥,異常處理器只能捕獲最終在controller層拋出的異常,(dao,service層的異常都會(huì)向上拋到controller層被捕獲赡鲜,但對(duì)例如 Interceptor(攔截器)層的異常空厌、定時(shí)任務(wù)中的異常、異步方法中的異常银酬,不會(huì)進(jìn)行處理)
2.@RestControllerAdvice修飾的類和其他非bean文件放在一起可能不會(huì)生效嘲更,單獨(dú)建一個(gè)包或者放到 已有bean的文件夾。
5.統(tǒng)一請(qǐng)求響應(yīng)格式封裝類
1.首先需要有這樣的實(shí)體類揩瞪,里面的data放了需要返回的具體數(shù)據(jù)赋朦,code是返回的狀態(tài)碼,msg是success/error這種返回成功李破,出現(xiàn)異常等描述宠哄。
2.在每次調(diào)用controller方法后,返回的就是這個(gè)實(shí)體類喷屋。
3.在前端的js中琳拨,會(huì)有方法接收到這個(gè)實(shí)體類,然后判斷發(fā)送成功與否屯曹。
6.WebSocket的使用
如果沒有接觸過WebSocket狱庇,我想下面幾個(gè)鏈接應(yīng)該會(huì)幫助到你惊畏。
websocket的各個(gè)方法詳解:
https://blog.csdn.net/zilaike/article/details/78227810
WebSocket實(shí)現(xiàn)服務(wù)器端消息推送的結(jié)構(gòu):(這兩個(gè)鏈接都是Springboot整合WebSocket的用法,有小的差異密任,可以互相印證)
https://blog.csdn.net/cwr452829537/article/details/91580331
https://www.cnblogs.com/bianzy/p/5822426.html
首先要注入ServerEndpointExporter(也就是再兩篇文章一開始都創(chuàng)建的一個(gè)WebSocketConfig颜启,然后在里面創(chuàng)建一個(gè)ServerEndpointExporter交給Spring管理),這個(gè)bean會(huì)自動(dòng)注冊(cè)使用了@ServerEndpoint注解聲明的Websocket endpoint浪讳。
要注意缰盏,如果使用獨(dú)立的servlet容器,而不是直接使用springboot的內(nèi)置容器淹遵,就不要注入ServerEndpointExporter因?yàn)樗鼘⒂扇萜髯约禾峁┖凸芾恚?否則就會(huì)報(bào)重復(fù)的endpoint錯(cuò)誤口猜。
1.為什么需要 WebSocket?
初次接觸 WebSocket 的人透揣,都會(huì)問同樣的問題:我們已經(jīng)有了 HTTP 協(xié)議济炎,為什么還需要另一個(gè)協(xié)議?它能帶來什么好處辐真?
答案很簡單须尚,因?yàn)?HTTP 協(xié)議有一個(gè)缺陷:通信只能由客戶端發(fā)起,HTTP 協(xié)議做不到服務(wù)器主動(dòng)向客戶端推送信息侍咱。
websocket連接通知的實(shí)現(xiàn) (websocket類中配置的url映射應(yīng)該在類上面而不是方法上耐床,用@ServerEndpoint(value = "/chat/{id}")類似的來映射)
2.WebSocket中的Session
介紹: Websocket中有一個(gè)session(不同于httpsession,這個(gè)是屬于websocket的)
httpsession是用來保存用戶的信息的楔脯,而這些信息也需要在用戶登錄的時(shí)候通過代碼邏輯保存在session里面 撩轰。
session可以理解成服務(wù)端點(diǎn)與遠(yuǎn)程客戶端點(diǎn)的一次會(huì)話,他是你使用了WebSocket后,WebSocket自帶的一個(gè)容器昧廷,里面有g(shù)etAsyncRemote()和getBasicRemote()兩個(gè)方法(前者異步钧敞,后者同步),需要?jiǎng)?chuàng)建session對(duì)象之后調(diào)用這兩個(gè)方法才能實(shí)現(xiàn)對(duì)這個(gè)session對(duì)象的對(duì)應(yīng)用戶的推送麸粮。(比如服務(wù)器包擁有用戶A的session,需要拿這個(gè)session去給A發(fā)消息)private void sendMessage(String message) throws IOException { this.session.getBasicRemote().sendText(message); }
3.如何在服務(wù)器端保存這些Session镜廉?
問題描述: Session對(duì)象建立在由@ServerEndpoint注解的類中弄诲。那么每個(gè)用戶連接,都會(huì)建立起一個(gè)Session娇唯,怎么保存這些session跟用戶一一對(duì)應(yīng)齐遵?
解決方法:可以建立一個(gè)全局的map(必須要是static),每次創(chuàng)建一個(gè)websocket的時(shí)候塔插,同時(shí)創(chuàng)建session梗摇,并且把這個(gè)session放到map中,key為session的userId想许。這樣map中就包含著所有的session連接伶授,也不會(huì)像set一樣每次取session都要遍歷(map可以通過key來快速找到對(duì)應(yīng)的value断序,在session特別多的時(shí)候可以提高效率)。
4.Session是無法序列化的糜烹,考慮一下分布式的情況违诗?
問題描述:我們了解到,session是無法序列化疮蹦,也就是沒有實(shí)現(xiàn)Seriazable接口诸迟,現(xiàn)在我們的session都是存儲(chǔ)在單機(jī)上的,沒法保存在數(shù)據(jù)庫里愕乎,如果有多個(gè)服務(wù)器呢阵苇?
session的映射關(guān)系是一對(duì)一的,就是一臺(tái)服務(wù)器對(duì)應(yīng)一臺(tái)客戶機(jī)感论,比如A號(hào)機(jī)連著用戶1绅项,B號(hào)機(jī)連著用戶2,用戶1怎么給用戶2發(fā)消息笛粘?在A號(hào)機(jī)里沒有用戶2的session對(duì)象趁怔,沒法序列化也存不到數(shù)據(jù)庫里去查這個(gè),那咋辦嘛薪前。
解決方法:
用redis的發(fā)布/訂閱功能润努,既然我們封裝不了session,我們把UserId和要發(fā)的消息存到redis中示括,每臺(tái)機(jī)子訂閱這個(gè)頻道铺浇,每次有新消息過來就存到這,(還記得我們的map嗎垛膝,對(duì)應(yīng)的key-value是UserId-Session)每個(gè)服務(wù)器都去查自己有沒有這個(gè)ID鳍侣,有的話就發(fā)給這個(gè)用戶。(優(yōu)化方案:可以每次消息過來先查詢自己有沒有這個(gè)ID吼拥,有的話就直接發(fā)送倚聚,就不用發(fā)布到頻道了,節(jié)省時(shí)間)
參考鏈接:
訂閱/發(fā)布的思路: https://blog.csdn.net/u011692924/article/details/81076263
訂閱/發(fā)布已有的實(shí)現(xiàn): https://gitee.com/xxssyyyyssxx/jfinal-websocket
如何在springboot中使用redis的訂閱/發(fā)布功能: https://www.cnblogs.com/sxdcgaq8080/p/10953693.html
5.WebSocket與Servlet無關(guān)凿可,怎么拿到Httpsession惑折?
問題描述:寫代碼的過程中會(huì)遇到websocket獲取不到httpsession的情況,因?yàn)閣ebsocket與servlet無關(guān)枯跑,所以取不到惨驶,怎么辦?
解決方法:修改握手方法敛助,一開始握手的時(shí)候就把HttpSession放到WebSocket對(duì)象的ServerEndpointConfig的map中粗卜。
下面鏈接是具體解決方案
https://www.cnblogs.com/hellxz/p/8063867.html
6.怎么使用WebSocket實(shí)現(xiàn)聊天功能?
websocket的方法的簡單聊天室實(shí)現(xiàn),只提供了思路纳击,如果想看具體實(shí)現(xiàn)請(qǐng)轉(zhuǎn)步文章頂部的Github续扔。
@onopen(這個(gè)用戶連接登錄時(shí))
需要在map中把Session對(duì)象加入進(jìn)來
@onclose(用戶關(guān)閉網(wǎng)頁或者注銷時(shí))
需要移除map中對(duì)應(yīng)的Session
@OnMessage(用戶發(fā)送消息時(shí))
需要把消息加到redis中
需要通過session.getBasicRemote().sendText()方法去把消息推送到對(duì)應(yīng)的用戶
@onerror
調(diào)用e.printStackTrace()
7.Fastjson的簡單使用
首先在maven中引入jar包攻臀。
上圖中第n次推送消息的方法解析:
1.圖中Objects.requireNonNull()方法可以提前拋出空指針異常(如果value為空指針,會(huì)拋出的空指針異常會(huì)定位到這個(gè)方法中)测砂,防止把這個(gè)異常帶到更深的方法中難debug茵烈。
- 拿到message對(duì)象和redis中的value后,想把message加到value中砌些,需要先把value(狀態(tài)是String)轉(zhuǎn)成JSONArray呜投,再調(diào)用JSONArray里的toJavaList方法轉(zhuǎn)化java中的list集合,最后把message對(duì)象加到list中存璃,最后把list用toJSONString()方法轉(zhuǎn)換成字符串的形式重新放在value中仑荐。
舉例:有多個(gè)聊天記錄,比如發(fā)了一句“在干嘛”發(fā)送成功后纵东,又發(fā)了一句“吃了嗎”粘招,此時(shí)redis里存在的是兩個(gè)message的字符串對(duì)象,在后面繼續(xù)append字符串是不現(xiàn)實(shí)的偎球,只能先把這兩個(gè)message的字符串轉(zhuǎn)化成JAVA的message對(duì)象保存在list中洒扎,然后新來了個(gè)message,再把這個(gè)message保存在list中衰絮,最后再把這個(gè)list轉(zhuǎn)化成JSON字符串后重新賦值給value袍冷,才算是保存成功了。
8.使用StringRedisTemplate對(duì)象操作redis
首先附上針對(duì)RedisTemplate方法操作解釋猫牡,非常簡單易懂:
RedisTemplate方法操作解釋 : https://blog.csdn.net/qieyi28/article/details/84902209
針對(duì)redis中key的命名規(guī)范:因?yàn)閞edis中不像mysql中有字段可以知道這個(gè)數(shù)據(jù)列是干嘛的胡诗,這邊用的又是key-value值的存儲(chǔ),需要對(duì)key命名的時(shí)候有一定的格式淌友。
這里我用的是interface接口存儲(chǔ)String字符串 煌恢,因?yàn)樽址淖兞慷际莝tatic和final,直接在接口中命名
*的作用是如果要對(duì)redis進(jìn)行模糊查詢震庭,需要在后面加上*
(可以用StringRedisTemplate中的keys()方法模糊查詢)
每次存儲(chǔ)的key都需要帶上這些前綴瑰抵,每次查也可以通過這些前綴去查
這里圖中分別表示的是一個(gè)公共聊天室記錄,一個(gè)單對(duì)單的聊天記錄器联,兩個(gè)用戶的個(gè)人信息谍憔。其中第二個(gè)單對(duì)單的聊天記錄由CHAT_FROM_+id+TO+id2組成
9.通過設(shè)置key的過期時(shí)間和Springboot的定時(shí)任務(wù)來刪除key
這兩個(gè)方法都可以來控制key的有效時(shí)間,有不同的應(yīng)用場景主籍。
1.設(shè)置key的過期時(shí)間
可以用redis的key過期時(shí)間來設(shè)置每個(gè)用戶和會(huì)話的存在時(shí)間,這個(gè)比較簡單逛球,下面?zhèn)鲄⒎謩e對(duì)應(yīng)的是key千元,value,時(shí)間颤绕,單位幸海。這個(gè)意思就是baike-100 的這個(gè)鍵值對(duì)存在600秒
stringRedisTemplate.opsForValue().set("baike", "100", 60 * 10, TimeUnit.SECONDS);
2.用Spring的定時(shí)任務(wù)刪除key
@EnableScheduling 在配置類上使用祟身,開啟計(jì)劃任務(wù)的支持(類上)
@Scheduled 來申明這是一個(gè)任務(wù),包括cron,fixDelay,fixRate等類型(方法上物独,需先開啟計(jì)劃任務(wù)的支持)
@Scheduled中有個(gè)cron表達(dá)式 下面是學(xué)習(xí)鏈接
https://blog.csdn.net/Linweiqiang5/article/details/86741258
具體實(shí)現(xiàn):
666.png
這里cron表達(dá)的就是每30分鐘做個(gè)定時(shí)任務(wù)袜硫,刪除注冊(cè)時(shí)間超過20分鐘的用戶,以及會(huì)話信息挡篓,我們的公共聊天室只有一個(gè)婉陷,誰先說話,后面的id跟著就是誰的官研。
在項(xiàng)目中UserId用getTime()這個(gè)方法得來的秽澳,其中g(shù)etTime()這個(gè)方法獲得的是1970年01月1日0點(diǎn)零分以來的毫秒數(shù),圖中MINUTE_30這個(gè)值代表的就是30分鐘的毫秒數(shù)戏羽。
最長用戶可能有59分59秒的壽命担神,剛刪過一次后注冊(cè),然后第二次刪除他的注冊(cè)時(shí)間只有29分29秒始花,不符合妄讯,等再下一次定時(shí)任務(wù)時(shí)回收。
10.Slf4j打印日志和lombok插件
這兩個(gè)插件的使用和學(xué)習(xí)都非常簡單酷宵,簡單過一遍就能上手了亥贸。
slf4j日志打印的學(xué)習(xí): https://blog.csdn.net/MengDiL_yl/article/details/86648197
注意在上面那張用Spring的定時(shí)任務(wù)刪除key的圖中,沒有像log4j一樣需要
private final Logger logger = LoggerFactory.getLogger(LoggerTest.class);
之后調(diào)用logger.info()
是因?yàn)橐昧薼ombok包忧吟,他可以讓你在實(shí)體類的時(shí)候少寫get set方法砌函,也可以與slf4j配合不用創(chuàng)建這個(gè)logger對(duì)象,直接log.info()就可以用了
lombok的學(xué)習(xí): https://www.cnblogs.com/heyonggang/p/8638374.html
11.項(xiàng)目中遇到的問題:
注冊(cè)時(shí)溜族,如果瀏覽器卡一下讹俊,多點(diǎn)幾次注冊(cè),會(huì)出現(xiàn)同用戶名不同id的用戶煌抒,(第二個(gè)注冊(cè)的時(shí)候去查redis中的的keys沒發(fā)現(xiàn)有這個(gè)id)
解決方案:需要在前端在點(diǎn)擊按鈕后仍劈,把按鈕置灰?guī)酌耄乐惯B點(diǎn)寡壮。
訂閱/通知版本中的問題
在controller調(diào)用redis的通知方法后贩疙,在Controller執(zhí)行完畢return后才會(huì)執(zhí)行監(jiān)聽器方法,前端是拿到controller的return的R之后去從redis查况既,在聊天記錄里打印出來这溅,現(xiàn)在的話就會(huì)造成發(fā)送成功但是不會(huì)顯示,需要重新點(diǎn)一下頭像才能刷新消息記錄棒仍。
解決方案:待更新