CTO要我搞個(gè)微信IM系統(tǒng),嚇出冷汗!
圖片來自 包圖網(wǎng)
其實(shí)因?yàn)閷W(xué)習(xí)也分為上策、中策和下策:
- 下策:眼睛看就行,坐著、窩著、躺著,都行,反正也不累,還能一邊回復(fù)下吹水的微信群。
- 中策:看完的資料做筆記整理歸納,長期積累資料。
- 上策:實(shí)踐、上手、應(yīng)用、調(diào)試、歸納、整理資料,總結(jié)經(jīng)驗(yàn)輸出文檔。
綜上,下策學(xué)起來很快感覺自己好像會(huì)了不少,中策有點(diǎn)要?jiǎng)邮至藨胁幌雱?dòng),上策就很耗時(shí)耗力了要自己對每一個(gè)知識點(diǎn)都能事必躬親到親力親為。就這樣你在學(xué)習(xí)的時(shí)候不自覺的就選擇了下策,因此其實(shí)并沒有學(xué)到什么。
學(xué)習(xí)能把知識學(xué)到手,講究的是實(shí)踐,從小我就喜歡動(dòng)手,就以一個(gè)即時(shí)通信的項(xiàng)目為例,已經(jīng)基于不同技術(shù)方案實(shí)現(xiàn)了 5、6 次,僅為了實(shí)踐技術(shù)。
如上圖:
- 有些是剛學(xué)完 Socket 和 Swing 的時(shí)候,想動(dòng)手試試這些技術(shù)能不能寫個(gè) QQ 出來。
- 也有的是因?yàn)閷?shí)習(xí)培訓(xùn)需要完成的項(xiàng)目,不過在有了一些基礎(chǔ)后,一周時(shí)間就能寫完全部功能。
- 雖然這些項(xiàng)目在現(xiàn)在看上去還是丑丑的界面,以及代碼邏輯可能也不是那么完善。但放在學(xué)習(xí)階段的每一次實(shí)現(xiàn)中,都能為自己帶來很多技術(shù)上的成長。
那么,這次 IM 實(shí)踐的機(jī)會(huì)給你,希望你能用的上!接下來我會(huì)給你介紹一個(gè) IM 的系統(tǒng)架構(gòu)、通信協(xié)議、單聊群聊、表情發(fā)送、UI 事件驅(qū)動(dòng)等各項(xiàng)內(nèi)容,以及提供全套的源碼讓你可以上手學(xué)習(xí)。
演示
在開始學(xué)習(xí)之前,先給大家演示下這套仿照 PC 端微信界面的 IM 系統(tǒng)運(yùn)行效果。
聊天頁面:
添加好友:
系統(tǒng)設(shè)計(jì)
在這套 IM 中,服務(wù)端采用 DDD 領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)模式進(jìn)行搭建。將 Netty 的功能交給 SpringBoot 進(jìn)行啟??刂?,同時(shí)在服務(wù)端搭建控制臺(tái)可以非常方便的操作通信系統(tǒng),進(jìn)行用戶和通信管理。
在客戶端的建設(shè)上采用 UI 分離的方式進(jìn)行搭建,以保證業(yè)務(wù)代碼與 UI 展示分離,做到非常易于擴(kuò)展的控制。
另外在功能實(shí)現(xiàn)上包括;完美仿照微信桌面版客戶端、登錄、搜索添加好友、用戶通信、群組通信、表情發(fā)送等核心功能。如果有對于實(shí)際需要使用的功能,可以按照這套系統(tǒng)框架進(jìn)行擴(kuò)展。
如上圖:
- UI 開發(fā):使用 JavaFx 與 Maven 搭建 UI 桌面工程,逐步講解登錄框體、聊天框體、對話框、好友欄等各項(xiàng) UI 展示及操作事件。從而在這一章節(jié)中讓 Java 程序員學(xué)會(huì)開發(fā)桌面版應(yīng)用。
- 架構(gòu)設(shè)計(jì):在這一章節(jié)中我們會(huì)使用 DDD 領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)的四層模型結(jié)構(gòu)與 Netty 結(jié)合使用,架構(gòu)出合理的分層框架。同時(shí)還有相應(yīng)庫表功能的設(shè)計(jì)。相信這些內(nèi)容學(xué)習(xí)后,你一定也可以假設(shè)出更好的框架。
- 功能實(shí)現(xiàn):這部分我們主要將通信中的各項(xiàng)功能逐步實(shí)現(xiàn),包括;登錄、添加好友、對話通知、消息發(fā)送、斷線重連等各項(xiàng)功能。最終完成整個(gè)項(xiàng)目的開發(fā),同時(shí)也可以讓你從實(shí)踐中學(xué)會(huì)技能。
UI 開發(fā)
①整體結(jié)構(gòu)定義、側(cè)邊欄
聊天窗體,相對于登陸窗體來說,聊天窗體的內(nèi)容會(huì)比較多,同時(shí)也會(huì)相對復(fù)雜一些。因此我們會(huì)分章節(jié)的逐步來實(shí)現(xiàn)這些窗體以及事件和接口功能。
在本篇文章中我們會(huì)主要講解聊天框體的搭建以及側(cè)邊欄 UI 開發(fā):
- 首先是我們整個(gè)聊天主窗體的定義,是一塊空白面板,并去掉默認(rèn)的邊框按鈕(最小化、退出等)。
- 之后是我們左側(cè)邊欄,我們稱之為條形 Bar,功能區(qū)域的實(shí)現(xiàn)。
- 最后添加窗體事件,當(dāng)點(diǎn)擊按鈕時(shí)變換 內(nèi)容面板 中的填充信息。
②對話聊天框
對話框選中后的內(nèi)容區(qū)域展現(xiàn),也就是用戶之間信息發(fā)送和展現(xiàn)。從整體上看這是一個(gè)聯(lián)動(dòng)的過程,點(diǎn)擊左側(cè)的對話框用戶,右側(cè)就有相應(yīng)內(nèi)容的填充。
那么右側(cè)被填充對話列表 ListView 需要與每一個(gè)對話用戶關(guān)聯(lián),點(diǎn)擊聊天用戶的時(shí)候,是通過反復(fù)切換填充的過程:
- 點(diǎn)擊左側(cè)的每一個(gè)對話框體,右側(cè)聊天框填充內(nèi)容即隨之變化。同時(shí)還有相應(yīng)的對話名稱也會(huì)也變化。
- 對話框中左側(cè)展示好友發(fā)送的信息,右側(cè)展示個(gè)人發(fā)送的信息。同時(shí)消息內(nèi)容會(huì)隨著內(nèi)容的增多而增加高度和寬度。
- 最下面是文本輸入框,在后面的實(shí)現(xiàn)里我們文本輸入框采用公用的方式進(jìn)行設(shè)計(jì),當(dāng)然你也可以設(shè)計(jì)為單獨(dú)的個(gè)人使用。
③好友欄
大家都經(jīng)常使用 PC 端的微信,可以知道在好友欄里是分了幾段內(nèi)容的,其中包含;新的朋友、公眾號、群組和最下面的好友。
如上圖:
- 最上面的搜索框這部分內(nèi)容不變,和前面的一樣。我們目前使用的方式是 fxml 設(shè)計(jì),例如這部分是通用功能,可以抽取出來放到代碼中,設(shè)計(jì)成一個(gè)組件元素類。
- 經(jīng)過我們的分析,在使用 JavaFx 組件開發(fā)為基礎(chǔ)下,這部分是一種嵌套 ListView,也就是最底層的面板是一個(gè) ListView,好友和群組有各是一個(gè) ListView,這樣處理后我們會(huì)很方便的進(jìn)行數(shù)據(jù)填充。
- 另外這樣的結(jié)構(gòu)主要有利于在我們程序運(yùn)行過程中,如果你添加了好友,那么我們需要將好友信息刷新到好友欄中,而在數(shù)據(jù)填充的時(shí)候,為了更加便捷高效,所以我們設(shè)計(jì)了嵌套的 ListView。如果還不是特別理解,可以從后續(xù)的代碼中獲得答案。
④事件定義
在桌面版 UI 開發(fā)中,為了能使 UI 與業(yè)務(wù)邏輯隔離,需要在我們把 UI 打包后提供出操作界面的展示效果的接口以及界面操作事件抽象類。
那么可以按照下圖理解:
以上這些接口就是我們目前 UI 為外部提供的所有行為接口,這些接口的一個(gè)鏈路描述就是:打開窗口、搜索好友、添加好友、打開對話框、發(fā)送消息。
通信設(shè)計(jì)
①系統(tǒng)架構(gòu)
如下圖:
在前面我們說到更適合的架構(gòu),才是符合你當(dāng)下需要最好的架構(gòu)。那么怎么設(shè)計(jì)這樣架構(gòu)呢,基本就是要找到符合點(diǎn)的目標(biāo)。
我們之所以這樣設(shè)計(jì)是為什么,那么在這個(gè)系統(tǒng)里有如下幾點(diǎn):
- 我們系統(tǒng)在服務(wù)端要有 web 頁面進(jìn)行管理通信用戶以及服務(wù)端的控制和監(jiān)控。
- 數(shù)據(jù)庫的對象類,不要被外部污染,要有隔離性。比如說你的數(shù)據(jù)庫類暴漏給外部做展示類使用了,那么現(xiàn)在需要增加一個(gè)字段,而這個(gè)字段又不是你數(shù)據(jù)庫存在的屬性。那么這個(gè)時(shí)候就已經(jīng)把數(shù)據(jù)庫類污染了。
- 因?yàn)槟壳拔覀兌际窃?Java 語言下實(shí)現(xiàn) Netty 通信,那么服務(wù)端與客戶端都會(huì)需要使用到通信過程中的協(xié)議定義和解析。那么我們需要抽離這一層對外提供 Jar 包。
- 接口、業(yè)務(wù)處理、底層服務(wù)、通信交互,要有明確的區(qū)分和實(shí)現(xiàn),避免造成混亂難以維護(hù)。
結(jié)合我們上面這四點(diǎn)的目標(biāo),你頭腦中有什么模型結(jié)構(gòu)體現(xiàn)了呢?以及相應(yīng)的技術(shù)棧選擇上是否有計(jì)劃了?
接下來我們會(huì)介紹兩種架構(gòu)設(shè)計(jì)的模型,一種是你非常熟悉的 MVC,另外一種是你可能聽說過的 DDD 領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)。
②通信協(xié)議
如下圖:
從圖稿上來看,我們在傳輸對象的時(shí)候需要在傳輸包中添加一個(gè) 幀標(biāo)識 以此來判斷當(dāng)前的業(yè)務(wù)對象是哪個(gè)對象,也就可以讓我們的業(yè)務(wù)更加清晰,避免使用大量的 if 語句判斷。
協(xié)議框架:
- agreement
- └── src
- ├── main
- │ ├── java
- │ │ └── org.itstack.naive.chat
- │ │ ├── codec
- │ │ │ ├── ObjDecoder.java
- │ │ │ └── ObjEncoder.java
- │ │ ├── protocol
- │ │ │ ├── demo
- │ │ │ ├── Command.java
- │ │ │ └── Packet.java
- │ │ └── util
- │ │ └── SerializationUtil.java
- │ ├── resources
- │ │ └── application.yml
- │ └── webapp
- │ └── chat
- │ └── res
- │ └── index.html
- └── test
- └── java
- └── org.itstack.demo.test
- └── ApiTest.java
協(xié)議包:
- public abstract class Packet {
- private final static Map<Byte, Class<? extends Packet>> packetType = new ConcurrentHashMap<>();
- static {
- packetType.put(Command.LoginRequest, LoginRequest.class);
- packetType.put(Command.LoginResponse, LoginResponse.class);
- packetType.put(Command.MsgRequest, MsgRequest.class);
- packetType.put(Command.MsgResponse, MsgResponse.class);
- packetType.put(Command.TalkNoticeRequest, TalkNoticeRequest.class);
- packetType.put(Command.TalkNoticeResponse, TalkNoticeResponse.class);
- packetType.put(Command.SearchFriendRequest, SearchFriendRequest.class);
- packetType.put(Command.SearchFriendResponse, SearchFriendResponse.class);
- packetType.put(Command.AddFriendRequest, AddFriendRequest.class);
- packetType.put(Command.AddFriendResponse, AddFriendResponse.class);
- packetType.put(Command.DelTalkRequest, DelTalkRequest.class);
- packetType.put(Command.MsgGroupRequest, MsgGroupRequest.class);
- packetType.put(Command.MsgGroupResponse, MsgGroupResponse.class);
- packetType.put(Command.ReconnectRequest, ReconnectRequest.class);
- }
- public static Class<? extends Packet> get(Byte command) {
- return packetType.get(command);
- }
- /**
- * 獲取協(xié)議指令
- *
- * @return 返回指令值
- */
- public abstract Byte getCommand();
- }
③添加好友
如上圖:
- 從上面的流程中可以看到,這里包含了兩部分內(nèi)容;(1) 搜索好友,(2) 添加好友。當(dāng)天就完成好友后,好友會(huì)出現(xiàn)到我們的好友欄中。
- 并且這里面我們采用的是單方面同意加好友,也就是你添加一個(gè)好友的時(shí)候,對方也同樣有你的好友信息。
- 如果你的業(yè)務(wù)中是需要添加好友并同意的,那么可以在發(fā)起好友添加的時(shí)候,添加一條狀態(tài)信息,請求加好友。對方同意后,兩個(gè)用戶才能成為好友并進(jìn)行通信。
添加好友,案例代碼:
- public class AddFriendHandler extends MyBizHandler<AddFriendRequest> {
- public AddFriendHandler(UserService userService) {
- super(userService);
- }
- @Override
- public void channelRead(Channel channel, AddFriendRequest msg) {
- // 1. 添加好友到數(shù)據(jù)庫中[A->B B->A]
- List<UserFriend> userFriendList = new ArrayList<>();
- userFriendList.add(new UserFriend(msg.getUserId(), msg.getFriendId()));
- userFriendList.add(new UserFriend(msg.getFriendId(), msg.getUserId()));
- userService.addUserFriend(userFriendList);
- // 2. 推送好友添加完成 A
- UserInfo userInfo = userService.queryUserInfo(msg.getFriendId());
- channel.writeAndFlush(new AddFriendResponse(userInfo.getUserId(), userInfo.getUserNickName(), userInfo.getUserHead()));
- // 3. 推送好友添加完成 B
- Channel friendChannel = SocketChannelUtil.getChannel(msg.getFriendId());
- if (null == friendChannel) return;
- UserInfo friendInfo = userService.queryUserInfo(msg.getUserId());
- friendChannel.writeAndFlush(new AddFriendResponse(friendInfo.getUserId(), friendInfo.getUserNickName(), friendInfo.getUserHead()));
- }
- }
④消息應(yīng)答
如上圖:
- 從整體的流程可以看到,在用戶發(fā)起好友、群組通信的時(shí)候,會(huì)觸發(fā)一個(gè)事件行為,接下來客戶端向服務(wù)端發(fā)送與好友的對話請求。
- 服務(wù)端收到對話請求后,如果是好友對話,那么需要保存與好友的通信信息到對話框中。同時(shí)通知好友,我與你要通信了。你在自己的對話框列表中,把我加進(jìn)去。
- 那么如果是群組通信,是可以不用這樣通知的,因?yàn)椴豢赡馨堰€沒有在線的所有群組用戶全部通知(人家還沒登錄呢),所以這部分只需要在用戶上線收到信息后,創(chuàng)建出對話框到列表中即可。可以仔細(xì)理解下,同時(shí)也可以想想其他實(shí)現(xiàn)的方式。
消息應(yīng)答,案例代碼:
- public class MsgHandler extends MyBizHandler<MsgRequest> {
- public MsgHandler(UserService userService) {
- super(userService);
- }
- @Override
- public void channelRead(Channel channel, MsgRequest msg) {
- logger.info("消息信息處理:{}", JSON.toJSONString(msg));
- // 異步寫庫
- userService.asyncAppendChatRecord(new ChatRecordInfo(msg.getUserId(), msg.getFriendId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));
- // 添加對話框[如果對方?jīng)]有你的對話框則添加]
- userService.addTalkBoxInfo(msg.getFriendId(), msg.getUserId(), Constants.TalkType.Friend.getCode());
- // 獲取好友通信管道
- Channel friendChannel = SocketChannelUtil.getChannel(msg.getFriendId());
- if (null == friendChannel) {
- logger.info("用戶id:{}未登錄!", msg.getFriendId());
- return;
- }
- // 發(fā)送消息
- friendChannel.writeAndFlush(new MsgResponse(msg.getUserId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));
- }
- }
⑤斷線重連
如上圖:
- 從上述流程中我們看到,當(dāng)網(wǎng)絡(luò)連接斷開以后,會(huì)像服務(wù)端發(fā)送重新鏈接的請求。那么在這個(gè)發(fā)起鏈接的過程,和系統(tǒng)的最開始鏈接有所區(qū)別。
斷線重連是需要將用戶的 ID 信息一同- - 發(fā)送給服務(wù)端,好讓服務(wù)端可以去更新用戶與通信管道 Channel 的綁定關(guān)系。
- 同時(shí)還需要更新群組內(nèi)的重連信息,把用戶的重連加入群組映射中。此時(shí)就可以恢復(fù)用戶與好友和群組的通信功能。
消息應(yīng)答,案例代碼:
- // Channel 狀態(tài)定時(shí)巡檢;3 秒后每 5 秒執(zhí)行一次
- scheduledExecutorService.scheduleAtFixedRate(() -> {while (!nettyClient.isActive()) {System.out.println("通信管道巡檢:通信管道狀態(tài)" + nettyClient.isActive());
- try {System.out.println("通信管道巡檢:斷線重連 [Begin]");
- Channel freshChannel = executorService.submit(nettyClient).get();
- if (null == CacheUtil.userId) continue;
- freshChannel.writeAndFlush(new ReconnectRequest(CacheUtil.userId));
- } catch (InterruptedException | ExecutionException e) {System.out.println("通信管道巡檢:斷線重連 [Error]");}
- }
- }, 3, 5, TimeUnit.SECONDS);
⑥集群通信
如上圖:
- 跨服務(wù)之間案例采用 redis 的發(fā)布和訂閱進(jìn)行傳遞消息,如果你是大型服務(wù)可以使用 zookeeper。
- 用戶 A 在發(fā)送消息給用戶 B 時(shí)候,需要傳遞 B 的 channeId,以用于服務(wù)端進(jìn)行查找 channeId 所屬是否自己的服務(wù)內(nèi)。
- 單臺(tái)機(jī)器也可以啟動(dòng)多個(gè) Netty 服務(wù),程序內(nèi)會(huì)自動(dòng)尋找可用端口。
源碼下載
本項(xiàng)目是我使用 JavaFx、Netty4.x、SpringBoot、MySQL 等技術(shù)棧和偏向于 DDD 領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)方式,搭建的仿桌面版微信實(shí)現(xiàn)通信核心功能。
這套 IM 代碼分為了三組模塊;UI、客戶端、服務(wù)端。之所以這樣拆分,是為了將 UI 展示與業(yè)務(wù)邏輯隔離,使用事件和接口進(jìn)行驅(qū)動(dòng),讓代碼層次更加干凈整潔易于擴(kuò)展和維護(hù)。
總結(jié)
如下圖:
此 IM 系統(tǒng)涉及到的技術(shù)棧內(nèi)容較多,Netty4.x、SpringBoot、Mybatis、MySQL、JavaFx、layui 等技術(shù)棧的使用,以及整個(gè)系統(tǒng)框架結(jié)構(gòu)采用 DDD 四層架構(gòu)+Socket 模塊的方式進(jìn)行搭建。
所有的 UI 都以前后端分離事件驅(qū)動(dòng)方式進(jìn)行設(shè)計(jì),在這個(gè)過程中只要你能堅(jiān)持學(xué)習(xí)下來,那么一定會(huì)收獲非常多的內(nèi)容。足夠吹牛啦!
任何一個(gè)新技術(shù)棧的學(xué)習(xí)過程都會(huì)包括這樣一條路線;運(yùn)行 HelloWorld、熟練使用 API、項(xiàng)目實(shí)踐以及最后的深度源碼挖掘。
那么在聽到這樣一個(gè)需求時(shí)候,Java 程序員肯定會(huì)想到一些列的技術(shù)知識點(diǎn)來填充我們項(xiàng)目中的各個(gè)模塊。
例如界面用 JavaFx、Swing 等,通信用 Socket 或者知道 Netty 框架、服務(wù)端控制用 MVC 模型加上 SpringBoot 等。
但是怎么將這些各個(gè)技術(shù)棧合理的架設(shè)出我們的系統(tǒng)確是學(xué)習(xí)、實(shí)踐、成長過程中最重要的部分。
作者:小傅哥
編輯:陶家龍
出處:轉(zhuǎn)載自公眾號 bugstack 蟲洞棧(ID:bugstack)