Zum Hauptinhalt springen

gRPC - Best Practices

Manuel

Ursprünglich auf dem Kreya Blog in Englisch veröffentlicht.

Die Erstellung und Verwendung von gRPC-Services ist ziemlich einfach. Es gibt jedoch ein paar Probleme und bewährte Lösungsansätze, die allen bekannt sein sollten. Kennst du zum Beispiel die standardmässige Grössenbegrenzung für Messages? Oder weisst du, dass protobuf seit v3.15 optionale Felder unterstützt? Ist dir bekannt weshalb Enum-Namen innerhalb eines protobuf-Pakets eindeutig sein sollten?

Wenn du die Antworten auf diese Fragen nicht kennst, dann ist dieser Blogbeitrag genau das Richtige für dich.

gRPC-APIs designen

Die Entwicklung von gRPC-Services unterscheidet sich von der Entwicklung von REST-Services, bei denen der API-Kontrakt (z.B. OpenAPI) oft aus der Serverimplementierung generiert wird. Bei gRPC ist es andersherum. Der API-Kontrakt wird zuerst in protobuf definiert, dann werden die Client- und Server-Stubs aus dieser API-Definition generiert. In diesem Abschnitt werden einige der besten Praktiken beim Entwurf von gRPC-APIs erläutert.

Style guide

Wenn du den protobuf style guide befolgst, bist du bereits auf einem sehr guten Weg! Die Einhaltung des Styleguides stellt sicher, dass Codegeneratoren die Definitionen korrekt in die sprachspezifische Namenskonvention umwandeln können. Zum Beispiel wird die folgende protobuf-Message

message Book {
string name = 1;
}

in den nachfolgenden Java Code übersetzt

public String getName() { ... }
public Builder setName(String v) { ... }

in C# würde es in etwa so aussehen:

public String Name { get; set; }

Die Durchsetzung dieses Styleguides sollte am besten durch einen Linter, wie z.B. protolint, in einer CI/CD-Pipeline erfolgen.

Getrennte Request- und Response-Messages

Ein weiteres Thema, das häufig auftaucht, ist die Trennung von Request- und Response-Messages. Wir empfehlen, dass du für jeden Request und jede Response eine eigene gRPC-Message erstellst. Benenne diese {MethodName}Request und {MethodName}Reponse. Auf diese Weise können Sie Request- und Response-Messages für eine einzelne gRPC-Methoden ändern, ohne versehentlich Änderungen an anderen Methoden vorzunehmen. Natürlich ist es verlockend, Messages wiederzuverwenden und Felder, die nicht benötigt werden, einfach zu ignorieren. Aber mit der Zeit führt dies zu einem Durcheinander, da nicht klar ist, welche Felder von der API erwartet werden. Ausnahmen von dieser Regel werden in der Regel bei der Rückgabe einer einzelnen, klar definierten Entität oder bei der Rückgabe einer leeren Message gemacht.

service BookService {
rpc CreateBook(Book) returns (Book); // sollte nicht gemacht werden
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse); // ist okay
rpc GetBook(GetBookRequest) returns (Book); // ist okay
rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty); // ist okay
}

Die leere Message ist bereits als google.protobuf.Empty definiert, sodass es keinen Sinn macht, eine weitere leere Message zu definieren. Man könnte argumentieren, dass es in diesem Fall besser wäre, eine leere DeleteBookResponse-Message zu verwenden, da man dieser Message dann in Zukunft Felder hinzufügen kann. Das stimmt zwar, aber die Wahrscheinlichkeit ist gross, dass Sie dieser Message nie ein Feld hinzufügen werden. Dennoch kann dies eine gute Strategie sein. Sei einfach konsequent und vermische diese beiden Ansätze nicht.

Ein ähnliches Argument kann für die Rückgabe einzelner Entitäten angeführt werden. Die Definition einer GetBookResponse-Message wäre durchaus zulässig, es wird nur nicht empfohlen, dies zu tun. Auch hier ist Konsistenz wichtig.

Enums

Enums können bei der Verwendung von gRPC lästig sein. Betrachte das folgende Beispiel:

enum Genre {
HORROR = 0;
FANTASY = 1;
ROMANCE = 2;
}

Dies ist ein gültiger Protobuf-Enum, hat aber mehrere Nachteile. Es wird empfohlen, den ersten Eintrag des Enums als {ENUM_NAME}_UNSPECIFIED zu definieren und ihm den Wert 0 zuzuweisen. Dies definiert ihn als Standardeintrag des Enums und er wird immer gesetzt, wenn kein anderer Wert explizit gesetzt wird. Wenn dieser Standardeintrag nicht angeben wird, kann nicht zwischen dem Standardwert und keinem Wert unterschieden werden (vor protobuf v3.15). Eine Ausnahme von dieser Regel wird gemacht, wenn es bereits einen sinnvollen Standardeintrag in der Aufzählung gibt, dann wird stattdessen dieser verwendet.

Ein weiteres Problem besteht darin, dass die Namen von Enum-Member im gesamten Paket eindeutig sein müssen. Die Definition eines völlig unzusammenhängenden Enum mit dem Eintrag ROMANCE führt zu einer Fehlermeldung, da Enums in C und C++ so implementiert sind. Um dies zu vermeiden, kann es nützlich sein, den Enum-Membern den Enum-Namen voranzustellen. Einige Codegeneratoren (z. B. für C#) entfernen diese Präfixe automatisch, sodass der resultierende Code wieder "sauber" zu lesen ist. Wenn keine doppelten Enum-Member vorhanden sind, kann der Präfix weglassen werden. Es sollte auf jeden Fall konsequent gemacht werden.

Eine bessere Enum-Definition würde daher wie folgt aussehen:

enum Genre {
GENRE_UNSPECIFIED = 0;
GENRE_HORROR = 1;
GENRE_FANTASY = 2;
GENRE_ROMANCE = 3;
}

Well-Known Typen

Viele neue gRPC-Anwender übersehen die Well-Known-Types. Diese Message-Typen sind standardmässig zusätzlich zu den skalaren Werttypen (string, float, ...) verfügbar. Anstatt z.B. einen eigenen Message-Typen Timestamp zu definieren, sollte der google.protobuf.Timestamp verwendet werden:

import "google/protobuf/timestamp.proto";

message Book {
google.protobuf.Timestamp creation_time = 1;
}

Versionierung und Breaking-Changes

Das gRPC-Protokoll unterstützt viele Änderungen, ohne bestehende Konsumenten der Schnittstelle zu beeinträchtigen. Zum Beispiel können folgende Änderungen für die Schnittstelle ohne Breaking-Change umgesetzt werden:

  • Hinzufügen eines Feldes zu einer Message
    • Der Standardwert wird gesetzt, wenn das Feld nicht gesendet wird
  • Umbenennen eines Feldes oder einer Message *
    • Die Feldnamen werden nicht serialisiert, nur die Feldnummern bzw. Tags
  • Löschen eines Feldes *
    • Ältere Clients "erhalten" weiterhin den Standardwert.
    • Denken Sie daran, entfernte Felder als reserviert zu kennzeichnen, z. B. "reserviert 2", damit sie in Zukunft nicht wieder verwendet werden.
  • Hinzufügen eines Wertes zu einem Enum
  • Hinzufügen einer neuen Message
  • Hinzufügen einer Methode zu einem bestehenden Service
  • Hinzufügen eines neuen Services

* Diese Änderungen führen nicht zu einer Änderung des gRPC-Verkehrs, welcher über das Netzwerk geht, bei der Aktualisierung der generierten Clients auf die neue Version müssen aber gegebenenfalls Änderungen vorgenommen werden.

Andere Änderungen, wie z.B. die Änderung einer Nummer/Tags eines Feldes oder das Entfernen einer Methode, stellen einen Breaking-Change dar. Beachte, dass die Änderung des Datentyps eines Feldes je nach den verwendeten Datentypen nicht Breaking sein muss. Weitere Informationen findest du in der proto3-Dokumentation.

Um Breaking-Changes in der Zukunft zu ermöglichen, schlägt die gRPC-Namenskonvention vor, die Versionsnummer als letzten Teil des Paketnamens zu verwenden:

package app.kreya.v1;

Wenn eine Breaking-Change eingeführt wird, sollte ein neues Paket mit einer neuen Versionsnummer erstellt werden. Das alte Paket soll so lange wie nötig beibehalten werden.

Optionale Felder

Betrachten wir die folgende proto3-Message, die das Feld "bar" definiert:

message Foo {
int32 bar = 1;
}

Mit dieser Definition ist es unmöglich zu überprüfen, ob bar auf 0 gesetzt wurde oder ob kein Wert gesetzt wurde, da der Standardwert von int32-Feldern 0 ist. Vor protobuf v3.15 wurde dies durch die Verwendung eines Wrapper-Typs gelöst:

import "google/protobuf/wrappers.proto";

message Foo {
google.protobuf.Int32Value bar = 1;
}

Da der Datentyp des Feldes "bar" nun ein Message-Type ist, kann er nicht gesetzt werden, und es kann unterschieden werden, ob "nichts" oder 0 gesetzt wurde.

Ab protobuf v3.15 (oder v3.12 über das --experimental_allow_proto3_optional Flag) wird field presence tracking über das optional Schlüsselwort unterstützt:

message Foo {
optional int32 bar = 1;
}

Dadurch werden die Methoden hasBar() und clearBar() (je nach Sprache) im generierten Code verfügbar.

Oneof

Oneof ist ein gutes Beispiel dafür, wie eine Protobuf-Sprachfunktion dazu beiträgt, gRPC-APIs intuitiv zu gestalten. Ein Beispiel: Stell dir vor, du hast eine Methode, mit der Benutzer ihr Profilbild ändern können, entweder mittels einer URL oder durch Hochladen eines eigenen (kleinen) Bildes. Statt zwei Felder zu führen

message ChangeProfilePictureRequest {
string image_url = 1;
bytes image_data = 2;
}

kann das gewünschte Verhalten direkt in der Message mit einer den folgenden Optionen definiert werden

message ChangeProfilePictureRequest {
oneof image {
string url = 1;
bytes data = 2;
}
}

Das ist nicht nur für API-Nutzer viel einfacher zu verstehen, sondern es ist auch einfacher zu überprüfen, welches Feld im generierten Code gesetzt wurde. Denk daran, dass oneof auch zulässt, dass keines der Felder gesetzt wurde, d.h. es besteht keine Notwendigkeit, ein separates "none"-Feld einzuführen, wenn das oneof optional sein sollte.

Untypisierte dynamische Daten

Es mag verlockend sein, untypisierte dynamische Daten als JSON zu serialisieren und als String zu senden. Das ist jedoch sehr ineffizient. Protobuf bietet zwei verschiedene Nachrichtentypen für dynamische Daten, abhängig vom Anwendungsfall. Wenn die JSON-Darstellung der Daten beispielsweise so aussieht und die Feldnamen und -typen nicht im Voraus kennen

{
"data": {
"some-value": 3
"some-other-value": "custom-string"
"some-array": [1, 2, 3],
"nested": {
"nested-string": "test"
}
]
}

ist es am besten einen Struct zu verwenden:

message StructTest {
google.protobuf.Struct data = 1;
}

Wenn dynamische protobuf Messages versendet werden müssen und deren Typ nicht bekannt ist, kann Any verwendet werden.

message LogEntry {
google.protobuf.Timestamp log_time = 1;
google.protobuf.Any log_message = 2; // Can be any protobuf message
}

Betrieb von gRPC-APIs

Die gRPC-API ist endlich definiert, ein Client und ein Server implementiert und im produktiven System deployed. Beim Betrieb von gRPC-Services können diverse Probleme auftreten. Im Folgenden haben wir die häufigsten uns bekannten Probleme aufgelistet.

Grosse gRPC-Messages

Da gRPC-Messages vollständig in den Arbeitsspeicher geladen werden, sollten grosse Messages vermieden werden. Insbesondere in Sprachen mit einem Garbage Collector. Bei der Implementierung oder Nutzung eines gRPC-Services sollte auch die standardmässige Begrenzung der Nachrichtengrösse von 4 MB beachtet werden. Auch wenn dieses Limit erhöht werden kann, ist es in den meisten Fällen wahrscheinlich keine gute Idee, da gRPC einfach nicht für grosse Messages ausgelegt ist.

Wenn es Anwendungsfälle gibt, in denen grosse Messages senden oder empfangen werden müssen, sollte ein separater HTTP-Endpunkt verwendet werden. Eine andere Möglichkeit wäre die Verwendung von gRPC-Streaming und die Aufteilung der grossen Datenmenge in mehrere überschaubare Teile (Chunks).

Load balancing

Load-Balancing für gRPC-Datenverkehr ist nicht so einfach wie Load-Balancing für HTTP/1.1. Bei HTTP/1.1 können Load-Balancer auf der Transportschicht (L4) arbeiten und einfach TCP-Verbindungen auf die Endpunkte verteilen. Dies ist bei gRPC nicht möglich, da üblicherweise HTTP/2 angewendet wird, das wiederum eine einzige TCP-Verbindung für alle Aufrufe nutzt. Um einen transparentes Load-Balancing mit gRPC zu erreichen, müssen Load-Balancer auf der Anwendungsschicht (L7) arbeiten, was sich negativ auf die Performance auswirken kann.

Ein anderer Ansatz wäre die Verwendung eines clientseitigen Load-Balancings, bei dem die Clients eine Liste aller verfügbaren gRPC-Endpunkte speichern (oder abrufen). Für jeden Aufruf wählt der Client dann einen anderen zu verwendenden Endpunkt aus. Dies führt zu einer besseren Lastverteilung, da kein Load-Balancing-Proxy beteiligt ist, kann aber schwieriger zu implementieren sein, da alle Clients die Liste der verfügbaren Endpunkte kennen müssen (und für jeden Endpunkt einen Channel erstellen müssen).

Channels wiederverwenden

Das Erstellen eines gRPC-Channels ist ein aufwendiger Prozess, da eine neue HTTP/2-Verbindung hergestellt werden muss. Ein Channel sollte, wann immer möglich, wiederverwendet werden. In der Dokumentation der gRPC-Implementierung der verwendeten Sprache kann nachgelesen werden, ob der Channel sicher über mehrere Threads hinweg verwendet werden kann und ob dieser für mehrere parallele Aufrufe verwendet werden kann.

Sollten ein Channel über längere Zeit inaktiv sein, muss die darunterliegende HTTP/2-Verbindungen aufrechterhalten werden. Andernfalls wird die Verbindung geschlossen und eine neue Verbindung muss von Grund auf neu aufgebaut werden. Dies kann mit keepalive pings gelöst werden. In vielen gRPC-Implementierungen gibt es eine explizite Unterstützung für diese Funktion.

Maximale Anzahl paralleler HTTP/2 Streams

HTTP/2 begrenzt die maximale Anzahl gleichzeitiger Streams (gleichzeitiger Anfragen) auf einer Verbindung. Da ein gRPC-Channel auf einer einzigen HTTP/2-Verbindung arbeitet, können viele gleichzeitige gRPC-Aufrufe an denselben Server dieses Limit überschreiten. Infolgedessen werden gRPC-Aufrufe, die dieses Limit überschreiten würden, in eine Warteschlange gestellt, bis ein vorheriger Aufruf abgeschlossen ist.

Einige gRPC-Implementierungen bieten bereits eine Lösung für dieses Problem, bei der zusätzliche HTTP/2-Verbindungen automatisch erstellt werden. Bei anderen muss dies manuell implementiert werden. Da jedoch die meisten Server das Limit auf 100 gleichzeitige Streams festlegen, wird dies nur selten zum Problem.

Abschluss

Bitte beachte, dass sich bewährte Verfahren im Laufe der Zeit ändern können. Es kann auch sein, dass nicht jeder mit der obigen Liste einverstanden ist, was auch völlig in Ordnung ist. Wir haben versucht, Fälle hervorzuheben und zu erklären, in denen Ausnahmen und Abweichungen von den üblichen Best Practices durchaus Sinn machen.

Als weitergehende Lektüre bietet Google unter https://google.aip.dev/general eine grossartige und umfangreiche Liste von Best Practices für das Design von gRPC-APIs an.

Wenn du Fragen oder Feedback hast, zögere nicht, uns unter [email protected] zu kontaktieren.