Hello. Today we will look at how to link the gRPC framework in C ++ and the Qt library. The article provides a code summarizing the use of all four interaction modes in gRPC. In addition, there is a code that allows gRPC to be used through Qt signals and slots. The article may be of interest primarily to Qt developers interested in using gRPC. However, a summary of the four gRPC modes of operation is written in C ++ without using Qt, which will allow developers to adapt the code to non-Qt developers. All interested in asking under the cat.
About half a year ago, two projects hung on me, using the client and server parts of gRPC. Both projects fell into production. These projects were written by developers who have already quit. I was glad only that I was actively involved in writing the gRPC server and client code. But that was about a year ago. Therefore, as usual, I had to deal with everything from scratch.
The gRPC server code was written with the expectation that it will be further generated from the .proto file. The code was written well. However, the server had one big disadvantage: only one client could connect to it.
The gRPC client was written terribly.
I figured out the client and server code for gRPC only a few days later. And I realized that if I had taken a project for a couple of weeks, I would have to deal with the gRPC server and client again.
It was then that I decided that it’s time to write and debug the gRPC client and server so that:
You could sleep at night;
There was no need to remember how this works every time you need to write a gRPC client or server;
It was possible to use the written client and server gRPC in other projects.
When writing code, I was guided by the following requirements:
Both the gRPC client and server can work using the signals and slots of the Qt library in a natural way;
The gRPC client and server code does not need to be corrected when the .proto file is changed;
The gRPC client must be able to tell the client code the status of the connection to the server.
The structure of the article is as follows. First, there will be a brief overview of the results of working with client code and small explanations to it. At the end of the review link to the repository. Next will be general things about architecture. Then a description of the server and client code (what is under the hood) and the conclusion.
The simplest pingproto.proto file was used as the .proto file, which defines the RPC of all types of interaction:
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; }
The pingpong.proto file repeats the file helloworld.proto from the article about the asynchronous modes of gRPC in C ++, up to names.
As a result, the written server can be used like this:
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. };
When a client calls an RPC, the gRPC server notifies the client code (in this case, class A) with the appropriate signal.
The gRPC client can be used like this:
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. };
The gRPC client allows you to call RPC directly, and subscribe to the server's response using the appropriate signals.
The gRPC client also has a signal:
channelStateChanged(int, int);
The principle of including the client and gRPC server in the project is shown in the figure.
The .pro project file contains the .proto files on which gRPC will work. The grpc.pri file contains commands for generating gRPC and QgRPC files. The protoc compiler generates [protofile] .grpc.pb.h and [protofile] .grpc.pb.cc files. [protofile] is the name of the .proto file passed to the input of the compiler.
The generation of QgRPC files [protofile] .qgrpc. [Config] .h is handled by the script genQGrpc.py. [config] is either "server" or "client".
')
The generated QgRPC files contain a Qt wrapper around the gRPC classes and calls with the appropriate signals. In previous examples, the QpingServerService and QpingClientService classes are declared respectively in the generated pingpong.qgrpc.server.h and pingpong.qgrpc.client.h files. The generated QgRPC files are added to moc processing.
In the generated QgRPC files, QGrpc [config] .h files are included, in which all the main work takes place. Read more about this below.
To connect all this construction to the project, in the .pro project file you need to include the grpc.pri file and specify three variables. The GRPC variable defines the .proto files to be transferred to the inputs of the protoc compiler and the genQGrpc.py script. The variable QGRPC_CONFIG determines the configuration value of the generated QgRPC files and may contain the values ​​“server” or “client”. You can also define an optional variable GRPC_VERSION to indicate the version of gRPC.
For more information on all this, read the grpc.pri file and the .pro sample files.
The class diagram of the server is shown in the figure.
Thick arrows show the class inheritance hierarchy, and thin ones show class members and methods belonging. In general, a service is generated for the service class Q [servicename] ServerService, where servicename is the name of the service declared in the .proto file. RPCCallData are control structures generated for each RPC in the service. In the constructor of the QpingServerService class, the base class QGrpcServerService is initialized by the asynchronous service gRPC pingpong :: ping :: AsyncService. To start the service, you need to call the Start () method with the address and port on which the service will run. The Start () function implements the standard service start procedure.
At the end of the Start () function, the pure virtual function makeRequests () is called, which is implemented in the generated QpingServerService class:
void makeRequests() { needAnotherCallData< SayHello_RPCtypes, SayHelloCallData >(); needAnotherCallData< GladToSeeMe_RPCtypes, GladToSeeMeCallData >(); needAnotherCallData< GladToSeeYou_RPCtypes, GladToSeeYouCallData >(); needAnotherCallData< BothGladToSee_RPCtypes, BothGladToSeeCallData >(); }
The second template parameter of the needAnotherCallData function is the generated RPCCallData structures. These same structures are the parameters of the signals in the generated class of the Qt service.
The generated RPCCallData structures are inherited from the ServerCallData class. In turn, the ServerCallData class is inherited from the ServerResponder responder. Thus, the creation of an object of generated structures leads to the creation of a responder object.
The constructor for the ServerCallData class takes two parameters: signal_func and request_func. signal_func is a generated signal that is called after the tag has been received from the queue. request_func is a function that should be called when creating a new responder. For example, in this case, it could be the RequestSayHello () function. The call to request_func occurs exactly in the needAnotherCallData () function. This is done so that responder management (creation and deletion) takes place in the service.
The needAnotherCallData () function code consists of creating a responder object and calling a function that associates a responder with an RPC call:
template<class RPCCallData, class RPCTypes> void needAnotherCallData() { RPCCallData* cd = new RPCCallData(); //... RequestRPC<RPCTypes::kind, ...> (service_, cd->request_func_, cd->responder, ..., (void*)cd); }
RequestRPC () functions are template functions for four kinds of interactions. As a result, the RequestRPC () call is reduced to a call:
service_->(cd->request_func_)(...,cd->responder, (void*)cd);
where service_ is a gRPC service. In this case, it is pingpong :: ping :: AsyncService.
To synchronously or asynchronously check the event queue, you must call the CheckCQ () or AsyncCheckCQ () functions, respectively. The function code CheckCQ () is reduced to calls to the function of synchronous retrieval of a tag from a queue and processing of this tag:
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); }
After receiving the tag from the queue, the validity of the tag and the start of the server are checked. If the server is turned off, then the tag is no longer needed - you can delete it. After that, the cqReaction () function is called, defined in the ServerCallData class:
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); }
The flag first_time_reaction_ says that you need to create a new responder for the called RPC. The CouldBeDeleted () and ProcessEvent () functions are inherited from the corresponding type of Responder ServerResponder class. The CouldBeDeleted () function returns a sign that the responder object can be deleted. The processEvent () function processes the tag and the ok flag. So, for example, for the Client Streaming view responder, the function looks like this:
bool processEvent(void* tag, bool ok) { this->tag_ = tag; read_mode_ = ok; return true; }
The ProcessEvent () function, regardless of the type of responder, always returns true. The return value of this function is left for possible extension of the functionality and, theoretically, to eliminate errors.
After processing the event, the call follows:
service_->(*signal_func_)(genRpcCallData);
The variable service_ is an instance of the generated service, in our case QpingServerService. The variable signal_func_ is a service signal corresponding to a specific RPC. For example, SayHelloRequest (). The variable genRpcCallData is the responder object of the corresponding type. From the perspective of the calling code, the genRpcCallData variable is an object of one of the generated RPCCallData structures.
Whenever possible, the names of the classes and functions of the client coincide with the names of the classes and functions of the server. The client class diagram is shown in the figure.
Thick arrows show the class inheritance hierarchy, and thin ones show class members and methods belonging. In the general case, a Q [servicename] ClientService class is generated for the service, where servicename is the name of the service declared in the .proto file. RPCCallData are control structures generated for each RPC in the service. For an RPC call, the generated class provides functions whose names exactly match the RPC declared in the .proto file. In our example, in the .proto file, the RPC SayHello () is declared as:
rpc SayHello (PingRequest) returns (PingReply) {}
In the generated QpingClientService class, the corresponding RPC function looks like this:
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); }
The generated RPCCallData structures, as in the case of the server, are ultimately inherited from the ClientResponder class. Therefore, creating an object of the generated structure leads to the creation of a responder. After the responder is created, an RPC call is made and the responder is bound to the event of receiving a response from the server. From the point of view of client code, the RPC call looks like this:
void ToSayHello() { PingRequest request; request.set_name("user"); request.set_message("user"); pingPongSrv.SayHello(request); }
Unlike the generated QpingServerService server class, the QpingClientService class is inherited from two template classes: ConnectivityFeatures and MonitorFeatures.
The ConnectivityFeatures class is responsible for connecting the client to the server and provides three functions for use: grpc_connect (), grpc_disconnect (), grpc_reconnect (). The grpc_disconnect () function simply deletes all the data structures responsible for interacting with the server. A call to the grpc_connect function is reduced to calls to the grpc_connect_ () function, which creates control data structures:
void grpc_connect_() { channel_ = grpc::CreateChannel(target_, creds_); stub_ = GRPCService::NewStub(channel_); channelFeatures_ = std::make_unique<ChannelFeatures>(channel_); channelFeatures_->checkChannelState(); }
The ChannelFeatures class monitors the state of the channel_ communication channel with the server. The ConnectivityFeatures class encapsulates an object of the ChannelFeatures class and uses this object to implement the abstract functions channelState (), checkChannelState () and connected (). The channelState () function returns the last observed state of the communication channel with the server. The checkChannelState () function, in fact, returns the current state of the channel. The connected () function returns the sign of the client connecting to the server.
The MonitorFeatures class is responsible for receiving and processing events from the server and provides the CheckCQ () function for use:
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; }
The code structure is the same as in the server case. Unlike the server, a block of code is added to the client that is responsible for handling the current state. If the link status has changed, the signal is called channelStateChangedSignal_ (). In all generated services, this is a signal:
void channelStateChanged(int, int);
After receiving the event from the queue, as in the case of the server, the cqReaction () function, defined in the ClientCallData class, is called:
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 ); }
As with the server, the processEvent () function processes the tag and the ok flag and always returns true. As in the case of the server, after the event is handled, the signal of the generated service is called. However, there are two significant differences from the eponymous server function. The first difference is that the creation of responders does not occur in this function. Creation of responders, as shown above, occurs when calling RPC. The second difference is that in this function the responders are not deleted. The lack of deletion of responders is done for two reasons. First, client code can use pointers to the generated RPCCallData structures for their own purposes. Deleting the contents of this pointer, hidden from the client code, can lead to unpleasant consequences. Secondly, deleting the responder will cause the data signal not to be generated. Consequently, the client code will not receive the last server message. Among several alternative solutions to the indicated problems, a solution was chosen to shift the deletion of the responder (generated structures) to the client code. Thus, signal handler functions (slots) must contain the following code:
void ResponseHandler(RPCCallData* response) { if (response->CouldBeDeleted()) delete response; //process response }
Failure to delete the responder in the client code will lead not only to a memory leak, but also to possible problems with the communication channel. Signal handlers of all types of RPC interactions are implemented in the example code.
In conclusion, we note two points. The first point is related to calling the CheckCQ () functions of the client and server. They work, as shown above, according to one principle: if there is an event in the queue, a signal with the corresponding generated RPCCallData structure is “emitted”. You can call this function manually and check (in the case of a client) the presence of an event. But initially the idea was to transfer the entire network portion associated with gRPC to another stream. For these purposes, QGrpcSrvMonitor auxiliary classes for the gRPC server and QGrpcCliServer for the gRPC client were written. Both classes work on the same principle: they create a separate stream, put the generated service into this stream, and periodically call the CheckCQ () function of this service. Thus, when using both auxiliary classes, there is no need to call CheckCQ () functions in the client code. The signals of the generated service, in this case, "come" from another thread. Client and server examples are implemented using these helper classes.
The second point concerns the majority of developers who do not use the Qt library in their work. Qt classes and macros in QgRPC are used only in two places: in the generated service files, and in files containing helper classes: QGrpcServerMonitor.h and QGrpcClientMonitor.h. The rest of the files with the Qt library are not related. It was planned to add an assembly using cmake, and stub some Qt directives. In particular, the QObject class and the Q_OBJECT macro. But this simply did not reach the hands. Therefore, any suggestions are welcome.
That's all. Thanks to all!
Source: https://habr.com/ru/post/420237/
All Articles