開發(fā)工程中,有一個(gè)常見的需求:服務(wù)端程序和多個(gè)客戶端程序通過 TCP 協(xié)議進(jìn)行通信,通信雙方需通信的消息種類眾多,并且客戶端的數(shù)量可能有數(shù)萬個(gè)。為此,雙方需要約定盡可能豐富、靈活的數(shù)據(jù)幀「數(shù)據(jù)包」協(xié)議,方便后續(xù)業(yè)務(wù)功能的設(shè)計(jì)。
本文設(shè)計(jì)了一種通信協(xié)議,為壓縮數(shù)據(jù)量,該協(xié)議的數(shù)據(jù)幀以二進(jìn)制方式進(jìn)行傳輸并識(shí)別,即其基本單位為字節(jié),必要時(shí)將部分字節(jié)流手動(dòng)轉(zhuǎn)化為可讀文本。通過設(shè)定功能位來實(shí)現(xiàn)豐富的通信消息類型,并且采用注冊(cè)的方式,可方便擴(kuò)展新的業(yè)務(wù)消息類型,可靈活地增刪通信消息對(duì)象。采用 Netty 框架保證高并發(fā)場(chǎng)景下程序的性能。
系統(tǒng)整體設(shè)計(jì)框圖如下:

1. 通信數(shù)據(jù)幀協(xié)議的設(shè)計(jì)
1.1 數(shù)據(jù)幀主幀的幀格式
首先給出通用的數(shù)據(jù)幀格式如下,一個(gè)數(shù)據(jù)幀主幀由:幀識(shí)別位、幀功能位、設(shè)備號(hào)、數(shù)據(jù)長度、數(shù)據(jù)體等 5 部分組成?!钙鋵?shí)最通用的數(shù)據(jù)幀只有幀識(shí)別位,根據(jù)幀識(shí)別位確定幀類型,從而確定其余四個(gè)部分,本文中幀識(shí)別位固定,幀格式即固定了」

- 幀識(shí)別位:確定數(shù)據(jù)幀的開始,亦確定本幀的幀類型。
- 幀功能位:確定該幀所傳送的消息類型,特定的幀功能位對(duì)應(yīng)特定的數(shù)據(jù)體。
- 設(shè)備號(hào):設(shè)備的識(shí)別號(hào),服務(wù)端據(jù)此識(shí)別不同的客戶端。
- 數(shù)據(jù)長度:數(shù)據(jù)體所占用的字節(jié)數(shù)。
- 數(shù)據(jù)體:根據(jù)幀功能位,所確定的需傳輸?shù)木唧w的消息。
1.2 數(shù)據(jù)幀子幀的幀格式
數(shù)據(jù)幀除數(shù)據(jù)體以外的部分稱為幀頭,考慮這樣一種需求,如果某幀所要傳輸?shù)臄?shù)據(jù)體部分內(nèi)容很少,導(dǎo)致一個(gè)幀的大部分容量均被幀頭占據(jù),導(dǎo)致有效數(shù)據(jù)的占比很小,這就產(chǎn)生了巨大的浪費(fèi),舉例如下:
- 如一個(gè)開鎖幀,只需傳輸一個(gè)開鎖信號(hào)即可,消息的接收方、消息類型均體現(xiàn)在了幀頭中,數(shù)據(jù)部分只需要 0 個(gè)或 1 個(gè)字節(jié)即可。
- 客戶端需要向服務(wù)器發(fā)送自己的當(dāng)前狀態(tài)信息,該狀態(tài)信息可能也只需要 1 個(gè)字節(jié)左右。
由于如上實(shí)際的需求,如果增大了每一幀的有效數(shù)據(jù)的占比,整個(gè)通信鏈路的數(shù)據(jù)量會(huì)明顯減少,IO 負(fù)擔(dān)也會(huì)因此減輕,所以據(jù)此繼續(xù)對(duì)幀協(xié)議進(jìn)行設(shè)計(jì)。

如上圖,對(duì)數(shù)據(jù)幀主幀中的「數(shù)據(jù)體」部分進(jìn)行進(jìn)一步拆分,數(shù)據(jù)幀主幀的數(shù)據(jù)體部分由子幀組成,子幀由:子幀功能位、數(shù)據(jù)長度、數(shù)據(jù)體等 3 部分組成。
- 子幀功能位:確定該子幀所傳送的消息類型,總而言之,主幀、子幀功能位共同確定了該子幀的消息類型。
- 數(shù)據(jù)長度:數(shù)據(jù)體所占用的字節(jié)數(shù)。
- 數(shù)據(jù)體:根據(jù)子幀功能位,所確定的需傳輸?shù)木唧w的消息。
1.3 數(shù)據(jù)幀的幀格式總覽
完整的幀格式如下圖所示,數(shù)據(jù)幀主幀的數(shù)據(jù)體部分完全由子幀組成,通信雙方通信時(shí),可以往一個(gè)主幀中添加多個(gè)子幀,從而可以極大提高鏈路的使用效率。

2 數(shù)據(jù)幀處理模塊的實(shí)現(xiàn)
數(shù)據(jù)幀已進(jìn)行了如上精心設(shè)計(jì),將設(shè)計(jì)的數(shù)據(jù)幀通過程序?qū)崿F(xiàn)并投入實(shí)際使用才是最終目的。
2.1 數(shù)據(jù)幀處理的基本方法
以服務(wù)端的工作為例來進(jìn)行說明。服務(wù)端程序監(jiān)聽指定端口,客戶端通過 TCP 協(xié)議向服務(wù)器發(fā)送二進(jìn)制數(shù)據(jù)消息,服務(wù)端接收到二進(jìn)制數(shù)據(jù)并進(jìn)行處理,此處采用責(zé)任鏈模式,Netty 框架內(nèi)建了方便的基于責(zé)任鏈模式的消息處理方法:
- 第一個(gè)處理器將捕獲的數(shù)據(jù)截取為一個(gè)一個(gè)協(xié)議約定的數(shù)據(jù)幀并送入下層處理器,如果捕獲的二進(jìn)制數(shù)據(jù)未符合協(xié)議約定的格式,則可以直接丟棄?!复颂幬纯紤]半包、粘包等場(chǎng)景」
- 第二個(gè)處理器捕獲到約定的數(shù)據(jù)幀,則著手對(duì)不同類型數(shù)據(jù)幀進(jìn)行解析,解析為不同類型的 Java 消息對(duì)象,并將反序列化成功并驗(yàn)證成功的 Java 對(duì)象送入下層處理器。如果上述過程失敗,可以認(rèn)為客戶端設(shè)計(jì)不合理,導(dǎo)致出現(xiàn)無效消息,直接丟棄該對(duì)象,也可以繼續(xù)通知服務(wù)端或客戶端該異常情況。
- 第三個(gè)處理器捕獲到正確的 Java 消息對(duì)象,則可以直接送入上層 Java 模塊進(jìn)行處理,此處可根據(jù)不同的對(duì)象類型送入不同的上層處理模塊,或者在此處進(jìn)行其他的工作「比如消息日志記錄工作等」。
2.2 基本 Java 消息對(duì)象的設(shè)計(jì)
Java 消息對(duì)象的設(shè)計(jì)主要由兩部分組成:
- 特定數(shù)據(jù)幀對(duì)應(yīng)的特定 Java 消息對(duì)象。
- 特定 Java 消息對(duì)象對(duì)應(yīng)的特定的該消息對(duì)象編解碼器。
以下是基本 Java 消息對(duì)象:
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;
}
/**
* 獲取該消息對(duì)象的細(xì)節(jié)描述
*
* @return 該消息對(duì)象的細(xì)節(jié)描述
*/
public String msgDetailToString() {
return msgCodec.getDetail() +
"[majorMsgId=" + Integer.toHexString(msgCodec.getMajorMsgId()).toUpperCase() +
", subMsgId=" + Integer.toHexString(msgCodec.getSubMsgId()).toUpperCase() +
", groupId=" + groupId +
", deviceId=" + deviceId + ']';
}
/**
* 重發(fā)該消息對(duì)象的記錄信息更新
*/
public void doResend() {
resendTimes++;
}
}
由上述代碼可知,每個(gè)消息對(duì)象均包含該對(duì)象對(duì)應(yīng)編解碼器的引用,方便獲取該消息對(duì)象的擴(kuò)展信息,或者方便將該消息對(duì)象重新序列化為數(shù)據(jù)幀。該類包含上節(jié)數(shù)據(jù)幀主幀及子幀的所有公共信息,僅僅未包含子幀中的數(shù)據(jù)體信息,該需求由基本 Java 消息對(duì)象的子類實(shí)現(xiàn)。
該類由 abstract 修飾,是抽象類,無法直接實(shí)例化,具體的工作由該類的子類完成,即由具體的真正業(yè)務(wù)相關(guān)的 Java 消息對(duì)象完成。
以下為 Java 消息對(duì)象的基本編解碼器:
/**
* 單個(gè)消息對(duì)象「幀」的編解碼器
*/
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 消息對(duì)象的編解碼器由數(shù)據(jù)幀的主幀、子幀功能位共同決定,這樣確保了消息編解碼器的規(guī)范,避免消息過多時(shí)的混亂。
Java 編解碼器實(shí)現(xiàn)了如下兩個(gè)接口,表明編解碼器可將 Java 消息對(duì)象編碼為數(shù)據(jù)幀,或?qū)?shù)據(jù)幀解碼為指定的 Java 消息對(duì)象:
public interface SubFramecoder {
/**
* 將 Java 消息對(duì)象編碼為數(shù)據(jù)幀
*
* @param msg 消息對(duì)象
* @param buffer TCP 數(shù)據(jù)幀的容器
* @return 生成的 TCP 數(shù)據(jù)幀的 ByteBuf
*/
ByteBuf code(BaseMsg msg, ByteBuf buffer);
}
public interface SubFramedecoder {
/**
* 將數(shù)據(jù)幀解碼為指定的 Java 消息對(duì)象
*
* @param groupId 設(shè)備組 ID
* @param deviceId 設(shè)備 ID
* @param data 幀數(shù)據(jù)
* @return 特定的 Java 消息對(duì)象
*/
BaseMsg decode(int groupId, int deviceId, byte[] data);
}