Qt wrapper alrededor del marco gRPC en C ++

Hola a todos Hoy veremos cómo puede vincular el marco gRPC en C ++ y la biblioteca Qt. El artículo proporciona un código que resume el uso de los cuatro modos de interacción en gRPC. Además, se proporciona un código que permite el uso de gRPC a través de señales y ranuras Qt. El artículo puede ser de interés principalmente para los desarrolladores de Qt interesados ​​en usar gRPC. Sin embargo, una generalización de los cuatro modos operativos de gRPC está escrita en C ++ sin usar Qt, lo que permitirá a los desarrolladores que no están relacionados con Qt adaptar el código. Le pregunto a todos los interesados ​​bajo cat.


Antecedentes


Hace unos seis meses, dos proyectos me colgaron, usando las partes cliente y servidor de gRPC. Ambos proyectos cayeron en producción. Estos proyectos fueron escritos por desarrolladores que ya han renunciado. La única buena noticia fue que participé activamente en la escritura del servidor gRPC y el código del cliente. Pero eso fue hace aproximadamente un año. Por lo tanto, como siempre, tuve que lidiar con todo desde cero.


El código del servidor gRPC se escribió con la expectativa de que el archivo .proto lo generará aún más. El código fue bien escrito. Sin embargo, el servidor tenía un gran inconveniente: solo un cliente podía conectarse a él.


El cliente gRPC fue escrito simplemente horrible.


Descubrí el código de cliente y servidor gRPC solo unos días después. Y me di cuenta de que si tomaba un proyecto durante un par de semanas, tendría que volver a tratar con el servidor y el cliente gRPC.


Fue entonces cuando decidí que era hora de escribir y depurar el cliente y el servidor gRPC para que:


  • Podrías dormir tranquilo por la noche;

  • No era necesario recordar cómo funciona esto cada vez que necesita escribir un cliente o servidor gRPC;

  • Puede usar el cliente y servidor gRPC escrito en otros proyectos.


Al escribir código, me guiaron los siguientes requisitos:


  • Tanto el cliente como el servidor gRPC pueden operar utilizando las señales y ranuras de la biblioteca Qt de forma natural;

  • El código de cliente y servidor gRPC no necesita repararse al cambiar el archivo .proto;

  • El cliente gRPC debería poder indicarle al código del cliente el estado de la conexión al servidor.


La estructura del artículo es la siguiente. Primero, habrá una breve descripción de los resultados de trabajar con el código del cliente y algunas explicaciones. Al final de la revisión, un enlace al repositorio. Además habrá cosas generales sobre arquitectura. Luego, una descripción del código del servidor y del cliente (lo que está debajo del capó) y una conclusión.


Breve reseña


El archivo pingproto.proto más simple se usó como un archivo .proto, en el que se definieron los RPC de todos los tipos de interacción:


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

El archivo pingpong.proto repite el archivo helloworld.proto del artículo sobre modos asincrónicos de gRPC en C ++ con el nombre exacto.


Como resultado, un servidor escrito se puede usar así:


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

Cuando un cliente llama a RPC, el servidor gRPC notifica el código del cliente (en este caso, clase A) con la señal adecuada.


El cliente gRPC se puede usar así:


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

El cliente gRPC le permite llamar a RPC directamente y suscribirse a la respuesta del servidor utilizando las señales apropiadas.


El cliente gRPC también tiene una señal:

 channelStateChanged(int, int); 

que informa sobre los estados de conexión del servidor pasados ​​y actuales. Todo el código de muestra está en el repositorio qgrpc .

Como funciona


El principio de incluir el cliente y el servidor gRPC en el proyecto se muestra en la figura.



En el archivo de proyecto .pro, se especifican los archivos .proto, en función de los cuales funcionará gRPC. El archivo grpc.pri contiene comandos para generar archivos gRPC y QgRPC. El compilador de protocolos genera archivos gRPC [protofile] .grpc.pb.h y [protofile] .grpc.pb.cc. [protofile] es el nombre del archivo .proto pasado a la entrada del compilador.


La generación de archivos QgRPC [protofile] .qgrpc. [Config] .h es manejada por el script genQGrpc.py. [config] es "servidor" o "cliente".

Los archivos QgRPC generados contienen un contenedor Qt alrededor de las clases gRPC y las llamadas con las señales correspondientes. En los ejemplos anteriores, las clases QpingServerService y QpingClientService se declaran en los archivos generados pingpong.qgrpc.server.h y pingpong.qgrpc.client.h respectivamente. Los archivos QgRPC generados se agregan al procesamiento moc.


En los archivos QgRPC generados, se incluyen los archivos QGrpc [config] .h, en los que se lleva a cabo todo el trabajo principal. Lea más sobre esto a continuación.


Para conectar toda esta construcción al proyecto, debe incluir el archivo grpc.pri en el archivo .pro del proyecto y especificar tres variables. La variable GRPC define archivos .proto que se transferirán a las entradas del compilador de protocolos y el script genQGrpc.py. La variable QGRPC_CONFIG define el valor de configuración de los archivos QgRPC generados y puede contener los valores "servidor" o "cliente". También puede definir la variable opcional GRPC_VERSION para indicar la versión de gRPC.


Para obtener más información sobre todo lo dicho, lea el archivo grpc.pri y los archivos de ejemplo .pro.


Arquitectura del servidor


El diagrama de clase del servidor se muestra en la figura.



Las flechas gruesas muestran la jerarquía de la herencia de clases, y las flechas delgadas muestran la membresía de miembros y métodos en las clases. En general, la clase Q [servicename] ServerService se genera para el servicio, donde servicename es el nombre del servicio declarado en el archivo .proto. RPCCallData son estructuras de control generadas para cada RPC en el servicio. En el constructor de la clase QpingServerService, la clase base QGrpcServerService se inicializa con el servicio asincrónico pingpong :: ping :: AsyncService de gRPC. Para iniciar el servicio, debe llamar al método Start () con la dirección y el puerto en el que se ejecutará el servicio. La función Start () implementa el procedimiento estándar para iniciar un servicio.


Al final de la función Start (), se llama a la función virtual pura makeRequests (), que se implementa en la clase QpingServerService generada:


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

El segundo parámetro de plantilla de la función needAnotherCallData son las estructuras RPCCallData generadas. Las mismas estructuras son los parámetros de las señales en la clase Qt generada del servicio.


Las estructuras RPCCallData generadas heredan de la clase ServerCallData. A su vez, la clase ServerCallData se hereda del servidor de respuesta ServerResponder. Por lo tanto, la creación de un objeto de estructuras coherentes conduce a la creación de un objeto respondedor.


El constructor para la clase ServerCallData toma dos parámetros: signal_func y request_func. signal_func es una señal generada que se llama después de recibir una etiqueta de la cola. request_func es una función que debería llamarse al crear un nuevo respondedor. Por ejemplo, en este caso puede ser la función RequestSayHello (). La llamada request_func ocurre en la función needAnotherCallData (). Esto se hace para que la gestión de los encuestados (creación y eliminación) tenga lugar en el servicio.


El código de la función needAnotherCallData () consiste en crear un objeto respondedor y llamar a una función que conecta al respondedor a una llamada RPC:


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

Las funciones RequestRPC () son funciones de plantilla para cuatro tipos de interacción. Como resultado, llamar a RequestRPC () se reduce a una llamada:


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

donde service_ es el servicio gRPC. En este caso, es pingpong :: ping :: AsyncService.


Para verificar de forma sincrónica o asincrónica la cola de eventos, debe llamar a las funciones CheckCQ () o AsyncCheckCQ (), respectivamente. El código de la función CheckCQ () se reduce a las llamadas a la etiqueta síncrona desde la cola y el procesamiento de esta etiqueta:


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

Después de recibir la etiqueta de la cola, se verifica la validez de la etiqueta y el inicio del servidor. Si el servidor está apagado, la etiqueta ya no es necesaria; se puede eliminar. Después de eso, la función cqReaction () definida en la clase ServerCallData se llama:


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

El indicador first_time_reaction_ indica que necesita crear un nuevo respondedor para el RPC llamado. Las funciones couldBeDeleted () y ProcessEvent () se heredan de la clase de respuesta ServerResponder correspondiente. La función couldBeDeleted () devuelve una señal de que el objeto de respuesta puede ser eliminado. La función processEvent () procesa la etiqueta y el indicador ok. Entonces, por ejemplo, para un respondedor de tipo Streaming de cliente, la función se ve así:


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

La función ProcessEvent (), independientemente del tipo de respondedor, siempre devuelve verdadero. El valor de retorno de esta función se deja para una posible extensión de funcionalidad y, en teoría, para eliminar errores.


Después de procesar el evento, la llamada sigue:

 service_->(*signal_func_)(genRpcCallData); 

La variable service_ es una instancia del servicio generado, en nuestro caso QpingServerService. La variable signal_func_ es una señal de servicio correspondiente a un RPC específico. Por ejemplo, SayHelloRequest (). La variable genRpcCallData es un objeto de respuesta del tipo correspondiente. Desde el punto de vista del código de llamada, la variable genRpcCallData es un objeto de una de las estructuras RPCCallData generadas.


Arquitectura del cliente


Siempre que sea posible, los nombres de las clases y funciones del cliente coinciden con los nombres de las clases y funciones del servidor. El diagrama de clase del cliente se muestra en la figura.



Las flechas gruesas muestran la jerarquía de la herencia de clases, y las flechas delgadas muestran la membresía de miembros y métodos en las clases. En general, para el servicio, se genera la clase Q [servicename] ClientService, donde servicename es el nombre del servicio declarado en el archivo .proto. RPCCallData son estructuras de control generadas para cada RPC en el servicio. Para llamar a un RPC, la clase generada proporciona funciones cuyos nombres coinciden exactamente con el RPC declarado en el archivo .proto. En nuestro ejemplo, en el archivo .proto RPC, SayHello () se declara como:

 rpc SayHello (PingRequest) returns (PingReply) {} 

En la clase QpingClientService generada, la función RPC correspondiente se ve así:


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

Las estructuras RPCCallData generadas, como en el caso del servidor, finalmente se heredan de la clase ClientResponder. Por lo tanto, la creación de un objeto de la estructura generada conduce a la creación de un respondedor. Después de crear el respondedor, se llama al RPC y el respondedor se asocia con el evento de recibir una respuesta del servidor. En términos de código de cliente, una llamada RPC se ve así:


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

A diferencia de la clase de servidor QpingServerService generada, la clase QpingClientService hereda de dos clases de plantilla: ConnectivityFeatures y MonitorFeatures.


La clase ConnectivityFeatures es responsable del estado de la conexión cliente-servidor y proporciona tres funciones para su uso: grpc_connect (), grpc_disconnect (), grpc_reconnect (). La función grpc_disconnect () simplemente elimina todas las estructuras de datos responsables de interactuar con el servidor. La llamada a grpc_connect se reduce a llamadas a la función grpc_connect_ (), que crea estructuras de datos de control:


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

La clase ChannelFeatures supervisa el estado de la comunicación channel_ channel con el servidor. La clase ConnectivityFeatures encapsula un objeto de la clase ChannelFeatures e implementa las funciones abstractas channelState (), checkChannelState () y conectado () utilizando este objeto. La función channelState () devuelve el último estado observado del canal de comunicación con el servidor. La función checkChannelState (), de hecho, devuelve el estado actual del canal. La función connected () devuelve el signo del cliente que se conecta al servidor.


La clase MonitorFeatures es responsable de recibir y procesar eventos del servidor y proporciona la función CheckCQ () para su uso:


 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 estructura del código es la misma que en el caso del servidor. A diferencia del servidor, se agrega al cliente un bloque de código responsable de procesar el estado actual. Si el estado del canal de comunicación ha cambiado, se llama a la señal channelStateChangedSignal_ (). En todos los servicios generados, esta es una señal:

 void channelStateChanged(int, int); 

Además, a diferencia del servidor, la función AsyncNext () se usa aquí en lugar de Next (). Esto se ha hecho por varias razones. En primer lugar, cuando se utiliza AsyncNext (), el código del cliente tiene la capacidad de aprender sobre el cambio en el estado del canal de comunicación. En segundo lugar, cuando se utiliza AsyncNext (), es posible llamar a varios RPC en el código del cliente varias veces. El uso de la función Next () en este caso bloqueará el hilo hasta que se reciba un evento de la cola y, como resultado, perderá las dos características descritas.

Después de recibir el evento desde la cola, como en el caso del servidor, la función cqReaction (), definida en la clase ClientCallData, se llama:


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

Al igual que con el servidor, la función processEvent () procesa la etiqueta y el indicador ok y siempre devuelve verdadero. Como en el caso del servidor, después de procesar el evento, se debe llamar a la señal del servicio generado. Sin embargo, hay dos diferencias significativas con respecto a la función del servidor del mismo nombre. La primera diferencia es que los respondedores no se crean en esta función. La creación de respondedores, como se muestra arriba, ocurre cuando se llama al RPC. La segunda diferencia es que los respondedores no se eliminan en esta función. La ausencia de la eliminación de los respondedores se realiza por dos razones. Primero, el código del cliente puede usar punteros para generar estructuras RPCCallData para sus propios fines. Eliminar el contenido de este puntero, oculto del código del cliente, puede tener consecuencias desagradables. En segundo lugar, la eliminación del respondedor conducirá al hecho de que no se generará una señal con datos. Por lo tanto, el código del cliente no recibirá el último mensaje del servidor. Entre varias alternativas para resolver los problemas indicados, se tomó la decisión de cambiar la eliminación del respondedor (estructuras generadas) al código del cliente. Por lo tanto, las funciones del controlador de señal (ranuras) deben contener el siguiente código:


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

La ausencia de la eliminación del respondedor en el código del cliente conducirá no solo a una pérdida de memoria, sino también a posibles problemas con el canal de comunicación. Los manejadores de señales de todo tipo de interacciones RPC se implementan en el código de muestra.


Conclusión


En conclusión, llamamos la atención sobre dos puntos. El primer punto está relacionado con la llamada a las funciones CheckCQ () del cliente y el servidor. Funcionan, como se muestra arriba, de acuerdo con un principio: si hay un evento en la cola, se emite una señal con la estructura RPCCallData generada correspondiente. Puede llamar a esta función manualmente y verificar (en el caso de un cliente) un evento. Pero inicialmente hubo una idea de transferir toda la parte de la red asociada con gRPC a otro hilo. Para estos fines, se escribieron las clases auxiliares QGrpcSrvMonitor para el servidor gRPC y QGrpcCliServer para el cliente gRPC. Ambas clases funcionan según el mismo principio: crean una secuencia separada, colocan el servicio generado en esta secuencia y llaman periódicamente a la función CheckCQ () de este servicio. Por lo tanto, cuando se usan ambas clases auxiliares, no hay necesidad de llamar a las funciones CheckCQ () en el código del cliente. Las señales del servicio generado, en este caso, "provienen" de otro flujo. Los ejemplos de cliente y servidor se implementan utilizando estas clases auxiliares.


El segundo punto concierne a la mayoría de los desarrolladores que no usan la biblioteca Qt en su trabajo. Las clases Qt y las macros en QgRPC se usan solo en dos lugares: en archivos de servicio generados y en archivos que contienen clases auxiliares: QGrpcServerMonitor.h y QGrpcClientMonitor.h. Los archivos restantes con la biblioteca Qt no están asociados de ninguna manera. Se planeó agregar el ensamblaje usando cmake y tropezar algunas directivas Qt. En particular, la clase QObject y la macro Q_OBJECT. Pero las manos simplemente no llegaron a esto. Por lo tanto, cualquier sugerencia es bienvenida.


Eso es todo. Gracias a todos!


Referencias


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


All Articles