本文是Netty系列第8篇
上一篇文章我們深入學習了Netty邏輯架構中的核心元件ChannelHandler和ChannelPipeline,並介紹了它在日常開發使用中的最佳實踐。文中也提到了,ChannelHandler主要用於資料輸入、輸出過程中的加工處理,比如編解碼、異常處理等。
今天,我們就選取日常開發中最常用的一種ChannelHandler用途來學習——編解碼器。
如果說ChannelHandler的學習是Netty的基礎招式,那麼編解碼就是“基礎招式”中衍生出的“常用招式“,我們往往會以一個ChannelHandler來實現編解碼邏輯。無論是網路程式設計實戰,還是面試八股文,都離不開編解碼的知識。
本文預計閱讀時間約 15分鐘,
將重點圍繞以下幾個問題展開:
- 學習編解碼器,從粘包/拆包開始
- 如何實現自定義編解碼器
- Netty有哪些開箱即用的編解碼器
1.學習編解碼器,從粘包/拆包開始
1.1為什麼會有是粘包/拆包
粘包/拆包問題,相信大家都有所耳聞,這個問題的出現主要包括三個原因:
1)MTU 和 MSS 限制
MTU(Maxitum Transmission Unit) 是OSI五層網路模型中 資料鏈路層 對一次可以傳送的最大資料的限制,一般來說大小為 1500 byte。
MSS(Maximum Segement Size) 是指 TCP報文中data部分的最大長度,它是傳輸層一次傳送最大資料的大小限制。
MSS和MTU的關係如下所示:
MSS長度=MTU長度 - IP Header - TCP Header
因此,當 MSS長度 + IP Header + TCP Header > MTU長度 時,就需要拆分多個報文進行傳送,會導致“拆包”現象。
2)TCP滑動視窗
TCP的流量控制方法就是“滑動視窗”。當A向B傳送資料時,B作為接收端會告知傳送端A自己可以接受的視窗數值,以此來控制A的傳送流量大小,從而達到流量控制的目的。
假設接收方B告知傳送方A的視窗大小為256,意味著傳送方最多還可以傳送256個位元組,而由於傳送方的資料大小是518位元組,因此只能傳送前256位元組,等到接收方ack後,才能傳送剩餘位元組。會導致“拆包”現象。
3)Nagle演算法
TCP/IP協議中,無論傳送多少大小的資料,都要在資料(DATA)前面加上協議頭(TCP Header + IP Header)。如果每次需要傳送的資料只有 1 位元組,加上 20 個位元組 IP Header 和 20 個位元組 TCP Header,每次傳送的資料包大小為 41 位元組,但真正有效的資訊只有1個位元組,這就造成了非常大的浪費。
因此,TCP/IP中使用Nagle 演算法來提高效率。
Nagle 演算法核心思想在於“化零為整“。它是在資料未得到確認之前先寫入緩衝區,等待資料確認或者緩衝區積攢到一定大小再把資料包傳送出去。
多個小資料包合併後一起傳送出去,就造成了粘包。
Q: 如果禁用了Nagle演算法,還需要對粘包情況進行處理嗎?
A: 需要。除了Nagle演算法外,接收端不及時也可能會造成粘包現象。當上一個資料包還在緩衝區未被接收端處理時,下一個資料包已經到達了,然後接收端根據緩衝區大小取到的資料有可能會取到多個資料包。
1.2 怎麼處理粘包/拆包
對於TCP,其實我們都知道它的一個特點就是“面向位元組流”的傳輸協議,本身並沒有資料包的界限。所以不管什麼原因造成了“粘包/拆包”,TCP協議本身的資料傳輸是可靠且正確的。
我們首先要明確一點:“粘包/拆包”導致的問題,本質上是應用層的資料解析問題。
因此,解決拆包/粘包問題的核心方法:定義應用層的通訊協議。
核心在於定義正確的資料邊界。
常見協議的解決方案包括三種:
1)固定長度
每個資料包文都約定一個固定的長度。
當接收方累計讀取到固定長度的報文後,就認為已經獲得一個完整的訊息。
比如我們要傳送一個ABCDEFGHIJKLM的訊息,約定固定訊息長度為4,那麼接收方就可以按照4的長度來解析。如下所示。
|
|
|
|
ABCD |
EFGH |
IJKL |
MN00 |
當傳送方的資料小於固定長度時,比如最後一個資料包,只有MN兩個字元,這時候就需要空位補齊。
這種方案非常簡單,但是缺點也非常明顯,非常不靈活。
如果固定長度定義太長,就會浪費資料傳輸空間。如果定義太短,就會影響正確的資料傳輸。
這種方法一般不採用。
2)特定分隔符
除了固定長度外,我們比較容易想到的區分“資料邊界”的方法,就是用“特定分隔符”。當接收方讀到特定的分隔符,就認為拿到了一個完整的訊息。
比如我們使用換行符 \n 來區分。
AB\nCDEFG\nHIJK\nLMN\n
這種方法就比較靈活了,適應不同長度的訊息。但是,必須要注意,“特殊分隔符”不能和訊息內容重複,否則就會解析失敗了。
因此,我們在實踐過程中,可以考慮把訊息進行編碼(如base64),然後用編碼字符集之外的符號作為“特定分隔符”。
這種方案一般用在協議比較簡單的場景中。
3)訊息長度+內容
一般專案開發中,最通用的方式還是採用 訊息長度+內容 的方式進行處理。
比如定義一個這樣的訊息格式:
訊息長度(比如4位元組長度儲存) |
訊息內容 |
3 |
ABC |
以這樣一個格式儲存,訊息接收方在解析時,先讀取4位元組長度的資訊作為”訊息長度“,這裡是3,表示訊息長度為3位元組。然後就讀取3位元組的訊息內容作為 完整 的訊息。
舉個例子:
2AB5CDEFG4HIJK3LMN
訊息長度+內容 的方式非常靈活,可以應用於各種場景中。
注意,在訊息頭中,除了定義訊息長度外,還可以自定義其他擴充套件欄位,比如訊息版本、演算法型別等。
2.如何在Netty中實現自定義編解碼器
上面我們瞭解了出現“粘包/拆包”的原因以及常用的解決方法。下面看看如何在Netty中實現自定義編解碼器。
Netty作為一個優秀的網路通訊框架,已經提供了非常豐富的處理編解碼的抽象類,我們只需要自定義編解碼演算法擴充套件即可。
2.1 自定義編碼器
我們先來看看自定義編碼器。因為編碼器比較簡單,不需要關注「粘包/拆包問題」。
常用的編碼抽象類包括MessageToByteEncoder 和 MessageToMessageEncoder,繼承自
ChannelOutboundHandlerAdapter,操作的是Outbound相關資料。
1)MessageToByteEncoder<I>
這個編碼器用於訊息物件編碼成位元組流。它提供了encode的抽象方法,我們只需要實現encode方法,就能進行自定義編碼了。
編碼器實現非常簡單,不需要關注拆包/粘包問題。
我們舉一個栗子,將String型別訊息轉換為位元組流:
2)MessageToMessageEncoder
這個編碼器用於將一種訊息物件編碼成另一種訊息物件。這裡的第二個Message可以理解為任意一個物件。如果是使用ByteBuf物件的話,就和上面的MessageToByteEncoder是一樣的了。
我們找一個Netty自帶的栗子看看,StringEncoder:
2.2 自定義解碼器
解碼器比編碼器要複雜一些,因為需要考慮“拆包/粘包”問題。
由於接收方有可能沒有接收到完整的訊息,所以解碼框架需要對入站的資料做緩衝操作,直至獲取到完整的訊息。
常用的解碼器抽象類包括 ByteToMessageDecoder 和 MessageToMessageDecoder,繼承自
ChannelInboundHandlerAdapter,操作的是Inbbound相關資料。
一般通用的做法是使用 ByteToMessageDecoder 解析 TCP 協議,解決拆包/粘包問題。解析得到有效的 ByteBuf 資料,然後傳遞給後續的 MessageToMessageDecoder 做資料物件的轉換。
1)ByteToMessageDecoder
ByteToMessageDecoder解碼器用於位元組流解碼成訊息物件。
拿上面的“固定長度法”解決“粘包/拆包”舉一個栗子,Netty自帶的FixedLengthFrameDecoder。
通過固定長度frameLength,來對訊息進行解析。
生產實踐中,可能會使用更加複雜的協議來實現自定義編解碼,比如protobuf。
2)MessageToMessageDecoder
MessageToMessageDecoder解碼器用於將一種訊息物件解碼成另一種訊息物件。如果你需要對解析後的位元組資料做物件模型的轉換,這時候便需要用到這個解碼器。
3.Netty有哪些開箱即用的解碼器
作為一個優秀的網路程式設計框架,Netty除了支援擴充套件自定義編解碼器外,還提供了非常豐富的開箱即用的編解碼器。尤其是針對我們上文1.2節中提過的三種解決「粘包/拆包問題」的方式,都有開箱即用的實現。
3.1 固定長度解碼器 FixedLengthFrameDecoder
這個解碼器上文已經提到過,對應1.2節中的「固定長度解碼」,這裡再稍微展開一下。
通過建構函式配置固定長度 frameLength,然後在decode時,按照frameLength 進行解碼。
- 當讀取到長度大小為 frameLength 的訊息,那麼解碼器認為已經獲取到了一個完整的訊息。
- 當訊息長度小於 frameLength,FixedLengthFrameDecoder 解碼器會一直等後續資料包的到達,直至獲得完整的訊息。
3.2 特殊分隔符解碼器 DelimiterBasedFrameDecoder
這個解碼器對應1.2節中的「特殊分隔符解碼」,也是一個繼承自ByteToMessageDecoder的解碼器。
這個解碼器會使用 1個 或 多個 符號delimiter 對傳入的訊息(ByteBuf)進行解碼。
我們看一下構造器,瞭解一下幾個重要引數。
- maxFranmeLength
maxFranmeLength 是待處理訊息的最大長度限制。如果超過 maxFranmeLength 還沒有檢測到指定分隔符,將會丟擲 TooLongFrameException。
- stripDelimiter
stripDelimiter是一個boolean型別, 用於判斷解碼後得到的訊息是否移除分隔符。如果 stripDelimiter=false,那麼解碼後的訊息內容就會保留分隔符資訊。
- failFast
failFast是一個boolean型別。如果為true,那麼訊息在超出 maxFranmeLength 後,會立即丟擲 TooLongFrameException。如果為false,那麼會等到解碼出一個完整的訊息後才會丟擲TooLongFrameException。
- delimiters
delimiters 的型別是 ByteBuf 陣列,可以在構造器中同時傳入多個分隔符,但是在解析時,最終會選擇長度最短的分隔符進行訊息拆分。
例如收到的資料為:
ABCD\nEFG\r\n
如果指定的分隔符為 \n 和 \r\n,那麼會解碼出兩個訊息。
ABCD EFG
如果指定的特定分隔符只有 \r\n,那麼只會解碼出一個訊息:
ABCD\nEFG
3.3 長度域解碼器 LengthFieldBasedFrameDecoder
這個解碼器是生產實踐中運用比較廣泛的一種(比如RocketMQ),相對複雜,但是特別靈活,基本能覆蓋各種基於長度進行拆包的方案,比如1.2節中提到的「訊息長度+內容」的方案。
使用這個解碼器的時候,重點需要了解4個引數,掌握了引數的設定,就能快速實現不同的基於長度的拆包解碼方案。
引數名 |
型別 |
含義 |
lengthFieldOffset |
int |
長度欄位的偏移量。表示「長度域」的起始位置 |
lengthFieldLength |
int |
長度欄位所佔用的位元組數 |
lengthAdjustment |
int |
訊息長度的修正值。表示一些複雜協議中,會在「長度域」新增一些其他內容,如版本號、訊息型別等,這就需要修正值進行修正處理 |
initialBytesToStrip |
int |
解碼後需要跳過的初始位元組數。表示訊息內容資料的起始位置 |
1)解碼方案一:基於訊息長度 + 訊息內容,解碼結果不截斷訊息頭
報文只包含訊息長度 Length 和訊息內容 Content 欄位,其中 Length 為 16 進製表示,共佔用 2 位元組,Length 的值 0x000C 代表 Content 佔用 12 位元組。
引數名 |
取值 |
lengthFieldOffset |
0 |
lengthFieldLength |
2 |
lengthAdjustment |
0 |
initialBytesToStrip |
0(表示解碼結果不截斷訊息頭) |
解碼示例:
2)解碼方案二:基於訊息長度 + 訊息內容,解碼結果截斷
與方案一不同之處在於,解碼結果會截斷訊息頭(跳過2位元組)
引數名 |
取值 |
lengthFieldOffset |
0 |
lengthFieldLength |
2 |
lengthAdjustment |
0 |
initialBytesToStrip |
2(表示跳過 Length 欄位的位元組長度,解碼後 只包含 訊息內容) |
解碼示例:
3)解碼方案三:基於訊息頭 + 訊息長度 + 訊息內容
訊息起始位置新增特殊訊息頭,訊息長度 Length欄位 後移。
引數名 |
取值 |
lengthFieldOffset |
2 |
lengthFieldLength |
3 |
lengthAdjustment |
0 |
initialBytesToStrip |
0(表示解碼結果不截斷訊息頭) |
解碼示例:
4)解碼方案四:基於訊息長度 + 訊息頭 + 訊息內容
訊息起始位置為訊息長度 Length欄位,後面並不直接新增 訊息內容,而是先新增 訊息頭header,再新增 訊息內容。
引數名 |
取值 |
lengthFieldOffset |
0 |
lengthFieldLength |
3 |
lengthAdjustment |
2 (Header1的長度) |
initialBytesToStrip |
0(表示解碼結果不截斷訊息頭) |
解碼示例:
由於 Length 後面不是馬上新增content,所以需要加上 lengthAdjustment(2 位元組)才能得到 Header + Content 的內容(14 位元組)。
4.小結
來簡單回顧下吧。
本文主要介紹了ChannelHandler的一種典型應用場景——編解碼器。
編解碼器核心關注點在於「粘包/拆包」的處理,我們介紹了「粘包/拆包」產生的原因以及常用解決方案。然後說明了如何使用Netty框架實現自定義編解碼器。
最後,介紹了Netty中非常好用的幾個開箱即用的編解碼器。
參考書目:
《Netty in Action》
都看到最後了,原創不易,點個關注,點個贊吧~
文章持續更新,可以微信搜尋「阿丸筆記 」第一時間閱讀,回覆【筆記】獲取Canal、MySQL、HBase、JAVA實戰筆記,回覆【資料】獲取一線大廠面試資料。
知識碎片重新梳理,構建Java知識圖譜:github.com/saigu/JavaK…(歷史文章查閱非常方便)