實戰:拆包粘包理論與解決方案
本小節我們來學習一下 Netty 裡面拆包和粘包的概念,并且如何選擇适合我們應用程式的拆包器
在開始本小節之前,我們首先來看一個例子,本小節的例子我們選擇用戶端與服務端雙向通信這小節的代碼,然後做适當修改
拆包粘包例子
用戶端 FirstClientHandler
public class FirstClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) {
for (int i = 0; i < 1000; i++) {
ByteBuf buffer = getByteBuf(ctx);
ctx.channel().writeAndFlush(buffer);
}
}
private ByteBuf getByteBuf(ChannelHandlerContext ctx) {
byte[] bytes = "你好,歡迎關注我的微信公衆号,《閃電俠的部落格》!".getBytes(Charset.forName("utf-8"));
ByteBuf buffer = ctx.alloc().buffer();
buffer.writeBytes(bytes);
return buffer;
}
}
用戶端在連接配接建立成功之後,使用一個 for 循環,不斷向服務端寫一串資料
服務端 FirstServerHandler
public class FirstServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println(new Date() + ": 服務端讀到資料 -> " + byteBuf.toString(Charset.forName("utf-8")));
}
}
服務端收到資料之後,僅僅把資料列印出來,讀者可以花幾分鐘時間思考一下,服務端的輸出會是什麼樣子的?
可能很多讀者覺得服務端會輸出 1000 次 “你好,歡迎關注我的微信公衆号,《閃電俠的部落格》!”,然而實際上服務端卻是如下輸出:

從服務端的控制台輸出可以看出,存在三種類型的輸出
- 一種是正常的字元串輸出。
- 一種是多個字元串“粘”在了一起,我們定義這種 ByteBuf 為粘包。
- 一種是一個字元串被“拆”開,形成一個破碎的包,我們定義這種 ByteBuf 為半包。
為什麼會有粘包半包現象?
我們需要知道,盡管我們在應用層面使用了 Netty,但是對于作業系統來說,隻認 TCP 協定,盡管我們的應用層是按照 ByteBuf 為 機關來發送資料,但是到了底層作業系統仍然是按照位元組流發送資料,是以,資料到了服務端,也是按照位元組流的方式讀入,然後到了 Netty 應用層面,重新拼裝成 ByteBuf,而這裡的 ByteBuf 與用戶端按順序發送的 ByteBuf 可能是不對等的。是以,我們需要在用戶端根據自定義協定來組裝我們應用層的資料包,然後在服務端根據我們的應用層的協定來組裝資料包,這個過程通常在服務端稱為拆包,而在用戶端稱為粘包。
拆包和粘包是相對的,一端粘了包,另外一端就需要将粘過的包拆開,舉個栗子,發送端将三個資料包粘成兩個 TCP 資料包發送到接收端,接收端就需要根據應用協定将兩個資料包重新組裝成三個資料包。
拆包的原理
在沒有 Netty 的情況下,使用者如果自己需要拆包,基本原理就是不斷從 TCP 緩沖區中讀取資料,每次讀取完都需要判斷是否是一個完整的資料包
- 如果目前讀取的資料不足以拼接成一個完整的業務資料包,那就保留該資料,繼續從 TCP 緩沖區中讀取,直到得到一個完整的資料包。
- 如果目前讀到的資料加上已經讀取的資料足夠拼接成一個資料包,那就将已經讀取的資料拼接上本次讀取的資料,構成一個完整的業務資料包傳遞到業務邏輯,多餘的資料仍然保留,以便和下次讀到的資料嘗試拼接。
如果我們自己實作拆包,這個過程将會非常麻煩,我們的每一種自定義協定,都需要自己實作,還需要考慮各種異常,而 Netty 自帶的一些開箱即用的拆包器已經完全滿足我們的需求了,下面我們來介紹一下 Netty 有哪些自帶的拆包器。
Netty 自帶的拆包器
1. 固定長度的拆包器 FixedLengthFrameDecoder
如果你的應用層協定非常簡單,每個資料包的長度都是固定的,比如 100,那麼隻需要把這個拆包器加到 pipeline 中,Netty 會把一個個長度為 100 的資料包 (ByteBuf) 傳遞到下一個 channelHandler。
2. 行拆包器 LineBasedFrameDecoder
從字面意思來看,發送端發送資料包的時候,每個資料包之間以換行符作為分隔,接收端通過 LineBasedFrameDecoder 将粘過的 ByteBuf 拆分成一個個完整的應用層資料包。
3. 分隔符拆包器 DelimiterBasedFrameDecoder
DelimiterBasedFrameDecoder 是行拆包器的通用版本,隻不過我們可以自定義分隔符。
4. 基于長度域拆包器 LengthFieldBasedFrameDecoder
最後一種拆包器是最通用的一種拆包器,隻要你的自定義協定中包含長度域字段,均可以使用這個拆包器來實作應用層拆包。由于上面三種拆包器比較簡單,讀者可以自行寫出 demo,接下來,我們就結合我們小冊的自定義協定,來學習一下如何使用基于長度域的拆包器來拆解我們的資料包。
如何使用 LengthFieldBasedFrameDecoder
首先,我們來回顧一下我們的自定義協定
詳細的協定分析參考 用戶端與服務端通信協定編解碼這小節,這裡不再贅述。 關于拆包,我們隻需要關注
- 在我們的自定義協定中,我們的長度域在整個資料包的哪個地方,專業術語來說就是長度域相對整個資料包的偏移量是多少,這裡顯然是 4+1+1+1=7。
- 另外需要關注的就是,我們長度域的長度是多少,這裡顯然是 4。 有了長度域偏移量和長度域的長度,我們就可以構造一個拆包器。
new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 7, 4);
其中,第一個參數指的是資料包的最大長度,第二個參數指的是長度域的偏移量,第三個參數指的是長度域的長度,這樣一個拆包器寫好之後,隻需要在 pipeline 的最前面加上這個拆包器。
由于這類拆包器使用最為廣泛,想深入學習的讀者可以參考我的這篇文章 netty源碼分析之LengthFieldBasedFrameDecoder
服務端
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 7, 4));
ch.pipeline().addLast(new PacketDecoder());
ch.pipeline().addLast(new LoginRequestHandler());
ch.pipeline().addLast(new MessageRequestHandler());
ch.pipeline().addLast(new PacketEncoder());
用戶端
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 7, 4));
ch.pipeline().addLast(new PacketDecoder());
ch.pipeline().addLast(new LoginResponseHandler());
ch.pipeline().addLast(new MessageResponseHandler());
ch.pipeline().addLast(new PacketEncoder());
這樣,在後續
PacketDecoder
進行 decode 操作的時候,ByteBuf 就是一個完整的自定義協定資料包。
LengthFieldBasedFrameDecoder 有很多重載的構造參數,由于篇幅原因,這裡不再展開, 但是沒關系,關于 LengthFieldBasedFrameDecoder 的詳細使用可參考我的簡書,對原理感興趣的同學可以參考我的視訊,了解了詳細的使用方法之後,就可以有針對性地根據你的自定義協定來構造 LengthFieldBasedFrameDecoder。
拒絕非本協定連接配接
不知道大家還記不記得,我們在設計協定的時候為什麼在資料包的開頭加上一個魔數,遺忘的同學可以參考用戶端與服務端通信協定編解碼回顧一下。我們設計魔數的原因是為了盡早屏蔽非本協定的用戶端,通常在第一個 handler 處理這段邏輯。我們接下來的做法是每個用戶端發過來的資料包都做一次快速判斷,判斷目前發來的資料包是否是滿足我的自定義協定, 我們隻需要繼承自 LengthFieldBasedFrameDecoder 的
decode()
方法,然後在 decode 之前判斷前四個位元組是否是等于我們定義的魔數
0x12345678
public class Spliter extends LengthFieldBasedFrameDecoder {
private static final int LENGTH_FIELD_OFFSET = 7;
private static final int LENGTH_FIELD_LENGTH = 4;
public Spliter() {
super(Integer.MAX_VALUE, LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH);
}
@Override
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
// 屏蔽非本協定的用戶端
if (in.getInt(in.readerIndex()) != PacketCodeC.MAGIC_NUMBER) {
ctx.channel().close();
return null;
}
return super.decode(ctx, in);
}
}
為什麼可以在
decode()
方法寫這段邏輯?是因為這裡的
decode()
方法中,第二個參數
in
,每次傳遞進來的時候,均為一個資料包的開頭,想了解原理的同學可以參考 netty 源碼分析之拆包器的奧秘。
最後,我們隻需要替換一下如下代碼即可
//ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 7, 4));
// 替換為
ch.pipeline().addLast(new Spliter());
然後,我們再來實驗一下
可以看到,我們使用 telnet 連接配接上服務端之後(與服務端建立了連接配接),向服務端發送一段字元串,由于這段字元串是不符合我們的自定義協定的,于是在第一時間,我們的服務端就關閉了這條連接配接。
服務端和用戶端的 pipeline 結構
至此,我們服務端和用戶端的 pipeline 結構為
最後,我們對本小節内容做一下總結
總結
- 我們通過一個例子來了解為什麼要有拆包器,說白了,拆包器的作用就是根據我們的自定義協定,把資料拼裝成一個個符合我們自定義資料包大小的 ByteBuf,然後送到我們的自定義協定解碼器去解碼。
- Netty 自帶的拆包器包括基于固定長度的拆包器,基于換行符和自定義分隔符的拆包器,還有另外一種最重要的基于長度域的拆包器。通常 Netty 自帶的拆包器已完全滿足我們的需求,無需重複造輪子。
- 基于 Netty 自帶的拆包器,我們可以在拆包之前判斷目前連上來的用戶端是否是支援自定義協定的用戶端,如果不支援,可盡早關閉,節省資源。