🐻

UnityとMagicOnionで、リアルタイム通信

16 min read

はじめに

今回は、「StreamingHub」の機能を使って、「リアルタイム通信」を試して行きます!!
MagicOnionの使用を考えている方は、この機能がメインになるのではないでしょうか?

MagicOnionのサンプルにも、チャットアプリがありますが、
ここでは、簡略化したチャットアプリを、順を追って作成していきます!!

環境

前提条件

チャットアプリの概要

下記の3機能のみ!!!!!
※後から入室したユーザーには、その前に送信されているメッセージは表示されません

  • 入室
  • 退出
  • メッセージ送信

手順

  • TextMeshProで日本語を使用可能にする
  • チャット画面の作成
  • クライアントとサーバーのインターフェースを作成
  • クライアントの実装
  • サーバーの実装
  • 動作確認

■TextMeshProで日本語を使用可能にする

使用フォントをダウンロード

フォントデータの「SourceHanCodeJP.ttc」をダウロードして、
任意の場所に「Fronts」フォルダを作成して、ファイルを配置

Source Han Code JP 2.012R

「Text Mesh Pro」にて、フォントアセットを作成

  1. Window -> TextMeshPro -> Font Asset Creator」を押下


  1. 下記を設定して、「Generate Font Atlas」を押下
項目 設定値
Source Font File 上記でダウンロードした「SourceHanCodeJP.ttc」を指定
Sampling Point Size Custom Size 48
Padding 5
Packing Method Fast
Atlas Resolution 8192 × 8192
Character Set Custom Characters
Select Font Asset None
Custom Character List ほとんどの日本語を網羅した文字セットを、
公開してくれてい方がいるので、使用させていただきました!!

UnityのText Mesh Proでほぼ全ての日本語を表示させる
Render Mode SDFAA


  1. 処理が完了したら、「Save as...」を押下
  2. SourceHanCodeJP.ttc」と同じディレクトリを指定して、「保存」を押下
  3. SourceHanCodeJP SDF.asset」が、作成されている事を確認

■チャット画面の作成

画面サイズ等は、動作環境にしたがって作成・設定してください

テキストはすべて、「TextMeshPro」を使用して、
Font Asset」に、「SourceHanCodeJP SDF.asset」を設定しています

チャットアプリ用の新規シーンを作成

入室画面を作成

項目 設定値
NameInput UI -> Input Field - TextMeshPro
JoinButton UI -> Button - TextMeshPro

チャット画面を作成

テキストエリアの作成方法は、下記の記事を参照
縦スクロールするテキストエリアを作成する方法

項目 設定値
CommentScrollView UI -> Scroll View
ChatComment UI -> Input Field - TextMeshPro
MessageInput UI -> Input Field - TextMeshPro
SendButton UI -> Button - TextMeshPro

■クライアントとサーバーのインターフェースを作成

クライアントインターフェース(サーバー ⇒ クライアント)

下記のサーバーからクライアントへの通知を受け取る

  • 入室通知
  • 退出通知
  • メッセージ通知

Assets/KumattaApp/Scripts/MagicOnion/Server/Hubs/IChatAppHubReceiver.cs
namespace KumattaAppServer.Hubs
{
    public interface IChatAppHubReceiver
    {
        /// <summary>
        /// 入室通知
        /// </summary>
        /// <param name="userName">ユーザー名</param>
        void OnJoin(string userName);

        /// <summary>
        /// 退出通知
        /// </summary>
        /// <param name="userName">ユーザー名</param>
        void OnLeave(string userName);

        /// <summary>
        /// メッセージ通知
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="message"></param>
        void OnSendMessage(string userName, string message);
    }
}

サーバーインターフェース(クライアント ⇒ サーバー)

下記のタイミングで、クライアントからサーバーへリクエストを行い、他のユーザーに通知

  • 入室
  • 退出
  • メッセージ送信

Assets/KumattaApp/Scripts/MagicOnion/Server/Hubs/IChatAppHub.cs
using MagicOnion;
using System.Threading.Tasks;

namespace KumattaAppServer.Hubs
{
    public interface IChatAppHub : IStreamingHub<IChatAppHub, IChatAppHubReceiver>
    {
        /// <summary>
        /// 入室通知
        /// </summary>
        /// <param name="roomName">ルーム名</param>
        /// <param name="userName">ユーザー名</param>
        /// <returns></returns>
        Task JoinAsync(string roomName, string userName);

        /// <summary>
        /// 退室通知
        /// </summary>
        /// <returns></returns>
        Task LeaveAsync();

        /// <summary>
        /// メッセージ通知
        /// </summary>
        /// <param name="userName">ユーザー名</param>
        /// <param name="message">メッセージ</param>
        /// <returns></returns>
        Task SendMessageAsync(string userName, string message);
    }
}

■クライアントの実装

gRPCの初期化処理を追加

下記のオプションを設定

項目 設定値 備考
grpc.keepalive_time_ms 5000 キープアライブを5秒単位
grpc.keepalive_timeout_ms 5000 キープアライブのタイムアウトを5秒

オプションについての詳細は、下記を参照してください
gRPCの設定可能なオプション

Assets/KumattaApp/Scripts/MagicOnion/Client/Generated/MagicOnionLoad.cs
using Grpc.Core;
using MagicOnion.Unity;
using MessagePack;
using MessagePack.Resolvers;
using UnityEngine;

public class MagicOnionLoad
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void RegisterResolvers()
    {
        StaticCompositeResolver.Instance.Register(
            MagicOnion.Resolvers.MagicOnionResolver.Instance,
            MessagePack.Resolvers.GeneratedResolver.Instance,
            StandardResolver.Instance
        );

        MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions
            .WithResolver(StaticCompositeResolver.Instance);
    }

    // gRPCの初期化処理を追加
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void OnRuntimeInitialize()
    {
        // gRPCの初期化
        GrpcChannelProviderHost.Initialize(new DefaultGrpcChannelProvider(new[]
        {
            // キープアライブを5秒単位に送信(デフォルト2時間)
            new ChannelOption("grpc.keepalive_time_ms", 5000),

            // キープアライブのpingタイムアウトを5秒(デフォルト20秒)
            new ChannelOption("grpc.keepalive_timeout_ms", 5000),
        }));
    }
}


クライアント処理作成

ボタンイベント、サーバーへのリクエスト処理を作成
サーバーからの通知を受け取る「IChatAppHubReceiver」のインターフェスを実装

接続先のIPアドレスは、環境に合わせて設定してください!!

ルーム名に固定値を設定しています
この値はサーバー側で、ユーザーをグルーピングする為に使用しています
実運用ではユニークID等を発行して、グループ識別をするのが良いと思います

/// <summary>
/// ルーム名
/// </summary>
private string roomName = "ChatAPP";
Assets/KumattaApp/Scripts/ChatApp/ChatApp.cs
using Grpc.Core;
using MagicOnion;
using MagicOnion.Client;
using System.Threading;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace KumattaAppServer.Hubs
{
    public class ChatApp : MonoBehaviour, IChatAppHubReceiver
    {
        #region 入室ページ
        /// <summary>
        /// 入室ページ
        /// </summary>
        [SerializeField]
        private GameObject joinPage;

        /// <summary>
        /// ユーザー名入力
        /// </summary>
        private TMP_InputField nameInput;
        #endregion

        #region チャットページ
        /// <summary>
        /// チャットページ
        /// </summary>
        [SerializeField]
        private GameObject chatPage;

        /// <summary>
        /// チャット表示
        /// </summary>
        private TextMeshProUGUI chatComment;

        /// <summary>
        /// メッセージ入力
        /// </summary>
        private TMP_InputField messageInput;
        #endregion

        #region MagicOnion用
        /// <summary>
        /// 通信キャンセル用トークン
        /// </summary>
        private CancellationTokenSource shutdownCancellation = new CancellationTokenSource();

        /// <summary>
        /// 接続チャンネル
        /// </summary>
        private ChannelBase channel;

        /// <summary>
        /// サーバー呼出し用
        /// </summary>
        private IChatAppHub streamingClient;
        #endregion

        /// <summary>
        /// ルーム名
        /// </summary>
        private string roomName = "ChatAPP";

        /// <summary>
        /// ユーザー名
        /// </summary>
        private string userName = "名無し";

        private void Start()
        {
            // 画面を非表示
            joinPage.SetActive(false);
            chatPage.SetActive(false);


            // 入室ページ設定
            nameInput = joinPage.GetComponentInChildren<TMP_InputField>(true);

            var joinButton = joinPage.GetComponentInChildren<Button>(true);
            joinButton.onClick.AddListener(OnClick_JoinButton);


            // チャットルーム設定
            chatComment = chatPage.GetComponentInChildren<TextMeshProUGUI>(true);
            chatComment.text = "";
            messageInput = chatPage.GetComponentInChildren<TMP_InputField>(true);

            var sendButton = chatPage.GetComponentInChildren<Button>(true);
            sendButton.onClick.AddListener(OnClick_SendButton);

            // 入室画面を表示
            joinPage.SetActive(true);
        }

        private async void OnDestroy()
        {
            // 通信キャンセル
            shutdownCancellation.Cancel();

            // 切断処理
            if (streamingClient != null) await streamingClient.DisposeAsync();
            if (channel != null) await channel.ShutdownAsync();
        }

        #region ボタン処理
        /// <summary>
        /// 入室
        /// </summary>
        private async void OnClick_JoinButton()
        {
            if (!string.IsNullOrEmpty(nameInput.text))
            {
                userName = nameInput.text;
            }

            // サーバーへ接続
            channel = GrpcChannelx.ForAddress("http://111.111.111.111:5001");
            streamingClient = await StreamingHubClient.ConnectAsync<IChatAppHub, IChatAppHubReceiver>(channel, this, cancellationToken: shutdownCancellation.Token);

            await streamingClient.JoinAsync(roomName, userName);
            joinPage.SetActive(false);
            chatPage.SetActive(true);
        }

        /// <summary>
        /// メッセージ送信
        /// </summary>
        private async void OnClick_SendButton()
        {
            await streamingClient.SendMessageAsync(userName, messageInput.text);
            messageInput.text = "";
        }
        #endregion

        #region MagicOnion サーバー⇒クライアントの受信

        /// <summary>
        /// 入室通知
        /// </summary>
        /// <param name="userName">ユーザー名</param>
        public void OnJoin(string userName)
        {
            chatComment.text = $"{chatComment.text}{userName}さんが入室しました。\n";
        }

        /// <summary>
        /// 退出通知
        /// </summary>
        /// <param name="userName">ユーザー名</param>
        public void OnLeave(string userName)
        {
            chatComment.text = $"{chatComment.text}{userName}さんが退出しました。\n";
        }

        /// <summary>
        /// メッセージ通知
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="message"></param>
        public void OnSendMessage(string userName, string message)
        {
            chatComment.text = $"{chatComment.text}{userName}{message}\n";
        }
        #endregion
    }
}

StreamingHubClient.ConnectAsync」の第二引数は、
IChatAppHubReceiver」を、実装しているオブジェクトを指定します!

今回は、「MonoBehaviour」と同じ場所に、実装している為、「this」を設定しています

// サーバーへ接続
streamingClient = await StreamingHubClient.ConnectAsync<IChatAppHub, IChatAppHubReceiver>(channel, this, cancellationToken: shutdownCancellation.Token);

双方向リアルタイム通信の場合は、UIと分けて実装して、
UIへの反映は、キューにしたりする感じで、実装するのが良いのかなと思います

■サーバーの実装

クライアントのリクエストを受け取る「IChatAppHub」のインターフェスを実装

KumattaApp/KumattaApp-Server/Hubs/ChatAppHub.cs
using MagicOnion.Server.Hubs;
using System.Threading.Tasks;

namespace KumattaAppServer.Hubs
{
    public class ChatAppHub : StreamingHubBase<IChatAppHub, IChatAppHubReceiver>, IChatAppHub
    {
        /// <summary>
        /// ルーム
        /// </summary>
        private IGroup room;

        /// <summary>
        /// ユーザー名
        /// </summary>
        private string userName;

        /// <summary>
        /// 入室通知
        /// </summary>
        /// <param name="roomName">ルーム名</param>
        /// <param name="userName">ユーザー名</param>
        /// <returns></returns>
        public async Task JoinAsync(string roomName, string userName)
        {
            this.userName = userName;
            room = await Group.AddAsync(roomName);
            Broadcast(room).OnJoin(userName);
        }

        /// <summary>
        /// 退室通知
        /// </summary>
        /// <returns></returns>
        public async Task LeaveAsync()
        {
            await room.RemoveAsync(this.Context);
            Broadcast(room).OnLeave(userName);
        }

        /// <summary>
        /// メッセージ通知
        /// </summary>
        /// <param name="userName">ユーザー名</param>
        /// <param name="message">メッセージ</param>
        /// <returns></returns>
        public async Task SendMessageAsync(string userName, string message)
        {
            Broadcast(room).OnSendMessage(userName, message);
            await Task.CompletedTask;
        }

        /// <summary>
        /// 切断通知
        /// </summary>
        /// <returns></returns>
        protected override ValueTask OnDisconnected()
        {
            BroadcastExceptSelf(room).OnLeave(userName);
            return CompletedTask;
        }
    }
}

動作確認

  1. 確認端末にアプリをインストール
  2. サーバー起動
  3. 各端末を起動して入室
  4. 各端末からメッセージ送信
  5. いずれかの端末のアプリを落とす

「入室」、「メッセージ」、「退出」の同期が取れていれば成功!!

あとがき

構築した環境のGitHub

ここまで読んでいただきありがとうございます!!!

次回は、フィルターにするか、それ以外にするか悩み中です!!!