Netty 中的粘包和拆包

rickiyang發表於2020-05-17

Netty 底層是基於 TCP 協議來處理網路資料傳輸。我們知道 TCP 協議是面向位元組流的協議,資料像流水一樣在網路中傳輸那何來 “包” 的概念呢?

TCP是四層協議不負責資料邏輯的處理,但是資料在TCP層 “流” 的時候為了保證安全和節約效率會把 “流” 做一些分包處理,比如:

  1. 傳送方約定了每次資料傳輸的最大包大小,超過該值的內容將會被拆分成兩個包傳送;
  2. 傳送端 和 接收端 約定每次傳送資料包長度並隨著網路狀況動態調整接收視窗大小,這裡也會出現拆包的情況;

Netty 本身是基於 TCP 協議做的處理,如果它不去對 “流” 進行處理,到底這個 “流” 從哪到哪才是完整的資料就是個迷。我們先來看在 TCP 協議中有哪些步驟可能會讓 “流” 不完整或者是出現粘滯的可能。

1. TCP 中可能出現粘包/拆包的原因

資料流在TCP協議下傳播,因為協議本身對於流有一些規則的限制,這些規則會導致當前對端接收到的資料包不完整,歸結原因有下面三種情況:

  • Socket 緩衝區與滑動視窗
  • MSS/MTU限制
  • Nagle演算法
1. Socket緩衝區與滑動視窗

對於 TCP 協議而言,它傳輸資料是基於位元組流傳輸的。應用層在傳輸資料時,實際上會先將資料寫入到 TCP 套接字的緩衝區,當緩衝區被寫滿後,資料才會被寫出去。每個TCP Socket 在核心中都有一個傳送緩衝區(SO_SNDBUF )和一個接收緩衝區(SO_RCVBUF),TCP 的全雙工的工作模式以及 TCP 的滑動視窗便是依賴於這兩個獨立的 buffer 以及此 buffer 的填充狀態。

SO_SNDBUF:

程式傳送的資料的時候假設呼叫了一個 send 方法,將資料拷貝進入 Socket 的核心傳送緩衝區之中,然後 send 便會在上層返回。換句話說,send 返回之時,資料不一定會傳送到對端去(和write寫檔案有點類似),send 僅僅是把應用層 buffer 的資料拷貝進 Socket 的核心傳送 buffer 中。

SO_RCVBUF:

把接收到的資料快取入核心,應用程式一直沒有呼叫 read 進行讀取的話,此資料會一直快取在相應 Socket 的接收緩衝區內。不管程式是否讀取 Socket,對端發來的資料都會經由核心接收並且快取到 Socket 的核心接收緩衝區之中。read 所做的工作,就是把核心緩衝區中的資料拷貝到應用層使用者的 buffer 裡面,僅此而已。

接收緩衝區儲存收到的資料一直到應用程式讀走為止。對於 TCP,如果應用程式一直沒有讀取,buffer 滿了之後發生的動作是:通知對端 TCP 協議中的視窗關閉。這個便是滑動視窗的實現。保證 TCP 套介面接收緩衝區不會溢位,從而保證了 TCP 是可靠傳輸。因為對方不允許發出超過所通告視窗大小的資料。 這就是 TCP 的流量控制,如果對方無視視窗大小而發出了超過視窗大小的資料,則接收方 TCP 將丟棄它。

滑動視窗:

TCP連線在三次握手的時候,會將自己的視窗大小(window size)傳送給對方,其實就是 SO_RCVBUF 指定的值。之後在傳送資料的時,傳送方必須要先確認接收方的視窗沒有被填充滿,如果沒有填滿,則可以傳送。

每次傳送資料後,傳送方將自己維護的對方的 window size 減小,表示對方的 SO_RCVBUF 可用空間變小。

當接收方處理開始處理 SO_RCVBUF 中的資料時,會將資料從 Socket 在核心中的接受緩衝區讀出,此時接收方的 SO_RCVBUF 可用空間變大,即 window size 變大,接受方會以 ack 訊息的方式將自己最新的 window size 返回給傳送方,此時傳送方將自己的維護的接受的方的 window size 設定為ack訊息返回的 window size。

此外,傳送方可以連續的給接受方傳送訊息,只要保證對方的 SO_RCVBUF 空間可以快取資料即可,即 window size>0。當接收方的 SO_RCVBUF 被填充滿時,此時 window size=0,傳送方不能再繼續傳送資料,要等待接收方 ack 訊息,以獲得最新可用的 window size。

2. MSS/MTU分片

MTU (Maxitum Transmission Unit,最大傳輸單元)是鏈路層對一次可以傳送的最大資料的限制。MSS(Maxitum Segment Size,最大分段大小)是 TCP 報文中 data 部分的最大長度,是傳輸層對一次可以傳送的最大資料的限制。

資料在傳輸過程中,每經過一層,都會加上一些額外的資訊:

  • 應用層:只關心傳送的資料 data,將資料寫入 Socket 在核心中的緩衝區 SO_SNDBUF 即返回,作業系統會將 SO_SNDBUF 中的資料取出來進行傳送;
  • 傳輸層:會在 data 前面加上 TCP Header(20位元組);
  • 網路層:會在 TCP 報文的基礎上再新增一個 IP Header,也就是將自己的網路地址加入到報文中。IPv4 中 IP Header 長度是 20 位元組,IPV6 中 IP Header 長度是 40 位元組;
  • 鏈路層:加上 Datalink Header 和 CRC。會將 SMAC(Source Machine,資料傳送方的MAC地址),DMAC(Destination Machine,資料接受方的MAC地址 )和 Type 域加入。SMAC+DMAC+Type+CRC 總長度為 18 位元組;
  • 物理層:進行傳輸。

在回顧這個基本內容之後,再來看 MTU 和 MSS。MTU 是乙太網傳輸資料方面的限制,每個乙太網幀最大不能超過 1518bytes。刨去乙太網幀的幀頭(DMAC+SMAC+Type域) 14Bytes 和幀尾 (CRC校驗 ) 4 Bytes,那麼剩下承載上層協議的地方也就是 data 域最大就只能有 1500 Bytes 這個值 我們就把它稱之為 MTU。

MSS 是在 MTU 的基礎上減去網路層的 IP Header 和傳輸層的 TCP Header 的部分,這就是 TCP 協議一次可以傳送的實際應用資料的最大大小。

MSS = MTU(1500) -IP Header(20 or 40)-TCP Header(20) 

由於 IPV4 和 IPV6 的長度不同,在 IPV4 中,乙太網 MSS 可以達到 1460byte。在 IPV6 中,乙太網 MSS 可以達到 1440byte。

傳送方傳送資料時,當 SO_SNDBUF 中的資料量大於 MSS 時,作業系統會將資料進行拆分,使得每一部分都小於 MSS,也形成了拆包。然後每一部分都加上 TCP Header,構成多個完整的 TCP 報文進行傳送,當然經過網路層和資料鏈路層的時候,還會分別加上相應的內容。

另外需要注意的是:對於本地迴環地址(lookback)不需要走乙太網,所以不受到乙太網 MTU=1500 的限制。linux 伺服器上輸入 ifconfig 命令,可以檢視不同網路卡的 MTU 大小,如下:

上圖顯示了 2 個網路卡資訊:

  • eth0 需要走乙太網,所以 MTU 是 1500;
  • lo 是本地迴環,不需要走乙太網,所以不受 1500 的限制。

Nagle 演算法

TCP/IP 協議中,無論傳送多少資料,總是要在資料(data)前面加上協議頭(TCP Header+IP Header),同時,對方接收到資料,也需要傳送 ACK 表示確認。

即使從鍵盤輸入的一個字元,佔用一個位元組,可能在傳輸上造成 41 位元組的包,其中包括 1 位元組的有用資訊和 40 位元組的首部資料。這種情況轉變成了 4000% 的消耗,這樣的情況對於重負載的網路來是無法接受的。稱之為"糊塗視窗綜合徵"。

為了儘可能的利用網路頻寬,TCP 總是希望儘可能的傳送足夠大的資料。(一個連線會設定 MSS 引數,因此,TCP/IP 希望每次都能夠以 MSS 尺寸的資料塊來傳送資料)。Nagle 演算法就是為了儘可能傳送大塊資料,避免網路中充斥著許多小資料塊。

Nagle 演算法的基本定義是任意時刻,最多隻能有一個未被確認的小段。 所謂 “小段”,指的是小於 MSS 尺寸的資料塊;所謂“未被確認”,是指一個資料塊傳送出去後,沒有收到對方傳送的 ACK 確認該資料已收到。

Nagle 演算法的規則:

  1. 如果 SO_SNDBUF 中的資料長度達到 MSS,則允許傳送;
  2. 如果該 SO_SNDBUF 中含有 FIN,表示請求關閉連線,則先將 SO_SNDBUF 中的剩餘資料傳送,再關閉;
  3. 設定了 TCP_NODELAY=true 選項,則允許傳送。TCP_NODELAY 是取消 TCP 的確認延遲機制,相當於禁用了 Negale 演算法。正常情況下,當 Server 端收到資料之後,它並不會馬上向 client 端傳送 ACK,而是會將 ACK 的傳送延遲一段時間(一般是 40ms),它希望在 t 時間內 server 端會向 client 端傳送應答資料,這樣 ACK 就能夠和應答資料一起傳送,就像是應答資料捎帶著 ACK 過去。當然,TCP 確認延遲 40ms 並不是一直不變的, TCP 連線的延遲確認時間一般初始化為最小值 40ms,隨後根據連線的重傳超時時間(RTO)、上次收到資料包與本次接收資料包的時間間隔等引數進行不斷調整。另外可以通過設定 TCP_QUICKACK 選項來取消確認延遲;
  4. 未設定 TCP_CORK 選項時,若所有發出去的小資料包(包長度小於MSS)均被確認,則允許傳送;
  5. 上述條件都未滿足,但發生了超時(一般為200ms),則立即傳送。

基於以上問題,TCP層肯定是會出現當次接收到的資料是不完整資料的情況。出現粘包可能的原因有:

  1. 傳送方每次寫入資料 < 套接字緩衝區大小;
  2. 接收方讀取套接字緩衝區資料不夠及時。

出現半包的可能原因有:

  1. 傳送方每次寫入資料 > 套接字緩衝區大小;
  2. 傳送的資料大於協議 MTU,所以必須要拆包。

解決問題肯定不是在4層來做而是在應用層,通過定義通訊協議來解決粘包和拆包的問題。傳送方 和 接收方約定某個規則:

  1. 當發生粘包的時候通過某種約定來拆包;
  2. 如果在拆包,通過某種約定來將資料組成一個完整的包處理。

2. 業界常用解決方案

1. 定長協議

指定一個報文具有固定長度。比如約定一個報文的長度是 5 位元組,那麼:

報文:1234,只有4位元組,但是還差一個怎麼辦呢,不足部分用空格補齊。就變為:1234 。

如果不補齊空格,那麼就會讀到下一個報文的位元組來填充上一個報文直到補齊為止,這樣粘包了。

定長協議的優點是使用簡單,缺點很明顯:浪費頻寬。

Netty 中提供了 FixedLengthFrameDecoder ,支援把固定的長度的位元組數當做一個完整的訊息進行解碼。

2. 特殊字元分割協議

很好理解,在每一個你認為是一個完整的包的尾部新增指定的特殊字元,比如:\n,\r等等。

需要注意的是:約定的特殊字元要保證唯一性,不能出現在報文的正文中,否則就將正文一分為二了。

Netty 中提供了 DelimiterBasedFrameDecoder 根據特殊字元進行解碼,LineBasedFrameDecoder預設以換行符作為分隔符。

3. 變長協議

變長協議的核心就是:將訊息分為訊息頭和訊息體,訊息頭中標識當前完整的訊息體長度。

  1. 傳送方在傳送資料之前先獲取資料的二進位制位元組大小,然後在訊息體前面新增訊息大小;
  2. 接收方在解析訊息時先獲取訊息大小,之後必須讀到該大小的位元組數才認為是完整的訊息。

Netty 中提供了 LengthFieldBasedFrameDecoder ,通過 LengthFieldPrepender 來給實際的訊息體新增 length 欄位。

3. Netty 粘包演示

程式碼示例請看:github點我

1. 實驗主要邏輯

演示客戶端傳送多條訊息,使用 Netty 自定義的 ByteBuf 作為傳輸資料格式,看看服務端接收資料是否是按每次傳送的條數來接收還是按照當前緩衝區大小來接收。

主要程式碼:

Server:

package com.rickiyang.learn.packageEvent1;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j;

/**
 * @author: rickiyang
 * @date: 2020/3/15
 * @description: server 端
 */
@Slf4j
public class PeServer {

    private int port;

    public PeServer(int port) {
        this.port = port;
    }

    public void start(){
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();

        ServerBootstrap server = new ServerBootstrap().group(bossGroup,workGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ServerChannelInitializer());

        try {
            ChannelFuture future = server.bind(port).sync();
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            log.error("server start fail",e);
        }finally {
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) {
        PeServer server = new PeServer(7788);
        server.start();
    }
}

ServerInitialzr:

package com.rickiyang.learn.packageEvent1;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;

/**
 * @author: rickiyang
 * @date: 2020/3/15
 * @description:
 */
public class ServerChannelInitializer  extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
        ChannelPipeline pipeline = socketChannel.pipeline();
        // 自己的邏輯Handler
        pipeline.addLast("handler", new PeServerHandler());
        }
}

ServerHandler:

package com.rickiyang.learn.packageEvent1;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;

import java.nio.charset.StandardCharsets;

/**
 * @author: rickiyang
 * @date: 2020/3/15
 * @description:
 */
@Slf4j
public class PeServerHandler extends SimpleChannelInboundHandler {

    private int counter;

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        log.info("server channelActive");
    }


    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req, StandardCharsets.UTF_8);
        System.out.println("-----start------\n"+ body + "\n------end------");

        String content = "receive" + ++counter;
        ByteBuf resp = Unpooled.copiedBuffer(content.getBytes());
        ctx.writeAndFlush(resp);
    }


    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        super.exceptionCaught(ctx, cause);
        ctx.close();
    }

}

服務端的 handler 主要邏輯是接收客戶端傳送過來的資料,看看是否是一條一條接收。然後每次接收到資料之後給客戶端回覆一個確認訊息。

Client:

package com.rickiyang.learn.packageEvent1;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import lombok.extern.slf4j.Slf4j;

/**
 * @author: rickiyang
 * @date: 2020/3/15
 * @description:
 */
@Slf4j
public class PeClient {

    private  int port;
    private  String address;

    public PeClient(int port, String address) {
        this.port = port;
        this.address = address;
    }

    public void start(){
        EventLoopGroup group = new NioEventLoopGroup();

        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .option(ChannelOption.TCP_NODELAY, true)
                .handler(new ClientChannelInitializer());
        try {
            ChannelFuture future = bootstrap.connect(address,port).sync();
            future.channel().writeAndFlush("Hello world, i'm online");
            future.channel().closeFuture().sync();
        } catch (Exception e) {
            log.error("client start fail",e);
        }finally {
            group.shutdownGracefully();
        }

    }

    public static void main(String[] args) {
        PeClient client = new PeClient(7788,"127.0.0.1");
        client.start();
    }
}

ClientInitializer:

package com.rickiyang.learn.packageEvent1;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class ClientChannelInitializer extends  ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        ChannelPipeline pipeline = socketChannel.pipeline();

        // 客戶端的邏輯
        pipeline.addLast("handler", new PeClientHandler());
    }
}

ClientHandler:

package com.rickiyang.learn.packageEvent1;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;

import java.nio.charset.StandardCharsets;

/**
 * @author: rickiyang
 * @date: 2020/3/15
 * @description:
 */
@Slf4j
public class PeClientHandler extends SimpleChannelInboundHandler {

    private int counter;

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req, StandardCharsets.UTF_8);
        System.out.println(body + " count:" + ++counter + "----end----\n");
    }



    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        log.info("client channelActive");
        byte[] req = ("我是一條測試訊息,快來讀我吧,啦啦啦").getBytes();

        for (int i = 0; i < 100; i++) {
            ByteBuf message = Unpooled.buffer(req.length);
            message.writeBytes(req);
            ctx.writeAndFlush(message);
        }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        log.info("Client is close");
    }


}

客戶端 handler 主要邏輯是:迴圈100次給服務端傳送測試訊息。接收服務端的確認訊息。

啟動專案之後我們來看看客戶端 和 服務端分別收到的訊息結果:

服務端接收到的訊息:

-----start------
我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦�
------end------
-----start------
��我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦�
------end------
-----start------
�啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦
------end------
-----start------
啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,�
------end------
-----start------
��啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧�
------end------
-----start------
�啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦我是一條測試訊息,快來讀我吧,啦啦啦
------end------

這裡能看到多條訊息被粘到一起傳送了。

客戶端接收到服務端回傳的訊息:

receive1receive2receive3receive4receive5 count:1----end----

receive6 count:2----end----

服務端收到 6 次訊息,所以回覆了 6 次,同樣客戶端接收訊息也出現粘包的現象。

因為我們並沒有對資料包做任何宣告,站在 TCP 協議端看, Netty 屬於應用層,我們上面的示例程式碼中未對原始的資料包做任何處理。

4. Netty 粘包處理

處理 TCP 粘包的唯一方法就是制定應用層的資料通訊協議,通過協議來規範現有接收的資料是否滿足訊息資料的需要。

1. Netty 提供的能力

為了解決網路資料流的拆包粘包問題,Netty 為我們內建瞭如下的解碼器:

  • ByteToMessageDecoder:如果想實現自己的半包解碼器,實現該類;
  • MessageToMessageDecoder:一般作為二次解碼器,當我們在 ByteToMessageDecoder 將一個 bytes 陣列轉換成一個 java 物件的時候,我們可能還需要將這個物件進行二次解碼成其他物件,我們就可以繼承這個類;
  • LineBasedFrameDecoder:通過在包尾新增回車換行符 \r\n 來區分整包訊息;
  • StringDecoder:字串解碼器;
  • DelimiterBasedFrameDecoder:特殊字元作為分隔符來區分整包訊息;
  • FixedLengthFrameDecoder:報文大小固定長度,不夠空格補全;
  • ProtoBufVarint32FrameDecoder:通過 Protobuf 解碼器來區分整包訊息;
  • ProtobufDecoder: Protobuf 解碼器;
  • LengthFieldBasedFrameDecoder:指定長度來標識整包訊息,通過在包頭指定整包長度來約定包長。

Netty 還內建瞭如下的編碼器:

  • ProtobufEncoder:Protobuf 編碼器;
  • MessageToByteEncoder:將 Java 物件編碼成 ByteBuf;
  • MessageToMessageEncoder:如果不想將 Java 物件編碼成 ByteBuf,而是自定義類就繼承這個;
  • LengthFieldPrepender:LengthFieldPrepender 是一個非常實用的工具類,如果我們在傳送訊息的時候採用的是:訊息長度欄位+原始訊息的形式,那麼我們就可以使用 LengthFieldPrepender。這是因為 LengthFieldPrepender 可以將待傳送訊息的長度(二進位制位元組長度)寫到 ByteBuf 的前兩個位元組。

編解碼相關類結構圖如下:

上面的類關係能看到所有的自定義解碼器都是繼承自 ByteToMessageDecoder。在Netty 中 Decoder 主要分為兩大類:

  1. 一種是將位元組流轉換為某種協議的資料格式:ByteToMessageDecoderReplayingDecoder
  2. 一種是將一直協議的資料轉為另一種協議的資料格式:MessageToMessageDecoder

將位元組流轉為物件是一種很常見的操作,也是一個訊息框架應該提供的基礎功能。因為 Decoder 的作用是將輸入的資料解析成特定協議,上圖中可以看到所有的 Decoder 都實現了 ChannelInboundHandler介面。在應用層將 byte 轉為 message 的難度在於如何確定當前的包是一個完整的資料包,有兩種方案可以實現:

  1. 監聽當前 socket 的執行緒一直等待,直到收到的 byte 可以完成的構成一個包為止。這種方式的弊端就在於要浪費一個執行緒去等。
  2. 第二種方案是為每個監聽的 socket 都構建一個本地快取,當前監聽執行緒如果遇到位元組數不夠的情況就先將獲取到的資料存入快取,繼而處理別的請求,等到這裡有資料的時候再來將新資料繼續寫入快取直到資料構成一個完整的包取出。

ByteToMessageDecoder 採用的是第二種方案。在 ByteToMessageDecoder 中有一個物件 ByteBuf,該物件用於儲存當前 Decoder接收到的 byte 資料。

public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
	
  // 用來儲存累計讀取到的位元組. 我們讀到的新位元組會儲存(緩衝)在這裡
  ByteBuf cumulation;
  // 用來做累計的,負責將讀到的新位元組寫入 cumulation,有兩個實現 MERGE_CUMULATOR 和 COMPOSITE_CUMULATOR
  private Cumulator cumulator = MERGE_CUMULATOR;
  //設定為true後, 單個解碼器只會解碼出一個結果
  private boolean singleDecode;
  private boolean decodeWasNull;
  //是否是第一次讀取資料
  private boolean first;
  //多少次讀取後, 丟棄資料 預設16次
  private int discardAfterReads = 16;
  //已經累加了多少次資料
  private int numReads;
  
  //每次接收到資料,就會呼叫channelRead 進行處理
  //該處理器用於處理二進位制資料,所以 msg 欄位的型別應該是 ByteBuf。
  //如果不是,則交給pipeLine的下一個處理器進行處理。
  //下面的程式碼中可以看出
  @Override
  public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    //如果不是ByteBuf則不處理
    if (msg instanceof ByteBuf) {
      //out用於儲存解析二進位制流得到的結果,一個二進位制流可能會解析出多個訊息,所以out是一個list
      CodecOutputList out = CodecOutputList.newInstance();
      try {
        ByteBuf data = (ByteBuf) msg;
        //判斷cumulation == null;並將結果賦值給first。因此如果first為true,則表示第一次接受到資料     
        first = cumulation == null;
        //如果是第一次接受到資料,直接將接受到的資料賦值給快取物件cumulation
        if (first) {
          cumulation = data;
        } else {
          // 第二次解碼,就將 data 向 cumulation 追加,並釋放 data
          //如果cumulation中的剩餘空間,不足以儲存接收到的data,將cumulation擴容
          cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
        }
        // 得到追加後的 cumulation 後,呼叫 decode 方法進行解碼
				// 解碼過程中,呼叫 fireChannelRead 方法,主要目的是將累積區的內容 decode 到 陣列中
        callDecode(ctx, cumulation, out);
      } catch (DecoderException e) {
        throw e;
      } catch (Throwable t) {
        throw new DecoderException(t);
      } finally {
         //如果cumulation沒有資料可讀了,說明所有的二進位制資料都被解析過了
         //此時對cumulation進行釋放,以節省記憶體空間。
         //反之cumulation還有資料可讀,那麼if中的語句不會執行,因為不對cumulation進行釋放
         //因此也就快取了使用者尚未解析的二進位制資料。
        if (cumulation != null && !cumulation.isReadable()) {
          // 將次數歸零
          numReads = 0;
          // 釋放累計區
          cumulation.release();
          // 等待 gc
          cumulation = null;
          
          // 如果超過了 16 次,就壓縮累計區,主要是將已經讀過的資料丟棄,將 readIndex 歸零。
        } else if (++ numReads >= discardAfterReads) {
          // We did enough reads already try to discard some bytes so we not risk to see a OOME.
          // See https://github.com/netty/netty/issues/4275
          numReads = 0;
          discardSomeReadBytes();
        }

        int size = out.size();
        // 如果沒有向陣列插入過任何資料
        decodeWasNull = !out.insertSinceRecycled();
        // 迴圈陣列,向後面的 handler 傳送資料,如果陣列是空,那不會呼叫
        fireChannelRead(ctx, out, size);
         // 將陣列中的內容清空,將陣列的陣列的下標恢復至原來
        out.recycle();
      }
    } else {
      //如果msg型別是不是ByteBuf,直接呼叫下一個handler進行處理
      ctx.fireChannelRead(msg);
    }
  }
  
  //callDecode方法主要用於解析cumulation 中的資料,並將解析的結果放入List<Object> out中。
  //由於cumulation中快取的二進位制資料,可能包含了出多條有效資訊,因此在callDecode方法中,預設會呼叫多次decode方法
  //我們在覆寫decode方法時,每次只解析一個訊息,新增到out中,callDecode通過多次回撥decode
  //每次傳遞進來都是相同的List<Object> out例項,因此每一次解析出來的訊息,都儲存在同一個out例項中。
  //當cumulation沒有資料可以繼續讀,或者某次呼叫decode方法後,List<Object> out中元素個數沒有變化,則停止回撥decode方法。
  protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    try {
      //如果cumulation中有資料可讀的話,一直迴圈呼叫decode
      while (in.isReadable()) {
        //獲取上一次decode方法呼叫後,out中元素數量,如果是第一次呼叫,則為0。
        int outSize = out.size();
				//上次迴圈成功解碼
        if (outSize > 0) {
          //用後面的業務 handler 的 ChannelRead 方法讀取解析的資料
          fireChannelRead(ctx, out, outSize);
          out.clear();

         
          if (ctx.isRemoved()) {
            break;
          }
          outSize = 0;
        }

        int oldInputLength = in.readableBytes();
        //回撥decode方法,由開發者覆寫,用於解析in中包含的二進位制資料,並將解析結果放到out中。
        decode(ctx, in, out);

      
        if (ctx.isRemoved()) {
          break;
        }
				//outSize是上一次decode方法呼叫時out的大小,out.size()是當前out大小
        //如果二者相等,則說明當前decode方法呼叫沒有解析出有效資訊。
        if (outSize == out.size()) {
          //此時,如果發現上次decode方法和本次decode方法呼叫候,in中的剩餘可讀位元組數相同
          //則說明本次decode方法沒有讀取任何資料解析
          //(可能是遇到半包等問題,即剩餘的二進位制資料不足以構成一條訊息),跳出while迴圈。
          if (oldInputLength == in.readableBytes()) {
            break;
          } else {
            continue;
          }
        }
				//處理人為失誤 。如果走到這段程式碼,則說明outSize != out.size()。
        //也就是本次decode方法實際上是解析出來了有效資訊放到out中。
        //但是oldInputLength == in.readableBytes(),說明本次decode方法呼叫並沒有讀取任何資料
        //但是out中元素卻新增了。
        //這可能是因為開發者錯誤的編寫了程式碼,例如mock了一個訊息放到List中。
        if (oldInputLength == in.readableBytes()) {
          throw new DecoderException(
            StringUtil.simpleClassName(getClass()) +
            ".decode() did not read anything but decoded a message.");
        }

        if (isSingleDecode()) {
          break;
        }
      }
    } catch (DecoderException e) {
      throw e;
    } catch (Throwable cause) {
      throw new DecoderException(cause);
    }
  }
  
}

這裡 channelRead()的主要邏輯是:

  1. 從物件池中取出一個空的陣列;
  2. 判斷成員變數是否是第一次使用,要注意的是,這裡既然使用了成員變數,所以這個 handler 不能是 @Shareble 狀態的 handler,不然你就分不清成員變數是哪個 channel 的。將 unsafe 中傳遞來的資料寫入到這個 cumulation 累積區中;
  3. 寫到累積區後,呼叫子類的 decode 方法,嘗試將累積區的內容解碼,每成功解碼一個,就呼叫後面節點的 channelRead 方法。若沒有解碼成功,什麼都不做;
  4. 如果累積區沒有未讀資料了,就釋放累積區;
  5. 如果還有未讀資料,且解碼超過了 16 次(預設),就對累積區進行壓縮。將讀取過的資料清空,也就是將 readIndex 設定為0;
  6. 設定 decodeWasNull 的值,如果上一次沒有插入任何資料,這個值就是 ture。該值在 呼叫 channelReadComplete 方法的時候,會觸發 read 方法(不是自動讀取的話),嘗試從 JDK 的通道中讀取資料,並將之前的邏輯重來。主要應該是怕如果什麼資料都沒有插入,就執行 channelReadComplete 會遺漏資料;
  7. 呼叫 fireChannelRead 方法,將陣列中的元素髮送到後面的 handler 中;
  8. 將陣列清空。並還給物件池。

當資料新增到累積區之後,需要呼叫 decode 方法進行解碼,程式碼見上面的 callDecode()方法。在 callDecode()中最關鍵的程式碼就是將解析完的資料拿取呼叫decode(ctx, in, out)方法。所以如果繼承 ByteToMessageDecoder 類實現自己的位元組流轉物件的邏輯我們就要覆寫該方法。

2. LineBasedFrameDecoder 使用

LineBasedFrameDecoder 通過在包尾新增回車換行符 \r\n 來區分整包訊息。邏輯比較簡單,示例程式碼見:

示例程式碼見:LineBasedFrameDecoder gitHub示例

3. FixedLengthFrameDecoder 使用

LineBasedFrameDecoder即固定訊息長度解碼器,個人認為這個貌似不能適用通用場景。

示例程式碼見:FixedLengthFrameDecoder gitHub 示例

4. DelimiterBasedFrameDecoder 使用

DelimiterBasedFrameDecoder即自定義分隔符解碼器。相當於是 LineBasedFrameDecoder的高階版。

示例程式碼見:DelimiterBasedFrameDecoder gitHub示例

5. LengthFieldBasedFrameDecoder 使用

LengthFieldBasedFrameDecoder相對就高階一點。前面我們使用到的拆包都是基於一些約定來做的,比如固定長度,特殊分隔符,這些方案總是有一定的弊端。最好的方案就是:傳送方告訴我當前訊息總長度,接收方如果沒有收到該長度大小的資料就認為是沒有收完繼續等待。

先看一下該類的建構函式:

		/**
     * Creates a new instance.
     *
     * @param maxFrameLength 幀的最大長度
     *        
     * @param lengthFieldOffset 長度欄位偏移的地址
     *        
     * @param lengthFieldLength 長度欄位所佔的位元組長
     *        修改幀資料長度欄位中定義的值,可以為負數 因為有時候我們習慣把頭部記入長度,
     *        若為負數,則說明要推後多少個欄位
     * @param lengthAdjustment 解析時候跳過多少個長度
     *
     * @param initialBytesToStrip 解碼出一個資料包之後,去掉開頭的位元組數
     *        
     * @param initialBytesToStrip  為true,當frame長度超過maxFrameLength時立即報
     *                   TooLongFrameException異常,為false,讀取完整個幀再報異
     *        
     */
public LengthFieldBasedFrameDecoder(
  int maxFrameLength,
  int lengthFieldOffset, int lengthFieldLength,
  int lengthAdjustment, int initialBytesToStrip) {
  this(
    maxFrameLength,
    lengthFieldOffset, lengthFieldLength, lengthAdjustment,
    initialBytesToStrip, true);
}

LengthFieldBasedFrameDecoder類的註解上給出了一些關於該類使用的示例:

示例1:

lengthFieldOffset = 0,長度欄位偏移位置為0表示從包的第一個位元組開始讀取;

lengthFieldLength = 2,長度欄位長為2,從包的開始位置往後2個位元組的長度為長度欄位;

lengthAdjustment = 0 ,解析的時候無需跳過任何長度;

initialBytesToStrip = 0,無需去掉當前資料包的開頭位元組數, header + body。

0x000C 轉為 int = 12。

 * <pre>
 * <b>lengthFieldOffset</b>   = <b>0</b>
 * <b>lengthFieldLength</b>   = <b>2</b>
 * lengthAdjustment    = 0
 * initialBytesToStrip = 0 (= do not strip header)
 *
 * BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)
 * +--------+----------------+      +--------+----------------+
 * | Length | Actual Content |----->| Length | Actual Content |
 * | 0x000C | "HELLO, WORLD" |      | 0x000C | "HELLO, WORLD" |
 * +--------+----------------+      +--------+----------------+
 * </pre>

上面這個設定表示:body長度為12,從當前包的第0個位元組開始讀取,前兩個位元組表示包長度,讀取資料 body的時候不偏移從0位元組開始,所以整包大小14個位元組,包含包頭長度位元組在內。

示例2:

lengthFieldOffset = 0,長度欄位偏移位置為0表示從包的第一個位元組開始讀取;

lengthFieldLength = 2,長度欄位長為2,從包的開始位置往後2個位元組的長度為長度欄位;

lengthAdjustment = 0 ,解析的時候無需跳過任何長度;

initialBytesToStrip = 2,去掉當前資料包的開頭2位元組,去掉 header。

0x000C 轉為 int = 12。

* <pre>
* lengthFieldOffset   = 0
* lengthFieldLength   = 2
* lengthAdjustment    = 0
* <b>initialBytesToStrip</b> = <b>2</b> (= the length of the Length field)
*
* BEFORE DECODE (14 bytes)         AFTER DECODE (12 bytes)
* +--------+----------------+      +----------------+
* | Length | Actual Content |----->| Actual Content |
* | 0x000C | "HELLO, WORLD" |      | "HELLO, WORLD" |
* +--------+----------------+      +----------------+
* </pre>

這個配置跟上面的而區別就在於,initialBytesToStrip = 2,表示當前包中的有效資料是從整包偏移2個位元組開始計算的,即包頭中的長度欄位 2 byte 不屬於包內容的一部分。

示例3:

lengthFieldOffset = 0,長度欄位偏移位置為0表示從包的第一個位元組開始讀取;

lengthFieldLength = 2,長度欄位長為2,從包的開始位置往後2個位元組的長度為長度欄位;

lengthAdjustment = -2 ,解析的時候無需跳過任何長度;

initialBytesToStrip = 0,無需去掉當前資料包的開頭位元組數。

0x000C 轉為 int = 12。

* <pre>
* lengthFieldOffset   =  0
* lengthFieldLength   =  2
* <b>lengthAdjustment</b>    = <b>-2</b> (= the length of the Length field)
* initialBytesToStrip =  0
*
* BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)
* +--------+----------------+      +--------+----------------+
* | Length | Actual Content |----->| Length | Actual Content |
* | 0x000E | "HELLO, WORLD" |      | 0x000E | "HELLO, WORLD" |
* +--------+----------------+      +--------+----------------+
* </pre>

length = 14,長度欄位為 2 位元組,真實的資料長度為 12 個位元組,但是 length = 14,那麼說明 length的長度也算上了資料包長度了。lengthAdjustment = -2 ,表示當前length長度往回撥2個位元組,這樣總包長度就是14個位元組。

示例4:

lengthFieldOffset = 2,長度欄位偏移位置為2表示從包的第3個位元組開始讀取;

lengthFieldLength = 3,長度欄位長為3,從包的開始位置往後3個位元組的長度為長度欄位;

lengthAdjustment = 0 ,解析的時候無需跳過任何長度;

initialBytesToStrip = 0,無需去掉當前資料包的開頭位元組數。

0x000E 轉為 int = 14。

  * <pre>
  * <b>lengthFieldOffset</b>   = <b>2</b> (= the length of Header 1)
  * <b>lengthFieldLength</b>   = <b>3</b>
  * lengthAdjustment    = 0
  * initialBytesToStrip = 0
  *
  * BEFORE DECODE (17 bytes)                      AFTER DECODE (17 bytes)
  * +----------+----------+----------------+      +----------+----------+----------------+
  * | Header 1 |  Length  | Actual Content |----->| Header 1 |  Length  | Actual Content |
  * |  0xCAFE  | 0x00000C | "HELLO, WORLD" |      |  0xCAFE  | 0x00000C | "HELLO, WORLD" |
  * +----------+----------+----------------+      +----------+----------+----------------+
  * </pre>
  *

header頭佔2個位元組,長度欄位佔3個位元組,content欄位佔12個位元組,總共17個位元組。body讀取無偏移要求,所以body整體也是17個位元組。

示例5:

lengthFieldOffset = 0,長度欄位偏移位置為0表示從包的第0個位元組開始讀取;

lengthFieldLength = 3,長度欄位長為3,從包的開始位置往後3個位元組的長度為長度欄位;

lengthAdjustment = 2 ,解析的時候跳過2個位元組;

initialBytesToStrip = 0,無需去掉當前資料包的開頭位元組數。

0x000C 轉為 int = 12。

* <pre>
* lengthFieldOffset   = 0
* lengthFieldLength   = 3
* <b>lengthAdjustment</b>    = <b>2</b> (= the length of Header 1)
* initialBytesToStrip = 0
*
* BEFORE DECODE (17 bytes)                      AFTER DECODE (17 bytes)
* +----------+----------+----------------+      +----------+----------+----------------+
* |  Length  | Header 1 | Actual Content |----->|  Length  | Header 1 | Actual Content |
* | 0x00000C |  0xCAFE  | "HELLO, WORLD" |      | 0x00000C |  0xCAFE  | "HELLO, WORLD" |
* +----------+----------+----------------+      +----------+----------+----------------+
* </pre>
*

這個包 length在最前面傳輸佔3個位元組,header在中間佔兩個位元組,content在最後佔12個位元組。body欄位只有content,所以讀取content的時候需要在length欄位的基礎上往前偏移2個位元組跳過heade欄位。

關於 LengthFieldBasedFrameDecoder 建構函式的示例用法我們先將這麼多,下來舉一個示例我們看看實際中的使用:

示例程式碼見:LengthFieldBasedFrameDecoder基本使用 gitHub示例

程式碼解釋:

@Slf4j
public class PeClientHandler extends SimpleChannelInboundHandler {

  @Override
  public void channelActive(ChannelHandlerContext ctx) throws Exception {
    log.info("client channelActive");
    for (int i = 0; i < 100; i++) {
      byte[] req = ("我是一條測試訊息,快來讀我吧,啦啦啦" + i).getBytes();
      ByteBuf message = Unpooled.buffer(req.length);
      message.writeInt(req.length);
      message.writeBytes(req);
      ctx.writeAndFlush(message);
    }
  }
}

客戶端傳送訊息是:int型的length欄位佔4個位元組,剩餘位元組為content內容。那麼對應到客戶端接收的解碼器設定:

pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, // 幀的最大長度,即每個資料包最大限度
                                                  0, // 長度欄位偏移量
                                                  4, // 長度欄位所佔的位元組數
                                                  0, // 訊息頭的長度,可以為負數
                                                  4) // 需要忽略的位元組數,從訊息頭開始,這裡是指整個包

                );

長度欄位4個位元組,訊息體忽略4位元組,即排除長度欄位之後的內容算是body。

以上的這段演示程式碼的重點,大家可以下載示例功能,自己演示一下。

但是有個問題是:我們上面寫的示例程式碼在生產環境中只能是玩具。訊息體的讀取配置不應該在這裡通過引數配置來設定,應該有一個約定的訊息結構體,每一個欄位是什麼資料結構會佔用多大空間都應該在結構體中約定清楚。每個欄位讀取對應空間大小的資料剩下的就是別人的部分互不侵犯。

所以下面的一個示例給出了通過繼承 LengthFieldBasedFrameDecoder 重寫 decode 方法來實現解析出約定物件的實現。

6. 自定義編解碼器的 LengthFieldBasedFrameDecoder 使用

首先我們自定義了一個訊息體:

public class MsgReq {

    private byte type;

    private int length;

    private String content;


}

包含3個欄位。

傳送訊息出去的時候肯定是要將物件轉為 byte 傳送,所以需要一個訊息編碼器,我們繼承 MessageToByteEncoder 來實現編碼器:

package com.rickiyang.learn.packageEvent5;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

import java.nio.charset.StandardCharsets;

/**
 * @author rickiyang
 * @date 2020-05-14
 * @Desc 自定義編碼器
 */
public class MyProtocolEncoder extends MessageToByteEncoder {



    @Override
    protected void encode(ChannelHandlerContext ctx, Object msg, ByteBuf out) throws Exception {
        MsgReq req = (MsgReq) msg;
        out.writeByte(req.getType());
        out.writeInt(req.getLength());
        out.writeBytes(req.getContent().getBytes(StandardCharsets.UTF_8));
    }
}

即將 MsgReq 物件轉為對應的 byte 傳送。

傳送出去的是 byte 位元組,對應的解碼器應該是將 byte 轉為物件。自然解碼器應該是繼承 ByteToMessageDecoder。我們的目的不是自己實現一個完完全全的自定義解碼器,而是在訊息長度解碼器的基礎上完成物件解析的工作,所以解碼器如下:

package com.rickiyang.learn.packageEvent5;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;

import java.nio.charset.StandardCharsets;

/**
 * @author rickiyang
 * @date 2020-05-14
 * @Desc 自定義解碼器
 */
public class MyProtocolDecoder extends LengthFieldBasedFrameDecoder {


    /**
     * @param maxFrameLength      幀的最大長度
     * @param lengthFieldOffset   length欄位偏移的地址
     * @param lengthFieldLength   length欄位所佔的位元組長
     * @param lengthAdjustment    修改幀資料長度欄位中定義的值,可以為負數 因為有時候我們習慣把頭部記入長度,若為負數,則說明要推後多少個欄位
     * @param initialBytesToStrip 解析時候跳過多少個長度
     * @param failFast            為true,當frame長度超過maxFrameLength時立即報TooLongFrameException異常,為false,讀取完整個幀再報異
     */
    public MyProtocolDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength,
                             int lengthAdjustment, int initialBytesToStrip, boolean failFast) {
        super(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip, failFast);
    }


    @Override
    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
        //在這裡呼叫父類的方法
        in = (ByteBuf) super.decode(ctx, in);
        if (in == null) {
            return null;
        }
        //讀取type欄位
        byte type = in.readByte();
        //讀取length欄位
        int length = in.readInt();
        if (in.readableBytes() != length) {
            throw new RuntimeException("長度與標記不符");
        }
        //讀取body
        byte[] bytes = new byte[in.readableBytes()];
        in.readBytes(bytes);
        return MsgReq.builder().length(length).type(type).content(new String(bytes, StandardCharsets.UTF_8)).build();
    }
}

通過這種方式,我們只用約定好訊息的最大長度,比如一條訊息超過多少位元組就拒收,約定好訊息長度欄位所佔的位元組,一般來說int型別4個位元組足夠。剩下的幾個引數都無需設定,按照約定的訊息格式進行解析即可。

示例程式碼見:LengthFieldBasedFrameDecoder自定義編解碼器 gitHub示例

5. 小結

本篇將了關於 Netty 中處理拆包粘包的一些實用工具以及如果實現自定義的編解碼器的方式。每種處理方式都給出了對應的案例操作,大家有興趣的可以下載程式碼自行執行看看處理效果。後面也給出了關於自定義編解碼器的示例,大家如果有興趣可以自己寫一下編解碼操作,下一篇再一起看看編解碼器在訊息讀寫過程被使用在哪個階段。

相關文章