如何手擼一個較為完整的RPC框架?
概念篇
RPC 是什么?
RPC 稱遠程過程調用(Remote Procedure Call),用于解決分布式系統(tǒng)中服務之間的調用問題。通俗地講,就是開發(fā)者能夠像調用本地方法一樣調用遠程的服務。所以,RPC的作用主要體現(xiàn)在這兩個方面:
- 屏蔽遠程調用跟本地調用的區(qū)別,讓我們感覺就是調用項目內的方法;
- 隱藏底層網(wǎng)絡通信的復雜性,讓我們更專注于業(yè)務邏輯。
RPC 框架基本架構
下面我們通過一幅圖來說說 RPC 框架的基本架構
RPC 框架包含三個最重要的組件,分別是客戶端、服務端和注冊中心。在一次 RPC 調用流程中,這三個組件是這樣交互的:
- 服務端在啟動后,會將它提供的服務列表發(fā)布到注冊中心,客戶端向注冊中心訂閱服務地址;
- 客戶端會通過本地代理模塊 Proxy 調用服務端,Proxy 模塊收到負責將方法、參數(shù)等數(shù)據(jù)轉化成網(wǎng)絡字節(jié)流;
- 客戶端從服務列表中選取其中一個的服務地址,并將數(shù)據(jù)通過網(wǎng)絡發(fā)送給服務端;
- 服務端接收到數(shù)據(jù)后進行解碼,得到請求信息;
- 服務端根據(jù)解碼后的請求信息調用對應的服務,然后將調用結果返回給客戶端。
RPC 框架通信流程以及涉及到的角色
從上面這張圖中,可以看見 RPC 框架一般有這些組件:服務治理(注冊發(fā)現(xiàn))、負載均衡、容錯、序列化/反序列化、編解碼、網(wǎng)絡傳輸、線程池、動態(tài)代理等角色,當然有的RPC框架還會有連接池、日志、安全等角色。
具體調用過程
- 服務消費方(client)以本地調用方式調用服務
- client stub 接收到調用后負責將方法、參數(shù)等封裝成能夠進行網(wǎng)絡傳輸?shù)南Ⅲw
- client stub 將消息進行編碼并發(fā)送到服務端
- server stub 收到消息后進行解碼
- server stub 根據(jù)解碼結果調用本地的服務
- 本地服務執(zhí)行并將結果返回給 server stub
- server stub 將返回導入結果進行編碼并發(fā)送至消費方
- client stub 接收到消息并進行解碼
- 服務消費方(client)得到結果
RPC 消息協(xié)議
RPC調用過程中需要將參數(shù)編組為消息進行發(fā)送,接收方需要解組消息為參數(shù),過程處理結果同樣需要經(jīng)編組、解組。消息由哪些部分構成及消息的表示形式就構成了消息協(xié)議。
RPC調用過程中采用的消息協(xié)議稱為RPC消息協(xié)議。
實戰(zhàn)篇
從上面的概念我們知道一個RPC框架大概有哪些部分組成,所以在設計一個RPC框架也需要從這些組成部分考慮。從RPC的定義中可以知道,RPC框架需要屏蔽底層細節(jié),讓用戶感覺調用遠程服務像調用本地方法一樣簡單,所以需要考慮這些問題:
- 用戶使用我們的RPC框架時如何盡量少的配置
- 如何將服務注冊到ZK(這里注冊中心選擇ZK)上并且讓用戶無感知
- 如何調用透明(盡量用戶無感知)的調用服務提供者
- 啟用多個服務提供者如何做到動態(tài)負載均衡
- 框架如何做到能讓用戶自定義擴展組件(比如擴展自定義負載均衡策略)
- 如何定義消息協(xié)議,以及編解碼
- ...等等
上面這些問題在設計這個RPC框架中都會給予解決。
技術選型
- 注冊中心 目前成熟的注冊中心有Zookeeper,Nacos,Consul,Eureka,這里使用ZK作為注冊中心,沒有提供切換以及用戶自定義注冊中心的功能。
- IO通信框架 本實現(xiàn)采用 Netty 作為底層通信框架,因為Netty 是一個高性能事件驅動型的非阻塞的IO(NIO)框架,沒有提供別的實現(xiàn),也不支持用戶自定義通信框架
- 消息協(xié)議 本實現(xiàn)使用自定義消息協(xié)議,后面會具體說明
項目總體結構
從這個結構中可以知道,以rpc命名開頭的是rpc框架的模塊,也是本項目RPC框架的內容,而consumer是服務消費者,provider是服務提供者,provider-api是暴露的服務API。
整體依賴情況
項目實現(xiàn)介紹
要做到用戶使用我們的RPC框架時盡量少的配置,所以把rpc框架設計成一個starter,用戶只要依賴這個starter,基本那就可以了。
為什么要設計成兩個 starter (client-starter/server-starter) ?
這個是為了更好的體現(xiàn)出客戶端和服務端的概念,消費者依賴客戶端,服務提供者依賴服務端,還有就是最小化依賴。
為什么要設計成 starter ?
基于spring boot自動裝配機制,會加載starter中的 spring.factories 文件,在文件中配置以下代碼,這里我們starter的配置類就生效了,在配置類里面配置一些需要的bean。
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.rrtv.rpc.client.config.RpcClientAutoConfiguration
發(fā)布服務和消費服務
- 對于發(fā)布服務
服務提供者需要在暴露的服務上增加注解 @RpcService,這個自定義注解是基于 @service 的,是一個復合注解,具備@service注解的功能,在@RpcService注解中指明服務接口和服務版本,發(fā)布服務到ZK上,會根據(jù)這個兩個元數(shù)據(jù)注冊
- 發(fā)布服務原理:
服務提供者啟動之后,根據(jù)spring boot自動裝配機制,server-starter的配置類就生效了,在一個 bean 的后置處理器(RpcServerProvider)中獲取被注解 @RpcService 修飾的bean,將注解的元數(shù)據(jù)注冊到ZK上。
- 對于消費服務
消費服務需要使用自定義的 @RpcAutowired 注解標識,是一個復合注解,基于 @Autowired。
- 消費服務原理
要讓客戶端無感知的調用服務提供者,就需要使用動態(tài)代理,如上面所示, HelloWordService 沒有實現(xiàn)類,需要給它賦值代理類,在代理類中發(fā)起請求調用。
基于spring boot自動裝配,服務消費者啟動,bean 后置處理器 RpcClientProcessor 開始工作,它主要是遍歷所有的bean,判斷每個bean中的屬性是否有被 @RpcAutowired 注解修飾,有的話把該屬性動態(tài)賦值代理類,這個再調用時會調用代理類的 invoke 方法。
代理類 invoke 方法通過服務發(fā)現(xiàn)獲取服務端元數(shù)據(jù),封裝請求,通過netty發(fā)起調用。
注冊中心
本項目注冊中心使用ZK,由于注冊中心被服務消費者和服務提供者都使用。所以把ZK放在rpc-core模塊。
rpc-core 這個模塊如上圖所示,核心功能都在這個模塊。服務注冊在 register 包下。
服務注冊接口,具體實現(xiàn)使用ZK實現(xiàn)。
負載均衡策略
負載均衡定義在rpc-core中,目前支持輪詢(FullRoundBalance)和隨機(RandomBalance),默認使用隨機策略。由rpc-client-spring-boot-starter指定。
通過ZK服務發(fā)現(xiàn)時會找到多個實例,然后通過負載均衡策略獲取其中一個實例
可以在消費者中配置 rpc.client.balance=fullRoundBalance 替換,也可以自定義負載均衡策略,通過實現(xiàn)接口 LoadBalance,并將創(chuàng)建的類加入IOC容器即可。由于我們配置 @ConditionalOnMissingBean,所以會優(yōu)先加載用戶自定義的 bean。
自定義消息協(xié)議、編解碼
所謂協(xié)議,就是通信雙方事先商量好規(guī)則,服務端知道發(fā)送過來的數(shù)據(jù)將如何解析。
- 自定義消息協(xié)議
- 魔數(shù):魔數(shù)是通信雙方協(xié)商的一個暗號,通常采用固定的幾個字節(jié)表示。魔數(shù)的作用是防止任何人隨便向服務器的端口上發(fā)送數(shù)據(jù)。例如 java Class 文件開頭就存儲了魔數(shù) 0xCAFEBABE,在加載 Class 文件時首先會驗證魔數(shù)的正確性
- 協(xié)議版本號:隨著業(yè)務需求的變化,協(xié)議可能需要對結構或字段進行改動,不同版本的協(xié)議對應的解析方法也是不同的。
- 序列化算法:序列化算法字段表示數(shù)據(jù)發(fā)送方應該采用何種方法將請求的對象轉化為二進制,以及如何再將二進制轉化為對象,如 JSON、Hessian、Java 自帶序列化等。
- 報文類型:在不同的業(yè)務場景中,報文可能存在不同的類型。RPC 框架中有請求、響應、心跳等類型的報文。
- 狀態(tài):狀態(tài)字段用于標識請求是否正常(SUCCESS、FAIL)。
- 消息ID:請求唯一ID,通過這個請求ID將響應關聯(lián)起來,也可以通過請求ID做鏈路追蹤。
- 數(shù)據(jù)長度:標明數(shù)據(jù)的長度,用于判斷是否是一個完整的數(shù)據(jù)包
- 數(shù)據(jù)內容:請求體內容
編解碼
編解碼實現(xiàn)在 rpc-core 模塊,在包 com.rrtv.rpc.core.codec下。
自定義編碼器通過繼承 netty 的 MessageToByteEncoder<MessageProtocol<T>>類實現(xiàn)消息編碼。
自定義解碼器通過繼承 netty 的 ByteToMessageDecoder類實現(xiàn)消息解碼。
解碼時需要注意TCP粘包、拆包問題
什么是TCP粘包、拆包
TCP 傳輸協(xié)議是面向流的,沒有數(shù)據(jù)包界限,也就是說消息無邊界??蛻舳讼蚍斩税l(fā)送數(shù)據(jù)時,可能將一個完整的報文拆分成多個小報文進行發(fā)送,也可能將多個報文合并成一個大的報文進行發(fā)送。因此就有了拆包和粘包。
在網(wǎng)絡通信的過程中,每次可以發(fā)送的數(shù)據(jù)包大小是受多種因素限制的,如 MTU 傳輸單元大小、滑動窗口等。
所以如果一次傳輸?shù)木W(wǎng)絡包數(shù)據(jù)大小超過傳輸單元大小,那么我們的數(shù)據(jù)可能會拆分為多個數(shù)據(jù)包發(fā)送出去。如果每次請求的網(wǎng)絡包數(shù)據(jù)都很小,比如一共請求了 10000 次,TCP 并不會分別發(fā)送 10000 次。TCP采用的 Nagle(批量發(fā)送,主要用于解決頻繁發(fā)送小數(shù)據(jù)包而帶來的網(wǎng)絡擁塞問題) 算法對此作出了優(yōu)化。
所以,網(wǎng)絡傳輸會出現(xiàn)這樣:
tcp_package.png
- 服務端恰巧讀到了兩個完整的數(shù)據(jù)包 A 和 B,沒有出現(xiàn)拆包/粘包問題;
- 服務端接收到 A 和 B 粘在一起的數(shù)據(jù)包,服務端需要解析出 A 和 B;
- 服務端收到完整的 A 和 B 的一部分數(shù)據(jù)包 B-1,服務端需要解析出完整的 A,并等待讀取完整的 B 數(shù)據(jù)包;
- 服務端接收到 A 的一部分數(shù)據(jù)包 A-1,此時需要等待接收到完整的 A 數(shù)據(jù)包;
- 數(shù)據(jù)包 A 較大,服務端需要多次才可以接收完數(shù)據(jù)包 A。
如何解決TCP粘包、拆包問題
解決問題的根本手段:找出消息的邊界:
- 消息長度固定
每個數(shù)據(jù)報文都需要一個固定的長度。當接收方累計讀取到固定長度的報文后,就認為已經(jīng)獲得一個完整的消息。當發(fā)送方的數(shù)據(jù)小于固定長度時,則需要空位補齊。
消息定長法使用非常簡單,但是缺點也非常明顯,無法很好設定固定長度的值,如果長度太大會造成字節(jié)浪費,長度太小又會影響消息傳輸,所以在一般情況下消息定長法不會被采用。
- 特定分隔符
在每次發(fā)送報文的尾部加上特定分隔符,接收方就可以根據(jù)特殊分隔符進行消息拆分。分隔符的選擇一定要避免和消息體中字符相同,以免沖突。否則可能出現(xiàn)錯誤的消息拆分。比較推薦的做法是將消息進行編碼,例如 base64 編碼,然后可以選擇 64 個編碼字符之外的字符作為特定分隔符
- 消息長度 + 消息內容
消息長度 + 消息內容是項目開發(fā)中最常用的一種協(xié)議,接收方根據(jù)消息長度來讀取消息內容。
本項目就是利用 “消息長度 + 消息內容” 方式解決TCP粘包、拆包問題的。所以在解碼時要判斷數(shù)據(jù)是否夠長度讀取,沒有不夠說明數(shù)據(jù)沒有準備好,繼續(xù)讀取數(shù)據(jù)并解碼,這里這種方式可以獲取一個個完整的數(shù)據(jù)包。
序列化和反序列化
序列化和反序列化在 rpc-core 模塊 com.rrtv.rpc.core.serialization 包下,提供了 HessianSerialization 和 JsonSerialization 序列化。
默認使用 HessianSerialization 序列化。用戶不可以自定義。
序列化性能:
- 空間上
serialization_space.png
- 時間上
serialization_time.png
網(wǎng)絡傳輸,使用netty
netty 代碼固定的,值得注意的是 handler 的順序不能弄錯,以服務端為例,編碼是出站操作(可以放在入站后面),解碼和收到響應都是入站操作,解碼要在前面。
image.png
客戶端 RPC 調用方式
成熟的 RPC 框架一般會提供四種調用方式,分別為同步 Sync、異步 Future、回調 Callback和單向 Oneway。
- Sync 同步調用
客戶端線程發(fā)起 RPC 調用后,當前線程會一直阻塞,直至服務端返回結果或者處理超時異常。
sync.png
- Future 異步調用
客戶端發(fā)起調用后不會再阻塞等待,而是拿到 RPC 框架返回的 Future 對象,調用結果會被服務端緩存,客戶端自行決定后續(xù)何時獲取返回結果。當客戶端主動獲取結果時,該過程是阻塞等待的
future.png
- Callback 回調調用
客戶端發(fā)起調用時,將 Callback 對象傳遞給 RPC 框架,無須同步等待返回結果,直接返回。當獲取到服務端響應結果或者超時異常后,再執(zhí)行用戶注冊的 Callback 回調
callback.png
- Oneway 單向調用
客戶端發(fā)起請求之后直接返回,忽略返回結果
oneway.png
這里使用的是第一種:客戶端同步調用,其他的沒有實現(xiàn)。邏輯在 RpcFuture 中,使用 CountDownLatch 實現(xiàn)阻塞等待(超時等待)
整體架構和流程
流程分為三塊:服務提供者啟動流程、服務消費者啟動、調用過程
服務提供者啟動
- 服務提供者 provider 會依賴 rpc-server-spring-boot-starter
- ProviderApplication 啟動,根據(jù)springboot 自動裝配機制,RpcServerAutoConfiguration 自動配置生效
- RpcServerProvider 是一個bean后置處理器,會發(fā)布服務,將服務元數(shù)據(jù)注冊到ZK上
- RpcServerProvider.run 方法會開啟一個 netty 服務
服務消費者啟動
- 服務消費者 consumer 會依賴 rpc-client-spring-boot-starter
- ConsumerApplication 啟動,根據(jù)springboot 自動裝配機制,RpcClientAutoConfiguration 自動配置生效
- 將服務發(fā)現(xiàn)、負載均衡、代理等bean加入IOC容器
- 后置處理器 RpcClientProcessor 會掃描 bean ,將被 @RpcAutowired 修飾的屬性動態(tài)賦值為代理對象
調用過程
- 服務消費者 發(fā)起請求http://localhost:9090/hello/world?name=hello
- 服務消費者 調用 helloWordService.sayHello() 方法,會被代理到執(zhí)行 ClientStubInvocationHandler.invoke() 方法
- 服務消費者 通過ZK服務發(fā)現(xiàn)獲取服務元數(shù)據(jù),找不到報錯404
- 服務消費者 自定義協(xié)議,封裝請求頭和請求體
- 服務消費者 通過自定義編碼器 RpcEncoder 將消息編碼
- 服務消費者 通過 服務發(fā)現(xiàn)獲取到服務提供者的ip和端口, 通過Netty網(wǎng)絡傳輸層發(fā)起調用
- 服務消費者 通過 RpcFuture 進入返回結果(超時)等待
- 服務提供者 收到消費者請求
- 服務提供者 將消息通過自定義解碼器 RpcDecoder 解碼
- 服務提供者 解碼之后的數(shù)據(jù)發(fā)送到 RpcRequestHandler 中進行處理,通過反射調用執(zhí)行服務端本地方法并獲取結果
- 服務提供者 將執(zhí)行的結果通過 編碼器 RpcEncoder 將消息編碼。(由于請求和響應的協(xié)議是一樣,所以編碼器和解碼器可以用一套)
- 服務消費者 將消息通過自定義解碼器 RpcDecoder 解碼
- 服務消費者 通過RpcResponseHandler將消息寫入 請求和響應 池中,并設置 RpcFuture 的響應結果
- 服務消費者 獲取到結果
以上流程具體可以結合代碼分析,代碼后面會給出
環(huán)境搭建
- 操作系統(tǒng):Windows
- 集成開發(fā)工具:IntelliJ IDEA
- 項目技術棧:SpringBoot 2.5.2 + JDK 1.8 + Netty 4.1.42.Final
- 項目依賴管理工具:Maven 4.0.0
- 注冊中心:Zookeeeper 3.7.0
項目測試
- 啟動 Zookeeper 服務器:bin/zkServer.cmd
- 啟動 provider 模塊 ProviderApplication
- 啟動 consumer 模塊 ConsumerApplication
- 測試:瀏覽器輸入 http://localhost:9090/hello/world?name=hello,成功返回 您好:hello, rpc 調用成功