Open9

MagicOnion を .NET 6 で動かしてみる

湯豆腐湯豆腐

MagicOnion を勉強するために、とりあえず README に書いてあることを試してみる
https://github.com/Cysharp/MagicOnion

.NET は昔々 C# でフォームアプリケーションを作ったことがある程度の初心者。
MagicOnion も初めてさわる。

湯豆腐湯豆腐

まずは QuickStart に沿ってサーバーサイドを作ってみる。
https://github.com/Cysharp/MagicOnion#quick-start

gRPC プロジェクトを作成

$ mkdir magic-onion-server
$ cd magic-onion-server
$ dotnet new grpc

色々とファイルやディレクトリが作成される。

$ ls
Program.cs  Services                      bin
Properties  appsettings.Development.json  magic-onion-server.csproj
Protos      appsettings.json              obj

Protos と Services ディレクトリは不要らしいので削除する。

$ rm -rf Protos
$ rm -rf Services

MagicOnion.Server を追加

$ dotnet add package MagicOnion.Server

magic-onion-server.csproj に以下の行が追加された。
dotnet ではこのファイルで依存関係の管理をするのかな。

   <ItemGroup>
     <PackageReference Include="Grpc.AspNetCore" Version="2.40.0" />
+    <PackageReference Include="MagicOnion.Server" Version="4.5.1" />^M
   </ItemGroup>

次に Startup.cs を変更すると書いてあるけど、そんなファイルが見当たらず、Program.cs しかない。
調べてみると、.NET 6 から最小ホスティングモデルとやらになり、Startup.cs は Program.cs に統合されたらしい?

Startup.cs と Program.cs を 1 つの Program.cs ファイルに統合します。

https://docs.microsoft.com/ja-jp/aspnet/core/migration/50-to-60?view=aspnetcore-6.0&tabs=visual-studio#new-hosting-model

とりあえず見様見真似でそれっぽいところを変更してみる。

 // Add services to the container.
 builder.Services.AddGrpc();
+builder.Services.AddMagicOnion();^M
 
 var app = builder.Build();
 
 // Configure the HTTP request pipeline.
-app.MapGrpcService<GreeterService>();
-app.MapGet("/", () => "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");
-
+app.MapMagicOnionService();^M
+app.MapGet("/", async context =>^M
+    {^M
+        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");^M
+    });^M
 app.Run();

とりあえずここまでで dotnet run をしてみる。
以下のようなエラーで怒られた。

$ dotnet run
ビルドしています...
Could not make proto path relative : error : Protos/greet.proto: No such file or directory [/(snip)/magic-onion-server/magic-onion-server.csproj]

どうやら削除した Protos ディレクトリへの参照が magic-onion-server.csproj に残っている模様。
その部分を消してみる。

     <ImplicitUsings>enable</ImplicitUsings>
   </PropertyGroup>
 
-  <ItemGroup>
-    <Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
-  </ItemGroup>
-
   <ItemGroup>
     <PackageReference Include="Grpc.AspNetCore" Version="2.40.0" />
     <PackageReference Include="MagicOnion.Server" Version="4.5.1" />

再度実行。
別のエラーで怒られる。

$ dotnet run
ビルドしています...
/(snip)/magic-onion-server/Program.cs(1,7): error CS0246: 型または名前空間の名前 'magic_onion_server' が見つかりませんでした (using ディレクティブまたはアセンブリ参照が指定されていることを確認してください) [/(snip)/magic-onion-server/magic-onion-server.csproj]

VSCode上でエラーが出ていたので薄々気づいていたが、デフォルトで書かれている using magic_onion_server.Services; が無効なよう。
以下のように修正してみると VSCode 上のエラーが消えた。

-using magic_onion_server.Services;
+using MagicOnion.Server;^M
 
 var builder = WebApplication.CreateBuilder(args);

再度実行。
成功!

$ dotnet run
ビルドしています...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5246
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7109
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /(snip)/magic-onion-server/

これで一応 MagicOnion サーバーを動かすことはできたっぽい。

湯豆腐湯豆腐

次に、MagicOnion サーバーに Service を実装してみる。
MagicOnion は Web-API ライクな Service とリアルタイミング通信のための StreamingHub という2種類の機能を提供しているらしい。

まずは Service のインタフェースを作る。
これをサーバーとクライアントで共有することで、RPCができるということらしい。

Shared.cs というファイルを新規作成し、以下のコードを書く。
MagicOnion.IService<T> を継承する必要があるようだ。

using System;
using MagicOnion;

namespace MyApp.Shared
{
    public interface IMyFirstService : IService<IMyFirstService>
    {
        UnaryResult<int> SumAsync(int x, int y);
    }
}

次に、この IMyFirstService を実装する Service を作る。

Services.cs というファイルを新規作成し、以下のコードを書く。
Service の実装は、MagicOnion.Server.ServiceBase<T> も継承する必要があるらしい。

using System;
using MagicOnion;
using MagicOnion.Server;
using MyApp.Shared;

namespace MyApp.Services
{
    public class MyFirstService : ServiceBase<IMyFirstService>, IMyFirstService
    {
        public async UnaryResult<int> SumAsync(int x, int y)
        {
            Console.WriteLine($"Received:{x}, {y}");
            return x + y;
        }
    }
}

これで Service の実装は完了。

湯豆腐湯豆腐

次に、この Service を呼び出すクライアント側のコードを書く。

dotnet のプロジェクト管理方法がいまいちわからないので、とりあえずサーバー側のプロジェクトと並べてクライアントのプロジェクトを作ってみる。
Console プロジェクトを作る。

$ cd ../
$ mkdir magic-onion-client
$ cd magic-onion-client
$ dotnet new console

クライアント側には MagicOnion.Client を追加する。

$ dotnet add package MagicOnion.Client

何らかの方法で、サーバー側の IMyFirstService をクライアントに共有する必要がある。
今回は Shared.cs を symlink で共有してみる。

$ ln -s ../magic-onion-server/Shared.cs Shared.cs

クライアント側のコードはシンプルに Program.cs に接続用のコードを書けば良いらしい。
README にしたがって、Program.cs に以下のように書く。

using Grpc.Net.Client;
using MagicOnion.Client;
using MyApp.Shared;

var channel = GrpcChannel.ForAddress("https://localhost:5001");

var client = MagicOnionClient.Create<IMyFirstService>(channel);

var result = await client.SumAsync(123, 456);
Console.WriteLine($"Result: {result}");

サーバー側を dotnet run で起動した状態で、クライアント側で dotnet run を実行してみる。
[サーバー側]

$ dotnet run
ビルドしています...
/(snip)/magic-onion-server/Services.cs(10,39): warning CS1998: この非同期メソッドには 'await' 演算子がないため、同期的に実行されます。'await' 演算子を使用して非ブロッキング API 呼び出しを待機するか、'await Task.Run(...)' を使用してバックグラウンドのスレッドに対して CPU 主体の処理を実行することを検討してください。 [/(snip)/magic-onion-server/magic-onion-server.csproj]
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5246
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7109
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /(snip)/magic-onion-server/

[クライアント側]

$ dotnet run
Unhandled exception. Grpc.Core.RpcException: Status(StatusCode="Unavailable", Detail="Error starting gRPC call. HttpRequestException: Connection refused (localhost:5001) SocketException: Connection refused", DebugException="System.Net.Http.HttpRequestException: Connection refused (localhost:5001)
(snip)

怒られた
どうやら localhost:5001 に繋げなかったらしい。

サーバー側の出力を見ると http://localhost:5246https://localhost:7109 で listen しているようなので、ポート番号を変更する必要がありそう。
もとのコードが https で接続するコードになっていたので、7109 に変更してみる。

 using MagicOnion.Client;
 using MyApp.Shared;
 
-var channel = GrpcChannel.ForAddress("https://localhost:5001");
+var channel = GrpcChannel.ForAddress("https://localhost:7109");^M
 
 var client = MagicOnionClient.Create<IMyFirstService>(channel);

また怒られた。

$ dotnet run
Unhandled exception. Grpc.Core.RpcException: Status(StatusCode="Internal", Detail="Error starting gRPC call. HttpRequestException: The SSL connection could not be established, see inner exception. AuthenticationException: The remote certificate is invalid because of errors in the certificate chain: PartialChain", DebugException="System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.

今度はどうやら無効な SSL 証明書だということで接続エラーになっている模様。

証明書を用意するのも面倒なので、http://localhost:5246 のほうに接続するようにしてみる。
(gRPC は基本 HTTP/2 を使うが、HTTP/2 は TLS なしでも動く(h2c)ようなので多分問題ない)
(gRPC, HTTP/2 の知識もあやしいのでちゃんと勉強せねば…)

 using MagicOnion.Client;
 using MyApp.Shared;
 
-var channel = GrpcChannel.ForAddress("https://localhost:7109");
+var channel = GrpcChannel.ForAddress("http://localhost:5246");^M
 
 var client = MagicOnionClient.Create<IMyFirstService>(channel);

動いた!
[クライアント側]

$ dotnet run
Result: 579

[サーバー側]

Received:123, 456

これで Service は動かせた。

湯豆腐湯豆腐

次は StreamingHub を試してみる。
README の例はクライアント側が Unity だが、Unity を入れるのも面倒なので適当に読み替えながら作ってみる。

まず、サーバー側。
Shared.cs を以下のようにする。

using MagicOnion;
using MessagePack;

namespace MyApp.Shared
{
    (snip)

    public interface IGamingHubReceiver
    {
        void OnJoin(Player player);
        void OnLeave(Player player);
        void OnMove(Player player);
    }

    public interface IGamingHub : IStreamingHub<IGamingHub, IGamingHubReceiver>
    {
        Task<Player[]> JoinAsync(string roomName, string userName, float position);
        Task LeaveAsync();
        Task MoveAsync(float position);
    }

    [MessagePackObject]
    public class Player
    {
        [Key(0)]
        public string Name { get; set; }
        [Key(1)]
        public float Position { get; set; }
    }
}

元々のコードでは Player クラスは Position と Rotation を持ち、それぞれ Vector3 と Quaternion だったが、面倒なので Position だけにして型も float にした。
(System.Numerics の Vector3 と Quaternion も試してみたが、MessagePack のシリアライズがサポートされていないというエラーが出た)

見た感じ、 IGamingHubReceiver はクライアント側で実行される処理、 IGamingHub はサーバー側で実行される処理を定義していそう。
Player というのがやり取りするデータ構造で、MessagePack を使ってシリアライズするために、[MessagePackObject] をつけて定義している。
(属性(Attribute)といういうらしい)
これを利用するために using MessagePack; が必要。

次に IGamingHub の実装を、StreamingHubs.cs というファイルを作成して、以下のように書く。

using MagicOnion.Server.Hubs;
using MyApp.Shared;

namespace MyApp.StreamingHubs
{
    public class GamingHub : StreamingHubBase<IGamingHub, IGamingHubReceiver>, IGamingHub
    {
        IGroup room;
        Player self;
        IInMemoryStorage<Player> storage;

        public async Task<Player[]> JoinAsync(string roomName, string userName, float position)
        {
            Console.WriteLine($"{userName} joined to {roomName}");
            self = new Player() { Name = userName, Position = position };

            (room, storage) = await Group.AddAsync(roomName, self);

            Broadcast(room).OnJoin(self);

            return storage.AllValues.ToArray();
        }
        public async Task LeaveAsync()
        {
            Console.WriteLine($"{self.Name} leaved");
            await room.RemoveAsync(this.Context);
            Broadcast(room).OnLeave(self);
        }
        public async Task MoveAsync(float position)
        {
            Console.WriteLine($"{self.Name} moved Position={position}");
            self.Position = position;
            Broadcast(room).OnMove(self);
        }

        protected override ValueTask OnDisconnected()
        {
            return CompletedTask;
        }
    }
}

クライアントが JoinAsync 呼び出すと、GamingHub の持っている room に追加され、その後同じ room に接続されているクライアントの OnJoin を呼び出すという仕組みになっていそう。
でも GamingHub の中で PlayerIGroup を1つずつしか持ってないので、直近 Join したプレイヤーしか LeaveAsyncMoveAsync で操作できなさそうだけど、どうなんだろう。

ともあれこれでサーバー側は実装完了?

湯豆腐湯豆腐

次はクライアント側の実装。

IGamingHubReceiver を実装したクライントを実装するため、Client.cs というファイルに以下のコードを書く。

using MyApp.Shared;
using Grpc.Core;
using MagicOnion.Client;

public class GamingHubClient : IGamingHubReceiver
{
    Dictionary<string, Player> players = new Dictionary<string, Player>();
    IGamingHub client;

    public async Task<Player> ConnectAsync(ChannelBase grpcChannel, string roomName, string playerName)
    {
        this.client = await StreamingHubClient.ConnectAsync<IGamingHub, IGamingHubReceiver>(grpcChannel, this);

        var roomPlayers = await client.JoinAsync(roomName, playerName, 0);

        foreach (var player in roomPlayers)
        {
            (this as IGamingHubReceiver).OnJoin(player);
        }
        return players[playerName];
    }

    public Task LeaveAsync()
    {
        return client.LeaveAsync();
    }

    public Task MoveAsync(float position)
    {
        return client.MoveAsync(position);
    }

    public Task DisposeAsync()
    {
        return client.DisposeAsync();
    }

    public Task WaitForDisconnect()
    {
        return client.WaitForDisconnect();
    }

    void IGamingHubReceiver.OnJoin(Player player)
    {
        Console.WriteLine("Join Player: " + player.Name);

        players[player.Name] = player;
        PrintPlayers();
    }

    void IGamingHubReceiver.OnLeave(Player player)
    {
        Console.WriteLine("Leave Player: " + player.Name);
        players.Remove(player.Name);
        PrintPlayers();
    }

    void IGamingHubReceiver.OnMove(Player player)
    {
        Console.WriteLine("Move Player: " + player.Name);

        if (players.TryGetValue(player.Name, out var p))
        {
            p.Position = player.Position;
        }
        PrintPlayers();
    }

    void PrintPlayers()
    {
        Console.WriteLine("\n==== Players ====");
        foreach (var player in players.Values)
        {
            Console.WriteLine($"{player.Name}: Position={player.Position}");
        }
        Console.WriteLine("=================\n");
    }
}

Unity と違って Player の位置がわからないので、イベントがあるたびに PrintPlayers で毎回表示するようにしてみた。

ConnectAsync で gRPC 接続して、room に Join する。
Join したあとは、その room に既にいる他の Player の情報を反映するために OnJoin を呼んでいる。
(自分自身に対して2回呼ばれてしまいそうだけどどうなんだろう?)

その他のメソッドは、基本的にサーバー側の関数を呼ぶだけ。

メインプログラム(Program.cs)は、CLI で参加や移動をするようにしてみた。
最初に room 名と player 名を入力し、その後は 0 を入力すると移動、9 を入力すると退出して終了する。

using Grpc.Net.Client;

var channel = GrpcChannel.ForAddress("http://localhost:5246");

var client = new GamingHubClient();

Console.Write("input room name > ");
var roomName = Console.ReadLine();
Console.Write("input player name > ");
var playerName = Console.ReadLine();

var player = await client.ConnectAsync(channel, roomName, playerName);

var exited = false;
while (!exited)
{
    Console.Write("input command (0: move, 9: exit) > ");
    var command = Console.ReadLine();
    switch (command)
    {
        case "0":
            Console.Write("input position > ");
            var position = float.Parse(System.Console.ReadLine());
            await client.MoveAsync(position);
            break;
        case "9":
            Console.WriteLine("Exit");
            await client.LeaveAsync();
            exited = true;
            break;
    }
}

動かしてみる。

[クライアント側]

input room name > hoge
input player name > fuga
Join Player: fuga

==== Players ====
fuga: Position=0
=================

Join Player: fuga

==== Players ====
fuga: Position=0
=================

input command (0: move, 9: exit) > 0
input position > 100
Move Player: fuga

==== Players ====
fuga: Position=100
=================

input command (0: move, 9: exit) > 0
input position > -100
Move Player: fuga

==== Players ====
fuga: Position=-100
=================

input command (0: move, 9: exit) > 9
Exit

[サーバー側]

fuga joined to hoge
fuga moved Position=100
fuga moved Position=-100
fuga leaved
info: Grpc.AspNetCore.Server.ServerCallHandler[14]
      Error reading message.
      System.IO.IOException: The request stream was aborted.
       ---> Microsoft.AspNetCore.Connections.ConnectionAbortedException: The HTTP/2 connection faulted.
         --- End of inner exception stack trace ---
         at System.IO.Pipelines.Pipe.GetReadResult(ReadResult& result)
         at System.IO.Pipelines.Pipe.GetReadAsyncResult()
         at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2MessageBody.ReadAsync(CancellationToken cancellationToken)
         at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
         at Grpc.AspNetCore.Server.Internal.PipeExtensions.ReadStreamMessageAsync[T](PipeReader input, HttpContextServerCallContext serverCallContext, Func`2 deserializer, CancellationToken cancellationToken)

一応動いた!
ただやはり OnJoin が2回呼ばれていた。
また、退出したあと、サーバー側で例外が出てしまっているがこれはどうしたら回避できるのだろう?

湯豆腐湯豆腐

とりあえず以下のようにクライアント側を修正して、OnJoin が2回呼ばれるのと、退出時のサーバー側の例外は解消。
終了時はちゃんと Dispose してあげる必要があった。
[Client.cs]

         foreach (var player in roomPlayers)
         {
+            if (player.Name == playerName)
+            {
+                // skip myself
+                continue;
+            }
             (this as IGamingHubReceiver).OnJoin(player);
         }
         return players[playerName];

[Program.cs]

             exited = true;
             break;
     }
-}
\ No newline at end of file
+}^M
+^M
+await client.DisposeAsync();
\ No newline at end of file

あと、以前の投稿の

でも GamingHub の中で Player と IGroup を1つずつしか持ってないので、直近 Join したプレイヤーしか LeaveAsync や MoveAsync で操作できなさそうだけど、どうなんだろう。
については、Connection ごとに GamingHub のインスタンスが作成されるようなので問題無さそう。

参考:https://qiita.com/mitchydeath/items/cecf01493d1efeb4ae55
(公式ドキュメントのどこに書いてあるのかは発見できず。。)

湯豆腐湯豆腐

複数クライアントを繋いでみたところ、

  1. クライアント1が MoveAsync を実行
  2. サーバーとクライアント1自身には変更が通知されるが、クライアント2には通知されない
  3. クライアント2が MoveAsync を実行
  4. このタイミングでクライアント1の移動がクライアント2にも通知される

となって、クライアント1の移動がクライアント2に即時に伝わらないのだけど、これはなぜなんだろう