MagicOnion – C#による .NET Core/Unity 用のリアルタイム通信フレームワーク


Cy#の河合です。Cy#は今年の9月に設立されたばかりの会社で、その名の通りC#関連の開発を行っていきます。今回はCy#よりオープンソースライブラリとして、Unity向けのリアルタイム通信/API通信統合ライブラリをリリースしました。

GitHub – cysharp/MagicOnion

もともと2年前に最初に公開し、実際にリリースされたモバイルゲームでも使用していたものですが、今回リアルタイム通信向け機能をよりブラッシュアップして、正式公開としました。そういう点では、”既に実績がある”とも言えます。今回より新しいスタートということで、バージョン2.0です。

基本的な機能は サーバークライアント間のストリーミングRPCを提供します。サーバー側をC#、クライアント側もC#で実装し、メッセージのフォーマットはLZ4圧縮されたMessagePack、通信はgRPCによるHTTP/2を用いています。他のミドルウェアにあてはめるとNode.js(JavaScript)によるSocket.io(WebSocket)がイメージとして近いかもしれません。また、同時にAPIサーバーとしての機能もサポートするため、一般のウェブフレームワーク的でもあります。

MagicOnionは最高のパフォーマンスと、C#として手触りの良いインターフェイスの実現に注力しました。

C#により強く型付けされたインターフェイス

サーバーとクライアント間ではC#のインターフェースを共有することによって、クライアント->サーバーへのメソッド呼び出しと、サーバー->クライアントへのメソッド呼び出し両方を、強く型定義します。例として以下のようなインターフェイスやクラスを共有するとします。

// サーバー -> クライアントの通信定義
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, Vector3 position, Quaternion rotation);
    Task LeaveAsync();
    Task MoveAsync(Vector3 position, Quaternion rotation);
}

// 送受信に使うカスタムオブジェクト
[MessagePackObject]
public class Player
{
    [Key(0)]
    public string Name { get; set; }
    [Key(1)]
    public Vector3 Position { get; set; }
    [Key(2)]
    public Quaternion Rotation { get; set; }
}

これをサーバーとクライアントで共有すると、どちらもこのインターフェイスを実装するだけで、正しく通信ができます。

と、いうように、中間言語からのコード生成等も不要で、C#として自然に(複数引数やプリミティブ型などが使える)メソッドを呼ぶだけで、ネットワークを超えてメソッド呼び出しができます。もちろん、入力補完も効きます。

具体的な実装は、以下のようになります。サーバーはIGamingHubとして定義したインターフェイスを実装します。

// サーバー実装
public class GamingHub : StreamingHubBase<IGamingHub, IGamingHubReceiver>, IGamingHub
{
    IGroup room;
    Player self;
    IInMemoryStorage<Player> storage;

    public async Task<Player[]> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation)
    {
        self = new Player() { Name = userName, Position = position, Rotation = rotation };

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

        Broadcast(room).OnJoin(self);

        return storage.AllValues.ToArray();
    }

    public async Task LeaveAsync()
    {
        await room.RemoveAsync(this.Context);
        Broadcast(room).OnLeave(self);
    }

    public async Task MoveAsync(Vector3 position, Quaternion rotation)
    {
        self.Position = position;
        self.Rotation = rotation;
        Broadcast(room).OnMove(self);
    }
}

ポイントは

  • 全て非同期(戻り値Taskでasync)
  • 戻り値を返すことも出来る(例外が発生した場合、それもクライアントに例外として伝搬されます)
  • Groupでグループ管理してBroadcast(group)でグループ内のクライアントに送信

といったところになっています。クライアントのほうはIGamingHubReceiverとして定義したインターフェイスを実装することでサーバーからブロードキャストしたデータを受け取れます。また、IGamingHubそのものがサーバーへの自動実装されたネットワーククライアントとなっています。

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

    // 委譲したメソッドを立てるのが面倒な場合は(面倒)これをそのまま公開したりしても勿論別に良い。
    IGamingHub client;

    public async Task<GameObject> ConnectAsync(Channel grpcChannel, string roomName, string playerName)
    {
        var client = StreamingHubClient.Connect<IGamingHub, IGamingHubReceiver>(grpcChannel, this);

        var roomPlayers = await client.JoinAsync(roomName, playerName, Vector3.zero, Quaternion.identity);
        foreach (var player in roomPlayers)
        {
            (this as IGamingHubReceiver).OnJoin(player);
        }

        return players[playerName]; // 名前だけでマッチとか脆弱の極みですが、まぁサンプルなので。
    }

    // サーバーへ送るメソッド群

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

    public Task MoveAsync(Vector3 position, Quaternion rotation)
    {
        return client.MoveAsync(position, rotation);
    }

    // 後始末するもの
    public Task DisposeAsync()
    {
        return client.DisposeAsync();
    }

    // 正常/異常終了を監視できる。これを待ってリトライかけたりなど。
    public Task WaitForDisconnect()
    {
        return client.WaitForDisconnect();
    }

    // サーバーからBroadcastされたものを受信するメソッド

    void IGamingHubReceiver.OnJoin(Player player)
    {
        Debug.Log("Join Player:" + player.Name);

        var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
        cube.name = player.Name;
        cube.transform.SetPositionAndRotation(player.Position, player.Rotation);
        players[player.Name] = cube;
    }

    void IGamingHubReceiver.OnLeave(Player player)
    {
        Debug.Log("Leave Player:" + player.Name);

        if (players.TryGetValue(player.Name, out var cube))
        {
            GameObject.Destroy(cube);
        }
    }

    void IGamingHubReceiver.OnMove(Player player)
    {
        Debug.Log("Move Player:" + player.Name);

        if (players.TryGetValue(player.Name, out var cube))
        {
            // 移動に対して補完が入っていないので勿論このままではワープしてしまいます
            // そこに対する補助は(現状は)ないので各自で行ってください
            cube.transform.SetPositionAndRotation(player.Position, player.Rotation);
        }
    }
}

C#として全てが強く型定義されているため、

  • メソッド名や引数の変更などに対してのIDEのリファクタリング追跡されてサーバー/クライアント双方で効く
  • 実装漏れなどはコンパイルエラーによって自然に防ぐことが出来る
  • 文字列を介さないことにより効率的な通信ができる(メソッド名は自動的に数値によるIDに変換されているため、文字列は送りません)
  • intなどプリミティブ型が自然に送れる(あえて固有のリクエストクラスなどにラップする必要はない)

というメリットがあります。Protocol Buffers を使う場合 .proto (IDL : 中間定義) ファイルの管理や、生成をどうするかなど、多くの問題が発生しますが、C#そのものである限り、一切その手の問題は起こりません。

ゼロデシリアライゼーションマッピング

RPC、特に頻度の高い通信が行われるリアルタイム通信では、送受信用にデータを変換するシリアライズ処理が性能面でのネックになることがありますが、MagicOnionでは私の開発したC#最速のバイナリシリアライザであるMessagePack for C#によってシリアライズするため、シリアライズ処理はボトルネックになりえません。また、MessagePack for C#でシリアライズできるなら、どんな型でも送ることができるというデータに対する柔軟性も、性能と同時に獲得しています。

また、クライアント/サーバーが共にC#であるため「内部のメモリ上のデータは同一レイアウトが期待できる」ことを生かして、値型の場合にシリアライズ/デシリアライズ処理をせずメモリコピーだけでマッピングする、というオプションを用意しました。

// Vector3などのUnityの標準structとその配列、あるいはカスタムstructとその配列が対応可能
// 厳密にサイズを合わせるため[StructLayout(LayoutKind.Explicit)]で明示することを薦めます
public struct CustomStruct
{
    public long Id;
    public int Hp;
    public int Mp;
    public byte Status;
}

// ---- 初期化事に以下のようなコードを登録する

// 登録することでTとT[]がゼロデシリアライゼーションマッピングになる
UnsafeDirectBlitResolver.Register<CustomStruct>();

// ↑のstructとUnity標準の構造体(Vector2, Rectなど)とその配列を標準登録
CompositeResolver.RegisterAndSetAsDefault(
    UnsafeDirectBlitResolver.Instance,
    MessagePack.Unity.Extension.UnityBlitResolver.Instance
    );

// --- あとは通信で使うと、自然に↑のフォーマットで送受信する
await client.SendAsync(new CustomStruct { Hp = 99 });

これは一切の処理が入らないため、転送処理において理論上最速といえます。ただしstructはコピーが発生するため、巨大なstructを定義するなら全てrefで処理するなどの工夫と規約をしないと、逆に遅くなる可能性もあるので、その点は注意。

大量のTransformを送る、例えばVector3の配列などでは、分かりやすく有効に活用できることと思います。

gRPCのBidirectional Streamingではダメな理由

素のgRPCでも Bidirectional Streaming として双方向通信の機能を持っています。そもそもMagicOnionのストリーミングRPCは Bidirectional Streamingの上に構築されています。

// protoによるBidirectional Streaming定義
rpc BidiHello(stream HelloRequest) returns (stream HelloResponse);

しかし、多くの理由でBidirectional Streamingをリアルタイム通信のRPCとして使うことは難しいでしょう。最大の点は、そもそもこの状態だとRPCではないため、コネクションが繋がった後、oneof(ひとつの型が複数の型を持つ)で定義したRequest/Responseを分岐させて、呼び出したいメソッドに手で振り分けることになるでしょう。そこまではやれたとしても、まだまだ足りないものは多く

  • クライアントがサーバー側の実行完了を待てない(リクエストを送信できたというところで制御が戻る)
  • 待てないということでリクエストに対する戻り値も例外も受け取れない
  • 複数の接続を束ねる手段が用意されていない

それらを処理するための仕組みを作ってもなお、結局protoの生成するBidirectional Streamingのテンプレートからは逃れられないため、実装がゴチャついてしまうのは避けられないでしょう。MagicOnionのStreamingHubは、コネクションの確立をBidirectional Streamingの上に構築しつつも、その通信フレームの中で独自の軽量なプロトコルを流すことによって、C#として自然に扱えるリアルタイム通信用のRPCを実現しています。

分散戦略とgRPCを選ぶ理由

Unityのための他のリアルタイム通信エンジンなどと違い、MagicOnion自体はロードバランサーを持ちません。分散処理のための戦略は幾つかありますが、クラウドプラットフォームや各種ミドルウェアを活用する方向の選択をお薦めしています。例えば、完全に独立してインメモリでホスティングする場合、サーバーの台の選択は外部のService Discoveryに任せてしまうのが一つ。

もう一つは、TCPロードバランサーを用いて完全に分散しつつ、Groupによるブロードキャスト処理をRedisに委譲することで異なる台に繋がったクライアントへのデータの送信を可能にします。これは標準でMagicOnion.Redisとして提供しています。例えばチャットの実装などに向いているでしょう。

他、MagicOnion自体はgRPCそのものと同じように、いわゆるMicroservicesの実装にも適しているため、サーバー間でコネクションを繋いで、サーバー-サーバーRPCによって構成を組むことも可能です。

さて、MagicOnionはgRPCの上に構築されていますが、同時に最大の特徴である.protoによる言語非依存のRPC提供という部分を完全に無視しています。おまけにネットワーク通信がHTTP/2(TCP)に限定されるというのは、必ずしもゲーム向きとは言い難いかもしれません。それでもなお、gRPCを選ぶべきと考えた理由があります。

一つはライブラリとしての成熟度で、そもそもUnity含めたサーバー/クライアント実装が使える通信ライブラリは存在しない上に、コア部分(全言語共通のgRPC C)はgoogle含めて圧倒的に世の中で使われているため、安定性が非常に高い。ゲームに特化した部分だけの通信の独自実装は、できなくはないでしょうが、一から安定性を高めていくことを考えると、乗れるものには乗っかたほうが無難でしょう。

ただし、主にパフォーマンス面でgRPCのC#バインディングに関しては不満があります。そのためgRPC C Coreはそのまま使いつつ、C#バインディングだけを完全に置き換えてしまうのはアリだと思っています。少なくともUnity側(クライアント通信)のみならば、現実的かつ効果も高いと思っているので。

もう一つはエコシステムで、gRPCは現行世代のRPCとしてほぼデファクトスタンダードと言える地位を確立したため、多くのサーバー用ミドルウェアが対応しています。NginxのgRPC対応Envoyによるリクエスト単位のロードバランシングなど、HTTP/2, gRPCという業界スタンダードなプロトコルだからこそ授かれる恩恵が多くあります。また、gRPCは英語でも日本語でも、ブログや事例スライドが豊富なため、より良いシステム構築を目指しやすいのも大きな利点です。

MagicOnionもアプリケーションレイヤーでは独自のものが構築されていますが、インフラ的な意味ではgRPCそのものなので、ミドルウェアも、共有されているナレッジも、ほとんどがそのまま適用できます。

現代のサーバーアーキテクチャはクラウド前提であるべきだと思いますし、クラウドプロバイダーの提供するインフラやミドルウェアにフルに乗っかったシステムのほうが、全部自前で持とうとするシステムよりも優れた結果を残せると思っています。よって、特にインフラに関わるフレームワーク自体の機能は薄くしていくべきでしょう。

API通信系への対応

MagicOnionはUnified Network Engineを標榜しています。この場合のUnifiedは、サーバーとクライアントがC#で統一されること、を意味するのではなくて、リアルタイム通信系とAPI通信系が統一されて扱えることを意味しています。API通信系も同じように、インターフェイスを共有し、C#として自然にメソッドを定義すれば、クライアントコードも自動で生成される仕組みになっています。

// StreamingHubと同じようにインターフェイスを定義し、共有する
public interface IMyFirstService : IService<IMyFirstService>
{
    UnaryResult<int> SumAsync(int x, int y);
}

// サーバー側はそのインターフェイスを実装する
public class MyFirstService : ServiceBase<IMyFirstService>, IMyFirstService
{
    // マジカルテクノロジにより戻り値がTask<T>じゃなくても良い
    public async UnaryResult<int> SumAsync(int x, int y)
    {
        // 内部は完全に非同期対応なので自然な書き味でノンブロッキングAPIが実装できる
        Logger.Debug($"Received:{x}, {y}");

        return x + y;
    }
}

// クライアント側ではそのインターフェイスを取り出し、呼び出す

// インターフェイスを通信コード実装に置き換えたプロキシを取得する
var client = MagicOnionClient.Create<IMyFirstService>(channel);

// 自然な感じに引数を渡し、自然な感じに戻り値が受け取れる
var result = await client.SumAsync(100, 200);

API系においてもフレームワークレベルで全てが非同期・ノンブロッキングであることが徹底されていますが、C#のasync/await言語機能によりほとんど自然に見せることに成功しています。リクエスト前後をフックするフィルター機能も用意されていて、これも自然に非同期で処理されるようになっています。

// 適用したいメソッドに[SampleFilter]のように属性を付与すると重ね合わせられる
public class SampleFilterAttribute : MagicOnionFilterAttribute
{
    // 前後の処理のフックを非同期で、かつC#の言語仕様のまま自然に書けるようになっています
    // このフィルタを重ね合わせた様がたまねぎ状であることが、MagicOnionの由来となっています
    public override async ValueTask Invoke(ServiceContext context)
    {
        try
        {
            /* 前処理 */
            await Next(context); // メソッド本体、あるいは次のフィルターを呼ぶ
            /* 後処理 */
        }
        catch
        {
            /* 例外時処理 */
            throw;
        }
        finally
        {
            /* 後始末 */
        }
    }
}

なお、フィルターはStreamingHubでも同様に使えます。

Swagger

APIは動作確認しづらい、常にUnityから叩くのも面倒くさいし、gRPCではPostmanのようなツールで叩くこともできない。と、そこでMagicOnionではSwaggerによる実行可能なAPIドキュメントを自動生成することにしました。MagicOnion自身がHTTP/1サーバーとなりホスティングするため、別途外部のプロキシサーバーを立てる必要などはありません、起動部分のコードに数行加えるだけです。

これで簡単に動作確認できるほか、デバッグコマンドなどもAPIとして定義するだけSwagger上に並ぶため、デバッグ用にデータベースを操作するコマンドなども簡単に用意していくことが可能かもしれません。

現状StreamingHubは対応していませんが、WebSocketとMagicOnionを接続するWebSocketGatewayを作る予定はあります。

デプロイとホスティング

従来、C#サーバーサイド最大の難関はデプロイどうしよう、ホスティングどうしよう、ということでした。なにせWindows Serverでしたし。gRPCだったら、更になおIISじゃないから余計に難しかったり。しかし、現在は簡単です。Dockerでコンテナ化してしまえば、別にC#だからって特別なことは何一つありません。.NET Coreによって作成されたMagicOnionアプリケーションをコンテナ化するのは特に複雑なこともなく簡単で(実態はただの.NET Coreコンソールアプリケーションです)、あとはそれをLinuxコンテナにデプロイしてしまえば良い。ECSでもFargateでもGKEでもAKSでも、どこでも良いのです。そのためのプラクティスも、ネットに存在する様々な記事のものをそのまま適用すれば良いでしょう。

現状C#でのコンテナ化の意義は、ローカル環境を構築するためというよりも、開発環境/本番環境に簡単に運ぶため、特に今までC#/Windowsに馴染みのなかった人が特別なことを学ぶことなく豊富なインフラ知識をそのまま活かして適用することができる、ということが最も大きいのではないかなあ、と感じています。

終わりに

Cy#の理念は「C#の可能性を切り開いていく」です。MagicOnionはリアルタイム通信系のためだけに導入してもらってもいいですし、API通信系だけでも十分以上に機能します。高速な通信と、圧縮も考慮されたシリアライザが適用されるため、導入すれば通信関連に関して一切悩む必要はなくなるでしょう。また、Unityにおいてはasync/awaitが活用されているため、最新のC#を導入していくためのゲートウェイとしても機能するかもしれません。

リアルタイム通信フレームワークとしては、Client-ServerのRPCしか提供していません、が、逆にそれがあれば他は全部なんとかなります(モノによりますが実装量もそれほど多くはないはず)。余計なものがついてなくて、RPCに関して最高の手触り(とパフォーマンス、と言いたいですがgRPC C#バインディングのほうで気になるところがあるので最高のパフォーマンスというのは、また次に取っておきます)を提供できたのではないかと自負しています。

また、完全に自前のシステムで完動するため、VR/ARの展示など限定されたLAN環境などでも、LAN内でサーバーを起動しておくだけで問題なく展開することができます……!

Cy#ではMagicOnionを中心に、C#活用の道を作り出していきたいと考えています。
是非、皆さんも試していただければ幸いです。

何かありましたらGitHubのissueや私の個人宛てにどうぞ。