開發工程中,有一個常見的需求:服務端程式和多個客戶端程式通過 TCP 協議進行通訊,通訊雙方需通訊的訊息種類眾多,並且客戶端的數量可能有數萬個。為此,雙方需要約定儘可能豐富、靈活的資料幀「資料包」協議,方便後續業務功能的設計。
本文設計了一種通訊協議,為壓縮資料量,該協議的資料幀以二進位制方式進行傳輸並識別,即其基本單位為位元組,必要時將部分位元組流手動轉化為可讀文字。通過設定功能位來實現豐富的通訊訊息型別,並且採用註冊的方式,可方便擴充套件新的業務訊息型別,可靈活地增刪通訊訊息物件。採用 Netty 框架保證高併發場景下程式的效能。
系統整體設計框圖如下:
1. 通訊資料幀協議的設計
1.1 資料幀主幀的幀格式
首先給出通用的資料幀格式如下,一個資料幀主幀由:幀識別位、幀功能位、裝置號、資料長度、資料體等 5 部分組成。「其實最通用的資料幀只有幀識別位,根據幀識別位確定幀型別,從而確定其餘四個部分,本文中幀識別位固定,幀格式即固定了」
- 幀識別位:確定資料幀的開始,亦確定本幀的幀型別。
- 幀功能位:確定該幀所傳送的訊息型別,特定的幀功能位對應特定的資料體。
- 裝置號:裝置的識別號,服務端據此識別不同的客戶端。
- 資料長度:資料體所佔用的位元組數。
- 資料體:根據幀功能位,所確定的需傳輸的具體的訊息。
1.2 資料幀子幀的幀格式
資料幀除資料體以外的部分稱為幀頭,考慮這樣一種需求,如果某幀所要傳輸的資料體部分內容很少,導致一個幀的大部分容量均被幀頭佔據,導致有效資料的佔比很小,這就產生了巨大的浪費,舉例如下:
- 如一個開鎖幀,只需傳輸一個開鎖訊號即可,訊息的接收方、訊息型別均體現在了幀頭中,資料部分只需要 0 個或 1 個位元組即可。
- 客戶端需要向伺服器傳送自己的當前狀態資訊,該狀態資訊可能也只需要 1 個位元組左右。
由於如上實際的需求,如果增大了每一幀的有效資料的佔比,整個通訊鏈路的資料量會明顯減少,IO 負擔也會因此減輕,所以據此繼續對幀協議進行設計。
如上圖,對資料幀主幀中的「資料體」部分進行進一步拆分,資料幀主幀的資料體部分由子幀組成,子幀由:子幀功能位、資料長度、資料體等 3 部分組成。
- 子幀功能位:確定該子幀所傳送的訊息型別,總而言之,主幀、子幀功能位共同確定了該子幀的訊息型別。
- 資料長度:資料體所佔用的位元組數。
- 資料體:根據子幀功能位,所確定的需傳輸的具體的訊息。
1.3 資料幀的幀格式總覽
完整的幀格式如下圖所示,資料幀主幀的資料體部分完全由子幀組成,通訊雙方通訊時,可以往一個主幀中新增多個子幀,從而可以極大提高鏈路的使用效率。
2 資料幀處理模組的實現
資料幀已進行了如上精心設計,將設計的資料幀通過程式實現並投入實際使用才是最終目的。
2.1 資料幀處理的基本方法
以服務端的工作為例來進行說明。服務端程式監聽指定埠,客戶端通過 TCP 協議向伺服器傳送二進位制資料訊息,服務端接收到二進位制資料並進行處理,此處採用責任鏈模式,Netty 框架內建了方便的基於責任鏈模式的訊息處理方法:
- 第一個處理器將捕獲的資料擷取為一個一個協議約定的資料幀並送入下層處理器,如果捕獲的二進位制資料未符合協議約定的格式,則可以直接丟棄。「此處未考慮半包、粘包等場景」
- 第二個處理器捕獲到約定的資料幀,則著手對不同型別資料幀進行解析,解析為不同型別的 Java 訊息物件,並將反序列化成功並驗證成功的 Java 物件送入下層處理器。如果上述過程失敗,可以認為客戶端設計不合理,導致出現無效訊息,直接丟棄該物件,也可以繼續通知服務端或客戶端該異常情況。
- 第三個處理器捕獲到正確的 Java 訊息物件,則可以直接送入上層 Java 模組進行處理,此處可根據不同的物件型別送入不同的上層處理模組,或者在此處進行其他的工作「比如訊息日誌記錄工作等」。
2.2 基本 Java 訊息物件的設計
Java 訊息物件的設計主要由兩部分組成:
- 特定資料幀對應的特定 Java 訊息物件。
- 特定 Java 訊息物件對應的特定的該訊息物件編解碼器。
以下是基本 Java 訊息物件:
public abstract class BaseMsg implements Cloneable {
private final BaseMsgCodec msgCodec;
private int groupId;
private int deviceId;
private int resendTimes = 0;
protected BaseMsg(BaseMsgCodec msgCodec, int groupId, int deviceId) {
this.msgCodec = msgCodec;
this.groupId = groupId;
this.deviceId = deviceId;
}
/**
* 獲取該訊息物件的細節描述
*
* @return 該訊息物件的細節描述
*/
public String msgDetailToString() {
return msgCodec.getDetail() +
"[majorMsgId=" + Integer.toHexString(msgCodec.getMajorMsgId()).toUpperCase() +
", subMsgId=" + Integer.toHexString(msgCodec.getSubMsgId()).toUpperCase() +
", groupId=" + groupId +
", deviceId=" + deviceId + ']';
}
/**
* 重發該訊息物件的記錄資訊更新
*/
public void doResend() {
resendTimes++;
}
}
複製程式碼
由上述程式碼可知,每個訊息物件均包含該物件對應編解碼器的引用,方便獲取該訊息物件的擴充套件資訊,或者方便將該訊息物件重新序列化為資料幀。該類包含上節資料幀主幀及子幀的所有公共資訊,僅僅未包含子幀中的資料體資訊,該需求由基本 Java 訊息物件的子類實現。
該類由 abstract
修飾,是抽象類,無法直接例項化,具體的工作由該類的子類完成,即由具體的真正業務相關的 Java 訊息物件完成。
以下為 Java 訊息物件的基本編解碼器:
/**
* 單個訊息物件「幀」的編解碼器
*/
public abstract class BaseMsgCodec implements SubFramecoder, SubFramedecoder {
private final int majorMsgId;
private final int subMsgId;
private final String detail;
protected BaseMsgCodec(int majorMsgId, int subMsgId, String detail) {
this.majorMsgId = majorMsgId;
this.subMsgId = subMsgId;
this.detail = detail;
}
public String getDetail() {
return detail;
}
public int getMajorMsgId() {
return majorMsgId;
}
public int getSubMsgId() {
return subMsgId;
}
}
複製程式碼
由上述程式碼可知,特定 Java 訊息物件的編解碼器由資料幀的主幀、子幀功能位共同決定,這樣確保了訊息編解碼器的規範,避免訊息過多時的混亂。
Java 編解碼器實現瞭如下兩個介面,表明編解碼器可將 Java 訊息物件編碼為資料幀,或將資料幀解碼為指定的 Java 訊息物件:
public interface SubFramecoder {
/**
* 將 Java 訊息物件編碼為資料幀
*
* @param msg 訊息物件
* @param buffer TCP 資料幀的容器
* @return 生成的 TCP 資料幀的 ByteBuf
*/
ByteBuf code(BaseMsg msg, ByteBuf buffer);
}
public interface SubFramedecoder {
/**
* 將資料幀解碼為指定的 Java 訊息物件
*
* @param groupId 裝置組 ID
* @param deviceId 裝置 ID
* @param data 幀資料
* @return 特定的 Java 訊息物件
*/
BaseMsg decode(int groupId, int deviceId, byte[] data);
}
複製程式碼