1. 程式人生 > >微言Netty:分散式服務框架

微言Netty:分散式服務框架

1. 前言

幾年前,我就一直想著要設計一款自己的實時通訊框架,於是出來了TinySocket,她是基於微軟的SocketAsyncEventArgs來實現的,由於此類提供的功能很簡潔,所以當時自己實現了緩衝區處理,粘包拆包等,彼時的.net平臺還沒有一款成熟的即時通訊框架出來,所以當這款框架出來的時候,將當時公司的商業專案的核心競爭力提升至行業前三。但是後來隨著.net平臺上越來越多的即時通訊框架出來,TinySocket也是英雄暮年,經過了諸多版本迭代和諸多團隊經手,她不僅變得臃腫,而且也不符合潮流。整體的重構勢在必行了。但是我還在等,在等一款真正的即時通訊底層庫出來。

都說念念不忘,必有迴響。通過不停的摸索後,我發現了netty這套底層通訊庫(對號入座,.net下對應的是dotnetty),憑藉著之前的經驗,第一感覺這就是我要找尋的東西。後來寫了一些demo徹底印證了我的猜想,簡直是欣喜若狂,想著如果早點發現這個框架,也許就不會那麼被動的踩坑了。就這樣,我算是開啟了自己的netty之旅。

微言netty系列,就是我的netty之旅的一些產出,它結合了我過往的經驗來產出一些對大家有用的東西,希望不會讓大家失望。

注:本文原理講解並非以某一種語言為主,但是對於具體場景分析,用的是Java,讀者可以類推到其他語言。同時本文並不提供原始碼級別的原理性講解,如讀者有興趣,可以自行查詢實踐。

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問題,諸位看官如果有興趣,可以探索一番。

然後來具體說下服務端設計吧:

由於程式碼做了具體的註釋,我這裡就不針對性的進行解釋了。需要注意的是,當服務啟動之後,會註冊兩個監聽器,一個繫結時間監聽,一個關閉事件監聽,當事件被觸發的時候,會回撥兩個事件內部的邏輯。最後服務端正常啟動,會被註冊到註冊中心中,以便於客戶端呼叫。需要注意的是,一般情況下,業務Handler最好和心跳包Handler等非業務性的Handler處理分開,避免業務高峰時期,因為心跳包等Handler的處理來耗費捉襟見肘的記憶體資源或者CPU資源等,造成伺服器效能下降。來看一下ServerHandler的具體設計:

從這裡可以看出,我們用了一個執行緒池來將業務處理進行池化,這樣做就不會受到心跳包等其他非業務處理Handler的影響,最大限度的保證系統的穩定性。

更多關於同步非同步,阻塞非阻塞的設計,請參見Doug Lea:Scalable IO in Java

客戶端設計

再來說說客戶端,是指消費服務的一方,一般用來實現特定的消費業務。同樣的,netty這塊已經將底層封裝的很好,所以直接編寫業務即可。和編寫服務端不同的是,這裡不需要分BossGroup和WorkerGroup,因為對於客戶端來說,只需要連線服務端,然後傳送資料並監聽即可,不存在影響效能的問題。具體的寫法看看吧:

由於程式碼中,我也做了諸多的註釋,所以這裡不再一一解釋。需要注意的是,和編寫服務端類似,我這裡添加了兩個監聽事件,監聽連線成功事件,監聽關閉事件,響應的業務場景如果觸發了這兩個事件,將會執行事件內部的邏輯。

這裡需要提一下訊息傳送的場景。一般說來,客戶端向服務端傳送資料,然後服務端處理功能後返回給客戶端,客戶端接收到訊息後再進行後續處理。這個流程一般有兩種實現方式,一種是同步的實現方式,另一種是非同步的實現方式,具體來呈現以下:

首先是同步實現方式,顧名思義,客戶端傳送資料給服務端,服務端在處理完畢並返回資料之前,客戶端一直處於阻塞等待狀態,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

負載均衡

前面說到了服務治理這塊,由於裡面涉及到負載均衡這塊,這裡就詳細說一下。

一般說來,有三種負載均衡模型是繞不開的,分別是一致性雜湊,此模型可以讓帶有業務標記的請求每次請求都會導向到指定的伺服器上。輪詢,此模型主要是對伺服器列表進行順序訪問。隨機,此模型主要是隨機獲取伺服器並返回。其他的模型還有很多,可以根據具體的業務進行衍生,這裡不做一一的展示。

首先來看看負載均衡基類:

然後看看三種模型的實現:

一致性雜湊實現,直接對服務端的size進行取餘操作:

輪詢實現,對訪問過的伺服器進行計數累加,然後把此計數作為下標並獲取元素返回:

隨機實現,對伺服器進行隨機選取:

你也許會問,為什麼你設計的負載均衡裡面沒有權重操作呢?其實如果願意,也是可以加上權重操作的,這樣就會衍生出來其他的負載均衡模型,比如服務訪問不同,權重-10,服務能訪問通,權重+1,這樣就可以通過權重,選取一些權重較高的伺服器優先返回,而對那些權重較低的伺服器,可以少分一些請求,讓其慢慢恢復到正常狀態之後,再多分配一些請求過來等等。

總之,你可以在此基礎上進行自己的設計,但是大體思想就是讓伺服器獲得的負載越均衡越好。

容災處理

此處整合Hystrix進行的設計,可以對請求做FailFast處理,RetryOnece處理,RetryTwice處理等, 具體細節可以翻看Hystrix設計即可。這裡就不詳解(哈哈哈,其實是因為寫著寫著,寫的懶了,這塊就不想講了,畢竟基本上都是Hystrix那套)。

反射呼叫

最後要說的部分就是反射呼叫這塊了。我們知道,當客戶端傳送待呼叫的方法傳送給服務端,服務端接收後,需要通過反射呼叫方法,然後將結果返回給客戶端。首先來看看服務端業務處理Handler:

可以看到,此業務處理handler會讀取客戶端的請求,然後分析資料包內容,最後利用反射來呼叫相應的方法獲取結果並壓入緩衝區中,之後傳送給客戶端。

再來看看handle方法是如何進行反射呼叫並得到結果的:

可以看到,很經典的反射呼叫場景,這裡就不細說了。

從這裡,我們可以看出,服務端的處理方式如上,非常的簡單。但是客戶端是怎麼傳送請求訊息給服務端,又是如何接收服務端的返回資料的呢?

從上面可以看出,我們用了javassist元件的反射(java自身的反射也是類似的使用方式)來構建完整的類物件,然後利用callback回撥來發送請求給服務端獲取資料,然後獲取服務端返回的資料,最後將返回的資料拆解後,返回給客戶端。如果用java自帶的反射來實現,編碼也是差不多的:

這裡需要注意的是,此處用了動態反射的功能來實現,效能並不是特別好,如果能用上位元組碼技術,效能會再提升一個臺階。具體的位元組碼實現方式,可以參見我後續的文章。

5. 跑起來吧!!

好了,我們終於把一切都準備好了,那麼就讓我們執行起來吧。

在服務端,首先可以看到如下的註冊中心上線日誌:

然後可以看到客戶端登入日誌:

在客戶端,我們可以看到如下的日誌:

可以看到,客戶端連線上來後,先發送鑑權請求,鑑權成功後,將會發送服務呼叫請求,呼叫完畢後,得到返回結果,整個過程耗費18ms,最後客戶端退出。

當我們在客戶端呼叫的時候,加上Thread.Sleep來觸發心跳探活,可以看到如下的檢測結果:

可以看到,每隔5秒鐘,我們都能收到客戶端的心跳,然後我們模擬網路差,客戶端掉線,看看服務端如何檢測:

可以看到客戶端被踢掉了,此時我們再去看看客戶端日誌,可以看出來,客戶端確實被服務端踢掉線了:

最後,東西做完後,補一個benchmark吧,由於我的機器效能比較差,而且測試是開始idea測試的,所以效能並不見得很好:

然後來看看benchmark結果吧:

效能並不是特別好,關鍵有以下幾個地方是耗時大戶:編解碼,反射,同步等待服務端返回

編解碼這個只能找效能比較好的元件來解決

反射可以通過位元組碼來實現,效能會再提升一個檔次,但是難度也會提升不少。

同步等待服務返回,可以通過完全非同步化實現來解決,那麼剛剛展示的

呼叫方式,會被改變成:

雖然這樣速度會快很多,但是使用者能否接受這種呼叫方式,則是另一個頭疼的問題。效能和易用,本身就具有相悖性,所以只能在進退之間做平衡了。

寫到這裡,整體介紹差不多了,但是還有很多東西沒有接入,譬如說kafka,mq,redis等。如果能把這些東西接入,則會讓其整體顯得更加豐滿,同時功能也更豐富,應用場景也會更廣闊一些。

6.總結

寫到這裡,利用netty打造分散式服務框架的要點就基本上完結了。通篇看來,知識點很多,但是都是我們耳熟能詳的東西,能把它們串在一起,組成一個可以用的框架,則需要一定的思考。

文中所有內容基本上為原創,如需轉載,請標明 轉載自部落格園程式詩人 字樣,算是對本家付出的辛苦的一點尊重吧。