Yandex.Taxi hält an der Microservice-Architektur fest. Mit der Zunahme der Anzahl von Microservices haben wir festgestellt, dass Entwickler viel Zeit mit Boilerplate und typischen Problemen verbringen, während Lösungen nicht immer optimal funktionieren.
Wir haben uns entschlossen, ein eigenes Framework mit C ++ 17 und Coroutinen zu erstellen. So sieht nun ein typischer Microservice-Code aus:
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>()}; }
Und hier ist, warum es extrem effektiv und schnell ist - wir werden es unter dem Schnitt erkennen.
Userver - Asynchron
Unser Team besteht nicht nur aus erfahrenen C ++ - Entwicklern: Es gibt Auszubildende, Nachwuchsentwickler und sogar Leute, die nicht besonders an das Schreiben in C ++ gewöhnt sind. Daher basiert das Benutzerdesign auf der Benutzerfreundlichkeit. Aufgrund unseres Datenvolumens und unserer Datenlast können wir es uns jedoch auch nicht leisten, Eisenressourcen ineffizient zu verschwenden.
Microservices zeichnen sich durch die Erwartung von Input / Output aus: Oft wird die Antwort eines Microservices aus mehreren Antworten anderer Microservices und Datenbanken gebildet. Das Problem der effizienten E / A-Wartezeit wird durch asynchrone Methoden und Rückrufe gelöst: Bei asynchronen Vorgängen müssen keine Ausführungsthreads erstellt werden, und dementsprechend entsteht kein großer Overhead für das Umschalten von Flows. Nur der Code ist ziemlich schwer zu schreiben und zu warten:
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>()}; }); }); }); }); }
Und hier kommen Stackfull-Coroutinen zur Rettung. Der Benutzer des Frameworks glaubt, den üblichen synchronen Code zu schreiben:
auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];
Unter der Haube tritt jedoch ungefähr Folgendes auf:
- TCP-Pakete werden generiert und mit einer Anforderung an die Datenbank gesendet.
- Die Ausführung der Coroutine, in der die View :: Handle-Funktion derzeit ausgeführt wird, wird angehalten.
- Wir sagen zum Kernel des Betriebssystems: "Stellen Sie die angehaltene Coroutine in die Warteschlange der Aufgaben, die zur Ausführung bereit sind, sobald genügend TCP-Pakete aus der Datenbank kommen."
- Ohne auf den vorherigen Schritt zu warten, starten wir eine weitere Coroutine, die zur Ausführung bereit ist, aus der Warteschlange.
Mit anderen Worten, die Funktion aus dem ersten Beispiel arbeitet asynchron und kommt einem solchen Code mit C ++ 20 Coroutines nahe:
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>()}; }
Das ist nur, dass der Benutzer nicht über co_await und co_return nachdenken muss, alles funktioniert "von alleine".
In unserem Framework ist das Wechseln zwischen Coroutinen schneller als das Aufrufen von std :: this_thread :: yield (). Der gesamte Microservice kostet sehr wenig Threads.
Im Moment enthält userver asynchrone Treiber:
* für Betriebssystem-Sockets;
* http und https (Client und Server);
* PostgreSQL;
* MongoDB;
* Redis;
* mit Dateien arbeiten;
* Timer;
* Grundelemente zum Synchronisieren und Starten neuer Coroutinen.
Der obige asynchrone Ansatz zum Lösen von E / A-gebundenen Aufgaben sollte Go-Entwicklern vertraut sein. Im Gegensatz zu Go erhalten wir jedoch keinen Overhead für Speicher und CPU vom Garbage Collector. Entwickler können eine reichhaltigere Sprache mit verschiedenen Containern und Hochleistungsbibliotheken verwenden, ohne an mangelnder Konsistenz, RAII oder Vorlagen zu leiden.
Userver - Komponenten
Ein vollwertiges Framework besteht natürlich nicht nur aus Coroutinen. Die Aufgaben der Entwickler in Taxi sind äußerst vielfältig und erfordern jeweils eigene Tools. Daher hat userver alles, was Sie brauchen:
* für die Protokollierung;
* Caching;
* mit verschiedenen Datenformaten arbeiten;
* mit Konfigurationen arbeiten und Konfigurationen aktualisieren, ohne den Dienst neu zu starten;
* verteilte Schlösser;
* Testen;
* Autorisierung und Authentifizierung;
* Metriken erstellen und senden;
* Schreiben von REST-Handlern;
+ Codegenerierung und Abhängigkeitsunterstützung (in einem separaten Teil des Frameworks).
Userver - Codegenerierung
Kehren wir zur ersten Zeile unseres Beispiels zurück und sehen, was sich hinter Antwort und Anfrage verbirgt:
Response Handle(Request&& request, const Dependencies& dependencies);
Mit userver können Sie jeden Microservice schreiben, aber für unsere Microservices ist es erforderlich, dass ihre APIs dokumentiert werden (beschrieben durch Swagger-Schemata).
Für den Griff aus dem Beispiel könnte das Prahlerdiagramm beispielsweise folgendermaßen aussehen:
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'
Nun, da der Entwickler bereits ein Schema mit einer Beschreibung der Anfragen und Antworten hat, warum nicht diese Anfragen und Antworten darauf basierend generieren? Gleichzeitig können im Schema auch Links zu protobuf / flatbuffer / ... -Dateien angegeben werden - die Codegenerierung aus der Anfrage selbst erhält alles, validiert die Eingabedaten gemäß dem Schema und zerlegt sie in die Felder der Antwortstruktur. Der Benutzer muss nur Funktionen in der Handle-Methode schreiben, ohne von der Boilerplate mit Anforderungsanalyse und Serialisierung der Antwort abgelenkt zu werden.
Gleichzeitig funktioniert die Codegenerierung für Servicekunden. Sie können angeben, dass für Ihren Dienst ein Client erforderlich ist, der nach einem solchen Schema arbeitet, und eine Klasse für die Erstellung asynchroner Anforderungen bereitstellen:
Request req; req.id = id; req.foo = foo; req.bar = bar; dependencies.sample_client.SomeSampleBarPost(req);
Dieser Ansatz hat ein weiteres Plus: Immer aktuelle Dokumentation. Wenn ein Entwickler plötzlich versucht, Parameter zu verwenden, die nicht in der Dokumentation enthalten sind, wird ein Kompilierungsfehler angezeigt.
Userver - Protokollierung
Wir lieben es, Protokolle zu schreiben. Wenn Sie nur die wichtigsten Informationen protokollieren, werden mehrere Terabyte Protokolle pro Stunde ausgeführt. Daher ist es nicht verwunderlich, dass unsere Protokollierung ihre eigenen Tricks hat:
* es ist asynchron (natürlich :-));
* Wir können das langsame std :: locale und std :: ostream umgehen.
* Wir können die Protokollierungsstufe im laufenden Betrieb ändern (ohne den Dienst neu zu starten).
* Wir führen keinen Benutzercode aus, wenn dieser nur für die Protokollierung benötigt wird.
Während des normalen Betriebs des Mikrodienstes wird beispielsweise die Protokollierungsstufe auf INFO und der gesamte Ausdruck festgelegt
LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();
wird nicht berechnet. Das Aufrufen der ressourcenintensiven Funktion GetSomeInfoFromDb () erfolgt nicht.
Wenn der Dienst plötzlich zu "täuschen" beginnt, kann der Entwickler dem funktionierenden Dienst immer mitteilen: "Im DEBUG-Modus anmelden". In diesem Fall werden die Einträge "ist nicht in Ordnung" in den Protokollen angezeigt. Die Funktion GetSomeInfoFromDb () wird ausgeführt.
Anstelle von Summen
In einem Artikel ist es unmöglich, alle Funktionen und Tricks auf einmal zu beschreiben. Deshalb haben wir mit einer kurzen Einführung begonnen. Schreiben Sie in die Kommentare, über welche Dinge von userver Sie interessiert wären, und lesen Sie darüber.
Jetzt überlegen wir, ob wir das Framework in Open Source veröffentlichen sollen. Wenn wir dies mit Ja entscheiden, erfordert die Vorbereitung des Frameworks zum Öffnen der Quelle viel Aufwand.