1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一人ゲーム開発TipsAdvent Calendar 2024

Day 18

サーバーサイドなんもわからんアナタに向けたハンズオン Unity × gRPC × Go × Firebase × Docker 〜第七章、ポーリングで実現するチャット画面実装〜

Last updated at Posted at 2024-12-17

最初に

本記事の内容は、サーバーサイドにほとんど触れてこなかったUnityエンジニアに向けて、最近の主流構成を学びながら少しずつサーバーの知識を身につけられるハンズオン形式で進めます。この記事が、サーバーサイドに対する理解を深める一助になれば幸いです。

本記事の対象者は以下の通りです。

  • A Tour of Goを一通り終わらせたが次に何をするべきかわからない初心者サーバーエンジニア
  • gRPCについて学びたいが、Unityとの関連性も知りたい初学者
  • なんとなくGoやgRPCについては理解できるけど、具体的な仕組みについて知りたいUnityエンジニアの方

また、今回のハンズオン記事は全7章が存在します。

こちらから他の章も見ていただけると嬉しいです!
「第一章、環境構築」
「第二章、Cloud Runへのデプロイ」
「第三章、CI/CDパイプライン構築」
「第四章、Firebaseプロジェクトの作成とFirebaseSDK導入」
「第五章、FirebaseSDKを用いたアカウント作成とログイン実装」

今回の環境

  • OS : MacOS
  • IDE : GoLand
  • Gitクライアント : Fork

前提

以下の記事を終えているか、チャットアプリケーションを作るにあたって、必要な設定をGCP上で行う準備ができていることを前提としています。

// TODO URL

また、記事の内容を自由に学習できるよう、GitHub上に公開リポジトリを作成しました。各ステップに対応したバージョンをReleasesとして登録しているので、興味のあるステップから学習を始められます。

上記リポジトリ内容を使用して特定のステップから始める場合は、リポジトリをForkするか、手元にDownloadやCloneして進めるようお願いします。


1. ゲームにおける通信手法

ゲームプロジェクトでは、プロジェクトの規模や要件によって通信方法が異なりますが、以下の理由から gRPC + Protocol Buffers が採用されることが多いです。

  • リアルタイム通信可能
  • クロスプラットフォーム
  • サーバーからのプッシュ通知
  • データの一貫性

他の通信手法との比較

通信方法 長所 短所 使用例
gRPC + Protocol Buffers 高速、型安全、リアルタイムストリーミング、低帯域消費 設定にやや手間、HTTP/1.1を使う古い環境では利用が難しい リアルタイムマルチプレイヤーゲーム
REST + JSON シンプルで広く利用されている。REST API設計が理解しやすい JSONのサイズが大きく、解析が遅いため、レスポンス速度が落ちる カジュアルゲーム、シングルプレイヤー
WebSocket 双方向通信が可能、リアルタイム性が高い 複雑なプロトコル設計が必要、バイナリデータの扱いが大変 チャットアプリ、リアルタイム通知
Firebase Realtime Database 設定が簡単で、リアルタイムデータ同期が可能 複雑なクエリや大規模なデータ処理には不向き 小規模なリアルタイムマルチプレイヤー

プロジェクト要件によっては、どれか一つだけを採用して使用するのではなく、いくつかを組み合わせて実装していくこともあります。

今回は 「gRPC + Protocol Buffers」 で進めていくために、以下の技術を使用します。

  • Google.Protobuf:Protocol Buffers を処理するためのライブラリ
  • Grpc.Net.Client:.NET 用 gRPC クライアントライブラリ
  • YetAnotherHttpHandler:Unity用のHTTP/2対応ライブラリ
  • System.IO.Pipelines:高パフォーマンスなIOを簡単に実現できるライブラリ、YetAnotherHttpHandlerが使用

そのためには、NuGet for Unityを使用する必要があります。


2. NuGet for Unityとは?

NuGet for Unityは、Microsoftのパッケージ管理システム「NuGet」をUnityエディターで利用できるようにするUnity向けの拡張ツールです。

Unityプロジェクトに必要なライブラリやツールを簡単にインストール・管理することができ、特にC#で開発されたパッケージ(DLLやコード)を効率よく導入する際に便利です。

主な機能

  • NuGetパッケージの検索:Unityエディター内でNuGetリポジトリからパッケージを検索できる
  • パッケージのインストールと管理:必要なライブラリを簡単にインストール・アップデート・削除が可能となる
  • 依存関係の自動解決:インストールするパッケージが依存している他のパッケージも自動的にインストールされる
  • Unityプロジェクトへの自動配置:インストールされたパッケージはUnityプロジェクトのAssets/Packagesに配置され、すぐに使える状態になる

NuGet for Unityのインポート

NuGet for UnityのGitHubページにあるReleasesから、最新のバージョンのUnityパッケージ(.unitypackage)をダウンロードし、Unityプロジェクトにインポートします。

image.png

Importが完了したら、Unityメニューの「NuGet」→「Manage NuGet Packages」を選択して、Nuget For Unityのウィンドウを表示しましょう。今後はこのウィンドウ上でパッケージの検索とインストールができます。

image.png

gRPC関連パッケージをインストール

Nuget For Unityウィンドウ上で以下のパッケージをインストールしてください。

  • **Google.Protobuf:**Protocol Buffers を処理するためのライブラリ
  • Grpc.Net.Client:.NET 用 gRPC クライアントライブラリ
  • System.IO.Pipelines:高パフォーマンスな非同期IOを簡単に実現できるライブラリ、YetAnotherHttpHandler側で使用

image.png


3. YetAnotherHttpHandlerのインポート

次に、Unity内でHTTP/2対応が可能になるYetAnotherHttpHandlerの導入を行います。

公式ページを見れば導入方法が書いてあるのですが、英語なため、今回はこの記事でも手順を紹介します。

まずはUnity上でPackage Managerを開いてください。Package Managerウィンドウが開いたら、左上の+マークを押して、「Install package from git URL」をクリックします。

URLは以下のものを使用します。

https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler

image.png


4. 通信用C#コードの自動生成

タグv0.8, 0.9に対応しています。

では、Unityで使用する通信用のC#コードをprotoファイルから自動生成してみましょう。

具体的な流れは以下のようになります。

  1. proto ファイルにデータ構造を定義する
  2. protoc コマンドを使って、proto ファイルからC#のソースコードを自動生成する
  3. 生成されたC#クラスをUnityプロジェクトに追加する
  4. 生成されたクラスを使ってプログラミングを行う

1に関しては、実はほぼ完了しています。Goのコードを自動生成する際に使用したprotoに少しだけ手を入れて使っていきましょう。

chat.protoを開いて、以下のように編集します。

syntax = "proto3";

package chat;

option go_package = "gen/api/chat";

// C#用の名前空間オプションを追加
option csharp_namespace = "grpc_chat_app.Scripts.Generated";

service ChatService {
  rpc GetChatMessages (ChatRequest) returns (ChatResponse);
}

message ChatRequest {
  string user_id = 1;
}

// サーバーからのレスポンスメッセージ
message ChatResponse {
  repeated string messages = 1;
}

名前空間オプションを追加すると、生成されたC#コード(chat.cschatGrpc.csなど)の先頭に以下のような名前空間が含まれます。

namespace GrpcChatApp.Scripts.Generated
{
    // 生成されたクラスやインターフェース
}

コードの自動生成を行う前に、一応grpc_csharp_pluginがインストールされているか確認するために、以下のコマンドを打っておいてください。

which grpc_csharp_plugin

問題なさそうであれば、次にルートフォルダ > genの下にclientというフォルダを作っておきます。

image.png


最後に以下のコマンドを使用してください。

protoc -I=./proto \
  --csharp_out=./gen/client \
  --grpc_out=./gen/client \
  --plugin=protoc-gen-grpc=$(which grpc_csharp_plugin) \
  ./proto/chat.proto

無事に2つのファイル

  • Chat.cs
  • ChatGrpc.cs

が生成されていればOKです!

image.png

生成した2つのファイルはUnityプロジェクトに持ってくる必要があります。

Unityプロジェクト内でAssets/grpc-chat-app/Scripts/下にGeneratedというフォルダを作成して、そこに上記2つのC#スクリプトファイルを追加してください。

追加後にエラーが出ていなければ、下準備は完了となります。


5. Unityでサーバーとの通信ロジックを作成

いよいよ、ハンズオンの最後の作業となる、Unity内でgRPCクライアントを使用するスクリプトを作成します。

まずはChatClient.csというスクリプトを新規で用意してください。

このクラスにはgRPCサーバーと実際に通信を行うロジックを追加します。

using System;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Net.Client;
using Cysharp.Net.Http;
using grpc_chat_app.Scripts.Generated;
using UnityEngine;

public class ChatClient
{
    // gRPCサーバーのアドレス (Cloud RunでデプロイしたサーバーのURL)
    private const string ServerAddress = "{URL}";
    
    // gRPCのクライアント (サーバーとやり取りするために使用)
    private readonly ChatService.ChatServiceClient client;

    public ChatClient()
    {
        // YetAnotherHttpHandlerを使用して、HTTP/2対応のカスタムハンドラを作成
        var httpHandler = new YetAnotherHttpHandler();

        // gRPC Channel(後述)を作成 (サーバーと通信するための基盤)
        var channel = GrpcChannel.ForAddress(ServerAddress, new GrpcChannelOptions
        {
		    // カスタムHTTPハンドラ(後述)を設定
            HttpHandler = httpHandler
        });

        // ChatServiceのクライアントを初期化 (これでサーバーのメソッドを呼び出せる)
        client = new ChatService.ChatServiceClient(channel);
    }

    /// <summary>
    /// 新しいメッセージをサーバーから非同期で取得
    /// </summary>
    /// <param name="userId">Firestoreで登録されたユーザーID</param>
    /// <returns>サーバーから取得したメッセージの配列</returns>
    public async Task<string[]> FetchNewMessagesAsync(string userId)
    {
        try
        {
            // サーバーに送信するリクエストを作成 (ユーザーIDを含む)
            var request = new ChatRequest { UserId = userId };

            // サーバーの GetChatMessages メソッドを呼び出し
            var response = await client.GetChatMessagesAsync(request);

            // レスポンス内のメッセージを配列に変換して返す
            return response.Messages.ToArray();
        }
        catch (Exception ex)
        {
            // エラーが発生した場合はUnityのデバッグログにエラーメッセージを出力
            Debug.LogError($"Failed to fetch messages: {ex.Message}");
            
            // 空の配列を返してエラー時でもアプリが落ちないようにする
            return Array.Empty<string>();
        }
    }
}

コードの中にgRPC Channelと、カスタムHTTPハンドラ、という言葉が出てきました。

それぞれ一体どういうものなのか見ておきましょう。


gRPC Channelとは?

gRPC通信を行う際の基盤であり、クライアントとサーバー間の通信を管理するためのオブジェクトです。以下のような役割を持ちます。

  • クライアントとサーバーの接続を確立
    gRPCでは、クライアントがリクエストを送信してサーバーからレスポンスを受信する際に、ネットワーク上の通信が必要です。この通信を管理するためにgRPC Channelが使われます。
  • 通信の設定を管理
    Channelを作成する際に、通信方法(例えば、セキュリティやHTTP/2プロトコルの使用)を設定できます。
  • 複数のリクエストを効率的に処理
    1つのChannelを使い回して、複数種類のリクエストをサーバーへ送信することが可能です。
  • エラーや接続の再試行を管理
    ネットワークの問題やサーバーの一時的な応答停止などが発生した場合、gRPC Channelは再接続を試みるなどのエラーハンドリングをサポートします。

gRPC Channelは通信の「道」や「パイプライン」のようなものです。

イメージとしては以下のような感じです。

[クライアント] --(リクエスト)--> [Channel] --(通信処理)--> [サーバー]
[クライアント] <--(レスポンス)-- [Channel] <--(通信処理)-- [サーバー]

カスタムHTTPハンドラとは?

カスタムHTTPハンドラとは、gRPC Channelを作成する際に、HTTP通信の挙動をカスタマイズするためのものです。

gRPCは、HTTP/2を活用して高速で効率的な通信を行うためのフレームワークです。通常はgRPCライブラリが提供するデフォルトのHTTPハンドラを使用しますが、今回のようにプロジェクト要件に合わせた形でgRPC Channelの細かな制御が行えるようになります。

今回のハンズオンでは、YetAnotherHttpHandlerを使用するため、カスタムHTTPハンドラを設定しています。


ポーリング実装

今回は、「新しいチャットメッセージがサーバーに追加されたかどうか」を確認するためにポーリングを使用しています。流れとしては以下のようになります。

  1. クライアント(Unityアプリ)はgRPCを使ってサーバーに問い合わせる
  2. サーバーは新しいチャットメッセージがあるかどうかをレスポンスとして返す
  3. 新しいメッセージがあれば、クライアント側でそれを表示

ポーリングとは?

ポーリングとは、クライアント(Unityアプリ)が一定の間隔でサーバーに問い合わせを行い、最新の情報を取得する方法です。

サーバーからの通知を待つのではなく、クライアントが能動的に「新しいデータはありますか?」と定期的に確認する仕組みのことを指します。


ポーリングのメリット

  • シンプルに実装できる
    サーバーが複雑な機能を持たなくてもクライアント側で実現可能。

  • リアルタイム性をある程度実現
    一定間隔で確認することで、ほぼリアルタイムに近い動作が可能。

  • サーバー負荷のコントロールが容易

ポーリングのデメリット

  • サーバー負荷が増える
    • クライアントが定期的に問い合わせを送るため、サーバーのリソースを消費します。
    • 短い間隔(例:1秒ごと)でポーリングすればリアルタイム性が上がりますが、サーバーへの負荷も増加します。
    • 今回は5秒ごとの間隔で、負荷とリアルタイム性のバランスをとっています。
  • 即時性が制限される
    • 例えば、5秒間隔の場合、最大5秒の遅延が発生する可能性があります。
  • 無駄なリクエストが発生
    • 新しいメッセージがない場合でも問い合わせが行われます。

ポーリングのデメリットを解決するには、サーバーがクライアントに直接通知を送る「リアルタイム通信」やサーバー側でイベントが発生した際にクライアントへ通知を送るPub/Subを導入する方法があります。

ただし、これらはポーリングに比べて実装が複雑になるため、今回は初心者向けにシンプルなポーリングを採用しています。


Unity側の実装

ポーリングについて、ざっと確認したところで、実際に処理を用意していきましょう。

ChatView.csを以下のように変更してください。

using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Firebase.Auth;
using Firebase.Extensions;
using Firebase.Firestore;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Random = System.Random;

namespace grpc_chat_app.Scripts
{
    public class ChatView : MonoBehaviour
    {
        ...
        
        private ChatClient _chatClient = default!;
        
        private bool _isCheckingMessages;
        
        // メインスレッドの SynchronizationContext を保持
        private SynchronizationContext _mainThreadContext;
        
        private int _fetchMessagesIntervalMs = 5000;
        
        private void Start()
        {
            _auth = FirebaseAuth.DefaultInstance;
            _firestore = FirebaseFirestore.DefaultInstance;

            // gRPCクライアントを初期化
            _chatClient = new ChatClient();
            
            // 現在の SynchronizationContext(メインスレッド)を取得
            _mainThreadContext = SynchronizationContext.Current;

            suffixDropdown.onValueChanged.AddListener(OnSuffixDropdownChanged);

            sendButton.onClick.AddListener(OnSendButtonClicked);
        }

        private void OnDestroy()
        {
		    // ループを終了
            _isCheckingMessages = false;
        }

        ...

        public void OnChatStart()
        {
            _firestore ??= FirebaseFirestore.DefaultInstance;
            _auth ??= FirebaseAuth.DefaultInstance;

            Debug.Log("Chat started.");
            var handleName = RandomStringGenerator.GenerateRandomString();
            var suffixType = suffixDropdown.options[suffixDropdown.value].text;

            // Firestoreにユーザー情報を保存
            var userDocRef = _firestore.Collection("users").Document(_auth.CurrentUser.UserId);
            var userData = new { handleName, suffixType };

            userDocRef.SetAsync(userData).ContinueWithOnMainThread(task =>
            {
                if (task.IsCompleted)
                    Debug.Log("User data saved successfully.");
                else
                    Debug.LogError("Failed to save user data: " + task.Exception);
            });

            Debug.Log($"Handle name: {handleName}");
            
            // 5秒ごとに新しいメッセージを確認するタスクを開始
            StartCheckingMessages();
        }
        
        // 新着メッセージが届いているか確認する非同期処理を5秒ごとに行う
        private void StartCheckingMessages()
        {
            _isCheckingMessages = true;
            Task.Run(async () =>
            {
                while (_isCheckingMessages)
                {
                    await CheckForNewMessages();
                    await Task.Delay(_fetchMessagesIntervalMs);
                }
            });
        }

		// 実際にサーバーに問い合わせるメソッド
        private async Task CheckForNewMessages()
        {
            if (_auth.CurrentUser == null)
            {
                Debug.LogError("No user is logged in.");
                return;
            }

            // 新しいメッセージが来ていないか問い合わせる
            var messages = await _chatClient.FetchNewMessagesAsync(_auth.CurrentUser.UserId);
            
            // メッセージがあれば、メインスレッド上でテキストチャットオブジェクトを生成
            _mainThreadContext.Post(_ =>
            {
                foreach (var message in messages)
                {
                    CreateChatMessagePrefab(message);
                }
            }, null);
        }
        
        ...
    }
    
    ...
}

ポーリングを実装しているのは以下のコード部分となります。

private void StartCheckingMessages()
{
    isCheckingMessages = true;
    Task.Run(async () =>
    {
        while (isCheckingMessages)
        {
            await CheckForNewMessages();
            await Task.Delay(fetchMessagesIntervalMs);
        }
    });
}
  • Task.Run
    • ポーリング処理を非同期で実行します。
    • メインスレッド(Unityの描画処理を行うスレッド)がブロックされないように、別スレッドで動作します。
  • CheckForNewMessages
    • gRPCクライアントを使用してサーバーにリクエストを送信し、新しいメッセージを取得します。
  • Task.Delay(5000)
    • 5秒間待機してから再びサーバーに問い合わせます。

ここまで実装できたら、実際に実行して見てください。

Firestore上で、現在ログインしているユーザー以外のIdを使い、新規メッセージを作成すると、Unity側の画面でも反映されると思います。

image.png

image.png

これで最低限の機能を備えたチャットアプリの完成です!

お疲れ様でした!

ハンズオン後のGCPプロジェクトは、意図しない課金を防ぐためにも「シャットダウン」しておきましょう。シャットダウンを行うには、GCP画面の「IAMと管理」から「設定」メニューを選び、「シャットダウン」をクリックしてください。

これで課金処理、トラフィック処理などが停止しますので、安心です。

まとめ

第七章では、通信用C#スクリプトの自動生成を行い、それを使ってクライアント側でのサーバーとの通信ロジックを用意しました。

長きに渡ったハンズオン記事も今回で最後となります。

今回のハンズオンでは以下のことを学びました。

  • Goプロジェクト用のリポジトリ作成方法
  • Goプロジェクトのセットアップ方法
  • Dockerの使い方
  • Protocol Buffersの使い方
  • gRPCについて
  • GCPのセットアップ
  • GCPを使った自動ビルド・自動デプロイ方法
  • Cloud Runの使い方
  • Firebaseプロジェクトのセットアップ
  • Firebaseを使ったユーザーログイン方法
  • Firestoreを使ったデータ保存方法
  • FirestoreとgRPCサーバーの連携方法
  • GoLandを使った開発方法
  • YetAnotherHttpHandlerを使った通信方法
  • ポーリングを用いたデータ取得方法

すごくモリモリですね。

上記の知識があるだけでも、サーバーエンジニアさんが一体どのような仕事をしているのか、想像しやすくなると思います。

長きにわたるハンズオンでしたが、私は実践的な知識がついてとても良かったと感じています。

もしも記事の中で進められない箇所があったら、Xなどでご連絡ください。

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?