Netty 粘包 拆包 | 史上最全解讀


Netty 粘包/半包原理與拆包實戰(史上最全)

瘋狂創客圈 Java 聊天程序【 億級流量】實戰系列之13 【博客園 總入口


本文的源碼工程Netty 粘包/半包原理與拆包實戰 源碼

  • 本實例是《Netty 粘包/半包原理與拆包實戰》 一文的源代碼工程。

寫在前面

大家好,我是作者尼恩。

為了完成了一個高性能的 Java 聊天程序,在前面的文章中,尼恩已經再一次的進行了通訊協議的重新選擇。

這就是:放棄了大家非常熟悉的json 格式,選擇了性能更佳的 Protobuf協議

在上一篇文章中,並且完成了Netty 和 Protobuf協議整合實戰。

具體的文章為: Netty+Protobuf 整合一:實戰案例,帶源碼

另外,專門開出一篇文章,介紹了通訊消息數據包的幾條設計准則。

具體的文章為: Netty +Protobuf 整合二:protobuf 消息通訊協議設計的幾個准則

在開始聊天器實戰開發之前,還有一個非常基礎的問題,需要解決:這就是通訊的粘包和半包問題。

什么是粘包和半包?

先從數據包的發送和接收開始講起。

我們知道, Netty 發送和讀取數據的單位,可以形象的使用 ByteBuf 來充當。

每一次發送,就是向Channel 寫入一個 ByteBuf ;每一次讀取,就是從 Channel 讀到一個 ByteBuf 。

發送一次數據,舉例如下:

channel.writeAndFlush(buffer);

   
   
  
  
          

讀取一次數據,舉例如下:

public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf byteBuf = (ByteBuf) msg;
   //....
}

   
   
  
  
          

我們的理想是:發送端每發送一個buffer,接收端就能接收到一個一模一樣的buffer。

然而,理想很豐滿,現實很骨感。

在實際的通訊過程中,並沒有大家預料的那么完美。

一種意料之外的情況,如期而至。這就是粘包和半包。

那么,什么是粘包和半包?

粘包和半包定義如下:

  1. 粘包和半包,指的都不是一次是正常的 ByteBuf 緩存區接收。

  2. 粘包,就是接收端讀取的時候,多個發送過來的 ByteBuf “粘”在了一起。

    換句話說,接收端讀取一次的 ByteBuf ,讀到了多個發送端的 ByteBuf ,是為粘包。

  3. 半包,就是接收端將一個發送端的ByteBuf “拆”開了,形成一個破碎的包,我們定義這種 ByteBuf 為半包。

    換句話說,接收端讀取一次的 ByteBuf ,讀到了發送端的一個 ByteBuf的一部分,是為半包。

粘包和半包 圖解

上面的理論比較抽象,下面用一幅圖來形象說明。

下圖中,發送端發出4個數據包,接受端也接受到了4個數據包。但是,通訊過程中,接收端出現了 粘包和半包。
在這里插入圖片描述

接收端收到的第一個包,正常。

接收端收到的第二個包,就是一個粘包。 將發送端的第二個包、第三個包,粘在一起了。

接收端收到的第三個包,第四個包,就是半包。將發送端的的第四個包,分開成了兩個了。

半包的實驗

由於在前文 Netty+Protobuf 整合一:實戰案例,帶源碼 的源碼中,沒有看到異常的現象。是因為代碼屏蔽了半包的輸出,所以看到的都是正常的數據包。

稍微調整一下,在前文解碼器的代碼,加上半包的提示信息輸出,就可以看到半包的提示。

示意圖如下:
在這里插入圖片描述

調整過的半包警告的代碼,如下:

/** * 解碼器 * */
public class ProtobufDecoder extends ByteToMessageDecoder {
    //....
   protected void decode(ChannelHandlerContext ctx, ByteBuf in,
         List<Object> out) throws Exception {
      //...
  <span class="hljs-comment">// 讀取傳送過來的消息的長度。</span>
  int length = in.readUnsignedShort();

  <span class="hljs-comment">//...</span>
  <span class="hljs-keyword">if</span> (length &gt; in.readableBytes()) {
     <span class="hljs-comment">// 讀到的半包</span>
     <span class="hljs-comment">// ...</span>
     <span class="hljs-type">LOG</span>.error(<span class="hljs-string">"告警:讀到的消息體長度小於傳送過來的消息長度"</span>);
     <span class="hljs-keyword">return</span>;
  }
 <span class="hljs-comment">//...  省略了正常包的處理</span>

}
}

具體的源碼,請參見本文的源碼工程Netty 粘包/半包原理與拆包實戰 源碼

源碼中,客戶端向服務器循環發了1000個數據包,服務器接收端,出現了很多的半包的場景。

可以下載源碼,進行實驗。

實驗時,服務器端運行 ChatServerApp 的main方法,客戶端運行 ChatClientApp 的main方法,就可以看到上面圖片中所示的半包的結果。

粘包和半包更全實驗

上面的實例,只能看到半包的結果,看不到粘包的結果。

為了看到粘包的場景,這里,不使用protobuf 協議,直接使用緩沖區進行讀寫通訊,設計了一個的簡單的演示實驗案例。

案例已經設計好,可以下載源碼,進行實驗。

運行實例,不僅可以看到半包的提示信息輸出,而且可以看到粘包的提示信息輸出,示意圖如下:

在這里插入圖片描述

我們可以看到,服務器收到的數據包,有包含多個發送端數據包的,這就是粘包了。

另外,接收端還有出現亂碼的數據包,就是只包含部分發送端數據,這就是半包了。

這個實例的源碼,直接簡化了前面的基於Protobuf協議通訊的實例源碼。代碼的邏輯結構,是一樣的。

源碼中,客戶端向服務器循環發了1000個數據包,服務器接收端,收到數據包,直接在屏幕輸出。

服務器端運行:DemoServerApp 的main方法,客戶端運行 DemoClientApp的main方法,就可以看到上面圖片中所示的半包的結果。

本實驗的具體的源碼,還是請參見本文的源碼工程Netty 粘包/半包原理與拆包實戰 源碼

粘包和半包原理

這得從底層說起。

在操作系統層面來說,我們使用了 TCP 協議。

在Netty的應用層,按照 ByteBuf 為 單位來發送數據,但是到了底層操作系統仍然是按照字節流發送數據,因此,從底層到應用層,需要進行二次拼裝

操作系統底層,是按照字節流的方式讀入,到了 Netty 應用層面,需要二次拼裝成 ByteBuf。

這就是粘包和半包的根源。

在Netty 層面,拼裝成ByteBuf時,就是對底層緩沖的讀取,這里就有問題了。

首先,上層應用層每次讀取底層緩沖的數據容量是有限制的,當TCP底層緩沖數據包比較大時,將被分成多次讀取,造成斷包,在應用層來說,就是半包。

其次,如果上層應用層一次讀到多個底層緩沖數據包,就是粘包。

如何解決呢?

基本思路是,在接收端,需要根據自定義協議來,來讀取底層的數據包,重新組裝我們應用層的數據包,這個過程通常在接收端稱為拆包

拆包的原理

拆包基本原理,簡單來說:

  • 接收端應用層不斷從底層的TCP 緩沖區中讀取數據。

  • 每次讀取完,判斷一下是否為一個完整的應用層數據包。如果是,上層應用層數據包讀取完成。

  • 如果不是,那就保留該數據在應用層緩沖區,然后繼續從 TCP 緩沖區中讀取,直到得到一個完整的應用層數據包為止。

  • 至此,半包問題得以解決

  • 如果從TCP底層讀到了多個應用層數據包,則將整個應用層緩沖區,拆成一個一個的獨立的應用層數據包,返回給調用程序。

  • 至此,粘包問題得以解決

Netty 中的拆包器

拆包這個工作,Netty 已經為大家備好了很多不同的拆包器。本着不重復發明輪子的原則,我們直接使用Netty現成的拆包器。

Netty 中的拆包器大致如下:

  1. 固定長度的拆包器 FixedLengthFrameDecoder

    每個應用層數據包的都拆分成都是固定長度的大小,比如 1024字節。

    這個顯然不大適應在 Java 聊天程序 進行實際應用。

  2. 行拆包器 LineBasedFrameDecoder

    每個應用層數據包,都以換行符作為分隔符,進行分割拆分。

    這個顯然不大適應在 Java 聊天程序 進行實際應用。

  3. 分隔符拆包器 DelimiterBasedFrameDecoder

    每個應用層數據包,都通過自定義的分隔符,進行分割拆分。

    這個版本,是LineBasedFrameDecoder 的通用版本,本質上是一樣的。

    這個顯然不大適應在 Java 聊天程序 進行實際應用。

  4. 基於數據包長度的拆包器 LengthFieldBasedFrameDecoder

    將應用層數據包的長度,作為接收端應用層數據包的拆分依據。按照應用層數據包的大小,拆包。這個拆包器,有一個要求,就是應用層協議中包含數據包的長度。

    這個顯然比較適和在 Java 聊天程序 進行實際應用。下面我們來應用這個拆分器。

拆包之前的消息包裝

在使用LengthFieldBasedFrameDecoder 拆包器之前 ,在發送端需要對protobuf 的消息包進行一輪包裝

發送端包裝的方法是:

在實際的protobuf 二進制消息包的前面,加上四個字節。

前兩個字節為版本號,后兩個字節為實際發送的 protobuf 的消息長度。

在這里插入圖片描述

強調一下,二進制消息包裝,在發送端進行。

修改發送端的編碼器 ProtobufEncoder ,代碼如下:

/** * 編碼器 */
public class ProtobufEncoder extends MessageToByteEncoder<ProtoMsg.Message>
{
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">protected</span> void encode(<span class="hljs-type">ChannelHandlerContext</span> ctx, <span class="hljs-type">ProtoMsg</span>.<span class="hljs-type">Message</span> msg, <span class="hljs-type">ByteBuf</span> out)
        <span class="hljs-keyword">throws</span> <span class="hljs-type">Exception</span>
{

    byte[] bytes = msg.toByteArray();<span class="hljs-comment">// 將對象轉換為byte</span>
    int length = bytes.length;<span class="hljs-comment">// 讀取 ProtoMsg 消息的長度</span>
    <span class="hljs-type">ByteBuf</span> buf = <span class="hljs-type">Unpooled</span>.buffer(<span class="hljs-number">2</span> + length);
    <span class="hljs-comment">// 先將消息協議的版本寫入,也就是消息頭</span>
    buf.writeShort(<span class="hljs-type">Constants</span>.<span class="hljs-type">PROTOCOL_VERSION</span>);
    <span class="hljs-comment">// 再將 ProtoMsg 消息的長度寫入</span>
    buf.writeShort(length);
    <span class="hljs-comment">// 寫入 ProtoMsg 消息的消息體</span>
    buf.writeBytes(bytes);
    <span class="hljs-comment">//發送</span>
    out.writeBytes(buf);

}

}

發送端的步驟是:

  • 先將消息協議的版本寫入,也就是消息頭

​ buf.writeShort(Constants.PROTOCOL_VERSION);

  • 再將 ProtoMsg 消息的長度寫入
    buf.writeShort(length);

  • 最后,寫入 ProtoMsg 消息的消息體
    buf.writeBytes(bytes);

開發一個接收端的自定義拆包器

使用Netty中,基於長度域拆包器 LengthFieldBasedFrameDecoder,按照實際的應用層數據包長度來拆分。

需要做兩個工作:

  • 設置長度信息(長度域)在數據包中的位置。
  • 設置長度信息(長度域)自身的長度,也就是占用的字節數。

在前面的小節中,我們的長度信息(長度域)的占用字節數為 2個字節; 在報文中的所處的位置,長度信息(長度域)處於版本號之后。

版本號是2個字節,從0開始數,長度信息(長度域)的在數據包中的位置為2。

這些數據定義在Constansts常量類中。

public class Constants {
    //協議版本號
    public static final short PROTOCOL_VERSION = 1;
    //頭部的長度: 版本號 + 報文長度
    public static final short PROTOCOL_HEADLENGTH = 4;
    //長度的偏移
    public static final short LENGTH_OFFSET = 2;
    //長度的字節數
    public static final short LENGTH_BYTES_COUNT = 2;

}

有了這些數據之后,可以基於Netty 的長度拆包器 LengthFieldBasedFrameDecoder, 開發自己的長度分割器。

新開發的分割器為PackageSpliter,代碼如下:

package com.crazymakercircle.chat.common.codec;

public class PackageSpliter extends LengthFieldBasedFrameDecoder
{

public <span class="hljs-type">PackageSpliter</span>() {
    <span class="hljs-keyword">super</span>(<span class="hljs-type">Integer</span>.<span class="hljs-type">MAX_VALUE</span>, <span class="hljs-type">Constants</span>.<span class="hljs-type">LENGTH_OFFSET</span>,<span class="hljs-type">Constants</span>.<span class="hljs-type">LENGTH_BYTES_COUNT</span>);
}

<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">protected</span> <span class="hljs-type">Object</span> decode(<span class="hljs-type">ChannelHandlerContext</span> ctx, <span class="hljs-type">ByteBuf</span> in) <span class="hljs-keyword">throws</span> <span class="hljs-type">Exception</span> {

    <span class="hljs-keyword">return</span> <span class="hljs-keyword">super</span>.decode(ctx, in);
}

}

分割器 PackageSpliter 繼承了 LengthFieldBasedFrameDecoder,傳入了三個參數。

  • 長度的偏移量 ,這里是 Constants.LENGTH_OFFSET,值為 2
  • 長度的字節數,這里是 Constants.LENGTH_BYTES_COUNT,值為 2
  • 最大的應用包長度,這里是 Integer.MAX_VALUE,表示不限制

分割器 寫好之后,只需要在 pipeline 的最前面加上這個分割器,就可以使用這個分割器(自定義的拆包器)。

自定義拆包器的實際應用

在服務器端的 pipeline 的最前面加上這個分割器,代碼如下:

package com.crazymakercircle.chat.server;
//...

@Service("ChatServer")
public class ChatServer
{

static final Logger LOGGER = LoggerFactory.getLogger(ChatServer.class);
//...
//有連接到達時會創建一個channel
protected void initChannel(SocketChannel ch) throws Exception
{ //應用自定義拆包器
ch.pipeline().addLast(new PackageSpliter());
ch.pipeline().addLast(new ProtobufDecoder());
ch.pipeline().addLast(new ProtobufEncoder());
// pipeline管理channel中的Handler
// 在channel隊列中添加一個handler來處理業務
ch.pipeline().addLast("serverHandler", serverHandler);
}
});
//....
}

在發送端的 pipeline 的最前面加上這個分割器,代碼也是類似的, 這里不再贅述。大家可以下載源碼查看。

為什么拆包器要加在pipeline 的最前面

這一點,需要從PackageSpliter 的根源講起。

下面是自定義分割器 PackageSpliter 的繼承關系圖。

在這里插入圖片描述

由此可見,分割器 PackageSpliter 繼承了ChannelInboundHandlerAdapter。

本質上,它是一個入站處理器

在 關於Netty的入站處理流程一文 Pipeline inbound 中, 我們已經知道,Netty的入站處理的順序,是從pipelin 流水線的前面到后面。

由於在入站過程中,解碼器 ProtobufDecoder 進行應用層 protobuf 的數據包的解碼,而在此之前,必須完成應用包的正確分割。

所以, 分割器 PackageSpliter 必須處於入站流水線處理的第一站,放在最前面。

題外話, PackageSpliter 分割器 和 ProtobufEncoder 編碼器 是否有關系呢?

從流水線處理的角度來說,是沒有次序關系的。

PackageSpliter 是入站處理器。 在入站流程中用到。

ProtobufEncoder 是出站處理器,在出站流程中用到。

特別提示一下: 發送端不存在粘包和半包問題。這是接收端的事情。

總之,在出站和入站處理流程上,分割器 PackageSpliter 和 編碼器ProtobufEncoder , 沒有半毛錢關系的。

寫在最后

至此為止,終於完成了 Java 聊天程序【 億級流量】實戰的一些基礎開發工作。

包括了協議的編碼解碼。包括了粘包和半包的拆包處理。

大家好,我是作者尼恩。 為大家預告一下接下來的工作:

下一步,基本上可以開始[ 瘋狂創客圈 IM] 聊天器的正式設計和開發的詳細講解了。


瘋狂創客圈 實戰計划
  • Java (Netty) 聊天程序【 億級流量】實戰 開源項目實戰

本文鏈接:https://blog.csdn.net/crazymakercircle/article/details/83957259


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM