とかげ備忘録

IT系の備忘録です。

MagicOnionを使ってリアルタイム通信を試す。【Unity】

はじめに

1年ほど前に使ったことがあったので、 久しぶりに導入してみたら躓いたので、 記録として残しておきます。

とりあえず覚えながら作ってみたやつ

MagicOnionについて

Web API ライクな通信や、リアルタイム通信ができるフレームワーク

特徴

サーバー側もクライアント側もC#で書ける。
gRPCを使用。(HTTP/2や、Protocol Buffersを使っていて、高速)
MessagePack for C#を使用。(LZ4で圧縮されてサイズが小さく、高速)
フィルタ機能を持ち、多段に重ねて処理を行うことができる。 ←この様子がMagicOnionの由来だとか
(↑通信の前後に追加の処理を行うことができる。)


色々調べていたら、チャットとか、マルチプレイを実現できそうなものとして、下記のようなものもありました。

  • SignalR

  • Photon(PUN2)

MagicOnionを選んだ理由は、1度勉強会で扱ったことがあるからです。

使うイメージ

クライアント側とサーバー側が共有するインターフェースを用意して、 それぞれ実装します。 互いのメソッドを呼び出してあげれば通信できます。

f:id:ttokage2233:20200425235331p:plain

準備

  • Unity

2018.4.2f1

各種ダウンロード

ダウンロードファイルの***.zipファイルですが、MagicOnionをIL2CPP環境で使う場合に使用します。 この対応については、最後の方に後述します。

MagicOnion

https://github.com/cysharp/MagicOnion/releases

バージョン

3.0.11

取得するファイル

  • MagicOnion.Client.Unity.unitypackage

  • moc.zip

gRPC

https://packages.grpc.io/

バージョン

2.26.0


https://packages.grpc.io/archive/2019/11/b5e0d5e0443e229841225a8727f5fb268544953f-18403aac-451d-4f74-8850-c2747419fe76/index.xml

取得するファイル

  • grpc_unity_package.2.26.0-dev.zip


その時最新だった2.29.0で試したが、VisualStudioでプロジェクト作成して作業しようとしたら、「予期せぬエラーです。」と表示されてしまった。
分からなかったので、このバージョンに落とした。

もしかしたら、System.Memory.dllバージョンが噛み合ってないからだろうか・・・。
https://github.com/grpc/grpc/issues/21908

↑2.26.0で動いたというコメントがあったので、このバージョンを使用した。

MessagePack for C#

https://github.com/neuecc/MessagePack-CSharp/releases

バージョン

2.1.90

取得するファイル

  • MessagePack.Unity.2.1.90.unitypackage

  • mpc.zip

導入

Unityの設定変更

「File」→「Build Settings」から、 「Player Settings...」 を押す。

ビルド対象OSの 「Other Settings」項目 → 「Configuration」項目の

Api Compatibility Level」 → .NET 4.x 「Allow 'unsafe' Code」 → チェック

に設定する。

f:id:ttokage2233:20200426000150p:plain

用意したファイルのインポート

Unityプロジェクトを立ち上げ、**.unitypackageのファイルをインポートします。

  • MagicOnion.Client.Unity.unitypackage

  • MessagePack.Unity.2.1.90.unitypackage

f:id:ttokage2233:20200426000713p:plain f:id:ttokage2233:20200426000725p:plain

ファイルの競合

導入時に、Assets/Plugins/ 直下の

System.Buffers
System.Memory
System.Runtime.CompilerServices.Unsafe
System.Threading.Tasks.Extensions

上記のファイルが競合するので、
名前を変えて、競合を回避する。
なんか消すのが怖かったので、実際どうなんだろう・・・。

これらを行えば、とりあえずUnity Editor上のエラーは消えた。

ひとまずテキトーにスクリプトを開いてVisual Studioを立ち上げる。

VisualStudioの設定

VisualStudioに.Net Core SDKとランタイムをインストールしていない場合は、インストールしておきます。 私の環境だと入っていたので、多分この辺りを確認すればよいと思います。

f:id:ttokage2233:20200426010710p:plain

f:id:ttokage2233:20200426010421p:plain f:id:ttokage2233:20200426010441p:plain

クライアント側の用意

特になし

サーバー側の用意

Unityのソリューションから右クリックで、
新規のプロジェクトを作成する。

f:id:ttokage2233:20200426013314p:plain f:id:ttokage2233:20200426013646p:plain

サーバー側にパッケージをインストールする。

  • MagicOnion.Hosting

  • MessagePack.UnityShims

f:id:ttokage2233:20200426012156p:plain f:id:ttokage2233:20200426012356p:plain 図:パッケージインストール例

(今思えば、これらのパッケージを参考にクライアント側のバージョンを合わせてダウンロードすればよかったのでは?と思う自分であった。)

クライアント - サーバー間の共有ファイルの設定

サーバー側のプロジェクトファイルを編集して、共有するフォルダの設定を行います。

  • クライアント側

f:id:ttokage2233:20200426014214p:plain

  • サーバー側

サーバー用プロジェクトの設定に、
Unityプロジェクトの共有用フォルダのパスを指定します。
f:id:ttokage2233:20200426015506p:plain

クイックスタートをやってみる

GitHubに載ってたクイックスタートを参考にやってみる。
https://github.com/cysharp/MagicOnion

サーバーのスタート処理

  • Program.cs(サーバー側)
using System;
using Grpc.Core;
using MagicOnion.Server;

namespace MagicOnionDemoServer
{
    class Program
    {
        static void Main(string[] args)
        {
            GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());

            // setup MagicOnion and option.
            var service = MagicOnionEngine.BuildServerServiceDefinition(isReturnExceptionStackTraceInErrorDetail: true);

            var server = new global::Grpc.Core.Server
            {
                Services = { service },
                Ports = { new ServerPort("localhost", 12345, ServerCredentials.Insecure) }
            };

            // launch gRPC Server.
            server.Start();

            // and wait.
            Console.ReadLine();
        }
    }
}

API通信

コーディング

共有するインターフェースを作成する。

  • IMyFirstService.cs
using Grpc.Core;
using MagicOnion;
using MagicOnion.Server;
using System;

namespace ServerShared.Services {
    public interface IMyFirstService : IService<IMyFirstService>
    {
        /// <summary>
        /// 足し算を行う
        /// </summary>
        /// <param name="x"></param>
        /// <param name="y"></param>
        /// <returns></returns>
        UnaryResult<int> SumAsync(int x, int y);
    }
}

サーバー側実装

  • MyFirstService.cs
using System.Collections.Generic;
using System.Text;
using Grpc.Core;
using MagicOnion.Hosting;
using MagicOnion;
using MagicOnion.Server;
using ServerShared.Services;

    class MyFirstService : ServiceBase<IMyFirstService>, IMyFirstService
    {
        public async UnaryResult<int> SumAsync(int x, int y)
        {
            Logger.Debug($"Received:{x}, {y}");
            return x + y;
        }
    }

クライアント側実装

  • ApiConnectionTest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Grpc.Core;
using MagicOnion.Client;
using ServerShared.Services;

public class ApiConnectionTest : MonoBehaviour
{
        async void Start() {
        // standard gRPC channel
        var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);

        // get MagicOnion dynamic client proxy
        var client = MagicOnionClient.Create<IMyFirstService>(channel);

        // call method.
        var result = await client.SumAsync(100, 200);
        Debug.Log("Client Received:" + result);
    }
}

動作確認

サーバー側のプロジェクトをスタートアッププロジェクトに設定し、
プログラムを実行します。

f:id:ttokage2233:20200426025149p:plain f:id:ttokage2233:20200426030111p:plain

クライアント側は、 空のGameObjectを作成して、ApiConnectionTest.csをアタッチして実行します。

f:id:ttokage2233:20200426025857p:plain

f:id:ttokage2233:20200426030230p:plain

通信できました。

リアルタイム通信

コーディング

インターフェースを作成

  • IGamingHub.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MagicOnion;
using ServerShared.MessagePackObjects;
using UnityEngine;

namespace ServerShared.GamingHub
{
    /// <summary>
    /// クライアント → サーバー 処理
    /// </summary>
    public interface IGamingHub : IStreamingHub<IGamingHub, IGamingHubReceiver>
    {
        /// <summary>
        /// 入室処理
        /// </summary>
        /// <param name="roomName"></param>
        /// <param name="userName"></param>
        /// <param name="position"></param>
        /// <param name="rotation"></param>
        /// <returns></returns>
        Task<Player[]> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation);

        /// <summary>
        /// 退出処理
        /// </summary>
        /// <returns></returns>
        Task LeaveAsync();

        /// <summary>
        /// 移動処理
        /// </summary>
        /// <param name="position"></param>
        /// <param name="rotation"></param>
        /// <returns></returns>
        Task MoveAsync(Vector3 position, Quaternion rotation);
    }

    /// <summary>
    /// サーバー → クライアント 処理
    /// </summary>
    public interface IGamingHubReceiver
    {
        /// <summary>
        /// 入室を接続クライアント全員に通知
        /// </summary>
        /// <param name="player"></param>
        void OnJoin(Player player);

        /// <summary>
        /// 退出を接続クライアント全員に通知
        /// </summary>
        /// <param name="player"></param>
        void OnLeave(Player player);

        /// <summary>
        /// 移動したことを接続クライアント全員に通知
        /// </summary>
        /// <param name="player"></param>
        void OnMove(Player player);
    }
}

サーバー側実装

  • GamingHub.cs
using System;
using System.Collections.Generic;
using System.Text;
using MagicOnion;
using MagicOnion.Server.Hubs;
using Grpc.Core;
using ServerShared.MessagePackObjects;
using ServerShared.GamingHub;
using System.Threading.Tasks;
using UnityEngine;
using System.Linq;

namespace MagicOnionDemoServer
{
    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 };

            // Group can bundle many connections and it has inmemory-storage so add any type per group. 
            (room, storage) = await Group.AddAsync(roomName, self);

            // Typed Server->Client broadcast.
            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);
        }

        /// <summary>
        /// 切断処理
        /// </summary>
        /// <returns></returns>
        protected override ValueTask OnDisconnected()
        {
            // on disconnecting, if automatically removed this connection from group.
            return CompletedTask;
        }
    }
}

クライアント側実装

  • GamingHubClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using Grpc.Core;
using MagicOnion.Client;

using ServerShared.GamingHub;
using ServerShared.MessagePackObjects;
using UnityEngine;

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)
    {
        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];
    }

    // methods send to server.

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

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

    // dispose client-connection before channel.ShutDownAsync is important!
    public Task DisposeAsync()
    {
        return client.DisposeAsync();
    }

    // You can watch connection state, use this for retry etc.
    public Task WaitForDisconnect()
    {
        return client.WaitForDisconnect();
    }

    // Receivers of message from server.

    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);
        }
    }
}
  • RealTimeConnectionTest.cs

テキトーなGameObjectを作成して、アタッチします。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using Grpc.Core;
using ServerShared.GamingHub;

public class RealTimeConnectionTest : MonoBehaviour
{
    public static GamingHubClient gamingHubClient;
    Channel channel;
    async void Start()
    {

        this.channel = new Channel("localhost:12345", ChannelCredentials.Insecure);

        RealTimeConnectionTest.gamingHubClient = new GamingHubClient();

        await gamingHubClient.ConnectAsync(channel, "SampleRoom", "tarou");
    }

}

動作確認

API通信と同様、サーバー側を実行しておいて、 クライアント側を実行します。

うまくいけば、四角いプレイヤーオブジェクトが生成されます。 これに、プレイヤーを動かすスクリプトを実行中にアタッチして、 確認してみます。

  • PlayerController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{

    float speed = 3f;
    Vector3 pos, currentPos;

    void Update()
    {
        Move();
    }
    
    void Move()
    {
        pos = transform.position;
        currentPos = pos;

        if (Input.GetKey("left"))
        {
            pos.x -= speed * Time.deltaTime;
        }
        if (Input.GetKey("right"))
        {
            pos.x += speed * Time.deltaTime;
        }
        if (Input.GetKey("up"))
        {
            pos.y += speed * Time.deltaTime;
        }
        if (Input.GetKey("down"))
        {
            pos.y -= speed * Time.deltaTime;
        }

        if (currentPos == pos) //値が変わらなかったら何もしない
        {
            return;
        }
        //サーバーを通して移動できるか確認したいので、コメントアウト
        //transform.position = pos;

        //移動情報送信
        RealTimeConnectionTest.gamingHubClient.MoveAsync(pos, transform.rotation);
    }

}

f:id:ttokage2233:20200426141854j:plain ↑*静止画です
移動はガタガタですが、情報のやりとりができました。

IL2CPP対応

MagicOnionをIL2CPP環境で使う場合は、事前のコードジェネレートが必要になります。
mpc.zipとmoc.zipファイルの中にコードジェネレートのための実行ファイルがあるので、
それを設定します。

minamiさんの記事が参考になりました。

  • Unity+.NET Core+MagicOnionの環境構築ハンズオン

https://qiita.com/_y_minami/items/c7899fdf1db505c06ba2

その中のMenuItems.csを、こちらが使うファイルパスに変更して、InitialSettings.csは下記のように設定してみました。
(このあたりのインスタンス登録処理は手探り状態で、どれを登録したらよいか、よくわかっていない。)

  • InitialSettings.cs
using MagicOnion.Resolvers;
using MessagePack.Resolvers;
using MessagePack.Unity;
using MessagePack;
using UnityEngine;

class InitialSettings
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void RegisterResolvers()
    {

        StaticCompositeResolver.Instance.Register(

                UnityResolver.Instance,
                MagicOnionResolver.Instance,
                GeneratedResolver.Instance,
                BuiltinResolver.Instance,
                PrimitiveObjectResolver.Instance
        );

        var options = MessagePackSerializerOptions.Standard.WithResolver(StaticCompositeResolver.Instance);

        MessagePackSerializer.DefaultOptions = options;

    }
}

MagicOnionResolver.Instance、GeneratedResolver.Instanceあたりは、
はじめは参照エラーだったが、
コードジェネレートした後に確認したら、参照エラーが解決していた。

さいごに

Taskや、await/asyncなどの理解をもう少し深めようと思った。
設計って難しい。
ここの処理はクライアント側orサーバー側に持たせる。とか
レスポンス速度これくらいで大丈夫かな。とか
通信が途中で切断したときどうしよう。とか
そういうところ意識しないとな。

参考

  • MagicOnion入門

https://www.slideshare.net/torisoup/magiconion-174973732

https://tech.cygames.co.jp/archives/3181/

  • Unity+.NET Core+MagicOnionの環境構築ハンズオン

https://qiita.com/_y_minami/items/c7899fdf1db505c06ba2