程序員的成長之路
互聯網/程序員/技術/資料共享
閱讀本文大概需要 28 分鐘。
原文:www.cnblogs.com/scy251147/p/10983814.html
作者:程序詩人
1. 前言
幾年前,我就一直想着要設計一款自己的實時通訊框架,於是出來了TinySocket,她是基於微軟的SocketAsyncEventArgs來實現的,由於此類提供的功能很簡潔,所以當時自己實現了緩衝區處理,粘包拆包等,彼時的.net平臺還沒有一款成熟的即時通訊框架出來,所以當這款框架出來的時候,將當時公司的商業項目的核心競爭力提升至行業前三。
但是後來隨着.net平臺上越來越多的即時通訊框架出來,TinySocket也是英雄暮年,經過了諸多版本迭代和諸多團隊經手,她不僅變得臃腫,而且也不符合潮流。整體的重構勢在必行了。
但是我還在等,在等一款真正的即時通訊底層庫出來。
都說念念不忘,必有迴響。通過不停的摸索後,我發現了netty這套底層通訊庫(對號入座,.net下對應的是dotnetty),憑藉着之前的經驗,第一感覺這就是我要找尋的東西。
後來寫了一些demo徹底印證了我的猜想,簡直是欣喜若狂,想着如果早點發現這個框架,也許就不會那麼被動的踩坑了。就這樣,我算是開啓了自己的netty之旅。
2. 整體架構模型
言歸正傳,我們繼續netty之旅吧。
分佈式服務框架,特點在於分佈式,功能在於服務提供,目標在於即時通訊框架整合。由於其能夠讓服務端和客戶端進行解耦,讓調用方和被調用方處於網絡的兩端但是通訊毫無障礙,從而能夠擴充整體的業務規模。對於一些業務場景稍微大一些的公司,一般都會採用分佈式服務框架。
包括目前興起的微服務設計,更是讓分佈式服務框架炙手可熱。那麼我們今天的目標,就是來打造一款手寫的分佈式服務框架TinyWhale,中文名巨小鯨(手寫作品,本文講解專用, 暫無更多精力打造成開源^_^),接下來讓我們開始吧。
說道目前比較流行的分佈式服務框架,朗朗上口的有Dubbo,gRpc,Spring cloud等。這些框架無一例外都有着如下圖所示的整體架構模型:
整體流程解釋如下:
1. 啓動註冊,指服務端開始啓動並將服務註冊到註冊中心中。註冊中心一般用zookeeper或者consul實現。
2. 啓動並監聽,指客戶端啓動並監聽註冊中心的服務列表。
3. 有變更則通知,指客戶端訂閱的服務列表發生改變,則將更新客戶端緩存。
4. 接口調用,指客戶端進行接口調用,此調用將首先會向服務端發起連接操作,然後進行鑑權,之後發起接口調用操作。
5. 客戶端數據監控,指監控端會監控客戶端的行爲和數據並做記錄。
6. 服務端數據監控,指監控端會監控服務端的行爲和數據並做記錄。
7. 數據分析並衍生出其他業務策略,指監控端會根據服務端和客戶端調用數據,來衍生出新的業務策略,比如熔斷,防刷,異地多活等。
當然,上面的流程是比較標準的分佈式服務框架所涉及的環節。在實際設計過程中,可以根據具體的使用方式進行調整,比如監控端只監控服務端數據,因爲客戶端我不用關心。或者客戶端不設置服務地址列表緩存,每次調用前都從註冊中心重新獲取最新的服務地址列表等。
TinyWhale,由於設計的初衷是簡單,可靠,高性能,所以這裏我們去除了監控端,所以流程5,流程6,流程7都會拿掉,如果有需要使用到監控端的,可以自行根據提供的接口來實現一套,這裏將不再對監控端做過多的贅述。
3. 即時通訊框架設計涉及要素
編解碼設計
編解碼設計任何通訊類框架,編解碼處理是無法繞過的一個話題。因爲網絡上只能流淌字節流,所以這種特性催生了很多的框架。由於這塊的工具非常多,諸如ProtoBuf,Marshalling,Msgpack等,所以喜歡用哪個,全憑喜好。
這裏我用使用ProtoStuff來作爲我們的編解碼工具,原因有二:其一是易用性,無需編寫描述文件;其二是高性能,性能屬於T0級別梯隊。下面來具體看看吧:
首先看看我們的編解碼類:
其中serialize方法,用於將類對象編碼成字節數據,然後通過本機發送出去。而deserialize方法,則用於將緩衝區中的字節數據還原爲類對象。
考慮到設計的簡潔性,我這裏並未抽象出一個公共的codecInterface和codecFactory來適配不同的編解碼工具,大家可以自行來進行設計和適配。
有了編解碼的輔助類了,如何集成到Netty中呢?
在Netty中,將對象編碼成字節數據的操作,我們可以使用已有的MessageToByteEncoder類來進行操作,繼承自此類,然後override encode方法,即可利用自己實現的protostuff工具類來進行編碼操作。
同樣的,將字節數據解碼成對象的操作,我們可以使用已有的ByteToMessageDecoder類來進行操作,繼承自此類,然後override decode方法,即可利用自己實現的protostuff工具類來進行解碼操作。
粘包拆包設計
之前章節已經講過,我們直接拿來展示下。
粘包拆包,顧名思義,粘包,就是指數據包黏在一塊了;拆包,則是指完整的數據包被拆開了。由於TCP通訊過程中,會將數據包進行合併後再發出去,所以會有這兩種情況發生,但是UDP通訊則不會。下面我們以兩個數據包A,B來講解具體的粘包拆包過程:
第一種情況,A數據包和B數據包被分別接收且都是整包狀態,無粘包拆包情況發生,此種情況最佳。
第二種情況,A數據包和B數據包在一塊兒且一起被接收,此種情況,即發生了粘包現象,需要進行數據包拆分處理。
第三種情況,A數據包和B數據包的一部分先被接收,然後收到B數據包的剩餘部分,此種情況,即發生了拆包現象,即B數據包被拆分。
第四種情況,A數據包的一部分先被接收,然後收到A數據包的剩餘部分和B數據包的完整部分,此種情況,即發生了拆包現象,即A數據包被拆分。
第五種情況,也是最複雜的一種,先收到A數據包的部分,然後收到A數據包剩餘部分和B數據包的一部分,最後收到B數據包的剩餘部分,此種情況也發生了拆包現象。
至於爲什麼會發生這種問題,根本原因在於緩衝區中的數據,Server端不大可能一次性的全部發出去,Client端也不大可能一次性正好把數據全部接收完畢。所以針對這些發生了粘包或者拆包的數據,我們需要找到合適的手段來讓其形成整包,以便於進行業務處理。
好消息是,Netty已經爲我們準備了多種處理工具,我們只需要簡單的動動代碼,就可以了,他們分別是:LineBasedFrameDecoder,StringDecoder,LengthFieldBasedFrameDecoder,DelimiterBasedFrameDecoder,FixedLengthFrameDecoder。
由於上節中,我們講解了其大概用法,所以這裏我們以LengthFieldBasedFrameDecoder來着重講解其使用方式。
LengthFieldBasedFrameDecoder:顧名思義,固定長度的粘包拆包器,此解碼器主要是通過消息頭部附帶的消息體的長度來進行粘包拆包操作的。由於其配置參數過多(maxFrameLength,lengthFieldOffset,lengthFieldLength,lengthAdjustment,initialBytesToStrip等),所以可以最大程度的保證能用消息體長度字段來進行消息的解碼操作。
這些不同的配置參數可以組合出不同的粘包拆包處理效果,在此Rpc框架的設計過程中,我的使用方式如下:
是不是代碼很簡單?
翻閱LengthFieldBasedFrameDecoder源碼,實現原理一覽無餘,由於網上講解足夠多,而且源碼中的講解也足夠詳細,所以這裏不再做過多闡釋。具體的原理解釋可以看這裏:LengthFieldBasedFrameDecoder
自定義協議設計
在進行網絡通訊的時候,數據包從一端傳輸到另一端,然後被解析,被消化。這裏就涉及到一個知識點,數據包是怎樣定義的,才能讓另一方識別出數據包所代表的業務含義。這就涉及到自定義傳輸協議的設計,我們來看看具體怎麼設計。
首先,我們需要明確自己定義的協議需要承載哪些業務數據,一般說來包含如下的業務要點:
1. 自定義協議需要讓雙端識別出哪些包是心跳包
2. 自定義協議需要讓雙端識別出哪些包是鑑權包
3. 自定義協議需要讓雙端識別出哪些包是具體的業務包
4. 自定義協議需要讓雙端識別出哪些包是上下線包等等(本條規則適用於IM系統)
不同的系統在設計的時候,自定義協議的設計是不一樣的,比如分佈式服務框架,其業務包則需要包含客戶端調用了哪個方法,入參中傳入了哪些參數等。物聯網採集框架,其業務包則需要包含底層採集硬件上傳的數據中,哪些數值代表空氣溫度,哪些數值代表光照強度等。
同樣的,IM系統則需要知道當前的聊天是誰發出的,想發給誰等等。正是由於不同系統承載的業務不同,所以導致自定義協議種類繁多,不一而足。性能表現也是錯落有致。複雜程度更是簡繁並舉。
那麼針對要講解的分佈式服務框架,我們來詳細看一下設計方式。
首先定義一個NettyMessage泛型類,此泛型類是一個基礎類,包含了會話ID,消息類型,消息體三個字段。這三個字段是服務端和客戶端進行數據交換過程中,必傳的三個字段,所以整體抽取出來,放到了這裏。
然後,針對客戶端,定義一個NettyRequest類,包含基本的請求ID,調用的類名稱,方法名稱,入參類型,入參值。
最後,客戶端的請求傳送到服務端,服務端需要反射調用方法並將結果返回,服務端的NettyResponse類,則包含了請求ID,用於識別請求來自於哪個客戶端,error錯誤,result結果三個字段:
當服務端調用完畢,就會把結果封裝到此類中,然後將結果返回給客戶端,客戶端還原此類,即可拿出自己想要的數據來。
那麼這個稍顯冗雜的自定義協議就設計完畢了,有人會問,心跳包用這個協議如何識別呢?其實直接實例化NettyMessage類,然後在其type字段中塞入心跳標記值即可,類似如下:
而上下線包和鑑權包則也是類似的構造,不通點在於,鑑權包 可能需要往body屬性裏面放一些鑑權用的用戶token等。
鑑權設計
顧名思義,就是進行客戶端登錄的認證操作。由於客戶端不是隨意就能連接上來的,所以需要對客戶端連接的合法性進行過濾操作,否則很容易造成各種業務或者非業務類的問題,比如數據被盜竊,服務器被壓垮等等。那麼一般說來,如何進行鑑權設計呢?
可以看到,上面的鑑權模塊裏面有三個屬性,一個是已登錄的用戶列表clientList,一個是用戶白名單whiteIP,一個是用戶黑名單blackIP,在進行用戶認證的時候,會通過用戶token,白名單,黑名單做驗證。由於不同業務的認證方式不一樣,所以這裏的設計方式也是五花八門。
一般說來,分佈式服務框架的認證方式依賴於token,也就是服務端的provider啓動的時候,會給當前服務分配一個token,客戶端進行請求的時候,需要附帶上這個token才能夠請求成功。
由於我這裏只是做演示效果,並未利用token進行驗證,實際設計的時候,可以附帶上token驗證即可。
心跳包設計
傳統的心跳包設計,基本上是服務端和客戶端同時維護Scheduler,然後雙方互相接收心跳包信息,然後重置雙方的上下線狀態表。
此種心跳方式的設計,可以選擇在主線程上進行,也可以選擇在心跳線程中進行,由於在進行業務調用過程中,此種心跳包是沒有必要進行發送的,所以在一定程度上會造成資源浪費。嚴重的甚至會影響業務線程的操作。但
是在netty中,心跳包的設計並非按照如上的方式來進行。而是通過檢測鏈路的空閒與否在進行的。鏈路分爲讀操作空閒檢測,寫操作空閒檢測,讀寫操作空閒檢測。
如果一段時間沒有收到客戶端的信息,則會觸發服務端發送心跳包到客戶端,稱爲讀操作空閒檢測;如果一段時間沒有向客戶端發送任何消息,則稱爲寫操作空閒檢測;如果一段時間服務端和客戶端沒有任何的交互行爲,則稱爲讀寫操作空閒檢測。
由於空閒檢測本身只有在通道空閒的時候才進行檢測,而不是固定頻率的進行心跳包通訊,所以可以節省網絡帶寬,同時對業務的影響也很小。
那麼就讓我們看看在netty中,怎麼實現高效的心跳檢測吧。
在netty中,進行讀寫操作空閒檢測,需要引入IdleStateHandler類,然後需要我們實現自己的心跳處理Handler,具體設計方式如下:
首先,引入IdleStateHandler和服務端心跳處理Handler
其中讀空閒檢測爲45秒,寫空閒檢測爲45秒,讀寫空閒檢測爲120秒,也就是說,如果服務器45秒沒有收到客戶端發來的消息,就會觸發一個回調事件,另外兩個同理。具體觸發什麼事件了呢?
我們來看看服務端心跳處理Handler:HeartBeatResponseHandler
可以看到,檢測到讀空閒,會調用processReadIdle方法來處理,我們進來看看具體處理方式:
可以看到,服務端發現一段時間沒收到客戶端消息後,就會主動給客戶端發一次心跳,確認客戶端是否存活。
如果在第90秒內還沒有收到客戶端的回覆心跳,則會嘗試再發一條,同時在客戶端上下線狀態表中,將當前客戶端的未響應次數加一;如果在第135後認爲收到客戶端的回覆心跳,則會嘗試重發一條,同時未響應次數再加一,當次數累積到三次的時候,則認爲此客戶端掉線,此時將會踢掉此客戶端。
如果是IM系統的話,此時服務端就可以將此客戶端的信息告知其他在線用戶掉線,這樣其他用戶就可以在自己的客戶端列表中刪掉掉線用戶。
至於processWriteIdle和processAllIdle方法,均是如上類似原理,至於需要處理,怎麼去處理,均是業務自己定製,相當靈活。
很遺憾,在翻閱很多基於Netty的源碼中,並未發現此樣的實現方式,這也是相當可惜的。
斷線重連設計
在實際網絡通訊過程中,客戶端可能由於網絡原因未能及時的響應服務端的心跳請求,從而被服務端踢下線。之所有有這種機制,一方面是爲了節省服務端資源,剔除死鏈接;另一方面則是出於業務要求,比如IM系統中,用戶掉線了,但是服務端沒有及時剔除,會導致其他用戶認爲此用戶在線,從而可能造成誤解等。
那麼就需要有一種機制來保證客戶端網絡掉線後,能夠及時的感知並進行重連,從而保證服務的可用性。之前我們介紹了心跳包,它是專門用來保持服務端和客戶端的通道連接保持的。
假設當客戶端因爲網絡原因,被服務端踢下線後,客戶端是無感知的,並不知道自己已經被服務端踢下線,所以這時候如果客戶端依舊向服務端發送數據,將會失敗。此時這就是斷線重連應該工作的地方了。
具體設計如下:
可以看到,我們依舊用了netty原生的IdleState類來檢測空閒通道。當客戶端一段時間沒收到服務端的消息,將會首先嚐試給服務端發送一次心跳,由於此時客戶端已經被服務端踢掉了,所以三次心跳均未獲得迴應,此時,客戶端突然想明白了:“哦,我想我已經掉線了”。
於是客戶端將會利用ctx對象進行服務端重連操作。
此種方式簡單易行,雖然不具有實時性,但是效果很好,可以有效地避免因爲網絡抖動等未知原因導致的掉線問題。
以上幾種特性,是設計通信框架過程中,基本上都繞不開。雖然不同的通信框架由於承載的業務不同而造成設計上的差異,但是正是因爲這些特性的存在,才能保證整個通信過程中的穩定性和可靠性。
接下來我們將焦點轉移到服務端和客戶端的設計上來。
先說說服務端和客戶端,基本上的通訊模型爲,服務端bind本地端口,然後進行listen監聽。客戶端connect服務端套接字,然後進行通訊。用netty打造的雙端,也繞不開這種通訊模型。
其實如果讀者有過通信框架的設計經驗的話,將會對此十分熟悉。不過就通訊方式來說,也是很統一的,一般都是一端發送數據,另一端接收處理,然後看具體業務再決定需不需要返回數據回去。
那麼這裏就涉及到一個要點,因爲數據的返回有同步和異步之分,一般說來同步等待數據返回的性能要比異步獲取數據的性能要差一些,但是具體能差異多少,完全由設計者自己把握。
同步等待數據返回這塊,我就無需多說了,基本上就是如下示例代碼:
異步獲取返回數據這塊,則設計上要複雜一些,因爲設計方式是多種多樣的。有用雙Queue來做異步化(任務quque和應答queue), 有用Future來做異步化,當然也有用多線程來做異步化等。TinyWhale的異步化處理,採用的是後者,在客戶端講解那塊,將會做詳細的解釋。
再說說netty框架,由於其純異步化模型,所以獲取的各種結果對象基本上是各種Future,如果之前對這種模型接觸比較少的話,將會不太習慣netty的這種設計思維。具體的使用方式,將會在接下來的設計中進行詳細講解。
服務端設計
首先說道服務端,是指提供服務的一方,一般用來處理客戶端請求。由於netty這塊,已經將底層封裝的特別好,所以這裏無需多餘設計,只需要瞭解netty的異步模型即可。那麼何爲netty的異步模型呢?
既然說到了同步異步,那麼不免就會提起阻塞非阻塞,我就說下個人的理解吧。同步異步的區別,個人認爲,只要不是一個時間只能做一件事兒的,均可稱爲異步。實現異步有多種方式,而多線程只是異步的一種實現方式而已。
比如我們用兩個queue模擬生產消費行爲,也可以稱之爲異步。阻塞非阻塞的區別,個人認爲,主要體現在對資源的爭搶等待上面,發生了資源爭搶等待,則被阻塞,反之爲非阻塞。比如http請求遠程結果,阻塞等待等。個人意見,如有謬誤,還請指教。接下來讓我們進入正題。
首先要從同步阻塞模型說起。
同步阻塞
相信大家都聽說過這個模型,客戶端請求到服務端,服務端裏面有個Acceptor接收請求,然後針對每個請求都創建一個Thread來處理,典型的一對一通信處理方式。看下具體的模型示意圖:
首先,客戶端請求達到Acceptor,Acceptor接收並處理,然後Acceptor爲每個請求創建一個線程來處理。這樣後續的請求處理工作就在各自的線程上進行處理了。此種方式最簡便,代碼也非常好寫,但是帶來的問題就是一個請求對應一個線程,無法做到高性能,而且由於線程開銷較大,對服務器的穩定運行也有一定的影響,隨時都有可能出現內存耗盡,創建線程失敗等,最終的結果就是因爲宕機等緣故造成生產問題。
由於上述問題,後來產生了僞異步處理模型,其實就是講Acceptor裏面爲每個請求分配一個線程,改成了線程池這種池化方式來處理,總體上性能比之前要好很多,而且機器運行也穩定很多,相對之前的模型,有了不小的提升。但是從本質上來將,此種方式和之前方式相比,並未有質的改變,之所以稱爲僞異步,緣由在此吧。
非阻塞
同步阻塞模型由於性能不好,可靠性低,所以催生了非阻塞模型的產生。目前非阻塞模型有兩種,一種是NIO,另一種是AIO,然而AIO雖可以稱得上爲真正的異步非阻塞IO模型,代碼也很簡便,但是並未大規模的應用,料想應該有它自身的短板,所以我們着重來講解NIO模型。首先來看看NIO模型示意圖:
上面這幅圖是網上流傳比較廣的一幅圖,因爲被大家所熟知,所以這裏我就直接拿來用了,這幅圖的出處在這裏。具體來看一下。
首先,從圖中可以看出,client爲客戶端請求,mainReactor主要接收客戶端請求,然後調用acceptor進行處理,acceptor查到已經就緒的連接,則交由subReactor進行處理。
subReactor這裏會負責已連接客戶端的讀寫網絡操作,也就是如果有讀寫操作,會反映到subReactor中來,至於業務處理部分,則直接扔給ThreadPool進行業務處理。
一般說來,subReactor的個數大概和CPU的核數是一致的。從這裏還可以看出mainReactor和subReactor都有派發器的意味。
由於此NIO模型使用了事件驅動,而且以linux底層作爲通訊支持,完全使用了epoll高性能的特點,所以整體表現堪稱完美。這裏我要推薦一座金礦,大名鼎鼎的C10k問題,諸位看官如果有興趣,可以探索一番。
然後來具體說下服務端設計吧:
public class NettyServer {
/** * 服務端帶參構造 * @param serverAddress * @param serviceRegistry * @param serverBeans */ public NettyServer(String serverAddress, ServerRegistry serviceRegistry, Map<String, Object> serverBeans) { this.serverAddress = serverAddress; this.serviceRegistry = serviceRegistry; this.serverBeans = serverBeans; }
/** * 日誌記錄 */ private static final Logger logger = LoggerFactory.getLogger(NettyServer.class);
/** * 服務端綁定地址 */ private String serverAddress;
/** * 服務註冊 */ private ServerRegistry serviceRegistry;
/** * 服務端加載的bean列表 */ private Map<String, Object> serverBeans;
/** * 主事件池 */ private EventLoopGroup bossGroup = new NioEventLoopGroup();
/** * 副事件池 */ private EventLoopGroup workerGroup = new NioEventLoopGroup();
/** * 服務端通道 */ private Channel serverChannel;
/** * 綁定本機監聽 * * @throws Exception */ public void bind() throws Exception {
//啓動器 ServerBootstrap serverBootstrap = new ServerBootstrap(); //爲Acceptor設置事件池,爲客戶端接收設置事件池 serverBootstrap.group(bossGroup, workerGroup) //工廠模式,創建NioServerSocketChannel類對象 .channel(NioServerSocketChannel.class) //等待隊列大小 .option(ChannelOption.SO_BACKLOG, 100) //地址複用 .option(ChannelOption.SO_REUSEADDR, true) //開啓Nagle算法, //網絡好的時候:對響應要求比較高的業務,不建議開啓,比如玩遊戲,鍵盤數據,鼠標響應等,需要實時呈現; // 對響應比較低的業務,建議開啓,可以有效減少小數據包傳輸。 //網絡差的時候:不建議開啓,否則會導致整體效果更差。 .option(ChannelOption.TCP_NODELAY, true) //日誌記錄組件的level .handler(new LoggingHandler(LogLevel.INFO)) //各種業務處理handler .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel channel) throws Exception { //空閒檢測handler,用於檢測通道空閒狀態 channel.pipeline().addLast("idleStateHandler", new IdleStateHandler(45, 45, 120)); //編碼器 channel.pipeline().addLast("nettyMessageDecoder", new NettyMessageDecoder(1024, 4, 4)); //解碼器 channel.pipeline().addLast("nettyMessageEncoder", new NettyMessageEncoder()); //心跳包業務處理,一般需要配置idleStateHandler一起使用 channel.pipeline().addLast("heartBeatHandler", new HeartBeatResponseHandler()); //服務端先進行鑑權,然後處理業務 channel.pipeline().addLast("loginAuthResponseHandler", new LoginAuthResponseHandler()); //業務處理handler channel.pipeline().addLast("nettyHandler", new ServerHandler(serverBeans)); } });
//獲取ip和端口 String[] array = serverAddress.split(":"); String host = array[0]; int port = Integer.parseInt(array[1]);
//綁定端口,同步等待成功 ChannelFuture future = serverBootstrap.bind(host, port).sync();
//註冊連接事件監聽器 future.addListener(cfl -> { if (cfl.isSuccess()) { logger.info("服務端[" + host + ":" + port + "]已上線..."); serverChannel = future.channel(); } });
//註冊關閉事件監聽器 future.channel().closeFuture().addListener(cfl -> { //關閉服務端 close(); logger.info("服務端[" + host + ":" + port + "]已下線..."); });
//註冊服務地址 if (serviceRegistry != null) { serviceRegistry.register(serverBeans.keySet(), host, port); } }
/** * 關閉server */ public void close() { //關閉套接字 if(serverChannel!=null){ serverChannel.close(); } //關閉主線程組 if (bossGroup != null) { bossGroup.shutdownGracefully(); } //關閉副線程組 if (workerGroup != null) { workerGroup.shutdownGracefully(); } }}
由於代碼做了具體的註釋,我這裏就不針對性的進行解釋了。需要注意的是,當服務啓動之後,會註冊兩個監聽器,一個綁定時間監聽,一個關閉事件監聽,當事件被觸發的時候,會回調兩個事件內部的邏輯。
最後服務端正常啓動,會被註冊到註冊中心中,以便於客戶端調用。需要注意的是,一般情況下,業務Handler最好和心跳包Handler等非業務性的Handler處理分開,避免業務高峯時期,因爲心跳包等Handler的處理來耗費捉襟見肘的內存資源或者CPU資源等,造成服務器性能下降。來看一下ServerHandler的具體設計:
從這裏可以看出,我們用了一個線程池來將業務處理進行池化,這樣做就不會受到心跳包等其他非業務處理Handler的影響,最大限度的保證系統的穩定性。
更多關於同步異步,阻塞非阻塞的設計,請參見Doug Lea:Scalable IO in Java(http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf)
客戶端設計
再來說說客戶端,是指消費服務的一方,一般用來實現特定的消費業務。同樣的,netty這塊已經將底層封裝的很好,所以直接編寫業務即可。
和編寫服務端不同的是,這裏不需要分BossGroup和WorkerGroup,因爲對於客戶端來說,只需要連接服務端,然後發送數據並監聽即可,不存在影響性能的問題。
具體的寫法看看吧:
public class NettyClient { /** * 日誌記錄 */ private static final Logger logger = LoggerFactory.getLogger(NettyClient.class); /** * 客戶端請求Future列表 */ private Map<String, TinyWhaleFuture> clientFutures = new ConcurrentHashMap<>(); /** * 客戶端業務處理handler */ private ClientHandler clientHandler = new ClientHandler(clientFutures); /** * 事件池 */ private EventLoopGroup group = new NioEventLoopGroup(); /** * 啓動器 */ private Bootstrap bootstrap = new Bootstrap(); /** * 客戶端通道 */ private Channel clientChannel; /** * 客戶端連接 * @param host * @param port * @throws InterruptedException */ public NettyClient(String host, int port) throws InterruptedException { bootstrap.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel channel) throws Exception { //通道空閒檢測 channel.pipeline().addLast("idleStateHandler", new IdleStateHandler(45, 45, 120)); //解碼器 channel.pipeline().addLast("nettyMessageDecoder", new NettyMessageDecoder(1024 * 1024, 4, 4)); //編碼器 channel.pipeline().addLast("nettyMessageEncoder", new NettyMessageEncoder()); //心跳處理 channel.pipeline().addLast("heartBeatHandler", new HeartBeatRequestHandler()); //業務處理 channel.pipeline().addLast("clientHandler", clientHandler); //鑑權處理 channel.pipeline().addLast("loginAuthHandler", new LoginAuthRequestHandler()); } }); //發起同步連接操作 ChannelFuture channelFuture = bootstrap.connect(host, port); //註冊連接事件 channelFuture.addListener((ChannelFutureListener)future -> { //如果連接成功 if (future.isSuccess()) { logger.info("客戶端[" + channelFuture.channel().localAddress().toString() + "]已連接..."); clientChannel = channelFuture.channel(); } //如果連接失敗,嘗試重新連接 else{ logger.info("客戶端[" + channelFuture.channel().localAddress().toString() + "]連接失敗,重新連接中..."); future.channel().close(); bootstrap.connect(host, port); } }); //註冊關閉事件 channelFuture.channel().closeFuture().addListener(cfl -> { close(); logger.info("客戶端[" + channelFuture.channel().localAddress().toString() + "]已斷開..."); }); } /** * 客戶端關閉 */ private void close() { //關閉客戶端套接字 if(clientChannel!=null){ clientChannel.close(); } //關閉客戶端線程組 if (group != null) { group.shutdownGracefully(); } } /** * 客戶端發送消息,將獲取的Future句柄保存到clientFutures列表 * @return * @throws InterruptedException * @throws ExecutionException */ public TinyWhaleFuture send(NettyMessage<NettyRequest> request) { TinyWhaleFuture rpcFuture = new TinyWhaleFuture(request); rpcFuture.addCallback(new TinyWhaleAsyncCallback() { @Override public void success(Object result) { } @Override public void fail(Exception e) { logger.error("發送失敗", e); } }); clientFutures.put(request.getBody().getRequestId(), rpcFuture); clientHandler.sendMessage(request); return rpcFuture; }}
由於代碼中,我也做了諸多的註釋,所以這裏不再一一解釋。需要注意的是,和編寫服務端類似,我這裏添加了兩個監聽事件,監聽連接成功事件,監聽關閉事件,響應的業務場景如果觸發了這兩個事件,將會執行事件內部的邏輯。
這裏需要提一下消息發送的場景。一般說來,客戶端向服務端發送數據,然後服務端處理功能後返回給客戶端,客戶端接收到消息後再進行後續處理。這個流程一般有兩種實現方式,一種是同步的實現方式,另一種是異步的實現方式,具體來呈現以下:
首先是同步實現方式,顧名思義,客戶端發送數據給服務端,服務端在處理完畢並返回數據之前,客戶端一直處於阻塞等待狀態,send方法的代碼設計如下:
來看看clientHandler裏面的sendMessage方法:
在開始發送之前,我們先拿到當前ctx的promise句柄,然後將數據寫入到緩衝區,最後將此句柄返回給send方法,send方法接收到此句柄後,將會等待promise執行完畢,如何判斷promise執行完畢呢?當客戶端接收到服務端返回,就可以將promise置爲完成狀態:
可以看到,通過重置promise的setSuccess方法,即可將promise置爲完成態,這樣操作之後,send方法裏面就可以正常的拿到數據並返回了。否則將會一直處於阻塞狀態。
可以看到,在netty中實現阻塞的方式來接收服務端返回,處理起來還是挺麻煩的,根本原因在於netty完全異步化的模型,所以只能用如上的方式來進行同步化處理。
再來說說異步化處理吧, 這也是netty很推崇的方式。
首先來看看send方法:
從上面代碼中可以看到,當我們將消息發送出去後,會立即獲得一個TinyWhaleFuture的句柄,不會再有阻塞等待的場景。我們看看clientHandler.sendMessage的具體實現:
可以看到,只是單純的將數據推送到緩衝區而已。
還記得我們的TinyWhaleFuture句柄嗎?既然返回給我們了這個句柄,那麼我們肯定是可以從此句柄中取出我們想要獲取的數據的,我們看看客戶端如果收到服務端的返回結果,該如何處理呢?
可以看到,這裏利用了一個Map來保存用戶每個發送請求,一旦當服務端返回數據後,就會將請求置爲完成態,同時從Map中將已完成的操作刪掉。這樣,客戶端拿到TinyWhaleFuture句柄後,通過提供的get方法即可在想獲取結果的地方來獲取返回結果。這樣做,是不會阻塞其他業務執行的。
其實不僅僅是netty中,在設計其他框架的時候,也可以利用此思想來實現真正意義上的異步執行邏輯。當然,能夠實現這種執行邏輯的方式有很多種,至於更好的實現方式,還請君細細斟酌吧,這裏只起到拋磚引玉的作用。
4. 動態調用設計
服務註冊和服務發現
先來上個大致的類設計圖,ServerCache接口提供基礎的本地緩存操作;ServerBase提供基礎的連接註冊中心,關閉註冊中心連接操作;ServerRegistry爲服務註冊類;ServerDiscovery爲服務發現類,下面是類UML圖,我們來具體的說一說:
首先是註冊中心,這個就不必說了,一般都是使用zookeeper或者consul等框架來實現,這裏我們使用zookeeper。但是我們這裏並不是用原生的zookeeper sdk來操作,而是使用curator來操作,curator是什麼呢?
在其介紹頁面有句很經典的話:Guava is to Java what Curator is to Zookeeper,相當的簡潔明瞭吧。來看下具體的使用方式吧。
首先定義用於加載註冊中心服務套接字的共享緩存,客戶端啓動的時候,此共享緩存會從註冊中心拉取服務器列表到本地保存:
然後,定義服務治理的公共操作類:
可以看到,此基類中,open方法和close方法,用於連接zk服務器,關閉和zk服務器的連接。之後便是對接口中操作本地緩存的實現。
由於服務治理這塊包含了服務註冊和服務發現功能,所以這裏,我們分別定義ServerRegistry類和ServerDiscovery類來進行處理。
ServerRegistry類,顧名思義,表示服務註冊,也就是當我們的服務端啓動之後,綁定了本機端口之後,會將承載的服務註冊到zk中。
ServerDiscovery類,顧名思義,服務發現,那麼此類中的discovery方法則就是根據用戶傳入的接口名稱來找到對應的服務器,然後將結果返回。
需要注意的是,服務發現的過程,需要涉及到負載均衡,之所以涉及到這個,主要是爲了讓每臺服務器收到的請求均勻一些,以達到均衡的目的,這樣就不會因爲請求打的不均勻導致有些服務器負載太大,有些服務器負載幾乎沒有的情況。
負載均衡,我將在後面的章節講解,先繼續看看服務發現這塊:
可以看到,我用了一個watchNode方法來檢測節點的改動,此方法內部設置了一個Listener,只要有節點的改動,都會推送到此Listener中,然後我就可以根據改動的類型來決定是否對本地緩存進行更新操作。
更具體的服務註冊和服務發現使用方式,可以參考curator官網:Service register and Service discovery(http://curator.apache.org/curator-x-discovery/index.html)
負載均衡
前面說到了服務治理這塊,由於裏面涉及到負載均衡這塊,這裏就詳細說一下。
一般說來,有三種負載均衡模型是繞不開的,分別是一致性哈希,此模型可以讓帶有業務標記的請求每次請求都會導向到指定的服務器上。輪詢,此模型主要是對服務器列表進行順序訪問。隨機,此模型主要是隨機獲取服務器並返回。其他的模型還有很多,可以根據具體的業務進行衍生,這裏不做一一的展示。
首先來看看負載均衡基類:
然後看看三種模型的實現:
一致性哈希實現,直接對服務端的size進行取餘操作:
輪詢實現,對訪問過的服務器進行計數累加,然後把此計數作爲下標並獲取元素返回:
隨機實現,對服務器進行隨機選取:
你也許會問,爲什麼你設計的負載均衡裏面沒有權重操作呢?
其實如果願意,也是可以加上權重操作的,這樣就會衍生出來其他的負載均衡模型,比如服務訪問不同,權重-10,服務能訪問通,權重+1,這樣就可以通過權重,選取一些權重較高的服務器優先返回,而對那些權重較低的服務器,可以少分一些請求,讓其慢慢恢復到正常狀態之後,再多分配一些請求過來等等。
總之,你可以在此基礎上進行自己的設計,但是大體思想就是讓服務器獲得的負載越均衡越好。
容災處理
此處整合Hystrix進行的設計,可以對請求做FailFast處理,RetryOnece處理,RetryTwice處理等, 具體細節可以翻看Hystrix設計即可。這裏就不詳解(哈哈哈,其實是因爲寫着寫着,寫的懶了,這塊就不想講了,畢竟基本上都是Hystrix那套)。
反射調用
最後要說的部分就是反射調用這塊了。我們知道,當客戶端發送待調用的方法發送給服務端,服務端接收後,需要通過反射調用方法,然後將結果返回給客戶端。首先來看看服務端業務處理Handler:
可以看到,此業務處理handler會讀取客戶端的請求,然後分析數據包內容,最後利用反射來調用相應的方法獲取結果並壓入緩衝區中,之後發送給客戶端。
再來看看handle方法是如何進行反射調用並得到結果的:
可以看到,很經典的反射調用場景,這裏就不細說了。
從這裏,我們可以看出,服務端的處理方式如上,非常的簡單。但是客戶端是怎麼發送請求消息給服務端,又是如何接收服務端的返回數據的呢?
從上面可以看出,我們用了javassist組件的反射(java自身的反射也是類似的使用方式)來構建完整的類對象,然後利用callback回調來發送請求給服務端獲取數據,然後獲取服務端返回的數據,最後將返回的數據拆解後,返回給客戶端。如果用java自帶的反射來實現,編碼也是差不多的:
這裏需要注意的是,此處用了動態反射的功能來實現,性能並不是特別好,如果能用上字節碼技術,性能會再提升一個臺階。具體的字節碼實現方式,可以參見我後續的文章。
5. 跑起來吧!!
好了,我們終於把一切都準備好了,那麼就讓我們運行起來吧。
在服務端,首先可以看到如下的註冊中心上線日誌:
然後可以看到客戶端登錄日誌:
在客戶端,我們可以看到如下的日誌:
可以看到,客戶端連接上來後,先發送鑑權請求,鑑權成功後,將會發送服務調用請求,調用完畢後,得到返回結果,整個過程耗費18ms,最後客戶端退出。
當我們在客戶端調用的時候,加上Thread.Sleep來觸發心跳探活,可以看到如下的檢測結果:
可以看到,每隔5秒鐘,我們都能收到客戶端的心跳,然後我們模擬網絡差,客戶端掉線,看看服務端如何檢測:
可以看到客戶端被踢掉了,此時我們再去看看客戶端日誌,可以看出來,客戶端確實被服務端踢掉線了:
最後,東西做完後,補一個benchmark吧,由於我的機器性能比較差,而且測試是直接開啓IDEA這個IDE來測試的,所以性能並不見得很好:
然後來看看benchmark結果吧:
性能並不是特別好,關鍵有以下幾個地方是耗時大戶:編解碼,反射,同步等待服務端返回
編解碼這個只能找性能比較好的組件來解決
反射可以通過字節碼來實現,性能會再提升一個檔次,但是難度也會提升不少。
同步等待服務返回,可以通過完全異步化實現來解決,那麼剛剛展示的
調用方式,會被改變成:
雖然這樣速度會快很多,但是用戶能否接受這種調用方式,則是另一個頭疼的問題。性能和易用,本身就具有相悖性,所以只能在進退之間做平衡了。
寫到這裏,整體介紹差不多了,但是還有很多東西沒有接入,譬如說kafka,mq,redis等。如果能把這些東西接入,則會讓其整體顯得更加豐滿,同時功能也更豐富,應用場景也會更廣闊一些。
6.總結
寫到這裏,利用netty打造分佈式服務框架的要點就基本上完結了。通篇看來,知識點很多,但是都是我們耳熟能詳的東西,能把它們串在一起,組成一個可以用的框架,則需要一定的思考。
<END>
推薦閱讀:
5T技術資源大放送!包括但不限於:C/C++,Linux,Python,Java,PHP,人工智能,單片機,樹莓派,等等。在公衆號內回覆「2048」,即可免費獲取!!
微信掃描二維碼,關注我的公衆號
寫留言
朕已閱