跟著原始碼學IM(九):基於Netty實現一套分散式IM系統

JackJiang發表於2021-12-24

本文作者小傅哥,原題“使用DDD+Netty,開發一個分散式IM(即時通訊)系統”。為了提升閱讀體驗,有大量修訂和改動,感謝原作者。

0、系列文章

《跟著原始碼學IM(一):手把手教你用Netty實現心跳機制、斷線重連機制》
《跟著原始碼學IM(二):自已開發IM很難?手把手教你擼一個Andriod版IM》
《跟著原始碼學IM(三):基於Netty,從零開發一個IM服務端》
《跟著原始碼學IM(四):拿起鍵盤就是幹,教你徒手開發一套分散式IM系統》
《跟著原始碼學IM(五):正確理解IM長連線、心跳及重連機制,並動手實現》
《跟著原始碼學IM(六):手把手教你用Go快速搭建高效能、可擴充套件的IM系統》
《跟著原始碼學IM(七):手把手教你用WebSocket打造Web端IM聊天》
《跟著原始碼學IM(八):萬字長文,手把手教你用Netty打造IM聊天》
《跟著原始碼學IM(九):基於Netty實現一套分散式IM系統》(* 本文)

1、本文引言

計算機程式設計的學習,能不能把知識學到手,講究的是動手實踐。在我編寫的文章中,基本都是以實踐程式碼驗證結果為核心來講述文章內容。

從小我就喜歡動手,就以一個即時通訊的專案為例,已經基於不同技術方案實現了5、6次,僅僅為了實踐技術,截圖如下。

正如上圖這樣:

1)有些是剛學完Socket和Swing的時候,想動手試試這些技術能不能寫個QQ出來;
2)也有的是因為實習培訓需要完成的專案,不過在有了一些基礎後,一週時間就能寫完全部功能;
3)雖然這些專案在現在看上去還是醜醜的介面,以及程式碼邏輯可能也不是那麼完善。但放在學習階段的每一次實現中,都能為自己帶來很多技術上的成長。

那麼,這次借本文的機會,將IM實踐的機會留給你,希望你能用的上。

接下來的內容,我會為你介紹如何開發一個IM的方方面面,包括系統架構、通訊協議、單聊群聊、表情傳送、UI事件驅動等,以及全套的實踐原始碼讓你可以上手學習。

注:原始碼在本文“4、本文原始碼”一節的附件處可下載。

學習交流:

  • 即時通訊/推送技術開發交流5群:215477170 [推薦]
  • 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
  • 開源IM框架原始碼:https://github.com/JackJiang2...

(本文已同步釋出於:http://www.52im.net/thread-37...

2、知識準備

  • 重要提示:本文不是一篇即時通訊理論文章,文章內容全部由實戰程式碼組織而成,如果你對即時通訊(IM)技術理論瞭解的太少,建議先詳細閱讀:《新手入門一篇就夠:從零開發移動端IM》。

可能有人不知道 Netty 是什麼,這裡簡單介紹下:

Netty 是一個 Java 開源框架。Netty 提供非同步的、事件驅動的網路應用程式框架和工具,用以快速開發高效能、高可靠性的網路伺服器和客戶端程式。

也就是說,Netty 是一個基於 NIO 的客戶、伺服器端程式設計框架,使用Netty 可以確保你快速和簡單的開發出一個網路應用,例如實現了某種協議的客戶,服務端應用。

Netty 相當簡化和流線化了網路應用的程式設計開發過程,例如,TCP 和 UDP 的 Socket 服務開發。

以下是幾篇有關Netty的入門文章,值得一讀:

《新手入門:目前為止最透徹的的Netty高效能原理和框架架構解析》
《寫給初學者:Java高效能NIO框架Netty的學習方法和進階策略》
《史上最通俗Netty框架入門長文:基本介紹、環境搭建、動手實戰》

如果你連Java的NIO都不知道是什麼,下面的文章建議優先讀一下:

《少囉嗦!一分鐘帶你讀懂Java的NIO和經典IO的區別》
《史上最強Java NIO入門:擔心從入門到放棄的,請讀這篇!》
《Java的BIO和NIO很難懂?用程式碼實踐給你看,再不懂我轉行!》

Netty原始碼和API的線上閱讀地址:

1)Netty-4.1.x 完整原始碼(線上閱讀版)(* 推薦)
2)Netty-4.0.x 完整原始碼(線上閱讀版)
3)Netty-4.1.x API文件(線上版)(* 推薦)
4)Netty-4.0.x API文件(線上版)

3、執行效果

在開始學習之前,先給大家演示下本文配套原始碼的執行效果(原始碼在本文“4、本文原始碼”一節的附件處可下載)。

聊天頁面:

新增好友:

訊息提醒:

4、本文原始碼

本文完整程式碼附件下載:
(請從同步釋出連結中下載:http://www.52im.net/thread-37...

原始碼的目錄結構,如下圖所示:

這套 IM 程式碼分為了三組模組:UI、客戶端、服務端。

之所以這樣拆分,是為了將UI展示與業務邏輯隔離,使用事件和介面進行驅動,讓程式碼層次更加乾淨整潔易於擴充套件和維護。

各模組的作用,具體解釋如下:

5、系統設計

在這套IM中,服務端採用DDD領域驅動設計模式進行搭建。將 Netty 的功能交給 SpringBoot 進行啟停控制,同時在服務端搭建控制檯可以非常方便的操作通訊系統,進行使用者和通訊管理。在客戶端的建設上採用UI分離的方式進行搭建,以保證業務程式碼與UI展示分離,做到非常易於擴充套件的控制。

另外,在功能實現上包括:完美仿照微信桌面版客戶端、登入、搜尋新增好友、使用者通訊、群組通訊、表情傳送等核心功能。如果有對於實際需要使用的功能,可以按照這套系統框架進行擴充套件。

解釋一下:

1)UI開發:使用JavaFx與Maven搭建UI桌面工程,逐步講解登入框體、聊天框體、對話方塊、好友欄等各項UI展示及操作事件;
2)架構設計:使用DDD領域驅動設計的四層模型結構與Netty結合使用,架構出合理的分層框架(相應庫表功能的設計);
3)功能實現:包括;登入、新增好友、對話通知、訊息傳送、斷線重連等各項功能。

6、UI開發

6.1 功能劃分
聊天窗體,相對於登陸窗體來說,聊天窗體的內容會比較多,同時也會相對複雜一些。

下圖是聊天窗體的功能定義草圖:

如上圖所示:

1)首先是我們整個聊天主窗體的定義,是一塊空白皮膚,並去掉預設的邊框按鈕 (最小化、退出等);
2)之後是我們左側邊欄,我們稱之為條形 Bar,功能區域的實現;
3)最後新增窗體事件,當點選按鈕時變換 內容皮膚 中的填充資訊。

6.2 聊天介面
對話方塊選中後的內容區域展現,也就是使用者之間資訊傳送和展現。

從整體上看這是一個聯動的過程,點選左側的對話方塊使用者,右側就有相應內容的填充。那麼右側被填充對話列表 ListView 需要與每一個對話使用者關聯,點選聊天使用者的時候,是通過反覆切換填充的過程。效果如下圖所示。

參見上圖,我解釋一下:

1)點選左側的每一個對話方塊體,右側聊天框填充內容即隨之變化(同時還有相應的對話名稱也會也變化);
2)對話方塊中左側展示好友傳送的資訊,右側展示個人傳送的資訊(同時訊息內容會隨著內容的增多而增加高度和寬度);
3)最下面是文字輸入框,在後面的實現裡我們文字輸入框採用公用的方式進行設計,當然你也可以設計為單獨的個人使用。

6.3 好友列表
大家都經常使用 PC 端的微信,可以知道在好友欄裡是分了幾段內容的,其中包含:新的朋友、公眾號、群組和最下面的好友(功能劃分如下圖)。

參見上圖,我解釋一下:

1)最上面的搜尋框這部分內容不變,和前面的一樣。我們目前使用的方式是 fxml 設計,例如這部分是通用功能,可以抽取出來放到程式碼中,設計成一個元件元素類;
2)經過我們的分析,在使用 JavaFx 元件開發為基礎下,這部分是一種巢狀 ListView,也就是最底層的皮膚是一個 ListView,好友和群組有各是一個 ListView,這樣處理後我們會很方便的進行資料填充;
3)另外這樣的結構主要有利於在我們程式執行過程中,如果你新增了好友,那麼我們需要將好友資訊重新整理到好友欄中,而在資料填充的時候,為了更加便捷高效,所以我們設計了巢狀的 ListView(如果還不是特別理解,可以從後續的程式碼中獲得答案)。

6.4 事件定義
在桌面版 UI 開發中,為了能使 UI 與業務邏輯隔離,需要在我們把 UI 打包後提供出操作介面的展示效果的介面以及介面操作事件抽象類。

那麼可以按照下圖理解:

以上這些介面就是我們目前 UI 為外部提供的所有行為介面,這些介面的一個鏈路描述就是:開啟視窗、搜尋好友、新增好友、開啟對話方塊、傳送訊息。

7、通訊設計

7.1 系統架構

在前面我說到更適合的架構,才是符合你當下需要最好的架構。

那麼怎麼設計需要的架構呢?

之所以這樣設計,在這個系統裡有如下幾點前提:

1)系統在服務端要有 web 頁面進行管理通訊使用者以及服務端的控制和監控;
2)資料庫的物件類,不要被外部汙染,要有隔離性(比如:你的資料庫類暴漏給外部做展示類使用了,那麼現在需要增加一個欄位,而這個欄位又不是你資料庫存在的屬性。那麼這個時候就已經把資料庫類汙染了)。
3)因為目前都是在 Java 語言下實現 Netty 通訊,那麼服務端與客戶端都會需要使用到通訊過程中的協議定義和解析。那麼我們需要抽離這一層對外提供 Jar 包(利於重用,不然客戶端和服務端複製同樣的程式碼維護,就太噁心了);
4)介面、業務處理、底層服務、通訊互動,要有明確的區分和實現,避免造成混亂難以維護。

結合我們上面這四點的前提,你頭腦中有什麼模型結構體現了?以及相應的技術棧選擇上是否有計劃了?

接下來我會介紹兩種架構設計的模型,一種是你非常熟悉的 MVC,另外一種是你可能聽說過的 DDD 領域驅動設計。

7.2 通訊協議

從圖稿上來看,我們在傳輸物件的時候需要在傳輸包中新增一個“幀標識”以此來判斷當前的業務物件是哪個物件,也就可以讓我們的業務更加清晰,避免使用大量的 if 語句判斷。

協議框架:

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

協議包:

public abstract class Packet {

private final static Map<Byte, Class<? extendsPacket>> 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);

}



/**

 * 獲取協議指令

 *

 * @return 返回指令值

 */

public abstract Byte getCommand();

}

7.3 新增好友

從上面的流程圖中可以看到,這裡包含了兩部分內容:搜尋好友和新增好友。

當新增完成好友後,好友會出現到我們的好友欄中。

並且這裡面我們採用的是單方面同意加好友,也就是你新增一個好友的時候,對方也同樣有你的好友資訊。

如果你的業務中是需要新增好友並同意的,那麼可以在發起好友新增的時候,新增一條狀態資訊,請求加好友。對方同意後,兩個使用者才能成為好友並進行通訊。

新增好友的樣例程式碼:

public class AddFriendHandler extends MyBizHandler<AddFriendRequest> {

public AddFriendHandler(UserService userService) {

    super(userService);

}

@Override

public void channelRead(Channel channel, AddFriendRequest msg) {

    // 1. 新增好友到資料庫中[A->B B->A]

    List<UserFriend> userFriendList = newArrayList<>();

    userFriendList.add(newUserFriend(msg.getUserId(), msg.getFriendId()));

    userFriendList.add(newUserFriend(msg.getFriendId(), msg.getUserId()));

    userService.addUserFriend(userFriendList);

    // 2. 推送好友新增完成 A

    UserInfo userInfo = userService.queryUserInfo(msg.getFriendId());

    channel.writeAndFlush(newAddFriendResponse(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(newAddFriendResponse(friendInfo.getUserId(), friendInfo.getUserNickName(), friendInfo.getUserHead()));

}

}

7.4 訊息應答

從整體的流程可以看到:在使用者發起好友、群組通訊的時候,會觸發一個事件行為,接下來客戶端向服務端傳送與好友的對話請求。

服務端收到對話請求後:如果是好友對話,那麼需要儲存與好友的通訊資訊到對話方塊中。同時通知好友,我與你要通訊了。你在自己的對話方塊列表中,把我加進去。

如果是群組通訊:是可以不用這樣通知的,因為不可能把還沒有線上的所有群組使用者全部通知(人家還沒登入呢),所以這部分只需要在使用者上線收到資訊後,建立出對話方塊到列表中即可。可以仔細理解下,同時也可以想想其他實現的方式。

訊息應答樣例程式碼:

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(newChatRecordInfo(msg.getUserId(), msg.getFriendId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));

    // 新增對話方塊[如果對方沒有你的對話方塊則新增]

    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;

    }

    // 傳送訊息

    friendChannel.writeAndFlush(newMsgResponse(msg.getUserId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));

}

}

7.5 斷線重連

從上述流程中我們看到:當網路連線斷開以後,會像服務端傳送重新連結的請求。 那麼在這個發起連結的過程,和系統的最開始連結有所區別。斷線重連是需要將使用者的 ID 資訊一同傳送給服務端,好讓服務端可以去更新使用者與通訊管道 Channel 的繫結關係。

同時還需要更新群組內的重連資訊,把使用者的重連加入群組對映中。此時就可以恢復使用者與好友和群組的通訊功能。

訊息應答樣例程式碼:

// Channel 狀態定時巡檢;3 秒後每 5 秒執行一次

scheduledExecutorService.scheduleAtFixedRate(() -> {while(!nettyClient.isActive()) {System.out.println("通訊管道巡檢:通訊管道狀態"+ nettyClient.isActive());

    try{System.out.println("通訊管道巡檢:斷線重連 [Begin]");

        Channel freshChannel = executorService.submit(nettyClient).get();

        if(null== CacheUtil.userId) continue;

        freshChannel.writeAndFlush(newReconnectRequest(CacheUtil.userId));

    } catch(InterruptedException | ExecutionException e) {System.out.println("通訊管道巡檢:斷線重連 [Error]");}

}

}, 3, 5, TimeUnit.SECONDS);

相關文章學習:

《為何基於TCP協議的移動端IM仍然需要心跳保活機制?》
《一文讀懂即時通訊應用中的網路心跳包機制:作用、原理、實現思路等》
《融雲技術分享:融雲安卓端IM產品的網路鏈路保活技術實踐》
《正確理解IM長連線的心跳及重連機制,並動手實現(有完整IM原始碼)》
《一種Android端IM智慧心跳演算法的設計與實現探討(含樣例程式碼)》
《手把手教你用Netty實現網路通訊程式的心跳機制、斷線重連機制》
《Web端即時通訊實踐乾貨:如何讓你的WebSocket斷網重連更快速?》

7.6 叢集通訊

如上圖所示,我是這樣實現IM叢集通訊的:

1)跨服務之間案例採用redis的釋出和訂閱進行傳遞訊息,如果你是大型服務可以使用zookeeper;
2)使用者A在傳送訊息給使用者B時候,需要傳遞B的channeId,以用於服務端進行查詢channeId所屬是否自己的服務內;
3)單臺機器也可以啟動多個Netty服務,程式內會自動尋找可用埠。

8、本文小結

此IM系統涉及到的技術棧內容較多:Netty4.x、SpringBoot、Mybatis、Mysql、JavaFx、layui等技術棧的使用,以及整個系統框架結構採用DDD四層架構+Socket模組的方式進行搭建,所有的UI都以前後端分離事件驅動方式進行設計。在這個過程中只要你能堅持學習下來,那麼一定會收穫非常多的內容。足夠吹牛啦!

任何一個新技術棧的學習過程都會包括這樣一條路線:執行HelloWorld、熟練使用API、專案實踐以及最後的深度原始碼挖掘。 那麼在聽到這樣一個需求時候,Java程式設計師肯定會想到一些列的技術知識點來填充我們專案中的各個模組(例如:介面用JavaFx、Swing等,通訊用Socket或者知道Netty框架、服務端控制用MVC模型加上SpringBoot等)。但是怎麼將這些各個技術棧合理的架設出我們的系統確是學習、實踐、成長過程中最重要的部分。

好了,IM開發實際上涉及的知識維度非常多,限於篇幅就不在這裡囉嗦更多,各位讀者務必對著原始碼同步進行學習,這樣效果會更好(原始碼在本文“4、本文原始碼”一節的附件處可下載)。

9、參考資料

[1] 新手入門:目前為止最透徹的的Netty高效能原理和框架架構解析
[2] 寫給初學者:Java高效能NIO框架Netty的學習方法和進階策略
[3] 史上最強Java NIO入門:擔心從入門到放棄的,請讀這篇!
[4] Java的BIO和NIO很難懂?用程式碼實踐給你看,再不懂我轉行!
[5] 史上最通俗Netty框架入門長文:基本介紹、環境搭建、動手實戰
[6] 理論聯絡實際:一套典型的IM通訊協議設計詳解
[7] 淺談IM系統的架構設計
[8] 簡述移動端IM開發的那些坑:架構設計、通訊協議和客戶端
[9] 一套海量線上使用者的移動端IM架構設計實踐分享(含詳細圖文)
[10] 一套原創分散式即時通訊(IM)系統理論架構方案
[11]一套高可用、易伸縮、高併發的IM群聊、單聊架構方案設計實踐
[12] 一套億級使用者的IM架構技術乾貨(上篇):整體架構、服務拆分等
[13] 一套億級使用者的IM架構技術乾貨(下篇):可靠性、有序性、弱網優化等
[14] 從新手到專家:如何設計一套億級訊息量的分散式IM系統
[15] 基於實踐:一套百萬訊息量小規模IM系統技術要點總結

本文已同步釋出於“即時通訊技術圈”公眾號。
同步釋出連結是:http://www.52im.net/thread-37...

相關文章