In de laatste twee edities van het magazine heb ik je laten zien hoe je gRPC en gRPC-Web binnen .NET kunt gebruiken op basis van een contract geschreven in protobuf. Dit is alleen niet de enige aanpak. In dit artikel zal ik je laten zien hoe je kunt werken met gRPC met een code first aanpak. De NuGet-packages die je hiervoor nodig hebt, zijn niet afkomstig van Microsoft, maar van het open source project “protobuf-net.Grpc”.
Door: Johan Smarius
Opzetten van het contract
Bij het gebruik van een code first aanpak moet je het contract in code op gaan nemen. Deze code zal moeten worden gedeeld tussen de server en de client. De gemakkelijkste manier om dit te doen, is om deze code op te nemen in een class library. Vanuit de server en de client kun je dan gemakkelijk een referentie opnemen naar deze class library. Als je je servercode in .NET 5 wilt maken, is het wel handig om ook het target framework voor deze class library op .NET 5 te zetten. Helaas kun je deze keuze nog niet maken bij het aanmaken van de library, dus dit moet je na het aanmaken even nog instellen bij de properties van je project (Figuur 1).
Voordat je het contract kunt coderen, moet je de NuGet-package “protobuf-net.Grpc” toevoegen aan het nieuw gemaakte project.
Je kunt nu het contract op twee manieren gaan definiëren. Voor contracten kun je gebruik maken van de types DataContract/DataMember (Figuur 2) en ServiceContract (Figuur 3). Je kent deze misschien nog wel van WCF.
Bij het datacontract is het belangrijk om het volgnummer van het veld expliciet op te nemen, net zoals je dat ook moet doen als je gebruik maakt van de normale protobuf-aanpak. In het datacontract kun je gebruik maken van de normale .NET typen.
Voor de interface moet je het attribuut “ServiceContract” gebruiken. De naam van de service wordt normaal bepaald aan de hand van de typenaam van de interface. Je kunt dit echter sturen door expliciet de naam op te geven bij het attribuut, zoals in de code van Figuur 3 gedaan is. In principe worden alle methoden binnen deze interface als beschikbare methoden voor het contract beschouwd. Om dit toch explicieter te maken, wordt in de voorbeeldcode altijd gebruik gemaakt van het attribuut “OperationContract”. Als returntype voor een methode kun je gebruik maken van een DataContract-type, void, Task of zoals in de voorbeeldcode gebruikt is ValueTask. Bij de normale protobuf-aanpak wordt standaard een synchrone en asynchrone methode gegenereerd op basis van het protobuf-bestand. Bij de code first aanpak wordt er geen code gegenereerd en moet je zelf expliciet een keuze maken door te kiezen voor een synchroon of asynchroon returntype. De code van een service wil je natuurlijk het liefst zo veel mogelijk asynchroon laten uitvoeren, dus in dit geval is Task of ValueTask het meest van toepassing. Om nu expliciet te maken dat een enkele waarde uit de methode wordt geretourneerd, is in deze voorbeeldcode gebruik gemaakt van ValueTask.
Voor simpele contracten waarbij je geen low-level invloed nodig hebt op het contract, voldoet deze aanpak. Als je meer controle nodig hebt, dan kun je in plaats van DataContract gebruik maken van de types ProtoContract en ProtoMember (Figuur 4). Beide contracten lijken heel erg op elkaar. Er wordt alleen gebruik gemaakt van andere attributen en de parameter voor het volgnummer hoef je niet meer expliciet te benoemen.
Voor dit eenvoudige voorbeeld is het gebruik van ProtoContract niet direct nodig, maar als je bijvoorbeeld gebruik wilt maken van DateTime typen in je contract, dan heb je deze constructie wel nodig (Figuur 5).
Met de code first aanpak hoef je geen gebruik meer te maken van het speciale type “google.protobuf.Timestamp”, maar moet je wel specificeren dat de omzetting door het protobuf-net.Grpc package moet worden geregeld. Hiervoor gebruik je de parameter DataFormat. Als je deze instelt op DataFormat.WellKnown, dan wordt de mapping van het interne type van en naar het .NET DateTime type automatisch voor je geregeld. In dit contract kun je ook zien dat collecties en enumeraties gewoon ondersteund worden. CustomerType is namelijk een normale C# enum (Figuur 6).
Alle code in dit artikel maakt gebruik van de ProtoContract-variant, omdat ik binnen een codebase graag gebruik maak van dezelfde variant.
Door het coderen van je data- en servicecontract ben je klaar met het definiëren van je interface van de service.
Coderen van de server
Voor het coderen van de server kun je in principe als basis werken met een ASP.NET Core Web Application. De voorbeeldcode maakt echter gebruik van de normale gRPC-template binnen Visual Studio. De normale aanpak via protobuf contracten en de code first aanpak kunnen zonder problemen naast elkaar gebruikt worden en om dit te laten zien, heb ik voor de gRPC-template gekozen. Om je geheugen nog even op te frissen, zal ik de stappen die hiervoor nodig zijn nog even herhalen. Bovendien is een scherm in de template een heel klein beetje veranderd sinds het eerste artikel.
In het startscherm kun je kiezen voor “Create a new project” (Figuur 7).
In de templatepagina (Figuur 8) kun je nu zoeken naar gRPC. Voor gRPC is er op dit moment maar een template beschikbaar. Als je deze template kiest en op “Next” drukt, dan kom je terecht op de pagina die voor alle templates gebruikt wordt om de project naam, locatie en solution name in te stellen (Figuur 9).
Als je nu op “Create” klikt dan kun je nog wat aanvullende opties instellen (Figuur 10).
Het is belangrijk om te controleren dat .NET 5 als framework geselecteerd staat en niet .NET Core 3.1. Als je nu weer op “Create” drukt, dan wordt de code voor je server gegenereerd.
Om de code voor de server te kunnen schrijven, moet je het NuGet-package “protobuf-net.Grpc.AspNetCore” toevoegen (Figuur 11).
De definitie van de interface staat in de Class Library die je aangemaakt hebt, dus daar moet je ook een project referentie naar leggen (Figuur 12).
De implementatiecode voor de server kun je het beste plaatsen in de map “Services”. Je kunt hiervoor gewoon een nieuw bestand maken van het type “Class”.
De hele implementatie van de server bestaat uit het implementeren van de interface (Figuur 13). Voor deze service komt dat vooral neer op het aanmaken van het response bericht.
Binnen deze code kun je ook gewoon gebruik maken van dependency injection (Figuur 14).
De server moet natuurlijk nog wel bekend gemaakt worden aan .NET. Hiervoor moeten wat aanpassingen in de Startup.cs worden gedaan.
Binnen de ConfigureServices methode moet de ondersteuning voor code first gRPC toegevoegd worden aan de services-collectie. Hiervoor is een aparte methode “AddCodeFirstGrpc” beschikbaar (Figuur 15). De normale gRPC-ondersteuning en de code first variant kunnen gewoon naast elkaar bestaan. In de code zie je dus ook gewoon de normale “AddGrpc” terug die door de standaard Microsoft implementatie gebruikt wordt.
De service zelf moet natuurlijk in de routing opgenomen worden. Net als bij de normale gRPC kun je dit bereiken door het type via de methode MapGrpcService toe te voegen aan de endpoints collectie (Figuur 16).
De code voor de server is hiermee volledig.
Gebruik maken van een service
Om gebruik te kunnen maken van de service moet je de NuGet-package “protobuf-net.Grpc” toevoegen aan je code en heb je natuurlijk ook een project referentie nodig naar de Class Library waarin de interface staat.
Voordat de service aangemaakt kan worden, moet je eerst een channel maken (Figuur 17).
De client kun je nu aanmaken door expliciet de service aan te maken (Figuur 18).
Via deze client kun je dan weer de methode aanroepen, die je in het servicecontract op hebt genomen (Figuur 19). Bij het opzetten van het contract was al beschreven dat code first niet automatisch synchrone en asynchrone methoden voor je maakt. In deze afbeelding is dat ook duidelijk te zien.
Voor het aanroepen moet natuurlijk nog wel een EchoRequest aan worden gemaakt (Figuur 20).
Bij het uitvoeren van deze code moet je er natuurlijk wel weer rekening mee houden dat je 2 uitvoerbare projecten hebt. Voor het makkelijk debuggen moet ik dus beide applicaties starten (Figuur 21).
De output van de draaiende applicaties wordt getoond in 2 console windows. In deze consoles kun je zien dat de code inderdaad werkt (Figuur 22). In de logging kun je ook zien dat de naam van de service inderdaad “SDN.EchoService” is zoals opgegeven in de Name-parameter van het ServiceContract (Figuur 3).
Uitgebreidere mogelijkheden
De EchoService is natuurlijk nog redelijk beperkt. Voor het eerste artikel uit deze reeks heb ik een uitgebreider voorbeeld gemaakt. Dit voorbeeld heb ik overgezet naar een code first aanpak. Aan de hand van deze code zal ik je nog wat uitgebreidere mogelijkheden laten zien.
In het begin van dit artikel heb ik bij het behandelen van het opzetten van het contract al laten zien dat ook enumeraties en collecties binnen een contract mogelijk zijn. In principe kun je bij het opzetten van het contract complete boomstructuren gebruiken. De enige beperking is dat dit niet mag resulteren in een graaf, omdat er dan cyclische afhankelijkheden ontstaan. Voor WCF-ontwikkelaars is dit geen onbekende beperking.
Bij de code first aanpak kun je gewoon gebruik maken van de .NET collecties die je al gewend bent om te gebruiken. Persoonlijk gebruik ik over het algemeen IEnumerable<T> voor collecties, om de exacte keuze niet in de interface vast te leggen en op die manier wat flexibiliteit te hebben bij de implementatie van de service. Bij het aanmaken van een collectie voor een aanroep, kun je gewoon gebruik maken van object en collection initializers (Figuur 23).
Als je binnen je contracten gebruik wilt maken van void of een lege parameterlijst voor een methode, dan wordt dit gewoon ondersteund. Je hebt hiervoor geen speciaal type nodig om dit aan te geven. Voor een methode die geen resultaat terug hoeft te geven, maar die wel asynchroon moet zijn, kun je gebruik maken van Task of ValueTask (Figuur 24).
Streaming binnen de code first aanpak wordt ook ondersteund door gebruik te maken van het type “IAsyncEnumerable”. Je kunt dit type gebruiken als returntype, maar ook als type voor een methodeparameter (Figuur 25).
In de code van de server kun je nu nieuwe returnwaarden aanmaken en via “yield return” teruggeven (Figuur 26).
In principe kan de server eeuwig nieuwe waarden blijven sturen. Om de client een mogelijkheid te geven om aan geven dat de stroom mag stoppen, kun je gebruik maken een CancellationToken. Dit token is opgenomen in de CallContext klasse uit de ProtoBuf.Grpc namespace (Figuur 27).
In de uitwerking van de servicemethode heb ik ervoor gekozen om de code te splitsen in een private methode die alleen weet heeft van een cancellationtoken (Figuur 28) en de methode die nodig is vanuit de interface. Om het cancellationtoken goed te laten werken, moet je bij de parameter het attribuut “EnumeratorCancellation” gebruiken. De compiler geeft hier ook een waarschuwing voor als je dit per ongeluk zou vergeten.
Met deze combinatie van methoden kan de client een context meegeven, maar is dat niet verplicht.
De client code kan gebruik maken van de await foreach constructie om de stroom van nieuwe data- elementen op te vangen. Deze code is opgenomen in een console applicatie dus om het annuleren te simuleren, wordt de foreach na 100 iteraties geforceerd afgebroken (Figuur 29). Bij het stoppen van de async stream wordt wel een RpcException gegooid, maar die kun je veilig negeren. Door het gebruik van een filter, kun je dit negeren ook heel specifiek doen.
Op dit moment ondersteunt protobuf-net.Grpc nog niet de integratie met het DI-framework van ASP.NET Core via de GrpcClientFactory. Als je dus gebruik wilt maken van een andere service binnen je code van een service, dan moet je expliciet de client aanmaken (Figuur 30).
Je kunt deze code natuurlijk wel netter maken door zelf met factory patterns te gaan werken. Het gebrek aan ondersteuning voor de GrpcClientFactory heeft wel tot gevolg dat deze variant niet goed samen kan werken met de gRPC-Web toevoeging aan gRPC.NET. Dit heeft natuurlijk ook gevolgen voor de flexibiliteit die je op dit moment hebt in de keuze van je hosting opties.
Samenvatting
In dit artikel heb ik de belangrijkste aspecten voor het bouwen van een gRPC service op basis van een code first aanpak behandeld. Met deze variant kun je gemakkelijk een contract in je code schrijven. Dit contract kan dan gedeeld worden tussen de server en de client. Het ontwikkelteam hoeft op deze manier veel minder kennis te hebben van protobuf en hoeft bovendien veel minder rekening te houden met een aantal specifieke typen, met een nogal aparte syntax, die bij het gebruik van de standaard protobuf aanpak helaas nodig is. Toch gaat mijn eigen voorkeur uit naar de protobuf aanpak. Vooral door het gebrek aan ondersteuning van GrpcClientFactory en daarmee dus ook gRPC-Web zal ik in de praktijk maar in heel specifieke gevallen voor deze code first aanpak kiezen. Voor ontwikkelteams die komen vanuit WCF kan deze optie wel heel handig en laagdrempelig zijn.
Op mijn github (https://github.com/JohanSmarius) heb ik een aantal gRPC demo projecten staan. De code uit dit artikel staan in de repo’s: “GrpcDemoCodeFirst” (port van de gRPC services uit het eerste artikel) en “GrpcServiceFromCode” (EchoService).
Biografie
Johan Smarius is een ervaren (lead) software developer, architect, spreker en trainer op het Microsoft .NET vlak met meer dan 25 jaar ervaring in de IT. Hij heeft ervaring met .NET sinds versie 1.0. Johan werkt als docent informatica bij Avans Hogeschool in Breda en als technical consultant bij zijn eigen bedrijf JMAC Software Solutions. In zijn vrije tijd leert Johan kinderen programmeren en is hij instructeur EHBO. Website: https://www.johansmarius.com