長文圖解Google的protobuf思考、設(shè)計、應用
時間:2021-08-19 16:27:59
手機看文章
掃描二維碼
隨時隨地手機看文章
[導讀]一、前言二、RPC基礎(chǔ)概念三、protobuf基本使用四、libevent五、實現(xiàn)RPC框架1.基本框架構(gòu)思2.元數(shù)據(jù)的設(shè)計3.分析:客戶端發(fā)送請求4.分析:服務端接收請求5.分析:服務端發(fā)送響應6.分析:客戶端接收響應六、總結(jié)1.protobuf的核心2.未解決的問題Warni...
- 一、前言
- 二、RPC 基礎(chǔ)概念
- 三、protobuf 基本使用
- 四、libevent
- 五、實現(xiàn) RPC 框架
- 1. 基本框架構(gòu)思
- 2. 元數(shù)據(jù)的設(shè)計
- 3. 分析:客戶端發(fā)送請求
- 4. 分析:服務端接收請求
- 5. 分析:服務端發(fā)送響應
- 6. 分析:客戶端接收響應
- 六、總結(jié)
- 1. protobuf 的核心
- 2. 未解決的問題
Warning: 文章有點長,我主要是想在一篇文章中把相關(guān)的重點內(nèi)容都講完、講透徹,請見諒。以后,我盡可能不寫這么長的文章。
一、前言
在嵌入式系統(tǒng)中,很少需要使用到 RPC (Remote Procedure Call)遠程方法調(diào)用,因為在大部分情況下,實現(xiàn)一個產(chǎn)品功能的所有進程、線程都是運行在同一個硬件設(shè)備中的。但是在一些特殊的場景中,RPC 調(diào)用還是很有市場的,比如:在計算密集型產(chǎn)品中,需要調(diào)用算力更強的中央服務器提供的算法函數(shù);因此,利用 RPC 來利用遠程提供的服務,相對于其他的機制來說,有更多的優(yōu)勢。這篇文章我們就來聊一聊 RPC 的相關(guān)內(nèi)容,來看一下如何利用 Google 的開源序列化工具 protobuf,來實現(xiàn)一個我們自己的 RPC 框架。
序列化[1]:將結(jié)構(gòu)數(shù)據(jù)或?qū)ο筠D(zhuǎn)換成能夠被存儲和傳輸(例如網(wǎng)絡(luò)傳輸)的格式,同時應當要保證這個序列化結(jié)果在之后(可能在另一個計算環(huán)境中)能夠被重建回原來的結(jié)構(gòu)數(shù)據(jù)或?qū)ο蟆?/p>我會以 protobuf 中的一些關(guān)鍵 C 類作為突破口,來描述從客戶端發(fā)起調(diào)用,到服務端響應,這個完整執(zhí)行序列。也就是下面這張圖:這張圖大概畫了 2 個小時(邊看代碼,邊畫圖),我已經(jīng)盡力了,雖然看起來有點亂。在下面的描述中,我會根據(jù)每一部分的主題,把這張圖拆成不同的模塊,從空間(文件和類的結(jié)構(gòu))和時間(函數(shù)的調(diào)用順序、數(shù)據(jù)流向)這兩個角度,來描述圖中的每一個元素,我相信聰明的你一定會看明白的!希望你看了這篇文章之后,對 RPC 框架的設(shè)計過程有一個基本的認識和理解,應對面試官的時候,關(guān)于 RPC 框架設(shè)計的問題應該綽綽有余了。如果在項目中恰好選擇了 protobuf,那么根據(jù)這張圖中的模塊結(jié)構(gòu)和函數(shù)調(diào)用流程分析,可以協(xié)助你更好的完成每一個模塊的開發(fā)。注意:這篇文章不會聊什么內(nèi)容:
- protfobuf 的源碼實現(xiàn);
- protfobuf 的編碼算法;
二、RPC 基礎(chǔ)概念
1. RPC 是什么?
RPC (Remote Procedure Call)從字面上理解,就是調(diào)用一個方法,但是這個方法不是運行在本地,而是運行在遠端的服務器上。也就是說,客戶端應用可以像調(diào)用本地函數(shù)一樣,直接調(diào)用運行在遠端服務器上的方法。下面這張圖描述了 RPC 調(diào)用的基本流程:int getMotionPath(float *input, int intputLen, float *output, int outputLen)
如果計算過程不復雜,可以把這個算法函數(shù)和應用程序放在本地的同一個進程中,以源代碼或庫的方式提供計算服務,如下圖:為了解決以上這幾個問題,于是 RPC 遠程調(diào)用框架就誕生了!
- 如何處理通信問題?TCP or UDP or HTTP?或者利用其他的一些已有的網(wǎng)絡(luò)協(xié)議?
- 如何把數(shù)據(jù)進行打包?服務端接收到打包的數(shù)據(jù)之后,如何還原數(shù)據(jù)?
- 對于特定領(lǐng)域的問題,可以專門寫一套實現(xiàn)來解決,但是對于通用的遠程調(diào)用,怎么做到更靈活、更方便?
- 服務器端利用這個庫,在網(wǎng)絡(luò)上提供函數(shù)調(diào)用服務;
- 客戶端利用這個庫,遠程調(diào)用位于服務器上的函數(shù);
2. 需要解決什么問題?
既然我們是介紹 RPC 框架,那么需要解決的問題就是一個典型的 RPC 框架所面對問題,如下:這 3 個問題是所有的 RPC 框架都必須解決的,這是最基本的問題,其他的考量因素就是:速度更快、成本更低、使用更靈活、易擴展、向后兼容、占用更少的系統(tǒng)資源等等。另外還有一個考量因素:跨語言。比如:客戶端可以用 C 語言實現(xiàn),服務端可以用 C/C 、Java或其他語言來實現(xiàn),在技術(shù)選型時這也是非常重要的考慮因素。
- 解決函數(shù)調(diào)用時,數(shù)據(jù)結(jié)構(gòu)的約定問題;
- 解決數(shù)據(jù)傳輸時,序列化和反序列化問題;
- 解決網(wǎng)絡(luò)通信問題;
3. 有哪些開源實現(xiàn)?
從上面的介紹中可以看出來,RPC 的最大優(yōu)勢就是降低了客戶端的函數(shù)調(diào)用難度,調(diào)用遠程的服務就好像在調(diào)用本地的一個函數(shù)一樣。因此,各種大廠都開發(fā)了自己的 RPC 框架,例如:Google 的 gRPC;另外,還有很多小廠以及個人,也會發(fā)布一些 RPC 遠程調(diào)用框架(tinyRPC,forestRPC,EasyRPC等等)。每一家 RPC 的特點,感興趣的小伙伴可以自行去搜索比對,這里對 gRPC 多說幾句,我們剛才主要聊了 protobuf,其實它只是解決了序列化的問題,對于一個完整的 RPC 框架,還缺少網(wǎng)絡(luò)通信這個步驟。gRPC 就是利用了 protobuf,來實現(xiàn)了一個完整的 RPC 遠程調(diào)用框架,其中的通信部分,使用的是 HTTP 協(xié)議。
Facebook 的 thrift;
騰訊的 Tars;
百度的 BRPC;
三、protobuf 基本使用
1. 基本知識
Protobuf 是 Protocol Buffers 的簡稱, 它是 Google 開發(fā)的一種跨語言、跨平臺、可擴展的用于序列化數(shù)據(jù)協(xié)議,Protobuf 可以用于結(jié)構(gòu)化數(shù)據(jù)序列化(串行化),它序列化出來的數(shù)據(jù)量少,再加上以 K-V 的方式來存儲數(shù)據(jù),非常適用于在網(wǎng)絡(luò)通訊中的數(shù)據(jù)載體。只要遵守一些簡單的使用規(guī)則,可以做到非常好的兼容性和擴展性,可用于通訊協(xié)議、數(shù)據(jù)存儲等領(lǐng)域的語言無關(guān)、平臺無關(guān)、可擴展的序列化結(jié)構(gòu)數(shù)據(jù)格式。Protobuf 中最基本的數(shù)據(jù)單元是 message ,并且在 message 中可以多層嵌套 message 或其它的基礎(chǔ)數(shù)據(jù)類型的成員。Protobuf 是一種靈活,高效,自動化機制的結(jié)構(gòu)數(shù)據(jù)序列化方法,可類比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更簡單,而且它支持 Java、C 、Python 等多種語言。2. 使用步驟
Step1:創(chuàng)建 .proto 文件,定義數(shù)據(jù)結(jié)構(gòu)例如,定義文件 echo_service.proto
, 其中的內(nèi)容為: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 方法:客戶端調(diào)用這個方法,請求的“數(shù)據(jù)結(jié)構(gòu)” EchoRequest 中包含一個 string 類型,也就是一串字符;服務端返回的“數(shù)據(jù)結(jié)構(gòu)” EchoResponse 中也是一個 string 字符串;Add 方法:客戶端調(diào)用這個方法,請求的“數(shù)據(jù)結(jié)構(gòu)” AddRequest 中包含 2 個整型數(shù)據(jù),服務端返回的“數(shù)據(jù)結(jié)構(gòu)” AddResponse 中包含一個整型數(shù)據(jù)(計算結(jié)果);Step2: 使用 protoc 工具,來編譯 .proto 文件,生成接口(類以及相應的方法)
protoc echo_service.proto -I./ --cpp_out=./
執(zhí)行以上命令,即可生成兩個文件:echo_service.pb.h, echo_service.pb.c
,在這 2 個文件中,定義了 2 個重要的類,也就是下圖中綠色部分:EchoService 和 EchoService_Stub 這 2 個類就是接下來要介紹的重點。我把其中比較重要的內(nèi)容摘抄如下(為減少干擾,把命名空間字符都去掉了):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:
// 成員變量,比較關(guān)鍵
RpcChannel* channel_;
};
Step3:服務端程序?qū)崿F(xiàn)接口中定義的方法,提供服務;客戶端調(diào)用接口函數(shù),調(diào)用遠程的服務。請關(guān)注上圖中的綠色部分。(1)服務端:EchoServiceEchoService 類中的兩個方法 Echo 和 Add 都是虛函數(shù),我們需要繼承這個類,定義一個業(yè)務層的服務類 EchoServiceImpl,然后實現(xiàn)這兩個方法,以此來提供遠程調(diào)用服務。EchoService 類中也給出了這兩個函數(shù)的默認實現(xiàn),只不過是提示錯誤信息:
void EchoService::Echo() {
controller->SetFailed("Method Echo() not implemented.");
done->Run();
}
void EchoService::Add() {
controller->SetFailed("Method Add() not implemented.");
done->Run();
}
圖中的 EchoServiceImpl 就是我們定義的類,其中實現(xiàn)了 Echo 和 Add 這兩個虛函數(shù):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)
{
// 獲取請求數(shù)據(jù)中的 2 個整型數(shù)據(jù)
int32_t a = request->a();
int32_t b = request->b();
// 計算結(jié)果,然后放入響應數(shù)據(jù)中
response->set_result(a b);
done->Run();
}
(2)客戶端:EchoService_StubEchoService_Stub 就相當于是客戶端的代理,應用程序只要把它"當做"遠程服務的替身,直接調(diào)用其中的函數(shù)就可以了(圖中左側(cè)的步驟1)。因此,EchoService_Stub 這個類中肯定要實現(xiàn) Echo 和 Add 這 2 個方法,看一下 protobuf 自動生成的實現(xiàn)代碼: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);
}
看到?jīng)],每一個函數(shù)都調(diào)用了成員變量 channel_ 的 CallMethod 方法(圖中左側(cè)的步驟2),這個成員變量的類型是 google::protobuf:RpcChannel。從字面上理解:channel 就像一個通道,是用來解決數(shù)據(jù)傳輸問題的。也就是說 channel_->CallMethod
方法會把所有的數(shù)據(jù)結(jié)構(gòu)序列化之后,通過網(wǎng)絡(luò)發(fā)送給服務器。既然 RpcChannel 是用來解決網(wǎng)絡(luò)通信問題的,因此客戶端和服務端都需要它們來提供數(shù)據(jù)的接收和發(fā)送。圖中的RpcChannelClient
是客戶端使用的 Channel, RpcChannelServer
是服務端使用的 Channel,它倆都是繼承自 protobuf 提供的 RpcChannel
。 RpcChannel
,只是提供了網(wǎng)絡(luò)通信的策略,至于通信的機制是什么(TCP? UDP? HTTP?),protobuf 并不關(guān)心,這需要由 RPC 框架來決定和實現(xiàn)。protobuf 提供了一個基類 RpcChannel
,其中定義了CallMethod
方法。我們的 RPC 框架中,客戶端和服務端實現(xiàn)的 Channel 必須繼承 protobuf 中的 RpcChannel
,然后重載 CallMethod
這個方法。CallMethod
方法的幾個參數(shù)特別重要,我們通過這些參數(shù),來利用 protobuf 實現(xiàn)序列化、控制函數(shù)調(diào)用等操作,也就是說這些參數(shù)就是一個紐帶,把我們寫的代碼與 protobuf 提供的功能,連接在一起。我們這里選了libevent
這個網(wǎng)絡(luò)庫來實現(xiàn) TCP 通信。四、libevent
實現(xiàn) RPC 框架,需要解決 2 個問題:通信和序列化。protobuf 解決了序列化問題,那么還需要解決通信問題。有下面幾種通信方式備選:如何選擇,那就是見仁見智的事情了,比如 gRPC 選擇的就是 HTTP,也工作的很好,更多的實現(xiàn)選擇的是 TCP 通信。下面就是要決定:是從 socket 層次開始自己寫?還是利用已有的一些開源網(wǎng)絡(luò)庫來實現(xiàn)通信?既然標題已經(jīng)是 libevent 了,那肯定選擇的就是它!當然還有很多其他優(yōu)秀的網(wǎng)絡(luò)庫可以利用,比如:libev, libuv 等等。
- TCP 通信;
- UDP 通信;
- HTTP 通信;
1. libevent 簡介
Libevent 是一個用 C 語言編寫的、輕量級、高性能、基于事件的網(wǎng)絡(luò)庫。主要有以下幾個亮點:1. 事件驅(qū)動( event-driven),高性能;從我們使用者的角度來看,libevent 庫提供了以下功能:當一個文件描述符的特定事件(如可讀,可寫或出錯)發(fā)生了,或一個定時事件發(fā)生了, libevent 就會自動執(zhí)行用戶注冊的回調(diào)函數(shù),來接收數(shù)據(jù)或者處理事件。此外,libevent 還把 fd 讀寫、信號、DNS、定時器甚至idle(空閑) 都抽象化成了event(事件)。總之一句話:使用很方便,功能很強大!
2. 輕量級,專注于網(wǎng)絡(luò);源代碼相當精煉、易讀;
3. 跨平臺,支持 Windows、 Linux、*BSD 和 Mac Os;
4. 支持多種 I/O 多路復用技術(shù), epoll、 poll、 dev/poll、 select 和 kqueue 等;
5. 支持 I/O,定時器和信號等事件;注冊事件優(yōu)先級。
2. 基本使用
libevent 是基于事件的回調(diào)函數(shù)機制,因此在啟動監(jiān)聽 socket 之前,只要設(shè)置好相應的回調(diào)函數(shù),當有事件或者網(wǎng)絡(luò)數(shù)據(jù)到來時,libevent 就會自動調(diào)用回調(diào)函數(shù)。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,
[讀取數(shù)據(jù)回調(diào)函數(shù)],
NULL,
[事件回調(diào)函數(shù)],
[回調(diào)函數(shù)傳參]);
// 開始監(jiān)聽 socket
event_base_dispatch(m_evBase);
有一個問題需要注意:protobuf 序列化之后的數(shù)據(jù),全部是二進制的。libevent 只是一個網(wǎng)絡(luò)通信的機制,如何處理接收到的二進制數(shù)據(jù)(粘包、分包的問題),是我們需要解決的問題。五、實現(xiàn) RPC 框架
從剛才的第三部分: 自動生成的幾個類EchoService, EchoService_Stub
中,已經(jīng)能夠大概看到 RPC 框架的端倪了。這里我們再整合在一起,看一下更具體的細節(jié)部分。1. 基本框架構(gòu)思
我把圖中的干擾細節(jié)全部去掉,得到下面這張圖:其中的綠色部分就是我們的 RPC 框架需要實現(xiàn)的部分,功能簡述如下:1. EchoService:服務端接口類,定義需要實現(xiàn)哪些方法;應用程序:
2. EchoService_Stub: 繼承自 EchoService,是客戶端的本地代理;
3. RpcChannelClient: 用戶處理客戶端網(wǎng)絡(luò)通信,繼承自 RpcChannel;
4. RpcChannelServer: 用戶處理服務端網(wǎng)絡(luò)通信,繼承自 RpcChannel;
1. EchoServiceImpl:服務端應用層需要實現(xiàn)的類,繼承自 EchoService;
2. ClientApp: 客戶端應用程序,調(diào)用 EchoService_Stub 中的方法;
2. 元數(shù)據(jù)的設(shè)計
在 echo_servcie.proto 文件中,我們按照 protobuf 的語法規(guī)則,定義了幾個 Message,可以看作是“數(shù)據(jù)結(jié)構(gòu)”:1. Echo 方法相關(guān)的“數(shù)據(jù)結(jié)構(gòu)”:EchoRequest, EchoResponse。這幾個數(shù)據(jù)結(jié)構(gòu)是直接與業(yè)務層相關(guān)的,是我們的客戶端和服務端來處理請求和響應數(shù)據(jù)的一種約定。為了實現(xiàn)一個基本完善的數(shù)據(jù) RPC 框架,我們還需要其他的一些“數(shù)據(jù)結(jié)構(gòu)”來完成必要的功能,例如:
2. Add 方法相關(guān)的“數(shù)據(jù)結(jié)構(gòu)”:AddRequest, AddResponse。
1. 消息 Id 管理;另外,在調(diào)用函數(shù)時,請求和響應的“數(shù)據(jù)結(jié)構(gòu)”是不同的數(shù)據(jù)類型。為了便于統(tǒng)一處理,我們把請求數(shù)據(jù)和響應數(shù)據(jù)都包裝在一個統(tǒng)一的 RPC “數(shù)據(jù)結(jié)構(gòu)”中,并用一個類型字段(type)來區(qū)分:某個 RPC 消息是請求數(shù)據(jù),還是響應數(shù)據(jù)。根據(jù)以上這些想法,我們設(shè)計出下面這樣的元數(shù)據(jù):
2. 錯誤處理;
3. 同步調(diào)用和異步調(diào)用;
4. 超時控制;
// 消息類型
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; // 請求數(shù)據(jù)
bytes response = 101; // 響應數(shù)據(jù)
}
注意: 這里的 request 和 response,它們的類型都是 byte。客戶端在發(fā)送數(shù)據(jù)時:首先,構(gòu)造一個 RpcMessage 變量,填入各種元數(shù)據(jù)(type, id, service, method, error);然后,序列化客戶端傳入的請求對象(EchoRequest), 得到請求數(shù)據(jù)的字節(jié)碼;再然后,把請求數(shù)據(jù)的字節(jié)碼插入到 RpcMessage 中的 request 字段;最后,把 RpcMessage 變量序列化之后,通過 TCP 發(fā)送出去。如下圖:
首先,把接收到的 TCP 數(shù)據(jù)反序列化,得到一個 RpcMessage 變量;然后,根據(jù)其中的 type 字段,得知這是一個調(diào)用請求,于是根據(jù) service 和 method 字段,構(gòu)造出兩個類實例:EchoRequest 和 EchoResponse(利用了 C 中的原型模式);最后,從 RpcMessage 消息中的 request 字段反序列化,來填充 EchoRequest 實例;這樣就得到了這次調(diào)用請求的所有數(shù)據(jù)。如下圖:
3. 客戶端發(fā)送請求數(shù)據(jù)
這部分主要描述下圖中綠色部分的內(nèi)容:Step1: 業(yè)務層客戶端調(diào)用 Echo() 函數(shù)// ip, port 是服務端網(wǎng)絡(luò)地址
RpcChannel *rpcChannel = new RpcChannelClient(ip, port);
EchoService_Stub *serviceStub = new EchoService_Stub(rpcChannel);
serviceStub->Echo(...);
上文已經(jīng)說過,EchoService_Stub 中的 Echo 方法,會調(diào)用其成員變量 channel_ 的 CallMethod 方法,因此,需要提前把實現(xiàn)好的 RpcChannelClient 實例,作為構(gòu)造函數(shù)的參數(shù),注冊到 EchoService_Stub 中。Step2: EchoService_Stub 調(diào)用 channel_.CallMethod() 方法這個方法在 RpcChannelClient (繼承自 protobuf 中的 RpcChannel 類)中實現(xiàn),它主要的任務就是:把 EchoRequest 請求數(shù)據(jù),包裝在 RPC 元數(shù)據(jù)中,然后序列化得到二進制數(shù)據(jù)。// 創(chuàng)建 RpcMessage
RpcMessage message;
// 填充元數(shù)據(jù)
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(