全圖文分析:如何利用Google的protobuf,來思考、設計、實現自己的RPC框架

sewain發表於2021-04-25

Warning: 文章有點長,我主要是想在一篇文章中把相關的重點內容都講完、講透徹,請見諒。

以後,我儘可能不寫這麼長的文章。

一、前言

在嵌入式系統中,很少需要使用到 RPC (Remote Procedure Call)遠端方法呼叫,因為在大部分情況下,實現一個產品功能的所有程式、執行緒都是執行在同一個硬體裝置中的。

但是在一些特殊的場景中,RPC 呼叫還是很有市場的,比如:

在計算密集型產品中,需要呼叫算力更強的中央伺服器提供的演算法函式;

因此,利用 RPC 來利用遠端提供的服務,相對於其他的機制來說,有更多的優勢。

這篇文章我們就來聊一聊 RPC 的相關內容,來看一下如何利用 Google 的開源序列化工具 protobuf,來實現一個我們自己的 RPC 框架

序列化[1]:將結構資料或物件轉換成能夠被儲存和傳輸(例如網路傳輸)的格式,同時應當要保證這個序列化結果在之後(可能在另一個計算環境中)能夠被重建回原來的結構資料或物件。

我會以 protobuf 中的一些關鍵 C++ 類作為突破口,來描述從客戶端發起呼叫,到服務端響應,這個完整執行序列。也就是下面這張圖:

這張圖大概畫了 2 個小時(邊看程式碼,邊畫圖),我已經盡力了,雖然看起來有點亂。

在下面的描述中,我會根據每一部分的主題,把這張圖拆成不同的模組,從空間(檔案和類的結構)和時間(函式的呼叫順序、資料流向)這兩個角度,來描述圖中的每一個元素,我相信聰明的你一定會看明白的!

希望你看了這篇文章之後,對 RPC 框架的設計過程有一個基本的認識和理解,應對面試官的時候,關於 RPC 框架設計的問題應該綽綽有餘了。

如果在專案中恰好選擇了 protobuf,那麼根據這張圖中的模組結構和函式呼叫流程分析,可以協助你更好的完成每一個模組的開發。

注意:這篇文章不會聊什麼內容:

  1. protfobuf 的原始碼實現;
  2. protfobuf 的編碼演算法;

二、RPC 基礎概念

1. RPC 是什麼?

RPC (Remote Procedure Call)從字面上理解,就是呼叫一個方法,但是這個方法不是執行在本地,而是執行在遠端的伺服器上。也就是說,客戶端應用可以像呼叫本地函式一樣,直接呼叫執行在遠端伺服器上的方法。

下面這張圖描述了 RPC 呼叫的基本流程:

假如,我們的應用程式需要呼叫一個演算法函式來獲取運動軌跡:

int getMotionPath(float *input, int intputLen, float *output, int outputLen) 

如果計算過程不復雜,可以把這個演算法函式和應用程式放在本地的同一個程式中,以原始碼或庫的方式提供計算服務,如下圖:

但是,如果這個計算過程比較複雜,需要耗費一定的資源(時間和空間),本地的 CPU 計算能力根本無法支撐,那麼就可以把這個函式放在 CPU 能力更強的伺服器上。

此時,呼叫過程如下圖這樣:

功能上來看,應用程式仍然是呼叫遠端伺服器上的一個方法,也就是虛線部分。但是由於他們執行在不同的實體裝置上,更不是在同一個程式中,因此,如果想呼叫成功就一定需要利用網路來傳輸資料

初步接觸 RPC 的朋友可能會提出:

那我可以在應用程式中把演算法需要的輸入資料打包好,通過網路傳送給演算法伺服器;伺服器計算出結果後,再打包好返回給應用程式就可以了。

這句話說的非常對,從功能上來說,這個描述過程就是 RPC 所需要做的所有事情

不過,在這個過程中,有很多問題需要我們來手動解決:

  1. 如何處理通訊問題?TCP or UDP or HTTP?或者利用其他的一些已有的網路協議?

  2. 如何把資料進行打包?服務端接收到打包的資料之後,如何還原資料?

  3. 對於特定領域的問題,可以專門寫一套實現來解決,但是對於通用的遠端呼叫,怎麼做到更靈活、更方便?

為了解決以上這幾個問題,於是 RPC 遠端呼叫框架就誕生了!

圖中的綠色背景部分,就是 RPC 框架需要做的事情。

對於應用程式來說,Client 端代理就相當於是演算法服務的“本地代理人”,至於這個代理人是怎麼來處理剛才提到的那幾個問題、然後從真正的演算法伺服器上得到結果,這就不需要應用程式來關心了。

結合文章的第一張圖中,從應用程式的角度看,它只是執行了一個函式呼叫(步驟1),然後就立刻得到了結果(步驟10),這中間的所有步驟(2-9),全部是 RPC 框架來處理,而且能夠靈活的處理各種不同的請求、響應資料。

鋪墊到這裡,我就可以更明確的再次重複一下了:這篇文章的目的,就是介紹如何利用 protobuf 來實現圖中的綠色部分的功能

最終的目的,將會輸出一個 RPC 遠端呼叫框架的庫檔案(動態庫、靜態庫)

  1. 伺服器端利用這個庫,在網路上提供函式呼叫服務;

  2. 客戶端利用這個庫,遠端呼叫位於伺服器上的函式;

2. 需要解決什麼問題?

既然我們是介紹 RPC 框架,那麼需要解決的問題就是一個典型的 RPC 框架所面對問題,如下:

  1. 解決函式呼叫時,資料結構的約定問題;
  2. 解決資料傳輸時,序列化和反序列化問題;
  3. 解決網路通訊問題;

這 3 個問題是所有的 RPC 框架都必須解決的,這是最基本的問題,其他的考量因素就是:速度更快、成本更低、使用更靈活、易擴充套件、向後相容、佔用更少的系統資源等等

另外還有一個考量因素:跨語言。比如:客戶端可以用 C 語言實現,服務端可以用 C/C++、Java或其他語言來實現,在技術選型時這也是非常重要的考慮因素。

3. 有哪些開源實現?

從上面的介紹中可以看出來,RPC 的最大優勢就是降低了客戶端的函式呼叫難度,呼叫遠端的服務就好像在呼叫本地的一個函式一樣。

因此,各種大廠都開發了自己的 RPC 框架,例如:

  1. Google 的 gRPC;
  2. Facebook 的 thrift;
  3. 騰訊的 Tars;
  4. 百度的 BRPC;

另外,還有很多小廠以及個人,也會發布一些 RPC 遠端呼叫框架(tinyRPC,forestRPC,EasyRPC等等)。每一家 RPC 的特點,感興趣的小夥伴可以自行去搜尋比對,這裡對 gRPC 多說幾句,

我們剛才主要聊了 protobuf,其實它只是解決了序列化的問題,對於一個完整的 RPC 框架,還缺少網路通訊這個步驟。

gRPC 就是利用了 protobuf,來實現了一個完整的 RPC 遠端呼叫框架,其中的通訊部分,使用的是 HTTP 協議。

三、protobuf 基本使用

1. 基本知識

Protobuf 是 Protocol Buffers 的簡稱, 它是 Google 開發的一種跨語言、跨平臺、可擴充套件的用於序列化資料協議

Protobuf 可以用於結構化資料序列化(序列化),它序列化出來的資料量少,再加上以 K-V 的方式來儲存資料,非常適用於在網路通訊中的資料載體。

只要遵守一些簡單的使用規則,可以做到非常好的相容性和擴充套件性,可用於通訊協議、資料儲存等領域的語言無關、平臺無關、可擴充套件的序列化結構資料格式。

Protobuf 中最基本的資料單元是 message ,並且在 message 中可以多層巢狀 message 或其它的基礎資料型別的成員。

Protobuf 是一種靈活,高效,自動化機制的結構資料序列化方法,可類比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更簡單,而且它支援 Java、C++、Python 等多種語言。

2. 使用步驟

Step1:建立 .proto 檔案,定義資料結構

例如,定義檔案 echo_service.proto, 其中的內容為:

message EchoRequest {
	string message = 1;
}

message EchoResponse {
	string message = 1;
}

message AddRequest {
	int32 a = 1;
	int32 b = 2;
}

message AddResponse {
	int32 result = 1;
}

service EchoService {
	rpc Echo(EchoRequest) returns(EchoResponse);
	rpc Add(AddRequest) returns(AddResponse);
}

最後的 service EchoService,是讓 protoc 生成介面類,其中包括 2 個方法 Echo 和 Add

Echo 方法:客戶端呼叫這個方法,請求的“資料結構” EchoRequest 中包含一個 string 型別,也就是一串字元;服務端返回的“資料結構” EchoResponse 中也是一個 string 字串;

Add 方法:客戶端呼叫這個方法,請求的“資料結構” AddRequest 中包含 2 個整型資料,服務端返回的“資料結構” AddResponse 中包含一個整型資料(計算結果);

Step2: 使用 protoc 工具,來編譯 .proto 檔案,生成介面(類以及相應的方法)

protoc echo_service.proto -I./ --cpp_out=./

執行以上命令,即可生成兩個檔案:echo_service.pb.h, echo_service.pb.c,在這 2 個檔案中,定義了 2 個重要的類,也就是下圖中綠色部分:

EchoService 和 EchoService_Stub 這 2 個類就是接下來要介紹的重點。我把其中比較重要的內容摘抄如下(為減少干擾,把名稱空間字元都去掉了):

class EchoService : public ::PROTOBUF_NAMESPACE_ID::Service {
    virtual void Echo(RpcController* controller, 
                    EchoRequest* request,
                    EchoResponse* response,
                    Closure* done);
                    
   virtual void Add(RpcController* controller,
                    AddRequest* request,
                    AddResponse* response,
                    Closure* done);
                    
    void CallMethod(MethodDescriptor* method,
                  RpcController* controller,
                  Message* request,
                  Message* response,
                  Closure* done);
}
class EchoService_Stub : public EchoService {
 public:
  EchoService_Stub(RpcChannel* channel);

  void Echo(RpcController* controller,
            EchoRequest* request,
            EchoResponse* response,
            Closure* done);
            
  void Add(RpcController* controller,
            AddRequest* request,
            AddResponse* response,
            Closure* done);
            
private:
    // 成員變數,比較關鍵
    RpcChannel* channel_;
};

Step3:服務端程式實現介面中定義的方法,提供服務;客戶端呼叫介面函式,呼叫遠端的服務。

請關注上圖中的綠色部分。

(1)服務端:EchoService

EchoService 類中的兩個方法 Echo 和 Add 都是虛擬函式,我們需要繼承這個類,定義一個業務層的服務類 EchoServiceImpl,然後實現這兩個方法,以此來提供遠端呼叫服務。

EchoService 類中也給出了這兩個函式的預設實現,只不過是提示錯誤資訊:

void EchoService::Echo() {
  controller->SetFailed("Method Echo() not implemented.");
  done->Run();
}
void EchoService::Add() {
  controller->SetFailed("Method Add() not implemented.");
  done->Run();
}

圖中的 EchoServiceImpl 就是我們定義的類,其中實現了 Echo 和 Add 這兩個虛擬函式:

void EchoServiceImpl::Echo(RpcController* controller,
                   EchoRequest* request,
                   EchoResponse* response,
                   Closure* done)
{
	// 獲取請求訊息,然後在末尾加上資訊:", welcome!",返回給客戶端
	response->set_message(request->message() + ", welcome!");
	done->Run();
}

void EchoServiceImpl::Add(RpcController* controller,
                   AddRequest* request,
                   AddResponse* response,
                   Closure* done)
{
	// 獲取請求資料中的 2 個整型資料
	int32_t a = request->a();
	int32_t b = request->b();

	// 計算結果,然後放入響應資料中
	response->set_result(a + b);

	done->Run();
}

(2)客戶端:EchoService_Stub

EchoService_Stub 就相當於是客戶端的代理,應用程式只要把它"當做"遠端服務的替身,直接呼叫其中的函式就可以了(圖中左側的步驟1)。

因此,EchoService_Stub 這個類中肯定要實現 Echo 和 Add 這 2 個方法,看一下 protobuf 自動生成的實現程式碼:

void EchoService_Stub::Echo(RpcController* controller,
                            EchoRequest* request,
                            EchoResponse* response,
                            Closure* done) {
  channel_->CallMethod(descriptor()->method(0),
                       controller, 
                       request, 
                       response, 
                       done);
}

void EchoService_Stub::Add(RpcController* controller,
                            AddRequest* request,
                            AddResponse* response,
                            Closure* done) {
  channel_->CallMethod(descriptor()->method(1),
                       controller, 
                       request, 
                       response, 
                       done);
}

看到沒,每一個函式都呼叫了成員變數 channel_ 的 CallMethod 方法(圖中左側的步驟2),這個成員變數的型別是 google::protobuf:RpcChannel

從字面上理解:channel 就像一個通道,是用來解決資料傳輸問題的。也就是說 channel_->CallMethod 方法會把所有的資料結構序列化之後,通過網路傳送給伺服器。

既然 RpcChannel 是用來解決網路通訊問題的,因此客戶端和服務端都需要它們來提供資料的接收和傳送。

圖中的RpcChannelClient是客戶端使用的 Channel, RpcChannelServer是服務端使用的 Channel,它倆都是繼承自 protobuf 提供的 RpcChannel

注意:這裡的 RpcChannel,只是提供了網路通訊的策略,至於通訊的機制是什麼(TCP? UDP? HTTP?),protobuf 並不關心,這需要由 RPC 框架來決定和實現。

protobuf 提供了一個基類 RpcChannel,其中定義了CallMethod方法。我們的 RPC 框架中,客戶端和服務端實現的 Channel 必須繼承 protobuf 中的 RpcChannel,然後過載 CallMethod這個方法。

CallMethod 方法的幾個引數特別重要,我們通過這些引數,來利用 protobuf 實現序列化、控制函式呼叫等操作,也就是說這些引數就是一個紐帶,把我們寫的程式碼與 protobuf 提供的功能,連線在一起。

我們這裡選了libevent這個網路庫來實現 TCP 通訊。

四、libevent

實現 RPC 框架,需要解決 2 個問題:通訊和序列化。protobuf 解決了序列化問題,那麼還需要解決通訊問題。

有下面幾種通訊方式備選:

  1. TCP 通訊;
  2. UDP 通訊;
  3. HTTP 通訊;

如何選擇,那就是見仁見智的事情了,比如 gRPC 選擇的就是 HTTP,也工作的很好,更多的實現選擇的是 TCP 通訊。

下面就是要決定:是從 socket 層次開始自己寫?還是利用已有的一些開源網路庫來實現通訊?

既然標題已經是 libevent 了,那肯定選擇的就是它!當然還有很多其他優秀的網路庫可以利用,比如:libev, libuv 等等。

1. libevent 簡介

Libevent 是一個用 C 語言編寫的、輕量級、高效能、基於事件的網路庫

主要有以下幾個亮點:

事件驅動( event-driven),高效能;
輕量級,專注於網路;原始碼相當精煉、易讀;
跨平臺,支援 Windows、 Linux、*BSD 和 Mac Os;
支援多種 I/O 多路複用技術, epoll、 poll、 dev/poll、 select 和 kqueue 等;
支援 I/O,定時器和訊號等事件;註冊事件優先順序。

從我們使用者的角度來看,libevent 庫提供了以下功能:當一個檔案描述符的特定事件(如可讀,可寫或出錯)發生了,或一個定時事件發生了, libevent 就會自動執行使用者註冊的回撥函式,來接收資料或者處理事件。

此外,libevent 還把 fd 讀寫、訊號、DNS、定時器甚至idle(空閒) 都抽象化成了event(事件)。

總之一句話:使用很方便,功能很強大!

2. 基本使用

libevent 是基於事件的回撥函式機制,因此在啟動監聽 socket 之前,只要設定好相應的回撥函式,當有事件或者網路資料到來時,libevent 就會自動呼叫回撥函式。

struct event_base  *m_evBase = event_base_new();
struct bufferevent *m_evBufferEvent =  bufferevent_socket_new(
        m_evBase, [socket Id], 
        BEV_OPT_CLOSE_ON_FREE | BEV_OPT_THREADSAFE);  
bufferevent_setcb(m_evBufferEvent, 
        [讀取資料回撥函式], 
        NULL, 
        [事件回撥函式], 
        [回撥函式傳參]);  

// 開始監聽 socket
event_base_dispatch(m_evBase);

有一個問題需要注意:protobuf 序列化之後的資料,全部是二進位制的。

libevent 只是一個網路通訊的機制,如何處理接收到的二進位制資料(粘包、分包的問題),是我們需要解決的問題。

五、實現 RPC 框架

從剛才的第三部分: 自動生成的幾個類EchoService, EchoService_Stub中,已經能夠大概看到 RPC 框架的端倪了。這裡我們再整合在一起,看一下更具體的細節部分。

1. 基本框架構思

我把圖中的干擾細節全部去掉,得到下面這張圖:

其中的綠色部分就是我們的 RPC 框架需要實現的部分,功能簡述如下:

  1. EchoService:服務端介面類,定義需要實現哪些方法;
  2. EchoService_Stub: 繼承自 EchoService,是客戶端的本地代理;
  3. RpcChannelClient: 使用者處理客戶端網路通訊,繼承自 RpcChannel;
  4. RpcChannelServer: 使用者處理服務端網路通訊,繼承自 RpcChannel;

應用程式:

  1. EchoServiceImpl:服務端應用層需要實現的類,繼承自 EchoService;
  2. ClientApp: 客戶端應用程式,呼叫 EchoService_Stub 中的方法;

2. 後設資料的設計

echo_servcie.proto 檔案中,我們按照 protobuf 的語法規則,定義了幾個 Message,可以看作是“資料結構”

Echo 方法相關的“資料結構”:EchoRequest, EchoResponse。
Add 方法相關的“資料結構”:AddRequest, AddResponse。

這幾個資料結構是直接與業務層相關的,是我們的客戶端和服務端來處理請求和響應資料的一種約定

為了實現一個基本完善的資料 RPC 框架,我們還需要其他的一些“資料結構”來完成必要的功能,例如:

  1. 呼叫 Id 管理;
  2. 錯誤處理;
  3. 同步呼叫和非同步呼叫;
  4. 超時控制;

另外,在呼叫函式時,請求和響應的“資料結構”是不同的資料型別。為了便於統一處理,我們把請求資料和響應資料都包裝在一個統一的 RPC “資料結構”中,並用一個型別欄位(type)來區分:某個 RPC 訊息是請求資料,還是響應資料。

根據以上這些想法,我們設計出下面這樣的後設資料

// 訊息型別
enum MessageType
{
	RPC_TYPE_UNKNOWN = 0;
	RPC_TYPE_REQUEST = 1;
	RPC_TYPE_RESPONSE = 2;
	RPC_TYPE_ERROR = 3;
}

// 錯誤程式碼
enum ErrorCode
{
	RPC_ERR_OK = 0;
	RPC_ERR_NO_SERVICE = 1;
	RPC_ERR_NO_METHOD = 2;
	RPC_ERR_INVALID_REQUEST = 3;
	RPC_ERR_INVALID_RESPONSE = 4
}

message RpcMessage
{
	MessageType type = 1;		// 訊息型別
	uint64      id   = 2;		// 訊息id
	string service   = 3;		// 服務名稱
	string method    = 4;		// 方法名稱
	ErrorCode error  = 5;		// 錯誤程式碼

	bytes request    = 100;		// 請求資料
	bytes response   = 101;		// 響應資料
}

注意: 這裡的 request 和 response,它們的型別都是 byte

客戶端在傳送資料時

首先,構造一個 RpcMessage 變數,填入各種後設資料(type, id, service, method, error);

然後,序列化客戶端傳入的請求物件(EchoRequest), 得到請求資料的位元組碼;

再然後,把請求資料的位元組碼插入到 RpcMessage 中的 request 欄位;

最後,把 RpcMessage 變數序列化之後,通過 TCP 傳送出去。

如下圖:

服務端在接收到 TCP 資料時,執行相反的操作:

首先,把接收到的 TCP 資料反序列化,得到一個 RpcMessage 變數;

然後,根據其中的 type 欄位,得知這是一個呼叫請求,於是根據 service 和 method 欄位,構造出兩個類例項:EchoRequest 和 EchoResponse(利用了 C++ 中的原型模式);

最後,從 RpcMessage 訊息中的 request 欄位反序列化,來填充 EchoRequest 例項;

這樣就得到了這次呼叫請求的所有資料。如下圖:

3. 客戶端傳送請求資料

這部分主要描述下圖中綠色部分的內容:

Step1: 業務層客戶端呼叫 Echo() 函式

// ip, port 是服務端網路地址
RpcChannel *rpcChannel = new RpcChannelClient(ip, port);
EchoService_Stub *serviceStub = new EchoService_Stub(rpcChannel);
serviceStub->Echo(...);

上文已經說過,EchoService_Stub 中的 Echo 方法,會呼叫其成員變數 channel_ 的 CallMethod 方法,因此,需要提前把實現好的 RpcChannelClient 例項,作為建構函式的引數,註冊到 EchoService_Stub 中。

Step2: EchoService_Stub 呼叫 channel_.CallMethod() 方法

這個方法在 RpcChannelClient (繼承自 protobuf 中的 RpcChannel 類)中實現,它主要的任務就是:把 EchoRequest 請求資料,包裝在 RPC 後設資料中,然後序列化得到二進位制資料

// 建立 RpcMessage
RpcMessage message;

// 填充後設資料
message.set_type(RPC_TYPE_REQUEST);
message.set_id(1);
message.set_service("EchoService");
message.set_method("Echo");

// 序列化請求變數,填充 request 欄位
// (這裡的 request 變數,是客戶端程式傳進來的)
message.set_request(request->SerializeAsString());

// 把 RpcMessage 序列化
std::string message_str;
message.SerializeToString(&message_str);

Step3: 通過 libevent 介面函式傳送 TCP 資料

bufferevent_write(m_evBufferEvent, [二進位制資料]);

4. 服務端接收請求資料

這部分主要描述下圖中綠色部分的內容:

Step4: 第一次反序列化資料

RpcChannelServer 是負責處理服務端的網路資料,當它接收到 TCP 資料之後,首先進行第一次反序列化,得到 RpcMessage 變數,這樣就獲得了 RPC 後設資料,包括:訊息型別(請求RPC_TYPE_REQUEST)、訊息 Id、Service 名稱("EchoServcie")、Method 名稱("Echo")。

RpcMessage rpcMsg;

// 第一次反序列化
rpcMsg.ParseFromString(tcpData); 

// 建立請求和響應例項
auto *serviceDesc = service->GetDescriptor();
auto *methodDesc = serviceDesc->FindMethodByName(rpcMsg.method());

從請求資料中獲取到請求服務的 Service 名稱(serviceDesc)之後,就可以查詢到服務物件 EchoService 了,因為我們也拿到了請求方法的名稱(methodDesc),此時利用 C++ 中的原型模式,構造出這個方法所需要的請求物件和響應物件,如下:

// 構造 request & response 物件
auto *echoRequest = service->GetRequestPrototype(methodDesc).New();
auto *echoResponse = service->GetResponsePrototype(methodDesc).New();

構造出請求物件 echoRequest 之後,就可以用 TCP 資料中的請求欄位(即: rpcMsg.request)來第二次反序列化了,此時就還原出了這次方法呼叫中的 引數,如下:

// 第二次反序列化:
request->ParseFromString(rpcMsg.request());

這裡有一個內容需要補充一下: EchoService 服務是如何被查詢到的?

在服務端可能同時執行了 很多個 Service 以提供不同的服務,我們的 EchoService 只是其中的服務之一。那麼這就需要解決一個問題:在從請求資料中提取出 Service 和 Method 的名稱之後,如何找到 EchoService 例項?

一般的做法是:在服務端有一個 Service 服務物件池,當 RpcChannelServer 接收到呼叫請求後,到這個池子中 查詢相應的 Service 物件,對於我們的示例來說,就是要查詢 EchoServcie 物件,例如:

std::map<std::string, google::protobuf::Service *> m_spServiceMap;

// 在服務端啟動的時候,把一個 EchoServcie 例項註冊到池子中
EchoService *echoService = new EchoServiceImpl();
m_spServiceMap->insert("EchoService", echoService);

由於EchoService示例已經提前建立好,並 註冊到 Service 物件池中(以 名稱字串作為關鍵字),因此當需要的時候,就可以通過 服務名稱來查詢相應的服務物件了。

Step5: 呼叫 EchoServiceImpl 中的 Echo() 方法

查詢到EchoService服務物件之後,就可以呼叫其中的 Echo() 這個方法了,但 不是直接呼叫,而是用一箇中間函式CallMethod來進行過渡。

// 查詢到 EchoService 物件
service->CallMethod(...)

echo_servcie.pb.cc 中,這個 CallMethod() 方法的實現為:

void EchoService::CallMethod(...)
{
    switch(method->index())
    {
        case 0: 
            Echo(...);
            break;
            
        case 1:
            Add(...);
            break;
    }
}

可以看到:protobuf 是利用固定(寫死)的 索引,來定位一個 Service 服務中所有的 method 的,也就是說 順序很重要

Step6: 呼叫 EchoServiceImpl 中的 Echo 方法

EchoServiceImpl 類繼承自 EchoService,並實現了其中的虛擬函式 Echo 和 Add,因此 Step5 中在呼叫 Echo 方法時,根據 C++ 的多型,就進入了業務層中實現的 Echo 方法。

再補充另一個知識點:我們這裡的示例程式碼中,客戶端是預先知道服務端的 IP 地址和埠號的,所以就直接建立到伺服器的 TCP 連線了。
在一些分步式應用場景中,可能會有一個服務發現流程。也就是說:每一個服務都註冊到“服務發現伺服器”上,然後客戶端在呼叫遠端服務的之前,並不知道服務提供者在什麼位置。客戶端首先到服務發現伺服器中查詢,拿到了某個服務提供者的網路地址之後,再向該服務提供者傳送遠端呼叫請求。

當查詢到 EchoServcie 服務物件之後,就可以呼叫其中的指定方法了。

5. 服務端傳送響應資料

這部分主要描述下圖中 綠色部分的內容:

Step7: 業務層處理完畢,回撥 RpcChannelServer 中的回撥物件

在上面的 Step4 中,我們通過原型模式構造了 2 個物件:請求物件(echoRequest)和響應物件(echoResponse),程式碼重貼一下:

// 構造 request & response 物件
auto *echoRequest = service->GetRequestPrototype(methodDesc).New();
auto *echoResponse = service->GetResponsePrototype(methodDesc).New();

構造 echoRequest 物件比較好理解,因為我們要從 TCP 二進位制資料中反序列化,得到 Echo 方法的請求引數

那麼 echoResponse 這個物件為什麼需要構造出來?這個物件的目的肯定是為了存放處理結果

在 Step5 中,呼叫 service->CallMethod(...) 的時候,傳遞引數如下:

service->CallMethod([引數1:先不管], [引數2:先不管], echoRequest, echoResponse, respDone);

// this position

按照一般的函式呼叫流程,在CallMethod中呼叫 Echo() 函式,業務層處理完之後,會回到上面 this position 這個位置。然後再把 echoResponse 響應資料序列化,最後通過 TCP 傳送出去。

但是 protobuf 的設計並不是如此,這裡利用了 C++ 中的閉包的可呼叫特性,構造了 respDone 這個變數,這個變數會一直作為引數傳遞到業務層的 Echo() 方法中。

這個respDone物件是這樣建立出來的:

auto respDone = google::protobuf::NewCallback(this, &RpcChannelServer::onResponseDoneCB, echoResponse);	

這裡的 NewCallback,是由 protobuf 提供的,在 protobuf 原始碼中,有這麼一段:

template <typename Class, typename Arg1>
inline Closure* NewPermanentCallback(Class* object, 
                void (Class::*method)(Arg1),
                Arg1 arg1) {
  return new internal::MethodClosure1<Class, Arg1>(object, method, false, arg1);
}


// 只貼出關鍵程式碼
class MethodClosure1 : public Closure
{
    void Run() override 
    { 
        (object_->*method_)(arg1_);
    }
}

因此,通過 NewCallBack 這個模板方法,就可以建立一個可呼叫物件 respDone,並且這個物件中儲存了傳入的引數:一個函式,這個函式接收的引數

當在以後某個時候,呼叫 respDone 這個物件的 Run 方法時,這個方法就會呼叫它儲存的那個函式,並且傳入儲存的引數。

有了這部分知識,再來看一下業務層的 Echo() 程式碼:

void EchoServiceImpl::Echo(protobuf::RpcController* controller,
                   EchoRequest* request,
                   EchoResponse* response,
                   protobuf::Closure* done)
{
	response->set_message(request->message() + ", welcome!");
	done->Run();
}

可以看到,在 Echo() 方法處理完畢之後,只呼叫了 done->Run() 方法,這個方法會呼叫之前作為引數註冊進去的 RpcChannelServer::onResponseDoneCB 方法,並且把響應物件echoResponse作為引數傳遞進去。

這這裡就比較好理解了,可以預見到:RpcChannelServer::onResponseDoneCB 方法中一定是進行了 2 個操作:

  1. 反序列化資料;
  2. 傳送 TCP 資料;

Step8: 序列化得到二進位制位元組碼,傳送 TCP 資料

首先,構造 RPC 後設資料,把響應物件序列化之後,設定到 response 欄位。

void RpcChannelImpl::onResponseDoneCB(Message *response)
{
    // 構造外層的 RPC 後設資料
	RpcMessage rpcMsg;
	rpcMsg.set_type(RPC_TYPE_RESPONSE);
	rpcMsg.set_id([訊息 Id]]);
	rpcMsg.set_error(RPC_ERR_SUCCESS);
	
	// 把響應物件序列化,設定到 response 欄位。
	rpcMsg.set_response(response->SerializeAsString());
}

然後,序列化資料,通過 libevent 傳送 TCP 資料。

std::string message_str;
rpcMsg.SerializeToString(&message_str);
bufferevent_write(m_evBufferEvent, message_str.c_str(), message_str.size());

6. 客戶端接收響應資料

這部分主要描述下圖中綠色部分的內容:

Step9: 反序列化接收到的 TCP 資料

RpcChannelClient 是負責客戶端的網路通訊,因此當它接收到 TCP 資料之後,首先進行第一次反序列化,構造出 RpcMessage 變數,其中的 response 欄位就存放著服務端的函式處理結果,只不過此時它是二進位制資料。

RpcMessage rpcMsg;
rpcMsg.ParseFromString(tcpData);

// 此時,rpcMsg.reponse 中儲存的就是 Echo() 函式處理結果的二進位制資料。

Step10: 呼叫業務層客戶端的函式來處理 RPC 結果

那麼應該把這個二進位制響應資料序列化到哪一個 response 物件上呢?

在前面的主題【客戶端傳送請求資料】,也就是 Step1 中,業務層客戶端在呼叫 serviceStub->Echo(...) 方法的時候,我沒有列出傳遞的引數,這裡把它補全:

// 定義請求物件
EchoRequest request;
request.set_message("hello, I am client");

// 定義響應物件
EchoResponse *response = new EchoResponse;


auto doneClosure = protobuf::NewCallback(
				    &doneEchoResponseCB, 
					response 	
				    );

// 第一個引數先不用關心
serviceStub->Echo(rpcController, &request, response, doneClosure);

可以看到,這裡同樣利用了 protobuf 提供的 NewCallback 模板方法,來建立一個可呼叫物件(閉包doneClosure),並且讓這個閉包儲存了 2 個引數:一個回撥函式(doneEchoResponseCB)response 物件(應該說是指標更準確)。

當回撥函式 doneEchoResponseCB 被呼叫的時候,會自動把 response 物件作為引數傳遞進去。

這個可呼叫物件(doneClosure閉包) 和 response 物件,被作為引數 一路傳遞到 EchoService_Stub --> RpcChannelClient,如下圖所示:

因此當 RpcChannelClient 接收到 RPC 遠端呼叫結果時,就把二進位制的 TCP 資料,反序列化到 response 物件上,然後再呼叫 doneClosure->Run() 方法,Run() 方法中執行 (object_->*method_)(arg1_),就呼叫了業務層中的回撥函式,也把引數傳遞進去了。

業務層的回撥函式 doneEchoResponseCB() 函式的程式碼如下:

void doneEchoResponseCB(EchoResponse *response)
{
	cout << "response.message = " << response->message() << endl;
	delete response;
}

至此,整個 RPC 呼叫流程結束。

六、總結

1. protobuf 的核心

通過以上的分析,可以看出 protobuf 主要是為我們解決了序列化和反序列化的問題。

然後又通過 RpcChannel 這個類,來完成業務層的使用者程式碼 protobuf 程式碼的整合問題。

利用這兩個神器,我們來實現自己的 RPC 框架,思路就非常的清晰了。

2. 未解決的問題

這篇文章僅僅是分析了利用 protobuf 工具,來實現一個 RPC 遠端呼叫框架中的幾個關鍵的類,以及函式的呼叫順序

按照文中的描述,可以實現出一個滿足基本功能的 RPC 框架,但是還不足以在產品中使用,因為還有下面幾個問題需要解決:

  1. 同步呼叫和非同步呼叫問題;
  2. 併發問題(多個客戶端的併發連線,同一個客戶端的併發呼叫);
  3. 呼叫超時控制;

以後有機會的話,再和大家一起繼續深入的討論這些話題,祝您好運!



---------- End ----------

讓知識流動起來,越分享,越幸運!

星標公眾號,能更快找到我!

這裡插入公眾號

Hi~你好,我是道哥,一枚嵌入式開發老兵。



推薦閱讀

1. C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
2. 原來gdb的底層除錯原理這麼簡單
3. 一步步分析-如何用C實現物件導向程式設計
4. 都說軟體架構要分層、分模組,具體應該怎麼做(一)
5. 都說軟體架構要分層、分模組,具體應該怎麼做(二)

相關文章