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.13.10, Ubuntu 22.04.3 LTS (Jammy Jellyfish)
AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores
.NET SDK 8.0.100
  [Host]   : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
  ShortRun : .NET 8.0.0 (8.0.23.53103), 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 131.854 μs 14.1959 μs 12.5843 μs 7,584.2 21.45 2.07 6.55 KB - 51.85 KB 7.76
ServiceModelGrpc.Protobuf 12.382 μs 0.0817 μs 0.0724 μs 80,760.2 2.01 0.01 1.33 KB 0.1068 9.07 KB 1.36
ServiceModelGrpc.MessagePack 7.079 μs 0.0272 μs 0.0241 μs 141,262.9 1.15 0.01 1.52 KB 0.1221 10.06 KB 1.51
grpc-dotnet 6.147 μs 0.0251 μs 0.0223 μs 162,690.8 1.00 0.00 1.32 KB 0.0763 6.68 KB 1.00
ServiceModelGrpc.proto-emulation 6.383 μs 0.0444 μs 0.0394 μs 156,667.2 1.04 0.01 1.32 KB 0.0763 6.8 KB 1.02

Server async unary call, client is stub


BenchmarkDotNet v0.13.10, Ubuntu 22.04.3 LTS (Jammy Jellyfish)
AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores
.NET SDK 8.0.100
  [Host]   : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
  ShortRun : .NET 8.0.0 (8.0.23.53103), 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 Allocated Alloc Ratio
ServiceModelGrpc.DataContract 215.75 μs 34.677 μs 30.740 μs 4,635.0 4.44 0.62 6.55 KB 60.76 KB 3.86
ServiceModelGrpc.Protobuf 65.24 μs 2.010 μs 1.569 μs 15,327.2 1.34 0.07 1.33 KB 18.11 KB 1.15
ServiceModelGrpc.MessagePack 62.09 μs 17.085 μs 15.982 μs 16,105.7 1.29 0.33 1.52 KB 19.1 KB 1.21
grpc-dotnet 48.57 μs 1.943 μs 1.723 μs 20,589.0 1.00 0.00 1.32 KB 15.76 KB 1.00
ServiceModelGrpc.proto-emulation 48.03 μs 1.581 μs 1.402 μs 20,821.3 0.99 0.05 1.32 KB 15.89 KB 1.01

Client plus server async unary call, without stubs


BenchmarkDotNet v0.13.10, Ubuntu 22.04.3 LTS (Jammy Jellyfish)
AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores
.NET SDK 8.0.100
  [Host]   : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
  ShortRun : .NET 8.0.0 (8.0.23.53103), 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 Allocated Alloc Ratio
ServiceModelGrpc.DataContract 382.49 μs 62.427 μs 55.340 μs 2,614.4 5.90 0.79 6.55 KB 98.45 KB 5.14
ServiceModelGrpc.Protobuf 90.63 μs 2.444 μs 1.908 μs 11,033.3 1.41 0.12 1.33 KB 23.88 KB 1.25
ServiceModelGrpc.MessagePack 68.14 μs 3.780 μs 2.951 μs 14,676.7 1.06 0.10 1.52 KB 25.48 KB 1.33
grpc-dotnet 64.90 μs 5.299 μs 4.697 μs 15,407.8 1.00 0.00 1.32 KB 19.14 KB 1.00
ServiceModelGrpc.proto-emulation 63.76 μs 4.117 μs 3.214 μs 15,683.4 1.00 0.12 1.32 KB 19.38 KB 1.01