一个基于protobuf的极简RPC
前言
rpc采用客户机/服务器模式实现两个进程之间的相互通信,socket是rpc经常采用的通信手段之一。当然,除了socket,rpc还有其他的通信方法:http、管道。。。网络开源的rpc框架也比较多,一个功能比较完善的rpc框架代码比较多,如何快速的从这些代码盲海中梳理清楚主要脉络,对于初学者来说比较困难,本文介绍之前自己实现的一个c++极简版的rpc框架(),代码只有100多行,希望尽量用少的代码来描述框架以减轻初学者的学习负担,同时便于大家阅读网络上复杂的rpc源码。
1、经典的rpc框架echo例子里面,echoserver_stub类是哪里来的?
2、为什么stub.echo(&controller, &request, &response, nullptr); 调用就执行到server端的echo函数?
3、stub.echo(&controller, &request, &response, nullptr); 最后一个参数是nullptr,调用到server端的echo(controller, request, response, done) 函数时,done指针为什么不为空了?
…
让我们通过下面这个简单的rpc框架,一层一层解开上面的疑惑。
echo_server.cc
class echoserverimpl : public goya::rpc::echo::echoserver { public: echoserverimpl() {} virtual ~echoserverimpl() {} private: virtual void echo(google::protobuf::rpccontroller* controller, const goya::rpc::echo::echorequest* request, goya::rpc::echo::echoresponse* response, google::protobuf::closure* done) { std::cout << "server received client msg: " << request->message() << std::endl; response->set_message( "server say: received msg: ***" + request->message() + std::string("***")); done->run(); } }; int main(int argc, char* argv[]) { rpcserver rpc_server; goya::rpc::echo::echoserver* echo_service = new echoserverimpl(); if (!rpc_server.registerservice(echo_service, false)) { std::cout << "register service failed" << std::endl; return -1; } std::string server_addr("0.0.0.0:12321"); if (!rpc_server.start(server_addr)) { std::cout << "start server failed" << std::endl; return -1; } return 0; }
echo_client.cc
int main(int argc, char* argv[]) { echo::echorequest request; echo::echoresponse response; request.set_message("hello tonull, from client"); char* ip = argv[1]; char* port = argv[2]; std::string addr = std::string(ip) + ":" + std::string(port); rpcchannel rpc_channel(addr); echo::echoserver_stub stub(&rpc_channel); rpccontroller controller; stub.echo(&controller, &request, &response, nullptr); if (controller.failed()) std::cout << "request failed: %s" << controller.errortext().c_str(); else std::cout << "resp: " << response.message() << std::endl; return 0; }
上面是一个简单的echo实例的代码,主要功能是:server端收到client发送来的消息,然后echo返回给client,功能非常简单,但是走完了整个流程。其他特性无非基于此的一些衍生。好了,我们现在来解析下这个源码,首先来看server端。
rpcserver rpc_server; goya::rpc::echo::echoserver* echo_service = new echoserverimpl(); rpc_server.registerservice(echo_service, false) rpc_server.start(server_addr)
最主要就上面四行代码,定义了两个对象rpc_server和echo_service,然后注册对象,启动服务。echoserverimpl继承于echoserver,讲到这里也许有人会问,我没有定义echoserver这个类啊,它是从哪里来的?ok,那我们这里先跳到讲解下protobuf,讲完之后再回过头来继续。
protobuf
通过socket,client和server可以互相交互消息,但这种通信效率不高,一般选择在发送的时候把消息经过序列化,而在接受的时候采用反序列化解析就可以了,本文采用谷歌开源的protobuf作为消息序列化的方法,其他序列化的方法还有json和rlp。。。
首先按照proto格式,定义消息传输的内容, echorequest为请求消息,echorequest为响应消息,在echoserver里面定义了echo方法。
syntax = "proto3"; package goya.rpc.echo; option cc_generic_services = true; message echorequest { string message = 1; } message echoresponse { string message = 1; } service echoserver { rpc echo(echorequest) returns(echoresponse); }
把定义的proto文件用protoc工具生成对应的echo_service.pb.h和 echo_service.pb.cc文件,网上有很多介绍怎么使用proto文件生成对应的pb.h和pb.c的文档,这里就不在过多描述。具体的也可以看工程里面的 sample/echo/cmakelists.txt 文件。
service echoservice这一句会生成echoservice和echoservice_stub两个类,分别是 server 端和 client 端需要关心的。
回到server
对 server 端,通过echoservice::echo来处理请求,代码未实现,需要子类来 override。
void echoservice::echo(::google::protobuf::rpccontroller* controller, const ::echo::echorequest*, ::echo::echoresponse*, ::google::protobuf::closure* done) { // 代码未实现,需要server返回给client什么内容,就在这里填写 controller->setfailed("method echo() not implemented."); done->run(); }
好了,我们现在回到上面没有讲完的server,server定义了echoserverimpl对象,实现了echo方法,功能也就是把client发送来的消息又返回给client。 server里面还没讲解完的是“注册”和“启动”服务两个功能,我们直接跳到代码讲解。
registerservice注册的功能非常简单,就是把我们自己定义的echoserverimpl对象echo_service给保存在services_这个数据结构里。
bool rpcserverimpl::registerservice(google::protobuf::service* service, bool ownership) { services_[0] = service; return true; }
start启动服务的功能也很简单,就是一个socket不断的accept远端传送过来的数据,然后进行处理。
bool rpcserverimpl::start(std::string& server_addr) { ... while (true) { auto socket = boost::make_shared<boost::asio::ip::tcp::socket>(io); acceptor.accept(*socket); std::cout << "recv from client: " << socket->remote_endpoint().address() << std::endl; int request_data_len = 256; std::vector<char> contents(request_data_len, 0); socket->receive(boost::asio::buffer(contents)); procrpcdata(std::string(&contents[0], contents.size()), socket); } }
回到client
rpcchannel rpc_channel(addr); echo::echoserver_stub stub(&rpc_channel); rpccontroller controller; stub.echo(&controller, &request, &response, nullptr);
对于client 端,最主要就上面四条语句,定义了rpcchannel、echoserver_stub、rpccontroller三个不同的对象,通过echoservice_stub来发送数据,echoservice_stub::echo调用了::google::protobuf::channel::callmethod方法,但是channel是一个纯虚类,需要 rpc 框架在子类里实现需要的功能。
class echoservice_stub : public echoservice { ... void echo(::google::protobuf::rpccontroller* controller, const ::echo::echorequest* request, ::echo::echoresponse* response, ::google::protobuf::closure* done); private: ::google::protobuf::rpcchannel* channel_; }; void echoservice_stub::echo(::google::protobuf::rpccontroller* controller, const ::echo::echorequest* request, ::echo::echoresponse* response, ::google::protobuf::closure* done) { channel_->callmethod(descriptor()->method(0), controller, request, response, done); }
也就是说,执行stub.echo(&controller, &request, &response, nullptr); 这条语句实际是执行到了
void rpcchannelimpl::callmethod(const ::google::protobuf::methoddescriptor* method, ::google::protobuf::rpccontroller* controller, const ::google::protobuf::message* request, ::google::protobuf::message* response, ::google::protobuf::closure* done) { std::string request_data = request->serializeasstring(); socket_->send(boost::asio::buffer(request_data)); int resp_data_len = 256; std::vector<char> resp_data(resp_data_len, 0); socket_->receive(boost::asio::buffer(resp_data)); response->parsefromstring(std::string(&resp_data[0], resp_data.size())); }
rpcchannelimpl::callmethod主要做了什么呢?主要两件事情:1、把request消息通过socket发送给远端;2、同时接受来自远端的reponse消息。
讲到这里基本流程就梳理的差不多了,文章开头的几个问题也基本在讲解的过程中回答了,对于后面两个问题,这里再划重点讲解下,stub.echo(&controller, &request, &response, nullptr); 最后一个参数是nullptr,这里你填啥都没啥卵用,因为在rpcchannelimpl::callmethod中根本就没使用到,而为什么又要加这个参数呢?这纯属是为了给人一种错觉:client端执行stub.echo(&controller, &request, &response, nullptr);就是调用到了server端的echoserverimpl::echo(*controller, *request, *response, *done),使远程调用看起来像本地调用一样(至少参数类型及个数是一致的)。而其实这也是最令初学者疑惑的地方。
而本质上,server端的echoserverimpl::echo(*controller, *request, *response, *done)函数其实是在接受到数据后,从这里调用过来的,具体见下面代码:
void rpcserverimpl::procrpcdata(const std::string& serialzied_data, const boost::shared_ptr<boost::asio::ip::tcp::socket>& socket) { auto service = services_[0]; auto m_descriptor = service->getdescriptor()->method(0); auto recv_msg = service->getrequestprototype(m_descriptor).new(); auto resp_msg = service->getresponseprototype(m_descriptor).new(); recv_msg->parsefromstring(serialzied_data); // 构建newcallback对象 auto done = google::protobuf::newcallback( this, &rpcserverimpl::oncallbackdone, resp_msg, socket); rpccontroller controller; service->callmethod(m_descriptor, &controller, recv_msg, resp_msg, done); }
service->callmethod(m_descriptor, &controller, recv_msg, resp_msg, done); 会调用到echoserver::callmethod,protobuf会根据method->index()找到对应的执行函数,echoserverimpl实现了echo函数,所以上面的service->callmethod(m_descriptor, &controller, recv_msg, resp_msg, done); 会执行到echoserverimpl::echo,这进一步说明了 echoserverimpl::echo 跟stub.echo()调用没有鸡毛关系,唯一有的关系,确实发起动作是stub.echo(); 中间经过了无数次解析最后确实是调到了echoserverimpl::echo。
void echoserver::callmethod(const ::protobuf_namespace_id::methoddescriptor* method, ::protobuf_namespace_id::rpccontroller* controller, const ::protobuf_namespace_id::message* request, ::protobuf_namespace_id::message* response, ::google::protobuf::closure* done) { google_dcheck_eq(method->service(), file_level_service_descriptors_echo_5fservice_2eproto[0]); switch(method->index()) { case 0: echo(controller, ::protobuf_namespace_id::internal::downcast<const ::goya::rpc::echo::echorequest*>( request), ::protobuf_namespace_id::internal::downcast<::goya::rpc::echo::echoresponse*>( response), done); break; default: google_log(fatal) << "bad method index; this should never happen."; break; } }