Skip to content

max-ieremenko/ServiceModel.Grpc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

Links

ServiceModel.Grpc at a glance

Declare a service contract

[ServiceContract]
public interface ICalculator
{
    [OperationContract]
    Task<long> Sum(long x, int y, int z, CancellationToken token = default);

    [OperationContract]
    ValueTask<(int Multiplier, IAsyncEnumerable<int> Values)> MultiplyBy(IAsyncEnumerable<int> values, int multiplier, CancellationToken token = default);
}

Client call (Reflection.Emit)

A proxy for the ICalculator 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 ICalculator service
var calculator = clientFactory.CreateClient<ICalculator>(channel);

// call Sum: sum == 6
var sum = await calculator.Sum(1, 2, 3);

// call MultiplyBy: multiplier == 2, values == [] {2, 4, 6}
var (multiplier, values) = await calculator.MultiplyBy(new[] {1, 2, 3}, 2);

Client call (source code generation)

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

PS> Install-Package ServiceModel.Grpc.DesignTime
// request ServiceModel.Grpc to generate a source code for ICalculator service proxy
[ImportGrpcService(typeof(ICalculator))]
internal static partial class MyGrpcServices
{
    // generated code ...
    public static IClientFactory AddCalculatorClient(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 ICalculator proxy generated by ServiceModel.Grpc.DesignTime
clientFactory.AddCalculatorClient();

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

// call Sum: sum == 6
var sum = await calculator.Sum(1, 2, 3);

// call MultiplyBy: multiplier == 2, values == [] {2, 4, 6}
var (multiplier, values) = await calculator.MultiplyBy(new[] {1, 2, 3}, 2);

Implement a service

internal sealed class Calculator : ICalculator
{
    public Task<long> Sum(long x, int y, int z, CancellationToken token) => x + y + z;

    public ValueTask<(int Multiplier, IAsyncEnumerable<int> Values)> MultiplyBy(IAsyncEnumerable<int> values, int multiplier, CancellationToken token)
    {
        var multiplicationResult = DoMultiplication(values, multiplier, token);
        return new ValueTask<(int, IAsyncEnumerable<int>)>((multiplier, multiplicationResult));
    }

    private static async IAsyncEnumerable<int> DoMultiplication(IAsyncEnumerable<int> values, int multiplier, [EnumeratorCancellation] CancellationToken token)
    {
        await foreach (var value in values.WithCancellation(token))
        {
            yield return value * multiplier;
        }
    }
}

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 Calculator service
app.MapGrpcService<Calculator>();

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 Calculator service
server.Services.AddServiceModelTransient(() => new Calculator());

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);
        }
    }
}

NuGet feed

Name Package Description
ServiceModel.Grpc Version main functionality, basic Grpc.Core.Api extensions and ClientFactory. ClientFactory is fully compatible with Grpc.Net.Client.
ServiceModel.Grpc.Client.DependencyInjection Version Dependency injection extensions for ClientFactory and Grpc.Net.ClientFactory
ServiceModel.Grpc.AspNetCore Version Grpc.AspNetCore.Server extensions
ServiceModel.Grpc.AspNetCore.Swashbuckle Version Swagger integration, based on Swashbuckle.AspNetCore
ServiceModel.Grpc.AspNetCore.NSwag Version Swagger integration, based on NSwag
ServiceModel.Grpc.SelfHost Version Grpc.Core extensions for self-hosted Grpc.Core.Server
ServiceModel.Grpc.DesignTime Version C# code generator
ServiceModel.Grpc.MessagePackMarshaller Version marshaller factory, based on MessagePack serializer
ServiceModel.Grpc.ProtoBufMarshaller Version marshaller factory, based on protobuf-net serializer
ServiceModel.Grpc.MemoryPackMarshaller Version marshaller factory, based on MemoryPack serializer

Benchmarks

ServiceModel.Grpc is a tiny layer on top of grpc-dotnet, which helps to adapt code-first to gRPC protocol. A serializer makes a picture of the performance.

Benchmark code is available here.

The following benchmarks show the performance for unary call on client and server.

[ServiceContract]
public interface ITestService
{
    [OperationContract]
    Task<SomeObject> PingPong(SomeObject value);
}

value = new SomeObject
{
    StringScalar = "some meaningful text",
    Int32Scalar = 1,
    DateScalar = DateTime.UtcNow,
    SingleScalar = 1.1f,
    Int32Array = new int[100],
    SingleArray = new float[100],
    DoubleArray = new double[100]
};
  • ServiceModelGrpc.DataContract test uses DataContractSerializer

  • ServiceModelGrpc.Protobuf test uses protobuf-net serializer

  • ServiceModelGrpc.MessagePack test uses MessagePack serializer

  • ServiceModelGrpc.proto-emulation test uses Google protobuf serialization, the same as grpc-dotnet. This test is designed to compare numbers between ServiceModelGrpc and grpc-dotnet without the influence of a serializer.

  • grpc-dotnet is a baseline:

service TestServiceNative {
	rpc PingPong (SomeObjectProto) returns (SomeObjectProto);
}

message SomeObjectProto {
	string stringScalar = 1;
	google.protobuf.Timestamp dateScalar = 2;
	float singleScalar = 3;
	int32 int32Scalar = 4;
	repeated float singleArray = 5 [packed=true];
	repeated int32 int32Array = 6 [packed=true];
	repeated double doubleArray = 7 [packed=true];
}

Client async unary call, server is stub

BenchmarkDotNet v0.14.0, Ubuntu 22.04.5 LTS (Jammy Jellyfish)
AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores
.NET SDK 9.0.100
  [Host]   : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  ShortRun : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2

Job=ShortRun  Platform=X64  Force=True  
Server=False  IterationCount=15  LaunchCount=1  
RunStrategy=Throughput  WarmupCount=3  

Method Mean Error StdDev Op/s Ratio RatioSD Message size Gen0 Allocated Alloc Ratio
ServiceModelGrpc.DataContract 124.528 μs 3.6707 μs 2.8658 μs 8,030.3 22.74 0.52 6.55 KB - 51.44 KB 7.70
ServiceModelGrpc.Protobuf 11.658 μs 0.0362 μs 0.0302 μs 85,776.6 2.13 0.01 1.33 KB 0.1068 9.07 KB 1.36
ServiceModelGrpc.MessagePack 6.663 μs 0.0208 μs 0.0174 μs 150,077.3 1.22 0.01 1.52 KB 0.1221 10.06 KB 1.51
ServiceModelGrpc.MemoryPack 3.955 μs 0.0238 μs 0.0211 μs 252,822.8 0.72 0.01 1.62 KB 0.1297 11.18 KB 1.67
grpc-dotnet 5.477 μs 0.0415 μs 0.0346 μs 182,573.9 1.00 0.01 1.32 KB 0.0763 6.68 KB 1.00
ServiceModelGrpc.proto-emulation 5.757 μs 0.0239 μs 0.0224 μs 173,690.9 1.05 0.01 1.32 KB 0.0763 6.8 KB 1.02

Server async unary call, client is stub


BenchmarkDotNet v0.14.0, Ubuntu 22.04.5 LTS (Jammy Jellyfish)
AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores
.NET SDK 9.0.100
  [Host]   : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  ShortRun : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2

Job=ShortRun  Platform=X64  Force=True  
Server=False  IterationCount=15  LaunchCount=1  
RunStrategy=Throughput  WarmupCount=3  

Method Mean Error StdDev Op/s Ratio RatioSD Message size Gen0 Allocated Alloc Ratio
ServiceModelGrpc.DataContract 199.83 μs 8.409 μs 7.022 μs 5,004.3 5.22 0.32 6.55 KB - 60.67 KB 3.87
ServiceModelGrpc.Protobuf 54.28 μs 0.775 μs 0.687 μs 18,424.1 1.42 0.07 1.33 KB - 18.01 KB 1.15
ServiceModelGrpc.MessagePack 40.07 μs 4.014 μs 3.755 μs 24,953.5 1.05 0.11 1.52 KB - 19.01 KB 1.21
ServiceModelGrpc.MemoryPack 29.80 μs 3.904 μs 3.651 μs 33,555.3 0.78 0.10 1.62 KB 0.2441 20.13 KB 1.28
grpc-dotnet 38.37 μs 1.899 μs 1.777 μs 26,062.1 1.00 0.07 1.32 KB - 15.67 KB 1.00
ServiceModelGrpc.proto-emulation 34.57 μs 3.891 μs 3.639 μs 28,927.5 0.90 0.10 1.32 KB - 15.76 KB 1.01

Client plus server async unary call, without stubs


BenchmarkDotNet v0.14.0, Ubuntu 22.04.5 LTS (Jammy Jellyfish)
AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores
.NET SDK 9.0.100
  [Host]   : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  ShortRun : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2

Job=ShortRun  Platform=X64  Force=True  
Server=False  IterationCount=15  LaunchCount=1  
RunStrategy=Throughput  WarmupCount=3  

Method Mean Error StdDev Op/s Ratio RatioSD Message size Gen0 Allocated Alloc Ratio
ServiceModelGrpc.DataContract 342.65 μs 37.438 μs 31.262 μs 2,918.4 6.42 1.42 6.55 KB - 98.36 KB 5.17
ServiceModelGrpc.Protobuf 80.59 μs 1.913 μs 1.494 μs 12,408.2 1.51 0.31 1.33 KB - 23.79 KB 1.25
ServiceModelGrpc.MessagePack 55.76 μs 1.197 μs 1.119 μs 17,932.5 1.04 0.21 1.52 KB 0.2441 25.38 KB 1.33
ServiceModelGrpc.MemoryPack 38.86 μs 3.701 μs 3.462 μs 25,732.0 0.73 0.16 1.62 KB 0.2441 27.38 KB 1.44
grpc-dotnet 56.27 μs 17.011 μs 15.080 μs 17,770.3 1.05 0.35 1.32 KB - 19.03 KB 1.00
ServiceModelGrpc.proto-emulation 50.95 μs 0.609 μs 0.508 μs 19,627.1 0.95 0.19 1.32 KB - 19.3 KB 1.01