當 RPC 框架使用 Netty 通訊時,實際上是將資料轉化成 ByteBuf 的方式進行傳輸。
那如何轉化呢?可不可以把 請求引數 或者 響應結果 直接無腦序列化成 byte 陣列發出去?
答:直接序列化傳輸是不行的,會出現粘包拆包的問題。
粘包拆包
什麼是粘包拆包
RPC 通訊使用 TPC (別問我為什麼不用 UDP),TCP 是一個“流”協議。所謂流,就是沒有界限的一長串二進位制資料。TCP 作為傳輸層協議,並不瞭解上層業務資料的具體含義,它會根據 TCP 緩衝區的實際情況進行資料包的劃分,所以在業務上認為是一個完整包的,可能會被 TCP 拆分成多個包進行傳送,也有可能把多個小的包封裝成一個大的資料包傳送,這就是所謂的 TCP 拆包和粘包問題。
直接序列化發出去是可以,但是接收方收到了一坨資料包,它不知道一個完整的報文哪裡開始、哪裡結束,也就沒有辦法解析了。
粘包拆包的解決方案
由於底層的 TCP 無法理解上層的業務資料,所以在底層是無法保證資料包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決。目前業界主流協議的解決方案如下:
- 訊息定長:報文長度固定,例如每個報文的長度固定為 200 位元組,如果不夠空位補空格,接受方每次拿 200 位元組。
- 使用特殊分隔符分割:例如每條報文結束都新增回車換行符作為報文分隔符,接收方讀到回車換行符則分割出報文。
- 將訊息分為訊息頭和訊息體,訊息頭包含訊息的長度。接收方從訊息頭拿到訊息長度,就知道剩下的報文是多少位元組了。
- 更復雜的自定義應用層協議。
編解碼
在網路通訊中,將資料轉成報文的過程稱為 編碼,將報文轉成資料的過程稱為 解碼。
在 Netty 中,編解碼的處理放在 PipeLine 中。在前文的介紹中,我們知道每個 PipeLine 都是和 Channel 唯一繫結的,一個 PipeLine 只對應一個 Channel,所以 Channel 中的資料讀取的時候經過解析,如果不是一個完整的資料包,則解析失敗,將這個資料包進行儲存,等下次解析時再和這個資料包進行組裝解析,直到解析到完整的資料包,才會將資料包向下傳遞。
解碼器
Netty提供了多個解碼器,分別是:
LineBasedFrameDecoder
:按行分包。DelimiterBasedFrameDecoder
:特殊分隔符分包。FixedLengthFrameDecoder
:使用定長的報文來分包。LengthFieldBasedFrameDecoder
: 將訊息分為訊息頭和訊息體,訊息頭包含訊息的長度的方式分包。
在 RPC 這個場景中,我們來分析一下我們應該選哪種解碼器:
LineBasedFrameDecoder
:按行分包顯然不行,因為我們的請求響應資料中,極有可能包含換行符。DelimiterBasedFrameDecoder
:按照特殊分隔符也不行,因為 RPC 框架是一個通用的場景,請求響應資料中什麼都有可能包含,特殊分隔符無論是什麼都有可能存在於請求響應資料中。這樣會導致分包錯誤。FixedLengthFrameDecoder
:使用定長報文顯然就更加不合適了,在 RPC 框架這樣一個通用場景中,定的長度太短,可能不夠,定得太長又會造成極大的資源浪費。LengthFieldBasedFrameDecoder
:將訊息分成訊息頭訊息體的方式比較使用於大部分的網路通訊場景。ccx-rpc
採用了此解碼器,並定義出自己的一套私有協議(下面講)。
編碼器
Netty 提供了個常用的抽象編碼器:MessageToByteEncoder<I>
,編碼器不像解碼器需要考慮粘包拆包,只需要將資料轉換成協議規定的二進位制格式傳送即可。
ccx-rpc 的自定義協議
前面提到 ccx-rpc
使用了訊息頭+訊息體 的方式制定私有協議。其格式如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
+---+---+-------+---+---+---+---+-----------+---------+--------+---+---+---+---+---+---+---+---+
| magic |version| full length |messageType|serialize|compress| RequestId |
+---+---+-------+---+---+---+---+-----------+---------+--------+---+---+---+---+---+---+---+---+
| |
| body |
| |
| ... ... |
+----------------------------------------------------------------------------------------------+
2B magic(魔數)
1B version(版本)
4B full length(訊息長度)
1B messageType(訊息型別)
1B serialize(序列化型別)
1B compress(壓縮型別)
8B requestId(請求的Id)
body(object型別資料)
欄位解釋
1. magic(魔數)
是通訊雙方協商的一個暗號,2 個位元組,定義在 MessageFormatConst.MAGIC
。
魔數的作用是用於服務端在接收資料時先解析出魔數做正確性對比。如果和協議中的魔數不匹配,則認為是非法資料,可以直接關閉連線或採取其他措施增強系統安全性。
注意:這只是一個簡單的校驗,如果有安全性方面的需求,需要使用其他手段,例如 SSL/TLS。
魔數的思想在很多場景中都有體現,如 Java Class 檔案開頭就儲存了魔數 OxCAFEBABE,在 JVM 載入 Class 檔案時首先就會驗證魔數對的正確性。
2. version(版本)
為了應對業務需求的變化,可能需要對自定義協議的結構或欄位進行改動。不同版本的協議對應的解析方法也是不同的。所以在生產級專案中強烈建議預留協議版本這個欄位。
3. full length(訊息長度)
記錄了整個訊息的長度,這個欄位是報文分包的關鍵。
4. messageType(訊息型別)
訊息型別包括,普通請求、普通響應、心跳。解碼器可以根據訊息型別來確定解析的型別。
訊息型別的定義如下:
public enum MessageType {
/**
* 普通請求
*/
REQUEST((byte) 1),
/**
* 普通響應
*/
RESPONSE((byte) 2),
/**
* 心跳
*/
HEARTBEAT((byte) 3),
;
private final byte value;
}
6. serialize(序列化型別)
通過這個型別來確定使用哪種序列化方式,將位元組流序列化成對應的物件。
序列化型別定義如下:
public enum SerializeType {
PROTOSTUFF((byte) 1, "protostuff");
}
7. compress(壓縮型別)
序列化的位元組流,還可以進行壓縮,使得體積更小,在網路傳輸更快,但是同時會消耗 CPU 資源。
如果使用壓縮效果好的序列化器,可以考慮不適用壓縮。
壓縮型別的定義如下:
public enum CompressType {
/**
* 偽壓縮器,啥事不幹。有一些序列化工具壓縮已經做得很好了,無需再壓縮
*/
DUMMY((byte) 0, "dummy"),
GZIP((byte) 1, "gzip");
private final byte value;
private final String name;
}
8. requestId(請求的Id)
每個請求分配好請求Id,這樣響應資料的時候,才能對的上。使用 8 位元組的 long 型別,可以支援更多的請求。
9. body
body 裡面放具體的資料,通常來說是請求的引數、響應的結果,再經過序列化、壓縮後的位元組陣列。
ccx-rpc 的編碼器 RpcMessageEncoder
RpcMessage
是通用的訊息結構體,請求引數和響應結果都會封裝成這個結構。
編碼器相對比較簡單,按照協議定義的長度和值進行設定,例如請求Id是8位元組的Long,那就 out.writeLong(rpcMessage.getRequestId())
。
有個細節:訊息長度事先不知道 body 的長度,可以先跳過。當然也可以先把 body 解析出來算長度。
程式碼如下:
@Override
protected void encode(ChannelHandlerContext ctx, RpcMessage rpcMessage, ByteBuf out) {
// 2B magic code(魔數)
out.writeBytes(MessageFormatConst.MAGIC);
// 1B version(版本)
out.writeByte(MessageFormatConst.VERSION);
// 4B full length(訊息長度). 總長度先空著,後面填。
out.writerIndex(out.writerIndex() + MessageFormatConst.FULL_LENGTH_LENGTH);
// 1B messageType(訊息型別)
out.writeByte(rpcMessage.getMessageType());
// 1B codec(序列化型別)
out.writeByte(rpcMessage.getSerializeType());
// 1B compress(壓縮型別)
out.writeByte(rpcMessage.getCompressTye());
// 8B requestId(請求的Id)
out.writeLong(rpcMessage.getRequestId());
// 寫 body,返回 body 長度
int bodyLength = writeBody(rpcMessage, out);
// 當前寫指標
int writerIndex = out.writerIndex();
out.writerIndex(MessageFormatConst.MAGIC_LENGTH + MessageFormatConst.VERSION_LENGTH);
// 4B full length(訊息長度)
out.writeInt(MessageFormatConst.HEADER_LENGTH + bodyLength);
// 寫指標復原
out.writerIndex(writerIndex);
}
寫 body 的方法抽了出來,因為涉及到了訊息型別、序列化、壓縮等步驟,比較長。程式碼如下:
private int writeBody(RpcMessage rpcMessage, ByteBuf out) {
byte messageType = rpcMessage.getMessageType();
// 如果是心跳型別的,沒有 body,直接返回頭部長度
if (messageType == MessageType.HEARTBEAT.getValue()) {
return 0;
}
// 序列化型別
SerializeType serializeType = SerializeType.fromValue(rpcMessage.getSerializeType());
if (serializeType == null) {
throw new IllegalArgumentException("codec type not found");
}
// 根據序列化型別獲得序列化器
Serializer serializer = ExtensionLoader.getLoader(Serializer.class).getExtension(serializeType.getName());
// 壓縮型別
CompressType compressType = CompressType.fromValue(rpcMessage.getCompressTye());
// 根據壓縮型別獲得壓縮器
Compressor compressor = ExtensionLoader.getLoader(Compressor.class).getExtension(compressType.getName());
// 使用序列化器對資料進行序列化
byte[] notCompressBytes = serializer.serialize(rpcMessage.getData());
// 序列化完之後進行壓縮
byte[] compressedBytes = compressor.compress(notCompressBytes);
// 寫 body
out.writeBytes(compressedBytes);
return compressedBytes.length;
}
從上面的程式碼和註釋可以看出,寫 body 的流程如下:
- 判斷訊息型別,如果是心跳的,則不用寫 body
- 根據序列化型別獲得序列化器
- 根據壓縮型別獲得壓縮器
- 使用序列化器對資料進行序列化
- 序列化完的資料再進行壓縮。如果獲取不到壓縮器,則不壓縮,這裡抽象成一個偽序列化器
DummyCompressor
,少點特殊化程式碼。
public class DummyCompressor implements Compressor {
@Override
public byte[] compress(byte[] bytes) {
return bytes;
}
@Override
public byte[] decompress(byte[] bytes) {
return bytes;
}
}
- 壓縮完的資料,就可以通過
out.writeBytes(compressedBytes)
寫到輸出流啦
ccx-rpc 的解碼器 RpcMessageDecoder
LengthFieldBasedFrameDecoder
ccx-rpc 的解碼器 RpcMessageDecoder
繼承 Netty 自帶的 LengthFieldBasedFrameDecoder
,其完整的建構函式定義如下:
public LengthFieldBasedFrameDecoder(
ByteOrder byteOrder, int maxFrameLength, int lengthFieldOffset, int lengthFieldLength,
int lengthAdjustment, int initialBytesToStrip, boolean failFast) {
// 忽略 ...
}
建構函式的引數非常多,我們來一一解釋一下:
byteOrder
:在各種計算機體系結構中,對於位元組、字等的儲存機制有所不同。如果不達成一致的規則,通訊雙方將無法進行正確的編/譯碼從而導致通訊失敗。預設值是:ByteOrder.BIG_ENDIAN
。maxFrameLength
:指定包的最大長度,如果超過,直接丟棄lengthFieldOffset
:描述長度的欄位(我們叫length
)在哪個位置(前面有幾個位元組)lengthFieldLength
:length
欄位本身的長度(幾個位元組)lengthAdjustment
:包的總長度調整。
這個引數比較難理解,我們先假設lengthFieldOffset
= 3,lengthFieldLength
=4,我們存的長度是 10。
那麼lengthFieldOffset
、lengthFieldLength
可以拿到長度結束的偏移量(lengthFieldEndOffset
)是 7。
這個長度10,Netty 認為是length
欄位後的長度,所以 Netty 在計算訊息總長度frameLength
的時候,會再加上lengthFieldEndOffset
:frameLength += lengthFieldEndOffset
。
如果我們本來存的長度就是length
欄位後的長度,那這個結果就是對的了。但是我們長度存的就是總長度,這麼一加,就相當於多加了一個lengthFieldEndOffset
了!!!
由於協議的定義沒有誰對誰錯,也不能強制要人家就那麼設定,所以 Netty 還提供了一個長度調整引數lengthAdjustment
給我們,frameLength += lengthAdjustment
。
因為多加了lengthFieldEndOffset
,那我們把這個它減回去,所以大部分的時候,這個引數就是個負數。initialBytesToStrip
:之前的幾個引數,已經足夠識別出整個資料包了。但是很多時候,呼叫者只關心包的內容,包的頭部完全可以丟棄掉,initialBytesToStrip
就是用來告訴 Netty,識別出整個資料包之後,截掉initialBytesToStrip
之前的資料。failFast
:引數一般設定為 true。當這個引數為 true 時,Netty 一旦讀到length
欄位,並判斷length
超過maxFrameLength
,就立即丟擲異常。false 表示只有當真正讀取完所有的位元組之後,才會丟擲異常。一般不用修改,否則可能會記憶體溢位。
RpcMessageDecoder 建構函式
下面來看看,ccx-rpc
是如何使用這幾個引數的吧,上程式碼:
public class RpcMessageDecoder extends LengthFieldBasedFrameDecoder {
public RpcMessageDecoder() {
super(
// 最大的長度,如果超過,會直接丟棄
MAX_FRAME_LENGTH,
// 描述長度的欄位[4B full length(訊息長度)]在哪個位置:在 [2B magic(魔數)]、[1B version(版本)] 後面
MAGIC_LENGTH + VERSION_LENGTH,
// 描述長度的欄位[4B full length(訊息長度)]本身的長度,也就是 4B 啦
FULL_LENGTH_LENGTH,
// LengthFieldBasedFrameDecoder 拿到訊息長度之後,還會加上 [4B full length(訊息長度)] 欄位前面的長度
// 因為我們的訊息長度包含了這部分了,所以需要減回去
-(MAGIC_LENGTH + VERSION_LENGTH + FULL_LENGTH_LENGTH),
// initialBytesToStrip: 去除哪個位置前面的資料。因為我們還需要檢測 魔數 和 版本號,所以不能去除
0);
}
}
解碼的方法先使用父類 LengthFieldBasedFrameDecoder
的 decode
方法得到完整的報文資料:
@Override
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
Object decoded = super.decode(ctx, in);
if (decoded instanceof ByteBuf) {
ByteBuf frame = (ByteBuf) decoded;
if (frame.readableBytes() >= HEADER_LENGTH) {
try {
return decodeFrame(frame);
} catch (Exception ex) {
log.error("Decode frame error.", ex);
} finally {
frame.release();
}
}
}
return decoded;
}
注意:如果解碼報錯,需要呼叫 frame.release()
來釋放。
自定義協議解碼
1. 初步讀出欄位並做基礎檢查
接下來就是自定義協議的解碼方法 decodeFrame
,返回值就是業務的訊息結構體 RpcMessage
。
/**
* 業務解碼
*/
private RpcMessage decodeFrame(ByteBuf in) {
readAndCheckMagic(in);
readAndCheckVersion(in);
int fullLength = in.readInt();
byte messageType = in.readByte();
byte codec = in.readByte();
byte compress = in.readByte();
long requestId = in.readLong();
RpcMessage rpcMessage = RpcMessage.builder()
.serializeType(codec)
.compressTye(compress)
.requestId(requestId)
.messageType(messageType)
.build();
//...
}
第一步:檢查魔數,比較簡單,就是把前兩位位元組讀出來,跟我們的魔數進行對比,不一樣就丟擲異常。
/**
* 讀取並檢查魔數
*/
private void readAndCheckMagic(ByteBuf in) {
byte[] bytes = new byte[MAGIC_LENGTH];
in.readBytes(bytes);
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] != MAGIC[i]) {
throw new IllegalArgumentException("Unknown magic: " + Arrays.toString(bytes));
}
}
}
第二步:檢查版本,目前來說版本的邏輯還很簡單。後續如果版本不一樣,可能解碼的方式還不一樣。
/**
* 讀取並檢查版本
*/
private void readAndCheckVersion(ByteBuf in) {
byte version = in.readByte();
if (version != VERSION) {
throw new IllegalArgumentException("Unknown version: " + version);
}
}
第三步:讀出其他欄位,並初步構造出 RpcMessage
2. 不需要解析 body 的情況
正常來說我們接下來需要解析 body 了,但是有幾種情況是不需要解析的。那就是心跳型別的請求、body 長度 0 的情況。
if (messageType == MessageType.HEARTBEAT.getValue()) {
return rpcMessage;
}
int bodyLength = fullLength - HEADER_LENGTH;
if (bodyLength == 0) {
return rpcMessage;
}
3. 解析 body
拿到 body 之後,應該先要解壓再反序列化,跟編碼時的先序列化再壓縮相反。程式碼如下:
byte[] bodyBytes = new byte[bodyLength];
in.readBytes(bodyBytes);
CompressType compressType = CompressType.fromValue(compress);
// 根據壓縮型別找出壓縮器
Compressor compressor = ExtensionLoader.getLoader(Compressor.class).getExtension(compressType.getName());
// 進行解壓
byte[] decompressedBytes = compressor.decompress(bodyBytes);
SerializeType serializeType = SerializeType.fromValue(codec);
if (serializeType == null) {
throw new IllegalArgumentException("unknown codec type:" + codec);
}
// 根據序列化型別找出序列化器
Serializer serializer = ExtensionLoader.getLoader(Serializer.class).getExtension(serializeType.getName());
// 根據訊息型別獲取訊息體結構
Class<?> clazz = messageType == MessageType.REQUEST.getValue() ? RpcRequest.class : RpcResponse.class;
// 反序列化
Object object = serializer.deserialize(decompressedBytes, clazz);
rpcMessage.setData(object);
return rpcMessage;
總結
上文介紹了 TCP 中的粘包拆包問題,並且介紹了 Netty 提供的解決方案。著重介紹了 ccx-rpc
選擇的 LengthFieldBasedFrameDecoder
,他的構造引數比較多,一次看不懂沒關係,多看幾遍,嘗試 debug 一下程式碼,也許就豁然開朗了。
最後介紹了 ccx-rpc
的自定義協議和編解碼器,大家在自定義協議的時候,可以不用跟我的一樣,不過大體上的思想是一樣的,希望同學們能活學活用。
ccx-rpc 程式碼已經開源
Github:https://github.com/chenchuxin/ccx-rpc
Gitee:https://gitee.com/imccx/ccx-rpc