netty 拆、粘包

定義

TCP是個“流”協議,所謂流,就是沒有界限的一串數據。大家可以想象河里的流水,它們是連成一片的,其間并沒有分界線。TCP底層并不了解上層業務數據的具體含義,它會根據TCP緩沖區的實際情況進行包的劃分,所以在業務上認為,一個完整的包可能會被TCP拆分成多個包進行發送,也有可能把多個小的包封裝成一個大的數據包發送,這就是所謂的TCP粘包和拆包問題。

TCP粘包/拆包問題說明
假設客戶端分別發送了兩個數據包D1和D2給服務端,由于服務端一次讀取的字節數是不定的,故可能存在以下四種情況。
(1) 服務端分兩次讀取到了兩個獨立的數據包,分別是D1和D2,沒有粘包和拆包;
(2) 服務端一次接收到了兩個數據包,D1和D2粘合在一起,被稱為TCP粘包;
(3) 服務端分兩次讀取到了兩個數據包,第一次讀取到了完整的D1包和D2包的部分內容,第二次讀取到了D2包的剩余內容,這被稱為TCP拆包;
(4) 服務端分兩次讀取到了兩個數據包,第一次讀取到了D1包的部分內容D1_1,第二次讀取到了D1包的剩余內容D1_2和D2包的整包。

如果此時服務端TCP接收滑窗非常小,而數據包D1和D2比較大,很有可能會發生第5種可能,即服務端分多次才能將D1和D2包接收完全,期間發生多次拆包。

緣由

問題產生的原因有三個,分別如下:

  1. 應用程序write寫入的字節大小大于套接口發送緩沖區大??;
  2. 進行MSS大小的TCP分段;
  3. 以太網幀的payload大于MTU進行IP分片;


    image.png

由于底層的TCP無法理解上層的業務數據,所以在底層是無法保證數據包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決。

TCP 是一個面向字節流的協議,它是性質是流式的,所以它并沒有分段。就像水流一樣,你沒法知道什么時候開始,什么時候結束。所以他會根據當前的套接字緩沖區的情況進行拆包或是粘包。

首先,人的正常思維習慣是,數據傳輸應該是基于報文的,人們不論是對話還是寫信,肯定是一句一句的話或者一封一封的信,這就是所謂的報文。而 TCP 是什么東西呢?TCP 是一種流式協議,簡單說,你使用它的時候,根本就沒有所謂的報文,無論是聊天說的話還是發的圖片,對于 TCP 來說,通通都是沒有邊界的,TCP 根本不認識這些東西,不知道你按換行時是一句話,你發的圖片是一張圖片,所有的東西都是數據流沒有任何邊界。這顯然不符合人的正常思維,是扭曲的。而無論是人的思維,還是實際的東西,一定是要有邊界的。這就有矛盾了。所以,對于程序員,在使用 TCP 之前,你必須進行報文協議設計。要么你使用別人設計好的報文協議,要么你自己設計。如果你自己不設計,也不使用別人設計好的,那么你肯定錯了,一定會遇到所謂的粘包和拆包。

一般的程序員當然不愿意自己設計,一是沒能力,二是不需要重新發明。那么設計完之后呢,必然是實現。因為自身水平的問題,還是會遇到 TCP 粘包和拆包問題。所以,我的建議是,不要自己去實現,應該去使用別人已經寫好的代碼。

下圖展示了一個 TCP 協議傳輸的過程:

image.png

發送端的字節流都會先傳入緩沖區,再通過網絡傳入到接收端的緩沖區中,最終由接收端獲取。

當我們發送兩個完整包到接收端的時候:

image.png

正常情況會接收到兩個完整的報文。

但也有以下的情況:

image.png

接收到的是一個報文,它是由發送的兩個報文組成的,這樣對于應用程序來說就很難處理了(這樣稱為粘包)。

image.png

還有可能出現上面這樣的雖然收到了兩個包,但是里面的內容卻是互相包含,對于應用來說依然無法解析(拆包)

接下來講一講如果自己直接使用 TCP 的接口,為什么會遇到粘包和拆包問題呢?TCP 的接口簡單說只有兩個:

send(data, length);
recv(buff);

不懂的人會基于人的正常思維想當然地認為 send() 就是發送報文,recv() 就是接收報文。但是,前面已經提到了,TCP 是沒有所謂的報文邊界的,所以你錯了。為什么錯?因為 send() 和 recv() 不是一一對應的。也就是說,send() 的調用次數和 recv() 的調用次數是獨立的,有時是相等的(你在自己機器上和內網測試時基本都是相等的),有時是不相等的(互聯網上非常容易出現)。

現在明白了吧,TCP 的 send() 和 recv() 不是一一對應的,理所當然會粘應用層的包,拆應用層的包。明白了之后怎么解決?簡單,用別人封裝好的代碼?;蛘?,你自己慢慢琢磨去吧!
正確地從TCP socket中讀取報文的代碼必須長這個樣子,如果不長這個樣子,就是錯的!

char recv_buf[];
Buffer buffer;
// 網絡循環:必須在一個循環中讀取網絡,因為網絡數據是源源不斷的。
while(1){
    // 從TCP流中讀取不定長度的一段流數據,不能保證讀到的數據是你期望的長度
    tcp.read(recv_buf);
    // 將這段流數據和之前收到的流數據拼接到一起
    buffer.append(recv_buf);
    // 解析循環:必須在一個循環中解析報文,避免所謂的粘包
    while(1){
        // 嘗試解析報文
        msg = parse(buffer);
        if(!msg){
            // 報文還沒有準備好,糟糕,我們遇到拆包了!跳出解析循環,繼續讀網絡。
            break;
        }
        // 將解析過的報文對應的流數據清除
        buffer.remove(msg.length);
        // 業務處理
        process(msg);
    }
}

對于具體的解析報文這樣的問題只能通過上層的應用來解決,常見的方式有:

  • 在報文末尾增加換行符表明一條完整的消息,這樣在接收端可以根據這個換行符來判斷消息是否完整。
  • 將消息分為消息頭、消息體??梢栽谙㈩^中聲明消息的長度,根據這個長度來獲取報文(比如 808 協議)。
  • 規定好報文長度,不足的空位補齊,取的時候按照長度截取即可。

以上的這些方式我們在 Netty 的 pipline 中里加入對應的解碼器都可以手動實現。
但其實 Netty 已經幫我們做好了,完全可以開箱即用。

LineBasedFrameDecoder 可以基于換行符解決。
DelimiterBasedFrameDecoder可基于分隔符解決。
FixedLengthFrameDecoder可指定長度解決。

字符串拆、粘包

簡單的來說:
多次發送較少內容,會發生粘包現象。
單次發送內容過多,會發生拆包現象
。

下面來模擬一下最簡單的字符串傳輸。

public class NettyClientHandler extends ChannelInboundHandlerAdapter{  
    
    private byte[] req;  
      
    private int counter;   
      
    public NettyClientHandler() {  
        req = ("書到用時方恨少,事非經過不知難!").getBytes();
    }  
      
    @Override  
    public void channelActive(ChannelHandlerContext ctx) throws Exception {  
        ByteBuf message = null;  
        //會發生粘包現象
        for (int i = 0; i < 100; i++) {
            message = Unpooled.buffer(req.length);
            message.writeBytes(req);
            ctx.writeAndFlush(message);
        }
    }  
      
    @Override  
    public void channelRead(ChannelHandlerContext ctx, Object msg)  
        throws Exception {  
        String buf = (String) msg;  
        System.out.println("現在 : " + buf + " ; 條數是 : "+ ++counter);  
    }  
  
    @Override  
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {  
        ctx.close();  
    }  
}

書到用時方恨少,事非經過不知難!發送一百次,就會發送粘包現象;
server端

public class NettyServerHandler extends ChannelInboundHandlerAdapter{  
    private int counter;  
    @Override  
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {  
          
        String body = (String)msg;  
        System.out.println("接受的數據是: " + body + ";條數是: " + ++counter);  
    }  
    @Override  
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {  
        cause.printStackTrace();  
        ctx.close();  
    }  
}  
服務端啟動成功,端口為 :2345
接受的數據是: 書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時;條數是: 1
接受的數據是: 方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,;條數是: 2
接受的數據是: 事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過;條數是: 3
接受的數據是: 不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!?;條數是: 4
接受的數據是: ?到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!書到用時方恨少,事非經過不知難!;條數是: 5

可以看到 ctx.writeAndFlush(message) 在client了調用了100次,也即100次send,但是在server端 read只調用了5次,這也驗證了前面講的send和receive次數不一致問題。

而將《春江花月夜》和《行路難》發送一次就會發送拆包現象;

接受的數據是: 春江潮水連海平,海上明月共潮生。  滟滟隨波千萬里,何處春江無月明!  江流宛轉繞芳甸,月照花林皆似霰;    空里流霜不覺飛,汀上白沙看不見。    江天一色無纖塵,皎皎空中孤月輪。  江畔何人初見月?江月何年初照人?  人生代代無窮已,江月年年望相似。    不知江月待何人,但見長江送流水。    白云一片去悠悠,青楓浦上不勝愁。    誰家今夜扁舟子?何處相思明月樓?    可憐樓上月徘徊,應照離人妝鏡臺。    玉戶簾中卷不去,搗衣砧上拂還來。    此時相望不相聞,愿逐月華流照君。    鴻雁長飛光不度,魚龍潛躍水成文。    昨夜閑潭夢落花,可憐春半不還家。    江水流春去欲盡,江潭落月復西斜。    斜月沉沉藏海霧,碣石瀟湘無限路。    不知乘月幾人歸,落月搖情滿江樹。 噫吁嚱,危乎高哉!蜀道之難,難于上青天!蠶叢及魚鳧,開國何茫然!爾來四萬八千歲,不與秦塞通人煙?;條數是: 1
接受的數據是: ? 西當太白有鳥道,可以橫絕峨眉巔。地崩山摧壯士死,然后天梯石棧相鉤連。上有六龍回日之高標,下有沖波逆折之回川。 黃鶴之飛尚不得過,猿猱欲度愁攀援。青泥何盤盤,百步九折縈巖巒。捫參歷井仰脅息,以手撫膺坐長嘆。 問君西游何時還?畏途巉巖不可攀。但見悲鳥號古木,雄飛雌從繞林間。又聞子規啼夜月,愁空山。 蜀道之難,難于上青天,使人聽此凋朱顏!連峰去天不盈尺,枯松倒掛倚絕壁。飛湍瀑流爭喧豗,砯崖轉石萬壑雷。 其險也如此,嗟爾遠道之人胡為乎來哉!劍閣崢嶸而崔嵬,一夫當關,萬夫莫開。 所守或匪親,化為狼與豺。朝避猛虎,夕避長蛇;磨牙吮血,殺人如麻。錦城雖云樂,不如早還家。 蜀道之難,難于上青天,側身西望長咨嗟!
;條數是: 2

實際只發送了一次,但receive了兩次,發生了拆包行為。

該怎么解決呢?這便可采用之前提到的 LineBasedFrameDecoder 利用換行符解決。LineBasedFrameDecoder 解碼器使用非常簡單,只需要在 pipline 鏈條上添加即可。

//字符串解析,換行防拆包
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new StringDecoder())

構造函數中傳入了 1024 是指報的長度最大不超過這個值,具體可以看下文的源碼分析。

注意,由于 LineBasedFrameDecoder 解碼器是通過換行符來判斷的,所以在發送時,一條完整的消息需要加上 \n。

LineBasedFrameDecoder 的原理

image.png

從這個邏輯中可以看出就是尋找報文中是否包含換行符,并進行相應的截取。

由于是通過緩沖區讀取的,所以即使這次沒有換行符的數據,只要下一次的報文存在換行符,上一輪的數據也不會丟。

為什么要粘包拆包

為什么要粘包
在用戶數據量非常小的情況下,極端情況下,一個字節,該TCP數據包的有效載荷非常低,傳遞100字節的數據,需要100次TCP傳送,100次ACK,在應用及時性要求不高的情況下,將這100個有效數據拼接成一個數據包,那會縮短到一個TCP數據包,以及一個ack,有效載荷提高了,帶寬也節省了。

非極端情況,有可能兩個數據包拼接成一個數據包,也有可能一個半的數據包拼接成一個數據包,也有可能兩個半的數據包拼接成一個數據包。

為什么要拆包
拆包和粘包是相對的,一端粘了包,另外一端就需要將粘過的包拆開,舉個栗子,發送端將三個數據包粘成兩個TCP數據包發送到接收端,接收端就需要根據應用協議將兩個數據包重新組裝成三個數據包. 還有一種情況就是用戶數據包超過了mss(最大報文長度),那么這個數據包在發送的時候必須拆分成幾個數據包,接收端收到之后需要將這些數據包粘合起來之后,再拆開。

拆包的原理
在沒有netty的情況下,用戶如果自己需要拆包,基本原理就是不斷從TCP緩沖區中讀取數據,每次讀取完都需要判斷是否是一個完整的數據包

1.如果當前讀取的數據不足以拼接成一個完整的業務數據包,那就保留該數據,繼續從tcp緩沖區中讀取,直到得到一個完整的數據包

  1. 如果當前讀到的數據加上已經讀取的數據足夠拼接成一個數據包,那就將已經讀取的數據拼接上本次讀取的數據,夠成一個完整的業務數據包傳遞到業務邏輯,多余的數據仍然保留,以便和下次讀到的數據嘗試拼接.

netty 中的拆包也是如上這個原理,內部會有一個累加器,每次讀取到數據都會不斷累加,然后嘗試對累加到的數據進行拆包,拆成一個完整的業務數據包,這個基類叫做 ByteToMessageDecoder,下面我們先詳細分析下這個類

累加器
ByteToMessageDecoder 中定義了兩個累加器

public static final Cumulator MERGE_CUMULATOR = ...;
public static final Cumulator COMPOSITE_CUMULATOR = ...;

默認情況下,會使用 MERGE_CUMULATOR

private Cumulator cumulator = MERGE_CUMULATOR;

MERGE_CUMULATOR 的原理是每次都將讀取到的數據通過內存拷貝的方式,拼接到一個大的字節容器中,這個字節容器在 ByteToMessageDecoder中叫做 cumulation

ByteBuf cumulation;

下面我們看一下 MERGE_CUMULATOR 是如何將新讀取到的數據累加到字節容器里的

public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
        ByteBuf buffer;
        if (cumulation.writerIndex() > cumulation.maxCapacity() - in.readableBytes()
                || cumulation.refCnt() > 1) {
            buffer = expandCumulation(alloc, cumulation, in.readableBytes());
        } else {
            buffer = cumulation;
        }
        buffer.writeBytes(in);
        in.release();
        return buffer;
}

netty 中ByteBuf的抽象,使得累加非常簡單,通過一個簡單的api調用 buffer.writeBytes(in); 便將新數據累加到字節容器中,為了防止字節容器大小不夠,在累加之前還進行了擴容處理

static ByteBuf expandCumulation(ByteBufAllocator alloc, ByteBuf cumulation, int readable) {
        ByteBuf oldCumulation = cumulation;
        cumulation = alloc.buffer(oldCumulation.readableBytes() + readable);
        cumulation.writeBytes(oldCumulation);
        oldCumulation.release();
        return cumulation;
}

下面我們回到netty讀的主流程,目光集中在 channelRead 方法,channelRead方法是每次從TCP緩沖區讀到數據都會調用的方法,觸發點在AbstractNioByteChannel的read方法中,里面有個while循環不斷讀取,讀取到一次就觸發一次channelRead

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    if (msg instanceof ByteBuf) {
        CodecOutputList out = CodecOutputList.newInstance();
        try {
            ByteBuf data = (ByteBuf) msg;
            first = cumulation == null;
            if (first) {
                cumulation = data;
            } else {
                cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
            }
            callDecode(ctx, cumulation, out);
        } catch (DecoderException e) {
            throw e;
        } catch (Throwable t) {
            throw new DecoderException(t);
        } finally {
            if (cumulation != null && !cumulation.isReadable()) {
                numReads = 0;
                cumulation.release();
                cumulation = null;
            } else if (++ numReads >= discardAfterReads) {
                numReads = 0;
                discardSomeReadBytes();
            }

            int size = out.size();
            decodeWasNull = !out.insertSinceRecycled();
            fireChannelRead(ctx, out, size);
            out.recycle();
        }
    } else {
        ctx.fireChannelRead(msg);
    }
}

可以分為以下幾個邏輯步驟
1.累加數據
2.將累加到的數據傳遞給業務進行業務拆包
3.清理字節容器
4.傳遞業務數據包給業務解碼器處理

累加數據
如果當前累加器沒有數據,就直接跳過內存拷貝,直接將字節容器的指針指向新讀取的數據,否則,調用累加器累加數據至字節容器

ByteBuf data = (ByteBuf) msg;
first = cumulation == null;
if (first) {
    cumulation = data;
} else {
    cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}

將累加到的數據傳遞給業務進行拆包
到這一步,字節容器里的數據已是目前未拆包部分的所有的數據了

CodecOutputList out = CodecOutputList.newInstance();
callDecode(ctx, cumulation, out);

callDecode 將嘗試將字節容器的數據拆分成業務數據包塞到業務數據容器out中

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    while (in.isReadable()) {
        // 記錄一下字節容器中有多少字節待拆
        int oldInputLength = in.readableBytes();
        decode(ctx, in, out);
        if (out.size() == 0) {
            // 拆包器未讀取任何數據
            if (oldInputLength == in.readableBytes()) {
                break;
            } else {
             // 拆包器已讀取部分數據,還需要繼續
                continue;
            }
        }

        if (oldInputLength == in.readableBytes()) {
            throw new DecoderException(
                    StringUtil.simpleClassName(getClass()) +
                    ".decode() did not read anything but decoded a message.");
        }

        if (isSingleDecode()) {
            break;
        }
    }
}

在解碼之前,先記錄一下字節容器中有多少字節待拆,然后調用抽象函數 decode 進行拆包

protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;

netty中對各種用戶協議的支持就體現在這個抽象函數中,傳進去的是當前讀取到的未被消費的所有的數據,以及業務協議包容器,所有的拆包器最終都實現了該抽象方法

業務拆包完成之后,如果發現并沒有拆到一個完整的數據包,這個時候又分兩種情況

  1. 一個是拆包器什么數據也沒讀取,可能數據還不夠業務拆包器處理,直接break等待新的數據
  2. 拆包器已讀取部分數據,說明解碼器仍然在工作,繼續解碼

業務拆包完成之后,如果發現已經解到了數據包,但是,發現并沒有讀取任何數據,這個時候就會拋出一個Runtime異常 DecoderException,告訴你,你什么數據都沒讀取,卻解析出一個業務數據包,這是有問題的

3 清理字節容器

業務拆包完成之后,只是從字節容器中取走了數據,但是這部分空間對于字節容器來說依然保留著,而字節容器每次累加字節數據的時候都是將字節數據追加到尾部,如果不對字節容器做清理,那么時間一長就會OOM
正常情況下,其實每次讀取完數據,netty都會在下面這個方法中將字節容器清理,只不過,當發送端發送數據過快,channelReadComplete可能會很久才被調用一次

public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
    numReads = 0;
    discardSomeReadBytes();
    if (decodeWasNull) {
        decodeWasNull = false;
        if (!ctx.channel().config().isAutoRead()) {
            ctx.read();
        }
    }
    ctx.fireChannelReadComplete();
}

如果一次數據讀取完畢之后(可能接收端一邊收,發送端一邊發,這里的讀取完畢指的是接收端在某個時間不再接受到數據為止),發現仍然沒有拆到一個完整的用戶數據包,即使該channel的設置為非自動讀取,也會觸發一次讀取操作 ctx.read(),該操作會重新向selector注冊op_read事件,以便于下一次能讀到數據之后拼接成一個完整的數據包

所以為了防止發送端發送數據過快,netty會在每次讀取到一次數據,業務拆包之后對字節字節容器做清理,清理部分的代碼如下

if (cumulation != null && !cumulation.isReadable()) {
    numReads = 0;
    cumulation.release();
    cumulation = null;
} else if (++ numReads >= discardAfterReads) {
    numReads = 0;
    discardSomeReadBytes();
}

如果字節容器當前已無數據可讀取,直接銷毀字節容器,并且標注一下當前字節容器一次數據也沒讀取

4 傳遞業務數據包給業務解碼器處理
以上三個步驟完成之后,就可以將拆成的包丟到業務解碼器處理了,代碼如下

int size = out.size();
decodeWasNull = !out.insertSinceRecycled();
fireChannelRead(ctx, out, size);
out.recycle();

期間用一個成員變量 decodeWasNull 來標識本次讀取數據是否拆到一個業務數據包,然后調用 fireChannelRead 將拆到的業務數據包都傳遞到后續的handler

static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {
    for (int i = 0; i < numElements; i ++) {
        ctx.fireChannelRead(msgs.getUnsafe(i));
    }
}

這樣,就可以把一個個完整的業務數據包傳遞到后續的業務解碼器進行解碼,隨后處理業務邏輯.

下面,以一個具體的例子來看看業netty自帶的拆包器是如何來拆包的

這個類叫做 LineBasedFrameDecoder,基于行分隔符的拆包器,TA可以同時處理 \n以及\r\n兩種類型的行分隔符,核心方法都在繼承的 decode 方法中

protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
    Object decoded = decode(ctx, in);
    if (decoded != null) {
        out.add(decoded);
    }
}

1 找到換行符位置

final int eol = findEndOfLine(buffer);

private static int findEndOfLine(final ByteBuf buffer) {
    int i = buffer.forEachByte(ByteProcessor.FIND_LF);
    if (i > 0 && buffer.getByte(i - 1) == '\r') {
        i--;
    }
    return i;
}

ByteProcessor FIND_LF = new IndexOfProcessor((byte) '\n');

for循環遍歷,找到第一個 \n 的位置,如果\n前面的字符為\r,那就返回\r的位置

2 非discarding模式的處理
接下來,netty會判斷,當前拆包是否屬于丟棄模式,用一個成員變量來標識

private boolean discarding;

第一次拆包不在discarding模式,于是進入以下環節

非discarding模式下找到行分隔符的處理

// 1.計算分隔符和包長度
final ByteBuf frame;
final int length = eol - buffer.readerIndex();
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;

// 丟棄異常數據
if (length > maxLength) {
    buffer.readerIndex(eol + delimLength);
    fail(ctx, length);
    return null;
}

// 取包的時候是否包括分隔符
if (stripDelimiter) {
    frame = buffer.readRetainedSlice(length);
    buffer.skipBytes(delimLength);
} else {
    frame = buffer.readRetainedSlice(length + delimLength);
}
return frame;
  1. 首先,新建一個幀,計算一下當前包的長度和分隔符的長度(因為有兩種分隔符)
    2.然后判斷一下需要拆包的長度是否大于該拆包器允許的最大長度(maxLength),這個參數在構造函數中被傳遞進來,如超出允許的最大長度,就將這段數據拋棄,返回null
    3.最后,將一個完整的數據包取出,如果構造本解包器的時候指定 stripDelimiter為false,即解析出來的包包含分隔符,默認為不包含分隔符

2.2 非discarding模式下未找到分隔符的處理
沒有找到對應的行分隔符,說明字節容器沒有足夠的數據拼接成一個完整的業務數據包,進入如下流程處理

final int length = buffer.readableBytes();
if (length > maxLength) {
    discardedBytes = length;
    buffer.readerIndex(buffer.writerIndex());
    discarding = true;
    if (failFast) {
        fail(ctx, "over " + discardedBytes);
    }
}
return null;

首先取得當前字節容器的可讀字節個數,接著,判斷一下是否已經超過可允許的最大長度,如果沒有超過,直接返回null,字節容器中的數據沒有任何改變,否則,就需要進入丟棄模式. 使用一個成員變量 discardedBytes 來表示已經丟棄了多少數據,然后將字節容器的讀指針移到寫指針,意味著丟棄這一部分數據,設置成員變量discarding為true表示當前處于丟棄模式。

3 discarding模式

如果解包的時候處在discarding模式,也會有兩種情況發生

3.1 discarding模式下找到行分隔符

在discarding模式下,如果找到分隔符,那可以將分隔符之前的都丟棄掉

final int length = discardedBytes + eol - buffer.readerIndex();
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
buffer.readerIndex(eol + delimLength);
discardedBytes = 0;
discarding = false;
if (!failFast) {
    fail(ctx, length);
}

計算出分隔符的長度之后,直接把分隔符之前的數據全部丟棄,當然丟棄的字符也包括分隔符,經過這么一次丟棄,后面就有可能是正常的數據包,下一次解包的時候就會進入正常的解包流程

3.2discarding模式下未找到行分隔符

這種情況比較簡單,因為當前還在丟棄模式,沒有找到行分隔符意味著當前一個完整的數據包還沒丟棄完,當前讀取的數據是丟棄的一部分,所以直接丟棄

discardedBytes += buffer.readableBytes();
buffer.readerIndex(buffer.writerIndex());

簡單說:LineBasedFrameDecoder的工作原理是它依次遍歷ByteBuf中的可讀字節,判斷是否有“\n”或“\r\n”,如果有,就以此位置為結束位置,從可讀索引到結束位置區間的字節就組成了一行。它是以換行符為結束標志的解碼器,支持攜帶結束符或不攜帶結束符兩種解碼方式,同時支持配置單行的最大長度。如果連接讀取到最大長度后仍然沒有發現換行符,就會拋出異常,同時忽略掉之前讀到的異常碼流。防止由于數據報沒有攜帶換行符導致接收到 ByteBuf 無限制積壓,引起系統內存溢出。

通常LineBasedFrameDecoder會和StringDecoder搭配使用。StringDecoder的功能非常簡單,就是將接收到的對象轉換成字符串,然后繼續調用后面的Handler。LineBasedFrameDecoder+StringDecoder組合就是按行切換的文本解碼器,它本設計用來支持TCP的粘包和拆包。對于文本類協議的解析,文本換行解碼器非常實用,例如對 HTTP 消息頭的解析、FTP 協議消息的解析等。

而DelimiterBasedFrameDecoder是分隔符解碼器,用戶可以指定消息結束的分隔符,它可以自動完成以分隔符作為碼流結束標識的消息的解碼。回車換行解碼器實際上是一種特殊的DelimiterBasedFrameDecoder解碼器。使用起來十分簡單,只需要在ChannelPipeline 中添加即可。

小結

netty中的拆包過程其實是和你自己去拆包過程一樣,只不過TA將拆包過程中邏輯比較獨立的部分抽象出來變成幾個不同層次的類,方便各種協議的擴展.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。