Juli 2019

Band 34, Nummer 7

[Cutting Edge]

ASP.NET Core: gRPC-Dienste

Von Dino Esposito | Juli 2019

Dino EspositoUrsprünglich bei Google entwickelt, ist gRPC heute ein RPC-Framework (Remote Procedure Call, Remoteprozeduraufruf), das als Alternative zu RESTful- und HTTP-basierten Schnittstellen zur Verbindung von Remotekomponenten und insbesondere Microservices entstanden ist. Das neue RPC-Framework wurde teilweise für die Nutzung mit modernen Technologien wie HTTP/2 und Protobuf entwickelt.

Das gRPC-Framework bietet native Bindungen für eine Reihe von Programmiersprachen (einschließlich C#), und die Verwendung aus ASP.NET- und ASP.NET Core-Microservices heraus war nie ein Problem. Es ist jedoch erwähnenswert, dass frühere .NET-Implementierungen von gRPC native DLLs in Wrapper verpackt und den Dienst auf einem eigenen Server gehostet haben. In .NET Core 3.0 ist ein gRPC-Dienst jedoch eine vollständige .NET-Implementierung, die unter Kestrel gehostet wird, wie andere ASP.NET-Webanwendungen auch. Dieser Artikel befasst sich mit der Visual Studio 2019-Projektvorlage und der dedizierten ASP.NET Core-Ebene.

Erstellen eines gRPC-Diensts in Visual Studio 2019

Wenn Sie sich für die Erstellung einer neuen ASP.NET Core-Webanwendung entscheiden, bietet Ihnen Visual Studio 2019 die Möglichkeit, eine neue Art von Komponente zu erstellen: einen gRPC-Dienst. Wenn Sie fortfahren und den Assistenten abschließen, erhalten Sie ein minimales ASP.NET Core-Projekt mit dem kanonischen Dateipaar „startup.cs“ und „program.cs“ sowie einige ungewöhnliche neue Ordner namens „protos“ und „services“. Die Datei „program.cs“ ist nichts Besonderes, aber die Datei „startup.cs“ ist durchaus einen Blick wert.

Die Configure-Methode der Startup-Klasse enthält die folgende Zeile:

public void ConfigureServices(IServiceCollection services)
{
  services.AddGrpc();
}

Erwartungsgemäß ist AddGrpc eine Erweiterungsmethode der IServiceCollection-Klasse. Wenn Sie neugierig sind und sich in die Interna der Methode vertiefen möchten, finden Sie hier eine Zusammenfassung dessen, was Sie herausfinden werden (dies sind wahrscheinlich keine Informationen, die Sie jemals verwenden werden, aber es ist wie ein Blick hinter die Kulissen):

services.AddRouting();
services.AddOptions();
services.TryAddSingleton<GrpcMarkerService>();
services.TryAddSingleton<ServiceMethodsRegistry>();
services.TryAddSingleton(typeof(ServerCallHandlerFactory<>));

Der Aufruf von AddRouting ist etwas funktionaler, da er die Verwendung von globalem Routing (eine neue Funktion, die mit ASP.NET Core 2.2 eingeführt wurde) für die Kommunikation zwischen gRPC-Clients und den gRPC-Endpunkten im ASP.NET Core-Dienst ermöglicht, der erstellt wird. Die drei Singletons, die der Laufzeitumgebung des Diensts hinzugefügt werden, übernehmen die gesamte Verwaltung des Dienstlebenszyklus: Factory, Ermittlung und Aufruf.

In der Configure-Methode der Startup-Klasse der Anwendung werden die Verwendung des globalen Routingsystems und die erforderlichen Endpunkte wie folgt deklariert:

app.UseRouting();
app.UseEndpoints(endpoints =>
{
  endpoints.MapGrpcService<GreeterService>();
});

Ein gRPC-Dienst basiert nicht auf Controllern, sondern verwendet eine Dienstklasse, die für jede Anforderung erstellt wird, um den Clientaufruf zu verarbeiten. Diese Dienstklasse heißt im Beispielcode, den die Vorlage generiert, GreeterService. Die MapGrpcService<T>-Methode erstellt eine Bindung der URL des gRPC-Aufrufs an einen Aufrufhandler, der bei der Verarbeitung der Anforderung aufgerufen wird. Die Handlerfactory wird abgerufen und verwendet, um eine neue Instanz der T-Klasse zu erstellen, damit die angeforderte Aktion stattfinden kann. Dies ist ein wesentlicher Unterschied zwischen der ASP.NET Core 3.0-Implementierung von gRPC und der vorhandenen C#-Implementierung. Beachten Sie jedoch, dass es immer noch möglich ist, dass eine Instanz der Dienstklasse als Singleton aus dem DI-Container aufgelöst wird.

Der Prototyp der Dienstklasse

Im vorlagenbasierten Beispielprojekt wird die Dienstklasse wie folgt definiert:

public class GreeterService : Greeter.GreeterBase
{
  ...
}

Auf den ersten Blick könnte man meinen, dass die Implementierung eines gRPC-Diensts (ähnlich wie reine Controllerklassen) aus dem Schreiben einer Klasse mit einer Reihe öffentlicher Aktionsmethoden besteht. Nun, das ist nicht ganz richtig. Natürlich enthält das Visual Studio-Projekt eine Datei mit einer Klasse, die wie im vorherigen Code gezeigt definiert ist. Es gibt jedoch keine Stelle im gleichen Projekt, an der die Basisklasse der Dienstklasse im Codeausschnitt (der Greeter.GreeterBase-Klasse) definiert wird. Wie ist das möglich? Die Antwort liegt im Quellcode einer anderen Datei (einer kleinen Textdatei), die im Ordner „protos“ abgelegt wurde (siehe Abbildung 1).

Projektmappenordner eines gRPC-Projekts
Abbildung 1: Projektmappenordner eines gRPC-Projekts

Der Ordner „protos“ enthält mindestens eine Textdatei mit der Erweiterung .proto, bekannt als Protocol Buffer-Definitionsdateien (Protokollpuffer) oder kurz Protobuf-Dateien. Normalerweise ist für jeden Dienst im ASP.NET Core-Dienst eine solche Datei vorhanden, aber es gibt keine wirklichen Einschränkungen. Tatsächlich können Sie einen Dienst in einer Datei und die von ihm verwendeten Nachrichten in einer anderen Datei definieren oder mehrere Dienste in derselben Datei definieren. Die PROTO-Textdatei enthält die Schnittstellendefinition des Diensts. So sieht der Inhalt der PROTO-Datei für den Begrüßungsbeispieldienst aus:

syntax = "proto3";
package Greet;service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
  string name = 1;
}
message HelloReply {
  string message = 1;
}

Wenn der erste Inhalt kein Syntaxelement ist, nimmt der Proto-Compiler eine ältere Proto2-Syntax an. Die neueste PROTO-Syntax ist stattdessen proto3. Als nächstes finden Sie den Namen des zu erstellenden Pakets.

Der Dienstabschnitt gibt die Anwendungsprogrammierschnittstelle (API) an, nämlich die Liste der aufrufbaren Endpunkte. Beachten Sie, dass SayHello eine unäre Methode ist, nämlich eine Methode, die wie ein normaler Funktionsaufruf funktioniert: Der Client sendet eine einzelne Anforderung an den Server und erhält eine einzelne Antwort zurück. Darüber hinaus unterstützt gRPC Client-, Server- und bidirektionale Streamingmethoden. Diese ermöglichen es dem Client, Nachrichten in einen Datenstrom zu schreiben und eine Antwort zu erhalten oder eine Nachricht zu senden und einen Datenstrom von Antwortnachrichten zu erhalten bzw. einen Datenstrom mit einem Server zum Lesen und Schreiben gemeinsam zu verwenden. Sie beschreiben Streamingmethoden in einer Protobuf-Datei wie folgt mit dem Schlüsselwort „stream“:

rpc SayHello1 (HelloRequest) returns (stream HelloReply) {}
rpc SayHello2 (stream HelloRequest) returns (HelloReply) {}
rpc SayHello3 (stream HelloRequest) returns (stream HelloReply) {}

Die Nachrichtenabschnitte in der Proto-Datei beziehen sich auf Nachrichtentypen, die von den Methoden verwendet werden. Mit anderen Worten: Der Nachrichtenabschnitt definiert einen der benutzerdefinierten Datenübertragungsobjekttypen, die von den öffentlichen Methoden verwendet werden. Im Beispielcodeausschnitt besteht der Dienst namens „Greeter“ aus einer RPC-Methode namens SayHello. Die Methode nimmt einen Eingabeparameter vom Typ HelloRequest an und gibt einen Ausgabewert in Form einer Instanz vom Typ HelloReply zurück. Beide Typen werden durch eine einzelne Zeichenfolgeneigenschaft erstellt. Der Integerwert (in diesem Fall 1), der den Eigenschaften beider Nachrichten zugewiesen ist, gibt die Feldnummer an und bestimmt die Position des spezifischen Inhalts im binären Nachrichtenformat, das über das Netzwerk übertragen wird. Daher sollten diese Werte nach der Bereitstellung des Diensts nicht mehr geändert werden.

Details der Nachrichtenelemente

Bei der Definition der Nachrichtentypen in der Proto-Datei können Sie eine Reihe von primitiven Typen verwenden, darunter bool, int32, sint32, double, float und eine lange Liste anderer numerischer Varianten (int64, fixed32, uint32 und viele mehr). Sie können auch den bytes-Typ für eine beliebige Folge von Bytes verwenden. Insbesondere der Typ sint32 ist ideal für Integerwerte mit Vorzeichen geeignet, da dieses Format zu einer effizienteren Codierung von negativen Werten führt. Beachten Sie, dass die oben genannten Typen die in der Proto3-Syntax definierten Typen sind. Jede unterstützte Sprache (z.B. C#) verwandelt sie dann in sprachspezifische Typen. In C# können Sie long, bool, int, float und double verwenden. Eigenschaften erhalten einen Standardwert, der (zumindest in C#) mit dem Standardwert des Sprachtyps übereinstimmt.

Das Schema einer Nachricht ist in dem Sinne teilweise variabel, dass jede deklarierte Eigenschaft optional ist. Wird die gleiche Eigenschaft jedoch mit dem wiederholten Schlüsselwort markiert, ist sie immer noch optional, kann aber mehrmals wiederholt werden. Dies erklärt, warum die Feldnummer relevant ist, da sie als Platzhalter für den eigentlichen Inhalt verwendet wird, wie hier gezeigt:

message ListOfCitiesResponse {
  repeated string city = 1;
}

Neben primitiven skalaren Typen unterstützt die Proto-Syntax auch Enumerationen. Enum-Typen können in der Proto-Datei oder sogar inline im Text des Nachrichtentyps wie folgt definiert werden:

enum Department {
  Unknown = 0;
  ICT = 1;
  HR = 2,
  Accounting = 3;
}

Beachten Sie, dass das erste Element den Wert 0 annehmen muss. Sie dürfen Member mit dem gleichen Wert verwenden, solange Sie dies mit der Option allow_alias deklarieren, wie hier gezeigt:

enum Department {
  option allow_alias = true;
  Unknown = 0;
  ICT = 1;
  HR = 2,
  Accounting = 3;
  Finance = 3;
}

Ohne die Option allow_alias erhalten Sie einen Kompilierungsfehler bei wiederholten Enum-Werten. Wenn die Enumeration in der Proto-Datei global definiert ist, können Sie sie nur anhand des Namens in den verschiedenen Nachrichtentypen verwenden. Wenn sie im Text eines Nachrichtentyps definiert ist, können Sie ihr den Namen des Nachrichtentyps als Präfix voranstellen. Abbildung 2 zeigt dies.

Abbildung 2: Eingebettet Definition von Enum-Typen

message EmployeeResponse {
  string firstName = 1;
  string lastName = 2;
  enum Department {
    Unknown = 0;
    ICT = 1;
    HR = 2;
  }
  Department currentDepartment = 3;
}
message ContactResponse {
  ...  EmployeeResponse.Department department = 3;
}

Sie können alle Nachrichtentypen in derselben Proto-Datei und auch die in externen Proto-Dateien definierten Nachrichtentypen frei referenzieren, sofern Sie sie zuerst importieren. Dies ist der Code:

import "protos/another.proto";

Eigenschaften eines Nachrichtentyps sind nicht auf skalare Typen und Enumerationen beschränkt. Ein Nachrichtenelement kann auf einen anderen Nachrichtentyp verweisen, und zwar unabhängig davon, ob seine Definition in die Nachricht eingebettet, global in der Proto-Datei vorhanden oder aus einer anderen Proto-Datei importiert ist.

Wenn Sie während der Lebensdauer des Diensts einen der Nachrichtentypen aktualisieren müssen, achten Sie einfach darauf, die Feldnummer nicht erneut zu verwenden. Sie können jederzeit neue Elemente hinzufügen, solange der Code weiß, wie mit Clients umzugehen ist, die möglicherweise Pakete ohne das zusätzliche Element senden. Sie können auch Felder zu entfernen. In diesem Fall ist es jedoch wesentlich, dass die Nummer des entfernten Felds nicht wiederverwendet wird. Dies würde tatsächlich zu Verwirrung und Konflikten führen. Um dies zu verhindern, können Sie die kritische Feldnummer wie folgt als reserviert deklarieren:

message PersoneResponse {
  reserved 1, 2, 5 to 8;
  reserved "gender", "address";
  ...
}

Sie können Feldnummern (auch mithilfe der erweiterten N-zu-M-Syntax) sowie Feldnamen reservieren. Das gleiche gilt für Einträge in einem Enum-Typ. Trotzdem ist es meistens besser, das Feld einfach mit einem Präfix wie NOTUSED_ umzubenennen.

Diese Erklärung berücksichtigt nicht alle möglichen Variationen der Proto3-Syntax. Weitere Informationen finden Sie unter bit.ly/2Hz5NJW.

Die eigentliche Dienstklasse

Der Quellcode der PROTO-Datei wird im Hintergrund verarbeitet, um eine Basisklasse (die fehlende Greeter.GreeterBase-Klasse) zu generieren, die die Voraussetzungen für die gRPC-Client/-Serverkommunikation schafft. Sie finden den tatsächlichen Quellcode der Basis im Ordner „\Debug\netcoreapp3.0“ des Projekts. Abbildung 3 zeigt einen Auszug.

Abbildung 3: Automatisch generierte rRPC-Dienstklasse

public static partial class Greeter
{
  public abstract partial class GreeterBase
  {
    public virtual Task<Greet.HelloReply> SayHello(
      Greet.HelloRequest request,
      ServerCallContext context)
    {
      throw new RpcException();
    }
    ...
  }
}

Diese Datei wird generiert, nachdem Sie den Buildvorgang ausgeführt haben. Sie ist vorher nicht vorhanden. Beachten Sie außerdem, dass Ihr Projekt einen Verweis auf das Grpc.Tools-Paket aufweisen muss.

Abgesehen von der PROTO-Textdatei und dem Vorgang im Hintergrund, um sie in eine Basisklasse zu kompilieren, unterscheidet sich die sich ergebende Dienstklasse nicht wirklich von einem reinen MVC-Controller. Wie Sie sehen können, besteht die Klasse aus einigen überschriebenen öffentlichen Methoden und ihrer tatsächlichen Implementierung:

public override Task<HelloReply> SayHello(
  HelloRequest request, ServerCallContext context)
{
  return Task.FromResult(new HelloReply
  {
    Message = "Hello " + request.Name
  });
}

Im Text der Methode können Sie beliebige Aktionen ausführen, die für die jeweilige Aufgabe sinnvoll sind, eine Datenbank oder einen externen Dienst aufrufen oder eine erforderliche Berechnung durchführen. Damit die Dienstklasse Aufrufe empfängt, müssen Sie den gRPC-Server starten, um über einen konfigurierten Port zu lauschen. Werfen wir nun einen Blick auf einen gRPC-Client.

Schreiben eines gRPC-Clients

Die ASP.NET Core-Serveranwendung weist Abhängigkeiten vom ASP.NET Core gRPC-Paket sowie vom Google.ProtoBuf-Kernprotokoll auf. Es besteht auch eine Abhängigkeit vom Grpc.Tools-Paket, aber nicht für Laufzeitaktionen. Das Paket ist für die Verarbeitung des Inhalts der Proto-Textdatei verantwortlich. Eine Clientanwendung kann eine Konsolenanwendung mit Abhängigkeiten nur vom Grpc.Core-Paket, vom Google.ProtoBuf-Paket und von Grpc.Tools sein.

Um eine Abhängigkeit für den eigentlichen Dienst hinzuzufügen, stehen Ihnen zwei Optionen zur Verfügung. Eine Option besteht im Hinzufügen eines Verweises auf die Proto-Datei, damit die Tools diese Aufgabe erledigen können. Die andere Option ist das Erstellen eines dritten Projekts (Klassenbibliothek), das nur die Proto-Datei enthält. Anschließend verknüpfen Sie die sich ergebende Assembly sowohl mit einem Client- als auch mit einem Serverprojekt. Um auf die Proto-Datei zu verweisen, kopieren Sie den Ordner „protos“ aus dem Serverprojekt in das Clientprojekt und bearbeiten die CSPROJ-Datei, indem Sie den folgenden Code hinzufügen:

<ItemGroup>
  <Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
</ItemGroup>

Dann schreiben Sie Code zum Öffnen eines Kanals und geben Aufrufe aus. Es ist erwähnenswert, dass das gRPC-Framework das binäre ProtoBuf-Protokoll für Remoteaufrufe verwendet, das wiederum HTTP/2 als Transportprotokoll verwendet. (Beachten Sie, dass „ProtoBuf“ die Standardeinstellung ist, aber Sie können theoretisch auch andere Serialisierungs-/Deserialisierungsstapel verwenden.) Hier ist die erforderliche Konfiguration für den Aufruf eines gRPC-Diensts. Beachten Sie, dass es bis zur Veröffentlichung von ASP.NET Core 3.0 einen verwalteten gRPC-Client geben wird, der den vorherigen Code zum Erstellen eines Clients ändert:

var channel = new Channel(serviceUrl, ChannelCredentials.Insecure);
var client = new Greeter.GreeterClient(channel);

Standardmäßig ist die Dienst-URL „localhost“, und der Port (wie im Serverprojekt konfiguriert) ist 50051. Aus dem Clientverweis rufen Sie prototypisierte Methoden wie im folgenden Code gezeigt auf, als wäre es ein lokaler Aufruf:

var request = new HelloRequest { Name = name };
var response = client.SayHelloAsync(request);
Console.WriteLine(response.Message);

Die sich ergebende Ausgabe wird in Abbildung 4 gezeigt.

Client- und Serveranwendungen in Aktion
Abbildung 4: Client- und Serveranwendungen in Aktion

Letztendlich weist gRPC eine auffällige Analogie zum altmodischen Distributed COM der 1990er Jahre auf. Wie DCOM ermöglicht gRPC es Ihnen, Remoteobjekte so aufzurufen, als wären sie lokal, und zwar über ein binäres und superschnelles Protokoll, das HTTP/2 nutzt.

gRPC ist nicht REST und auch nicht perfekt, aber es ist eine weitere Option und vollständig Open-Source. Es ist wahrscheinlich verfrüht zu sagen, ob gRPC REST als Vorliebe der Entwickler ersetzen wird. Sicherlich gibt es bereits einige konkrete und realistische Microservicesszenarien, in denen gRPC wirklich von Vorteil ist. Einen nützlichen Vergleich zwischen gRPC und REST finden Sie hier: bit.ly/30VB7do.


Dino Esposito hat in seiner 25-jährigen Karriere über 20 Bücher und mehr als 1.000 Artikel verfasst. Als Autor von „The Sabbatical Break“, einer theatralisch angehauchten Show, schreibt Esposito Software für eine grünere Welt als digitaler Stratege bei BaxEnergy. Folgen Sie ihm auf Twitter: @despos.

Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: John Luo, James Newton-King


Diesen Artikel im MSDN Magazine-Forum diskutieren