Yandex.Taxi se adhiere a la arquitectura de microservicios. Con el aumento en el número de microservicios, notamos que los desarrolladores dedican mucho tiempo a repeticiones y problemas típicos, mientras que las soluciones no siempre funcionan de manera óptima.
Decidimos crear nuestro propio marco, con C ++ 17 y corutinas. Así es como se ve un código típico de microservicio:
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>()}; }
Y aquí es por qué es extremadamente efectivo y rápido: lo contaremos debajo del corte.
Usuario - Asíncrono
Nuestro equipo no solo está formado por desarrolladores experimentados de C ++: hay aprendices, desarrolladores junior e incluso personas que no están particularmente acostumbradas a escribir en C ++. Por lo tanto, el diseño del usuario se basa en la facilidad de uso. Sin embargo, con nuestros volúmenes y carga de datos, tampoco podemos permitirnos desperdiciar los recursos de hierro de manera ineficiente.
Los microservicios se caracterizan por la expectativa de entrada / salida: a menudo la respuesta de un microservicio se forma a partir de varias respuestas de otros microservicios y bases de datos. El problema de la espera eficiente de E / S se resuelve a través de métodos asíncronos y devoluciones de llamada: con operaciones asincrónicas, no hay necesidad de generar hilos de ejecución y, en consecuencia, no hay una gran sobrecarga para cambiar los flujos ... solo el código es bastante difícil de escribir y mantener:
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>()}; }); }); }); }); }
Y aquí las pilas llenas de coroutines vienen al rescate. El usuario del marco cree que escribe el código síncrono habitual:
auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];
Sin embargo, aproximadamente lo siguiente ocurre debajo del capó:
- Los paquetes TCP se generan y envían con una solicitud a la base de datos;
- se suspende la ejecución de la rutina, en la que la función View :: Handle se está ejecutando actualmente;
- le decimos al núcleo del sistema operativo: "" Coloque la rutina suspendida en la cola de tareas listas para su ejecución tan pronto como salgan suficientes paquetes TCP de la base de datos ";
- sin esperar el paso anterior, tomamos y lanzamos otra corutina lista para su ejecución desde la cola.
En otras palabras, la función del primer ejemplo funciona de forma asíncrona y está cerca de dicho código utilizando C ++ 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>()}; }
Eso es solo que el usuario no necesita pensar en co_await y co_return, todo funciona "por sí solo".
En nuestro marco, el cambio entre corutinas es más rápido que llamar a std :: this_thread :: yield (). Todo el microservicio cuesta una cantidad muy pequeña de subprocesos.
Por el momento, userver contiene controladores asincrónicos:
* para tomas de SO;
* http y https (cliente y servidor);
* PostgreSQL;
* MongoDB;
* Redis;
* trabajar con archivos;
* temporizadores;
* primitivas para sincronizar y lanzar nuevas corutinas.
El enfoque asincrónico anterior para resolver tareas vinculadas a E / S debería ser familiar para los desarrolladores de Go. Pero, a diferencia de Go, no obtenemos gastos de memoria y CPU del recolector de basura. Los desarrolladores pueden usar un lenguaje más rico, con varios contenedores y bibliotecas de alto rendimiento, sin sufrir falta de consistencia, RAII o plantillas.
Userver - componentes
Por supuesto, un marco completo no es solo corutinas. Las tareas de los desarrolladores en Taxi son extremadamente diversas, y cada una de ellas requiere su propio conjunto de herramientas para resolver. Por lo tanto, userver tiene todo lo que necesita:
* para iniciar sesión;
* almacenamiento en caché;
* trabajar con varios formatos de datos;
* trabajar con configuraciones y actualizar configuraciones sin reiniciar el servicio;
* cerraduras distribuidas;
* prueba;
* autorización y autenticación;
* crear y enviar métricas;
* escribir manejadores REST;
+ generación de código y soporte de dependencia (hecho en una parte separada del marco).
Userver - generación de código
Volvamos a la primera línea de nuestro ejemplo y veamos qué se esconde detrás de Respuesta y Solicitud:
Response Handle(Request&& request, const Dependencies& dependencies);
Con userver puede escribir cualquier microservicio, pero nuestros microservicios tienen el requisito de que sus API deben estar documentadas (descritas a través de esquemas swagger).
Por ejemplo, para Handle del ejemplo, el diagrama swagger podría verse así:
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'
Bueno, dado que el desarrollador ya tiene un esquema con una descripción de solicitudes y respuestas, ¿por qué no generar estas solicitudes y respuestas basadas en él? Al mismo tiempo, los enlaces a los archivos protobuf / flatbuffer / ... también se pueden indicar en el esquema: la generación de código a partir de la solicitud obtendrá todo, validará los datos de entrada de acuerdo con el esquema y los descompondrá en los campos de la estructura de Respuesta. El usuario solo necesita escribir la funcionalidad en el método Handle, sin ser distraído por el repetitivo con el análisis de solicitudes y la serialización de la respuesta.
Al mismo tiempo, la generación de código funciona para clientes de servicio. Puede indicar que su servicio necesita un cliente que funcione de acuerdo con dicho esquema y obtener una clase lista para usar para crear solicitudes asincrónicas:
Request req; req.id = id; req.foo = foo; req.bar = bar; dependencies.sample_client.SomeSampleBarPost(req);
Este enfoque tiene otra ventaja: la documentación siempre actualizada. Si un desarrollador intenta de repente usar parámetros que no están en la documentación, recibirá un error de compilación.
Userver - registro
Nos encanta escribir registros. Si registra solo la información más importante, se ejecutarán varios terabytes de registros por hora. Por lo tanto, no es sorprendente que nuestro registro tenga sus propios trucos:
* es asíncrono (por supuesto :-));
* Podemos iniciar sesión sin pasar por std :: locale y std :: ostream lentos;
* podemos cambiar el nivel de registro sobre la marcha (sin reiniciar el servicio);
* no ejecutamos código de usuario si es necesario solo para iniciar sesión.
Por ejemplo, durante el funcionamiento normal del microservicio, el nivel de registro se establecerá en INFO y toda la expresión
LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();
No se calculará. Incluyendo la llamada a la función de uso intensivo de recursos GetSomeInfoFromDb () no ocurrirá.
Si de repente el servicio comienza a "engañar", el desarrollador siempre puede decirle al servicio en funcionamiento: "Inicie sesión en modo DEPURAR". Y en este caso las entradas "no está bien de" comenzarán a aparecer en los registros, se ejecutará la función GetSomeInfoFromDb ().
En lugar de totales
En un artículo es imposible contar de inmediato sobre todas las características y trucos. Por lo tanto, comenzamos con una breve introducción. Escriba en los comentarios sobre qué cosas del usuario le interesaría aprender y leer.
Ahora estamos considerando si publicar el marco en código abierto. Si decidimos que sí, preparar el marco para abrir la fuente requerirá mucho esfuerzo.