Olá pessoal. Hoje, veremos como você pode vincular a estrutura gRPC em C ++ e a biblioteca Qt. O artigo fornece um código que resume o uso de todos os quatro modos de interação no gRPC. Além disso, é fornecido um código que permite o uso de gRPC através de sinais e slots Qt. O artigo pode ser de interesse principalmente para desenvolvedores de Qt interessados em usar o gRPC. No entanto, uma generalização dos quatro modos de operação do gRPC é escrita em C ++ sem o uso do Qt, o que permitirá que desenvolvedores não relacionados ao Qt adaptem o código. Peço a todos os interessados em gato.
Antecedentes
Cerca de seis meses atrás, dois projetos dependiam de mim, usando as partes cliente e servidor do gRPC. Ambos os projetos caíram em produção. Esses projetos foram escritos por desenvolvedores que já foram encerrados. A única boa notícia foi que participei ativamente da redação do servidor gRPC e do código do cliente. Mas isso foi cerca de um ano atrás. Portanto, como sempre, tive que lidar com tudo do zero.
O código do servidor gRPC foi gravado com a expectativa de que será gerado ainda mais pelo arquivo .proto. O código foi escrito bem. No entanto, o servidor teve uma grande desvantagem: apenas um cliente pôde se conectar a ele.
O cliente gRPC foi gravado simplesmente horrível.
Eu descobri o código de cliente e servidor gRPC apenas alguns dias depois. E percebi que, se fizesse um projeto por algumas semanas, teria que lidar com o servidor e o cliente gRPC novamente.
Foi então que decidi que era hora de escrever e depurar o cliente e o servidor gRPC para que:
Você pode dormir em paz à noite;
Não havia necessidade de lembrar como isso funciona toda vez que você precisa gravar um cliente ou servidor gRPC;
Você pode usar o cliente e servidor gRPC gravados em outros projetos.
Ao escrever o código, fui guiado pelos seguintes requisitos:
O cliente e o servidor gRPC podem operar usando os sinais e slots da biblioteca Qt de maneira natural;
O código do cliente e do servidor gRPC não precisa ser corrigido ao alterar o arquivo .proto;
O cliente gRPC deve poder informar ao código do cliente o status da conexão com o servidor.
A estrutura do artigo é a seguinte. Primeiro, haverá uma breve visão geral dos resultados do trabalho com o código do cliente e algumas explicações para ele. No final da revisão, um link para o repositório. Além disso, haverá coisas gerais sobre arquitetura. Em seguida, uma descrição do código do servidor e do cliente (o que há sob o capô) e uma conclusão.
Breve revisão
O arquivo pingproto.proto mais simples foi usado como um arquivo .proto, no qual foram definidas RPCs de todos os tipos de interação:
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; }
O arquivo pingpong.proto repete o arquivo helloworld.proto do artigo sobre modos de gRPC assíncrono em C ++ para o nome exato.
Como resultado, um servidor gravado pode ser usado assim:
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(); }
Quando um cliente chama RPC, o servidor gRPC notifica o código do cliente (neste caso, classe A) com o sinal apropriado.
O cliente gRPC pode ser usado assim:
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. };
O cliente gRPC permite ligar diretamente para a RPC e assinar a resposta do servidor usando os sinais apropriados.
O cliente gRPC também possui um sinal:
channelStateChanged(int, int);
que relata os status de conexão do servidor anteriores e atuais. Todo o código de
amostra está no
repositório qgrpc .
Como isso funciona
O princípio de incluir o cliente e o servidor gRPC no projeto é mostrado na figura.
No arquivo de projeto .pro, os arquivos .proto são especificados, com base nos quais o gRPC funcionará. O arquivo grpc.pri contém comandos para gerar arquivos gRPC e QgRPC. O compilador protoc gera arquivos gRPC [protofile] .grpc.pb.h e [protofile] .grpc.pb.cc. [protofile] é o nome do arquivo .proto passado para a entrada do compilador.
A geração de arquivos QgRPC [protofile] .qgrpc. [Config] .h é manipulada pelo script genQGrpc.py. [config] é "servidor" ou "cliente".
Os arquivos QgRPC gerados contêm um wrapper Qt em torno das classes gRPC e chamadas com os sinais correspondentes. Nos exemplos anteriores, as classes QpingServerService e QpingClientService são declaradas respectivamente nos arquivos gerados pingpong.qgrpc.server.he pingpong.qgrpc.client.h. Arquivos QgRPC gerados são adicionados ao processamento moc.
Nos arquivos QgRPC gerados, os arquivos QGrpc [config] .h estão incluídos, nos quais todo o trabalho principal ocorre. Leia mais sobre isso abaixo.
Para conectar toda essa construção ao projeto, você precisa incluir o arquivo grpc.pri no arquivo .pro do projeto e especificar três variáveis. A variável GRPC define arquivos .proto que serão transferidos para as entradas do compilador protoc e do script genQGrpc.py. A variável QGRPC_CONFIG define o valor de configuração dos arquivos QgRPC gerados e pode conter os valores "servidor" ou "cliente". Você também pode definir a variável opcional GRPC_VERSION para indicar a versão do gRPC.
Para obter mais informações sobre tudo o que foi dito, leia o arquivo grpc.pri e os arquivos de exemplo .pro.
Arquitetura do servidor
O diagrama de classes do servidor é mostrado na figura.
As setas grossas mostram a hierarquia da herança de classe e as setas finas mostram a associação de membros e métodos nas classes. Em geral, a classe Q [servicename] ServerService é gerada para o serviço, em que servicename é o nome do serviço declarado no arquivo .proto. RPCCallData são estruturas de controle geradas para cada RPC no serviço. No construtor da classe QpingServerService, a classe base QGrpcServerService é inicializada com o serviço assíncrono gRPC pingpong :: ping :: AsyncService. Para iniciar o serviço, você precisa chamar o método Start () com o endereço e a porta na qual o serviço será executado. A função Start () implementa o procedimento padrão para iniciar um serviço.
No final da função Start (), a função virtual pura makeRequests () é chamada, implementada na classe QpingServerService gerada:
void makeRequests() { needAnotherCallData< SayHello_RPCtypes, SayHelloCallData >(); needAnotherCallData< GladToSeeMe_RPCtypes, GladToSeeMeCallData >(); needAnotherCallData< GladToSeeYou_RPCtypes, GladToSeeYouCallData >(); needAnotherCallData< BothGladToSee_RPCtypes, BothGladToSeeCallData >(); }
O segundo parâmetro de modelo da função needAnotherCallData são as estruturas RPCCallData geradas. As mesmas estruturas são os parâmetros dos sinais na classe Qt gerada do serviço.
As estruturas RPCCallData geradas são herdadas da classe ServerCallData. Por sua vez, a classe ServerCallData é herdada do respondente ServerResponder. Assim, a criação de um objeto de estruturas coerentes leva à criação de um objeto respondedor.
O construtor da classe ServerCallData utiliza dois parâmetros: signal_func e request_func. signal_func é um sinal gerado que é chamado após o recebimento de um tag da fila. request_func é uma função que deve ser chamada ao criar um novo respondedor. Por exemplo, nesse caso, pode ser a função RequestSayHello (). A chamada request_func ocorre na função needAnotherCallData (). Isso é feito para que o gerenciamento dos respondentes (criação e exclusão) ocorra no serviço.
O código da função needAnotherCallData () consiste em criar um objeto de resposta e chamar uma função que conecta o respondente a uma chamada RPC:
template<class RPCCallData, class RPCTypes> void needAnotherCallData() { RPCCallData* cd = new RPCCallData();
As funções RequestRPC () são funções de modelo para quatro tipos de interação. Como resultado, chamar RequestRPC () se resume a uma chamada:
service_->(cd->request_func_)(...,cd->responder, (void*)cd)
onde service_ é o serviço gRPC. Nesse caso, é pingpong :: ping :: AsyncService.
Para verificar de forma síncrona ou assíncrona a fila de eventos, você deve chamar as funções CheckCQ () ou AsyncCheckCQ (), respectivamente. O código da função CheckCQ () se resume a chamadas para o tag síncrono da fila e o processamento desse 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); }
Depois de receber a tag da fila, a validade da tag e o início do servidor são verificados. Se o servidor estiver desligado, a tag não será mais necessária - ela poderá ser excluída. Depois disso, a função cqReaction () definida na classe ServerCallData é chamada:
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;
O sinalizador first_time_reaction_ indica que você precisa criar um novo respondedor para o RPC chamado. As funções CouldBeDeleted () e ProcessEvent () são herdadas da classe de resposta ServerResponder correspondente. A função CouldBeDeleted () retorna um sinal de que o objeto respondedor pode ser excluído. A função processEvent () processa a tag e o sinalizador ok. Assim, por exemplo, para um respondedor de tipo de fluxo de cliente, a função se parece com:
bool processEvent(void* tag, bool ok) { this->tag_ = tag; read_mode_ = ok; return true; }
A função ProcessEvent (), independentemente do tipo de resposta, sempre retorna true. O valor de retorno dessa função é deixado para uma possível extensão de funcionalidade e, teoricamente, para eliminar erros.
Após o processamento do evento, a chamada a seguir:
service_->(*signal_func_)(genRpcCallData)
A variável service_ é uma instância do serviço gerado, no nosso caso QpingServerService. A variável signal_func_ é um sinal de serviço correspondente a um RPC específico. Por exemplo, SayHelloRequest (). A variável genRpcCallData é um objeto de resposta do tipo correspondente. Do ponto de vista do código de chamada, a variável genRpcCallData é um objeto de uma das estruturas RPCCallData geradas.
Arquitetura do cliente
Sempre que possível, os nomes das classes e funções do cliente correspondem aos nomes das classes e funções do servidor. O diagrama de classes do cliente é mostrado na figura.
As setas grossas mostram a hierarquia da herança de classe e as setas finas mostram a associação de membros e métodos nas classes. Em geral, para o serviço, é gerada a classe Q [servicename] ClientService, em que servicename é o nome do serviço declarado no arquivo .proto. RPCCallData são estruturas de controle geradas para cada RPC no serviço. Para chamar um RPC, a classe gerada fornece funções cujos nomes correspondem exatamente ao RPC declarado no arquivo .proto. No nosso exemplo, no arquivo .proto RPC, SayHello () é declarado como:
rpc SayHello (PingRequest) returns (PingReply) {}
Na classe QpingClientService gerada, a função RPC correspondente é semelhante a esta:
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); }
As estruturas RPCCallData geradas, como no caso do servidor, são finalmente herdadas da classe ClientResponder. Portanto, a criação de um objeto da estrutura gerada leva à criação de um respondedor. Após criar o respondedor, o RPC é chamado e o respondedor é associado ao evento de recebimento de uma resposta do servidor. Em termos de código do cliente, uma chamada RPC é assim:
void ToSayHello() { PingRequest request; request.set_name("user"); request.set_message("user"); pingPongSrv.SayHello(request); }
Diferentemente da classe de servidor QpingServerService gerada, a classe QpingClientService herda de duas classes de modelo: ConnectivityFeatures e MonitorFeatures.
A classe ConnectivityFeatures é responsável pelo status da conexão do cliente com o servidor e fornece três funções para uso: grpc_connect (), grpc_disconnect (), grpc_reconnect (). A função grpc_disconnect () simplesmente remove todas as estruturas de dados responsáveis pela interação com o servidor. A chamada para grpc_connect é reduzida para chamadas para a função grpc_connect_ (), que cria estruturas de dados de controle:
void grpc_connect_() { channel_ = grpc::CreateChannel(target_, creds_); stub_ = GRPCService::NewStub(channel_); channelFeatures_ = std::make_unique<ChannelFeatures>(channel_); channelFeatures_->checkChannelState(); }
A classe ChannelFeatures monitora o status da comunicação channel_ channel com o servidor. A classe ConnectivityFeatures encapsula um objeto da classe ChannelFeatures e implementa as funções abstratas channelState (), checkChannelState () e conectado () usando este objeto. A função channelState () retorna o último estado observado do canal de comunicação com o servidor. A função checkChannelState (), de fato, retorna o estado atual do canal. A função connected () retorna o sinal do cliente se conectando ao servidor.
A classe MonitorFeatures é responsável por receber e processar eventos do servidor e fornece a função CheckCQ () para 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; }
A estrutura do código é a mesma do caso do servidor. Diferentemente do servidor, um bloco de código responsável pelo processamento do estado atual é adicionado ao cliente. Se o estado do canal de comunicação mudou, o sinal channelStateChangedSignal_ () é chamado. Em todos os serviços gerados, este é um sinal:
void channelStateChanged(int, int);
Além disso, ao contrário do servidor, a função AsyncNext () é usada aqui em vez de Next (). Isso foi feito por várias razões. Primeiramente, ao usar o AsyncNext (), o código do cliente pode aprender sobre a alteração no estado do canal de comunicação. Em segundo lugar, ao usar AsyncNext (), é possível chamar vários RPCs no código do cliente várias vezes. O uso da função Next () nesse caso bloqueará o encadeamento até que um evento seja recebido da fila e, como resultado, perderá os dois recursos descritos.
Depois de receber o evento da fila, como no caso do servidor, a função cqReaction (), definida na classe ClientCallData, é chamada:
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 ); }
Assim como no servidor, a função processEvent () processa a tag e o sinalizador ok e sempre retorna true. Como no caso do servidor, após o processamento do evento, o sinal do serviço gerado deve ser chamado. No entanto, existem duas diferenças significativas em relação à função do servidor com o mesmo nome. A primeira diferença é que os respondedores não são criados nessa função. A criação de respondedores, como mostrado acima, ocorre quando o RPC é chamado. A segunda diferença é que os respondedores não são excluídos nesta função. A ausência da remoção dos respondedores é feita por dois motivos. Primeiro, o código do cliente pode usar ponteiros para gerar estruturas RPCCallData para seus próprios propósitos. A exclusão de conteúdo por esse ponteiro, oculta do código do cliente, pode levar a conseqüências desagradáveis. Em segundo lugar, a remoção do respondedor levará ao fato de que um sinal com dados não será gerado. Portanto, o código do cliente não receberá a última mensagem do servidor. Entre várias alternativas para solucionar os problemas indicados, foi tomada a decisão de mudar a remoção do respondedor (estruturas geradas) para o código do cliente. Portanto, as funções do manipulador de sinal (slots) devem conter o seguinte código:
void ResponseHandler(RPCCallData* response) { if (response->CouldBeDeleted()) delete response;
A ausência da remoção do respondedor no código do cliente levará não apenas a um vazamento de memória, mas também a possíveis problemas com o canal de comunicação. Manipuladores de sinais de todos os tipos de interações RPC são implementados no código de exemplo.
Conclusão
Concluindo, chamamos atenção para dois pontos. O primeiro ponto está relacionado à chamada das funções CheckCQ () do cliente e servidor. Eles funcionam, como mostrado acima, de acordo com um princípio: se houver um evento na fila, um sinal com a estrutura RPCCallData gerada correspondente será "emitido". Você pode chamar essa função manualmente e verificar (no caso de um cliente) por um evento. Mas, inicialmente, havia uma ideia de transferir toda a parte da rede associada ao gRPC para outro encadeamento. Para esses propósitos, as classes auxiliares QGrpcSrvMonitor para o servidor gRPC e QGrpcCliServer para o cliente gRPC foram gravadas. Ambas as classes funcionam com o mesmo princípio: elas criam um fluxo separado, colocam o serviço gerado nesse fluxo e chamam periodicamente a função CheckCQ () desse serviço. Portanto, ao usar as duas classes auxiliares, não há necessidade de chamar funções CheckCQ () no código do cliente. Os sinais do serviço gerado, neste caso, "vêm" de outro fluxo. Exemplos de cliente e servidor são implementados usando essas classes auxiliares.
O segundo ponto diz respeito à maioria dos desenvolvedores que não usam a biblioteca Qt em seus trabalhos. As classes e macros Qt no QgRPC são usadas apenas em dois locais: nos arquivos de serviço gerados e nos arquivos que contêm classes auxiliares: QGrpcServerMonitor.he QGrpcClientMonitor.h. Os arquivos restantes com a biblioteca Qt não estão de forma alguma associados. Foi planejado adicionar montagem usando cmake e remover algumas diretivas de Qt. Em particular, a classe QObject e a macro Q_OBJECT. Mas as mãos simplesmente não chegaram a isso. Portanto, todas as sugestões são bem-vindas.
Só isso. Obrigado a todos!
Referências