DEV Community

Cover image for Dotnetifying gRPC: Sane Edition
João Antunes
João Antunes

Posted on • Updated on

Dotnetifying gRPC: Sane Edition

So last time I played around with gRPC (and wrote about it) I went a little bit insane with a different way of implementing and invoking gRPC services in .NET (you can take a look at it here). Although it was a fun experiment and I could even see it being used, the reality is it’s better to stick closer to the official and supported way of doing things. With that in mind, this time I went on the completely opposite direction, so it’ll work more as a guide of how to integrate gRPC in a .NET application. I built a library out of this, but it’s really so simple, it’ll be mostly for me to use, as others will probably avoid adding an extra external dependency for not so much added value.

The accompanying code for this post is here.

Quick gRPC service initial setup walk-through

Maybe it’s a bit of a repetition of my previous post on gRPC (and others around the internets) but to add a little more context to the rest of the post (and I don’t have to jump around posts when I can’t remember what I’ve done), I’ll start with how to go about the initial setup of a gRPC service.

Service definition

The first thing to do is defining the service, much like one would create a WSDL before implementing a SOAP service (I know, the usual way is to implement the service and get the generated contract afterwards, but it shouldn’t!), in gRPC we create a proto file (or more) with the service definition - its methods, input and output messages.

syntax = "proto3";

option csharp_namespace 
    = "CodingMilitia.GrpcExtensions.ScopedRequestHandlerSample.Generated";

service SampleService {
    rpc Send(SampleRequest) returns (SampleResponse) {}
}

message SampleRequest {
    int32 value = 1;
}

message SampleResponse {
    int32 value = 1;
}
Enter fullscreen mode Exit fullscreen mode

So, here I’m defining a service called SampleService with a method Send that receives a SampleRequest parameter and returns a SampleResponse - such naming imagination right?
This is a unary method definition, simple request response. In gRPC we have some streaming options, but like I said, I'm keeping it simpler for now, next time I'll play around with streaming.
Finally, it’s rather obvious but, the csharp_namespace option indicates the namespace to which the generated classes will belong to.

Code generation

With the service definition done, we need to generate the code to use in our applications. The tooling for the code generation is installed along with the Grpc.Tools NuGet package. With the tooling in place, for this simple case I’m describing, it’s just a command away from getting the required client and base server code,

./protoc service.proto --csharp_out ./Generated/. --grpc_out ./Generated/. --plugin=protoc-gen-grpc=grpc_csharp_plugin
Enter fullscreen mode Exit fullscreen mode

With our code generated, we can move on to using it.

Client

On the client side of things I really didn’t add anything to the library as the generated one really doesn’t require a lot of configuration.

One of the generated classes for our sample is the SampleServiceClient. To use it in an application, for instance a web application, we just need to configure it in the Startup class as following.

services.AddSingleton(_ =>
{
    var channel = new Channel(
        "127.0.0.1:5050", 
        ChannelCredentials.Insecure
    );
    return new SampleServiceClient(channel);
});
Enter fullscreen mode Exit fullscreen mode

Of course the hardcoded url and credentials configuration part is good only for sample purposes.

Server

For the server side of things is where I really felt the need to create something to help with hosting the service. Before looking into hosting though, let’s first take a look at some required considerations on implementing the service logic.

Implementing the service

The service implementation is the same for the most part, as we must inherit from a generated base service class, in this case SampleServiceBase. Now the problem with implementing our logic directly in the class that inherits from the generated one is that it will live as a singleton class, so if we want to have scoped dependencies we’ll need to bring the dependency injection stuff into the class, making it more of a service locator thing, which isn’t as nice.

So to reduce the need fiddle around with DI when we should be focusing on the service’s logic, the best way is probably to implement the service logic in another class, which in the simplest case (like this unary method case we’re using as sample) doesn’t even need to know anything about gRPC.

public class RandomSampleServiceLogic : ISampleServiceLogic
{
    private static readonly Random RandomSource = new Random();
    private readonly ILogger<RandomSampleServiceLogic> _logger;

    public RandomSampleServiceLogic(
        ILogger<RandomSampleServiceLogic> logger
    )
    {
        _logger = logger;
    }
    public async Task<SampleResponse> SendAsync(
        SampleRequest request, 
        CancellationToken ct
    )
    {
        _logger.LogInformation(
            "Received request with value {requestValue}",
            request.Value
        );

        _logger.LogInformation(
            "Simulating slow operation with a delay for request value {requestValue}", 
            request.Value
        );

        await Task.Delay(1000, ct);

        var response = new SampleResponse
        {
            Value = request.Value + RandomSource.Next()
        };

        _logger.LogInformation(
            "Random response to request with value {requestValue} will be {responseValue}",
            request.Value,
            response.Value
        );

        return response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the CancellationToken is passed as an argument to SendAsync method, as in this simple case it is completely oblivious of being part of a gRPC service, and if needed could be used as is as the logic provider of a REST API or any other type of API.

Then the service implementation to be hosted can be something like the following.

public class AnotherSampleServiceImplementation : SampleServiceBase
{
    private readonly IServiceScopeFactory _scopeFactory;

    public AnotherSampleServiceImplementation(
        IServiceScopeFactory scopeFactory
    )
    {
        _scopeFactory = scopeFactory;
    }

    public override async Task<Generated.SampleResponse> Send(
        Generated.SampleRequest request, 
        ServerCallContext context
    )
    {
        using (var scope = _scopeFactory.CreateScope())
        {
            var service = scope.ServiceProvider
                .GetRequiredService<ISampleServiceLogic>();

            var response = await service.SendAsync(
                request.ToInternalRequest(), 
                context.CancellationToken
            );

            return response.ToExternalResponse();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Alternatively I created a IScopedExecutor that can be used to abstract away this, but in reality there’s not that much need for it.

public class SampleServiceImplementation : SampleServiceBase
{
    private readonly IScopedExecutor _scopedExecutor;

    public SampleServiceImplementation(
        IScopedExecutor scopedExecutor
    )
    {
        _scopedExecutor = scopedExecutor;
    }

    public override async Task<Generated.SampleResponse> Send(
        Generated.SampleRequest request, 
        ServerCallContext context
    )
    {
        return await _scopedExecutor
            .ExecuteAsync<ISampleServiceLogic, Generated.SampleResponse>(
                async (service) =>
                {
                    var response = await service.SendAsync(
                        request.ToInternalRequest(), 
                        context.CancellationToken);

                    return response.ToExternalResponse();
                });
    }
}
Enter fullscreen mode Exit fullscreen mode

Hosting

Now that we have the service implemented, we can host it. As I mentioned, this is where I really felt I could put something up to simplify things.

To start with, how to host the service? The simpler way is probably just to instantiate the Server class, configure it and call the Start method on it. The problem with this approach is if we want to use DI, we need to set it all up by hand. A good alternative to this, taking advantage of what’s already done for us, is implementing a background service, using the IHostedService interface provided with Microsoft.Extensions.Hosting and then registering it with a HostBuilder (much like the usual WebHostBuilder we use in ASP.NET Core applications).

So with this in mind I started by implementing the GrpcBackgroundService class. This is a pretty simple class, receiving the Server instances to host in the constructor, so only one GrpcBackgroundService instance is needed even if we want to host multiple services in multiple Servers (we can also host multiple services in a single Server).

internal class GrpcBackgroundService : IHostedService
{
    private readonly IEnumerable<Server> _servers;
    private readonly ILogger<GrpcBackgroundService> _logger;

    public GrpcBackgroundService(
        IEnumerable<Server> servers, 
        ILogger<GrpcBackgroundService> logger
    )
    {
        _servers = servers;
        _logger = logger;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogDebug("Starting gRPC background service");

        foreach(var server in _servers)
        {
            StartServer(server);
        }

        _logger.LogDebug("gRPC background service started");

        return Task.CompletedTask;
    }



    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogDebug("Stopping gRPC background service");

        var shutdownTasks = _servers
            .Select(server => server.ShutdownAsync()).ToList();

        await Task.WhenAll(shutdownTasks).ConfigureAwait(false);

        _logger.LogDebug("gRPC background service stopped");
    }

    private void StartServer(Server server)
    {
        _logger.LogDebug(
            "Starting gRPC server listening on: {hostingEndpoints}",
            string.Join(
                "; ", 
                server.Ports.Select(p => $"{p.Host}:{p.BoundPort}")
            )
        );

        server.Start();

        _logger.LogDebug(
            "Started gRPC server listening on: {hostingEndpoints}",
            string.Join(
                "; ", 
                server.Ports.Select(p => $"{p.Host}:{p.BoundPort}")
            )
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Then to assist in registering the services I created some extensions methods for the IServiceCollection interface. Below I included only the ones I find most interesting for this scenario, as the others do little more than registering the provided Server to DI along with the GrpcBackgroundService.

//...

public static IServiceCollection AddGrpcServer<TService>(
    this IServiceCollection serviceCollection,
    IEnumerable<ServerPort> ports,
    IEnumerable<ChannelOption> channelOptions = null
)
    where TService : class
{
    return serviceCollection.AddGrpcServer(
            serverConfigurator: 
                configurator => configurator.AddService<TService>(),
            ports: ports,
            channelOptions: channelOptions
        );
}

public static IServiceCollection AddGrpcServer(
    this IServiceCollection serviceCollection,
    Action<IGrpcServerBuilder> serverConfigurator,
    IEnumerable<ServerPort> ports,
    IEnumerable<ChannelOption> channelOptions = null
)
{
    if (serviceCollection == null)
    {
        throw new ArgumentNullException(nameof(serviceCollection));
    }

    if (serverConfigurator == null)
    {
        throw new ArgumentNullException(nameof(serverConfigurator));
    }

    if (ports == null)
    {
        throw new ArgumentNullException(nameof(ports));
    }

    if (!ports.Any())
    {
        throw new ArgumentException(
            message: "At least one port must be specified", 
            paramName: nameof(ports)
        );
    }

    var builder = new GrpcServerBuilder(
        serviceCollection, 
        ports, 
        channelOptions
    );
    serverConfigurator(builder);
    builder.AddServerToServiceCollection();
    serviceCollection
        .AddGrpcBackgroundServiceIfNotAlreadyRegistered();

    return serviceCollection;
}

//...
Enter fullscreen mode Exit fullscreen mode

The bulk of the implementation is in the second version of the AddGrpcServer method I show, so the first one is only a simplified version that uses the second as base.

Regarding the methods signature, the first one is a version that registers a single service (being it TService, passed as a generic argument), using the provided ports and channel options to configure the Server. The second version allows for the registration of multiple services in the same Server, getting a Action<IGrpcServerBuilder> serverConfigurator as argument with that goal. The IGrpcServerBuilder interface exposes an AddService method that allows for the multiple desired services to be registered.
With all the required dependencies, an instance of the IGrpcServerBuilder implementation (GrpcServerBuilder) is created and takes care of building a Server to be hosted by the application.

internal class GrpcServerBuilder : IGrpcServerBuilder
{
    private readonly IServiceCollection _serviceCollection;
    private readonly IEnumerable<ServerPort> _ports;
    private readonly IEnumerable<ChannelOption> _channelOptions;
    private readonly List<ServiceRegistrationInfo> _registrationInfo;
    private bool _built;

    public GrpcServerBuilder(
        IServiceCollection serviceCollection, 
        IEnumerable<ServerPort> ports,
        IEnumerable<ChannelOption> channelOptions
    )
    {
        if (ports == null)
        {
            throw new ArgumentNullException(nameof(ports));
        }

        if (!ports.Any())
        {
            throw new ArgumentException(
                message: "At least one port must be specified", 
                paramName: nameof(ports)
            );
        }

        _serviceCollection = serviceCollection;
        _ports = ports;
        _channelOptions = 
            channelOptions ?? Array.Empty<ChannelOption>();
        _registrationInfo = new List<ServiceRegistrationInfo>();
        _built = false;

    }

    public IGrpcServerBuilder AddService<TService>() 
        where TService : class
    {
        ThrowIfServerAlreadyBuilt();

        var serviceType = typeof(TService);
        if (
            _serviceCollection.Any(
                s => s.ServiceType.Equals(serviceType)
            ) 
            || 
            _registrationInfo.Any(
                s => s.ServiceType.Equals(serviceType)
            )
        )
        {
            throw new InvalidOperationException(
                $"{typeof(TService).Name} is already registered in the container."
            );
        }

        _serviceCollection.AddSingleton<TService>();
        var serviceBinder = ServerBuildHelpers.GetServiceBinder<TService>();

        //Storing a lambda to use later, because this avoids 
        //reflection tricks later when we don't have access 
        //to the TService type so easily to invoke the binder.

        //Also, not invoking it immediately to keep it lazy.

        _registrationInfo.Add(
            new ServiceRegistrationInfo(
                serviceType, 
                appServices 
                    => serviceBinder(appServices.GetRequiredService<TService>())
            )
        );

        return this;
    }

    public void AddServerToServiceCollection()
    {
        ThrowIfServerAlreadyBuilt();

        if (_registrationInfo.Count == 0)
        {
            throw new InvalidOperationException(
                "Must add at least one service for a server to be created."
            );
        }

        _serviceCollection.AddSingleton(appServices =>
        {
            var server = _channelOptions.Count() > 0 
                ? new Server(_channelOptions) 
                : new Server();

            server.AddPorts(_ports);

            foreach (var serviceDefinition in _registrationInfo)
            {
                server.AddServices(
                    serviceDefinition
                        .ServiceDefinitionProvider(appServices)
                );
            }
            return server;
        });

        _built = true;
    }

    private void ThrowIfServerAlreadyBuilt()
    {
        if (_built)
        {
            throw new InvalidOperationException(
                "Server already build."
            );
        }
    }

    private class ServiceRegistrationInfo
    {
        public Type ServiceType { get; }
        public Func<IServiceProvider, ServerServiceDefinition> 
            ServiceDefinitionProvider { get; }

        public ServiceRegistrationInfo(
            Type serviceType, 
            Func<IServiceProvider, ServerServiceDefinition> 
                serviceDefinitionProvider
        )
        {
            ServiceType = serviceType;
            ServiceDefinitionProvider = serviceDefinitionProvider;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The AddService method makes some initial validity checks, registers the services in the DI container and stores some information to use when finally building the service. The most important part of this information to use when building the Server is what I called the serviceBinder, which is used to bind the service implementation to the ServerServiceDefinition that is registered to the Server. We can see this method in the generated SampleService class with the name BindService. Initially I was passing this method as an argument to the AddGrpcServer methods, but then I went ahead and created an helper to fetch this for me given the service implementation class.

//Using reflection tricks and assumptions on the way 
//the core gRPC lib works so, if Google changes this, 
//it'll break and only be noticed at runtime :)
public static Func<TService, ServerServiceDefinition> GetServiceBinder<TService>()
{
    var serviceType = typeof(TService);
    var baseServiceType = GetBaseType(serviceType);
    var serviceDefinitionType = typeof(ServerServiceDefinition);

    var serviceContainerType = baseServiceType.DeclaringType;
    var methods = serviceContainerType
        .GetMethods(BindingFlags.Public | BindingFlags.Static);

    var binder =
        (from m in methods
            let parameters = m.GetParameters()
            where m.Name.Equals("BindService")
                && parameters.Length == 1
                && parameters.First()
                    .ParameterType.Equals(baseServiceType)
                && m.ReturnType.Equals(serviceDefinitionType)
            select m)
    .SingleOrDefault();

    if (binder == null)
    {
        throw new InvalidOperationException(
            $"Could not find service binder for provided service {serviceType.Name}"
        );
    }

    var serviceParameter = Expression.Parameter(serviceType);

    var invocation = Expression.Call(
        null, 
        binder, 
        new[] { serviceParameter }
    );

    var func = Expression
        .Lambda<Func<TService, ServerServiceDefinition>>(
            invocation, 
            false, 
            new[] { serviceParameter }
        ).Compile();

    return func;
}
Enter fullscreen mode Exit fullscreen mode

This uses reflection to go through the class hierarchy of the service class to find the binder method. It’s probably a bit risky to count that this won’t change in future releases of the gRPC libraries, but for now it works well enough and simplifies the server configuration.

Getting back to the GrpcServerBuilder, when all the services are registered the Server is built and added to DI service collection with a call to AddServerToServiceCollection.

Wrapping up the AddGrpcServer method, the GrpcBackgroundService is added to the service collection (only once) so it can get the registered Servers and host them.

Everything that’s being added to DI is using the singleton scope, as we’ll only have one background service running with one (or more) Server listening for requests at all the time. As the Server requires the services instances to be registered at startup time, the services are also registered as singleton. As I mentioned earlier regarding implementing the service, it is the service that’s responsible for taking care of the lifecycle of its dependencies, hence the recommendations I made.

Configuring the application

Now to configure the application, it’s just a matter of creating an HostBuilder and using the previously described extensions to configure the services.

class Program
{
    static async Task Main(string[] args)
    {
        var serverHostBuilder = new HostBuilder()
        .ConfigureAppConfiguration((hostingContext, config) =>
        {
            //...
        })
        .ConfigureLogging((context, logging) =>
        {
            //...
        })
        .ConfigureServices((hostContext, services) =>
        {
            services
            .AddScoped<
                ISampleServiceLogic, 
                RandomSampleServiceLogic
            >()
            .AddScopedExecutor()
            //the most "magic" solution
            .AddGrpcServer<SampleServiceImplementation>(
                new[] { 
                    new ServerPort(
                        "127.0.0.1", 
                        5050, 
                        ServerCredentials.Insecur
                    e) 
                }
            )
            //a more manual solution if the flexibility is required
            //also not using the IScopedExecutor (although it could) 
            //for a more traditional example
            .AddGrpcServer(appServices =>
            {
                var scopeFactory 
                    = appServices.GetRequiredService<
                        IServiceScopeFactory
                    >();
                var server = new Grpc.Core.Server
                {
                    Services = { 
                        SampleService.BindService(
                            new AnotherSampleServiceImplementation(
                                scopeFactory
                            )
                        ) 
                    },
                    Ports = { 
                        new ServerPort(
                            "127.0.0.1", 
                            5051, 
                            ServerCredentials.Insecure
                        ) 
                    }
                };
                return server;
            });
        });

        await serverHostBuilder.RunConsoleAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

To begin registering the services, the ISampleServiceLogic is added as scoped, followed by the registration of the IScopedExecutor I mentioned earlier.

Then, just for the sample, I’m using 2 different extensions to register the gRPC service implementations, so we can see the available possibilities:
In the first case, the easiest to use solution, providing the class implementing the service and its configurations, allowing for the extension method to handle the rest of the work.

In the second case, I’m passing in a factory that returns a Server. In this case, the extension method only responsibility is passing the factory in to DI and registering the GrpcBackgroundService.

With everything configured we’re left with starting hosting the services by calling RunConsoleAsync on the HostBuilder.

Wrapping up

Like I mentioned in the beginning, the helper library I created isn’t really very complex and some stuff is maybe unnecessary, so the main take away out of this are the things to be aware when we want to host a gRPC service in as similar fashion as possible to a ASP.NET Core application:

  • Implementing IHostedService to host the Server
  • Beware that dependency injection is not baked into gRPC core libraries, so some extra hoops are in our way to use it properly

Regarding this library, it’s on GitHub and I’ll probably create a NuGet package out of it one of these days, even if only for my own usage.

There have been hints in the past to support gRPC out of the box in ASP.NET Core (like this one here, or this), which would simplify a lot, but we're not there yet.

Any suggestions on improving the code (or the article) please do share.

Thanks for reading through, cyaz!

PS: originally posted here

Top comments (0)