ServiceModel.Grpc

Code-first for gRPC

View on GitHub

ServiceModel.Grpc

ServiceModel.Grpc enables applications to communicate with gRPC services using a code-first approach (no .proto files), helps to get around limitations of gRPC protocol like “only reference types”, “exact one input”, “no nulls”, “no value-types”. Provides exception handling. Helps to migrate existing WCF solution to gRPC with minimum effort.

The library supports lightweight runtime proxy generation via Reflection.Emit and C# source code generation.

The solution is built on top of gRPC C# and grpc-dotnet.

Usage

Declare a service contract

[DataContract]
public class Person
{
    [DataMember]
    public string FirstName { get; set; }

    [DataMember]
    public string SecondName { get; set; }
}

[ServiceContract]
public interface IGreeter
{
    [OperationContract]
    Task<string> SayHello(Person person, CancellationToken token = default);

    [OperationContract]
    ValueTask<(string Greeting, IAsyncEnumerable<string> Greetings)> Greet(IAsyncEnumerable<Person> persons, string greeting, CancellationToken token = default);
}

Client call (Reflection.Emit)

A proxy for IGreeter service will be generated on demand via Reflection.Emit.

PS> Install-Package ServiceModel.Grpc
// create a channel
var channel = new Channel("localhost", 5000, ...);

// create a client factory
var clientFactory = new ClientFactory();

// request the factory to generate a proxy for IGreeter service
var greeter = clientFactory.CreateClient<IGreeter>(channel);

// call SayHello
var greet = await greeter.SayHello(new Person { FirstName = "John", SecondName = "X" });

// call Greet
var (greeting, greetings) = await greeter.Greet(new[] { new Person { FirstName = "John", SecondName = "X" } }, "hello");

Client call (source code generation)

A proxy for IGreeter service will be generated in the source code.

PS> Install-Package ServiceModel.Grpc.DesignTime
// request ServiceModel.Grpc to generate a source code for IGreeter service proxy
[ImportGrpcService(typeof(IGreeter))]
internal static partial class MyGrpcServices
{
    // generated code ...
    public static IClientFactory AddGreeterClient(this IClientFactory clientFactory, Action<ServiceModelGrpcClientOptions> configure = null) {}
}

// create a channel
var channel = new Channel("localhost", 5000, ...);

// create a client factory
var clientFactory = new ClientFactory();

// register IGreeter proxy generated by ServiceModel.Grpc.DesignTime
clientFactory.AddGreeterClient();

// create a new instance of the proxy
var greeter = clientFactory.CreateClient<IGreeter>(channel);

// call SayHello
var greet = await greeter.SayHello(new Person { FirstName = "John", SecondName = "X" });

// call Greet
var (greeting, greetings) = await greeter.Greet(new[] { new Person { FirstName = "John", SecondName = "X" } }, "hello");

ServiceModel.Grpc.DesignTime uses roslyn source generators.

Implement a service

internal sealed class Greeter : IGreeter
{
    public Task<string> SayHello(Person person, CancellationToken token = default)
    {
        return string.Format("Hello {0} {1}", person.FirstName, person.SecondName);
    }

    public ValueTask<(string Greeting, IAsyncEnumerable<string> Greetings)> Greet(IAsyncEnumerable<Person> persons, string greeting, CancellationToken token)
    {
        var greetings = DoGreet(persons, greeting, token);
        return new ValueTask<(string, IAsyncEnumerable<string>)>((greeting, greetings));
    }

    private static async IAsyncEnumerable<string> DoGreet(IAsyncEnumerable<Person> persons, string greeting, [EnumeratorCancellation] CancellationToken token)
    {
        await foreach (var person in persons.WithCancellation(token))
        {
            yield return string.Format("{0} {1} {2}", greeting, person.FirstName, person.SecondName);
        }
    }
}

Host the service in the asp.net core application

PS> Install-Package ServiceModel.Grpc.AspNetCore
var builder = WebApplication.CreateBuilder();

// enable ServiceModel.Grpc
builder.Services.AddServiceModelGrpc();

var app = builder.Build();

// // bind Greeter service
app.MapGrpcService<Greeter>();

Integrate with Swagger, see example

UI demo

Host the service in Grpc.Core.Server

PS> Install-Package ServiceModel.Grpc.SelfHost
var server = new Grpc.Core.Server
{
    Ports = { new ServerPort("localhost", 5000, ...) }
};

// bind Greeter service
server.Services.AddServiceModelTransient(() => new Greeter());

Server filters

see example

var builder = WebApplication.CreateBuilder();

// setup filter life time
builder.Services.AddSingleton<LoggingServerFilter>();

// attach the filter globally
builder.Services.AddServiceModelGrpc(options =>
{
	options.Filters.Add(1, provider => provider.GetRequiredService<LoggingServerFilter>());
});

internal sealed class LoggingServerFilter : IServerFilter
{
    private readonly ILoggerFactory _loggerFactory;

    public LoggingServerFilter(ILoggerFactory loggerFactory)
    {
        _loggerFactory = loggerFactory;
    }

    public async ValueTask InvokeAsync(IServerFilterContext context, Func<ValueTask> next)
    {
        // create logger with a service name
        var logger = _loggerFactory.CreateLogger(context.ServiceInstance.GetType().Name);

        // log input
        logger.LogInformation("begin {0}", context.ContractMethodInfo.Name);
        foreach (var entry in context.Request)
        {
            logger.LogInformation("input {0} = {1}", entry.Key, entry.Value);
        }

        try
        {
            // invoke all other filters in the stack and the service method
            await next().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            // log exception
            logger.LogError("error {0}: {1}", context.ContractMethodInfo.Name, ex);
            throw;
        }

        // log output
        logger.LogInformation("end {0}", context.ContractMethodInfo.Name);
        foreach (var entry in context.Response)
        {
            logger.LogInformation("output {0} = {1}", entry.Key, entry.Value);
        }
    }
}

Data contracts

By default, the DataContractSerializer is used for marshaling data between the server and the client. This behavior is configurable, see examples ProtobufMarshaller, MessagePackMarshaller, CustomMarshaller.

[DataContract]
public class Person
{
    [DataMember]
    public string Name { get; set;}

    [DataMember]
    public DateTime BirthDay { get; set;}
}

Service contracts

A service contract is a public interface marked with ServiceContractAttribute. Methods marked with OperationContractAttribute are gRPC calls.

for net462 System.ServiceModel.dll, for netstandard package System.ServiceModel.Primitives

[ServiceContract]
public interface IPersonService
{
    // gRPC call
    [OperationContract]
    Task Ping();

    // method is not gRPC call
    Task Ping();
}

Operation contracts

Any operation in a service contract is one of gRPC method: Unary, ClientStreaming, ServerStreaming or DuplexStreaming.

Context parameters

ServiceModel.Grpc.CallContext

// contract
[OperationContract]
Task Ping(CallContext context = default);

// client
await client.Ping(new CallOptions(....));

// server
Task Ping(CallContext context)
{
    // take ServerCallContext
    Grpc.Core.ServerCallContext serverContext = context;
    var token = serverContext.CancellationToken;
    var requestHeaders = serverContext.RequestHeaders;
}

Grpc.Core.CallOptions

// contract
[OperationContract]
Task Ping(CallOptions context = default);
[OperationContract]
Task Ping(CallOptions? context = default);

// client
await client.Ping(new CallOptions(....));

// server
Task Ping(CallOptions context)
{
    // the following properties are copied from the current Grpc.Core.ServerCallContext
    var token = context.CancellationToken;
    var requestHeaders = context.RequestHeaders;
    var deadline = context.Deadline;
    var writeOptions = context.WriteOptions;
}

Grpc.Core.ServerCallContext

// contract
[OperationContract]
Task Ping(ServerCallContext context = default);

// client
await client.Ping();

// server
Task Ping(ServerCallContext context)
{
    var token = context.CancellationToken;
    var requestHeaders = context.RequestHeaders;
}

System.Threading.CancellationToken

// contract
[OperationContract]
Task Ping(CancellationToken token = default);
[OperationContract]
Task Ping(CancellationToken? token = default);

// client
var tokenSource = new CancellationTokenSource();
await client.Ping(tokenSource.Token);

// server
Task Ping(CancellationToken token)
{
    // the token was copied from the current Grpc.Core.ServerCallContext
    if (!token.IsCancellationRequested)
    {
        // ...
    }
}

Limitations