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。
然而,理想很豐滿,現實很骨感。
在實際的通訊過程中,並沒有大家預料的那麼完美。
一種意料之外的情況,如期而至。這就是粘包和半包。
那麼,什麼是粘包和半包?
粘包和半包定義如下:
-
粘包和半包,指的都不是一次是正常的 ByteBuf 快取區接收。
-
粘包,就是接收端讀取的時候,多個傳送過來的 ByteBuf “粘”在了一起。
換句話說,接收端讀取一次的 ByteBuf ,讀到了多個傳送端的 ByteBuf ,是為粘包。
-
半包,就是接收端將一個傳送端的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 {
//...
// 讀取傳送過來的訊息的長度。
int length = in.readUnsignedShort();
//...
if (length > in.readableBytes()) {
// 讀到的半包
// ...
LOG.error("告警:讀到的訊息體長度小於傳送過來的訊息長度");
return;
}
//... 省略了正常包的處理
}
}
具體的原始碼,請參見本文的原始碼工程: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 中的拆包器大致如下:
-
固定長度的拆包器 FixedLengthFrameDecoder
每個應用層資料包的都拆分成都是固定長度的大小,比如 1024位元組。
這個顯然不大適應在 Java 聊天程式 進行實際應用。
-
行拆包器 LineBasedFrameDecoder
每個應用層資料包,都以換行符作為分隔符,進行分割拆分。
這個顯然不大適應在 Java 聊天程式 進行實際應用。
-
分隔符拆包器 DelimiterBasedFrameDecoder
每個應用層資料包,都通過自定義的分隔符,進行分割拆分。
這個版本,是LineBasedFrameDecoder 的通用版本,本質上是一樣的。
這個顯然不大適應在 Java 聊天程式 進行實際應用。
-
基於資料包長度的拆包器 LengthFieldBasedFrameDecoder
將應用層資料包的長度,作為接收端應用層資料包的拆分依據。按照應用層資料包的大小,拆包。這個拆包器,有一個要求,就是應用層協議中包含資料包的長度。
這個顯然比較適和在 Java 聊天程式 進行實際應用。下面我們來應用這個拆分器。
拆包之前的訊息包裝
在使用LengthFieldBasedFrameDecoder 拆包器之前 ,在傳送端需要對protobuf 的訊息包進行一輪包裝。
傳送端包裝的方法是:
在實際的protobuf 二進位制訊息包的前面,加上四個位元組。
前兩個位元組為版本號,後兩個位元組為實際傳送的 protobuf 的訊息長度。
強調一下,二進位制訊息包裝,在傳送端進行。
修改傳送端的編碼器 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 的繼承關係圖。
由此可見,分割器 PackageSpliter 繼承了ChannelInboundHandlerAdapter。
本質上,它是一個入站處理器。
在 關於Netty的入站處理流程一文 Pipeline inbound 中, 我們已經知道,Netty的入站處理的順序,是從pipelin 流水線的前面到後面。
由於在入站過程中,解碼器 ProtobufDecoder 進行應用層 protobuf 的資料包的解碼,而在此之前,必須完成應用包的正確分割。
所以, 分割器 PackageSpliter 必須處於入站流水線處理的第一站,放在最前面。
題外話, PackageSpliter 分割器 和 ProtobufEncoder 編碼器 是否有關係呢?
從流水線處理的角度來說,是沒有次序關係的。
PackageSpliter 是入站處理器。 在入站流程中用到。
ProtobufEncoder 是出站處理器,在出站流程中用到。
特別提示一下: 傳送端不存在粘包和半包問題。這是接收端的事情。
總之,在出站和入站處理流程上,分割器 PackageSpliter 和 編碼器ProtobufEncoder , 沒有半毛錢關係的。
寫在最後
至此為止,終於完成了 Java 聊天程式【 億級流量】實戰的一些基礎開發工作。
包括了協議的編碼解碼。包括了粘包和半包的拆包處理。
大家好,我是作者尼恩。 為大家預告一下接下來的工作:
下一步,基本上可以開始[ 瘋狂創客圈 IM] 聊天器的正式設計和開發的詳細講解了。
瘋狂創客圈 實戰計劃
- Java (Netty) 聊天程式【 億級流量】實戰 開源專案實戰
- Netty 原始碼、原理、JAVA NIO 原理
- Java 面試題 一網打盡
- 瘋狂創客圈 【 部落格園 總入口 】