微言netty:不在浮沙築高臺
幾年前,我在一家農業物聯網公司,負責解決其物聯網產品線。我們當時基於.net平臺打造了一套實時資料採集系統,可以把數以百萬級的感測器傳送回來的資料採集入庫並根據這些資料進行建模。在搭建這套實時資料採集系統的時候,高併發高可用被首次提出,同時要求系統不會有太大的時延。一旦有時延,也就意味著損失。比如一個有3000頭豬的豬舍,假設空氣溫度達到了比較高的水平,但是採集探頭採集的資料上傳到伺服器管道中,由於被積壓了5分鐘後才被處理,那麼主動預警系統開啟風機的時候,也許已經晚了,這五分鐘的時間裡,上百頭小豬仔因為溫度過高的緣故死於非命。當然,魚塘,蔬菜大棚等也有類似的場景。
當時在打造此係統的時候,我們用的還是.net,翻閱了很多原始碼,查閱了很多資料,最後我們基於SocketAsyncEventArgs來打造一個自己的物聯網服務端。當時在.net裡面,還沒有一款能夠匹敵netty的開源元件出來,這就導致我們不僅要處理心跳,而且還要處理粘包,甚至緩衝區都需要自己來處理,一旦訊息沒被及時拿出來,那麼後到的資料會將之前的資料一股腦兒的覆蓋。從底層來實現這些功能的好處是讓我們對服務端的編寫有了非常清楚的認知,但是也由於思慮不全帶來非常多的坑。可以說那幾年是踩著TCP的坑走過來的。最後我們基於SocketAsyncEventArgs封裝了我們自己的物聯網通訊框架:TinySocket。在那個時候,彼時的聯想佳沃藍莓基地依舊用資料庫輪詢的方式來支援物聯網裝置,和他們對接的時候,發現經常會因為遇到網路層面的問題而愁眉不展,而彼時的我們卻因為我們可以在任何裝置上自動/手動控制我們的裝置而高興不已。因為她的可靠度極高。
後來,離開了那裡,但是懷著要打造一個能支撐巨流量的物聯網高併發和高可用架構的夢想,而選擇了網際網路公司來進行深造。也是在這個時候,我從.net平臺轉到了java平臺,也正是在這個時候,我有緣認識了netty,一個彷彿是為了解決我當年的各種問題而生的框架,雖和她只有一面之緣,但是那一刻,我決定將她納入麾下,情定終生也許用在此刻再合適不過了。因為她有成熟的架構,普適的解決方式,優雅的接入方式,良好的社群支援,成熟的商業產品。這些特性,讓我們無法拒絕使用。
由於對netty的執迷,導致我說起了過往,止不住的文字流淌,接下來我們就轉入正題吧。
在資料傳輸過程中,由於網路的不確定性,每個資料包都有可能遭遇形式各樣的問題,諸如掉線,網路變差等,所以到達的時候,這些資料包有可能亂序,也有可能丟失。所以為了應對這些異常狀況,TCP協議在其內部通過序列號來保證資料包亂序的問題,同時通過確認號來保證資料包丟失的問題。所以基於TCP協議實現的上層應用,都認為TCP傳輸是可靠的。但是通過一些網路抓包工具,可以窺見其具體實現資料包有序和防丟失的過程,感興趣的可以自己去試試。
那麼上面提到序列號和確認號,究竟是什麼呢?我們來看一下:
-
Sequence Number: 順序號,意即資料包的序號,主要用來解決資料包亂序問題。
-
Acknowledgement Number:確認號,意即資料包用來進行雙端訊息確認的號碼,主要用來解決網路傳輸過程中,資料丟包的問題。
在TCP進行資料傳輸的過程中,主機A傳輸資料給主機B,假設第一次A傳輸512位元組的資料給B,那麼seq=1;當B收到這512位元組的時候,會將seq進行累加來避免亂序,在這裡,B會將seq重新設定為512+1,然後回傳給A,A收到B傳回來的seq=513的時候,就知道第一個資料包已經傳給了B。如果A收到B的回覆,發現B沒有收到資料包的話,那麼將會進行重發操作,這樣來防止丟包。
下面來說下TCP的標誌位,一共有6種:
-
SYN(synchronous建立聯機)
-
ACK(acknowledgement 確認)
-
PSH(push傳送)
-
FIN(finish結束)
-
RST(reset重置)
-
URG(urgent緊急)
第一次握手:建立連線時,客戶端傳送syn包(syn=j)到伺服器,並進入SYN_SEND狀態,等待伺服器確認;
第二次握手:伺服器收到syn包,必須確認客戶的SYN(ack=j+1),同時自己也傳送一個SYN包(syn=k),即SYN+ACK包,此時伺服器進入SYN_RECV狀態;
第三次握手:客戶端收到伺服器的SYN+ACK包,向伺服器傳送確包ACK(ack=k+1),此包傳送完畢,客戶端和伺服器進入ESTABLISHED狀態,完成三次握手。
完成三次握手,客戶端與伺服器開始傳送資料.
更多詳細的資訊,推薦閱讀斯坦福大學的 Transmission Control Protocol (TCP) 的這篇短小精悍的文章。
大略講解了下TCP的基礎,我們接下來開始我們的netty之旅吧。由於JDK內建的NIO操作類庫並非我們的講解要點,所以這裡我不會過多的進行講解,直接從netty講起吧。
2. 網路通訊基礎,包含(粘包拆包,編解碼,鑑權認證,心跳檢測,斷線重連)
在設計網路通訊框架的時候,有些設計點是必須被考慮進去的,這些設計點可以說是不可或缺的。接下來我們就一一梳理並進行講解。
>>粘包拆包
粘包拆包,顧名思義,粘包,就是指資料包黏在一塊了;拆包,則是指完整的資料包被拆開了。由於TCP通訊過程中,會將資料包進行合併後再發出去,所以會有這兩種情況發生,但是UDP通訊則不會。下面我們以兩個資料包A,B來講解具體的粘包拆包過程:
第一種情況,A資料包和B資料包被分別接收且都是整包狀態,無粘包拆包情況發生,此種情況最佳。
第二種情況,A資料包和B資料包在一塊兒且一起被接收,此種情況,即發生了粘包現象,需要進行資料包拆分處理。
第三種情況,A資料包和B資料包的一部分先被接收,然後收到B資料包的剩餘部分,此種情況,即發生了拆包現象,即B資料包被拆分。
第四種情況,A資料包的一部分先被接收,然後收到A資料包的剩餘部分和B資料包的完整部分,此種情況,即發生了拆包現象,即A資料包被拆分。
第五種情況,也是最複雜的一種,先收到A資料包的部分,然後收到A資料包剩餘部分和B資料包的一部分,最後收到B資料包的剩餘部分,此種情況也發生了拆包現象。
上面五種粘包拆包現象的發生,其實歸根到底,原因有三:
(1) 應用程式write寫入的位元組大小大於套介面傳送緩衝區大小。
(2) 進行MSS大小的TCP分段。
(3) 乙太網幀的payload大於MTU進行IP分片。
我們來詳細講解一下。
對於(1)中的內容,我們可以認定為應用程式內部自身的緩衝區,此緩衝區因為大小不同會導致連續寫入的資料太長被截斷,從而導致一個完整的業務訊息體被分為兩段傳送出去。
對於(2)中的內容,其實是TCP協議裡面的MSS大小,此大小會決定傳送的資料包的長度。屬於協議層面的緩衝區。
對於(3)中的內容,則屬於網絡卡自身的緩衝區大小,屬於硬體層面。
既然瞭解了粘包拆包發生的原因了,那麼有什麼辦法來應對呢?由於不同業務有不同的實現方式,所以一般情況下都會採用如下的解決方式來進行處理:
(1) 資料訊息固定長度,比如說1024位元組,接收方接收到資料,以1024位元組為單位進行擷取即可。如若當前接收到的資料不夠1024位元組,可以等後續的資料到達後,以1024為單位進行擷取。適用於資料結構固定長度的場合。
(2) 資料訊息採用分隔符,比如用換行符或者使用豎線分隔等,依據具體的業務來進行。在進行資料處理的時候,可以根據這些分隔符來擷取資料。適用於資料結構長度不固定的場合。前面提到的物聯網採集端通訊協議就是採用的此種做法。
(3) 資料訊息包含資料頭和資料體,資料頭中包含資料長度,此種做法可以讓資料定義更為靈活多變,但是會讓資料結構變得臃腫,非常適合於自定義通訊協議的場合中。
(4) 其他根據具體業務而衍生出來的處理方式。比如Dubbo通訊協議等。
>>編解碼
當我們將資料從本機發到遠端的時候,我們需要將資料轉換為二進位制放到緩衝區,然後傳送出去,這叫做編碼。當我們接收遠端資料到本機的時候,我們需要將緩衝區的二進位制資料還原為物件,這叫做解碼。
由於目前能夠進行這種編解碼的元件非常的多,比如ProtoBuffer,ProtoStuff,Marshalling,MessagePack等,由於這些元件有效能上的差別和使用簡便性方面的差別,所以需要自己通過Benchmark來選擇最適合自己業務的。由於ProtoStuff是對ProtoBuffer的封裝,省去了我們手寫協議檔案的煩惱,且效能上的損耗在可以接收範圍內,所以我們接下來的講解均以此元件來進行。
>>鑑權認證
雙端的機器在進行通訊的時候,必須要進行身份認證後才能進行連線,此舉可以防止非法使用者通過構造資料包來非法訪問服務資料的作用。此鑑權認證發生在雙方機器第一次進行連線通訊的時候,客戶端必須先發送鑑權認證的資料包給服務端,服務端對此客戶端進行鑑權認證,如果鑑權認證不通過(比如客戶端ip在黑名單中或者客戶端的請求token無效等),則拒絕連線。
其實這種鑑權認證就類似咱們訪問網頁時候,需要先進行使用者登入的情況一樣。雖然此種做法無法百分之百的保證非法使用者的訪問,但是可以在極大程度上提升服務端的安全效能。
>>心跳檢測
雙端的機器在進行通訊的時候,由於鏈路保持在活躍狀態,所以不會導致鏈路中斷。但是一旦當一方機器(比如說客戶端)由於網路變差,網路閃斷,機器掛掉等原因導致掉線,那麼此種情況下,服務端是感知不到客戶端掉線的。所以這裡需要利用心跳包來檢測客戶端的這種行為。心跳包的實現方式有多種,但是無外乎如下幾種情況:
(1) 服務端傳送心跳包給客戶端,客戶端接收到後計數清零,當客戶端在規定的時間間隔內(比如1分鐘)沒有接收到服務端傳送的心跳包,則計數器遞增一次,累積遞增三次,則視為服務端掉線。此種方式主要檢測服務端存活。比如物聯網採集模組中,就需要客戶端實時檢測服務端的存活。
(2) 客戶端傳送心跳給服務端,服務端接收到後計數清零,當服務端在規定的時間間隔內(比如1分鐘)沒有接收到客戶端傳送的心跳包,則計數器遞增一次,累積遞增三次,則視為客戶端掉線。此種方式主要檢測客戶端存活。比如IM通訊軟體中,通過此方法可以檢測哪個使用者掉線,然後將此掉線使用者廣播給其他使用者告知掉線資訊。
(3) 客戶端傳送心跳給服務端,服務端接收後計數清零,同時服務端給客戶端傳送一個心跳包,客戶端接收後計數清零。當雙端任何一方未能及時收到心跳包,則計數器進行遞增,累積遞增三次,則視為對方掉線。此種方式可以同時檢測服務端和客戶端的存活。
當然,上面是我經常用到的三種心跳包設計模式,如果有更好的設計方式,還請指教。
>>斷線重連
客戶端由於種種原因,導致和服務端的連線中斷,此種情況下,需要考慮到重連。此種機制可最大程度的保證整體服務的穩定性和可用性。所以其重要性毋庸置疑。
上面就是在設計通訊元件的時候,必須要考慮的諸多細節,由於不同的業務對這些細節的依賴度有高有低,所以在實際設計的時候,可以依據業務來進行詳細定製或者粗粒度實現,由此出發,打造一套自己的通訊元件,不是什麼難事兒了。
上面都是一些理論點,如何將這些理論點變成實踐,則是接下來要講的內容了。Netty,終於要出場了。
3. 自定義協議棧。
封裝一個通用的通訊元件所具備的一些要點,已經講解的比較全面和清楚了,但是隻是理論知識,本著實踐出真知的態度,我們決定利用上面的知識點來打造一款自己的通訊協議,這個通訊協議會在基於CS模型(Client-Server)的通訊元件上進行資訊傳輸。本次我們將採用Netty作為通訊元件的底層,ProtoStuff作為編解碼的工具。接下來就開始吧。
>>編解碼
在Netty中,編碼是指將資料轉換為緩衝區中的二進位制資料,對應的編碼類是MessageToByteEncoder,此類中的write方法可以將訊息物件進行編碼,然後寫入到傳送管道中。由於在此類中,encode編碼方法是abstract的,所以需要使用者來自己實現,我們就以ProtoStuff來書寫一下。而解碼則是指將緩衝區中的二進位制資料轉換為資料物件,對應的解碼類是ByteToMessageDecoder,類似的,我們需要自己實現decode的編碼方法,因為它也是abstract的。
首先我們需要封裝一個SerializeUtil通用類出來,此類只包含基於ProtoStuff實現的serialize(Object object)和deserialize(byte[] data, Class<T> clazz)出來,具體封裝如下:
由於Netty提供了MessageToByteEncoder和ByteToMessageDecoder這兩個類供我們進行編碼解碼,所以我們需要分別繼承這兩個類來實現我們的編碼器,解碼器。
首先來看看編碼器,主要是將二進位制資料放入管道中。
然後來看看解碼器,主要是將二進位制資料提取出來並轉換為訊息物件。
注意這裡我們並非直接繼承自ByteToMessageDecoder來實現,是因為單純的繼承自這個類,需要我們自己手動處理粘包拆包的情況,比較麻煩。所以我們繼承自LengthFieldBasedFrameDecoder這個用來處理粘包拆包的類,此類正是繼承自ByteToMessageDecoder,所以大大簡化了我們的工作。粘包拆包的具體實現,後面我們會詳細講解。
從上面的程式碼中,我們就可以看到在Netty中,實現自己的編碼解碼器是多麼的簡單和方便。需要注意的是,在解碼的時候,由於ByteBuf本身的readerIndex和writeIndex機制,在讀取的時候需要用readBytes來使得readerIndex索引後移,不可以用getBytes來操作,否則會導致readerIndex不能向後移動,從而導致netty did not read anything but decoded a message的錯誤,這個錯誤的意思就是你當前讀取的資料是空的,無法轉化為訊息物件,原因是因為我們之前已經讀過此資料了,由於readerIndex未更新,導致我們讀取的是空資料。關於readerIndex和writIndex更多詳細內容,可以翻閱此文,我在這裡做了更加詳細的講解。
>>粘包拆包
在Netty中,已經提供好了粘包拆包的公共類庫,他們是:LineBasedFrameDecoder,StringDecoder,LengthFieldBasedFrameDecoder,DelimiterBasedFrameDecoder,FixedLengthFrameDecoder。其中StringDecoder擴充套件自MessageToMessageDecoder類,其他的幾個均擴充套件自ByteToMessageDecoder類。為什麼擴充套件自ByteToMessageDecoder類呢?因為粘包拆包發生在從緩衝區中將二進位制資料讀取出來的過程中,而ByteToMessageDecoder類,是將二進位制資料轉換為具體的訊息物件的類,所以這些類庫繼承自這個類也是理所當然的事情了。接下來我們對這些粘包拆包工具進行一一講解和實踐。
LineBasedFrameDecoder:遍歷ByteBuf中的可讀位元組,然後看是否有\n或者\r\n,如果存在,就認為當前尋找的訊息體已經找尋完畢。同時此類也支援最大長度的資料匹配,當讀取的資料長度已達到最大長度但是仍舊沒有找到\n或者\r\n換行結束符的時候,將會丟擲異常,同時忽略掉之前讀取的異常碼流。
StringDecoder:將接收到的內容轉換為String串。
將LineBasedFrameDecoder+StringDecoder組合起來,就可以形成按行進行切分的文字解碼器,使用這種組合來進行粘包拆包處理,非常可靠易用。由於此組合只支援資料訊息含有結束換行符的,所以只適合簡單的純文字場合。
LengthFieldBasedFrameDecoder:此解碼器主要是通過訊息頭部附帶的訊息體的長度來進行粘包拆包操作的。由於其配置引數過多(maxFrameLength,lengthFieldOffset,lengthFieldLength,lengthAdjustment,initialBytesToStrip等),所以可以最大程度的保證能用訊息體長度欄位來進行訊息的解碼操作。這些不同的配置引數可以組合出不同的粘包拆包處理效果。
DelimiterBasedFrameDecoder:此解碼器主要通過設定分隔符來進行訊息的粘包拆包處理。
FixedLengthFrameDecoder:此解碼器主要是通過設定固定資料長度來進行訊息的粘包拆包處理。
>>鑑權認證
此包為Client連線Server的時候,需要傳送的第一個資料包,Server端接收到此包的內容後,通過業務解析,來對當前請求登入的Client進行鑑權操作。如果操作成功,則允許登入,否則拒絕登入。由於業務解析這塊不屬於我們重點講解的內容,在示例程式碼中,我們以簡單的鑑權操作來進行延時講解:
首先,Client端連線到Server端,當鏈路Active的時候,Client端開始傳送鑑權申請。
然後,Server端接收到Client的鑑權申請,進行鑑權操作:
當Server端鑑權成功之後,會將鑑權成功的資訊傳送給Client端,Client端接收到鑑權成功的資訊後,打印出鑑權成功資訊:
這樣,一個鑑權認證的基本流程就出來了,從Client端到Server端,然後再到Client端。由於鑑權的具體方式和業務關聯性比較高,所以可以利用具體鑑權業務進行替換即可。
>>心跳檢測
當鑑權通過之後,Client端和Server端的正常通訊建立。可以進行業務訊息的交流。但是由於網路原因等會造成Client和Server的交流中斷,而且此種中斷是無法被感知的,所以Client端的心跳檢測設計如下:
從程式碼可以看出,我們的HeartBeatTask會以固定5秒的頻率向Server端傳送一次心跳資訊,如果收到Server端的心跳回復,則打印出來。
然後來看看Server端的心跳檢測程式碼:
從程式碼可以看出,Server端收到Client端的心跳包後,會打印出來,然後構建另一個心跳包回覆給Client端,也就是向Client端報告我還活著。
這樣,通過一來一去的心跳包檢測機制,就可以對Server端和Client端進行探活操作,避免業務上的不可用問題。
>>斷線重連
為了提高高可用性,可以對Client端加上此項特性保證服務的可用率。Client端示例程式碼如下:
由於Client關閉後,會跑到finally程式碼塊中,所以在這裡可以進行重連操作。
>>服務端編寫
首先來看看Netty建立服務端的時序圖:
從圖示可以看出,ServerBootstrap例項是出發點;然後繫結EventLoopGroup執行緒池;之後設定並繫結服務端Channel,繫結各種Handler;最後就繫結到本機進行監聽。此時Selector會一直進行輪詢操作,一旦發現註冊的Channel處於Ready狀態,則執行Handler鏈呼叫。
由於以上所有的元件都準備齊全,所以我們這裡可以很方便的進行服務端編碼了:
從程式碼中我們可以看到,之前講過的鑑權認證,編碼解碼,粘包拆包等都體現在了服務端Handler中,所以非常的簡介明瞭。
>>客戶端編寫
首先來看看Netty建立客戶端的時序圖:
從圖示可以看出,BootStrap是出發點;然後設定EventLoopGroup執行緒池;之後設定並繫結客戶端Channel和各種Handler;最後通過Connect方法進行服務端連線操作。其實和服務端差別不大。由於其設計也涉及到鑑權認證,編碼解碼,粘包拆包等,所以編碼是有些類似的:
好了,到了這裡,我們就已經能夠打造出來一個通用的通訊框架了,此框架雖然簡單,但是勝在囊括了各種必須的設計元素。可以作為指導框架進行業務邏輯的耦合設計,避免出現設計過程中因為缺乏指導思想導致設計出來的東西不符合業務需求,比如高可用需求。
上面就是Netty初級應用,我們介紹了在設計一個簡單通訊框架過程中所涉及到的比較重要的特性,接下來的篇章,我們將會講解如何設計分散式服務框架等一些中級內容,希望您能夠繼續駐足品嚐。