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