最初に
本記事の内容は、サーバーサイドにほとんど触れてこなかった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プロジェクトにインポートします。
Importが完了したら、Unityメニューの「NuGet」→「Manage NuGet Packages」を選択して、Nuget For Unityのウィンドウを表示しましょう。今後はこのウィンドウ上でパッケージの検索とインストールができます。
gRPC関連パッケージをインストール
Nuget For Unityウィンドウ上で以下のパッケージをインストールしてください。
- **Google.Protobuf:**Protocol Buffers を処理するためのライブラリ
- Grpc.Net.Client:.NET 用 gRPC クライアントライブラリ
- System.IO.Pipelines:高パフォーマンスな非同期IOを簡単に実現できるライブラリ、YetAnotherHttpHandler側で使用
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
4. 通信用C#コードの自動生成
タグv0.8, 0.9に対応しています。
では、Unityで使用する通信用のC#コードをprotoファイルから自動生成してみましょう。
具体的な流れは以下のようになります。
- proto ファイルにデータ構造を定義する
- protoc コマンドを使って、proto ファイルからC#のソースコードを自動生成する
- 生成されたC#クラスをUnityプロジェクトに追加する
- 生成されたクラスを使ってプログラミングを行う
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.cs
やchatGrpc.cs
など)の先頭に以下のような名前空間が含まれます。
namespace GrpcChatApp.Scripts.Generated
{
// 生成されたクラスやインターフェース
}
コードの自動生成を行う前に、一応grpc_csharp_plugin
がインストールされているか確認するために、以下のコマンドを打っておいてください。
which grpc_csharp_plugin
問題なさそうであれば、次にルートフォルダ > gen
の下にclient
というフォルダを作っておきます。
最後に以下のコマンドを使用してください。
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です!
生成した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ハンドラを設定しています。
ポーリング実装
今回は、「新しいチャットメッセージがサーバーに追加されたかどうか」を確認するためにポーリングを使用しています。流れとしては以下のようになります。
- クライアント(Unityアプリ)はgRPCを使ってサーバーに問い合わせる
- サーバーは新しいチャットメッセージがあるかどうかをレスポンスとして返す
- 新しいメッセージがあれば、クライアント側でそれを表示
ポーリングとは?
ポーリングとは、クライアント(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側の画面でも反映されると思います。
これで最低限の機能を備えたチャットアプリの完成です!
お疲れ様でした!
ハンズオン後のGCPプロジェクトは、意図しない課金を防ぐためにも「シャットダウン」しておきましょう。シャットダウンを行うには、GCP画面の「IAMと管理」から「設定」メニューを選び、「シャットダウン」をクリックしてください。
これで課金処理、トラフィック処理などが停止しますので、安心です。
まとめ
第七章では、通信用C#スクリプトの自動生成を行い、それを使ってクライアント側でのサーバーとの通信ロジックを用意しました。
長きに渡ったハンズオン記事も今回で最後となります。
今回のハンズオンでは以下のことを学びました。
- Goプロジェクト用のリポジトリ作成方法
- Goプロジェクトのセットアップ方法
- Dockerの使い方
- Protocol Buffersの使い方
- gRPCについて
- GCPのセットアップ
- GCPを使った自動ビルド・自動デプロイ方法
- Cloud Runの使い方
- Firebaseプロジェクトのセットアップ
- Firebaseを使ったユーザーログイン方法
- Firestoreを使ったデータ保存方法
- FirestoreとgRPCサーバーの連携方法
- GoLandを使った開発方法
- YetAnotherHttpHandlerを使った通信方法
- ポーリングを用いたデータ取得方法
すごくモリモリですね。
上記の知識があるだけでも、サーバーエンジニアさんが一体どのような仕事をしているのか、想像しやすくなると思います。
長きにわたるハンズオンでしたが、私は実践的な知識がついてとても良かったと感じています。
もしも記事の中で進められない箇所があったら、Xなどでご連絡ください。
読んでいただきありがとうございます。