Netty系列之Netty編解碼框架分析
1. 背景
1.1. 編解碼技術
通常我們也習慣將編碼(Encode)稱為序列化(serialization),它將物件序列化為位元組陣列,用於網路傳輸、資料持久化或者其它用途。
反之,解碼(Decode)/反序列化(deserialization)把從網路、磁碟等讀取的位元組陣列還原成原始物件(通常是原始物件的拷貝),以方便後續的業務邏輯操作。
進行遠端跨程序服務呼叫時(例如RPC呼叫),需要使用特定的編解碼技術,對需要進行網路傳輸的物件做編碼或者解碼,以便完成遠端呼叫。
1.2. 常用的編解碼框架
1.2.1. Java序列化
相信大多數Java程式設計師接觸到的第一種序列化或者編解碼技術就是Java預設提供的序列化機制,需要序列化的Java物件只需要實現java.io.Serializable介面並生成序列化ID,這個類就能夠通過java.io.ObjectInput和java.io.ObjectOutput序列化和反序列化。
由於使用簡單,開發門檻低,Java序列化得到了廣泛的應用,但是由於它自身存在很多缺點,因此大多數的RPC框架並沒有選擇它。Java序列化的主要缺點如下:
1) 無法跨語言:是Java序列化最致命的問題。對於跨程序的服務呼叫,服務提供者可能會使用C++或者其它語言開發,當我們需要和異構語言程序互動時,Java序列化就難以勝任。由於Java序列化技術是Java語言內部的私有協議,其它語言並不支援,對於使用者來說它完全是黑盒。Java序列化後的位元組陣列,別的語言無法進行反序列化,這就嚴重阻礙了它的應用範圍;
2) 序列化後的碼流太大: 例如使用二進位制編解碼技術對同一個複雜的POJO物件進行編碼,它的碼流僅僅為Java序列化之後的20%左右;目前主流的編解碼框架,序列化之後的碼流都遠遠小於原生的Java序列化;
3) 序列化效率差:在相同的硬體條件下、對同一個POJO物件做100W次序列化,二進位制編碼和Java原生序列化的效能對比測試如下圖所示:Java原生序列化的耗時是二進位制編碼的16.2倍,效率非常差。
圖1-1 二進位制編碼和Java原生序列化效能對比
1.2.2. Google的Protobuf
Protobuf全稱Google Protocol Buffers,它由谷歌開源而來,在谷歌內部久經考驗。它將資料結構以.proto檔案進行描述,通過程式碼生成工具可以生成對應資料結構的POJO物件和Protobuf相關的方法和屬性。
它的特點如下:
1) 結構化資料儲存格式(XML,JSON等);
2) 高效的編解碼效能;
3) 語言無關、平臺無關、擴充套件性好;
4) 官方支援Java、C++和Python三種語言。
首先我們來看下為什麼不使用XML,儘管XML的可讀性和可擴充套件性非常好,也非常適合描述資料結構,但是XML解析的時間開銷和XML為了可讀性而犧牲的空間開銷都非常大,因此不適合做高效能的通訊協議。Protobuf使用二進位制編碼,在空間和效能上具有更大的優勢。
Protobuf另一個比較吸引人的地方就是它的資料描述檔案和程式碼生成機制,利用資料描述檔案對資料結構進行說明的優點如下:
1) 文字化的資料結構描述語言,可以實現語言和平臺無關,特別適合異構系統間的整合;
2) 通過標識欄位的順序,可以實現協議的前向相容;
3) 自動程式碼生成,不需要手工編寫同樣資料結構的C++和Java版本;
4) 方便後續的管理和維護。相比於程式碼,結構化的文件更容易管理和維護。
1.2.3. Apache的Thrift
Thrift源於Facebook,在2007年Facebook將Thrift作為一個開源專案提交給Apache基金會。對於當時的Facebook來說,創造Thrift是為了解決Facebook各系統間大資料量的傳輸通訊以及系統之間語言環境不同需要跨平臺的特性,因此Thrift可以支援多種程式語言,如C++、C#、Cocoa、Erlang、Haskell、Java、Ocami、Perl、PHP、Python、Ruby和Smalltalk。
在多種不同的語言之間通訊,Thrift可以作為高效能的通訊中介軟體使用,它支援資料(物件)序列化和多種型別的RPC服務。Thrift適用於靜態的資料交換,需要先確定好它的資料結構,當資料結構發生變化時,必須重新編輯IDL檔案,生成程式碼和編譯,這一點跟其他IDL工具相比可以視為是Thrift的弱項。Thrift適用於搭建大型資料交換及儲存的通用工具,對於大型系統中的內部資料傳輸,相對於JSON和XML在效能和傳輸大小上都有明顯的優勢。
Thrift主要由5部分組成:
1) 語言系統以及IDL編譯器:負責由使用者給定的IDL檔案生成相應語言的介面程式碼;
2) TProtocol:RPC的協議層,可以選擇多種不同的物件序列化方式,如JSON和Binary;
3) TTransport:RPC的傳輸層,同樣可以選擇不同的傳輸層實現,如socket、NIO、MemoryBuffer等;
4) TProcessor:作為協議層和使用者提供的服務實現之間的紐帶,負責呼叫服務實現的介面;
5) TServer:聚合TProtocol、TTransport和TProcessor等物件。
我們重點關注的是編解碼框架,與之對應的就是TProtocol。由於Thrift的RPC服務呼叫和編解碼框架繫結在一起,所以,通常我們使用Thrift的時候會採取RPC框架的方式。但是,它的TProtocol編解碼框架還是可以以類庫的方式獨立使用的。
與Protobuf比較類似的是,Thrift通過IDL描述介面和資料結構定義,它支援8種Java基本型別、Map、Set和List,支援可選和必選定義,功能非常強大。因為可以定義資料結構中欄位的順序,所以它也可以支援協議的前向相容。
Thrift支援三種比較典型的編解碼方式:
1) 通用的二進位制編解碼;
2) 壓縮二進位制編解碼;
3) 優化的可選欄位壓縮編解碼。
由於支援二進位制壓縮編解碼,Thrift的編解碼效能表現也相當優異,遠遠超過Java序列化和RMI等。
1.2.4. JBoss Marshalling
JBoss Marshalling是一個Java物件的序列化API包,修正了JDK自帶的序列化包的很多問題,但又保持跟java.io.Serializable介面的相容;同時增加了一些可調的引數和附加的特性,並且這些引數和特性可通過工廠類進行配置。
相比於傳統的Java序列化機制,它的優點如下:
1) 可插拔的類解析器,提供更加便捷的類載入定製策略,通過一個介面即可實現定製;
2) 可插拔的物件替換技術,不需要通過繼承的方式;
3) 可插拔的預定義類快取表,可以減小序列化的位元組陣列長度,提升常用型別的物件序列化效能;
4) 無須實現java.io.Serializable介面,即可實現Java序列化;
5) 通過快取技術提升物件的序列化效能。
相比於前面介紹的兩種編解碼框架,JBoss Marshalling更多是在JBoss內部使用,應用範圍有限。
1.2.5. 其它編解碼框架
除了上述介紹的編解碼框架和技術之外,比較常用的還有MessagePack、kryo、hession和Json等。限於篇幅所限,不再一一列舉,感興趣的朋友可以自行查閱相關資料學習。
2. Netty編解碼框架
2.1. Netty為什麼要提供編解碼框架
作為一個高效能的非同步、NIO通訊框架,編解碼框架是Netty的重要組成部分。儘管站在微核心的角度看,編解碼框架並不是Netty微核心的組成部分,但是通過ChannelHandler定製擴展出的編解碼框架卻是不可或缺的。
下面我們從幾個角度詳細談下這個話題,首先一起看下Netty的邏輯架構圖:
圖2-1 Netty邏輯架構圖
從網路讀取的inbound訊息,需要經過解碼,將二進位制的資料報轉換成應用層協議訊息或者業務訊息,才能夠被上層的應用邏輯識別和處理;同理,使用者傳送到網路的outbound業務訊息,需要經過編碼轉換成二進位制位元組陣列(對於Netty就是ByteBuf)才能夠傳送到網路對端。編碼和解碼功能是NIO框架的有機組成部分,無論是由業務定製擴充套件實現,還是NIO框架內建編解碼能力,該功能是必不可少的。
為了降低使用者的開發難度,Netty對常用的功能和API做了裝飾,以遮蔽底層的實現細節。編解碼功能的定製,對於熟悉Netty底層實現的開發者而言,直接基於ChannelHandler擴充套件開發,難度並不是很大。但是對於大多數初學者或者不願意去了解底層實現細節的使用者,需要提供給他們更簡單的類庫和API,而不是ChannelHandler。
Netty在這方面做得非常出色,針對編解碼功能,它既提供了通用的編解碼框架供使用者擴充套件,又提供了常用的編解碼類庫供使用者直接使用。在保證定製擴充套件性的基礎之上,儘量降低使用者的開發工作量和開發門檻,提升開發效率。
Netty預置的編解碼功能列表如下:base64、Protobuf、JBoss Marshalling、spdy等。
圖2-2 Netty預置的編解碼功能列表
2.2. 常用的解碼器
2.2.1. LineBasedFrameDecoder解碼器
LineBasedFrameDecoder是回車換行解碼器,如果使用者傳送的訊息以回車換行符作為訊息結束的標識,則可以直接使用Netty的LineBasedFrameDecoder對訊息進行解碼,只需要在初始化Netty服務端或者客戶端時將LineBasedFrameDecoder正確的新增到ChannelPipeline中即可,不需要自己重新實現一套換行解碼器。
LineBasedFrameDecoder的工作原理是它依次遍歷ByteBuf中的可讀位元組,判斷看是否有“\n”或者“\r\n”,如果有,就以此位置為結束位置,從可讀索引到結束位置區間的位元組就組成了一行。它是以換行符為結束標誌的解碼器,支援攜帶結束符或者不攜帶結束符兩種解碼方式,同時支援配置單行的最大長度。如果連續讀取到最大長度後仍然沒有發現換行符,就會丟擲異常,同時忽略掉之前讀到的異常碼流。防止由於資料報沒有攜帶換行符導致接收到ByteBuf無限制積壓,引起系統記憶體溢位。
它的使用效果如下:
解碼之前: +------------------------------------------------------------------+ 接收到的資料報 “This is a netty example for using the nio framework.\r\n When you“ +------------------------------------------------------------------+ 解碼之後的ChannelHandler接收到的Object如下: +------------------------------------------------------------------+ 解碼之後的文字訊息 “This is a netty example for using the nio framework.“ +------------------------------------------------------------------+
通常情況下,LineBasedFrameDecoder會和StringDecoder配合使用,組合成按行切換的文字解碼器,對於文字類協議的解析,文字換行解碼器非常實用,例如對HTTP訊息頭的解析、FTP協議訊息的解析等。
下面我們簡單給出文字換行解碼器的使用示例:
@Override protected void initChannel(SocketChannel arg0) throws Exception { arg0.pipeline().addLast(new LineBasedFrameDecoder(1024)); arg0.pipeline().addLast(new StringDecoder()); arg0.pipeline().addLast(new UserServerHandler()); }
初始化Channel的時候,首先將LineBasedFrameDecoder新增到ChannelPipeline中,然後再依次新增字串解碼器StringDecoder,業務Handler。
2.2.2. DelimiterBasedFrameDecoder解碼器
DelimiterBasedFrameDecoder是分隔符解碼器,使用者可以指定訊息結束的分隔符,它可以自動完成以分隔符作為碼流結束標識的訊息的解碼。回車換行解碼器實際上是一種特殊的DelimiterBasedFrameDecoder解碼器。
分隔符解碼器在實際工作中也有很廣泛的應用,筆者所從事的電信行業,很多簡單的文字私有協議,都是以特殊的分隔符作為訊息結束的標識,特別是對於那些使用長連線的基於文字的私有協議。
分隔符的指定:與大家的習慣不同,分隔符並非以char或者string作為構造引數,而是ByteBuf,下面我們就結合實際例子給出它的用法。
假如訊息以“$_”作為分隔符,服務端或者客戶端初始化ChannelPipeline的程式碼例項如下:
@Override public void initChannel(SocketChannel ch) throws Exception { ByteBuf delimiter = Unpooled.copiedBuffer("$_" .getBytes()); ch.pipeline().addLast( new DelimiterBasedFrameDecoder(1024, delimiter)); ch.pipeline().addLast(new StringDecoder()); ch.pipeline().addLast(new UserServerHandler()); }
首先將“$_”轉換成ByteBuf物件,作為引數構造DelimiterBasedFrameDecoder,將其新增到ChannelPipeline中,然後依次新增字串解碼器(通常用於文字解碼)和使用者Handler,請注意解碼器和Handler的新增順序,如果順序顛倒,會導致訊息解碼失敗。
DelimiterBasedFrameDecoder原理分析:解碼時,判斷當前已經讀取的ByteBuf中是否包含分隔符ByteBuf,如果包含,則擷取對應的ByteBuf返回,原始碼如下:
詳細分析下indexOf(buffer, delim)方法的實現,程式碼如下:
該演算法與Java String中的搜尋演算法類似,對於原字串使用兩個指標來進行搜尋,如果搜尋成功,則返回索引位置,否則返回-1。
2.2.3. FixedLengthFrameDecoder解碼器
FixedLengthFrameDecoder是固定長度解碼器,它能夠按照指定的長度對訊息進行自動解碼,開發者不需要考慮TCP的粘包/拆包等問題,非常實用。
對於定長訊息,如果訊息實際長度小於定長,則往往會進行補位操作,它在一定程度上導致了空間和資源的浪費。但是它的優點也是非常明顯的,編解碼比較簡單,因此在實際專案中仍然有一定的應用場景。
利用FixedLengthFrameDecoder解碼器,無論一次接收到多少資料報,它都會按照建構函式中設定的固定長度進行解碼,如果是半包訊息,FixedLengthFrameDecoder會快取半包訊息並等待下個包到達後進行拼包,直到讀取到一個完整的包。
假如單條訊息的長度是20位元組,使用FixedLengthFrameDecoder解碼器的效果如下:
解碼前: +------------------------------------------------------------------+ 接收到的資料報 “HELLO NETTY FOR USER DEVELOPER“ +------------------------------------------------------------------+ 解碼後: +------------------------------------------------------------------+ 解碼後的資料報 “HELLO NETTY FOR USER“ +------------------------------------------------------------------+
2.2.4. LengthFieldBasedFrameDecoder解碼器
瞭解TCP通訊機制的讀者應該都知道TCP底層的粘包和拆包,當我們在接收訊息的時候,顯示不能認為讀取到的報文就是個整包訊息,特別是對於採用非阻塞I/O和長連線通訊的程式。
如何區分一個整包訊息,通常有如下4種做法:
1) 固定長度,例如每120個位元組代表一個整包訊息,不足的前面補位。解碼器在處理這類定常訊息的時候比較簡單,每次讀到指定長度的位元組後再進行解碼;
2) 通過回車換行符區分訊息,例如HTTP協議。這類區分訊息的方式多用於文字協議;
3) 通過特定的分隔符區分整包訊息;
4) 通過在協議頭/訊息頭中設定長度欄位來標識整包訊息。
前三種解碼器之前的章節已經做了詳細介紹,下面讓我們來一起學習最後一種通用解碼器-LengthFieldBasedFrameDecoder。
大多數的協議(私有或者公有),協議頭中會攜帶長度欄位,用於標識訊息體或者整包訊息的長度,例如SMPP、HTTP協議等。由於基於長度解碼需求的通用性,以及為了降低使用者的協議開發難度,Netty提供了LengthFieldBasedFrameDecoder,自動遮蔽TCP底層的拆包和粘包問題,只需要傳入正確的引數,即可輕鬆解決“讀半包“問題。
下面我們看看如何通過引數組合的不同來實現不同的“半包”讀取策略。第一種常用的方式是訊息的第一個欄位是長度欄位,後面是訊息體,訊息頭中只包含一個長度欄位。它的訊息結構定義如圖所示:
圖2-3 解碼前的位元組緩衝區(14位元組)
使用以下引數組合進行解碼:
1) lengthFieldOffset = 0;
2) lengthFieldLength = 2;
3) lengthAdjustment = 0;
4) initialBytesToStrip = 0。
解碼後的位元組緩衝區內容如圖所示:
圖2-4 解碼後的位元組緩衝區(14位元組)
通過ByteBuf.readableBytes()方法我們可以獲取當前訊息的長度,所以解碼後的位元組緩衝區可以不攜帶長度欄位,由於長度欄位在起始位置並且長度為2,所以將initialBytesToStrip設定為2,引數組合修改為:
1) lengthFieldOffset = 0;
2) lengthFieldLength = 2;
3) lengthAdjustment = 0;
4) initialBytesToStrip = 2。
解碼後的位元組緩衝區內容如圖所示:
圖2-5 跳過長度欄位解碼後的位元組緩衝區(12位元組)
解碼後的位元組緩衝區丟棄了長度欄位,僅僅包含訊息體,對於大多數的協議,解碼之後訊息長度沒有用處,因此可以丟棄。
在大多數的應用場景中,長度欄位僅用來標識訊息體的長度,這類協議通常由訊息長度欄位+訊息體組成,如上圖所示的幾個例子。但是,對於某些協議,長度欄位還包含了訊息頭的長度。在這種應用場景中,往往需要使用lengthAdjustment進行修正。由於整個訊息(包含訊息頭)的長度往往大於訊息體的長度,所以,lengthAdjustment為負數。圖2-6展示了通過指定lengthAdjustment欄位來包含訊息頭的長度:
1) lengthFieldOffset = 0;
2) lengthFieldLength = 2;
3) lengthAdjustment = -2;
4) initialBytesToStrip = 0。
解碼之前的碼流:
圖2-6 包含長度欄位自身的碼流
解碼之後的碼流:
圖2-7 解碼後的碼流
由於協議種類繁多,並不是所有的協議都將長度欄位放在訊息頭的首位,當標識訊息長度的欄位位於訊息頭的中間或者尾部時,需要使用lengthFieldOffset欄位進行標識,下面的引數組合給出瞭如何解決訊息長度欄位不在首位的問題:
1) lengthFieldOffset = 2;
2) lengthFieldLength = 3;
3) lengthAdjustment = 0;
4) initialBytesToStrip = 0。
其中lengthFieldOffset表示長度欄位在訊息頭中偏移的位元組數,lengthFieldLength 表示長度欄位自身的長度,解碼效果如下:
解碼之前:
圖2-8 長度欄位偏移的原始碼流
解碼之後:
圖2-9長度欄位偏移解碼後的碼流
由於訊息頭1的長度為2,所以長度欄位的偏移量為2;訊息長度欄位Length為3,所以lengthFieldLength值為3。由於長度欄位僅僅標識訊息體的長度,所以lengthAdjustment和initialBytesToStrip都為0。
最後一種場景是長度欄位夾在兩個訊息頭之間或者長度欄位位於訊息頭的中間,前後都有其它訊息頭欄位,在這種場景下如果想忽略長度欄位以及其前面的其它訊息頭欄位,則可以通過initialBytesToStrip引數來跳過要忽略的位元組長度,它的組合配置示意如下:
1) lengthFieldOffset = 1;
2) lengthFieldLength = 2;
3) lengthAdjustment = 1;
4) initialBytesToStrip = 3。
解碼之前的碼流(16位元組):
圖2-10長度欄位夾在訊息頭中間的原始碼流(16位元組)
解碼之後的碼流(13位元組):
圖2-11長度欄位夾在訊息頭中間解碼後的碼流(13位元組)
由於HDR1的長度為1,所以長度欄位的偏移量lengthFieldOffset為1;長度欄位為2個位元組,所以lengthFieldLength為2。由於長度欄位是訊息體的長度,解碼後如果攜帶訊息頭中的欄位,則需要使用lengthAdjustment進行調整,此處它的值為1,代表的是HDR2的長度,最後由於解碼後的緩衝區要忽略長度欄位和HDR1部分,所以lengthAdjustment為3。解碼後的結果為13個位元組,HDR1和Length欄位被忽略。
事實上,通過4個引數的不同組合,可以達到不同的解碼效果,使用者在使用過程中可以根據業務的實際情況進行靈活調整。
由於TCP存在粘包和組包問題,所以通常情況下使用者需要自己處理半包訊息。利用LengthFieldBasedFrameDecoder解碼器可以自動解決半包問題,它的習慣用法如下:
pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(65536,0,2)); pipeline.addLast("UserDecoder", new UserDecoder());
在pipeline中增加LengthFieldBasedFrameDecoder解碼器,指定正確的引數組合,它可以將Netty的ByteBuf解碼成整包訊息,後面的使用者解碼器拿到的就是個完整的資料報,按照邏輯正常進行解碼即可,不再需要額外考慮“讀半包”問題,降低了使用者的開發難度。