如何手擼一個較為完整的RPC框架?
來源:juejin.cn/post/6992867064952127524
緣起
最近在公司分享了手擼RPC,因此做一個總結(jié)。
概念篇
RPC 是什么?
RPC 稱遠程過程調(diào)用(Remote Procedure Call),用于解決分布式系統(tǒng)中服務之間的調(diào)用問題。通俗地講,就是開發(fā)者能夠像調(diào)用本地方法一樣調(diào)用遠程的服務。所以,RPC的作用主要體現(xiàn)在這兩個方面:
-
屏蔽遠程調(diào)用跟本地調(diào)用的區(qū)別,讓我們感覺就是調(diào)用項目內(nèi)的方法; -
隱藏底層網(wǎng)絡通信的復雜性,讓我們更專注于業(yè)務邏輯。
RPC 框架基本架構
下面我們通過一幅圖來說說 RPC 框架的基本架構
RPC 框架包含三個最重要的組件,分別是客戶端、服務端和注冊中心。在一次 RPC 調(diào)用流程中,這三個組件是這樣交互的:
-
服務端在啟動后,會將它提供的服務列表發(fā)布到注冊中心,客戶端向注冊中心訂閱服務地址; -
客戶端會通過本地代理模塊 Proxy 調(diào)用服務端,Proxy 模塊收到負責將方法、參數(shù)等數(shù)據(jù)轉(zhuǎn)化成網(wǎng)絡字節(jié)流; -
客戶端從服務列表中選取其中一個的服務地址,并將數(shù)據(jù)通過網(wǎng)絡發(fā)送給服務端; -
服務端接收到數(shù)據(jù)后進行解碼,得到請求信息; -
服務端根據(jù)解碼后的請求信息調(diào)用對應的服務,然后將調(diào)用結(jié)果返回給客戶端。
RPC 框架通信流程以及涉及到的角色
從上面這張圖中,可以看見 RPC 框架一般有這些組件:服務治理(注冊發(fā)現(xiàn))、負載均衡、容錯、序列化/反序列化、編解碼、網(wǎng)絡傳輸、線程池、動態(tài)代理等角色,當然有的RPC框架還會有連接池、日志、安全等角色。
具體調(diào)用過程
-
服務消費方(client)以本地調(diào)用方式調(diào)用服務 -
client stub接收到調(diào)用后負責將方法、參數(shù)等封裝成能夠進行網(wǎng)絡傳輸?shù)南Ⅲw -
client stub將消息進行編碼并發(fā)送到服務端 -
server stub收到消息后進行解碼 -
server stub根據(jù)解碼結(jié)果調(diào)用本地的服務 -
本地服務執(zhí)行并將結(jié)果返回給 server stub -
server stub將返回導入結(jié)果進行編碼并發(fā)送至消費方 -
client stub接收到消息并進行解碼 -
服務消費方(client)得到結(jié)果
RPC 消息協(xié)議
RPC調(diào)用過程中需要將參數(shù)編組為消息進行發(fā)送,接收方需要解組消息為參數(shù),過程處理結(jié)果同樣需要經(jīng)編組、解組。消息由哪些部分構成及消息的表示形式就構成了消息協(xié)議。
RPC調(diào)用過程中采用的消息協(xié)議稱為RPC消息協(xié)議。
實戰(zhàn)篇
從上面的概念我們知道一個RPC框架大概有哪些部分組成,所以在設計一個RPC框架也需要從這些組成部分考慮。從RPC的定義中可以知道,RPC框架需要屏蔽底層細節(jié),讓用戶感覺調(diào)用遠程服務像調(diào)用本地方法一樣簡單,所以需要考慮這些問題:
-
用戶使用我們的RPC框架時如何盡量少的配置 -
如何將服務注冊到ZK(這里注冊中心選擇ZK)上并且讓用戶無感知 -
如何調(diào)用透明(盡量用戶無感知)的調(diào)用服務提供者 -
啟用多個服務提供者如何做到動態(tài)負載均衡 -
框架如何做到能讓用戶自定義擴展組件(比如擴展自定義負載均衡策略) -
如何定義消息協(xié)議,以及編解碼 -
...等等
上面這些問題在設計這個RPC框架中都會給予解決。
技術選型
-
注冊中心 目前成熟的注冊中心有Zookeeper,Nacos,Consul,Eureka,這里使用ZK作為注冊中心,沒有提供切換以及用戶自定義注冊中心的功能。 -
IO通信框架 本實現(xiàn)采用 Netty 作為底層通信框架,因為Netty 是一個高性能事件驅(qū)動型的非阻塞的IO(NIO)框架,沒有提供別的實現(xiàn),也不支持用戶自定義通信框架 -
消息協(xié)議 本實現(xiàn)使用自定義消息協(xié)議,后面會具體說明
項目總體結(jié)構
從這個結(jié)構中可以知道,以rpc命名開頭的是rpc框架的模塊,也是本項目RPC框架的內(nèi)容,而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。
-
消費服務原理
要讓客戶端無感知的調(diào)用服務提供者,就需要使用動態(tài)代理,如上面所示, HelloWordService 沒有實現(xiàn)類,需要給它賦值代理類,在代理類中發(fā)起請求調(diào)用。
基于spring boot自動裝配,服務消費者啟動,bean 后置處理器 RpcClientProcessor 開始工作,它主要是遍歷所有的bean,判斷每個bean中的屬性是否有被 @RpcAutowired 注解修飾,有的話把該屬性動態(tài)賦值代理類,這個再調(diào)用時會調(diào)用代理類的 invoke 方法。
代理類 invoke 方法通過服務發(fā)現(xiàn)獲取服務端元數(shù)據(jù),封裝請求,通過netty發(fā)起調(diào)用。
注冊中心
本項目注冊中心使用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é)議可能需要對結(jié)構或字段進行改動,不同版本的協(xié)議對應的解析方法也是不同的。 -
序列化算法:序列化算法字段表示數(shù)據(jù)發(fā)送方應該采用何種方法將請求的對象轉(zhuǎn)化為二進制,以及如何再將二進制轉(zhuǎn)化為對象,如 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ù)內(nèi)容:請求體內(nèi)容
編解碼
編解碼實現(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ù)包界限,也就是說消息無邊界。客戶端向服務端發(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)這樣:
-
服務端恰巧讀到了兩個完整的數(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 個編碼字符之外的字符作為特定分隔符
-
消息長度 + 消息內(nèi)容
消息長度 + 消息內(nèi)容是項目開發(fā)中最常用的一種協(xié)議,接收方根據(jù)消息長度來讀取消息內(nèi)容。
本項目就是利用 “消息長度 + 消息內(nèi)容” 方式解決TCP粘包、拆包問題的。所以在解碼時要判斷數(shù)據(jù)是否夠長度讀取,沒有不夠說明數(shù)據(jù)沒有準備好,繼續(xù)讀取數(shù)據(jù)并解碼,這里這種方式可以獲取一個個完整的數(shù)據(jù)包。
序列化和反序列化
序列化和反序列化在 rpc-core 模塊 com.rrtv.rpc.core.serialization 包下,提供了 HessianSerialization 和 JsonSerialization 序列化。
默認使用 HessianSerialization 序列化。用戶不可以自定義。
序列化性能:
-
空間上
-
時間上
網(wǎng)絡傳輸,使用netty
netty 代碼固定的,值得注意的是 handler 的順序不能弄錯,以服務端為例,編碼是出站操作(可以放在入站后面),解碼和收到響應都是入站操作,解碼要在前面。
客戶端 RPC 調(diào)用方式
成熟的 RPC 框架一般會提供四種調(diào)用方式,分別為同步 Sync、異步 Future、回調(diào) Callback和單向 Oneway。
-
Sync 同步調(diào)用
客戶端線程發(fā)起 RPC 調(diào)用后,當前線程會一直阻塞,直至服務端返回結(jié)果或者處理超時異常。
-
Future 異步調(diào)用
客戶端發(fā)起調(diào)用后不會再阻塞等待,而是拿到 RPC 框架返回的 Future 對象,調(diào)用結(jié)果會被服務端緩存,客戶端自行決定后續(xù)何時獲取返回結(jié)果。當客戶端主動獲取結(jié)果時,該過程是阻塞等待的
-
Callback 回調(diào)調(diào)用
客戶端發(fā)起調(diào)用時,將 Callback 對象傳遞給 RPC 框架,無須同步等待返回結(jié)果,直接返回。當獲取到服務端響應結(jié)果或者超時異常后,再執(zhí)行用戶注冊的 Callback 回調(diào)
-
Oneway 單向調(diào)用
客戶端發(fā)起請求之后直接返回,忽略返回結(jié)果
這里使用的是第一種:客戶端同步調(diào)用,其他的沒有實現(xiàn)。邏輯在 RpcFuture 中,使用 CountDownLatch 實現(xiàn)阻塞等待(超時等待)
整體架構和流程
流程分為三塊:服務提供者啟動流程、服務消費者啟動、調(diào)用過程
服務提供者啟動
-
服務提供者 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)賦值為代理對象
調(diào)用過程
-
服務消費者 發(fā)起請求 http://localhost:9090/hello/world?name=hello -
服務消費者 調(diào)用 helloWordService.sayHello()方法,會被代理到執(zhí)行ClientStubInvocationHandler.invoke()方法 -
服務消費者 通過ZK服務發(fā)現(xiàn)獲取服務元數(shù)據(jù),找不到報錯404 -
服務消費者 自定義協(xié)議,封裝請求頭和請求體 -
服務消費者 通過自定義編碼器 RpcEncoder 將消息編碼 -
服務消費者 通過 服務發(fā)現(xiàn)獲取到服務提供者的ip和端口, 通過Netty網(wǎng)絡傳輸層發(fā)起調(diào)用 -
服務消費者 通過 RpcFuture 進入返回結(jié)果(超時)等待 -
服務提供者 收到消費者請求 -
服務提供者 將消息通過自定義解碼器 RpcDecoder 解碼 -
服務提供者 解碼之后的數(shù)據(jù)發(fā)送到 RpcRequestHandler中進行處理,通過反射調(diào)用執(zhí)行服務端本地方法并獲取結(jié)果 -
服務提供者 將執(zhí)行的結(jié)果通過 編碼器 RpcEncoder 將消息編碼。(由于請求和響應的協(xié)議是一樣,所以編碼器和解碼器可以用一套) -
服務消費者 將消息通過自定義解碼器 RpcDecoder 解碼 -
服務消費者 通過 RpcResponseHandler將消息寫入 請求和響應 池中,并設置 RpcFuture 的響應結(jié)果 -
服務消費者 獲取到結(jié)果
以上流程具體可以結(jié)合代碼分析,代碼后面會給出
環(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 調(diào)用成功
項目代碼地址
https://gitee.com/listen_w/rpc.git
-End-
最近有一些小伙伴,讓我?guī)兔φ乙恍?nbsp;面試題 資料,于是我翻遍了收藏的 5T 資料后,匯總整理出來,可以說是程序員面試必備!所有資料都整理到網(wǎng)盤了,歡迎下載!

面試題】即可獲取
