【C#】.NET CoreでMagicOnion+IPC通信を試す

タイトルの通り gRPC のライブラリである MagicOnion を使いつつ IPC(プロセス間) 通信したいと思います。

ちなみに、MagicOnion を使うと WCF を使った RPC の API 呼び出しのワークフローと極めて似た感じで実装できるようになります。特に proto からの型生成を省略できるため使用感が非常に似た感じで使用できます。

今回は単純に RPC(Remote Procedure Call) でメソッドを呼び出して戻り値を取得するのを IPC で実装していきます。

確認環境

今回の確認環境は以下の通りです。

  • Windows11
  • VisualStudio 2022
  • .NET Core 3.1
  • MagicOnion 4.3.1

コンソールアプリで動作検証

環境のセットアップ

とりあえず、公式の Installationの項目 を見れば書いてあるけど、それだとあんまりなので概要だけ以下の通り書いておきます。

VisualStudio 使ってるので IDE から以下をソリューション上に構成します。

  • (1) サーバー
    • Web > ASP.NET Core WebAPIを作成 > 「GrpcService」で作成
    • NuGet で MagicOnion.Server を追加
  • (2) クライアント
    • コンソール > コンソールアプリケーション > 「GrpcClient」で追加
    • NuGet > MagicOnion.Client を追加
  • (3) 共有プロジェクト
    • デスクトップ > 共有プロジェクト > 「SharedProject」で追加

(1)サーバーと(2)クライアントのプロジェクトは共有プロジェクトを参照に追加しておきます。

これで準備完了です。

実装

IPC 通信をするための設定を公式のコードに IPC 通信用の設定を足していきます。

共有プロジェクト

まずはどんなAPIを公開するのかのインターフェース定義を共有プロジェクトに

using System.IO;
using MagicOnion;

namespace SharedProject
{
    public interface ISample : IService<ISample> // MagicOnionのIServiceを継承して定義する
    {
        UnaryResult<int> SumAsync(int x, int y); // サンプルの通り足し算して結果を取得する
    }

    // サーバーへの接続用文字列定義
    public static class SampleDef
    {
        public static string SocketPath => Path.Combine(Path.GetTempPath(), "socket.tmp");
    }
}

サーバー側

Program.cs

サーバー側のメインメソッド

// Program.cs

using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace GrpcService
{
    public partial class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args)
        {
            string socketPath = SharedProject1.SampleDef.SocketPath;

            IHostBuilder host = Host.CreateDefaultBuilder(args);
            host.ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();

                // ★★★IPC用の定義を追加
                webBuilder.ConfigureKestrel(options =>
                {
                    if (File.Exists(socketPath))
                    {
                        File.Delete(socketPath);
                    }
                    options.ListenUnixSocket(socketPath, listenOptions =>
                    {
                        listenOptions.Protocols = HttpProtocols.Http2;
                    });
                });
                
                // ★★既定のコンソール出力は動作速度が大きく低下するので出力を止める(任意)
                webBuilder.ConfigureLogging(logging =>
                {
                    logging.ClearProviders();
                });
            });

            return host;
        }
    }
}
Startup.cs

サーバーの構成定義(公式と同じ)

// Startup.cs

using System.IO;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace GrpcService
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddGrpc();
            services.AddMagicOnion(); // ★リファレンスの通り
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapMagicOnionService(); // ★リファレンスの通り
            });
        }
    }
}
SampleService.cs

サーバーが外部に公開するクラスの定義(公式と同じ)

// SampleService.cs

using System;
using MagicOnion;
using MagicOnion.Server;
using SharedProject;

namespace GrpcService
{
    public class SampleService : ServiceBase<ISample>, ISample
    {
        public async UnaryResult<int> SumAsync(int x, int y)
        {
            Console.WriteLine($"Received:{x}, {y}");
            return x + y;
        }
    }
}

クライアント

UDSConnectionFactory.cs

クライアント側が IPC 通信用の GrpcChannel を作成するために利用するクラス

// UDSConnectionFactory.cs

using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

namespace GrpcClient
{
    // UnixDomainSocket
    public class UDSConnectionFactory
    {
        private readonly EndPoint _endPoint;

        public UnixDomainSocketConnectionFactory(EndPoint endPoint)
        {
            _endPoint = endPoint;
        }

        public async ValueTask<Stream> 
            ConnectAsync(SocketsHttpConnectionContext _, 
            CancellationToken cancellationToken = default)
        {
            var socket = 
                new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            try
            {
                await socket.ConnectAsync(_endPoint, cancellationToken).ConfigureAwait(false);
                return new NetworkStream(socket, true);
            }
            catch
            {
                socket.Dispose();
                throw;
            }
        }
    }
}
Program.cs

クライアント側のメインメソッド

// Program.cs

using System;
using System.Net.Http;
using System.Net.Sockets;
using Grpc.Net.Client;
using MagicOnion.Client;
using SharedProject;

namespace GrpcClient
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            Console.WriteLine("Start");

            GrpcChannel channel = CreateChannel();
            var client = MagicOnionClient.Create<ISample>(channel);

            var result = client.SumAsync(10, 20);
            int sum = result.ResponseAsync.Result;
            Console.WriteLine($"Sum={sum}");

            Console.WriteLine("END");
            Console.ReadLine();
        }

        public static GrpcChannel CreateChannel()
        {
            string socketPath = SampleDef.SocketPath;

            var udsEndPoint = new UnixDomainSocketEndPoint(socketPath);
            var connectionFactory = new UDSConnectionFactory(udsEndPoint);
            var socketsHttpHandler = new SocketsHttpHandler
            {
                ConnectCallback = connectionFactory.ConnectAsync
            };

            return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
            {
                HttpHandler = socketsHttpHandler
            });
        }
    }
}

実行してみる

(1) サーバーを実行 (2) クライアントを実行

の順に実行します。VisualStudio を2つ起動して1つずつ起動してみます。

サーバーを起動すると出力ウインドウに以下のような出力がされていれば設定が利いています。

// 意訳: Kestrel が http://localhost:5000 or 5001 のアドレスをオーバーライドしています
Microsoft.AspNetCore.Server.Kestrel: Warning: Overriding address(es) 'http://localhost:5000, https://localhost:5001'. Binding to endpoints defined in UseKestrel() instead.
Microsoft.Hosting.Lifetime: Information: Now listening on: http://unix:C:\Users\Xxxxx\AppData\Local\Temp\socket.tmp
Microsoft.Hosting.Lifetime: Information: Application started. Press Ctrl+C to shut down.
Microsoft.Hosting.Lifetime: Information: Hosting environment: Development
Microsoft.Hosting.Lifetime: Information: Content root path: C:\Users\Xxxx\Downloads\MagicOnionSample\GrpcService

この場合 UseKestrel ではなく ConfigureKestrel で指定した内容でバインドしていますと表示されています。実際このメッセージが出てると速度オーバーヘッドが 1.3~.6倍 くらい違ったので確かに UDS(Unix Domain Socket) が使用されているようです。

このメッセージ後にクライアントを実行するとコンソールに以下のように表示されます。

Sum=30

パフォーマンスについて

正確なところは分かりませんが、gRPC の方が WCF より処理が重いようで、自分の環境では下表のとおりの速度となりました。

Item Speed
WCF(IPC Named Pipe) 1.0
gRPC(UnixDomainSocket) x0.6~0.8
gRPC(TCP) x0.4~0.6

トーレスとコンソールへの出力を停止しないと更に速度が低下するためリリース時は状況に応じてOFFる、、と問題が起きた時に困るので、より高速に動作するロガーを設定したほうがいいと思います。速度に関しては何か追加で設定しないといけないのかもしれませんが今はちょっと方法がわかりませんでした。

具体的な数値で言うと WCF だと 1.2ms/リクエストの処理が gRCP では 2ms/リクエスト くらいの速度感でした。このためものすごい細かいデータ断片を単方向に投げまくるという用途にはやや向いていないのかもしれません。これは MagicOnion が悪いのではなく .NET の gRPC の特徴なのかと思います。