從零開始實現簡單 RPC 框架 7:網路通訊之自定義協議(粘包拆包、編解碼)

小新是也發表於2021-09-05

當 RPC 框架使用 Netty 通訊時,實際上是將資料轉化成 ByteBuf 的方式進行傳輸。
那如何轉化呢?可不可以把 請求引數 或者 響應結果 直接無腦序列化成 byte 陣列發出去?
答:直接序列化傳輸是不行的,會出現粘包拆包的問題。

粘包拆包

什麼是粘包拆包

RPC 通訊使用 TPC (別問我為什麼不用 UDP),TCP 是一個“流”協議。所謂流,就是沒有界限的一長串二進位制資料。TCP 作為傳輸層協議,並不瞭解上層業務資料的具體含義,它會根據 TCP 緩衝區的實際情況進行資料包的劃分,所以在業務上認為是一個完整包的,可能會被 TCP 拆分成多個包進行傳送,也有可能把多個小的包封裝成一個大的資料包傳送,這就是所謂的 TCP 拆包和粘包問題。

直接序列化發出去是可以,但是接收方收到了一坨資料包,它不知道一個完整的報文哪裡開始、哪裡結束,也就沒有辦法解析了。

粘包拆包的解決方案

由於底層的 TCP 無法理解上層的業務資料,所以在底層是無法保證資料包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決。目前業界主流協議的解決方案如下:

  1. 訊息定長:報文長度固定,例如每個報文的長度固定為 200 位元組,如果不夠空位補空格,接受方每次拿 200 位元組。
  2. 使用特殊分隔符分割:例如每條報文結束都新增回車換行符作為報文分隔符,接收方讀到回車換行符則分割出報文。
  3. 將訊息分為訊息頭和訊息體,訊息頭包含訊息的長度。接收方從訊息頭拿到訊息長度,就知道剩下的報文是多少位元組了。
  4. 更復雜的自定義應用層協議。

編解碼

在網路通訊中,將資料轉成報文的過程稱為 編碼,將報文轉成資料的過程稱為 解碼
在 Netty 中,編解碼的處理放在 PipeLine 中。在前文的介紹中,我們知道每個 PipeLine 都是和 Channel 唯一繫結的,一個 PipeLine 只對應一個 Channel,所以 Channel 中的資料讀取的時候經過解析,如果不是一個完整的資料包,則解析失敗,將這個資料包進行儲存,等下次解析時再和這個資料包進行組裝解析,直到解析到完整的資料包,才會將資料包向下傳遞。

解碼器

Netty提供了多個解碼器,分別是:

  1. LineBasedFrameDecoder按行分包。
  2. DelimiterBasedFrameDecoder特殊分隔符分包。
  3. FixedLengthFrameDecoder:使用定長的報文來分包。
  4. LengthFieldBasedFrameDecoder: 將訊息分為訊息頭和訊息體,訊息頭包含訊息的長度的方式分包。

在 RPC 這個場景中,我們來分析一下我們應該選哪種解碼器:

  1. LineBasedFrameDecoder:按行分包顯然不行,因為我們的請求響應資料中,極有可能包含換行符。
  2. DelimiterBasedFrameDecoder:按照特殊分隔符也不行,因為 RPC 框架是一個通用的場景,請求響應資料中什麼都有可能包含,特殊分隔符無論是什麼都有可能存在於請求響應資料中。這樣會導致分包錯誤。
  3. FixedLengthFrameDecoder:使用定長報文顯然就更加不合適了,在 RPC 框架這樣一個通用場景中,定的長度太短,可能不夠,定得太長又會造成極大的資源浪費。
  4. 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 的流程如下:

  1. 判斷訊息型別,如果是心跳的,則不用寫 body
  2. 根據序列化型別獲得序列化器
  3. 根據壓縮型別獲得壓縮器
  4. 使用序列化器對資料進行序列化
  5. 序列化完的資料再進行壓縮。如果獲取不到壓縮器,則不壓縮,這裡抽象成一個偽序列化器DummyCompressor ,少點特殊化程式碼。
public class DummyCompressor implements Compressor {
    @Override
    public byte[] compress(byte[] bytes) {
        return bytes;
    }

    @Override
    public byte[] decompress(byte[] bytes) {
        return bytes;
    }
}
  1. 壓縮完的資料,就可以通過 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) {
    // 忽略 ...
}

建構函式的引數非常多,我們來一一解釋一下:

  1. byteOrder:在各種計算機體系結構中,對於位元組、字等的儲存機制有所不同。如果不達成一致的規則,通訊雙方將無法進行正確的編/譯碼從而導致通訊失敗。預設值是:ByteOrder.BIG_ENDIAN
  2. maxFrameLength:指定包的最大長度,如果超過,直接丟棄
  3. lengthFieldOffset:描述長度的欄位(我們叫length)在哪個位置(前面有幾個位元組)
  4. lengthFieldLengthlength 欄位本身的長度(幾個位元組)
  5. lengthAdjustment:包的總長度調整。
    這個引數比較難理解,我們先假設 lengthFieldOffset = 3,lengthFieldLength=4,我們存的長度是 10。
    那麼lengthFieldOffsetlengthFieldLength可以拿到長度結束的偏移量(lengthFieldEndOffset)是 7。
    這個長度10,Netty 認為是 length 欄位後的長度,所以 Netty 在計算訊息總長度frameLength的時候,會再加上lengthFieldEndOffsetframeLength += lengthFieldEndOffset
    如果我們本來存的長度就是 length 欄位後的長度,那這個結果就是對的了。但是我們長度存的就是總長度,這麼一加,就相當於多加了一個 lengthFieldEndOffset 了!!!
    由於協議的定義沒有誰對誰錯,也不能強制要人家就那麼設定,所以 Netty 還提供了一個長度調整引數 lengthAdjustment 給我們, frameLength += lengthAdjustment
    因為多加了 lengthFieldEndOffset,那我們把這個它減回去,所以大部分的時候,這個引數就是個負數。
  6. initialBytesToStrip:之前的幾個引數,已經足夠識別出整個資料包了。但是很多時候,呼叫者只關心包的內容,包的頭部完全可以丟棄掉,initialBytesToStrip 就是用來告訴 Netty,識別出整個資料包之後,截掉 initialBytesToStrip 之前的資料。
  7. 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);
    }
}

解碼的方法先使用父類 LengthFieldBasedFrameDecoderdecode 方法得到完整的報文資料:

@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

相關文章