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 {
//...
<span class="hljs-comment">// 讀取傳送過來的消息的長度。</span>
int length = in.readUnsignedShort();
<span class="hljs-comment">//...</span>
<span class="hljs-keyword">if</span> (length > 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 中的拆包器大致如下:
-
固定長度的拆包器 FixedLengthFrameDecoder
每個應用層數據包的都拆分成都是固定長度的大小,比如 1024字節。
這個顯然不大適應在 Java 聊天程序 進行實際應用。
-
行拆包器 LineBasedFrameDecoder
每個應用層數據包,都以換行符作為分隔符,進行分割拆分。
這個顯然不大適應在 Java 聊天程序 進行實際應用。
-
分隔符拆包器 DelimiterBasedFrameDecoder
每個應用層數據包,都通過自定義的分隔符,進行分割拆分。
這個版本,是LineBasedFrameDecoder 的通用版本,本質上是一樣的。
這個顯然不大適應在 Java 聊天程序 進行實際應用。
-
基於數據包長度的拆包器 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) 聊天程序【 億級流量】實戰 開源項目實戰
- Netty 源碼、原理、JAVA NIO 原理
- Java 面試題 一網打盡
- 瘋狂創客圈 【 博客園 總入口 】
本文鏈接:https://blog.csdn.net/crazymakercircle/article/details/83957259