文章有點長,我主要是想在一篇文章中把相關的重點內容都講完、講透徹,請見諒。
一、前言今天聊一聊 RPC 的相關內容,來看一下如何利用 Google 的開源序列化工具protobuf,來實現一個我們自己的 RPC 框架。
序列化[1]:將結構資料或物件轉換成能夠被儲存和傳輸(例如網路傳輸)的格式,同時應當要保證這個序列化結果在之後(可能在另一個計算環境中)能夠被重建回原來的結構資料或物件。
高併發之protobuf通訊協議設計.mp4
90分鐘搞懂libevent網路庫.mp4
90分鐘搞懂網路程式設計的細節處理.mp4
我會以 protobuf 中的一些關鍵 C++ 類作為突破口,來描述從客戶端發起呼叫,到服務端響應,這個完整執行序列。 也就是下面這張圖:
在下面的描述中,我會根據每一部分的主題,把這張圖拆成不同的模組,從空間(檔案和類的結構)和時間(函式的呼叫順序、資料流向)這兩個角度,來描述圖中的每一個元素,我相信聰明的你一定會看明白的!
希望你看了這篇文章之後,對 RPC 框架的設計過程有一個基本的認識和理解,應對面試官的時候,關於 RPC 框架設計的問題應該綽綽有餘了。
如果在專案中恰好選擇了 protobuf,那麼根據這張圖中的模組結構和函式呼叫流程分析,可以協助你更好的完成每一個模組的開發。
注意:這篇文章不會聊什麼內容:
protfobuf 的原始碼實現;
protfobuf 的編碼演算法;
二、RPC 基礎概念1. RPC 是什麼?RPC (Remote Procedure Call)從字面上理解,就是呼叫一個方法,但是這個方法不是執行在本地,而是執行在遠端的伺服器上。 也就是說,客戶端應用可以像呼叫本地函式一樣,直接呼叫執行在遠端伺服器上的方法。
下面這張圖描述了 RPC 呼叫的基本流程:
假如,我們的應用程式需要呼叫一個演算法函式來獲取運動軌跡:
int getMotionPath(float *input, int intputLen, float *output, int outputLen)
如果計算過程不復雜,可以把這個演算法函式和應用程式放在本地的同一個程序中,以原始碼或庫的方式提供計算服務,如下圖:
但是,如果這個計算過程比較複雜,需要耗費一定的資源(時間和空間),本地的 CPU 計算能力根本無法支撐,那麼就可以把這個函式放在 CPU 能力更強的伺服器上。
此時,呼叫過程如下圖這樣:
從功能上來看,應用程式仍然是呼叫遠端伺服器上的一個方法,也就是虛線部分。 但是由於他們執行在不同的實體裝置上,更不是在同一個程序中,因此,如果想呼叫成功就一定需要利用網路來傳輸資料。
初步接觸 RPC 的朋友可能會提出:
那我可以在應用程式中把演算法需要的輸入資料打包好,透過網路傳送給演算法伺服器;伺服器計算出結果後,再打包好返回給應用程式就可以了。
這句話說得非常對,從功能上來說,這個描述過程就是RPC 所需要做的所有事情。
不過,在這個過程中,有很多問題需要我們來手動解決:
如何處理通訊問題? TCP or UDP or HTTP? 或者利用其他的一些已有的網路協議?
如何把資料進行打包? 服務端接收到打包的資料之後,如何還原資料?
對於特定領域的問題,可以專門寫一套實現來解決,但是對於通用的遠端呼叫,怎麼做到更靈活、更方便?
為了解決以上這幾個問題,於是 RPC 遠端呼叫框架就誕生了!
圖中的綠色背景部分,就是 RPC 框架需要做的事情。
對於應用程式來說,Client 端代理就相當於是演算法服務的「本地代理人」,至於這個代理人是怎麼來處理剛才提到的那幾個問題、然後從真正的演算法伺服器上得到結果,這就不需要應用程式來關心了。
結合文章的第一張圖中,從應用程式的角度看,它只是執行了一個函式呼叫(步驟1),然後就立刻得到了結果(步驟10),這中間的所有步驟(2-9),全部是 RPC 框架來處理,而且能夠靈活地處理各種不同的請求、響應資料。
鋪墊到這裡,我就可以更明確地再次重複一下了:這篇文章的目的,就是介紹如何利用 protobuf 來實現圖中的綠色部分的功能。
最終的目的,將會輸出一個RPC 遠端呼叫框架的庫檔案(動態庫、靜態庫):
伺服器端利用這個庫,在網路上提供函式呼叫服務;
客戶端利用這個庫,遠端呼叫位於伺服器上的函式;
2. 需要解決什麼問題?既然我們是介紹 RPC 框架,那麼需要解決的問題就是一個典型的 RPC 框架所面對問題,如下:
解決函式呼叫時,資料結構的約定問題;
解決資料傳輸時,序列化和反序列化問題;
解決網路通訊問題;
這 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協議。
文章福利 Linux後端開發網路底層原理知識學習提升,私信(Linux),完善技術棧,內容知識點包括Linux,Nginx,ZeroMQ,MySQL,Redis,執行緒池,MongoDB,ZK,Linux核心,CDN,P2P,epoll,Docker,TCP/IP,協程,DPDK等等。
三、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);}
最後的 ,是讓 protoc 生成介面類,其中包括 2 個方法Echo 和 Add:service EchoService
Echo 方法:客戶端呼叫這個方法,請求的「資料結構」 EchoRequest 中包含一個 string 型別,也就是一串字元;服務端返回的「資料結構」 EchoResponse 中也是一個 string 字串; Add 方法:客戶端呼叫這個方法,請求的「資料結構」 AddRequest 中包含 2 個整型資料,服務端返回的「資料結構」 AddResponse 中包含一個整型資料(計算結果);
Step2: 使用 protoc 工具,編譯 .proto 文件,生成介面(類以及相應的方法)
protoc echo_service.proto -I./ --cpp_out=./
執行以上命令,即可生成兩個檔案:,在這2個檔案中,定義了2個重要的類,也就是下圖中綠色部分:echo_service.pb.h, echo_service.pb.c
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::p rotobuf:RpcChannel。
從字面上理解:channel 就像一個通道,是用來解決資料傳輸問題的。 也就是說方法會把所有的資料結構序列化之後,透過網路傳送給伺服器。channel_->CallMethod
既然 RpcChannel 是用來解決網路通訊問題的,因此客戶端和服務端都需要它們來提供資料的接收和傳送。
圖中的是客戶端使用的 Channel, 是服務端使用的 Channel,它倆都是繼承自 protobuf 提供的。RpcChannelClientRpcChannelServer RpcChannel
注意:這裡的,只是提供了網路通訊的策略,至於通訊的機制是什麼(TCP? UDP? http?),protobuf並不關心,這需要由RPC框架來決定和實現。 RpcChannel
protobuf 提供了一個基類,其中定義了方法。 我們的 RPC 框架中,客戶端和服務端實現的 Channel必須繼承protobuf 中的,然後過載這個方法。 RpcChannelCallMethod RpcChannelCallMethod
CallMethod 方法的幾個引數特別重要,我們透過這些引數,來利用 protobuf 實現序列化、控制函式呼叫等操作,也就是說這些引數就是一個紐帶,把我們寫的程式碼與 protobuf 提供的功能,連線在一起。
我們這裡選了這個網路庫來實現 TCP 通訊。libevent
四、libevent實現 RPC 框架,需要解決 2 個問題:通訊和序列化。 protobuf 解決了序列化問題,那麼還需要解決通訊問題。
有下面幾種通訊方式備選:
TCP 通訊;
UDP 通訊;
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, [事件回撥函式], [回撥函式傳參]);// 開始監聽 socketevent_base_dispatch(m_evBase);
有一個問題需要注意:protobuf 序列化之後的資料,全部是二進位制的。
libevent 只是一個網路通訊的機制,如何處理接收到的二進位制資料(粘包、分包的問題),是我們需要解決的問題。
文章福利 Linux後端開發網路底層原理知識學習提升,私信(Linux),完善技術棧,內容知識點包括Linux,Nginx,ZeroMQ,MySQL,Redis,執行緒池,MongoDB,ZK,Linux核心,CDN,P2P,epoll,Docker,TCP/IP,協程,DPDK等等。
五、實現 RPC 框架從剛才的第三部分: 自動生成的幾個類中,已經能夠大概看到 RPC 框架的端倪了。 這裡我們再整合在一起,看一下更具體的細節部分。EchoService, EchoService_Stub
1. 基本框架構思我把圖中的干擾細節全部去掉,得到下面這張圖:
其中的綠色部分就是我們的 RPC 框架需要實現的部分,功能簡述如下:
EchoService:服務端介面類,定義需要實現哪些方法;
EchoService_Stub: 繼承自 EchoService,是客戶端的本地代理;
RpcChannelClient: 使用者處理客戶端網路通訊,繼承自 RpcChannel;
RpcChannelServer: 使用者處理服務端網路通訊,繼承自 RpcChannel;
應用程式 :
EchoServiceImpl:服務端應用層需要實現的類,繼承自 EchoService;
ClientApp: 客戶端應用程式,呼叫 EchoService_Stub 中的方法;
2.元資料的設計在echo_servcie.proto檔案中,我們按照 protobuf 的語法規則,定義了幾個 Message,可以看作是"資料結構」:
Echo 方法相關的「資料結構」:EchoRequest, EchoResponse。 Add 方法相關的「資料結構」:AddRequest, AddResponse。
這幾個資料結構是直接與業務層相關的,是我們的客戶端和服務端來處理請求和響應資料的一種約定。
為了實現一個基本完善的資料 RPC 框架,我們還需要其他的一些「資料結構」來完成必要的功能,例如:
呼叫 Id 管理;
錯誤處理;
同步呼叫和非同步呼叫;
超時控制;
另外,在呼叫函式時,請求和響應的「資料結構」是不同的資料型別。 為了便於統一處理,我們把請求資料和響應資料都包裝在一個統一的 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 元資料中,然後序列化得到二進位制資料。
// 建立 RpcMessageRpcMessage 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);
由於示例已經提前建立好,並註冊到 Service 物件池中(以名稱字串作為關鍵字),因此當需要的時候,就可以透過服務名稱來查詢相應的服務物件了。EchoService
Step5: 呼叫 EchoServiceImpl 中的 Echo() 方法
查詢到服務物件之後,就可以呼叫其中的 Echo() 這個方法了,但不是直接呼叫,而是用一箇中間函式來進行過渡。EchoServiceCallMethod
// 查詢到 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 類繼承自,並實現了其中的虛擬函式 Echo 和 Add,因此 Step5 正在呼叫 Echo 方法時,根據 C++ 的多型,就進入了業務層中實現的 Echo 方法。 EchoService
再補充另一個知識點:我們這裡的示例程式碼中,客戶端是預先知道服務端的 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
按照一般的函式呼叫流程,在中呼叫 Echo() 函式,業務層處理完之後,會回到上面 這個位置。 然後再把 echoResponse 響應資料序列化,最後透過 TCP 傳送出去。CallMethodthis position
但是 protobuf 的設計並不是如此,這裡利用了 C++ 中的閉包的可呼叫特性,構造了respDone這個變數,這個變數會一直作為引數傳遞到業務層的 Echo() 方法中。
這個物件是這樣創建出來的:respDone
auto respDone = google::protobuf::NewCallback(this, &RpcChannelServer::onResponseDoneCB, echoResponse);
這裡的,是由 protobuf 提供的,在 protobuf 原始碼中,有這麼一段: NewCallback
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
這這裡就比較好理解了,可以預見到:方法中一定是進行了 2 個操作:RpcChannelServer::onResponseDoneCB
反序列化資料;
傳送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 框架,但是還不足以在產品中使用,因為還有下面幾個問題需要解決:
同步呼叫和非同步呼叫問題;
併發問題(多個客戶端的併發連線,同一個客戶端的併發呼叫);
呼叫超時控制;