天天看點

如何設計一個 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

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

遠端調用的優勢有:

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

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

遠端調用的缺點:

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

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

是以,遠端調用最适合的場景是:業務需求多變,網絡環境多變。

由于遠端調用的使用接口是“函數”,是以要如何建構這個“函數”,就産生了三個方面需要決策的問題:

所謂遠端,就是指網絡上另外一個位置,那麼網絡位址就是必須要輸入的部分。在TCP/IP網絡下,IP位址和端口号代表了運作中程式的一個入口。是以指定IP位址和端口是發起遠端調用所必需的。

然而,一個程式可能會運作很多個功能,可以接收多個不同含義的遠端調用。這樣如何去讓使用者指定這些不同含義的遠端調用入口,就成為了另外一個問題。當然最簡單的是每個端口一種調用,但是一個IP最多支援65535個端口,而且别的網絡功能也可能需要端口,是以這種方案可能會不夠用,同時一個數字代表一個功能也不太好了解,必須要查表才能明白。

是以我們必須想别的方法。在面向對象的思想下,有些方案提出了:以不同的對象來歸納不同的功能組合,先指定對象,再指定方法。這個想法非常符合程式員的了解方式,EJB就是這種方案的。一旦你确定了用對象這種模型來定義遠端調用的位址,那麼你就需要有一種指定遠端對象的方法,為了指定對象,你必須要能把對象的一些資訊,從被調用方(伺服器端)傳輸給調用方(用戶端)。

最簡單的方案就是用戶端輸入一串字元串作為對象的“名字”,發給伺服器端,查找注冊了這個“名字”的對象,如果找到了,伺服器端就會用某種技術“傳輸”這個對象給用戶端,然後用戶端就可以調用他的方法了。當然這種傳輸不可能是把整個伺服器上的對象資料拷貝給用戶端,而是用一些符号或者标志的方法,來代表這個伺服器上的對象,然後發給用戶端。

如果你不是使用面向對象的模型,那麼遠端的一個函數,也是必須要定位和傳輸的,因為你調用的函數必須先能找到,然後成為用戶端側的一個接口,才能調用。針對“遠端對象”(這裡說的對象包括面向對象的對象或者僅僅是 函數)如何表達才能在網絡上定位;以及定位成功之後以什麼形式供用戶端調用,都是“遠端調用”設計方案中第一個重要的問題。

遠端調用由于受到網絡通信的限制,是以往往不能完全的支援程式設計語言的所有特性。比如C語言函數中的指針類型參數,就無法通過網絡傳遞出去。是以遠端調用的函數定義,能用語言中的什麼特性,不能用什麼特性,是需要在設計方案是規定下來的。

這種規定如果太嚴格,會影響使用者的易用性;如果太寬泛,則可能導緻遠端調用的性能低下。如何去設計一種方式,把程式設計語言中的函數,描述成一個遠端調用的函數,也是需要考慮的問題。很多方案采用了配置檔案這種通用的方式,而另外一些方案可以直接在源代碼中裡面加特殊的注釋。

一般來說,編譯型語言如C/C++隻能采用源代碼根據配置檔案生成的方案,虛拟機型語言如C#/JAVA可以采用反射機制結合配置檔案(設定是在源代碼中用特殊注釋來代替配置檔案)的方案,如果是腳本語言就更簡單,有時候連配置檔案都不需要,因為腳本自己就可以充當。總之遠端調用的接口要滿足怎樣的限制,也是一個需要仔細考慮的問題。

遠端調用最重要的實作細節,就是關于網絡通信。用何種通信方式來承載遠端調用的問題,細化下來就是兩個子問題:用什麼樣的服務程式提供網絡功能?用什麼樣的通信協定?

遠端調用系統可以自己直接對TCP/IP程式設計來實作通信,也可以委托一些其他軟體,比如Web伺服器、消息隊列伺服器等等……也可以使用不同的網絡通信架構,如Netty/Mina這些開源架構。通信協定則一般有兩層:一個是傳輸協定,比如TCP/UDP或者高層一點的HTTP,或者自己定義的傳輸協定;另外一個是編碼協定,就是如何把一個程式設計語言中的對象,序列化和反序列化成為二進制位元組流的方案,流行的方案有JSON、Google Protocol Buffer等等,很多開發語言也有自己的序列化方案,如JAVA/C#都自帶。以上這些技術細節,應該選擇使用哪些,直接關系到遠端調用系統的性能和環境相容性。

如何設計一個 RPC 系統

以上三個問題,就是遠端調用系統必須考慮的核心選型。根據每個方案所面對的限制不同,他們都會在這三個問題上做出取舍,進而适應其限制。但是現在并不存在一個“萬能”或者“通用”的方案,其原因就是:在如此複雜的一個系統中,如果要照顧的特性越多,需要付出的成本(易用性代價、性能開銷)也會越多。

下面,我們可以研究下業界現存的各種遠端調用方案,看他們是如何在這三個方面做平衡和選擇的。

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模型幾乎是所有遠端調用方案的标配。

如何設計一個 RPC 系統

JAVA RMI是JAVA虛拟機自帶的一個遠端調用方案。它也是可以使用URL來定位遠端對象,使用JAVA自帶的序列化編碼協定傳遞參數值。在接口描述上,由于這是一個僅限于JAVA環境下的方案,是以直接用JAVA語言的Interface類型作為定義語言。使用者通過實作這個接口類型來提供遠端服務,同時JAVA會根據這個接口檔案自動生成用戶端的調用代碼供調用者使用。他的底層通信實作,還是用TCP協定實作的。在這裡,Interface檔案就是JAVA語言的IDL,同時也是skeleton模闆,供開發者來填寫遠端服務内容。而stub代碼則由于JAVA的反射功能,由虛拟機直接包辦了。

這個方案由于JAVA虛拟機的支援,使用起來非常簡單,完全按照标志的JAVA程式設計方法就可以輕松解決問題,但是這也僅僅能在JAVA環境下運作,限制了其适用的範圍。魚與熊掌不可兼得,易用性和适用性往往是互相沖突的。這和CORBA/Thrift追求最大範圍的适用性有很大的差别,也導緻了兩者在易用性上的不同。

Windows中對RPC支援是比較早和比較完善的。首先它通過GUID來查詢對象,然後使用C語言類型作為參數值的傳遞。由于Windows的API主要是C語言的,是以對于RPC功能來說,還是要用一種IDL來描述接口,最後生成.h和.c檔案來生産RPC的stub和skeleton代碼。而通信機制,由于是作業系統自帶的,是以使用核心LPC機制承載,這一點還是對使用者來說比較友善的。但是也限制了隻能用于Windows程式之間做調用。

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

如何設計一個 RPC 系統

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

遠端對象定位:使用URL;或者使用名字服務來查找

遠端調用參數傳遞:使用C的基本類型定義;或者使用某種預訂的序列化(反序列化)方案

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

通信承載:有使用特定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等各種協定中選擇。