C ++中围绕gRPC框架的Qt包装器

大家好 今天,我们将研究如何在C ++和Qt库中链接gRPC框架。 本文提供了总结gRPC中所有四种交互模式的用法的代码。 另外,提供了允许通过Qt信号和插槽使用gRPC的代码。 本文可能主要是对使用gRPC感兴趣的Qt开发人员感兴趣的。 尽管如此,在不使用Qt的情况下,用C ++编写了gRPC四种操作模式的概括,这将使与Qt无关的开发人员可以修改代码。 我问每个有兴趣的猫。


背景知识


大约六个月前,使用gRPC的客户端和服务器部分挂起了两个项目。 这两个项目都减产。 这些项目是由已经退出的开发人员编写的。 唯一的好消息是,我积极参与了编写gRPC服务器和客户端代码的工作。 但是那是大约一年前的事。 因此,像往常一样,我必须从头开始处理所有问题。


编写gRPC服务器代码时,期望它将由.proto文件进一步生成。 代码写得很好。 但是,服务器有一个很大的缺点:只有一个客户端可以连接到它。


gRPC客户端的编写非常糟糕。


几天后,我弄清楚了客户端和服务器代码gRPC。 而且我意识到,如果我花了几个星期的时间来做项目,我将不得不再次处理服务器和gRPC客户端。


那时我决定是时候编写和调试gRPC客户端和服务器了,以便:


  • 您可以在晚上安然入睡;

  • 无需记住每次您需要编写客户端或gRPC服务器时它是如何工作的。

  • 您可以在其他项目中使用书面的gRPC客户端和服务器。


在编写代码时,我遵循以下要求:


  • gRPC客户端和服务器都可以自然地使用Qt库信号和插槽进行操作;

  • 更改.proto文件时,无需修复gRPC客户端和服务器代码;

  • gRPC客户端应该能够告诉客户端代码与服务器的连接状态。


本文的结构如下。 首先,将简要概述使用客户端代码的结果以及一些解释。 审查结束时,指向存储库的链接。 此外,在架构上还会有一些一般性的事情。 然后是服务器和客户端代码的说明(幕后花絮)和结论。


简短评论


最简单的pingproto.proto文件用作.proto文件,其中定义了所有交互类型的RPC:


syntax = "proto3"; package pingpong; service ping { rpc SayHello (PingRequest) returns (PingReply) {} rpc GladToSeeMe(PingRequest) returns (stream PingReply){} rpc GladToSeeYou(stream PingRequest) returns (PingReply){} rpc BothGladToSee(stream PingRequest) returns (stream PingReply){} } message PingRequest { string name = 1; string message = 2; } message PingReply { string message = 1; } 

pingpong.proto文件将有关C ++中异步gRPC模式的文章中的helloworld.proto文件重复到确切名称。


结果,可以像下面这样使用书面服务器:


 class A: public QObject { Q_OBJECT; QpingServerService pingservice; public: A() { bool is_ok; is_ok = connect(&pingservice, SIGNAL(SayHelloRequest(SayHelloCallData*)), this, SLOT(onSayHello(SayHelloCallData*))); assert(is_ok); is_ok = connect(&pingservice, SIGNAL(GladToSeeMeRequest(GladToSeeMeCallData*)), this, SLOT(onGladToSeeMe(GladToSeeMeCallData*))); assert(is_ok); is_ok = connect(&pingservice, SIGNAL(GladToSeeYouRequest(GladToSeeYouCallData*)), this, SLOT(onGladToSeeYou(GladToSeeYouCallData*))); assert(is_ok); is_ok = connect(&pingservice, SIGNAL(BothGladToSeeRequest(BothGladToSeeCallData*)), this, SLOT(onBothGladToSee(BothGladToSeeCallData*))); assert(is_ok); } public slots: void onSayHello(SayHelloCallData* cd) { std::cout << "[" << cd->peer() << "][11]: request: " << cd->request.name() << std::endl; cd->reply.set_message("hello " + cd->request.name()); cd->Finish(); } //etc. }; 

客户端调用RPC时,gRPC服务器会使用适当的信号通知客户端代码(在本例中为A类)。


gRPC客户端可以这样使用:


 class B : public QObject { Q_OBJECT QpingClientService pingPongSrv; public: B() { bool c = false; c = connect(&pingPongSrv, SIGNAL(SayHelloResponse(SayHelloCallData*)), this, SLOT(onSayHelloResponse(SayHelloCallData*))); assert(c); c = connect(&pingPongSrv, SIGNAL(GladToSeeMeResponse(GladToSeeMeCallData*)), this, SLOT(onGladToSeeMeResponse(GladToSeeMeCallData*))); assert(c); c = connect(&pingPongSrv, SIGNAL(GladToSeeYouResponse(GladToSeeYouCallData*)), this, SLOT(onGladToSeeYouResponse(GladToSeeYouCallData*))); assert(c); c = connect(&pingPongSrv, SIGNAL(BothGladToSeeResponse(BothGladToSeeCallData*)), this, SLOT(onBothGladToSeeResponse(BothGladToSeeCallData*))); assert(c); c = connect(&pingPongSrv, SIGNAL(channelStateChanged(int, int)), this, SLOT(onPingPongStateChanged(int, int))); assert(c); } void usage() { //Unary PingRequest request; request.set_name("user"); request.set_message("user"); pingPongSrv.SayHello(request); //Server streaming PingRequest request2; request2.set_name("user"); pingPongSrv.GladToSeeMe(request2); //etc. } public slots: void SayHelloResponse(SayHelloCallData* response) { std::cout << "[11]: reply: " << response->reply.message() << std::endl; if (response->CouldBeDeleted()) delete response; } //etc. }; 

使用gRPC客户端,您可以直接调用RPC,并使用适当的信号订阅服务器的响应。


gRPC客户端也有一个信号:

 channelStateChanged(int, int); 

报告过去和当前服务器连接状态。 所有示例代码都在qgrpc存储库中

如何运作


图中显示了将客户端和gRPC服务器包括在项目中的原理。



在.pro项目文件中,指定了.proto文件,gRPC将基于该文件工作。 grpc.pri文件包含用于生成gRPC和QgRPC文件的命令。 协议编译器生成gRPC文件[protofile] .grpc.pb.h和[protofile] .grpc.pb.cc。 [protofile]是传递给编译器输入的.proto文件的名称。


QgRPC文件[protofile] .qgrpc。[Config] .h的生成由脚本genQGrpc.py处理。 [config]是“服务器”或“客户端”。

生成的QgRPC文件包含围绕gRPC类的Qt包装器以及带有相应信号的调用。 在前面的示例中,分别在生成的文件pingpong.qgrpc.server.h和pingpong.qgrpc.client.h中声明了QpingServerService和QpingClientService类。 生成的QgRPC文件将添加到moc处理中。


在生成的QgRPC文件中,包括QGrpc [config] .h文件,所有主要工作都在其中进行。 在下面阅读有关此内容的更多信息。


要将所有此构造连接到项目,您需要在项目.pro文件中包括grpc.pri文件,并指定三个变量。 GRPC变量定义了.proto文件,这些文件将被传输到protoc编译器和genQGrpc.py脚本的输入中。 QGRPC_CONFIG变量定义生成的QgRPC文件的配置值,并且可以包含值“ server”或“ client”。 您还可以定义可选的GRPC_VERSION变量以指示gRPC的版本。


有关所有内容的更多信息,请阅读grpc.pri文件和.pro示例文件。


服务器架构


服务器类图如图所示。



粗箭头显示类继承的层次结构,细箭头显示类中成员和方法的成员资格。 通常,会为服务生成Q [servicename] ServerService类,其中servicename是.proto文件中声明的服务的名称。 RPCCallData是为服务中的每个RPC生成的控制结构。 在QpingServerService类的构造函数中,基类QGrpcServerService使用gRPC pingpong :: ping :: AsyncService异步服务初始化。 要启动该服务,您需要使用该服务将在其上运行的地址和端口来调用Start()方法。 Start()函数实现了启动服务的标准过程。


在Start()函数的末尾,将调用纯虚函数makeRequests(),该函数在生成的QpingServerService类中实现:


 void makeRequests() { needAnotherCallData< SayHello_RPCtypes, SayHelloCallData >(); needAnotherCallData< GladToSeeMe_RPCtypes, GladToSeeMeCallData >(); needAnotherCallData< GladToSeeYou_RPCtypes, GladToSeeYouCallData >(); needAnotherCallData< BothGladToSee_RPCtypes, BothGladToSeeCallData >(); } 

needAnotherCallData函数的第二个模板参数是生成的RPCCallData结构。 相同的结构是所生成的服务的Qt类中的信号参数。


生成的RPCCallData结构继承自ServerCallData类。 反过来,ServerCallData类是从ServerResponder响应器继承的。 因此,创建相干结构的对象会导致创建响应者对象。


ServerCallData类的构造函数采用两个参数:signal_func和request_func。 signal_func是生成的信号,在从队列中接收到标签后将调用该信号。 request_func是创建新响应者时应调用的函数。 例如,在这种情况下,它可能是RequestSayHello()函数。 request_func调用发生在needAnotherCallData()函数中。 这样做是为了在服务中进行响应者的管理(创建和删除)。


needAnotherCallData()函数的代码包括创建响应者对象和调用将响应者连接到RPC调用的函数:


 template<class RPCCallData, class RPCTypes> void needAnotherCallData() { RPCCallData* cd = new RPCCallData(); //... RequestRPC<RPCTypes::kind, ...> (service_, cd->request_func_, cd->responder, ..., (void*)cd); } 

RequestRPC()函数是用于四种类型的交互的模板函数。 结果,调用RequestRPC()归结为一个调用:


 service_->(cd->request_func_)(...,cd->responder, (void*)cd); 

其中service_是gRPC服务。 在这种情况下,它是pingpong :: ping :: AsyncService。


若要同步或异步检查事件队列,必须分别调用CheckCQ()或AsyncCheckCQ()函数。 CheckCQ()函数的代码可归结为从队列中调用同步标签以及对该标签的处理:


 virtual void CheckCQ() override { void* tag; bool ok; server_cq_->Next(&tag, &ok); //tagActions_ call if (!tag) return; AbstractCallData* cd = (AbstractCallData*)tag; if (!started_.load()) { destroyCallData(cd); return; } cd->cqReaction(this, ok); } 

从队列接收标签后,将检查标签的有效性和服务器启动。 如果服务器已关闭,则不再需要该标签-可以将其删除。 之后,将调用ServerCallData类中定义的cqReaction()函数:


 void cqReaction(const QGrpcServerService* service_, bool ok) { if (!first_time_reaction_) { first_time_reaction_ = true; service_->needAnotherCallData<RPC, RPCCallData>(); } auto genRpcCallData = dynamic_cast<RPCCallData*>(this); void* tag = static_cast<void*>(genRpcCallData); if (this->CouldBeDeleted()) { service_->destroyCallData(this); return; } if (!this->processEvent(tag, ok)) return; //call generated service signal with generated call data argument service_->(*signal_func_)(genRpcCallData); } 

first_time_reaction_标志指示您需要为被调用的RPC创建一个新的响应器。 函数CouldBeDeleted()和ProcessEvent()继承自相应的ServerResponder响应器类。 CouldBeDeleted()函数返回一个标志,表明可以删除响应程序对象。 processEvent()函数处理标记和ok标志。 因此,例如,对于客户端流类型的响应者,该函数如下所示:


 bool processEvent(void* tag, bool ok) { this->tag_ = tag; read_mode_ = ok; return true; } 

无论响应器的类型如何,ProcessEvent()函数始终返回true。 该函数的返回值保留为可能的功能扩展,并且从理论上讲,是为了消除错误。


处理事件后,调用如下:

 service_->(*signal_func_)(genRpcCallData); 

变量service_是所生成服务的实例,在本例中为QpingServerService。 变量signal_func_是对应于特定RPC的服务信号。 例如,SayHelloRequest()。 变量genRpcCallData是相应类型的响应者对象。 从调用代码的角度来看,变量genRpcCallData是所生成的RPCCallData结构之一的对象。


客户架构


客户端的类和功能的名称尽可能匹配服务器的类和功能的名称。 客户端类图如图所示。



粗箭头显示类继承的层次结构,细箭头显示类中成员和方法的成员资格。 通常,对于服务,将生成Q [servicename]类ClientService,其中servicename是.proto文件中声明的服务的名称。 RPCCallData是为服务中的每个RPC生成的控制结构。 要调用RPC,生成的类提供名称与.proto文件中声明的RPC完全匹配的函数。 在我们的示例中,在.proto RPC文件中,SayHello()声明为:

 rpc SayHello (PingRequest) returns (PingReply) {} 

在生成的QpingClientService类中,相应的RPC函数如下所示:


 void SayHello(PingRequest request) { if(!connected()) return; SayHelloCallData* call = new SayHelloCallData; call->request = request; call->responder = stub_->AsyncSayHello(&call->context, request, &cq_); call->responder->Finish(&call->reply, &call->status, (void*)call); } 

与服务器一样,生成的RPCCallData结构最终从ClientResponder类继承。 因此,所生成结构的对象的创建导致响应者的创建。 创建响应者后,将调用RPC,并将响应者与从服务器接收响应的事件相关联。 在客户端代码方面,RPC调用如下所示:


 void ToSayHello() { PingRequest request; request.set_name("user"); request.set_message("user"); pingPongSrv.SayHello(request); } 

与生成的QpingServerService服务器类不同,QpingClientService类从两个模板类继承:ConnectivityFeatures和MonitorFeatures。


ConnectivityFeatures类负责客户端-服务器连接的状态,并提供三个使用功能:grpc_connect(),grpc_disconnect(),grpc_reconnect()。 grpc_disconnect()函数仅删除负责与服务器交互的所有数据结构。 对grpc_connect的调用被简化为对grpc_connect_()函数的调用,该函数创建控制数据结构:


 void grpc_connect_() { channel_ = grpc::CreateChannel(target_, creds_); stub_ = GRPCService::NewStub(channel_); channelFeatures_ = std::make_unique<ChannelFeatures>(channel_); channelFeatures_->checkChannelState(); } 

ChannelFeatures类监视与服务器的channel_通道通信的状态。 ConnectivityFeatures类封装ChannelFeatures类的一个对象,并使用此对象实现抽象功能channelState(),checkChannelState()和connected()。 channelState()函数返回与服务器通信通道的最后观察到的状态。 实际上,checkChannelState()函数返回通道的当前状态。 connected()函数返回客户端连接到服务器的标志。


MonitorFeatures类负责从服务器接收和处理事件,并提供CheckCQ()函数以供使用:


 bool CheckCQ() { auto service_ = dynamic_cast< SERVICE* >(this); //connection state auto old_state = conn_->channelState(); auto new_state = conn_->checkChannelState(); if (old_state != new_state) service->*channelStateChangedSignal_(old_state, new_state); //end of connection state void* tag; bool ok = false; grpc::CompletionQueue::NextStatus st; st = cq_.AsyncNext(&tag, &ok, deadlineFromMSec(100)); if ((st == grpc::CompletionQueue::SHUTDOWN) || (st == grpc::CompletionQueue::TIMEOUT)) return false; (AbstractCallData< SERVICE >*)(tag)->cqActions(service_, ok); return true; } 

代码结构与服务器的情况相同。 与服务器不同,将负责处理当前状态的代码块添加到客户端。 如果通信通道的状态已更改,则会调用信号channelStateChangedSignal_()。 在所有生成的服务中,这是一个信号:

 void channelStateChanged(int, int); 

另外,与服务器不同,此处使用AsyncNext()函数代替Next()。 这样做有几个原因。 首先,当使用AsyncNext()时,客户端代码具有了解通信通道状态变化的能力。 其次,当使用AsyncNext()时,可以在客户端代码中多次调用各种RPC。 在这种情况下,使用Next()函数将阻塞线程,直到从队列接收到事件为止,结果将丢失所描述的两个功能。

与服务器一样,从队列接收事件后,将调用ClientCallData类中定义的cqReaction()函数:


 void cqActions(RPC::Service* service, bool ok) { auto response = dynamic_cast<RPCCallData*>(this); void* tag = static_cast<void*>(response); if (!this->processEvent(tag, ok)) return; service->*func_( response ); } 

与服务器一样,processEvent()函数处理标记和ok标志,并始终返回true。 与服务器的情况一样,在处理事件之后,应调用生成的服务的信号。 但是,同名服务器功能有两个重要区别。 第一个区别是没有在此功能中创建响应者。 如上所示,响应者的创建在调用RPC时发生。 第二个区别是此功能未删除响应者。 由于两个原因,没有删除响应者。 首先,客户端代码可以出于自身目的使用指向生成的RPCCallData结构的指针。 用此指针删除隐藏在客户端代码中的内容可能导致不愉快的后果。 其次,移除响应者将导致以下事实:将不会生成带有数据的信号。 因此,客户端代码将不会收到最后的服务器消息。 在解决指示问题的几种选择中,决定将响应者(生成的结构)的移除转移到客户端代码。 因此,信号处理程序功能(插槽)必须包含以下代码:


 void ResponseHandler(RPCCallData* response) { if (response->CouldBeDeleted()) delete response; //process response } 

如果没有在客户端代码中删除响应者,则不仅会导致内存泄漏,还会导致通信通道出现问题。 示例代码中实现了各种RPC交互的信号处理程序。


结论


总之,我们提请注意两点。 第一点与调用客户端和服务器的CheckCQ()函数有关。 如上所示,它们按照一种原理工作:如果队列中有事件,则会发出具有相应生成的RPCCallData结构的信号。 您可以手动调用此函数并检查(对于客户端)事件。 但是最初有一个想法将与gRPC相关的整个网络部分转移到另一个线程。 为此,编写了用于gRPC服务器的辅助类QGrpcSrvMonitor和用于gRPC客户端的QGrpcCliServer。 这两个类的工作原理相同:它们创建一个单独的流,将生成的服务放入此流中,并定期调用此服务的CheckCQ()函数。 因此,当使用两个辅助类时,无需在客户端代码中调用CheckCQ()函数。 在这种情况下,生成的服务的信号“来自”另一个流。 使用这些帮助程序类来实现客户端和服务器示例。


第二点涉及大多数在工作中不使用Qt库的开发人员。 QgRPC中的Qt类和宏仅在两个地方使用:在生成的服务文件中以及在包含辅助类的文件中:QGrpcServerMonitor.h和QGrpcClientMonitor.h。 与Qt库的其余文件没有任何关联。 计划使用cmake添加程序集,并添加一些Qt指令。 特别是QObject类和Q_OBJECT宏。 但是,人们对此一无所知。 因此,欢迎提出任何建议。


仅此而已。 谢谢大家!


参考文献


Source: https://habr.com/ru/post/zh-CN420237/


All Articles