MagicOnionに入門した

Published on
Updated on

はじめに

友人がC#のgRPCライブラリのMagicOnionの導入に苦戦してたので、手伝いながら使ってみたときにつまったところを纏めたものです。

リポジトリはこちら

MagicOnion

MagicOnionは、共通のインターフェースを介してクライアントとサーバーで手続きを呼び合う技術のgRPCをC#用に最適化した、リアルタイム通信ライブラリです。

ASP.NET CoreにもgRPCのテンプレートは存在しますが、そちらはprotoファイルを作成し、そのファイルにインターフェースを定義を行います。一方MagicOnionの場合は、C#のinterfaceを定義すればめんどくさいことはMagicOnion側でいろいろやってくれるため、クライアントとサーバーでどちらもC#を利用する場合には一つのソースを使いまわすことができたりと嬉しいことが多いです。そのため、クライアントはUnity、サーバーはASP.NET Coreを使うモバイルゲームなどのプロジェクトでよく使われるそうです。

環境

作ってみる1

MagicOnionを使うにあたって、ASP.NET Coreでのサーバー、Unityでのクライアント、共有Apiの3つのプロジェクトを構成します。

MagicOnionSample/
  |- MagicOnionSample.Server/
  |- MagicOnionSample.Shared/
  |- MagicOnionSample.Unity/
  |
  |- MagicOnionSample.sln

MagicOnionSample.ServerはASP.NET CoreのgRPCテンプレートで作成しました。 MagicOnionSample.UnityにはUnityプロジェクトを作成します。 MagicOnionSample.slnにはMagicOnionSample.ServerMagicOnionSample.Sharedを追加します。

クライアント側の準備

プロジェクトを作成したら、はじめにProject Settingsを以下に変更します。

Item Value
Scripting Backend Mono
Api Compatibility Level .NET 4.x
Allow unsafe code True

次に、MagicOnionとMessagePackのunitypackageをプロジェクトにインポートします。 また、gRPCのパッケージから、Google.ProtobufGrpc.CoreGrpc.Core.Apiの3つのフォルダをAssets/Plugins/にインポートします。

MagicOnionとMessagePackのバージョンによってはUnityのコンパイルエラーは発生しませんが、MagicOnion 4.0.4とMessagePack 2.2.85の場合はMagicOnion側でコンパイルエラーが発生してしまいます。MessagePack 2.2.85からMessagePackの属性が含まれている名前空間がMessagePackからMessagePack.Annotationsに変更されているようなので、Assets/Scripts/MagicOnion.Client/MagicOnion.Client.asmdefAssemblyDefinition ReferencesMessagePack.Annotationsの参照を追加することでコンパイルエラーを解消できます。

サーバー側の準備

ASP.NET CoreのgRPCテンプレートで作成した場合、以下のような構成でプロジェクトが作成されます。

MagicOnionSample
  |-MagicOnionSample.Server
      |- Properties/
      |    |- launchSettings.json
      |- Protos/
      |    |- greet.proto
      |- Services/
      |    |- GreeterService.cs
      |- appsettings.json
      |- appsettings.development.json
      |- Program.cs
      |- Startup.cs

この状態から、Protosディレクトリと、GreeterService.csを削除します。 次にStartup.csGenericHostの構成にMagicOnionを追加します。

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

namespace MagicOnionSample.Server
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddGrpc();
            services.AddMagicOnion(); // Here
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapMagicOnionService(); // Here
                endpoints.MapGet("/",
                    async context =>
                    {
                        await context.Response.WriteAsync(
                            "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
                    });
            });
        }
    }
}

また、今回はlocalhostで通信を行うので、appsettings.development.jsonに以下の設定を追加します。

...
"Kestrel": {
    "Endpoints": {
      "Grpc": {
        "Url": "http://localhost:5000",
        "Protocols": "Http2"
      },
      "Https": {
        "Url": "https://localhost:5001",
        "Protocols": "Http1AndHttp2"
      },
      "Http": {
        "Url": "http://localhost:5002",
        "Protocols": "Http1"
      }
    }
  }
...

また、Program.csCreateHostBuilderKestrelとHttp2を使うための設定を追加します。

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Hosting;

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

        public static IHostBuilder CreateHostBuilder(string[] args)
        {
            return Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder => webBuilder
                    .UseKestrel(options => options.ConfigureEndpointDefaults(endpointOptions =>
                        endpointOptions.Protocols = HttpProtocols.Http2))
                    .UseStartup<Startup>());
        }
    }
}

Httpsで通信を行う場合は、こちらを参照してください。

共有Apiの定義1

Unityに戻り、MagicOnionで使用するinterfaceやモデルクラス類を作成します。 今回はAssets/MagicOnionSample/Scripts/Shared/に共有Apiを構成します。

SharedディレクトリにISampleService.csを作成し,stringの値を渡すと挨拶のstringを返すinterfaceを定義します。また、このinterfaceにはIService<T>もあわせて定義します。

using MagicOnion;

namespace MagicOnionSample.Shared
{
    public interface ISampleService : IService<ISampleService>
    {
        UnaryResult<string> GreetAsync(string name);
    }
}

クライアント側の実装1

Sharedディレクトリでは、クライアントとサーバーで共有できるクラスやインターフェースのみを持たせるために、Sharedディレクトリとは別に、Assets/MagicOnionSample/Scripts/Unity/を作成し、名前空間と実装を分離します。

UnityディレクトリにSampleEntryPoint.csを作成し、サーバーにローカルホストでアクセスする実装をします。

MagicOnionClientからISampleServiceのエンドポイントに対して、上記で定義したGreetAsyncinterface経由で呼び、結果をDebug.Logに表示させます。 interface経由で呼ぶことで、クライアント側は実装を気にする必要がありません。

using System.Threading.Tasks;
using Grpc.Core;
using MagicOnion.Client;
using MagicOnionSample.Shared;
using UnityEngine;

namespace MagicOnionSample.Unity
{
    public class SampleEntryPoint : MonoBehaviour
    {
        public string host = "localhost";
        public int port = 5000;

        public string user = "Foo";
        public string room = "Bar";

        private Channel _channel;

        private async Task Start()
        {
            _channel = new Channel(host, port, ChannelCredentials.Insecure);

            var client = MagicOnionClient.Create<ISampleService>(_channel);
            var greet = await client.GreetAsync(user);
            Debug.Log(greet);
        }

        private async Task OnDestroy()
        {
            await _channel.ShutdownAsync();
        }
    }
}

作成後、UnityのHierarchyに適当なGameObjectを作成し、SampleEntryPointを付与します。

サーバー側における共有Api

UnityがコンパイルできるスクリプトはAssets/以下にあるものに限るため、サーバー側で共有Api用のプロジェクトを作成すると不整合がおきてしまうかもしれません。そのため、MagicOnionSample.Sharedのプロジェクトでは、中身を実際には持たずに、上記で作成したUnityプロジェクト内のAssets/MagicOnionSample/Scripts/Sharedディレクトリにあるスクリプトを参照することでサーバー側でも共有Apiとして使えるようにします。

そのため、MagicOnionSample.Sharedのディレクトリ構成は以下のようになります。

MagicOnionSample
  |-MagicOnionSample.Shared
      |-MagicOnionSample.Shared.csproj

nugetからMagicOnionMagicOnion.AbstractionsMessagePackMessagePack.UnityShimsをインストールします。 MessagePack.UnityShimsをインストールすることで、UnityEngineのApiを利用することができるため、Vector3Quatarnionなどを使う場合はインストールします。

<Compile Include="path/to/file"/>を定義することで、指定したパスのファイルをコンパイルに含めることができます。

csprojは以下のようになります。

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netcoreapp3.1</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="MagicOnion" Version="4.0.4" />
        <PackageReference Include="MagicOnion.Abstractions" Version="4.0.4" />
        <PackageReference Include="MessagePack" Version="2.2.85" />
        <PackageReference Include="MessagePack.UnityShims" Version="2.2.85" />
    </ItemGroup>

    <ItemGroup>
        <Compile Include="../MagicOnionSample.Unity/Assets/MagicOnionSample/Scripts/Shared/**/*.cs" />
    </ItemGroup>

</Project>

サーバー側の実装1

上記で準備した共有Apiのプロジェクトをサーバー側のプロジェクトで参照することで、Unity上で定義したISampleServiceを利用することができるようになります。 SampleService.csを作成し、ISampleServiceの実装を行います。 簡単な文字列を返すように実装しました。

using System;
using MagicOnion;
using MagicOnion.Server;
using MagicOnionSample.Shared;

namespace MagicOnionSample.Server.Services
{
    public class SampleService : ServiceBase<ISampleService>, ISampleService
    {
        public async UnaryResult<string> GreetAsync(string name)
        {
            await Console.Out.WriteLineAsync(name);
            return $"Welcome {name}!";
        }
    }
}

動作確認1

dotnet runコマンド等でサーバーを起動し、SampleEntryPointが適当なGameObjectに付与されているのを確認した後にUnityを実行し、UnityのConsoleにWelcome Foo!と表示されたら成功です。 以上で、サーバーとクライアントの1対1のApiコールができました。

作ってみる2

前の項では、サーバーとクライアントの1対1のApiコールを実装しました。次に、サーバーとクライアントの1対多のApiコールを実装します。 マルチプレイでプレイヤーの座標をリアルタイムで同期させるといったことが用途としてあげられます。

今回は、プレイヤーが部屋に参加したかどうかを知らせるApiを実装します。

共有Apiの定義2

初めに、Playerを一つのモデルとして管理するために、SharedディレクトリにPlayer.csを作成します。 MessagePackObjectの属性をクラスや構造体に付与することで、MessagePackがシリアライズできるようになり、Keyによってそれぞれのプロパティを管理します。

using MessagePack;

namespace MagicOnionSample.Shared
{
    [MessagePackObject]
    public class Player
    {
        [Key(0)] public string Name { get; set; }
        [Key(1)] public string Room { get; set; }
    }
}

次に、SharedディレクトリにISampleHubReceiver.csを作成します。 Playerが部屋に参加したことを知らせるコールバークとしてのinterfaceを定義します。

namespace MagicOnionSample.Shared
{
    public interface ISampleHubReceiver
    {
        void OnJoin(Player player);
    }
}

また、SharedディレクトリにISampleHub.csを作成します。 nameroomを渡すことで、部屋に参加するinterfaceを定義します。このinterfaceにはIStreamingHub<T, U>もあわせて定義します。

ISampleServiceと同じようにApiコール用のinterfaceです。

using System.Threading.Tasks;
using MagicOnion;

namespace MagicOnionSample.Shared
{
    public interface ISampleHub : IStreamingHub<ISampleHub, ISampleHubReceiver>
    {
        Task JoinAsync(string name, string room);
    }
}

これらのApiコールのの流れとして、ISampleHubJoinAsyncを呼ぶことで、サーバーに名前と部屋名を渡し、サーバー側の処理が完了するとISampleHubReceiverOnJoinがコールバックとして呼ばれる形になります。

クライアント側の実装2

クライアント側では、ISampleHubReceiverを実装したSampleHubReceiverを作成します。 UnityディレクトリにSampleHubReceiver.csを作成し、コールバックの内容を実装します。 Playerが参加したらPlayerNameRoomがUnityのConsoleに表示されます。

using MagicOnionSample.Shared;
using UnityEngine;

namespace MagicOnionSample.Unity
{
    public class SampleHubReceiver : ISampleHubReceiver
    {
        public void OnJoin(Player player)
        {
            Debug.Log($"{player.Name}, {player.Room}");
        }
    }
}

上記で作成したSampleEntryPoint.csを更新します。

ChannelISampleReceiverのインスタンスをStreamingHubClient.Connectに渡すことで、ISampleHubを実装したインスタンスを得ることができます。このインスタンスはサーバー側で実装されるので、クライアント側は気にする必要がありません。 ISampleHubのインスタンスを使ってJoinAsyncを呼ぶことで、サーバー側にnameroomを渡すことができ、コールバックとしてSampleHubReceiverOnJoinPlayerのインスタンスが渡されます。 また、ISampleHubIDisposableなので、忘れずにDisposeでリソースを解放します。

using System.Threading.Tasks;
using Grpc.Core;
using MagicOnion.Client;
using MagicOnionSample.Shared;
using UnityEngine;

namespace MagicOnionSample.Unity
{
    public class SampleEntryPoint : MonoBehaviour
    {
        public string host = "localhost";
        public int port = 5000;

        public string user = "Foo";
        public string room = "Bar";

        private Channel _channel;

        // Here
        private ISampleHub _hub;
        private ISampleHubReceiver _receiver;

        private async Task Start()
        {
            _channel = new Channel(host, port, ChannelCredentials.Insecure);

            var client = MagicOnionClient.Create<ISampleService>(_channel);
            var greet = await client.GreetAsync(user);
            Debug.Log(greet);

            // Here
            _receiver = new SampleHubReceiver();
            _hub = StreamingHubClient.Connect<ISampleHub, ISampleHubReceiver>(_channel, _receiver);
            await _hub.JoinAsync(user, room);
        }

        private async Task OnDestroy()
        {
            await _hub.DisposeAsync(); // Here
            await _channel.ShutdownAsync();
        }
    }
}

サーバー側の実装2

サーバー側ではISampleHubの実装を行います。 SampleHub.csを作成し、nameroomが与えられたらPlayerを作成して返すといった実装を行います。

BroadcastIGroupのインスタンスを渡すことで、グループ内のすべてのクライアントに対してコールバックを呼ぶことができます。

using System;
using System.Threading.Tasks;
using MagicOnion.Server.Hubs;
using MagicOnionSample.Shared;

namespace MagicOnionSample.Server.Hubs
{
    public class SampleHub : StreamingHubBase<ISampleHub, ISampleHubReceiver>, ISampleHub
    {
        private Player _player;
        private IGroup _room;

        public async Task JoinAsync(string name, string room)
        {
            _player = new Player {Name = name, Room = room};
            await Console.Out.WriteLineAsync($"Join {_player.Name} to the {_player.Room}");
            (_room, _) = await Group.AddAsync(_player.Room, _player);
            Broadcast(_room).OnJoin(_player);
        }
    }
}

動作確認2

上記の動作確認と同じように、dotnet runコマンド等でサーバーを起動してUnityを実行すると、UnityのConsoleにWelcome Foo!Foo, bar表示されたら成功です。 また、サーバー側のConsoleではJoin Foo to the Barと表示されます。 以上で、サーバーとクライアントの1対多のApiコールができました。

その他注意点

List<T>Array<T>などをMessagePackに渡す場合は、シリアライズの時にnullの場合、エラーが発生することがあります。プロパティの初期化子を使って初期化をすることで、シリアライズでエラーを回避することができます。

自作クラスのコンストラクタを実装する場合、コンストラクタ引数がないコンストラクタをMessagePackに渡すと、シリアライズ時にエラーが発生するため、引数があるコンストラクタに加えて、引数がないコンストラクタを作成する必要があります。

まとめ

MagicOnionを使ってリアルタイム通信の世界に入門しました。

  • UnityプロジェクトにインストールしたMagicOnionとMessagePackでコンパイルエラーが発生する場合はMagicOnion.ClientMessagePack.Annotationsを追加する
  • 1対1ではIService<T>を使う
  • 1対多ではIStreamingHub<T, U>を使う
  • MessagePackではnullに注意
  • MessagePackではコンストラクタに注意

マルチプレイのゲームを作るときには有効活用したいです。