【C#】MagicOnion(gRPC)+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 の方がかかるようで、この例だと、gRPC は WCF に比べて 速度が 6割くらいに低下します(素のHTTP通信で gRPC すると 4~5割程度に低下するので IPC の方が高速なのは確かなようです)

Item Speed
WCF 1.0
gRPC(UDS) x0.6~0.8
gRPC(TCP) x0.4~0.6

具体的には WCF → gRCPで 1.2ms/リクエスト → 2ms/リクエスト くらいに速度が低下するのでものすごい細かいデータ断片を単方向に投げまくるという用途にはやや向いていないのかもしれません。