本文已同步至我的公眾號 Code4j,歡迎各位看官老爺來玩。
1. 什么是遠程過程調用
在講述 Dubbo 的服務調用過程之前,讓我們先來了解一下什么是遠程過程調用。
遠程過程調用即 Remote Producedure Call
,簡單來說就是跨進程調用,通過網絡傳輸,使得 A 機器上的應用可以像調用本地的服務一樣去調用 B 機器上的服務。
舉個最簡單的栗子,假設現在有一個電商系統,其中有着用戶服務,優惠券服務,訂單服務等服務模塊,這些不同的服務並不是運行在同一個 JVM 中,而是分開運行在不同的 JVM 中。因此,當訂單服務想要調用優惠券服務時,就不能像以前的單體應用一樣,直接向對應服務發起本地調用,只能夠通過網絡來發起調用。
那么,一個最簡單的遠程過程調用是怎么樣的呢?來看下面這張圖。
也就是說,一次最簡單的 RPC 調用,無非就是調用方通過網絡,將調用的參數傳送到服務方,服務方收到調用請求后,根據參數完成本地調用,並且將結果通過網絡傳送回調用方。
在這個過程中,像參數的封裝,網絡傳輸等細節會由 RPC 框架來完成,把上面的圖片完善一下,一個完整的 RPC 調用的流程是這樣的:
- 客戶端(Client)以本地調用的方式調用遠程服務。
- 客戶端代理對象(Client Stub)將本次請求的相關信息(要調用的類名、方法名、方法參數等)封裝成
Request
,並且對其序列化,為網絡通信做准備。 - 客戶端代理對象(Client Stub)找到服務端(Server)的地址,通過網絡(Socket 通信)將
Request
發送到服務端。 - 服務端代理對象(Server Stub)接收到客戶端(Client)的請求后,將二進制數據反序列化為
Request
。 - 服務端代理對象(Server Stub)根據調用信息向本地的方法發起調用。
- 服務端代理對象(Server Stub)將調用后的結果封裝到
Response
中,並且對其序列化,通過網絡發送給客戶端。 - 客戶端代理對象(Client Stub)收到響應后,將其反序列化為
Response
,遠程調用結束。
2. Dubbo 的遠程調用過程
本節內容基於 Dubbo 2.6.x 版本,並且使用官網提供的 Demo 對同步調用進行分析。
在上一節內容中,我們已經對服務調用的過程有了一定的了解。實際上,Dubbo 在實現遠程調用的時候,核心流程和上面的圖片是完全一樣的,只不過 Dubbo 在此基礎上增加了一些額外的流程,例如集群容錯、負載均衡、過濾器鏈等。
本篇文章只分析核心的調用流程,其它的額外流程可以自行了解。
在講解 Dubbo 的調用過程之前,我們先來了解一下 Dubbo 的一些概念。
-
Invoker
:在 Dubbo 中作為實體域,也就是代表了要操作的對象模型,這有點像 Spring 中的 Bean,所有的操作都是圍繞着這個實體域來進行。- 代表了一個可執行體,可以向它發起
invoke
調用。它有可能是一個本地實現,也有可能是一個遠程實現,也有可能是一個集群實現。
- 代表了一個可執行體,可以向它發起
-
Invocation
:在 Dubbo 中作為會話域,表示每次操作的瞬時狀態,操作前創建,操作后銷毀。- 其實就是調用信息,存放了調用的類名、方法名、參數等信息。
-
Protocol
:在 Dubbo 作為服務域,負責實體域和會話域的生命周期管理。- 可以理解為 Spring 中的 BeanFactory,是產品的入口。
2.1 遠程調用的開端 —— 動態代理
在了解以上基本概念后,我們開始來跟蹤 Dubbo 的遠程調用流程。在 RPC 框架中,想要實現遠程調用,代理對象是不可或缺的,因為它可以幫我們屏蔽很多底層細節,使得我們對遠程調用無感知。
如果用過 JDK 的動態代理或者是 CGLIB 的動態代理,那么應該都知道每個代理對象都會有對應的一個處理器,用於處理動態代理時的增強,例如 JDK 使用的 InvacationHandler
或者 CGLIB 的 MethodInterceptor
。在 Dubbo 中,默認是使用 javasisst 來實現動態代理的,它與 JDK 動態一樣使用 InvocationHandler
來進行代理增強。
下面分別是使用 javasisst 和使用 JDK 動態代理時對代理類進行反編譯后的結果。
從上面可以看出,InvacationHandler
要做的事無非就是根據本次調用的方法名和方法參數,將其封裝成調用信息 Invacation
,然后將其傳遞給持有的 Invoker
對象。從這里開始,才算是真正進入到了 Dubbo 的核心模型中。
2.2 客戶端的調用鏈路
在了解客戶端的調用鏈路之前,我們需要先看一下 Dubbo 的整體設計,下圖是來自於 Dubbo 官網的一張框架設計圖,很好地展示了整個框架的結構。
為了容易理解,我把上圖中的 Proxy
代理層、Cluster
集群層以及 Protocol
協議層進行了一個抽象。
如下圖所示, Dubbo 的 Proxy
代理層先與下層的 Cluster
集群層進行交互。Cluster
這一層的作用就是將多個 Invoker
偽裝成一個 ClusterInvoker
后暴露給上層使用,由該 ClusterInvoker
來負責容錯的相關邏輯,例如快速失敗,失敗重試等等。對於上層的 Proxy
來說,這一層的容錯邏輯是透明的。
因此,當 Proxy
層的 InvocationHandler
將調用請求委托給持有的 Invoker
時,其實就是向下傳遞給對應的 ClusterInvoker
,並且經過獲取可用 Invoker
,根據路由規則過濾 Invoker
,以及負載均衡選中要調用的 Invoker
等一系列操作后,就會得到一個具體協議的 Invoker
。
這個具體的 Invoker
可能是一個遠程實現,例如默認的 Dubbo 協議對應的 DubboInvoker
,也有可能是一個本地實現,例如 Injvm 協議對應的 InjvmInvoker
等。
關於集群相關的
Invoker
,如果有興趣的話可以看一下用於服務降級的MockClusterInvoker
,集群策略抽象父類AbstractClusterInvoker
以及默認的也是最常用的失敗重試集群策略FailoverClusterInvoker
,實際上默認情況下的集群調用鏈路就是逐個經過這三個類的。順帶提一句,在獲取到具體的協議
Invoker
之前會經過一個過濾器鏈,對於每一個過濾器對於本次請求都會做一些處理,比如用於統計的MonitorFilter
,用於處理當前上下文信息的ConsumerContextFilter
等等。過濾器這一部分給用戶提供了很大的擴展空間,有興趣的話可以自行了解。
拿到具體的 Invoker
之后,此時所處的位置為上圖中的 Protocol
層,這時候就可以通過下層的網絡層來完成遠程過程調用了,先來看一下 DubboInvoker
的源碼。
可以看到,Dubbo 對於調用方式做了一些區分,分別為同步調用,異步調用以及單次調用。
首先有一點要明確的是,同步調用也好,異步調用也好,這都是站在用戶的角度來看的,但是在網絡這一層面的話,所有的交互都是異步的,網絡框架只負責將數據發送出去,或者將收到的數據向上傳遞,網絡框架並不知道本次發送出去的二進制數據和收到的二進制的數據是否是一一對應的。
因此,當用戶選擇同步調用的時候,為了將底層的異步通信轉化為同步操作,這里 Dubbo 需要調用某個阻塞操作,使用戶線程阻塞在這里,直到本次調用的結果返回。
2.3 遠程調用的基石 —— 網絡層
在上一小節的 DubboInvoker
當中,我們可以看到遠程調用的請求是通過一個 ExchangeClient
的類發送出去的,這個 ExchangeClient
類處於 Dubbo 框架的遠程通信模塊中的 Exchange
信息交換層。
從前面出現過的架構圖中可以看到,遠程通信模塊共分為三層,從上到下分別是 Exchange
信息交換層,Transport
網絡傳輸層以及 Serialize
序列化層,每一層都有其特定的作用。
從最底層的 Serialize
層說起,這一層的作用就是負責序列化/反序列化,它對多種序列化方式進行了抽象,如 JDK 序列化,Hessian 序列化,JSON 序列化等。
往上則是 Transport
層,這一層負責的單向的消息傳輸,強調的是一種 Message
的語義,不體現交互的概念。同時這一層也對各種 NIO 框架進行了抽象,例如 Netty,Mina 等等。
再往上就是 Exhange
層,和 Transport
層不同,這一層負責的是請求/響應的交互,強調的一種 Request
和 Reponse
的語義,也正是由於請求響應的存在,才會有 Client
和 Server
的區分。
了解完遠程通信模塊的分層結構后,我們再來看一下該模塊中的核心概念。
Dubbo 在這個模塊中抽取出了一個端點 Endpoint
的概念,通過一個 IP 和 一個 Port,就可以唯一確定一個端點。在這兩個端點之間,我們可以建立 TCP 連接,而這個連接被 Dubbo 抽象成了通道 Channel
,通道處理器 ChannelHandler
則負責對通道進行處理,例如處理通道的連接建立事件、連接斷開事件,處理讀取到的數據、發送的數據以及捕獲到的異常等。
同時,為了在語義上對端點進行區分,Dubbo 將發起請求的端點抽象為客戶端 Client
,而發送響應的端點則抽象成服務端 Server
。由於不同的 NIO 框架對外接口和使用方式不一樣,所以為了避免上層接口直接依賴具體的 NIO 庫,Dubbo 在 Client
和 Server
之上又抽象出了一個 Transporter
接口,該接口用於獲取 Client
和 Server
,后續如果需要更換使用的 NIO 庫,那么只需要替換相關實現類即可。
Dubbo 將負責數據編解碼功能的處理器抽象成了
Codec
接口,有興趣的話可以自行了解。
Endpoint
主要的作用就是發送數據,因此 Dubbo 為其定義了 send()
方法;同時,讓 Channel
繼承 Endpoint
,使其在發送數據的基礎上擁有添加 K/V
屬性的功能。
對於客戶端來說,一個 Cleint
只會關聯着一個 Channel
,因此直接繼承 Channel
使其也具備發送數據的功能即可,而 Server
可以接受多個 Cleint
建立的 Channel
連接,所以 Dubbo 沒有讓其繼承 Channel
,而是選擇讓其直接繼承 Endpoint
,並且提供了 getChannels()
方法用於獲取關聯的連接。
為了體現了請求/響應的交互模式,在 Channel
、Server
以及 Client
的基礎上進一步抽象出 ExchangeChannel
、ExchangeServer
以及 ExchangeClient
接口,並為 ExchangeChannel
接口添加 request()
方法,具體類圖如下。
了解完網絡層的相關概念后,讓我們看回 DubboInvoker
,當同步調用時,DubboInvoker
會通過持有的 ExchangeClient
來發起請求。實際上,這個調用最后會被 HeaderExchangeChannel
類所接收,這是一個實現了 ExchangeChannel
的類,因此也具備請求的功能。
可以看到,其實 request()
方法只不過是將數據封裝成 Request
對象,構造一個請求的語義,最終還是通過 send()
方法將數據單向發送出去。下面是一張關於客戶端發送請求的調用鏈路圖。
這里值得注意的是 DefaultFuture
對象的創建。DefaultFuture
類是 Dubbo 參照 Java 中的 Future
類所設計的,這意味着它可以用於異步操作。每個 Request
對象都有一個 ID,當創建 DefaultFuture
時,會將請求 ID 和創建的 DefaultFutrue
映射給保存起來,同時設置超時時間。
保存映射的目的是因為在異步情況下,請求和響應並不是一一對應的。為了使得后面接收到的響應可以正確被處理,Dubbo 會在響應中帶上對應的請求 ID,當接收到響應后,根據其中的請求 ID 就可以找到對應的 DefaultFuture
,並將響應結果設置到 DefaultFuture
,使得阻塞在 get()
操作的用戶線程可以及時返回。
整個過程可以抽象為下面的時序圖。
當 ExchangeChannel
調用 send()
后,數據就會通過底層的 NIO 框架發送出去,不過在將數據通過網絡傳輸之前,還有最后一步需要做的,那就是序列化和編碼。
注意,在調用 send() 方法之前,所有的邏輯都是用戶線程在處理的,而編碼工作則是由 Netty 的 I/O 線程處理,有興趣的話可以了解一下 Netty 的線程模型。
2.4 協議和編碼
上文提到過很多次協議(Protocol)和編碼,那么到底什么是協議,什么又是編碼呢?
其實,通俗一點講,協議就是一套約定好的通信規則。打個比方,張三和李四要進行交流,那么他們之間在交流之前就需要先約定好如何交流,比如雙方約定,當聽到“Hello World”的時候,就代表對方要開始講話了。此時,張三和李四之間的這種約定就是他們的通信協議。
而對於編碼的話,其實就是根據約定好的協議,將數據組裝成協議規定的格式。當張三想和李四說“早上好”的時候,那么張三只需要在“早上好”之前加上約定好的“Hello World”,也就是最終的消息為“Hello World 早上好”。李四一聽到“Hello World”,就知道隨后的內容是張三想說的,通過這種形式,張三和李四之間就可以完成正常的交流了。
具體到實際的 RPC 通信中,所謂的 Dubbo 協議,RMI 協議,HTTP 協議等等,它們只不過是對應的通信規則不一樣,但最終的作用都是一樣的,就是提供給組裝通信數據的一套規則,僅此而已。
這里借用一張官網的圖,展示了默認的 Dubbo 協議數據包格式。
Dubbo 數據包分為消息頭和消息體。消息頭為定長格式,共 16 字節,用於存儲一些元信息,例如消息的起始標識 Magic Number
,數據包的類型,使用的序列化方式 ID,消息體長度等。消息體則為變長格式,具體長度存儲在消息頭中,這部分是用於存儲了具體的調用信息或調用結果,也就是 Invocation
序列化后的字節序列或遠程調用返回的對象的字節序列,消息體這部分的數據是由序列化/反序列化來處理的。
之前提到過,Dubbo 將用於編解碼數據的通道處理器抽象為了 Codec
接口,所以在消息發送出去之前,Dubbo 會調用該接口的 encode()
方法進行編碼。其中,對於消息體,也就是本次調用的調用信息 Invacation
,會通過 Serialization
接口來進行序列化。
Dubbo 在啟動客戶端和服務端的時候,會通過適配器模式,將
Codec
相關的編解碼器與 Netty 進行適配,將其添加到 Netty 的 pipeline 中,參見NettyCodecAdapter
、NettyClient
和NettyServer
。
下面是相關的編碼邏輯,對照上圖食用更佳。
編碼完成之后,數據就會被 NIO 框架所發出,通過網絡到達服務端。
2.5 服務端的調用鏈路
當服務端接收到數據的時候,因為接收到的都是字節序列,所以第一步應該是對其解碼,這一步最終會交給 Codec
接口的 decode
方法處理。
解碼的時候會先解析得到消息頭,然后再根據消息頭中的元信息,例如消息頭長度,消息類型,將消息體反序列化為 DecodeableRpcInvocation
對象(也就是調用信息)。
此時的線程為 Netty 的 I/O 線程,不一定會在當前線程解碼,所以有可能會得到部分解碼的 Request 對象,具體分析見下文。
值得注意的是,在 2.6.x 版本中,默認情況下對於請求的解碼會在 I/O 線程中執行,而 2.7.x 之后的版本則是交給業務線程執行。
這里的 I/O 線程指的是底層通信框架中接收請求的線程(其實就是 Netty 中的 Worker 線程),業務線程則是 Dubbo 內部用於處理請求/響應的線程池中的線程。如果某個事件可能比較耗時,不能在 I/O 線程上執行,那么就需要通過線程派發器將線程派發到線程池中去執行。
再次借用官網的一張圖,當服務端接收到請求時,會根據不同的線程派發策略,將請求派發到線程池中執行。線程派發器 Dispatcher
本身並不具備線程派發的能力,它只是用於創建具有線程派發能力的 ChannelHandler
。
Dubbo 擁有 5 種線程派發策略,默認使用的策略為 all
,具體策略差別見下表。
策略 | 用途 |
---|---|
all | 所有消息都派發到線程池,包括請求,響應,連接事件,斷開事件等 |
direct | 所有消息都不派發到線程池,全部在 IO 線程上直接執行 |
message | 只有請求和響應消息派發到線程池,其它消息均在 IO 線程上執行 |
execution | 只有請求消息派發到線程池,不含響應。其它消息均在 IO 線程上執行 |
Connection | 在 IO 線程上,將連接斷開事件放入隊列,有序逐個執行,其它消息派發到線程池 |
經過 DubboCodec
解碼器處理過的數據會被 Netty 傳遞給下一個入站處理器,最終根據配置的線程派發策略來到對應的 ChannelHandler
,例如默認的 AllChannelHandler
。
可以看到,對於每種事件,AllChannelHandler
只是創建了一個 ChannelEventRunnable
對象並提交到業務線程池中去執行,這個 Runnable
對象其實只是一個中轉站,它是為了避免在 I/O 線程中執行具體的操作,最終真正的操作它會委托給持有的 ChannelHandler
去處理。
服務端對請求進行派發的過程如下圖所示。
上面說過,解碼操作也有可能在業務線程中執行,因為 ChannelEventRunnable
中直接持有的 ChannelHandler
就是一個用於解碼的 DecodeHandler
。
如果需要解碼,那么這個通道處理器會調用在 I/O 線程中創建的 DecodeableRpcInvocation
對象的 decode
方法,從字節序列中反序列化得到本次調用的類名,方法名,參數信息等。
解碼完成后,DecodeHandler
會將完全解碼的 Request
對象繼續傳遞到下一個通道處理器即 HeaderExchangeHandler
。
到這里其實已經可以體會到 Dubbo 抽取出 ChannelHandler
的好處了,可以避免和特定 NIO 庫耦合,同時使用裝飾者模式一層層地處理請求,最終對 NIO 庫只暴露出一個特定的 Handler,更加靈活。
這里附上一張服務端 ChannelHandler
的結構圖。
HeaderExchangeHandler
會根據本次請求的類型決定如何處理。如果是單向調用,那么只需向后調用即可,不需要返回響應。如果是雙向調用,那么就需要在得到具體的調用結果后,封裝成 Response
對象,並通過持有的 Channel
對象將本次調用的響應發送回客戶端。
HeaderExchangeHandler
將調用委托給持有的 ExchangeHandler
處理器,這個處理器是和服務暴露時使用的協議有關的,一般來說都是某個協議的內部類。
由於默認情況下都是使用的 Dubbo 協議,所以接下來對 Dubbo 協議中的處理器進行分析。
Dubbo 協議內部的 ExchangeHandler
會從已經暴露的服務列表中找到本次調用的 Invoker
,並且向其發起本地調用。不過要注意的是,這里的 Invoker
是一個動態生成的代理對象,類型為 AbstractProxyInvoker
,它持有了處理業務的真實對象。
當發起 invoke
調用時,它會通過持有的真實對象完成調用,並將其封裝到 RpcResult
對象中並且返回給下層。
關於
RpcResult
有興趣的話可以了解一下 2.7.x 異步化改造后的變化。簡單來說就是RpcResult
被AppResonse
所替代,用來保存調用結果或調用異常,同時引入了一個新的中間狀態類AsyncRpcResult
用於代表未完成的 RPC 調用。
這個代理對象是在服務端進行服務暴露的時候生成的,javassist 會動態生成一個 Wrapper
類,並且創建一個匿名內部對象,將調用操作委托給 Wrapper
。
下面是反編譯得到的 Wrapper
類,可以看到具體的處理邏輯和客戶端的 InvocationHandler
類似,都是根據本次調用的方法名來向真實對象發起調用。
至此,服務端已完成了調用過程。下層 ChannelHandler
收到調用結果后,就會通過 Channel
將響應發送回客戶端,期間又會經過編碼序列化等操作,由於和請求的編碼序列化過程類似,這里不再贅述,感興趣的話可以自行查看 ExchangeCodec#encodeResponse()
以及 DubboCodec#encodeResponseData()
。
這里再附上一張服務端處理請求的時序圖。
2.6 客戶端處理響應
當客戶端收到調用的響應后,毫無疑問依舊需要對收到的字節序列進行解碼及反序列化,這里和服務端解碼請求的過程是類似的,查看 ExchangeCodec#decode()
以及 DubboCodec#decodeBody()
自行了解,也可參考上面的服務端解碼請求的時序圖,這里只附上一張客戶端處理已(部分)解碼的響應的時序圖。
這里主要講的是客戶端對解碼后的 Reponse
對象的處理邏輯。客戶端的 ChannelHandler
結構和上面的服務端 ChnnelHandler
結構圖沒有太大區別,經過解碼后的響應最終也會傳遞到 HeaderExchangeHandler
處理器中進行處理。
在客戶端發起請求時我們提到過,每個構造的請求都有一個 ID 標識,當對應的響應返回時,就會把這個 ID 帶上。當接收到響應時, Dubbo 會從請求的 Future 映射集合中,根據返回的請求 ID,找到對應的 DefaultFuture
,並將結果設置到 DefaultFuture
中,同時喚醒阻塞的用戶線程,這樣就完成了 Dubbo 的業務線程到用戶線程的轉換。
有興趣的話可以再了解一下 DefauFuture 的超時處理 以及 Dubbo 2.7 異步化改造后的線程模型變化。
最后附上一張來源官網的圖。
至此,一個完整的 RPC 調用就結束了。
由於本人水平有限,可能部分細節並沒有講清楚 ,如果有疑問的話歡迎大家指出,一起交流學習。
3. 參考鏈接
-
《深入理解 Apache Dubbo 與實戰》