bitchat 是一個基於 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();
}
}
複製程式碼
服務端啟動成功後,將顯示如下資訊:
啟動客戶端
目前只實現了直連伺服器的客戶端,通過 SimpleClientFactory 只需要指定一個 ServerAttr 即可獲取一個客戶端,然後進行客戶端與服務端的連線,如下所示:
public class DirectConnectServerClientApplication {
public static void main(String[] args) {
Client client = SimpleClientFactory.getInstance()
.newClient(ServerAttr.getLocalServer(8864));
client.connect();
doClientBiz(client);
}
}
複製程式碼
客戶端連線上服務端後,將顯示如下資訊:
體驗客戶端的功能
目前客戶端提供了三種 Func,分別是:登入,檢視線上使用者列表,傳送單聊訊息,每種 Func 有不同的命令格式。
登入
通過在客戶端中執行以下命令 -lo houyi 123456
即可實現登入,目前使用者中心還未實現,通過 Mock 的方式實現一個假的使用者服務,所以輸入任何的使用者名稱密碼都會登入成功,並且會為使用者建立一個使用者id。
登入成功後,顯示如下:
檢視線上使用者
再啟動一個客戶端,並且也執行登入,登入成功後,可以執行 -lu
命令,獲取線上使用者列表,目前使用者是儲存在記憶體中,獲取的結果如下所示:
傳送單聊資訊
用 gris 這個使用者向 houyi 這個使用者傳送單聊資訊,只要執行 -pc 1 hello,houyi
命令即可
其中第二個引數數要傳送訊息給那個使用者的使用者id,第三個引數是訊息內容
訊息傳送方,傳送完訊息:
訊息接收方,接收到訊息:
客戶端斷線重連
客戶端和服務端之間維持著心跳,雙方都會檢查連線是否可用,客戶端每隔5s會向服務端傳送一個 PingPacket,而服務端接收到這個 PingPacket 之後,會回覆一個 PongPacket,這樣表示雙方都是健康的。
當因為某種原因,服務端沒有收到客戶端傳送的訊息,服務端將會把該客戶端的連線斷開,同樣的客戶端也會做這樣的檢查。
當客戶端與服務端之間的連線斷開之後,將會觸發客戶端 HealthyChecker 的 channelInactive 方法,從而進行客戶端的斷線重連。
整體架構
單機版
單機版的架構只涉及到服務端、客戶端,另外有兩者之間的協議層,如下圖所示:
除了服務端和客戶端之外,還有三大中心:訊息中心,使用者中心,連結中心。
-
訊息中心:主要負責訊息的儲存與歷史、離線訊息的查詢
-
使用者中心:主要負責使用者和群組相關的服務
-
連結中心:主要負責儲存客戶端的連結,服務端從連結中心獲取客戶端的連結,向其推送訊息
叢集版
單機版無法做到高可用,效能與可服務的使用者數也有一定的限制,所以需要有可擴充套件的叢集版,叢集版在單機版的基礎上增加了一個路由層,客戶端通過路由層來獲得可用的服務端地址,然後與服務端進行通訊,如下圖所示:
客戶端傳送訊息給另一個使用者,服務端接收到這個請求後,從 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 作為資料處理器即可實現客戶端與服務端的通訊。