RPC 的主要功能目標是讓構建分布式計算(應用)更容易,在提供強大的遠程調用能力時不損失本地調用的語義簡潔性。 為實現該目標,RPC 框架需提供一種透明調用機制讓使用者不必顯式的區分本地調用和遠程調用。 下面我們將具體細化 stub 結構的實現。
RPC 調用分以下兩種:
異步和同步的區分在於是否等待服務端執行完成並返回結果。
如下圖所示。
RPC 服務方通過 RpcServer
去導出(export)遠程接口方法,而客戶方通過 RpcClient
去引入(import)遠程接口方法。 客戶方像調用本地方法一樣去調用遠程接口方法,RPC 框架提供接口的代理實現,實際的調用將委托給代理 RpcProxy
。 代理封裝調用信息並將調用轉交給 RpcInvoker
去實際執行。 在客戶端的 RpcInvoker
通過連接器 RpcConnector
去維持與服務端的通道 RpcChannel
, 並使用 RpcProtocol
執行協議編碼(encode)並將編碼後的請求消息通過通道發送給服務方。
RPC 服務端接收器 RpcAcceptor
接收客戶端的調用請求,同樣使用 RpcProtocol
執行協議解碼(decode)。 解碼後的調用信息傳遞給 RpcProcessor
去控制處理調用過程,最後再委托調用給 RpcInvoker
去實際執行並返回調用結果。
上面我們進一步拆解了 RPC 實現結構的各個組件組成部分,下面我們詳細說明下每個組件的職責劃分。
RpcServer
RpcClient
RpcProxy
RpcInvoker
RpcProtocol
RpcConnector
RpcAcceptor
RpcProcessor
RpcChannel
在進一步拆解了組件並劃分了職責之後,這裡以在 java 平台實現該 RPC 框架概念模型為例,詳細分析下實現中需要考慮的因素。
導出遠程接口的意思是指只有導出的接口可以供遠程調用,而未導出的接口則不能。 在 java 中導出接口的代碼片段可能如下:
DemoService demo =
new
...;
RpcServer server =
new
...;
server.export(DemoService.
class
, demo, options);
我們可以導出整個接口,也可以更細粒度一點只導出接口中的某些方法,如:
// 只導出 DemoService 中簽名為 hi(String s) 的方法
server.export(DemoService.
class
, demo,
"hi"
,
new
Class<?>[] { String.
class
}, options);
java 中還有一種比較特殊的調用就是多態,也就是一個接口可能有多個實現,那麼遠程調用時到底調用哪個? 這個本地調用的語義是通過 jvm 提供的引用多態性隱式實現的,那麼對於 RPC 來說跨進程的調用就沒法隱式實現了。 如果前面 DemoService 接口有 2 個實現,那麼在導出接口時就需要特殊標記不同的實現,如:
1 2 3 4 5DemoService demo =
new
...;
DemoService demo2 =
new
...;
RpcServer server =
new
...;
server.export(DemoService.
class
, demo, options);
server.export(
"demo2"
, DemoService.
class
, demo2, options);
上面 demo2 是另一個實現,我們標記為 demo2 來導出, 那麼遠程調用時也需要傳遞該標記才能調用到正確的實現類,這樣就解決了多態調用的語義。
導入相對於導出遠程接口,客戶端代碼為了能夠發起調用必須要獲得遠程接口的方法或過程定義。 目前,大部分跨語言平台 RPC 框架采用根據 IDL 定義通過 code generator 去生成 stub 代碼, 這種方式下實際導入的過程就是通過代碼生成器在編譯期完成的。 我所使用過的一些跨語言平台 RPC 框架如 CORBAR、WebService、ICE、Thrift 均是此類方式。
代碼生成的方式對跨語言平台 RPC 框架而言是必然的選擇,而對於同一語言平台的 RPC 則可以通過共享接口定義來實現。 在 java 中導入接口的代碼片段可能如下:
RpcClient client =
new
...;
DemoService demo = client.refer(DemoService.
class
);
demo.hi(
"how are you?"
);
在 java 中 import
是關鍵字,所以代碼片段中我們用 refer 來表達導入接口的意思。 這裡的導入方式本質也是一種代碼生成技術,只不過是在運行時生成,比靜態編譯期的代碼生成看起來更簡潔些。 java 裡至少提供了兩種技術來提供動態代碼生成,一種是 jdk 動態代理,另外一種是字節碼生成。 動態代理相比字節碼生成使用起來更方便,但動態代理方式在性能上是要遜色於直接的字節碼生成的,而字節碼生成在代碼可讀性上要差很多。 兩者權衡起來,個人認為犧牲一些性能來獲得代碼可讀性和可維護性顯得更重要。
客戶端代理在發起調用前需要對調用信息進行編碼,這就要考慮需要編碼些什麼信息並以什麼格式傳輸到服務端才能讓服務端完成調用。 出於效率考慮,編碼的信息越少越好(傳輸數據少),編碼的規則越簡單越好(執行效率高)。 我們先看下需要編碼些什麼信息:
除了以上這些必須的調用信息,我們可能還需要一些元信息以方便程序編解碼以及未來可能的擴展。 這樣我們的編碼消息裡面就分成了兩部分,一部分是元信息、另一部分是調用的必要信息。 如果設計一種 RPC 協議消息的話,元信息我們把它放在協議消息頭中,而必要信息放在協議消息體中。 下面給出一種概念上的 RPC 協議消息設計格式:
magic
: 協議魔數,為解碼設計header size
: 協議頭長度��為擴展設計version
: 協議版本,為兼容設計st
: 消息體序列化類型hb
: 心跳消息標記,為長連接傳輸層心跳設計ow
: 單向消息標記,rp
: 響應消息標記,不置位默認是請求消息status code
: 響應消息狀態碼reserved
: 為字節對齊保留message id
: 消息 idbody size
: 消息體長度采用序列化編碼,常見有以下格式
xml
: 如 webservie SOAPjson
: 如 JSON-RPCbinary
: 如 thrift; hession; kryo 等格式確定後編解碼就簡單了,由於頭長度一定所以我們比較關心的就是消息體的序列化方式。 序列化我們關心三個方面:
上面這三點有時是魚與熊掌不可兼得,這裡面涉及到具體的序列化庫實現細節,就不在本文進一步展開分析了。
協議編碼之後,自然就是需要將編碼後的 RPC 請求消息傳輸到服務方,服務方執行後返回結果消息或確認消息給客戶方。 RPC 的應用場景實質是一種可靠的請求應答消息流,和 HTTP 類似。 因此選擇長連接方式的 TCP 協議會更高效,與 HTTP 不同的是在協議層面我們定義了每個消息的唯一 id,因此可以更容易的復用連接。
既然使用長連接,那麼第一個問題是到底 client 和 server 之間需要多少根連接? 實際上單連接和多連接在使用上沒有區別,對於數據傳輸量較小的應用類型,單連接基本足夠。 單連接和多連接最大的區別在於,每根連接都有自己私有的發送和接收緩沖區, 因此大數據量傳輸時分散在不同的連接緩沖區會得到更好的吞吐效率。 所以,如果你的數據傳輸量不足以讓單連接的緩沖區一直處於飽和狀態的話,那麼使用多連接並不會產生任何明顯的提升, 反而會增加連接管理的開銷。
連接是由 client 端發起建立並維持。 如果 client 和 server 之間是直連的,那麼連接一般不會中斷(當然物理鏈路故障除外)。 如果 client 和 server 連接經過一些負載中轉設備,有可能連接一段時間不活躍時會被這些中間設備中斷。 為了保持連接有必要定時為每個連接發送心跳數據以維持連接不中斷。 心跳消息是 RPC 框架庫使用的內部消息,在前文協議頭結構中也有一個專門的心跳位, 就是用來標記心跳消息的,它對業務應用透明。
client stub 所做的事情僅僅是編碼消息並傳輸給服務方,而真正調用過程發生在服務方。 server stub 從前文的結構拆解中我們細分了 RpcProcessor
和 RpcInvoker
兩個組件, 一個負責控制調用過程,一個負責真正調用。 這裡我們還是以 java 中實現這兩個組件為例來分析下它們到底需要做什麼?
java 中實現代碼的動態接口調用目前一般通過反射調用。 除了原生的 jdk 自帶的反射,一些第三方庫也提供了性能更優的反射調用, 因此 RpcInvoker 就是封裝了反射調用的實現細節。
調用過程的控制需要考慮哪些因素,RpcProcessor 需要提供什麼樣地調用控制服務呢? 下面提出幾點以啟發思考:
無論 RPC 怎樣努力把遠程調用偽裝的像本地調用,但它們依然有很大的不同點,而且有一些異常情況是在本地調用時絕對不會碰到的。 在說異常處理之前,我們先比較下本地調用和 RPC 調用的一些差異:
正是這些區別決定了使用 RPC 時需要更多考量。 當調用遠程接口拋出異常時,異常可能是一個業務異常, 也可能是 RPC 框架拋出的運行時異常(如:網絡中斷等)。 業務異常表明服務方已經執行了調用,可能因為某些原因導致未能正常執行, 而 RPC 運行時異常則有可能服務方根本沒有執行,對調用方而言的異常處理策略自然需要區分。
由於 RPC 固有的消耗相對本地調用高出幾個數量級,本地調用的固有消耗是納秒級,而 RPC 的固有消耗是在毫秒級。 那麼對於過於輕量的計算任務就並不合適導出遠程接口由獨立的進程提供服務, 只有花在計算任務上時間遠遠高於 RPC 的固有消耗才值得導出為遠程接口提供服務。
至此我們提出了一個 RPC 實現的概念框架,並詳細分析了需要考慮的一些實現細節。 無論 RPC 的概念是如何優雅,但是“草叢中依然有幾條蛇隱藏著”,只有深刻理解了 RPC 的本質,才能更好地應用。