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

# 最初に

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

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

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

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

こちらから他の章も見ていただけると嬉しいです！
[「第一章、環境構築」](https://qiita.com/4_mio_11/items/08e972ae361c55b317f0)
[「第二章、Cloud Runへのデプロイ」](https://qiita.com/4_mio_11/items/2027978feb5f131fdd48)
[「第三章、CI/CDパイプライン構築」](https://qiita.com/4_mio_11/items/1148aa1603f4acb32088)
[「第四章、Firebaseプロジェクトの作成とFirebaseSDK導入」](https://qiita.com/4_mio_11/items/6a9b61fe2f1db2b5c38a)
[「第五章、FirebaseSDKを用いたアカウント作成とログイン実装」](https://qiita.com/4_mio_11/items/aabefeac56e700afc6e4)

### **今回の環境**

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

## 前提

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

// TODO URL

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

https://github.com/rossam/grpc-chat-app-sample/releases

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

<br>

# 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**を使用する必要があります。

<br>

# 2. **NuGet for Unityとは？**

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

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

### **主な機能**

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

<br>

### **NuGet for Unityのインポート**

[NuGet for UnityのGitHubページ](https://github.com/GlitchEnzo/NuGetForUnity)にあるReleasesから、最新のバージョンのUnityパッケージ（`.unitypackage`）をダウンロードし、Unityプロジェクトにインポートします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/2cba9081-99d6-3e9b-0314-b3269e94f37c.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/b09765cb-62ec-7c7f-371f-0b4668f51337.png)



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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/cd0a5552-892f-a257-223a-637a096f7c4e.png)

<br>

# 3. YetAnotherHttpHandlerのインポート

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

[公式ページ](https://github.com/Cysharp/YetAnotherHttpHandler)を見れば導入方法が書いてあるのですが、英語なため、今回はこの記事でも手順を紹介します。

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/6102dc88-5cd1-fdc7-cb68-f6df4ce7738a.png)

<br>

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

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

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

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

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

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

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

```go
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.cs`や`chatGrpc.cs`など）の先頭に以下のような名前空間が含まれます。

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

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

```bash
which grpc_csharp_plugin
```

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/e6513160-af52-0219-6188-135d4c9cc7e7.png)

<br>

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

```bash
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/3c8a73cb-28c6-bdef-52cc-806b3bb418df.png)

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

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

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

<br>

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

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

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

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

```csharp
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ハンドラ、という言葉が出てきました。

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

<br>

### gRPC Channelとは？

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

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

<br>

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

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

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

<br>

### カスタムHTTPハンドラとは？

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

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

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

<br>

## ポーリング実装

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

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

### **ポーリングとは？**

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

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

<br>

### ポーリングのメリット

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


- **リアルタイム性をある程度実現**：
一定間隔で確認することで、ほぼリアルタイムに近い動作が可能。
- **サーバー負荷のコントロールが容易**


### ポーリングのデメリット

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

<br>

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

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

<br>

### Unity側の実装

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

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

```csharp
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);
        }
        
        ...
    }
    
    ...
}
```

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

```csharp
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秒間待機してから再びサーバーに問い合わせます。

<br>


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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/9bf5a6e1-dc82-5348-700b-39330422baf7.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/2281d38a-b582-37e6-6021-eac5ea1f863f.png)


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

お疲れ様でした！

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

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

# まとめ

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

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

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

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

すごくモリモリですね。

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

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

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

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