如何設計一個RPC系統

楊粼波發表於2017-10-09

RPC是一種方便的網路通訊程式設計模型,由於和程式語言的高度結合,大大減少了處理網路資料的複雜度,讓程式碼可讀性也有可觀的提高。但是RPC本身的構成卻比較複雜,由於受到程式語言、網路模型、使用習慣的約束,有大量的妥協和取捨之處。本文就是通過分析幾種流行的RPC實現案例,提供大家在設計RPC系統時的參考。

由於RPC底層的網路開發一般和具體使用環境有關,而程式設計實現手段也非常多樣化,但不影響使用者,因此本文基本涉及如何實現一個RPC系統。

認識RPC(遠端呼叫)

我們在各種作業系統、程式語言生態圈中,多少都會接觸過“遠端呼叫”的概念。一般來說,他們指的是用簡單的一行程式碼,通過網路呼叫另外一個計算機上的某段程式。比如:

  • RMI——Remote Method Invoke:呼叫遠端的方法。“方法”一般是附屬於某個物件上的,所以通常RMI指對在遠端的計算機上的某個物件,進行其方法函式的呼叫。

  • RPC——Remote Procedure Call:遠端過程呼叫。指的是對網路上另外一個計算機上的,某段特定的函式程式碼的呼叫。

遠端呼叫本身是網路通訊的一種概念,他的特點是把網路通訊封裝成一個類似函式的呼叫。網路通訊在遠端呼叫外,一般還有其他的幾種概念:資料包處理、訊息佇列、流過濾、資源拉取等待。下面比較一下他們差異:

方案 程式設計方式 資訊封裝 傳輸模型 典型應用
遠端呼叫 呼叫函式,輸入引數,獲得返回值。 使用程式語言的變數、型別、函式 發出請求,獲得響應 Java RMI
資料包處理 呼叫Send()/Recv(),使用位元組碼資料,編解碼,處理內容 把通訊內容構造成二進位制的協議包 傳送/接收 UDP程式設計
訊息佇列 呼叫Put()/Get(),使用“包”物件,處理其包含的內容 訊息被封裝成語言可用的物件或結構 對某佇列,存入一個訊息;取出一個訊息 ActiveMQ
流過濾 讀取一個流,或寫出一個流,對流中的單元包即刻處理 單元長度很小的統一資料結構 連線;傳送/接收;處理 網路視訊
資源拉取 輸入一個資源ID,獲得資源內容 請求或響應都包含:頭部+正文 請求後等待響應 WWW

針對遠端呼叫的特點——呼叫函式。業界在各種語言下都開發過類似的方案,同時也有些方案是試圖做到跨語言的。儘管遠端呼叫在程式設計方式上,看起來似乎是最簡單易用的,但是也有明顯的缺點。所以瞭解清楚遠端呼叫的優勢和缺點,是決定是否要開發、或者使用遠端呼叫這種模型的關鍵問題。

遠端呼叫的優勢有:

  1. 遮蔽了網路層。因此在傳輸協議和編碼協議上,我們可以選擇不同的方案。比如WebService方案就是用的HTTP傳輸協議+SOAP編碼協議;而REST的方案往往使用HTTP+JSON協議。Facebook的Thrift甚至可以定製任何不同的傳輸協議和編碼協議,你可以用TCP+Google Protocol Buffer,也可以用UDP+JSON……。由於遮蔽了網路層,你可以根據實際需要來獨立的優化網路部分,而無需涉及業務邏輯的處理程式碼,這對於需要在各種網路環境下執行的程式來說,非常有價值。

  2. 函式對映協議。你可以直接用程式語言來書寫資料結構和函式定義,取代編寫大量的編碼協議格式和分包處理邏輯。對於那些業務邏輯非常複雜的系統,比如網路遊戲,可以節省大量定義訊息格式的時間。而且函式呼叫模型非常容易學習,不需要學習通訊協議和流程,讓經驗較淺的程式設計師也能很容易的開始使用網路程式設計。

遠端呼叫的缺點:

  1. 增加了效能消耗。由於把網路通訊包裝成“函式”,需要大量額外的處理。比如需要預生產程式碼,或者使用反射機制。這些都是額外消耗CPU和記憶體的操作。而且為了表達複雜的資料型別,比如變長的型別string/map/list,這些都要資料包中增加更多的描述性資訊,則會佔用更多的網路包長度。

  2. 不必要的複雜化。如果你僅僅是為了某些特定的業務需求,比如傳送一個固定的檔案,那麼你應該用HTTP/FTP協議模型。如果為了做監控或者IM軟體,用簡單的訊息編碼收發會更快速高效。如果是為了做代理伺服器,用流式的處理會很簡單。另外,如果你要做資料廣播,那麼訊息佇列會很容易做到,而遠端呼叫這幾乎無法完成。

因此,遠端呼叫最適合的場景是:業務需求多變,網路環境多變。

RPC方案的核心問題

由於遠端呼叫的使用介面是“函式”,所以要如何構建這個“函式”,就產生了三個方面需要決策的問題:

  1. 如何表示“遠端”的資訊。所謂遠端,就是指網路上另外一個位置,那麼網路地址就是必須要輸入的部分。在TCP/IP網路下,IP地址和埠號代表了執行中程式的一個入口。所以指定IP地址和埠是發起遠端呼叫所必需的。然而,一個程式可能會執行很多個功能,可以接收多個不同含義的遠端呼叫。這樣如何去讓使用者指定這些不同含義的遠端呼叫入口,就成為了另外一個問題。當然最簡單的是每個埠一種呼叫,但是一個IP最多支援65535個埠,而且別的網路功能也可能需要埠,所以這種方案可能會不夠用,同時一個數字代表一個功能也不太好理解,必須要查表才能明白。所以我們必須想別的方法。在物件導向的思想下,有些方案提出了:以不同的物件來歸納不同的功能組合,先指定物件,再指定方法。這個想法非常符合程式設計師的理解方式,EJB就是這種方案的。一旦你確定了用物件這種模型來定義遠端呼叫的地址,那麼你就需要有一種指定遠端物件的方法,為了指定物件,你必須要能把物件的一些資訊,從被呼叫方(伺服器端)傳輸給呼叫方(客戶端)。最簡單的方案就是客戶端輸入一串字串作為物件的“名字”,發給伺服器端,查詢註冊了這個“名字”的物件,如果找到了,伺服器端就會用某種技術“傳輸”這個物件給客戶端,然後客戶端就可以呼叫他的方法了。當然這種傳輸不可能是把整個伺服器上的物件資料拷貝給客戶端,而是用一些符號或者標誌的方法,來代表這個伺服器上的物件,然後發給客戶端。如果你不是使用物件導向的模型,那麼遠端的一個函式,也是必須要定位和傳輸的,因為你呼叫的函式必須先能找到,然後成為客戶端側的一個介面,才能呼叫。針對“遠端物件”(這裡說的物件包括物件導向的物件或者僅僅是 函式)如何表達才能在網路上定位;以及定位成功之後以什麼形式供客戶端呼叫,都是“遠端呼叫”設計方案中第一個重要的問題。

  2. 函式的介面形式應該如何表示。遠端呼叫由於受到網路通訊的約束,所以往往不能完全的支援程式語言的所有特性。比如C語言函式中的指標型別引數,就無法通過網路傳遞出去。因此遠端呼叫的函式定義,能用語言中的什麼特性,不能用什麼特性,是需要在設計方案是規定下來的。這種規定如果太嚴格,會影響使用者的易用性;如果太寬泛,則可能導致遠端呼叫的效能低下。如何去設計一種方式,把程式語言中的函式,描述成一個遠端呼叫的函式,也是需要考慮的問題。很多方案採用了配置檔案這種通用的方式,而另外一些方案可以直接在原始碼中裡面加特殊的註釋。一般來說,編譯型語言如C/C++只能採用原始碼根據配置檔案生成的方案,虛擬機器型語言如C#/JAVA可以採用反射機制結合配置檔案(設定是在原始碼中用特殊註釋來代替配置檔案)的方案,如果是指令碼語言就更簡單,有時候連配置檔案都不需要,因為指令碼自己就可以充當。總之遠端呼叫的介面要滿足怎樣的約束,也是一個需要仔細考慮的問題。

  3. 用什麼方法來實現網路通訊。遠端呼叫最重要的實現細節,就是關於網路通訊。用何種通訊方式來承載遠端呼叫的問題,細化下來就是兩個子問題:用什麼樣的服務程式提供網路功能?用什麼樣的通訊協議?遠端呼叫系統可以自己直接對TCP/IP程式設計來實現通訊,也可以委託一些其他軟體,比如Web伺服器、訊息佇列伺服器等等……也可以使用不同的網路通訊框架,如Netty/Mina這些開源框架。通訊協議則一般有兩層:一個是傳輸協議,比如TCP/UDP或者高層一點的HTTP,或者自己定義的傳輸協議;另外一個是編碼協議,就是如何把一個程式語言中的物件,序列化和反序列化成為二進位制位元組流的方案,流行的方案有JSON、Google Protocol Buffer等等,很多開發語言也有自己的序列化方案,如JAVA/C#都自帶。以上這些技術細節,應該選擇使用哪些,直接關係到遠端呼叫系統的效能和環境相容性。

以上三個問題,就是遠端呼叫系統必須考慮的核心選型。根據每個方案所面對的約束不同,他們都會在這三個問題上做出取捨,從而適應其約束。但是現在並不存在一個“萬能”或者“通用”的方案,其原因就是:在如此複雜的一個系統中,如果要照顧的特性越多,需要付出的成本(易用性代價、效能開銷)也會越多。下面,我們可以研究下業界現存的各種遠端呼叫方案,看他們是如何在這三個方面做平衡和選擇的。

業界方案舉例:

  1. CORBA是一個“古老”的,雄心勃勃的方案,他試圖在完成遠端呼叫的同時,還完成跨語言的通訊的任務,因此其複雜程度是最高的,但是它的設計思想,也被後來更多的其他方案所學習。在通訊物件的定位上,它使用URL來定義一個遠端物件,這是在網際網路時代非常容易接受的。其物件的內容則限定在C語言型別上,並且只能傳遞值,這也是非常容易理解的。為了能讓不同語言的程式通訊,所以就必須要在各種程式語言之外獨立設計一種僅僅用於描述遠端介面的語言,這就是所謂的IDL:Interface Description Language 介面描述語言。用這個方法,你就可以先用一種超然於所有語言之外的語言來定義介面,然後使用工具自動生成各種程式語言的程式碼。這種方案對於編譯型語言幾乎是唯一選擇。CORBA並沒有對通訊問題有任何約定,而是留給具體語言的實現者去處理,這也許是他沒有廣泛流行的原因之一。實際上CORBA有一個非常著名的繼承者,他就是Facebook公司的Thrift框架。Thrift也是使用一種IDL編譯生成多種語言的遠端呼叫方案,並且用C++/JAVA等多種語言完整的實現了通訊承載,所以在開源框架中是特別有號召力的一個。Thrfit的通訊承載還有個特點,就是能組合使用各種不同的傳輸協議和編碼協議,比如TCP/UDP/HTTP配合JSON/BIN/PB……這讓它幾乎可以選擇任何的網路環境。Thrift的模型類似下圖,這裡有的stub表示“樁程式碼”,就是客戶端直接使用的函式形式程式;skeleton表示“骨架程式碼”,是需要程式設計師編寫具體提供遠端服務功能的模板程式碼,一般對模版做填空或者繼承(擴充套件)即可。這個stub-skeleton模型幾乎是所有遠端呼叫方案的標配。
  2. JAVA RMI是JAVA虛擬機器自帶的一個遠端呼叫方案。它也是可以使用URL來定位遠端物件,使用JAVA自帶的序列化編碼協議傳遞引數值。在介面描述上,由於這是一個僅限於JAVA環境下的方案,所以直接用JAVA語言的Interface型別作為定義語言。使用者通過實現這個介面型別來提供遠端服務,同時JAVA會根據這個介面檔案自動生成客戶端的呼叫程式碼供呼叫者使用。他的底層通訊實現,還是用TCP協議實現的。在這裡,Interface檔案就是JAVA語言的IDL,同時也是skeleton模板,供開發者來填寫遠端服務內容。而stub程式碼則由於JAVA的反射功能,由虛擬機器直接包辦了。這個方案由於JAVA虛擬機器的支援,使用起來非常簡單,完全按照標誌的JAVA程式設計方法就可以輕鬆解決問題,但是這也僅僅能在JAVA環境下執行,限制了其適用的範圍。魚與熊掌不可兼得,易用性和適用性往往是互相沖突的。這和CORBA/Thrift追求最大範圍的適用性有很大的差別,也導致了兩者在易用性上的不同。

  3. Windows RPC:Windows中對RPC支援是比較早和比較完善的。首先它通過GUID來查詢物件,然後使用C語言型別作為引數值的傳遞。由於Windows的API主要是C語言的,所以對於RPC功能來說,還是要用一種IDL來描述介面,最後生成.h和.c檔案來生產RPC的stub和skeleton程式碼。而通訊機制,由於是作業系統自帶的,所以使用核心LPC機制承載,這一點還是對使用者來說比較方便的。但是也限制了只能用於Windows程式之間做呼叫。

  4. WebService & REST:在網際網路時代,程式需要通過網際網路來互相呼叫。而網際網路上最流行的協議是HTTP協議和WWW服務,因此使用HTTP協議的Web Service就順理成章的成為跨系統呼叫的最流行方案。由於可以使用大多數網際網路的基礎設施,所以Web Service的開發和實現幾乎是毫無難度的。一般來說,它都會使用URL來定位遠端物件,而引數則通過一系列預定義的型別(主要是C語言基礎型別),以及物件序列化方式來傳遞。介面生成方面,你可以自己直接對HTTP做解析,也可以使用諸如WSDL或者SOAP這樣的規範。在REST的方案中,則限定了只有PUT/GET/DELETE/POST四種操作函式,其他都是引數。

總結一下上面的這些RPC方案,我們發現,針對遠端呼叫的三個核心問題,一般業界有以下幾個選擇:

  1. 遠端物件定位:使用URL;或者使用名字服務來查詢。

  2. 遠端呼叫引數傳遞:使用C的基本型別定義;或者使用某種預訂的序列化(反序列化)方案

  3. 介面定義:使用某種特定格式的技術,直接按預先約定一種介面定義檔案;或者使用某種描述協議IDL來生成這些介面檔案。

  4. 通訊承載:有使用特定TCP/UDP之類的伺服器,也有可以讓使用者自己開發定製的通訊模型;還有使用HTTP或者訊息佇列這一類更加高階的傳輸協議。

方案選型

在我們確定了遠端呼叫系統方案几個可行選擇後,自然就要明確一下各個方案的優缺點,這樣才能選擇真正合適需求的設計:

  1. 對於遠端物件的描述:使用URL是網際網路通行的標準,比較方便使用者理解,也容易新增日後需要擴充套件到內容,因為URL本身是一個由多個部分組合的字串;而名字服務則老式一些,但是依然有他的好處,就是名字服務可以附帶負載均衡、容災擴容、自定義路由等一系列特性,對於需求複雜的定位比較容易實現。

  2. 遠端呼叫的介面描述:如果只限制於某個語言、作業系統、平臺上,直接利用“隱喻”方式的介面描述,或者以“註解”型別註釋手段來標註原始碼,實現遠端呼叫介面的定義,是最方便不過的。但是,如果需要相容編譯型語言,如C/C++,就一定要用某種IDL來生成這些編譯語言的原始碼了。

  3. 通訊承載:給使用者自己定製通訊模組,能提供最好的適用性,但是也讓使用者增加了使用的複雜程度。而HTTP/訊息佇列這種承載方式,在系統的部署、運維、程式設計上都會比較簡單,缺點就是對於效能、傳輸特性的定製空間就比較小。

分析完核心問題,我們還需要考慮一些適用性場景:

  1. 物件導向還是程式導向:如果我們只是考慮做程式導向的遠端呼叫,只需要定位到“函式”即可。而如果是物件導向的,則需要定位到“物件”。由於函式是無狀態的,所以其定位過程可以簡單到一個名字即可,而物件則需要動態的查詢到其ID或控制程式碼。

  2. 跨語言還是單一語言:單一語言的方案中,標頭檔案或介面定義完全用一種語言處理即可,如果是跨語言的,就少不免要IDL

  3. 混合式通訊承載還是使用HTTP伺服器承載:混合式承載可能可以用到TCP/UDP/共享記憶體等底層技術,可以提供最優的效能,但是使用起來必然非常麻煩。使用HTTP伺服器的話,則非常簡單,因為WWW服務的開源軟體、庫眾多,而且客戶端使用瀏覽器或者一些JS頁面即可除錯,缺點是其效能較低。

假設我們現在要為某種業務邏輯非常多變的領域,如企業業務應用領域,或遊戲伺服器端領域,去設計一個遠端呼叫系統,我們可能應該如下選擇:

  1. 使用名字服務定位遠端物件:由於企業服務是需要高可用性的,使用名字服務能在查詢名字時識別和選擇可用性服務物件。J2EE方案中的EJB(企業JavaBean)就是用名字服務的。

  2. 使用IDL來生成介面定義:由於企業服務或遊戲服務,其開發語言可能不是統一的,又或者需要高效能的程式語言如C/C++,所以只能使用IDL。

  3. 使用混合式通訊承載:雖然企業服務看起來無需在很複雜的網路下執行,但是不同的企業的網路環境又可能是千差萬別的,所以要做一個通用的系統,最好還是不怕麻煩提供混合式的通訊承載,這樣可以在TCP/UDP等各種協議中選擇。


相關文章