最初に
本記事の内容は、サーバーサイドにほとんど触れてこなかった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. Firestoreへユーザー情報を送信する
Firebase Firestoreは、アプリケーションのユーザー情報やメッセージなどのデータを簡単に保存・管理できるクラウドデータベースです。
以降では、Firestoreの初期設定や、SDKの導入は既に済んでいる状態と仮定して進めていきます。もしまだお済みで無い方は以下の記事から進めてみてください。
// TODO : URL
今回は、ユーザーがログインまたは新規登録した後に、ユーザー情報をFirestoreに保存する、といった機能の実装を行なっていきます。
このユーザー情報は、FirebaseのAPIを使用すれば簡単に保存・取得して利用することができます。もちろん、保存した情報は即座にFirestoreへ反映されるため、Firebaseのコンソール画面からアクセスして確認することもできます。
Firestoreにユーザー情報を保存する理由
-
再利用性:
Firestoreにユーザー情報を保存しておくことで、ユーザーがアプリに再ログインしたときでも同じデータ(ハンドルネームなど)を使用できます。 -
中央集中的な管理:
全てのユーザーの情報をFirestoreで一元管理できるため、データの取得・更新が容易になります。 -
リアルタイム性:
Firestoreはリアルタイムデータベースであり、データの変更がすぐに反映されるため、データの一貫性を保つことができます。
Unity側での準備
まずは、チャット画面用のスクリプトとなる ChatView.cs
を新規作成しましょう。
public class ChatView : MonoBehaviour
{
public void OnChatStart()
{
}
}
MonoBehaviour
を継承して、Unityエディタ上で「Chat」というGameObject
にアタッチしておきましょう。
ChatView
クラスのOnChatStart
は、LoginOrSingUpView
クラス内の、以下のタイミングで発火させます。
- ユーザーが既にログインしていた状態で、アプリを起動した時
- ユーザーが新規アカウントを作成・ログインしてChat Startボタンを押した時
以下は`LoginOrSingUpView`クラスの中身となります。
新しく、chatview
の追加と、チャット画面表示処理をOnShowChatScreen()
というメソッドに切り分けています。ChatView
クラスのOnChatStart
はOnShowChatScreen()
内で呼び出しています。
...
[SerializeField] private ChatView chatView = default!;
private void Start()
{
_auth = FirebaseAuth.DefaultInstance;
var currentUser = _auth.CurrentUser;
if (currentUser != null)
{
Debug.Log($"User is logged in: {currentUser.DisplayName} ({currentUser.UserId})");
loginFeedbackText.text = $"ログイン中: {currentUser.DisplayName} ({currentUser.UserId})";
signUpFeedbackText.text = $"ログイン中: {currentUser.DisplayName} ({currentUser.UserId})";
// アカウント作成・ログインをスキップしてチャット画面を表示
OnShowChatScreen();
}
else
{
Debug.Log("No user is logged in.");
}
signUpButton.onClick.AddListener(RegisterButton);
loginButton.onClick.AddListener(LoginButton);
// ChatStartボタンを押したらチャット画面を表示
chatStartButton.onClick.AddListener(OnShowChatScreen);
}
private void OnShowChatScreen()
{
if (_auth.CurrentUser == null)
{
Debug.LogError("User is not logged in.");
return;
}
chatView.OnChatStart();
chatScreen.SetActive(true);
gameObject.SetActive(false);
}
Unityエディタ上で、LoginOrSingUpView
オブジェクトから、chatView
に対して先ほどスクリプトをアタッチしたChat
という名前のGameObjectをアタッチしておきます。
一旦ここまでで、下準備は完了です。
実際にユーザー情報をFirestoreに保存する
今回は、以下の情報をFirestoreに保存してみます。
- ハンドルネーム(という名のランダム文字列、アプリ起動ごとに異なる名前になる)
- メッセージになんの語尾を足すか設定する情報(後程、サーバー実装で活用させる)
今回のハンズオンでは行いませんが、ハンドルネームを実際にユーザーに入力してもらって、それをFirestoreに保存すれば、実際にチャット機能を試す際に、ハンドルネームも一緒に表示する仕組みを作れます。
以下のコードを ChatView
クラスに追加します。
public class ChatView : MonoBehaviour
{
// ユーザーが選択する語尾タイプ用のドロップダウンメニュー
[SerializeField] private TMP_Dropdown suffixDropdown = default!;
private FirebaseFirestore _firestore = default!;
private FirebaseAuth _auth = default!;
public void OnChatStart()
{
_firestore ??= FirebaseFirestore.DefaultInstance;
_auth ??= FirebaseAuth.DefaultInstance;
Debug.Log("Chat started.");
// ランダムな5文字を選出し、ハンドルネームとして設定
var handleName = RandomStringGenerator.GenerateRandomString();
// ユーザーがドロップダウンで選択した語尾タイプを取得
var suffixType = suffixDropdown.options[suffixDropdown.value].text;
// Firestoreにユーザー情報を保存
// "users" というコレクションに現在のユーザーIDをキーとするドキュメントを参照
var userDocRef = _firestore.Collection("users").Document(_auth.CurrentUser.UserId);
// 保存するユーザー情報をオブジェクトとしてまとめる
var userData = new { handleName, suffixType };
// ユーザー情報を非同期にFirestoreに保存(後述)
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}");
}
}
// ランダムなハンドルネーム生成メソッド
internal static class RandomStringGenerator
{
private static readonly char[] Characters = "abcdefghijklmnopqrstuvwxyz0123456789".ToCharArray();
private static readonly System.Random Random = new();
public static string GenerateRandomString(int length = 5)
{
var result = new StringBuilder(length);
for (var i = 0; i < length; i++)
{
var randomChar = Characters[Random.Next(Characters.Length)];
result.Append(randomChar);
}
return result.ToString();
}
}
ここまで完了したら、Unityエディタ上で、Chat
オブジェクトの、suffixDropdown
に対して同じ名前のSuffixDropdown
をアタッチしてください。
アプリを実行し、Chat画面まで到達するとFirestoreの users
コレクションに以下のようなデータが保存されます。
これで、ユーザー情報の保存が正しく動作することを確認できました。次に、チャットのメッセージ機能を実装していきます。
2. Firestoreへの書き込み操作種類
次に進む前に、ChatView
クラス内のコードにて、ユーザー情報をFirestoreに保存する際に
userDocRef.SetAsync(userData)
というAPIを使用しました。
Firestoreにはこのようにデータを書き込むためのいくつかの操作種類があります。それぞれの特徴を簡単に解説します。
1. SetAsync
SetAsync
は、指定したデータを新規作成または上書きする操作です。
もし既存のドキュメントが存在していれば、それを上書きします。
await userDocRef.SetAsync(userData);
2. UpdateAsync
UpdateAsync
は既存ドキュメントの特定フィールドを更新する操作です。
指定したドキュメントが存在しない場合、エラーが発生します。
await userDocRef.UpdateAsync(new { age = 30 });
3. DeleteAsync
DeleteAsync
はドキュメント全体を削除します。
対応するドキュメントが存在しない場合でもエラーは発生しません。
await userDocRef.DeleteAsync();
4. AddAsync
AddAsync
は、コレクションに新しいドキュメントを自動生成されたIDで追加する操作です。
await firestore.Collection("users").AddAsync(userData);
どの操作を選ぶべきか?
-
ドキュメント全体を新規作成・上書きしたい場合 →
SetAsync
-
特定のフィールドだけを更新したい場合 →
UpdateAsync
-
ドキュメント全体を削除したい場合 →
DeleteAsync
-
ユニークなIDで新しいデータを追加したい場合 →
AddAsync
3. ユーザー情報を更新してみる
次に、ユーザーがDropdownメニューで選択した値をFirestoreに反映させる処理を実装します。
ChatView
クラスのコードに実装を追加してみましょう。
...
private void Start()
{
_auth = FirebaseAuth.DefaultInstance;
_firestore = FirebaseFirestore.DefaultInstance;
// ドロップダウンの値が変更された際にFirestoreにsuffixTypeを更新
suffixDropdown.onValueChanged.AddListener(OnSuffixDropdownChanged);
}
...
// ドロップダウンの値が変更された際にFirestoreにsuffixTypeを更新
private void OnSuffixDropdownChanged(int newValue)
{
var currentUser = _auth.CurrentUser;
if (currentUser == null)
{
Debug.LogError("No user is logged in.");
return;
}
// 新しいsuffixTypeの取得
var newSuffixType = suffixDropdown.options[newValue].text;
// FirestoreでsuffixTypeを更新
var userDocRef = _firestore.Collection("users").Document(currentUser.UserId);
userDocRef.UpdateAsync("suffixType", newSuffixType).ContinueWithOnMainThread(task =>
{
if (task.IsCompleted)
{
Debug.Log("Suffix type updated successfully.");
}
else
{
Debug.LogError("Failed to update suffix type: " + task.Exception);
}
});
}
このコードを実行すると、Firestoreに保存されているsuffixType
フィールドがチャット画面上にあるDropdownで選択された値に応じて更新されます。
「キャラクター」に変えてみます。
無事Firestoreでデータの更新が行われていることを確認できました。
これで、Firestoreのデータ更新機能が実装できました!
4. Firestoreへチャットメッセージを送信する
続いて、アプリ上で入力したチャットのテキストメッセージをFirestoreへ送信し、保存する処理を追加してみましょう。
「messages」コレクションの作成
まずFirestoreに新しいコレクション「messages」を作成します。このコレクションは、ユーザーが送信したチャットメッセージを保存するために使用します。
Firestoreコンソールを開き、「コレクションを開始」をクリックします。
コレクションIDとして messages
を入力してください。
最初のドキュメントの追加を求められますが、ここでも空のフィールドで問題ありません。
Unity側での実装
messagesコレクションにメッセージを保存する処理を、ChatView
クラスに追加します。
public class ChatView : MonoBehaviour
{
...
[SerializeField] private TMP_InputField chatInputField = default!;
[SerializeField] private Button sendButton = default!;
[SerializeField] private GameObject chatTextBoxPrefab = default!;
[SerializeField] private Transform chatContentTransform = default!;
private FirebaseFirestore _firestore = default!;
private FirebaseAuth _auth = default!;
private void Start()
{
_auth = FirebaseAuth.DefaultInstance;
_firestore = FirebaseFirestore.DefaultInstance;
suffixDropdown.onValueChanged.AddListener(OnSuffixDropdownChanged);
// 送信ボタンにリスナーを設定
sendButton.onClick.AddListener(OnSendButtonClicked);
}
// 送信ボタンがクリックされたときに呼ばれる処理
private void OnSendButtonClicked()
{
var currentUser = _auth.CurrentUser;
if (currentUser == null)
{
Debug.LogError("User is not logged in.");
return;
}
// ユーザーが入力したメッセージを取得
var message = chatInputField.text;
if (string.IsNullOrWhiteSpace(message))
{
Debug.LogWarning("Cannot send an empty message.");
return;
}
// チャット入力フィールドをクリアする
chatInputField.text = "";
// メッセージテキストをFirestoreに送信する
SendChatMessageToFirestore(currentUser.UserId, message);
// チャットメッセージ用のPrefabを生成してScroll Viewに追加する
CreateChatMessagePrefab(message);
}
// チャットメッセージをFirestoreに保存
private void SendChatMessageToFirestore(string userId, string message)
{
// Firestoreに保存するメッセージデータを作成
var chatData = new
{
userId,
message,
timestamp = Timestamp.GetCurrentTimestamp()
};
// "messages" コレクションに新しいドキュメントを作成
_firestore.Collection("messages").AddAsync(chatData).ContinueWithOnMainThread(task =>
{
if (task.IsCompleted)
{
Debug.Log("Chat message saved successfully in Firestore.");
}
else
{
Debug.LogError("Failed to save chat message: " + task.Exception);
}
});
}
// チャットメッセージのPrefabを生成してScroll Viewに追加する
private void CreateChatMessagePrefab(string message)
{
// チャットボックスのPrefabをインスタンス化して、Scroll ViewのContentの子供として追加
var chatMessageObject = Instantiate(chatTextBoxPrefab, chatContentTransform);
// ChatTextBoxクラスのSetTextメソッドを呼び出してメッセージを設定
var chatTextBox = chatMessageObject.GetComponent<ChatTextBox>();
if (chatTextBox != null)
{
chatTextBox.SetText(message);
}
else
{
Debug.LogError("ChatTextBox component not found on the prefab.");
}
}
...
}
新しく追加したプロパティにアタッチをしていきましょう。
以下のように設定してください。
-
Chat Input Field →
Chat/InputChatText
-
Send Button →
Chat/SendButton
-
Chat Text Box Prefab →
Asset
フォルダ下にあるgrpc-chat-app/Prefabs/ChatTextBox.prefab
-
Chat Content Transform →
Chat/Scroll View/Viewport/Content
実際に起動して、チャットを打ち込んでみましょう。
Firestoreにアクセスしてmessages
コレクションにメッセージが保存されていたら成功です!
5. メッセージ加工+取得機能をサーバーに実装
いよいよ、サーバー側の実装に入ります。
Firestoreを利用したメッセージの加工と取得機能をサーバーに追加してみましょう。
GCP側の設定
まず、Google Cloud Platformコンソール上で、今回の実装に必要な設定を行います。
Firestoreを使用するために、「Google Cloud Firestore API」 を有効化します。
GCPコンソールの「APIとサービス」から該当のAPIを検索して有効化してください。
このAPIを有効化することで、Firestoreのデータベースにアクセスできるようになります。
次に、Cloud Runで使用されるサービスアカウントを確認します。
Cloud Runの「セキュリティ」タブを開き、サービスアカウントをgrpc-chat-app
に変更しておいてください。
サービスアカウントは、FirestoreやCloud RunなどのGCPリソースにアクセスする際、認証情報を管理するために必要となります。
最後に「IAMと管理」から今回使用しているサービスアカウントの権限を変更していきましょう。
以下の権限を付与してください。
- Artifact Registry 書き込み:コンテナイメージの保存に必要
- Cloud Datastore ユーザー:Firestoreを利用するために必要
- Cloud Run 管理者:Cloud Run サービスの操作と起動に必要
- Cloud Run 起動元:Cloud Run サービスの操作と起動に必要
- Firebase Admin SDK 管理者サービス エージェント:Firebase Admin SDKを介してFireStoreを操作する場合に必要
- Firestore サービスエージェント:Firestoreへのアクセスを許可する
- ストレージ管理者:Firebaseがバックエンドで利用する場合がある
これに伴って
- Cloud Run 閲覧者
を削除してしまってもOKです。
今回追加した権限によ李、Firestoreの読み書きやCloud Runの操作が可能になります。
一通り設定し終わった場合、以下のようになります。
protoファイルの作成
次に、gRPCの仕様を定義するためのProtoファイルを作成していきます。
Protoファイルは、サーバーとクライアントがどのように通信するかを記述するためのものです。
今回は、Firestoreに保存されている未送信メッセージを取得し、それに選択した語尾を付与して返すメソッドを定義します。
grpc-chat-appのプロジェクトを開いてください。
ルート下にあるproto
フォルダ内に新しいprotoファイルを作成します。このファイルに、gRPCで使用するリクエストとレスポンスの形式、そしてそれを取り扱うサービスを定義していきます。
chat.proto
ファイルの中身は以下のようにします。
// Protocol Buffersのver 3を使用する
syntax = "proto3";
// このファイル内で使うパッケージ名を定義
// 他のprotoファイルやコードで、このファイルを参照する際に使われる
package chat;
// Goのコードを生成する際のパッケージパスを指定
// 生成されたGoコードがこの指定されたディレクトリに配置される
option go_package = "gen/api/chat";
// サービスの定義
service ChatService {
// クライアントがチャットメッセージをリクエストし、
// サーバーが更新されたチャットメッセージをレスポンスとして返すメソッド
rpc GetChatMessages (ChatRequest) returns (ChatResponse);
}
// クライアントからサーバーに送られるリクエストメッセージの形式
message ChatRequest {
// ユーザーID(Firestoreの語尾設定を取得するために必要)
// stringはテキスト型で、1はこのフィールドの一意の識別子
string user_id = 1;
}
// サーバーからクライアントに送られるレスポンスメッセージの形式
message ChatResponse {
// クライアントに返すチャットメッセージのリスト
// repeatedは配列を意味する
repeated string messages = 1;
}
これにより、サーバーとクライアントがどのようなデータをやり取りするかが明確になります。
Goコードの自動生成
proto
ファイルを作成したら、次はその内容を元にGoコードを自動生成します。
protoc
と呼ばれるProtocol Buffersコンパイラと、Go用のプラグインが必要です。
protoc
のインストール確認
まず、protoc
がすでにインストールされているか確認します。
以下のコマンドを実行してください。
protoc --version
表示されるバージョンがインストール済みのものです。
libprotoc 29.0
インストールされていない場合は、Protocol Buffersの公式サイトの手順に従ってインストールしてください。
Go用のプラグインのインストール確認
Goコードを生成するには、protoc-gen-go
およびprotoc-gen-go-grpc
というプラグインが必要です。
以下のコマンドで、すでにインストールされているか確認してください。
protoc-gen-go --version
以下のように表示されていればOKです。
protoc-gen-go v1.34.2
もし、インストールがまだの場合は以下のコマンドを実行してインストールしてください。
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
コード生成の実行
プロジェクトのルートディレクトリで以下のコマンドを実行して、Goコードを生成しましょう。
protoc --go_out=. --go-grpc_out=. proto/chat.proto
成功すると、gen/api/chat/
フォルダ内に chat.pb.go
と chat_grpc.pb.go
が生成されます。
-
chat.pb.go
: Protocol Buffersメッセージの定義を含むファイル -
chat_grpc.pb.go
: gRPCサービスの定義を含むファイル
これでGoコードの自動生成が完了しました。
次は、生成したコードを使用してサーバーやクライアントのロジックを実装していきます。
サーバーのロジック実装
タグv0.7に対応しています。
今回のアプリ用にgRPCサーバーを構築するためには、FirestoreのデータにアクセスするロジックをGo言語で実装する必要があります。
Firestoreの操作には、専用のパッケージを利用します。以下のコマンドを実行してパッケージをインストールしてください。
go get cloud.google.com/go/firestore
その後、以下のコマンドを実行して依存関係の整理をしてください。
go mod tidy
これにより、不要な依存関係が削除され、必要なものがインストールされます。
go.mod
とgo.sum
ファイルが更新されているかと思います。
それでは、main.go
を以下のように変えていきましょう。
コード量が多いので、コンストラクタまで通して見たら、GetChatMessages
へ飛んで、中の流れに合わせて読むことをおすすめします。
また、メッセージが既読かどうかを確認して、新着メッセージだけをクライアントに送るために、サーバー側で「read
」というフィールドをFiresetoreに追加しています。
package main
import (
// Firestoreを操作するためのクライアントライブラリ
"cloud.google.com/go/firestore"
"errors"
"fmt"
// コンテキスト処理(タイムアウトやキャンセルなど)を行うための標準パッケージ
"context"
// Firestoreのイテレーション処理(反復処理)に必要
"google.golang.org/api/iterator"
// gRPCエラーコードを扱うためのライブラリ
"google.golang.org/grpc/codes"
// gRPCのエラー処理に必要なライブラリ
"google.golang.org/grpc/status"
// ログ出力を行うための標準ライブラリ
"log"
// ネットワークのリスナー作成に必要なパッケージ
"net"
// gRPCの機能を提供するパッケージ
"google.golang.org/grpc"
// gRPCサーバー用のリフレクションを有効にするライブラリ
"google.golang.org/grpc/reflection"
// 定義されたプロトコルバッファー(.proto)の自動生成コード
pb "grpc-chat-app-sample/gen/api/chat"
)
const (
// gRPCサーバーのポート番号
port = ":8080"
// FirestoreプロジェクトID
projectID = "grpc-chat-app"
// ユーザー情報を格納するFirestoreコレクション
collectionUsers = "users"
// メッセージ情報を格納するFirestoreコレクション
collectionMessages = "messages"
)
// gRPCサーバーの構造体定義
type ChatServiceServer struct {
// gRPCの実装を埋め込む
pb.UnimplementedChatServiceServer
firestoreClient *firestore.Client
}
// サーバーのコンストラクタ
func NewChatServiceServer(client *firestore.Client) *ChatServiceServer {
return &ChatServiceServer{
firestoreClient: client,
}
}
// ユーザーのsuffixTypeを取得し、接尾辞を決定する関数
func (s *ChatServiceServer) getUserSuffix(ctx context.Context, userId string) (string, error) {
// Firestoreからユーザー情報を取得
userDoc, err := s.firestoreClient.Collection(collectionUsers).Doc(userId).Get(ctx)
if err != nil {
// ユーザーが見つからない場合のエラー処理
if status.Code(err) == codes.NotFound {
log.Printf("UserID %s not found in Firestore", userId)
return "", status.Errorf(codes.NotFound, "User not found: %s", userId)
}
log.Printf("Error fetching user document: %v", err)
return "", status.Errorf(codes.Internal, "Error fetching user document: %v", err)
}
// FirestoreからsuffixTypeを取得
rawSuffix, ok := userDoc.Data()["suffixType"].(string)
if !ok {
log.Printf("Invalid suffixType for UserID: %s, defaulting to empty", userId)
return "", nil
}
// suffixTypeに応じた接尾辞を決定
switch rawSuffix {
case "猫":
return "にゃん", nil
case "犬":
return "わん", nil
case "キャラクター":
return "だよん", nil
default:
return "", nil
}
}
// "read"フィールドが存在しないメッセージにデフォルト値を設定する関数
func updateMessagesWithDefaultRead(ctx context.Context, client *firestore.Client) error {
// メッセージコレクションの全ドキュメントを取得
iter := client.Collection("messages").Documents(ctx)
// イテレータのクリーンアップ
defer iter.Stop()
for {
// 次のドキュメントを取得
doc, err := iter.Next()
if errors.Is(err, iterator.Done) {
// すべてのドキュメントを処理し終えた場合
return nil
}
if err != nil {
// ドキュメント取得中のエラー処理
log.Printf("Error iterating documents: %v", err)
return err
}
// `read` フィールドが存在しない場合にのみ追加
if _, exists := doc.Data()["read"]; !exists {
_, err := doc.Ref.Update(ctx, []firestore.Update{
{Path: "read", Value: false},
})
if err != nil {
log.Printf("Failed to update document %s: %v", doc.Ref.ID, err)
} else {
log.Printf("Document %s updated successfully", doc.Ref.ID)
}
}
}
}
// 未読のメッセージをFirestoreから取得する関数
func (s *ChatServiceServer) fetchUnreadMessages(ctx context.Context, userId string) ([]*firestore.DocumentSnapshot, error) {
// Firestoreクエリを作成
// "messages" コレクションから "userId" が一致しない未読のメッセージを取得する
iter := s.firestoreClient.Collection(collectionMessages).Documents(ctx)
var filteredMessages []*firestore.DocumentSnapshot
for {
doc, err := iter.Next()
if errors.Is(err, iterator.Done) {
break
}
if err != nil {
log.Printf("Failed to iterate messages: %v", err)
return nil, status.Errorf(codes.Internal, "Failed to fetch messages: %v", err)
}
// "read" フィールドが存在しない、または false のメッセージをフィルタリング
data := doc.Data()
read, readExists := data["read"].(bool)
if !readExists || !read {
// "userId" フィールドをチェックして、一致しない場合のみ追加
if docUserId, ok := data["userId"].(string); ok && docUserId != userId {
filteredMessages = append(filteredMessages, doc)
}
}
}
return filteredMessages, nil
}
// メッセージを取得し、未読を既読に更新するメインのgRPCメソッド
func (s *ChatServiceServer) GetChatMessages(ctx context.Context, req *pb.ChatRequest) (*pb.ChatResponse, error) {
// Firestoreクライアントが初期化されていない場合のエラー処理
if s.firestoreClient == nil {
return nil, status.Error(codes.Internal, "Firestore client is not initialized")
}
// 既読フィールドがないメッセージに対してデフォルト値を設定
if err := updateMessagesWithDefaultRead(ctx, s.firestoreClient); err != nil {
return nil, status.Errorf(codes.Internal, "Failed to update messages: %v", err)
}
// ユーザーの接尾辞を取得
suffix, err := s.getUserSuffix(ctx, req.UserId)
if err != nil {
return nil, err
}
// 未読メッセージを取得
messagesSnapshot, err := s.fetchUnreadMessages(ctx, req.UserId)
if err != nil {
return nil, err
}
// 未読メッセージがない場合の処理
if len(messagesSnapshot) == 0 {
log.Println("No new messages found")
return &pb.ChatResponse{Messages: []string{}}, nil
}
// メッセージのリストを作成し、既読に更新
var updatedMessages []string
// BulkWriterを作成
// BulkWriterはFirestoreへの複数の書き込みを効率的に処理するためのツール
// この場合、メッセージの既読状態を一括で更新するために使用
bulkWriter := s.firestoreClient.BulkWriter(ctx)
for _, doc := range messagesSnapshot {
messageData := doc.Data()
// もし"message"フィールドが存在しないか、文字列ではない場合、okはfalse
messageText, ok := messageData["message"].(string)
if !ok {
log.Printf("Invalid message format in document: %s", doc.Ref.ID)
continue
}
// ユーザーの接尾辞(suffix)をメッセージの末尾に追加
// fmt.Sprintfは、フォーマット済みの文字列を作成するための関数
updatedMessages = append(updatedMessages, fmt.Sprintf("%s%s", messageText, suffix))
// メッセージを既読に更新
_, err := bulkWriter.Update(doc.Ref, []firestore.Update{{Path: "read", Value: true}})
if err != nil {
log.Printf("Error queueing read update for document %s: %v", doc.Ref.ID, err)
}
}
// BulkWriterをフラッシュして一括更新
bulkWriter.Flush()
log.Printf("Successfully fetched and updated %d messages", len(updatedMessages))
return &pb.ChatResponse{Messages: updatedMessages}, nil
}
// gRPCサーバーのエントリーポイント
func main() {
// コンテキストを初期化
// コンテキストは、タイムアウトやキャンセルなどの制御をするために使用
ctx := context.Background()
// Firestoreクライアントの初期化
// Firestoreクライアントは、Firestoreデータベースとやり取りするための重要なオブジェクト
log.Println("Initializing Firestore client...")
// projectIDで指定されたプロジェクトに接続
client, err := firestore.NewClient(ctx, projectID)
if err != nil {
log.Fatalf("Failed to initialize Firestore client: %v", err)
}
// Firestoreクライアントの終了処理
// データベースとの接続をクリーンに切断するために必要
defer func() {
if err := client.Close(); err != nil {
log.Fatalf("Failed to close Firestore client: %v", err)
}
}()
// gRPCサーバーの起動
log.Println("Starting gRPC server...")
// TCPリスナーを作成
// クライアントが接続するためのポートを指定
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("Failed to initialize listener: %v", err)
}
// gRPCサーバーを作成
s := grpc.NewServer()
// gRPCサーバーにサービスを登録
// NewChatServiceServerはFirestoreクライアントを受け取り、gRPCサービスを初期化
pb.RegisterChatServiceServer(s, NewChatServiceServer(client))
// gRPCのリフレクションを有効化
// リフレクションは、クライアントがサーバーのメソッド情報を動的に取得できる機能
reflection.Register(s)
// gRPCサーバーのリッスンを開始
// クライアントからの接続を受け付ける
log.Printf("gRPC server listening on %s", port)
if err := s.Serve(lis); err != nil {
log.Fatalf("Failed to start gRPC server: %v", err)
}
}
ここまで出来たら、コミットしてCloud Runに反映が行われるまで待ち、実際にAPIのテストをして見ましょう。
GoLandでのテスト方法
今回のプロジェクトでは、GoLandを用いてgRPCサーバーロジックをテストします。GoLandではgRPCサービスのテストを簡単に行うためのHTTPリクエスト生成機能を提供しています。
まずはproto
ファイルに定義したgRPCサービスを確認します。
GetChatMessages
のコード行数の横にある水色の四角っぽいアイコンを押してください。
そうするとGoLandのプロジェクトディレクトリ内に、generated-requests.http
ファイルが作成されます。
このファイルには、以下のようにgRPCリクエストの内容を記述します。
### gRPC リクエスト
GRPC {ここはCloud RunのURL}/chat.ChatService/GetChatMessages
Content-Type: application/grpc
{
"user_id": ""
}
リクエストURLを記入する箇所には、サーバーロジックのエンドポイントを指定します。今回の場合はデプロイ済みのCloud RunのURLを使用します。
更にそこに、/chat.ChatService/GetChatMessages
を付け足しています。
これは、今回用意したチャットサービスのGetChatMessages
というAPIを呼ぶ、と指示していることになります。
user_id
の中身には、Firestore上のmessages
にあるドキュメント内のuser_id
と被らないユーザーIDを、usersの
データベースにいるユーザー情報からコピーして貼り付けてください。
generated-requests.http
ファイルを実行してテストが成功すると、Firestore内で未読のmessages
コレクションに追加されたメッセージがレスポンスとして表示されます。
まとめ
第六章では、本格的にFirestoreを使用したり、サーバー側のロジック用意を行いました。
-
Unityプロジェクトの準備
- Firestoreを使用してユーザー情報やチャットメッセージを保存
- UIコンポーネントを設定し、送信・表示のロジックを実装
-
gRPCの基礎設定
-
proto
ファイルを作成し、サービスやメッセージ形式を定義 - gRPCコード生成ツールを使用して、Go言語でサーバーロジックを自動生成
-
-
FirestoreとgRPCサーバーの連携
- サーバーロジックでFirestoreから未読メッセージを取得
- Firestoreに保存されたデータに語尾を付加する処理を実装
-
クラウド環境でのデプロイとテスト
- GCPのCloud Runを利用してgRPCサーバーをデプロイ
- GoLandを使ってローカルおよびクラウド環境でのテストを実施
難易度も高くなっていますが、次の記事で最後となります。
ラストはUnity側でサーバーとの通信ロジック構築に挑戦していきます。後もう少しですので、挫けず頑張りましょう…!
もしも記事の中で進められない箇所があったら、Xなどでご連絡ください。