【C#】protobuf-net.GrpcでコードファーストなRPCを実装する

protobuf-net 」という .NET 向けの protocol buffers という Google 製のデータシリアライザーがあります。このライブラリ、基本的にはオブジェクト ⇔ バイナリ形式に相互変換する機能を持っています。 が、 protobuf-net は「protobuf-net.Grpc」という追加のパッケージがあり、これを gRPC 環境で使用すると .proto を使用せずに、C# の interface を共有する形でコードベースで RPC (リモートプロシージャコール) の呼び出しができるため、サーバーのメソッドを直接呼び出すかのような実装ができます。

長らく .NET Framework で存在した、WCF のリモート呼び出し機能の代替はどうしたらいいという議論がありましたが今回はこの protobuf-net を用いたリモート呼び出しを紹介したいと思います。

また、サーバーからの Push 通知をリアルタイムに受け取ることもできるのでそれも併せて紹介したいと思います。WCFとは大幅に実装のイデオムが変化しているためその雰囲気も感じることができるように紹介していきます。

  • protobuf-net.Grpc の機能
    • protobuf 形式のバイナリシリアライズ(を使ったデータ伝送)
    • .proto 不要の code-first によるリモート呼び出し
    • サーバーからのPush通知

確認環境

この記事は以下の環境で作成・確認を行っています

  • .NET8 + C#12
  • Visual Studio 2022 (17.14.18)
  • Windows11

構文が C#12 以前のバージョンでは一部コンパイルエラーになる構文が含まれます。

ソリューションの作成

まずは以下のような役割でプロジェクトを作成してNuGetパッケージを導入します。

  • 001_Server

    • テンプレート: ASP.NET Core WebAPIで作成
    • 役割: サーバー側の機能を配置する
    • NuGet:
      • protobuf-net.Grpc.AspNetCore (ver 1.2.2)
  • 002_Shared

    • テンプレート: クラスライブラリ
    • 役割: サーバー・クライアント間で共有するC#実装を配置
    • NuGet:
      • protobuf-net.Grpc (ver 1.2.2)
      • System.ServiceModel.Primitives (ver 8.1.2)
  • 003_Client

    • テンプレート: コンソールアプリ
    • 役割: サーバーを呼び出す + 確認用のプログラム
    • NuGet:
      • Grpc.Net.Client (ver 2.71.0)

Server と Client は Shared をプロジェクト参照に追加します。

最近の .NET プロジェクトは、間接的な NuGet パッケージ参照でも、推移的な参照として参照側でもパッケージが使用できるようになります。また、以下のような依存関係がパッケージ構成となっているため、Shared を参照すれば全体的に機能が使えるようになります。

// パッケージの参照関係

+ protobuf-net.Grpc.AspNetCore
   |
   + protobuf-net.Grpc
      |
      + protobuf-net

プロジェクト作成が完了したら、以下のjsonの設定を変更して実行時にブラウザが起動するのを停止しておきます。

+ 001_Server
   |
   + Properties
      |
      + launchSettings.json ← これ

これでブラウザが実行時に自動で立ち上がらなくなります。

// launchSettings.json
...(省略)...
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false, ← ★★★truefalseに変更
      "launchUrl": "weatherforecast",
      "applicationUrl": "https://localhost:7278;http://localhost:5039",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },

実装例

002_Shared

まず、サーバーとクライアントで共有するインターフェースと型を定義します。

// Any.cs

using ProtoBuf;

// このように定義しておくとプリミティブ型のラッパーとして
// 汎用的にパラメーターを扱えるので動作検証時に簡単のため事前に準備
[ProtoContract]
public class Any<T>
{
    // 汎用的なデータ格納用のプロパティ
    [ProtoMember(1)]
    public T? Value { get; set; }

    // コンストラクタ類
    public Any() { }
    public Any(T value) => Value = value;

    // Tを簡単にAnyに変換できるようにするためのオペレーター
    public static implicit operator Any<T>(T value) => new(value);
    public static implicit operator T?(Any<T> value) => value.Value;
    // 内容を文字列に変換
    public override string? ToString() => Value is not null ? Value.ToString() : "";
}

使用するクラスに、ProtoContract属性を指定し、送受信するメンバーに ProtoMember(番号)属性を指定します。DataContract/DataMember属性も指定可能でも良いようですが、微妙に性能の差がありエッジケースで問題になる可能性があるので Proto系の属性が推奨と思われます。

次にサーバーが外部に公開するメソッドを以下のように定義します。

1つは通常のメソッド呼び出しと、もう一つはサーバーからの通知をリアルタイムに受け取るための Push 通知用のメソッドです。

// ISampleService.cs

using System.ServiceModel;
using ProtoBuf.Grpc;

// サービスのAPI定義
[ServiceContract]
public interface ISampleService
{
    // 通常のメソッド呼び出し
    ValueTask<Any<int>> Foo(Any<string> request, CallContext context = default);

    // サーバーからのPush通知
    IAsyncEnumerable<Any<int>> Push(Any<string> request, CallContext context = default);
}

2つ注意点があります。

  1. プリミティブ型は直接引数や戻り値に指定できない

ProtoContract / ProtoMember を使用したクラスを作成し、そのメンバーとしてプリミティブ型は使用可能です。この場合 Any 型で string などを保持して送信するのでこの制限を回避していますが、一般的には送受信それぞれに専用の XxxxRequest / YyyyResult など

  1. 引数は1つしか指定できない

メソッドの引数は1つだけ(自分の指定した第1引数と制御用のCallContextという構成)しか指定できません。例えば以下のように引数が3つ(自分の引数が2つ以上)になるとサーバーにメッセージが到達しなくなります。呼び出しに失敗した旨のエラーも出ないようなので注意が必要です。

こういった場合、複数のプロパティを持つ ProtoContract を持つ型を作成して引数や戻り値に指定しましょう。

// このような2つ以上の引数に無理
ValueTask<Any<int>> Foo(Any<string> request, Any<double> value,  CallContext context = default);


// このような型を作成して
[ProtoContract]
public class SampleRequest
{
    [ProtoMember(1)] public string Request { get;set; }
    [ProtoMember(2)] public double Value { get;set; }
}
// 以下のように引数で指定してまとめる
ValueTask<Any<int>> Foo(SampleRequest request,  CallContext context = default);

001_Server

サーバー側は ISampleService を実装した SampleServiceImplements クラスをまず作成します。

動作確認用のため固定的な値を返すように実装しておきます。

// SampleServiceImplements.cs

using ProtoBuf.Grpc;

public class SampleServiceImplements : ISampleService
{
    public ValueTask<Any<int>>
        Foo(Any<string> request, CallContext context = default)
    {
        return ValueTask.FromResult<Any<int>>(10); // 固定値を応答する
    }

    public async IAsyncEnumerable<Any<int>>
        Push(Any<string> request, CallContext context = default)
    {
        int i = 0;
        while (!context.CancellationToken.IsCancellationRequested)
        {
            yield return new Any<int>(i++); // 定周期に数値を返す
            await Task.Delay(1000, context.CancellationToken);
        }
    }
}

次に、本当に最小限ですが Main メソッドを以下の通り実装します。

最初のテンプレートに★のコメントの部分を追記すれば動作するようになります。

// Program.cs

using System.Threading.Tasks;
using ProtoBuf.Grpc.Server;

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddCodeFirstGrpc(); // ★追記1

        var app = builder.Build();

        app.MapGrpcService<SampleServiceImplements>(); // ★追記2: 作成した型を登録
        app.Run();
    }
}

もし外部に公開するインターフェースを増やして実装を追加した場合、追記2の項目を複数登録してくことになります。

003_Client

クライアントの実装は以下の通りです。

// Program.cs

using Grpc.Net.Client;
using ProtoBuf.Grpc.Client;

public class Program
{
    public static async Task Main(string[] args)
    {
        using var channel = GrpcChannel.ForAddress("https://localhost:7278");
        ISampleService client = channel.CreateGrpcService<ISampleService>();

        // サーバーのメソッドを読み出し
        int response = await client.Foo("Hello", CancellationToken.None);
        Console.WriteLine($"Foo={response}");

        // Push通知の受け取りをバックグラウンドで開始する
        using CancellationTokenSource cts = new();
        var _ = AcceptPushAsync(client, cts.Token);

        Console.ReadLine(); // エンターが入力されたら終了
        cts.Cancel();

        await Task.Delay(1000);
    }

    static async Task AcceptPushAsync(ISampleService client, CancellationToken ct = default)
    {
        await foreach (var response in client.Push($"{DateTime.Now:HH:mm:ss.fff}", ct))
        {
            Console.WriteLine($"Push={response}");
        }
    }
}

// 実行結果
Foo=10
Push=0
Push=1
Push=2
Push=3
...

Foo の第2引数はインターフェースだとCallContextでしたが、CancellationTokenを指定するとCallContextに暗黙的に変換してくれます。キャンセルが必要な場合は、CancellationToken.None ではなく、CancellationToken を渡して呼び出しましょう。

protobuf-net.Grpc パッケージにある CreateGrpcService メソッドをサンプルのように呼び出すと指定したインターフェースが サーバーメソッドを呼び出すためのクライアント(=プロキシー)として取得できます。

このクライアントのメソッドを呼び出すとサーバーのメソッドが呼び出されます。

参考サイト

Github

protobuf-net.Grpc

https://github.com/protobuf-net/protobuf-net.Grpc

MSDN

.NET を使用したコードファーストの gRPC サービスとクライアント

https://learn.microsoft.com/ja-jp/aspnet/core/grpc/code-first

Stack Overflow

Can we use [DataMember] instead of [ProtoMember] while use protobuf in WCF?

https://stackoverflow.com/questions/45139969/can-we-use-datamember-instead-of-protomember-while-use-protobuf-in-wcf