本文為該系列的第三篇文章,設計需求為:服務端程式和眾多客戶端程式通過 TCP 協議進行通訊,通訊雙方需通訊的訊息種類眾多。上一篇文章以一個具體的需求為例,探討了指定的 Java 訊息物件與其相應的二進位制資料幀相互轉換的方法。本文仍以該例項為例,探討該自定義通訊協議的具體工作流程,以及如何以註冊的形式靈活插拔通訊訊息物件。
1. 以註冊的形式實現通訊訊息物件的統一管理
通過該系列的第二篇文章可知,各個訊息物件的編解碼器類均擁有一個靜態工廠方法,用於手動傳入功能位及功能文字描述,進而生成包含這些引數的編解碼器。如此設計,使得所有訊息的功能位和文字描述均能夠統一管理,降低維護成本。
根據上述需求,可通過 Map 容器管理所有的編解碼器,有如下優點:
- 進行訊息物件生成操作時,可直接使用相應編解碼器的訊息物件靜態建立方法。
- 進行訊息物件的編碼操作時,已擁有該 Java 訊息物件,即可知道訊息物件的功能位,據此可獲取相應的編解碼器;或者,每個 Java 訊息物件均內含相應編解碼器的引用,故可直接對該訊息物件進行編碼操作。
- 進行二進位制資料幀的解碼操作時,資料幀中已包含了訊息的功能位,據此可獲取相應的編解碼器,而後可以對該資料幀進行解析,生成相應的 Java 訊息物件。
通訊訊息物件註冊方法如下所示:
/**
* 訊息物件的註冊
*
* @param toolkit 訊息物件編解碼器容器的工具類
*/
private void initialMsg() {
saveNormalMsgCodec(new MsgCodecDeviceUnlock(0x10, 0x11, "客戶端解鎖"));
saveNormalMsgCodec(new MsgCodecDeviceClear(0x10, 0x13, "客戶端初始化"));
saveNormalMsgCodec(new MsgCodecDeviceId(0x10, 0x1B, "客戶端ID設定"));
saveNormalMsgCodec(new MsgCodecEmployeeName(0x10, 0x1C, "客戶端別名設定"));
... ...
}
/**
* 將普通訊息物件及其回覆訊息物件的編解碼器均儲存到 HashMap 中
*
* @param baseMsgCodec 特定的訊息物件編解碼器
*/
private void saveNormalMsgCodec(BaseMsgCodec baseMsgCodec) {
saveSpecialMsgCodec(baseMsgCodec);
baseMsgCodec = new MsgCodecReplyNormal(baseMsgCodec.getMajorMsgId() + 0x10, baseMsgCodec.getSubMsgId(), baseMsgCodec.getDetail());
saveSpecialMsgCodec(baseMsgCodec);
}
/**
* 將訊息物件的編解碼器儲存到 HashMap 中
*
* @param baseMsgCodec 特定的編解碼器
*/
private void saveSpecialMsgCodec(BaseMsgCodec baseMsgCodec) {
HASH_MAP.put(figureFrameId(baseMsgCodec.getMajorMsgId(), baseMsgCodec.getSubMsgId()), baseMsgCodec);
}
複製程式碼
上述程式碼表明,如果有新的業務需求,需要增刪「插拔」業務訊息物件,只需在 initialMsg()
方法中,對相應編解碼器的註冊語句進行增刪即可。
saveNormalMsgCodec(BaseMsgCodec)
方法可以同時註冊特定業務訊息物件及其通用回覆訊息物件,操作方法清晰、簡潔。
所以,在啟動該 Java 程式時,只需要在啟動過程中,執行上述 initialMsg()
方法,即可完成所有業務訊息物件的註冊。
2. 多個訊息物件自由組合進同一個資料幀的實現原理
由該系列的第一篇文章可知,如果某二進位制資料幀所要傳輸的資料體部分內容很少,導致一個幀的大部分容量均被幀頭佔據,導致有效資料的佔比很小,這就產生了巨大的浪費,故資料幀的資料體部分由子幀組成,同一類子幀均可被組裝進同一個資料幀。如此做法,整個通訊鏈路的資料量會明顯減少,IO 負擔也會因此減輕。
該需求的實現原理如下所示:
/**
* 啟動一個Channel的定時任務,用於間隔指定的時間對訊息佇列進行輪詢,併傳送指定資料幀
*
* @param deque 指定的訊息傳送佇列
* @param channelId 指定 Channel 的序號
*/
private void startMessageQueueTask(LinkedBlockingDeque<BaseMsg> deque, Integer channelId) {
executorService.scheduleWithFixedDelay(() -> {
try {
BaseMsg baseMsg = deque.take(); // 從佇列中取出一個訊息物件,佇列為空時阻塞
Thread.sleep(AWAKE_TO_PROCESS_INTERVAL);// 等待極短的時間,保證佇列中快取儘可能多的物件
Channel channel = touchChannel(channelId); // 獲取指定的待傳送的 Channel
List<ByteBuf> dataList = new ArrayList<>();// 子幀容器
ByteBuf data = baseMsg.subFrameEncode(channel.alloc().buffer());// 編碼一個子幀
dataList.add(data);
touchNeedReplyMsg(baseMsg); // 對該子幀設定檢錯重發任務
int length = data.readableBytes();
int flag = baseMsg.combineFrameFlag(); // 獲取訊息物件標識
while (true) {
BaseMsg subMsg = deque.peek(); // 檢視佇列中的第一個訊息物件
if (subMsg == null || subMsg.combineFrameFlag() != flag) {
break; // 訊息物件標識不同,即欲生成的主幀幀頭不同,不能組合進同一主幀
}
data = subMsg.subFrameEncode(channel.alloc().buffer());
if (length + data.readableBytes() > FrameSetting.MAX_DATA_LENGTH) {
break;
}
length += data.readableBytes();
dataList.add(data); // 組合進了同一主幀
deque.poll(); // 從佇列中移除該訊息物件
touchNeedReplyMsg(subMsg);
}
FrameMajorHeader frameHeader = new FrameMajorHeader(
baseMsg.getMajorMsgId(),
baseMsg.getGroupId(),
baseMsg.getDeviceId(),
length); // 生成主幀幀頭訊息物件
channel.writeAndFlush(new SendableMsgContainer(frameHeader, dataList)); // 送入Channel進行傳送
} catch (InterruptedException e) {
logger.warn("訊息佇列定時傳送任務被中斷");
}
}, channelId, CommSetting.FRAME_SEND_INTERVAL, TimeUnit.MILLISECONDS);
}
複製程式碼
由程式碼可知,待傳送的訊息物件均被送入指定的傳送佇列進行快取,某客戶端相應的執行緒對佇列進行操作,取出訊息物件並進行編碼、組裝、傳送等。當然,當客戶端數量較多時,上述的執行緒實現方式可採用 Netty 的 NIO 方式進行優化,以降低系統開銷。
由上述描述可知,欲傳送一個訊息物件,只需將該訊息物件送入相應的傳送佇列即可。
3. 實際業務訊息物件的編解碼
3.1 訊息物件的編碼方式
由於每個 Java 訊息物件均內含相應編解碼器的引用,故可直接對該訊息物件進行編碼操作,程式碼如下:
public abstract class BaseMsg implements Cloneable {
private final BaseMsgCodec msgCodec;
... ...
/**
* 將 java 訊息物件編碼為 TCP 子幀
*
* @param buffer 空白的 TCP 子幀的容器
* @return 儲存有 TCP 子幀的容器
*/
public ByteBuf subFrameEncode(ByteBuf buffer) {
return msgCodec.code(this, buffer);
}
}
複製程式碼
3.2 訊息物件的解碼方式
首先根據資料幀的幀頭,即可解析出 FrameMajorHeader
物件,然後即可呼叫如下方法完成子幀的解析工作。實現原理文章開頭已指出。
/**
* TCP 幀解碼為 Java 訊息物件
*
* @param head 主幀頭
* @param subMsgId 子幀功能位
* @param data 子幀資料
* @return 已解碼的 Java 物件
*/
public BaseMsg decode(FrameMajorHeader head, int subMsgId, byte[] data) {
BaseMsgCodec msgCodec = MsgCodecToolkit.getMsgCodec(head.getMsgId(), subMsgId);
return msgCodec.decode(head.getGroupId(), head.getDeviceId(), data);
}
複製程式碼