1. 程式人生 > >Java 粘包/半包 原理與拆包實戰(史上最全)

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

瘋狂創客圈 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個數據包。但是,通訊過程中,接收端出現了 粘包和半包。


1541896556499

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

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

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

半包的實驗

由於在前文 Netty+Protobuf 整合一:實戰案例,帶原始碼 的原始碼中,沒有看到異常的現象。是因為程式碼遮蔽了半包的輸出,所以看到的都是正常的資料包。

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

示意圖如下:

1541864129972

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

 
  
/**
 * 解碼器
 *
 */
public class ProtobufDecoder extends ByteToMessageDecoder {
    //....
   protected void decode(ChannelHandlerContext ctx, ByteBuf in,
         List<Object> out) throws Exception {
      //...
​
      // 讀取傳送過來的訊息的長度。
      int length = in.readUnsignedShort();
​
      //...
      if (length > in.readableBytes()) {
         // 讀到的半包
         // ...
         LOG.error("告警:讀到的訊息體長度小於傳送過來的訊息長度");
         return;
      }
     //...  省略了正常包的處理
   }
}

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

原始碼中,客戶端向伺服器迴圈發了1000個數據包,伺服器接收端,出現了很多的半包的場景。

可以下載原始碼,進行實驗。

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

粘包和半包更全實驗

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

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

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

執行例項,不僅可以看到半包的提示資訊輸出,而且可以看到粘包的提示資訊輸出,示意圖如下:

1541866734826

我們可以看到,伺服器收到的資料包,有包含多個傳送端資料包的,這就是粘包了。

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

這個例項的原始碼,直接簡化了前面的基於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 的訊息長度。

1541902930243

強調一下,二進位制訊息包裝,在傳送端進行。

修改傳送端的編碼器 ProtobufEncoder ,程式碼如下:

 
  
/**
 * 編碼器
 */
public class ProtobufEncoder extends MessageToByteEncoder<ProtoMsg.Message>
{

    @Override
    protected void encode(ChannelHandlerContext ctx, ProtoMsg.Message msg, ByteBuf out)
            throws Exception
    {
​
        byte[] bytes = msg.toByteArray();// 將物件轉換為byte
        int length = bytes.length;// 讀取 ProtoMsg 訊息的長度
        ByteBuf buf = Unpooled.buffer(2 + length);
        // 先將訊息協議的版本寫入,也就是訊息頭
        buf.writeShort(Constants.PROTOCOL_VERSION);
        // 再將 ProtoMsg 訊息的長度寫入
        buf.writeShort(length);
        // 寫入 ProtoMsg 訊息的訊息體
        buf.writeBytes(bytes);
        //傳送
        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 PackageSpliter() {
        super(Integer.MAX_VALUE, Constants.LENGTH_OFFSET,Constants.LENGTH_BYTES_COUNT);
    }
​
    @Override
    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
​
        return super.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 的繼承關係圖。

1541904610308

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

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

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

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

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

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

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

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

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

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

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

寫在最後

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

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

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

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


瘋狂創客圈 實戰計劃
  • Java (Netty) 聊天程式【 億級流量】實戰 開源專案實戰