前言
為了滿足自己業務場景的需要, 應用層之間通信需要實現各種各樣的網絡協議。本文記錄如何設計一個高效、可擴展、易維護的自定義通信協議,以及如何使用 Netty 實現自定義的通信協議。
一、通信協議設計
所謂的協議,就是通信雙方事先商量好的接口“暗語”, 在 TCP 網絡編程中,發送方和接收方的數據包格式都是二進制,
發送方將對象轉化成二進制流發送給接收方,接收方獲得二進制數據后需要知道如何解析對象,所以協議是雙方能夠正常通行的基礎。
通用協議
市面上已經有不小通用的協議,例如 HTTP、 HTTPS、JSON-RPC、FTP、IMAP、Protobuf等。通用協議兼容性好,易於維護,各種異構系統間可以實現無縫對接等。
如果滿足業務場景及性能需求的前提下,推薦采用通用協議的方案。
自定義協議
在特定的場景下,需要自定義自有協議。自定義協議有以下的優點:
- 極致性能:通用協議考慮很多兼容性的因素,必然在性能有所損失。
- 擴展性:自定義的協議相比通用協議更好擴展,可以更好地滿足自己的業務需求。
- 安全性:通用協議是公開的,可能存在很多漏洞。自定義協議通常是私有的,黑客需要先破解協議內容,才能攻破漏洞。
網絡協議需要具備的要素
一個較為通用的協議示例:
/* +---------------------------------------------------------------+ | 魔數 2byte | 協議版本號 1byte | 序列化算法 1byte | 報文類型 1byte | +---------------------------------------------------------------+ | 狀態 1byte | 保留字段 4byte | 數據長度 4byte | +---------------------------------------------------------------+ | 數據內容 (長度不定) | 校驗字段 2byte | +---------------------------------------------------------------+ */
- 1. 魔數
魔數是通信雙方協商的一個暗號,通常采用固定的幾個字節表示。魔數的作用是用於服務端在接收數據時先解析出前幾個固定字節做正確性對比。
如果和協議中的魔數不匹配,則認為是非法數據,可以直接關閉連接或采取其他措施增強系統安全性。
魔數的思想在很多場景中都有體現,如 Java Class 文件開頭就存儲了魔數 OxCAFEBABE,在 JVM 加載 Class 文件時首先就會驗證魔數對的正確性。
- 2. 協議版本號
為了應對業務需求的變化,可能需要對自定義協議的結構或字段進行改動。不同版本的協議對應的解析方法也是不同的。所以在生產級項目中強烈建議預留協議版本這個字段。
-
3. 序列化算法
序列化算法字段表示發送方將對象轉換成二進制流,以及接收方將接收的二進制流轉換成對象的方法,如 JSON、 Hessian、Java 自帶序列化等。
-
4. 報文類型
報文類型用於描述業務場景中存在的不同報文類型。如 RPC 框架中有請求、響應、心跳類型。IM 通訊場景中有登陸、創建群聊、發送消息、接收消息、退出群聊等類型。
-
5. 長度域字段
長度域字段代表請求數據的長度,可以定義整個報文的長度,也可以是請求數據部分的長度。
-
6. 請求數據
請求數據通常為的業務對象信息序列化后的二進制流。是整個報文的主體。
-
7. 狀態
狀態字段用於標識請求是否正常,一般由被調用方設置。例如一次 RPC 調用失敗,狀態字段可被服務提供方設置為異常狀態。
- 8. 校驗字段
校驗字段存放某種校驗算法計算報文校驗碼,校驗碼用於驗證報文的正確性。
- 9. 保留字段
保留字段是可選項,為了應對協議升級的可能性,可以預留若干字節的保留字段,以備不時之需。
二、Netty 實現自定義通信協議
Netty 作為一個非常優秀的網絡通信框架,提供了非常豐富的編解碼抽象基類來實現自定義協議。
Netty 中編解碼器分類
編碼解碼分類:
分層解碼分類:
一次解碼:一次解碼用於解決 TCP 拆包/粘包問題,按協議解析得到的字節數據。常用一次編解碼器:MessageToByteEncoder / ByteToMessageDecoder。
二次解碼:對一次解析后的字節數據做對象模型的轉換,這時候需要二次解碼器,同理編碼器的過程是反過來的。常用二次編解碼器:MessageToMessageEncoder / MessageToMessageDecoder。
抽象編碼類
通過抽象編碼類的繼承圖可以看出,編碼類是 ChanneOutboundHandler 的抽象類實現,具體操作的是 Outbound 出站數據。
抽象解碼類
解碼類是 ChanneInboundHandler 的抽象類實現,操作的是 Inbound 入站數據。解碼器的主要難度在於拆包和粘包問題,
由於接收方可能沒有接受到完整的消息,所以編碼框架還要對入站數據做緩沖處理,直到獲取到完整的消息。
三、通信協議實戰
/* +---------------------------------------------------------------+ | 魔數 2byte | 協議版本號 1byte | 序列化算法 1byte | 報文類型 1byte | +---------------------------------------------------------------+ | 狀態 1byte | 保留字段 4byte | 數據長度 4byte | +---------------------------------------------------------------+ | 數據內容 (長度不定) | +---------------------------------------------------------------+ */
對以上的自定義報文,協議頭部包含了魔數、協議版本號、數據長度等固定字段。而 ByteBuf 是否完整,需要通過消息長度 dataLength 字段來判斷。
自定義編碼器需要重寫 ByteToMessageDecoder 的 encode 方法,具體代碼如下所示:
@Override public final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // 判斷 ByteBuf 可讀取字節 if (in.readableBytes() < 14) { return; } in.markReaderIndex(); // 標記 ByteBuf 讀指針位置 in.skipBytes(2); // 跳過魔數 in.skipBytes(1); // 跳過協議版本號 byte serializeType = in.readByte(); in.skipBytes(1); // 跳過報文類型 in.skipBytes(1); // 跳過狀態字段 in.skipBytes(4); // 跳過保留字段 int dataLength = in.readInt(); if (in.readableBytes() < dataLength) { in.resetReaderIndex(); // 重置 ByteBuf 讀指針位置 return; } byte[] data = new byte[dataLength]; in.readBytes(data); SerializeService serializeService = getSerializeServiceByType(serializeType); Object obj = serializeService.deserialize(data); if (obj != null) { out.add(obj); } }
引用:
- https://blog.csdn.net/weixin_39340061/article/details/109611936
- https://blog.csdn.net/qq276726581/article/details/55522144
- https://blog.csdn.net/zbw18297786698/article/details/53691915
- http://zhongm.in/2019/09/25/To-Solve-Two-Problem-In-Using-ByteToMessageDecoder/
- https://vimsky.com/examples/detail/java-method-io.netty.buffer.ByteBuf.markReaderIndex.html