gRPC in .NET Core 3.0

gRPC is een RPC (Remote Procedure Call) framework dat origineel ontwikkeld is door Google. Sinds 2015 werkt Google hier via een open source project aan. In .NET Core 3.0 wordt dit volledig ondersteund. Maar wat is gRPC precies? gRPC is een framework waarbij m.b.v. een binair transport protocol over HTTP/2 gecommuniceerd wordt. Dit zorgt voor efficiëntie maar biedt ook mogelijkheden als twee richtingen communicatie. Zowel de client als de server kunnen berichten naar elkaar sturen. Daarnaast wil gRPC taal en platform onafhankelijk zijn. gRPC werkt o.b.v. een universele taal “Protocol Buffers”. Met deze taal leg je het contract tussen de client en de server vast. O.b.v. dit contract kan er met gRPC code gegenereerd worden voor vrijwel alle moderne programmeertalen (en dus ook C#).

Protocol buffers

Zoals gezegd gebruikt gRPC de universele taal ‘Protocol buffers’ om het contract vast te leggen. Dit ziet er als volgt uit:

syntax = "proto3";

option csharp_namespace = "gRPCDemo";

package gRPCDemo;

// The service definition.
service DemoService {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
    
  rpc SayMultipleHello(HelloRequest) returns (stream HelloReply) {}
  
  rpc SayOneHelloForMultipleNames(stream HelloRequest) returns (HelloReply) {}
  
  rpc SayOneHelloInBothWays(stream HelloRequest) returns (stream HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings.
message HelloReply {
  string message = 1;
}

In basis is het redelijk voor zichzelf sprekend. Je geeft een namespace op (in dit geval gRPCDemo) en je definieert een service (in dit geval Greeter). Binnen de service kun je vervolgens methoden definiëren. Dit geef je aan met het ‘rpc’ keyword. In dit geval wordt er een SayHello methode gedefinieerd. Deze heeft als input een HelloRequest en als output een HelloReply. Deze types staan vervolgens weer daaronder gedefinieerd m.b.v. het ‘message’ keyword.

Elke message heeft een aantal properties. Elke property heeft een type (bijvoorbeeld string of int). Vervolgens heeft ook elke property een index nummer. Dit is een belangrijk onderdeel van gRPC want deze nummers dienen uniek te zijn. Dit wordt gebruikt om het veld in het binaire message format te identificeren (zie ook https://developers.google.com/protocol-buffers/docs/encoding). Deze unieke nummers dienen ook na contract wijzigingen uniek te blijven. Om die reden bestaat ook het reserved keyword. Daarmee kunnen velden die bestaan hebben maar niet meer bestaan nooit dubbel gebruikt worden:

message Foo {
   reserved 2, 15, 9 to 11;
   reserved "foo", "bar";
}

Naast enkele velden biedt ‘Protocol Buffers’ ook ondersteuning voor lijsten. Hiervoor bestaat het repeated keyword:

message SearchResponse {
  repeated Result results = 1;
}

Wat verder nog belangrijk is is dat je bij elke RPC bij zowel het request als bij de response het ‘stream’ keyword kan gebruiken. Dit geeft aan of het een request of response is. Hierdoor wordt het mogelijk om een client bijvoorbeeld meerdere requests te laten sturen voordat er een response komt of andersom. Meer hierover verderop.

In basis hebben we nu de ‘Protocol Buffers’ taal te pakken maar er is natuurlijk nog veel meer mogelijk. Kijk hiervoor op https://developers.google.com/protocol-buffers/docs/proto3.

Soorten RPC’s

Binnen gRPC worden 4 soorten methoden onderkend:

  • Unary RPCs –> Client stuurt 1 request en server geeft 1 response terug.
  • Server RPCs –> Client stuurt 1 request en server geeft meerdere berichten terug. De client blijft lezen totdat er geen berichten meer komen.
  • Client RPCs –> Client stuurt een aantal berichten naar de server (gebruikt een stream). Zodra de client stopt met sturen wacht het tot de server een response teruggeeft.
  • Bidirectional –> Client en Server sturen over elk hun eigen stream berichten heen en weer.

Voorbeeld in .NET Core 3.0

In de huidige versie van Visual Studio 2019 kun je een protocol buffer file toe voegen via Add à New Item. Dit zorgt er helaas nog niet voor dat er ook code gegenereerd wordt voor deze file. Om dit te doen dien je bij de properties van de .proto file aan te geven dat de protobuf compiler deze file moet compileren:

Dit geeft het volgende resultaat in de project file:

  <itemgroup>
    <protobuf include="Protos\gRPCDemo.proto" grpcservices="Server">
    </protobuf>
  </itemgroup>

Wanneer dit gebeurd is, zal op elke build nieuwe code worden gegenereerd o.b.v. deze file. Met het attribuut GrpcServices kun je aangeven of er code moet worden gegenereerd voor de Server, Client of beide. Nu de code gegenereerd is kan deze aangeroepen worden. Er wordt altijd een Client class gegenereerd welke in de constructor een Grpc.Core.Channel verwacht. Met deze Channel class kun je aangeven op welk adres de server draait:

const int Port = 50051;
const string Host = "127.0.0.1";

var channel = new Channel(Host, Port, ChannelCredentials.Insecure);
var client = new gRPCDemo.DemoService.DemoServiceClient(channel);

Nu de client gecreëerd is kan deze worden gebruikt om een methode aan te roepen.

Voorbeeld van een Unary RPC call

Wanneer er in de .proto file zowel bij de request als de response geen ‘stream’ keyword staat zal het een traditionele request – reply methode worden. Dit ziet er als volgt uit:

var call = await client.SayHelloAsync(new gRPCDemo.HelloRequest { Name = "James Bond" });
Console.WriteLine($"Response is { call.Message }");

In het bovenstaande voorbeeld komt er als response op de SayHelloAsync een AsyncUnaryCall terug. Het betreft hier een AsyncUnaryCall omdat het stream keyword in de .proto file niet is gebruikt.

Voorbeeld van een RPC

Een Server RPC kan in de .proto file als volgt gedefinieerd worden:

rpc SayMultipleHello(HelloRequest) returns (stream HelloReply) {}

Doordat bij de HelloReply het ‘stream’ keyword staat kunnen er vanaf de server meerdere replies naar de client gestreamd worden. In het geval van een Server RPC zal er aan de client altijd één request zijn waarop meerdere response berichten komen. Dit ziet er in code als volgt uit:

var serverCall = client.SayMultipleHello(new gRPCDemo.HelloRequest { Name = "James Bond" });
while (await serverCall.ResponseStream.MoveNext(new System.Threading.CancellationToken()))
{
     Console.WriteLine($"Server response {serverCall.ResponseStream.Current.Message}");
}

Wat je hier ziet is dat de RPC methode nu een AsyncServerCall terug geeft. Deze class heeft een property ResponseStream. Deze stream kunnen we vervolgens blijven lezen totdat er geen resultaat meer komt.

Voorbeeld van een Client RPC

Een Client RPC kan in de .proto file als volgt gedefinieerd worden:

rpc SayOneHelloForMultipleNames(stream HelloRequest) returns (HelloReply) {}

In dit geval is het ‘stream’ keywordt gebruikt bij het request. Dit zorgt ervoor dat de server meerdere requests accepteert voordat het een response terug gaat sturen. De client zal daarbij wel moeten aangeven wanneer hij klaar is met het versturen van zijn requests. De volgende code laat dit zien:

var clientCall = client.SayOneHelloForMultipleNames();
await clientCall.RequestStream.WriteAsync(new gRPCDemo.HelloRequest { Name = "Monneypenny" });
await clientCall.RequestStream.WriteAsync(new gRPCDemo.HelloRequest { Name = "Q" });
await clientCall.RequestStream.WriteAsync(new gRPCDemo.HelloRequest { Name = "James Bond" });
await clientCall.RequestStream.CompleteAsync();
Console.WriteLine($"Response is { clientCall.ResponseAsync.Result.Message }");

De CompleteAsync methode is in deze belangrijk omdat daarmee een signaal aan de server wordt gegeven dat er geen requests meer zullen volgen.

Aan de server kant moet ook naar meerdere requests geluisterd worden. De volgende code laat zien hoe dit gebeurt:

public override async Task SayOneHelloForMultipleNames(IAsyncStreamReader requestStream, ServerCallContext context)
{
    var names = new List();
    while (await requestStream.MoveNext())
    {
        names.Add(requestStream.Current.Name);
    }
    return await Task.FromResult(new HelloReply() { Message = $"Hello all {String.Join(", ", names)}" });
}

De methode waarin de server kant is geïmplementeerd krijgt i.p.v. het request een requestStream binnen. Op deze stream kan hij luisteren totdat er geen bericht meer komt (de CompleteAsync van de client). Vervolgens zal de server één resultaat teruggeven voor all requests.

Zowel de client als de server kunnen gecombineerd worden in een Bidirectional methode. In dat geval dient zowel bij het request als de response het stream keyword gebruikt te worden. Verder werkt het dan precies zoals bij de Client en de Server RPC.

Tot zover deze blogpost over gRPC in .NET Core 3.0. Alle voorbeeld code kun je hier downloaden.