C#
Unity
.NETCore
MagicOnion
gumi Inc.Day 15

Unity+MagicOnionで超絶手軽にリアルタイム通信を実装してみた

ちょっとした前置き

先日、@neuecc氏が開発したMagicOnionのver2が正式リリースされました。
SnapCrab_NoName_2018-12-16_0-39-5_No-00.png

MagicOnionとはHttp/2を使用したgoogle製rpcフレームワーク「grpc」をC#で使いやすくラップし、さらにUnityでの動作もサポートしているリアルタイム通信が可能なフレームワークです。

この記事では

「MagicOnionを使えばUnityでこんな簡単にリアルタイム通信が行えるんだ!(^o^)」

という事を伝えていきたいと思います。
※色々便利な補助ツールが付いててその紹介もしたいんですが、この記事では「簡単、手軽にリアルタイム通信を実現する事」を重視します。
※チュートリアルをなぞるような入門者向け内容となってます。

あと、出来上がるものを先に見せておきますが、まぁなんてことない簡単なチャットです。
chat.gif

PC環境

・Windows10
・Unity 2018.3f02(C# 7.0以降が使える環境であることが必要)
・Visual Studio 2017 Community 15.8
.NET Core SDK 2.2.1
.NET Core Runtime 2.2.0
※.NET CoreはVisual Studioインストール時にデフォで入ってた気がするけど…入ってなかったら↑のリンクから入れる。

1.Unity側環境構築

1.1 Unity.package, PluginのImport

まずはUnity側で空のプロジェクトを作成し、Unity側でMagicOnionを使えるように各種UnityPackage、Pluginをimportします。

・MagicOnion
https://github.com/cysharp/MagicOnion/releases
からMagicOnion.Unity.2.x.x.unitypackageをダウンロードし、import

・MessagePack CSharp
https://github.com/neuecc/MessagePack-CSharp/releases
からMessagePack.Unity.x.x.x.zipをダウンロードし、import

・grpc
https://packages.grpc.io/
の最新のBuildIDのリンク先へ遷移し、grpc_unity_package.1.18.0-dev.zip
をダウンロードし、中にあるPluginsフォルダをUnityのProjectWindowへそのままDrag&Drop

1.2 PlayerSettingsの編集

MagicOnionはTask-Likeを使ったTask型を使用しているためC#7.0以降である必要があります。
それに加えてMessagePack CSharpのためにunsafeを許可する必要があるので、PlayerSettingsから以下の画像のように編集します。
SnapCrab_NoName_2018-12-16_2-47-2_No-00.png

これでMagicOnionを使える環境は整いました。
コンパイルしてもエラーが出ることはないはずです。

2.サーバ側環境構築

次はUnityのメニューからVisualStudioを開いて、サーバのプロジェクトをの用意をします。
.NET Coreのコンソールアプリを作成し、NugetからMagicOnionの最新の安定板をインストールしましょう。
SnapCrab_NoName_2018-12-16_3-1-34_No-00.png

SnapCrab_NoName_2018-12-16_3-14-3_No-00.png

これでとりあえずサーバ側でMagicOnionを使う事ができるようになったので
Program.csにMagicOnionのサービスを起動するコードを書きます。

namespace MagicOnionServer
{
    class Program
    {
        static void Main(string[] args)
        {
            //コンソールにログを表示させる
            GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());

            var service = MagicOnionEngine.BuildServerServiceDefinition(isReturnExceptionStackTraceInErrorDetail: true);

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

            // MagicOnion起動
            server.Start();

            // コンソールアプリが落ちないようにReadLineで待つ
            Console.ReadLine();
        }
    }
}

これでMagicOnionを使用するための最低限の環境構築は終わりました。

3.サーバ側API定義&実装

ではサーバの実装になりますが、まずはServerからClientにPushするためのAPIを定義します。

3.1 Server -> Clientのインターフェースを定義

/// <summary>
/// Server -> ClientのAPI
/// </summary>
public interface IChatHubReceiver
{
    /// <summary>
    /// 誰かがチャットに参加したことをクライアントに伝える。
    /// </summary>
    /// <param name="name">参加した人の名前</param>
    void OnJoin(string name);
    /// <summary>
    /// 誰かがチャットから退室したことをクライアントに伝える。
    /// </summary>
    /// <param name="name">退室した人の名前</param>
    void OnLeave(string name);
    /// <summary>
    /// 誰かが発言した事をクライアントに伝える。
    /// </summary>
    /// <param name="name">発言した人の名前</param>
    /// <param name="message">メッセージ</param>
    void OnSendMessage(string name, string message);
}

3.2 Client -> Serverのインターフェースを定義

/// <summary>
/// CLient -> ServerのAPI
/// </summary>
public interface IChatHub : IStreamingHub<IChatHub, IChatHubReceiver>
{
    /// <summary>
    /// 参加することをサーバに伝える
    /// </summary>
    /// <param name="userName">参加者の名前</param>
    /// <returns></returns>
    Task JoinAsync(string userName);
    /// <summary>
    /// 退室することをサーバに伝える
    /// </summary>
    /// <returns></returns>
    Task LeaveAsync();
    /// <summary>
    /// メッセージをサーバに伝える
    /// </summary>
    /// <param name="message"></param>
    /// <returns></returns>
    Task SendMessageAsync(string message);
}

3.3 サーバ側APIの実装を書く

public class ChatHub : StreamingHubBase<IChatHub, IChatHubReceiver>, IChatHub
{
    IGroup room;
    string me;

    public async Task JoinAsync(string userName)
    {
        //ルームは全員固定
        const string roomName = "SampleRoom";
        //ルームに参加&ルームを保持
        this.room = await this.Group.AddAsync(roomName);
        //自分の名前も保持
        me = userName;
        //参加したことをルームに参加している全メンバーに通知
        this.Broadcast(room).OnJoin(userName);
    }

    public async Task LeaveAsync()
    {
        //ルーム内のメンバーから自分を削除
        await room.RemoveAsync(this.Context);
        //退室したことを全メンバーに通知
        this.Broadcast(room).OnLeave(me);
    }


    public async Task SendMessageAsync(string message)
    {
        //発言した内容を全メンバーに通知
        this.Broadcast(room).OnSendMessage(me, message);
    }

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

StreamingHubBaseを継承した各Hub(今回はChatHub)はConnection毎に作成され、各ユーザ毎の情報を保持する事ができます。
あとは、this.Group.AddAsync()で取得できるIGroupを元にRoomの管理を行い、他のクライアントへ通信をPushする事ができる仕組みです。
サーバ側のコードはこれだけです。

4.クライアント側UI&実装

まずはざっとチャットのUIを作って実装に入ります
SnapCrab_NoName_2018-12-16_4-6-18_No-00.png

クライアントコード側で気を付けることとしては
サーバ側で定義したIChatHubReceiver、IChatHubをクライアント側も知っている必要があります。
SharedProjectを使用して同じコードを読み込む事が推奨されますが、今回は雑に簡単にコピペで済ませています。

クライアント側のコードですが、1ファイルのみなので一旦ひととおり見てみましょう。

public class ChatComponent : MonoBehaviour, IChatHubReceiver
{
    private IChatHub _chatHub;
    private bool _isJoin;

    //受信メッセージ
    public Text ChatText;

    //入室・退室UI
    public Button JoinOrLeaveButton;
    public Text JoinOrLeaveButtonText;

    //テキスト送信UI
    public Button SendMessageButton;
    public InputField Input;

    // Start is called before the first frame update
    void Start()
    {
        this._isJoin = false;

        //Client側のHubの初期化
        var channel = new Channel("localhost:12345", ChannelCredentials.Insecure);
        this._chatHub = StreamingHubClient.Connect<IChatHub, IChatHubReceiver>(channel, this);

        //メッセージ送信ボタンはデフォルト非表示
        this.SendMessageButton.gameObject.SetActive(false);
    }

    // Update is called once per frame
    void Update()
    {

    }

    #region Client -> Server

    /// <summary>
    /// 参加してなかったら参加する。
    /// 参加してたら退室する。
    /// </summary>
    public async void JoinOrLeave()
    {
        if (this._isJoin)
        {
            await this._chatHub.LeaveAsync();
            this._isJoin = false;
            this.JoinOrLeaveButtonText.text = "入室する";
            //メッセージ送信ボタンを非表示に
            this.SendMessageButton.gameObject.SetActive(false);
        }
        else
        {
            await this._chatHub.JoinAsync(this.Input.text);
            this._isJoin = true;
            this.JoinOrLeaveButtonText.text = "退室する";
            //メッセージ送信ボタンを表示
            this.SendMessageButton.gameObject.SetActive(true);
        }
    }

    /// <summary>
    /// メッセージを送信
    /// </summary>
    public async void SendMessage()
    {
        //入室してなかったら何もしない
        if (!this._isJoin)
            return;

        await this._chatHub.SendMessageAsync(this.Input.text);
    }


    #endregion  

    #region Client <- Server

    public void OnJoin(string name)
    {
        this.ChatText.text += $"\n{name}さんが入室しました";
    }

    public void OnLeave(string name)
    {
        this.ChatText.text += $"\n{name}さんが退室しました";
    }

    public void OnSendMessage(string name, string message)
    {
        this.ChatText.text += $"\n{name}{message}";
    }
    #endregion
}

public interface IChatHubReceiver
{
    void OnJoin(string name);
    void OnLeave(string name);
    void OnSendMessage(string name, string message);
}

public interface IChatHub : IStreamingHub<IChatHub, IChatHubReceiver>
{
    Task JoinAsync(string userName);
    Task LeaveAsync();
    Task SendMessageAsync(string message);
}

ポイントとしては
①ChatComponentがIChatHubReceiverを実装しており、

public class ChatComponent : MonoBehaviour, IChatHubReceiver

②サーバのEndPointを持つChannelとIChatHubReceiverを実装したChatComponentを元に
StreamingHubClient(ChatHub)を生成することにより、

var channel = new Channel("localhost:12345", ChannelCredentials.Insecure);
this._chatHub= StreamingHubClient.Connect<IChatHub, IChatHubReceiver>(channel, this);

③サーバ側への通信を飛ばしたり、サーバ側からの通信を受け取ることができる。

await this._chatHub.JoinAsync(this.Input.text);
public void OnJoin(string name)
{
    this.ChatText.text += $"\n{name}さんが入室しました";
}

以上の事を気にしておくだけでサーバ側との通信を元にした処理を書くことができます。
あとは、このコンポーネントにTextの参照刺したり、ボタンにイベントくっ付けたりするだけですね。

5 動かしてみる。

あとは、VisualStudio上からサーバ側のプロジェクトを起動し、Unityで実行するだけです。
手っ取り早く確認したい人はこちらをどうぞ

6 終わり

いかがでしたでしょうか。
今回の記事では本当に「ちゃちゃっと動かす」事にフォーカスしましたが、便利なTipsやツールはまだあります。
それはまた今度別記事にするかほかの方が紹介してくれればよいなと。

7 余談

初代MagicOnionはneuecc氏が株式会社GraniのCTOを務めているときにリリースされましたが、まだ当時はUnityのバージョンが古くてTaskを使うことができず、Unity、Server間のインターフェースを生成するための環境構築が難しかったり、UniRxに実装が完全に依存していて敷居が高かったため、使用者はそこまで増える事はなかったように感じます。。。

が!今はUniTaskもありますし、C#7.3も使えるようになり、初代MagicOnionの頃よりも導入、実装の敷居は非常に低くなっているので、使用者の増加を願ってこのようなチュートリアルをなぞるような記事を書くことにしました。
使用者が増えれば私が本格的に使うまでにみんながバグを踏んでくれるneuecc氏が笑顔になってくれると思うので皆さま、ぜひお願いします。

8 余談の余談

近いうちに検証したいと思ってますが、MagicOnion2でも再接続周りの処理は苦労するんじゃないかなと予想してます。
初代MagicOnionではHttp/2(というかTCP)の都合上、IPが変わる(4G<->Wifi)とSessionが切れてしまうため、Sessionが切れた時に「どのように再接続し、再接続後どのような処理を行うか」を各Channelで実装する必要がありました。
その辺の仕様はver2でもそこまで変わってないんじゃないかなと思うんですが、ちょっとでもシンプルになってるといいな(願望)