2
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 17

サーバーサイドなんもわからんアナタに向けたハンズオン Unity × gRPC × Go × Firebase × Docker 〜第六章、Firestoreを使ったユーザー情報保存とメッセージ機能実装〜

Last updated at Posted at 2024-12-16

最初に

本記事の内容は、サーバーサイドにほとんど触れてこなかった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にアタッチしておきましょう。

image.png

ChatViewクラスのOnChatStartは、LoginOrSingUpViewクラス内の、以下のタイミングで発火させます。

  • ユーザーが既にログインしていた状態で、アプリを起動した時
  • ユーザーが新規アカウントを作成・ログインしてChat Startボタンを押した時

以下は`LoginOrSingUpView`クラスの中身となります。

新しく、chatviewの追加と、チャット画面表示処理をOnShowChatScreen()というメソッドに切り分けています。ChatViewクラスのOnChatStartOnShowChatScreen()内で呼び出しています。

...

[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をアタッチしておきます。

image.png

一旦ここまでで、下準備は完了です。


実際にユーザー情報を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をアタッチしてください。

image.png

アプリを実行し、Chat画面まで到達するとFirestoreの users コレクションに以下のようなデータが保存されます。

image.png

これで、ユーザー情報の保存が正しく動作することを確認できました。次に、チャットのメッセージ機能を実装していきます。


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で選択された値に応じて更新されます。

image.png

「キャラクター」に変えてみます。

image.png

無事Firestoreでデータの更新が行われていることを確認できました。

これで、Firestoreのデータ更新機能が実装できました!


4. Firestoreへチャットメッセージを送信する

続いて、アプリ上で入力したチャットのテキストメッセージをFirestoreへ送信し、保存する処理を追加してみましょう。

「messages」コレクションの作成

まずFirestoreに新しいコレクション「messages」を作成します。このコレクションは、ユーザーが送信したチャットメッセージを保存するために使用します。

Firestoreコンソールを開き、「コレクションを開始」をクリックします。

コレクションIDとして messages を入力してください。

image.png

最初のドキュメントの追加を求められますが、ここでも空のフィールドで問題ありません。

image.png


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 FieldChat/InputChatText
  • Send ButtonChat/SendButton
  • Chat Text Box PrefabAssetフォルダ下にあるgrpc-chat-app/Prefabs/ChatTextBox.prefab
  • Chat Content TransformChat/Scroll View/Viewport/Content

image.png

実際に起動して、チャットを打ち込んでみましょう。

image.png

Firestoreにアクセスしてmessagesコレクションにメッセージが保存されていたら成功です!

image.png


5. メッセージ加工+取得機能をサーバーに実装

いよいよ、サーバー側の実装に入ります。

Firestoreを利用したメッセージの加工と取得機能をサーバーに追加してみましょう。

GCP側の設定

まず、Google Cloud Platformコンソール上で、今回の実装に必要な設定を行います。

Firestoreを使用するために、「Google Cloud Firestore API」 を有効化します。

GCPコンソールの「APIとサービス」から該当のAPIを検索して有効化してください。

image.png

このAPIを有効化することで、Firestoreのデータベースにアクセスできるようになります。


次に、Cloud Runで使用されるサービスアカウントを確認します。

Cloud Runの「セキュリティ」タブを開き、サービスアカウントをgrpc-chat-appに変更しておいてください。

image.png

サービスアカウントは、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の操作が可能になります。

一通り設定し終わった場合、以下のようになります。

image.png


protoファイルの作成

次に、gRPCの仕様を定義するためのProtoファイルを作成していきます。

Protoファイルは、サーバーとクライアントがどのように通信するかを記述するためのものです。

今回は、Firestoreに保存されている未送信メッセージを取得し、それに選択した語尾を付与して返すメソッドを定義します。

grpc-chat-appのプロジェクトを開いてください。

ルート下にあるprotoフォルダ内に新しいprotoファイルを作成します。このファイルに、gRPCで使用するリクエストとレスポンスの形式、そしてそれを取り扱うサービスを定義していきます。

image.png

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.gochat_grpc.pb.go が生成されます。

  • chat.pb.go: Protocol Buffersメッセージの定義を含むファイル
  • chat_grpc.pb.go: gRPCサービスの定義を含むファイル

image.png

これでGoコードの自動生成が完了しました。

次は、生成したコードを使用してサーバーやクライアントのロジックを実装していきます。


サーバーのロジック実装

タグv0.7に対応しています。

今回のアプリ用にgRPCサーバーを構築するためには、FirestoreのデータにアクセスするロジックをGo言語で実装する必要があります。

Firestoreの操作には、専用のパッケージを利用します。以下のコマンドを実行してパッケージをインストールしてください。

go get cloud.google.com/go/firestore

その後、以下のコマンドを実行して依存関係の整理をしてください。

go mod tidy

これにより、不要な依存関係が削除され、必要なものがインストールされます。

go.modgo.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のコード行数の横にある水色の四角っぽいアイコンを押してください。

image.png

そうするとGoLandのプロジェクトディレクトリ内に、generated-requests.httpファイルが作成されます。

image.png

このファイルには、以下のようにgRPCリクエストの内容を記述します。

### gRPC リクエスト
GRPC {ここはCloud RunのURL}/chat.ChatService/GetChatMessages
Content-Type: application/grpc

{
  "user_id": ""
}

リクエストURLを記入する箇所には、サーバーロジックのエンドポイントを指定します。今回の場合はデプロイ済みのCloud RunのURLを使用します。

image.png

更にそこに、/chat.ChatService/GetChatMessagesを付け足しています。

これは、今回用意したチャットサービスのGetChatMessagesというAPIを呼ぶ、と指示していることになります。

user_idの中身には、Firestore上のmessagesにあるドキュメント内のuser_idと被らないユーザーIDを、usersデータベースにいるユーザー情報からコピーして貼り付けてください。

image.png

generated-requests.httpファイルを実行してテストが成功すると、Firestore内で未読のmessagesコレクションに追加されたメッセージがレスポンスとして表示されます。

image.png


まとめ

第六章では、本格的にFirestoreを使用したり、サーバー側のロジック用意を行いました。

  • Unityプロジェクトの準備
    • Firestoreを使用してユーザー情報やチャットメッセージを保存
    • UIコンポーネントを設定し、送信・表示のロジックを実装
  • gRPCの基礎設定
    • protoファイルを作成し、サービスやメッセージ形式を定義
    • gRPCコード生成ツールを使用して、Go言語でサーバーロジックを自動生成
  • FirestoreとgRPCサーバーの連携
    • サーバーロジックでFirestoreから未読メッセージを取得
    • Firestoreに保存されたデータに語尾を付加する処理を実装
  • クラウド環境でのデプロイとテスト
    • GCPのCloud Runを利用してgRPCサーバーをデプロイ
    • GoLandを使ってローカルおよびクラウド環境でのテストを実施

難易度も高くなっていますが、次の記事で最後となります。

ラストはUnity側でサーバーとの通信ロジック構築に挑戦していきます。後もう少しですので、挫けず頑張りましょう…!

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

2
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
2
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?