利用protobuf实现RPC框架之实现 RPC 框架

一、前言

今天聊一聊 RPC 的相关内容,来看一下如何利用 Google 的开源序列化工具protobuf,来实现一个我们自己的 RPC 框架。文章比较长,但是值得想了解RPC的小伙伴阅读参考。整个系列内容分为四个部分:

二、实现 RPC 框架

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 元数据中,然后序列化得到二进制数据。

// 创建 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);

由于示例已经提前创建好,并注册到 Service 对象池中(以名称字符串作为关键字),因此当需要的时候,就可以通过服务名称来查找相应的服务对象了。EchoService

Step5: 调用 EchoServiceImpl 中的 Echo() 方法

查找到服务对象之后,就可以调用其中的 Echo() 这个方法了,但不是直接调用,而是用一个中间函数来进行过渡。EchoServiceCallMethod

// 查找到 EchoService 对象
service->CallMethod(...)

在http://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 方法。

再补充另一个知识点:我们这里的示例代码中,客户端是预先知道服务端的 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 框架,但是还不足以在产品中使用,因为还有下面几个问题需要解决:

同步调用和异步调用问题; 并发问题(多个客户端的并发连接,同一个客户端的并发调用); 调用超时控制;

原文链接

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Protobuf-rpc是一种基于protobuf的远程方法调用(RPC)框架。它提供了服务器端和客户端的实现,其中服务器端仅支持Java,而客户端则支持Objective-C和Java。RPC是一种封装了网络协议和序列化、反序列化功能的通信框架,而protobuf-rpc使用protobuf实现了序列化和反序列化的功能。通过protobuf-rpc,客户端可以像调用本地方法一样调用远程接口方法,实现了透明调用机制,让使用者不必显示区分本地调用和远程调用。这使得开发人员可以很方便地在分布式系统中进行远程方法调用。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [protobuf-rpc:protobuf-rpc 是一个基于 Google ProtocolBuffer 的 RPC 框架。 目前 protobuf-rpc 支持 ...](https://download.csdn.net/download/weixin_42123296/19257793)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *2* [protobuf+RPC技术](https://blog.csdn.net/weixin_27015375/article/details/114350163)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *3* [python如何通过protobuf实现rpc](https://download.csdn.net/download/weixin_38599545/13771570)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值