O Yandex.Taxi adere à arquitetura de microsserviço. Com o aumento do número de microsserviços, percebemos que os desenvolvedores passam muito tempo em problemas comuns e problemas comuns, enquanto as soluções nem sempre funcionam da maneira ideal.
Decidimos criar nossa própria estrutura, com C ++ 17 e corotinas. É assim que um código típico de microsserviço se parece agora:
Response View::Handle(Request&& request, const Dependencies& dependencies) { auto cluster = dependencies.pg->GetCluster(); auto trx = cluster->Begin(storages::postgres::ClusterHostType::kMaster); const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; auto row = psql::Execute(trx, statement, request.id)[0]; if (!row["ok"].As<bool>()) { LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb(); return Response400(); } psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar); trx.Commit(); return Response200{row["baz"].As<std::string>()}; }
E aqui está o porquê de ser extremamente eficaz e rápido - contaremos sob o corte.
Userver - Assíncrono
Nossa equipe não consiste apenas de desenvolvedores experientes em C ++: existem estagiários, desenvolvedores juniores e até pessoas que não estão acostumadas a escrever em C ++. Portanto, o design do usuário é baseado na facilidade de uso. No entanto, com nossos volumes e carga de dados, também não podemos desperdiçar recursos de ferro ineficientemente.
Os microsserviços são caracterizados pela expectativa de entrada / saída: geralmente a resposta de um microsserviço é formada a partir de várias respostas de outros microsserviços e bancos de dados. O problema da espera de E / S eficiente é resolvido através de métodos assíncronos e retornos de chamada: com operações assíncronas, não há necessidade de produzir encadeamentos de execução e, portanto, não há grande sobrecarga para alternar fluxos ... apenas o código é bastante difícil de escrever e manter:
void View::Handle(Request&& request, const Dependencies& dependencies, Response response) { auto cluster = dependencies.pg->GetCluster(); cluster->Begin(storages::postgres::ClusterHostType::kMaster, [request = std::move(request), response](auto& trx) { const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; psql::Execute(trx, statement, request.id, [request = std::move(request), response, trx = std::move(trx)](auto& res) { auto row = res[0]; if (!row["ok"].As<bool>()) { if (LogDebug()) { GetSomeInfoFromDb([id = request.id](auto info) { LOG_DEBUG() << id << " is not OK of " << info; }); } *response = Response400{}; } psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar, [row = std::move(row), trx = std::move(trx), response]() { trx.Commit([row = std::move(row), response]() { *response = Response200{row["baz"].As<std::string>()}; }); }); }); }); }
E aqui as empilhadeiras cheias de socorro vêm em socorro. O usuário da estrutura pensa que ele escreve o código síncrono usual:
auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];
No entanto, aproximadamente o seguinte ocorre sob o capô:
- Pacotes TCP são gerados e enviados com uma solicitação ao banco de dados;
- a execução da corotina, na qual a função View :: Handle está em execução, está suspensa;
- dizemos ao kernel do sistema operacional: ““ Coloque a corotina suspensa na fila de tarefas prontas para execução assim que pacotes TCP suficientes saírem do banco de dados ”;
- sem aguardar a etapa anterior, lançamos outra corotina pronta para execução a partir da fila.
Em outras palavras, a função do primeiro exemplo funciona de forma assíncrona e fica próxima a esse código usando Cout 20 Coroutines:
Response View::Handle(Request&& request, const Dependencies& dependencies) { auto cluster = dependencies.pg->GetCluster(); auto trx = co_await cluster->Begin(storages::postgres::ClusterHostType::kMaster); const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; auto row = co_await psql::Execute(trx, statement, request.id)[0]; if (!row["ok"].As<bool>()) { LOG_DEBUG() << request.id << " is not OK of " << co_await GetSomeInfoFromDb(); co_return Response400{"NOT_OK", "Please provide different ID"}; } co_await psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar); co_await trx.Commit(); co_return Response200{row["baz"].As<std::string>()}; }
Isso é apenas o usuário não precisa pensar em co_await e co_return, tudo funciona "por conta própria".
Em nossa estrutura, alternar entre corotinas é mais rápido do que chamar std :: this_thread :: yield (). Todo o microsserviço custa um número muito pequeno de threads.
No momento, userver contém drivers assíncronos:
* para soquetes do sistema operacional;
* http e https (cliente e servidor);
* PostgreSQL;
* MongoDB;
* Redis;
* trabalha com arquivos;
* temporizadores;
* primitivas para sincronizar e lançar novas corotinas.
A abordagem assíncrona acima para resolver tarefas ligadas a E / S deve ser familiar para os desenvolvedores do Go. Mas, diferentemente do Go, não sobrecarregamos a memória e a CPU do coletor de lixo. Os desenvolvedores podem usar uma linguagem mais rica, com vários contêineres e bibliotecas de alto desempenho, sem sofrer falta de consistência, RAII ou modelos.
Userver - componentes
Evidentemente, uma estrutura completa não é apenas corotinas. As tarefas dos desenvolvedores em Taxi são extremamente diversas e cada uma delas requer seu próprio conjunto de ferramentas. Portanto, userver tem tudo o que você precisa:
* para registro;
* cache;
* trabalhar com vários formatos de dados;
* trabalhe com configurações e atualize as configurações sem reiniciar o serviço;
* bloqueios distribuídos;
* teste;
* autorização e autenticação;
* criar e enviar métricas;
* escrevendo manipuladores REST;
+ suporte à geração e dependência de código (feito em uma parte separada da estrutura).
Userver - geração de código
Vamos voltar à primeira linha do nosso exemplo e ver o que está oculto por trás da resposta e solicitação:
Response Handle(Request&& request, const Dependencies& dependencies);
Com o userver, você pode gravar qualquer microsserviço, mas existe um requisito para nossos microsserviços de que suas APIs sejam documentadas (descritas por meio de esquemas de swagger).
Por exemplo, para o Handle do exemplo, o diagrama de arrogância pode ser assim:
paths: /some/sample/{bar}: post: description: | Habr. summary: | , - . parameters: - in: query name: id type: string required: true - in: header name: foo type: string enum: - foo1 - foo2 required: true - in: path name: bar type: string required: true responses: '200': description: OK schema: type: object additionalProperties: false required: - baz properties: baz: type: string '400': $ref: '#/responses/ResponseCommonError'
Bem, como o desenvolvedor já possui um esquema com uma descrição de solicitações e respostas, por que não gerar essas solicitações e respostas com base nele? Ao mesmo tempo, links para arquivos protobuf / flatbuffer / ... também podem ser indicados no esquema - a geração de código da própria solicitação obterá tudo, validará os dados de entrada de acordo com o esquema e os decomporá nos campos da estrutura de resposta. O usuário só precisa escrever a funcionalidade no método Handle, sem se distrair com o clichê com análise de solicitação e serialização da resposta.
Ao mesmo tempo, a geração de código funciona para clientes de serviço. Você pode indicar que seu serviço precisa de um cliente trabalhando de acordo com esse esquema e preparar uma classe para uso para criar solicitações assíncronas:
Request req; req.id = id; req.foo = foo; req.bar = bar; dependencies.sample_client.SomeSampleBarPost(req);
Essa abordagem tem outra vantagem: documentação sempre atualizada. Se um desenvolvedor tentar repentinamente usar parâmetros que não estão na documentação, ele receberá um erro de compilação.
Userver - registro
Adoramos escrever logs. Se você registrar apenas as informações mais importantes, vários terabytes de logs por hora serão executados. Portanto, não é surpreendente que nosso registro tenha seus próprios truques:
* é assíncrono (é claro :-));
* podemos registrar ignorando o lento std :: locale e std :: ostream;
* podemos mudar o nível de registro em tempo real (sem reiniciar o serviço);
* não executamos o código do usuário se for necessário apenas para o registro.
Por exemplo, durante a operação normal do microsserviço, o nível de log será definido como INFO e toda a expressão
LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();
não será calculado. A inclusão da chamada para a função de uso intensivo de recursos GetSomeInfoFromDb () não ocorrerá.
Se de repente o serviço começar a "enganar", o desenvolvedor sempre poderá dizer ao serviço em funcionamento: "Efetue login no modo DEBUG". E, nesse caso, as entradas “não está OK de” começarão a aparecer nos logs, a função GetSomeInfoFromDb () será executada.
Em vez de totais
Em um artigo, é impossível contar de uma vez sobre todos os recursos e truques. Portanto, começamos com uma breve introdução. Escreva nos comentários sobre o que você gostaria de aprender e ler sobre o userver.
Agora, estamos pensando em publicar a estrutura em código aberto. Se decidirmos que sim, preparar a estrutura para abrir a fonte exigirá muito esforço.