開發一個分散式IM(即時通訊)系統!

作者:小傅哥

部落格://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!😄

一、前言

這知識學的,根本沒有忘的快呀?!

是不是感覺很多資料,點收藏起來爽看影片時候嗨讀文章當時會,只要過了那個勁,就完了,根本不記得這裡面都講了啥。時間浪費了,東西還沒學到手,這是為啥?

其實因為學習也分為上策、中策和下策:

  • 下策:眼睛看就行,坐著、窩著、躺著,都行,反正也不累,還能一邊回復下吹水的微信群
  • 中策:看完的資料做筆記整理歸納,長期積累資料
  • 上策:實踐、上手、應用、調試、歸納、整理資料,總結經驗輸出文檔

綜上,下策學起來很快感覺自己好像會了不少,中策有點要動手了懶不想動,上策就很耗時耗力了要自己對每一個知識點都能事必躬親到親力親為。就這樣你在學習的時候不自覺的就選擇了下策,因此其實並沒有學到什麼。

學習能把知識學到手,講究的是實踐,在小傅哥編寫的文章中,基本都是以實踐程式碼驗證結果為核心,講述文章內容。😁從小我就喜歡動手,就以一個即時通訊的項目為例,已經基於不同技術方案實現了5、6次,僅為了實踐技術,截圖如下:

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

那麼,這次IM實踐的機會給你,希望你能用的上!接下來我會給你介紹一個IM的系統架構、通訊協議、單聊群聊、表情發送、UI事件驅動等各項內容,以及提供全套的源碼讓你可以上手學習。

二、演示

在開始學習之前,先給大家演示下這套仿照PC端微信介面的IM系統運行效果。

聊天頁面

添加好友

影片演示

//www.bilibili.com/video/BV1BZ4y1W7fC

三、系統設計

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

另外在功能實現上包括;完美仿照微信桌面版客戶端、登錄、搜索添加好友、用戶通訊、群組通訊、表情發送等核心功能。如果有對於實際需要使用的功能,可以按照這套系統框架進行擴展。

  • UI開發:使用JavaFxMaven搭建UI桌面工程,逐步講解登錄框體、聊天框體、對話框、好友欄等各項UI展示及操作事件。從而在這一章節中讓Java 程式設計師學會開發桌面版應用。
  • 架構設計:在這一章節中我們會使用DDD領域驅動設計的四層模型結構與Netty結合使用,架構出合理的分層框架。同時還有相應庫表功能的設計。相信這些內容學習後,你一定也可以假設出更好的框架。
  • 功能實現:這部分我們主要將通訊中的各項功能逐步實現,包括;登錄、添加好友、對話通知、消息發送、斷線重連等各項功能。最終完成整個項目的開發,同時也可以讓你從實踐中學會技能。

四、UI開發

1. 整體結構定義、側邊欄

聊天窗體,相對於登陸窗體來說,聊天窗體的內容會比較多,同時也會相對複雜一些。因此我們會分章節的逐步來實現這些窗體以及事件和介面功能。在本篇文章中我們會主要講解聊天框體的搭建以及側邊欄 UI 開發。

  • 首先是我們整個聊天主窗體的定義,是一塊空白面板,並去掉默認的邊框按鈕 (最小化、退出等)
  • 之後是我們左側邊欄,我們稱之為條形 Bar,功能區域的實現。
  • 最後添加窗體事件,當點擊按鈕時變換 內容面板 中的填充資訊。

2. 對話聊天框

對話框選中後的內容區域展現,也就是用戶之間資訊發送和展現。從整體上看這是一個聯動的過程,點擊左側的對話框用戶,右側就有相應內容的填充。那麼右側被填充對話列表 ListView 需要與每一個對話用戶關聯,點擊聊天用戶的時候,是通過反覆切換填充的過程。

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

3. 好友欄

大家都經常使用 PC 端的微信,可以知道在好友欄里是分了幾段內容的,其中包含;新的朋友、公眾號、群組和最下面的好友。

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

4. 事件定義

在桌面版 UI 開發中,為了能使 UI 與業務邏輯隔離,需要在我們把 UI 打包後提供出操作介面的展示效果的介面以及介面操作事件抽象類。那麼可以按照下圖理解;

序號 介面名 描述
1 void doShow() 打開窗口
2 void setUserInfo(String userId, String userNickName, String userHead) 設置登陸用戶 ID、昵稱、頭像
3 void addTalkBox(int talkIdx, Integer talkType, String talkId, String talkName, String talkHead, String talkSketch, Date talkDate, Boolean selected) 填充對話框列表
4 void addTalkMsgUserLeft(String talkId, String msg, Date msgData, Boolean idxFirst, Boolean selected, Boolean isRemind) 填充對話框消息 – 好友 (別人的消息)
  • 以上這些介面就是我們目前 UI 為外部提供的所有行為介面,這些介面的一個鏈路描述就是;打開窗口、搜索好友、添加好友、打開對話框、發送消息。

五、通訊設計

1. 系統架構

在前面我們說到更適合的架構,才是符合你當下需要最好的架構。那麼怎麼設計這樣架構呢,基本就是要找到符合點的目標。我們之所以這樣設計是為什麼,那麼在這個系統里有如下幾點;

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

結合我們上面這四點的目標,你頭腦中有什麼模型結構體現了呢?以及相應的技術棧選擇上是否有計划了?接下來我們會介紹兩種架構設計的模型,一種是你非常熟悉的 MVC,另外一種是你可能聽說過的 DDD 領域驅動設計。

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<? 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);
    }

    /**
     * 獲取協議指令
     *
     * @return 返回指令值
     */
    public abstract Byte getCommand();

}

3. 添加好友

  • 從上面的流程中可以看到,這裡包含了兩部分內容;(1) 搜索好友,(2) 添加好友。當天就完成好友後,好友會出現到我們的好友欄中。
  • 並且這裡面我們採用的是單方面同意加好友,也就是你添加一個好友的時候,對方也同樣有你的好友資訊。
  • 如果你的業務中是需要添加好友並同意的,那麼可以在發起好友添加的時候,添加一條狀態資訊,請求加好友。對方同意後,兩個用戶才能成為好友並進行通訊。

添加好友,案例程式碼

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 = 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()));
    }

}

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(new ChatRecordInfo(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(new MsgResponse(msg.getUserId(), msg.getMsgText(), msg.getMsgType(), msg.getMsgDate()));
    }

}

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(new ReconnectRequest(CacheUtil.userId));
        } catch (InterruptedException | ExecutionException e) {System.out.println("通訊管道巡檢:斷線重連 [Error]");}
    }
}, 3, 5, TimeUnit.SECONDS);

6. 集群通訊

  • 跨服務之間案例採用redis的發布和訂閱進行傳遞消息,如果你是大型服務可以使用zookeeper
  • 用戶A在發送消息給用戶B時候,需要傳遞B的channeId,以用於服務端進行查找channeId所屬是否自己的服務內
  • 單台機器也可以啟動多個Netty服務,程式內會自動尋找可用埠

六、源碼下載

本項目是作者小傅哥使用JavaFx、Netty4.x、SpringBoot、Mysql等技術棧和偏向於DDD領域驅動設計方式,搭建的仿桌面版微信實現通訊核心功能。

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

序號 工程 介紹
1 itstack-naive-chat-ui 使用JavaFx開發的UI端,在我們的UI端中提供了;登錄框體、聊天框體,同時在聊天框體中有大量的行為交互介面以及介面和事件。最終我的UI端使用Maven打包的方式向外提供Jar包,以此來達到UI介面與業務行為流程分離。
2 itstack-naive-chat-client 客戶端是我們的通訊核心工程,主要使用Netty4.x作為我們的socket框架來完成通訊交互。並且在此工程中負責引入UI的Jar包,完成UI定義的事件(登錄驗證、搜索添加好友、對話通知、發送資訊等等),以及需要使用我們在服務端工程定義的通訊協議來完成資訊的交互操作。
3 itstack-navie-chat-server 服務端同樣使用Netty4.x作為socket的通訊框架,同時在服務端使用Layui作為管理後台的頁面,並且我們的服務端採用偏向於DDD領域驅動設計的方式與Netty集合,以此來達到我們的框架結構整潔乾淨易於擴展。
4 itstack.sql 系統工程資料庫表結構以及初始化數據資訊,共計6張核心表;用戶表、群組表、用戶群組關聯表、好友表、對話表以及聊天記錄表。用戶在實際業務開發中可以自行拓展完善,目前庫表結構只以核心功能為基礎。

七、總結

  • 此IM系統涉及到的技術棧內容較多,Netty4.x、SpringBoot、Mybatis、Mysql、JavaFx、layui等技術棧的使用,以及整個系統框架結構採用DDD四層架構+Socket模組的方式進行搭建,所有的UI都以前後端分離事件驅動方式進行設計,在這個過程中只要你能堅持學習下來,那麼一定會收穫非常多的內容。足夠吹牛啦!🌶
  • 任何一個新技術棧的學習過程都會包括這樣一條路線;運行HelloWorld、熟練使用API、項目實踐以及最後的深度源碼挖掘。 那麼在聽到這樣一個需求時候,Java程式設計師肯定會想到一些列的技術知識點來填充我們項目中的各個模組,例如;介面用JavaFx、Swing等,通訊用Socket或者知道Netty框架、服務端控制用MVC模型加上SpringBoot等。但是怎麼將這些各個技術棧合理的架設出我們的系統確是學習、實踐、成長過程中最重要的部分。