Netty實戰:設計一個IM框架就這麼簡單!

風平浪靜如碼發表於2019-12-13

bitchat 是一個基於 Netty 的 IM 即時通訊框架

專案地址:github.com/all4you/bit…

Netty實戰:設計一個IM框架就這麼簡單!

快速開始

bitchat-example 模組提供了一個服務端與客戶端的實現示例,可以參照該示例進行自己的業務實現。

啟動服務端

要啟動服務端,需要獲取一個 Server 的例項,可以通過 ServerFactory 來獲取。

目前只實現了單機模式下的 Server ,通過 SimpleServerFactory 只需要定義一個埠即可獲取一個單機的 Server 例項,如下所示:

public class StandaloneServerApplication {
    public static void main(String[] args) {
        Server server = SimpleServerFactory.getInstance()
            .newServer(8864);
        server.start();
    }
}
複製程式碼

服務端啟動成功後,將顯示如下資訊:

Netty實戰:設計一個IM框架就這麼簡單!

啟動客戶端

目前只實現了直連伺服器的客戶端,通過 SimpleClientFactory 只需要指定一個 ServerAttr 即可獲取一個客戶端,然後進行客戶端與服務端的連線,如下所示:

public class DirectConnectServerClientApplication {

    public static void main(String[] args) {
        Client client = SimpleClientFactory.getInstance()
            .newClient(ServerAttr.getLocalServer(8864));
        client.connect();

        doClientBiz(client);
    }
}
複製程式碼

客戶端連線上服務端後,將顯示如下資訊:

Netty實戰:設計一個IM框架就這麼簡單!

體驗客戶端的功能

目前客戶端提供了三種 Func,分別是:登入,檢視線上使用者列表,傳送單聊訊息,每種 Func 有不同的命令格式。

登入

通過在客戶端中執行以下命令 -lo houyi 123456 即可實現登入,目前使用者中心還未實現,通過 Mock 的方式實現一個假的使用者服務,所以輸入任何的使用者名稱密碼都會登入成功,並且會為使用者建立一個使用者id。

登入成功後,顯示如下:

Netty實戰:設計一個IM框架就這麼簡單!
login.jpg

檢視線上使用者

再啟動一個客戶端,並且也執行登入,登入成功後,可以執行 -lu 命令,獲取線上使用者列表,目前使用者是儲存在記憶體中,獲取的結果如下所示:

Netty實戰:設計一個IM框架就這麼簡單!
list-user.jpg

傳送單聊資訊

用 gris 這個使用者向 houyi 這個使用者傳送單聊資訊,只要執行 -pc 1 hello,houyi 命令即可

其中第二個引數數要傳送訊息給那個使用者的使用者id,第三個引數是訊息內容

訊息傳送方,傳送完訊息:

Netty實戰:設計一個IM框架就這麼簡單!
send-p2p-msg.jpg

訊息接收方,接收到訊息:

Netty實戰:設計一個IM框架就這麼簡單!
received-p2p-msg.jpg

客戶端斷線重連

客戶端和服務端之間維持著心跳,雙方都會檢查連線是否可用,客戶端每隔5s會向服務端傳送一個 PingPacket,而服務端接收到這個 PingPacket 之後,會回覆一個 PongPacket,這樣表示雙方都是健康的。

當因為某種原因,服務端沒有收到客戶端傳送的訊息,服務端將會把該客戶端的連線斷開,同樣的客戶端也會做這樣的檢查。

當客戶端與服務端之間的連線斷開之後,將會觸發客戶端 HealthyChecker 的 channelInactive 方法,從而進行客戶端的斷線重連。

Netty實戰:設計一個IM框架就這麼簡單!
client-reconnect.jpg

整體架構

單機版

單機版的架構只涉及到服務端、客戶端,另外有兩者之間的協議層,如下圖所示:

Netty實戰:設計一個IM框架就這麼簡單!
stand-alone-arch.jpg

除了服務端和客戶端之外,還有三大中心:訊息中心,使用者中心,連結中心。

  • 訊息中心:主要負責訊息的儲存與歷史、離線訊息的查詢

  • 使用者中心:主要負責使用者和群組相關的服務

  • 連結中心:主要負責儲存客戶端的連結,服務端從連結中心獲取客戶端的連結,向其推送訊息

叢集版

單機版無法做到高可用,效能與可服務的使用者數也有一定的限制,所以需要有可擴充套件的叢集版,叢集版在單機版的基礎上增加了一個路由層,客戶端通過路由層來獲得可用的服務端地址,然後與服務端進行通訊,如下圖所示:

Netty實戰:設計一個IM框架就這麼簡單!
cluster-arch.jpg

客戶端傳送訊息給另一個使用者,服務端接收到這個請求後,從 Connection中心中獲取目標使用者“掛”在哪個服務端下,如果在自己名下,那最簡單直接將訊息推送給目標使用者即可,如果在其他服務端,則需要將該請求轉交給目標服務端,讓目標服務端將訊息推送給目標使用者。

自定義協議

通過一個自定義協議來實現服務端與客戶端之間的通訊,協議中有如下幾個欄位:

*
* <p>
* The structure of a Packet is like blow:
* +----------+----------+----------------------------+
* |  size    |  value   |  intro                     |
* +----------+----------+----------------------------+
* | 1 bytes  | 0xBC     |  magic number              |
* | 1 bytes  |          |  serialize algorithm       |
* | 4 bytes  |          |  packet symbol             |
* | 4 bytes  |          |  content length            |
* | ? bytes  |          |  the content               |
* +----------+----------+----------------------------+
* </p>
*
複製程式碼

每個欄位的含義

所佔位元組 用途
1 魔數,預設為 0xBC
1 序列化的演算法
4 Packet 的型別
4 Packet 的內容長度
? Packet 的內容

序列化演算法將會決定該 Packet 在編解碼時,使用何種序列化方式。

Packet 的型別將會決定到達服務端的位元組流將被反序列化為何種 Packet,也決定了該 Packet 將會被哪個 PacketHandler 進行處理。

內容長度將會解決 Packet 的拆包與粘包問題,服務端在解析位元組流時,將會等到位元組的長度達到內容的長度時,才進行位元組的讀取。

除此之外,Packet 中還會儲存一個 sync 欄位,該欄位將指定服務端在處理該 Packet 的資料時是否需要使用非同步的業務執行緒池來處理。

健康檢查

服務端與客戶端各自維護了一個健康檢查的服務,即 Netty 為我們提供的 IdleStateHandler,通過繼承該類,並且實現 channelIdle 方法即可實現連線 “空閒” 時的邏輯處理,當出現空閒時,目前我們只關心讀空閒,我們既可以認為這條連結出現問題了。

那麼只需要在連結出現問題時,將這條連結關閉即可,如下所示:

public class IdleStateChecker extends IdleStateHandler {

    private static final int DEFAULT_READER_IDLE_TIME = 15;

    private int readerTime;

    public IdleStateChecker(int readerIdleTime) {
        super(readerIdleTime == 0 ? DEFAULT_READER_IDLE_TIME : readerIdleTime, 0, 0, TimeUnit.SECONDS);
        readerTime = readerIdleTime == 0 ? DEFAULT_READER_IDLE_TIME : readerIdleTime;
    }

    @Override
    protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) {
        log.warn("[{}] Hasn't read data after {} seconds, will close the channel:{}", 
        IdleStateChecker.class.getSimpleName(), readerTime, ctx.channel());
        ctx.channel().close();
    }

}
複製程式碼

另外,客戶端需要額外再維護一個健康檢查器,正常情況下他負責定時向服務端傳送心跳,當連結的狀態變成 inActive 時,該檢查器將負責進行重連,如下所示:

public class HealthyChecker extends ChannelInboundHandlerAdapter {

    private static final int DEFAULT_PING_INTERVAL = 5;

    private Client client;

    private int pingInterval;

    public HealthyChecker(Client client, int pingInterval) {
        Assert.notNull(client, "client can not be null");
        this.client = client;
        this.pingInterval = pingInterval <= 0 ? DEFAULT_PING_INTERVAL : pingInterval;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        super.channelActive(ctx);
        schedulePing(ctx);
    }

    private void schedulePing(ChannelHandlerContext ctx) {
        ctx.executor().schedule(() -> {
            Channel channel = ctx.channel();
            if (channel.isActive()) {
                log.debug("[{}] Send a PingPacket", HealthyChecker.class.getSimpleName());
                channel.writeAndFlush(new PingPacket());
                schedulePing(ctx);
            }
        }, pingInterval, TimeUnit.SECONDS);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        ctx.executor().schedule(() -> {
            log.info("[{}] Try to reconnecting...", HealthyChecker.class.getSimpleName());
            client.connect();
        }, 5, TimeUnit.SECONDS);
        ctx.fireChannelInactive();
    }

}
複製程式碼

業務執行緒池

我們知道,Netty 中維護著兩個 IO 執行緒池,一個 boss 主要負責連結的建立,另外一個 worker 主要負責連結上的資料讀寫,我們不應該使用 IO 執行緒來處理我們的業務,因為這樣很可能會對 IO 執行緒造成阻塞,導致新連結無法及時建立或者資料無法及時讀寫。

為了解決這個問題,我們需要在業務執行緒池中來處理我們的業務邏輯,但是這並不是絕對的,如果我們要執行的邏輯很簡單,不會造成太大的阻塞,則可以直接在 IO 執行緒中處理,比如客戶端傳送一個 Ping 服務端回覆一個 Pong,這種情況是沒有必要在業務執行緒池中進行處理的,因為處理完了最終還是要交給 IO 執行緒去寫資料。但是如果一個業務邏輯需要查詢資料庫或者讀取檔案,這種操作往往比較耗時間,所以就需要將這些操作封裝起來交給業務執行緒池去處理。

服務端允許客戶端在傳輸的 Packet 中指定採用何種方式進行業務的處理,服務端在將位元組流解碼成 Packet 之後,會根據 Packet 中的 sync 欄位的值,確定怎樣對該 Packet 進行處理,如下所示:

public class ServerPacketDispatcher extends 
    SimpleChannelInboundHandler<Packet> {
    @Override
    public void channelRead0(ChannelHandlerContext ctx, Packet request) {
        // if the packet should be handled async
        if (request.getAsync() == AsyncHandle.ASYNC) {
            EventExecutor channelExecutor = ctx.executor();
            // create a promise
            Promise<Packet> promise = new DefaultPromise<>(channelExecutor);
            // async execute and get a future
            Future<Packet> future = executor.asyncExecute(promise, ctx, request);
            future.addListener(new GenericFutureListener<Future<Packet>>() {
                @Override
                public void operationComplete(Future<Packet> f) throws Exception {
                    if (f.isSuccess()) {
                        Packet response = f.get();
                        writeResponse(ctx, response);
                    }
                }
            });
        } else {
            // sync execute and get the response packet
            Packet response = executor.execute(ctx, request);
            writeResponse(ctx, response);
        }
    }
}
複製程式碼

不止是IM框架

bitchat 除了可以作為 IM 框架之外,還可以作為一個通用的通訊框架。

Packet 作為通訊的載體,通過繼承 AbstractPacket 即可快速實現自己的業務,搭配 PacketHandler 作為資料處理器即可實現客戶端與服務端的通訊。

相關文章