天天看點

如何利用google的protobuf設計、實作自己的RPC架構

作者:linux技術棧

一、前言

這篇文章我們就來聊一聊 RPC 的相關内容,來看一下如何利用 Google 的開源序列化工具 protobuf,來實作一個我們自己的 RPC 架構,内容有點長,請耐心看完。

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

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

如何利用google的protobuf設計、實作自己的RPC架構

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

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

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

如果在項目中恰好選擇了 protobuf,那麼根據這張圖中的子產品結構和函數調用流程分析,可以協助你更好的完成每一個子產品的開發。

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

  1. protfobuf 的源碼實作;
  2. protfobuf 的編碼算法;

二、RPC 基礎概念

1. RPC 是什麼?

RPC (Remote Procedure Call)從字面上了解,就是調用一個方法,但是這個方法不是運作在本地,而是運作在遠端的伺服器上。也就是說,用戶端應用可以像調用本地函數一樣,直接調用運作在遠端伺服器上的方法。

下面這張圖描述了 RPC 調用的基本流程:

如何利用google的protobuf設計、實作自己的RPC架構

​假如,我們的應用程式需要調用一個算法函數來擷取運動軌迹:

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

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

如何利用google的protobuf設計、實作自己的RPC架構

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

此時,調用過程如下圖這樣:

如何利用google的protobuf設計、實作自己的RPC架構

​從功能上來看,應用程式仍然是調用遠端伺服器上的一個方法,也就是虛線部分。但是由于他們運作在不同的實體裝置上,更不是在同一個程序中,是以,如果想調用成功就一定需要利用網絡來傳輸資料。

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

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

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

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

  1. 如何處理通信問題?TCP or UDP or HTTP?或者利用其他的一些已有的網絡協定?
  2. 如何把資料進行打包?服務端接收到打包的資料之後,如何還原資料?
  3. 對于特定領域的問題,可以專門寫一套實作來解決,但是對于通用的遠端調用,怎麼做到更靈活、更友善?

為了解決以上這幾個問題,于是 RPC 遠端調用架構就誕生了!

如何利用google的protobuf設計、實作自己的RPC架構

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

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

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

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

最終的目的,将會輸出一個 RPC 遠端調用架構的庫檔案(動态庫、靜态庫):

  1. 伺服器端利用這個庫,在網絡上提供函數調用服務;
  2. 用戶端利用這個庫,遠端調用位于伺服器上的函數;

相關視訊推薦

protobuf序列化協定工程應用方法和實踐分析

從 4 個方面了解 libevent 的原理及使用

C++後端必讀7個開源項目源碼(redis、mysql、nginx、protobuf...)

需要C/C++ Linux伺服器架構師學習資料加qun812855908擷取(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享

如何利用google的protobuf設計、實作自己的RPC架構

2. 需要解決什麼問題?

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

  1. 解決函數調用時,資料結構的約定問題;
  2. 解決資料傳輸時,序列化和反序列化問題;
  3. 解決網絡通信問題;

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

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

3. 有哪些開源實作?

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

是以,各種大廠都開發了自己的 RPC 架構,例如:

Google 的 gRPC; Facebook 的 thrift; 騰訊的 Tars; 百度的 BRPC;

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

我們剛才主要聊了 protobuf,其實它隻是解決了序列化的問題,對于一個完整的 RPC 架構,還缺少網絡通信這個步驟。

gRPC 就是利用了 protobuf,來實作了一個完整的 RPC 遠端調用架構,其中的通信部分,使用的是 HTTP 協定。

如何利用google的protobuf設計、實作自己的RPC架構

三、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 個重要的類,也就是下圖中綠色部分:

如何利用google的protobuf設計、實作自己的RPC架構

​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:服務端程式實作接口中定義的方法,提供服務;用戶端調用接口函數,調用遠端的服務。

如何利用google的protobuf設計、實作自己的RPC架構

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

(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。

如何利用google的protobuf設計、實作自己的RPC架構

​注意:這裡的 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 語言編寫的、輕量級、高性能、基于事件的網絡庫。

主要有以下幾個亮點:

1. 事件驅動( event-driven),高性能; 2. 輕量級,專注于網絡;源代碼相當精煉、易讀; 3. 跨平台,支援 Windows、 Linux、*BSD 和 Mac Os; 4. 支援多種 I/O 多路複用技術, epoll、 poll、 dev/poll、 select 和 kqueue 等; 5. 支援 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. 基本架構構思

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

如何利用google的protobuf設計、實作自己的RPC架構

​其中的綠色部分就是我們的 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,可以看作是“資料結構”:

1. Echo 方法相關的“資料結構”:EchoRequest, EchoResponse。 2. 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 發送出去。

如下圖:

如何利用google的protobuf設計、實作自己的RPC架構

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

首先,把接收到的 TCP 資料反序列化,得到一個 RpcMessage 變量; 然後,根據其中的 type 字段,得知這是一個調用請求,于是根據 service 和 method 字段,構造出兩個類執行個體:EchoRequest 和 EchoResponse(利用了 C++ 中的原型模式); 最後,從 RpcMessage 消息中的 request 字段反序列化,來填充 EchoRequest 執行個體;

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

如何利用google的protobuf設計、實作自己的RPC架構

3. 用戶端發送請求資料

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

如何利用google的protobuf設計、實作自己的RPC架構

​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. 服務端接收請求資料

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

如何利用google的protobuf設計、實作自己的RPC架構

​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 連接配接了。在一些分步式應用場景中,可能會有一個服務發現流程。也就是說:每一個服務都注冊到“服務發現伺服器”上,然後用戶端在調用遠端服務的之前,并不知道服務提供者在什麼位置。用戶端首先到服務發現伺服器中查詢,拿到了某個服務提供者的網絡位址之後,再向該服務提供者發送遠端調用請求。
如何利用google的protobuf設計、實作自己的RPC架構

​當查找到 EchoServcie 服務對象之後,就可以調用其中的指定方法了。

5. 服務端發送響應資料

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

如何利用google的protobuf設計、實作自己的RPC架構

​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. 用戶端接收響應資料

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

如何利用google的protobuf設計、實作自己的RPC架構

​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,如下圖所示:

如何利用google的protobuf設計、實作自己的RPC架構

​是以當 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. 調用逾時控制;

繼續閱讀