Enveloppe Qt autour du framework gRPC en C ++

Bonjour à tous. Aujourd'hui, nous allons voir comment vous pouvez lier le framework gRPC en C ++ et la bibliothèque Qt. L'article fournit un code résumant l'utilisation des quatre modes d'interaction dans gRPC. De plus, un code est fourni qui permet l'utilisation de gRPC via des signaux Qt et des slots. L'article peut intéresser principalement les développeurs Qt intéressés par l'utilisation de gRPC. Néanmoins, une généralisation des quatre modes de fonctionnement de gRPC est écrite en C ++ sans utiliser Qt, ce qui permettra aux développeurs qui ne sont pas liés à Qt d'adapter le code. Je demande à tout le monde intéressé par cat.


Contexte


Il y a environ six mois, deux projets m'ont accroché, utilisant les parties client et serveur de gRPC. Les deux projets ont chuté en production. Ces projets ont été écrits par des développeurs qui ont déjà quitté. La seule bonne nouvelle est que j'ai pris une part active à l'écriture du code serveur et client gRPC. Mais c'était il y a environ un an. Par conséquent, comme d'habitude, j'ai dû tout gérer à partir de zéro.


Le code du serveur gRPC a été écrit dans l'espoir qu'il sera généré davantage par le fichier .proto. Le code a été bien écrit. Cependant, le serveur avait un gros inconvénient: un seul client pouvait s'y connecter.


Le client gRPC a été écrit juste horrible.


J'ai découvert le code client et serveur gRPC quelques jours plus tard. Et j'ai réalisé que si je prenais un projet pendant quelques semaines, je devrais à nouveau traiter avec le serveur et le client gRPC.


C'est alors que j'ai décidé qu'il était temps d'écrire et de déboguer le client et le serveur gRPC afin que:


  • Vous pouvez dormir paisiblement la nuit;

  • Il n'était pas nécessaire de se rappeler comment cela fonctionne à chaque fois que vous devez écrire un client ou un serveur gRPC;

  • Vous pouvez utiliser le client et le serveur gRPC écrits dans d'autres projets.


Lors de l'écriture de code, j'ai été guidé par les exigences suivantes:


  • Le client et le serveur gRPC peuvent fonctionner de manière naturelle en utilisant les signaux et les emplacements de la bibliothèque Qt;

  • Le code client et serveur gRPC n'a pas besoin d'être corrigé lors de la modification du fichier .proto;

  • Le client gRPC doit pouvoir indiquer au code client l'état de la connexion au serveur.


La structure de l'article est la suivante. Tout d'abord, il y aura un bref aperçu des résultats de l'utilisation du code client et quelques explications. À la fin de l'examen, un lien vers le référentiel. De plus, il y aura des choses générales sur l'architecture. Ensuite, une description du serveur et du code client (ce qui est sous le capot) et une conclusion.


Brève revue


Le fichier pingproto.proto le plus simple a été utilisé comme fichier .proto, dans lequel des RPC de tous les types d'interaction ont été définis:


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; } 

Le fichier pingpong.proto répète le fichier helloworld.proto de l'article sur les modes gRPC asynchrones en C ++ jusqu'au nom exact.


En conséquence, un serveur écrit peut être utilisé comme ceci:


 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. }; 

Lorsqu'un client appelle RPC, le serveur gRPC notifie le code client (dans ce cas, la classe A) avec le signal approprié.


Le client gRPC peut être utilisé comme ceci:


 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. }; 

Le client gRPC vous permet d'appeler directement RPC et de vous abonner à la réponse du serveur en utilisant les signaux appropriés.


Le client gRPC a également un signal:

 channelStateChanged(int, int); 

qui rend compte des états de connexion passés et actuels du serveur. Tous les exemples de code se trouvent dans le référentiel qgrpc .

Comment ça marche


Le principe de l'inclusion du client et du serveur gRPC dans le projet est illustré dans la figure.



Dans le fichier de projet .pro, les fichiers .proto sont spécifiés, sur la base desquels gRPC fonctionnera. Le fichier grpc.pri contient des commandes pour générer des fichiers gRPC et QgRPC. Le compilateur de protocole génère des fichiers gRPC [protofile] .grpc.pb.h et [protofile] .grpc.pb.cc. [protofile] est le nom du fichier .proto passé à l'entrée du compilateur.


La génération des fichiers QgRPC [protofile] .qgrpc. [Config] .h est gérée par le script genQGrpc.py. [config] est soit «serveur» soit «client».

Les fichiers QgRPC générés contiennent un wrapper Qt autour des classes gRPC et des appels avec les signaux correspondants. Dans les exemples précédents, les classes QpingServerService et QpingClientService sont déclarées respectivement dans les fichiers générés pingpong.qgrpc.server.h et pingpong.qgrpc.client.h. Les fichiers QgRPC générés sont ajoutés au traitement moc.


Dans les fichiers QgRPC générés, les fichiers QGrpc [config] .h sont inclus, dans lesquels tout le travail principal a lieu. En savoir plus à ce sujet ci-dessous.


Pour connecter toute cette construction au projet, vous devez inclure le fichier grpc.pri dans le fichier .pro du projet et spécifier trois variables. La variable GRPC définit les fichiers .proto qui seront transférés aux entrées du compilateur de protocole et du script genQGrpc.py. La variable QGRPC_CONFIG définit la valeur de configuration des fichiers QgRPC générés et peut contenir les valeurs «serveur» ou «client». Vous pouvez également définir la variable facultative GRPC_VERSION pour indiquer la version de gRPC.


Pour plus d'informations sur tout ce qui est dit, lisez le fichier grpc.pri et les fichiers d'exemple .pro.


Architecture de serveur


Le diagramme de classe du serveur est illustré dans la figure.



Les flèches épaisses indiquent la hiérarchie de l'héritage de classe et les flèches fines indiquent l'appartenance des membres et des méthodes aux classes. En général, la classe Q [servicename] ServerService est générée pour le service, où servicename est le nom du service déclaré dans le fichier .proto. RPCCallData sont des structures de contrôle générées pour chaque RPC du service. Dans le constructeur de la classe QpingServerService, la classe de base QGrpcServerService est initialisée avec le service asynchrone gRPC pingpong :: ping :: AsyncService. Pour démarrer le service, vous devez appeler la méthode Start () avec l'adresse et le port sur lesquels le service s'exécutera. La fonction Start () implémente la procédure standard pour démarrer un service.


À la fin de la fonction Start (), la fonction virtuelle pure makeRequests () est appelée, qui est implémentée dans la classe QpingServerService générée:


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

Le deuxième paramètre de modèle de la fonction needAnotherCallData est les structures RPCCallData générées. Les mêmes structures sont les paramètres des signaux dans la classe Qt générée du service.


Les structures RPCCallData générées héritent de la classe ServerCallData. À son tour, la classe ServerCallData est héritée du répondeur ServerResponder. Ainsi, la création d'un objet de structures cohérentes conduit à la création d'un objet répondeur.


Le constructeur de la classe ServerCallData prend deux paramètres: signal_func et request_func. signal_func est un signal généré qui est appelé après avoir reçu une balise de la file d'attente. request_func est une fonction qui doit être appelée lors de la création d'un nouveau répondeur. Par exemple, dans ce cas, il peut s'agir de la fonction RequestSayHello (). L'appel request_func se produit dans la fonction needAnotherCallData (). Ceci est fait pour que la gestion des répondants (création et suppression) se fasse dans le service.


Le code de la fonction needAnotherCallData () consiste à créer un objet répondeur et à appeler une fonction qui connecte le répondeur à un appel RPC:


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

Les fonctions RequestRPC () sont des fonctions de modèle pour quatre types d'interaction. Par conséquent, l'appel de RequestRPC () se résume à un appel:


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

où service_ est le service gRPC. Dans ce cas, il s'agit de pingpong :: ping :: AsyncService.


Pour vérifier de manière synchrone ou asynchrone la file d'attente d'événements, vous devez appeler les fonctions CheckCQ () ou AsyncCheckCQ (), respectivement. Le code de la fonction CheckCQ () se résume aux appels à la balise synchrone de la file d'attente et au traitement de cette balise:


 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); } 

Après avoir reçu la balise de la file d'attente, la validité de la balise et le démarrage du serveur sont vérifiés. Si le serveur est éteint, la balise n'est plus nécessaire - elle peut être supprimée. Après cela, la fonction cqReaction () définie dans la classe ServerCallData est appelée:


 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); } 

L'indicateur first_time_reaction_ indique que vous devez créer un nouveau répondeur pour le RPC appelé. Les fonctions CouldBeDeleted () et ProcessEvent () sont héritées de la classe de répondeur ServerResponder correspondante. La fonction Can'tBeDeleted () renvoie un signe que l'objet répondeur peut être supprimé. La fonction processEvent () traite la balise et le drapeau ok. Ainsi, par exemple, pour un répondeur de type Client Streaming, la fonction ressemble à ceci:


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

La fonction ProcessEvent (), quel que soit le type de répondeur, renvoie toujours true. La valeur de retour de cette fonction est laissée pour une éventuelle extension de fonctionnalité et, théoriquement, pour éliminer les erreurs.


Après avoir traité l'événement, l'appel suit:

 service_->(*signal_func_)(genRpcCallData); 

La variable service_ est une instance du service généré, dans notre cas QpingServerService. La variable signal_func_ est un signal de service correspondant à un RPC spécifique. Par exemple, SayHelloRequest (). La variable genRpcCallData est un objet répondeur du type correspondant. Du point de vue du code appelant, la variable genRpcCallData est un objet de l'une des structures RPCCallData générées.


Architecture client


Dans la mesure du possible, les noms des classes et fonctions du client correspondent aux noms des classes et fonctions du serveur. Le diagramme de classe client est illustré dans la figure.



Les flèches épaisses indiquent la hiérarchie de l'héritage de classe et les flèches fines indiquent l'appartenance des membres et des méthodes aux classes. En général, pour le service, la classe Q [nom_service] ClientService est générée, où nom_service est le nom du service déclaré dans le fichier .proto. RPCCallData sont des structures de contrôle générées pour chaque RPC du service. Pour appeler un RPC, la classe générée fournit des fonctions dont les noms correspondent exactement au RPC déclaré dans le fichier .proto. Dans notre exemple, dans le fichier RPC .proto, SayHello () est déclaré comme:

 rpc SayHello (PingRequest) returns (PingReply) {} 

Dans la classe QpingClientService générée, la fonction RPC correspondante ressemble à ceci:


 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); } 

Les structures RPCCallData générées, comme dans le cas du serveur, sont finalement héritées de la classe ClientResponder. Par conséquent, la création d'un objet de la structure générée conduit à la création d'un répondeur. Après avoir créé le répondeur, le RPC est appelé et le répondeur est associé à l'événement de réception d'une réponse du serveur. En termes de code client, un appel RPC ressemble à ceci:


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

Contrairement à la classe de serveur QpingServerService générée, la classe QpingClientService hérite de deux classes de modèle: ConnectivityFeatures et MonitorFeatures.


La classe ConnectivityFeatures est responsable de l'état de la connexion client-serveur et fournit trois fonctions à utiliser: grpc_connect (), grpc_disconnect (), grpc_reconnect (). La fonction grpc_disconnect () supprime simplement toutes les structures de données responsables de l'interaction avec le serveur. L'appel à grpc_connect est réduit aux appels à la fonction grpc_connect_ (), qui crée des structures de données de contrôle:


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

La classe ChannelFeatures surveille l'état de la communication channel_ channel avec le serveur. La classe ConnectivityFeatures encapsule un objet de la classe ChannelFeatures et implémente les fonctions abstraites channelState (), checkChannelState () et connected () à l'aide de cet objet. La fonction channelState () renvoie le dernier état observé du canal de communication avec le serveur. En fait, la fonction checkChannelState () renvoie l'état actuel du canal. La fonction connected () renvoie le signe du client se connectant au serveur.


La classe MonitorFeatures est responsable de la réception et du traitement des événements du serveur et fournit la fonction CheckCQ () à utiliser:


 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; } 

La structure du code est la même que dans le cas du serveur. Contrairement au serveur, un bloc de code responsable du traitement de l'état actuel est ajouté au client. Si l'état du canal de communication a changé, le signal channelStateChangedSignal_ () est appelé. Dans tous les services générés, c'est un signal:

 void channelStateChanged(int, int); 

De plus, contrairement au serveur, la fonction AsyncNext () est utilisée ici au lieu de Next (). Cela a été fait pour plusieurs raisons. Premièrement, lors de l'utilisation d'AsyncNext (), le code client a la possibilité de connaître le changement d'état du canal de communication. Deuxièmement, lorsque vous utilisez AsyncNext (), il est possible d'appeler divers RPC dans le code client autant de fois que nécessaire. L'utilisation de la fonction Next () dans ce cas bloquera le thread jusqu'à ce qu'un événement soit reçu de la file d'attente et, par conséquent, perdra les deux fonctionnalités décrites.

Après avoir reçu l'événement de la file d'attente, comme dans le cas du serveur, la fonction cqReaction (), définie dans la classe ClientCallData, est appelée:


 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 ); } 

Comme avec le serveur, la fonction processEvent () traite la balise et le drapeau ok et renvoie toujours true. Comme dans le cas du serveur, après traitement de l'événement, le signal du service généré doit être appelé. Cependant, il existe deux différences importantes par rapport à la fonction serveur du même nom. La première différence est que les répondeurs ne sont pas créés dans cette fonction. La création de répondeurs, comme indiqué ci-dessus, se produit lorsque le RPC est appelé. La deuxième différence est que les répondeurs ne sont pas supprimés dans cette fonction. L'absence de destitution des intervenants se fait pour deux raisons. Tout d'abord, le code client peut utiliser des pointeurs pour générer des structures RPCCallData à leurs propres fins. La suppression de contenu par ce pointeur, caché du code client, peut entraîner des conséquences désagréables. Deuxièmement, la suppression du répondeur entraînera le fait qu'aucun signal contenant des données ne sera généré. Par conséquent, le code client ne recevra pas le dernier message du serveur. Parmi plusieurs alternatives pour résoudre les problèmes indiqués, la décision a été prise de déplacer la suppression du répondeur (structures générées) vers le code client. Ainsi, les fonctions de gestionnaire de signaux (slots) doivent contenir le code suivant:


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

L'absence de suppression du répondeur dans le code client entraînera non seulement une fuite de mémoire, mais également des problèmes éventuels avec le canal de communication. Les gestionnaires de signaux de toutes sortes d'interactions RPC sont implémentés dans l'exemple de code.


Conclusion


En conclusion, nous attirons l'attention sur deux points. Le premier point est lié à l'appel des fonctions CheckCQ () du client et du serveur. Ils fonctionnent, comme illustré ci-dessus, selon un principe: s'il y a un événement dans la file d'attente, un signal avec la structure RPCCallData générée correspondante est «émis». Vous pouvez appeler cette fonction manuellement et rechercher (dans le cas d'un client) un événement. Mais au départ, l'idée était de transférer la totalité de la partie réseau associée à gRPC vers un autre thread. À ces fins, les classes auxiliaires QGrpcSrvMonitor pour le serveur gRPC et QGrpcCliServer pour le client gRPC ont été écrites. Les deux classes fonctionnent sur le même principe: elles créent un flux séparé, placent le service généré dans ce flux et appellent périodiquement la fonction CheckCQ () de ce service. Ainsi, lors de l'utilisation des deux classes auxiliaires, il n'est pas nécessaire d'appeler les fonctions CheckCQ () dans le code client. Les signaux du service généré, dans ce cas, «proviennent» d'un autre flux. Des exemples de clients et de serveurs sont implémentés à l'aide de ces classes d'assistance.


Le deuxième point concerne la majorité des développeurs qui n'utilisent pas la bibliothèque Qt dans leur travail. Les classes et macros Qt dans QgRPC ne sont utilisées qu'à deux endroits: dans les fichiers de service générés et dans les fichiers contenant des classes auxiliaires: QGrpcServerMonitor.h et QGrpcClientMonitor.h. Les fichiers restants avec la bibliothèque Qt ne sont en aucun cas associés. Il était prévu d'ajouter un assemblage à l'aide de cmake et de bloquer certaines directives Qt. En particulier, la classe QObject et la macro Q_OBJECT. Mais les mains n’y sont pas parvenues. Par conséquent, toute suggestion est la bienvenue.


C’est tout. Merci à tous!


Les références


Source: https://habr.com/ru/post/fr420237/


All Articles