【CloudFuncionts/PubSub】スケジュールされたメッセージを表示する

自分の備忘録として書いています。

# この記事でやること
時間指定してメッセージを送信できるアプリを開発します。
実アプリのフロントエンドはXcodeでSwiftを使って実装し、バックエンドはCloudFunctinosを利用して構築します。

# isDisplayedを更新するCloudFunctionを作成
Pub/Subによって毎分トリガーされ、メッセージのisDisplayedフィールドをチェックして更新するCloud Functionを作成します。

## Firebaseプロジェクトのセットアップ
Cloud Functionsを利用するために、Firebase CLIをインストールし、Firebaseプロジェクトのセットアップを行います。

### Firebase CLIのインストール:
Firebase CLIがインストールされていない場合、以下のコマンドでインストールします（Node.jsがインストールされている必要があります）。
```
npm install -g firebase-tools
```

### Firebaseプロジェクトにログイン
ターミナルまたはコマンドプロンプトで以下のコマンドを実行し、Firebaseアカウントにログインします。
```
firebase login
```

### プロジェクトの初期化
プロジェクトのディレクトリで以下のコマンドを実行し、Cloud Functionsを含むFirebaseプロジェクトを初期化します。
```
firebase init
```
ここで、以下のオプションを選択します：
- Functions: Cloud Functionsを選択
- Use an existing project: 既存のFirebaseプロジェクトを選択（ない場合は、Firebaseコンソールで作成します）
- JavaScriptまたはTypeScript: 言語を選択（今回はJavaScriptで進めます）
- ESLintの設定: 必要に応じて選択（コードの品質を保つためのツール、今回はnoで進めます）
- npm installを実行: 依存関係をインストール

（任意）ENOENT（"Error NO ENTry"）エラーが出る場合、以下のコマンドを実行してキャッシュをクリアしてから再度試して見てください。
```
npm cache clean --force
```

# Pub/Subを設定して毎分関数をトリガー
## Pub/Subトピックの作成
FirebaseコンソールまたはGoogle Cloudコンソールを使用して、Pub/Subトピックを作成します。

### Google CloudコンソールでPub/Subへアクセス:
「Google Cloudコンソール」を開き、現在のプロジェクトを選択します。左側のナビゲーションメニューから「Pub/Sub」を選択します。

### トピックを作成:
「トピックを作成」をクリックし、トピック名に updateIsDisplayed と入力して作成します。

## Cloud Functionの作成
次に、実際にCloud Functionを作成します。

### functions/index.jsファイルの編集:
firebase initの際に作成された functions ディレクトリ内の index.js ファイルを以下のように編集します。

```
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

// Pub/Subトピック "updateIsDisplayed" によってトリガーされるCloud Function
exports.updateIsDisplayed = functions.pubsub.topic('updateIsDisplayed').onPublish(async (message) => {
    const db = admin.firestore();
    const now = admin.firestore.Timestamp.now();

    const messagesRef = db.collection('messages');
    // scheduledTimeが現在の時間以下で、isDisplayedがfalseのメッセージを取得
    const snapshot = await messagesRef.where('scheduledTime', '<=', now).where('isDisplayed', '==', false).get();

    const batch = db.batch();

    snapshot.forEach(doc => {
        // 各ドキュメントのisDisplayedフィールドをtrueに更新
        batch.update(doc.ref, { isDisplayed: true });
    });

    await batch.commit(); // 一括更新を実行

    console.log('Messages updated:', snapshot.size);
});
```

### Cloud Functionのデプロイ:
左側のナビゲーションメニューから「お支払い」を選択し、請求先アカウントを紐づけます。（artifactregistry.googleapis.comを有効にするには、Blazeプランにする必要があります。）
次に、以下のコマンドを実行して、Cloud Functionをデプロイします。
```
firebase deploy --only functions
```
デプロイが完了すると、updateIsDisplayed関数がFirebase上で稼働し、設定したPub/Subトピックによって毎分トリガーされるようになります。

## Cloud Schedulerでのジョブ設定
Google Cloud Schedulerを使用して、この関数を毎分実行するように設定します。

### Google Cloudコンソールに移動:
Google Cloudコンソールの左側のナビゲーションメニューから「Cloud Scheduler」を選択します。

### 新しいジョブを作成:
「ジョブを作成」ボタンをクリックして、新しいジョブを設定します。

- 名前: 任意の名前を入力
- 頻度: * * * * *（毎分）
- ターゲット: Pub/Sub
- トピック: 先ほど作成した updateIsDisplayed を選択
- ペイロード（メッセージ本文）: {}（空のペイロードでOK）

### ジョブを保存して開始:
保存してジョブを開始すると、このジョブが毎分実行され、updateIsDisplayed Cloud Functionがトリガーされます。

# クライアントの設定
クライアント側の実装は以下の記事を元に実装します。
ChatViewControllerとMessagesTableViewControllerのみ修正します。

https://qiita.com/taku-ll/items/5ac86bfa8d6529d9d3b4

## ChatViewControllerの修正
```
import UIKit
import MessageKit
import InputBarAccessoryView
import Firebase
import FirebaseFirestore

class ChatViewController: MessagesViewController, MessagesDataSource, MessagesLayoutDelegate, MessagesDisplayDelegate, InputBarAccessoryViewDelegate {

    // MARK: - Properties
    private var messages: [Message] = []
    private var messageListener: ListenerRegistration?

    // MARK: - Lifecycle Methods
    override func viewDidLoad() {
        super.viewDidLoad()
        configureMessageKit()
        listenForMessages()
    }

    deinit {
        messageListener?.remove()
    }

    // MARK: - MessageKit Configuration
    private func configureMessageKit() {
        messagesCollectionView.messagesDataSource = self
        messagesCollectionView.messagesLayoutDelegate = self
        messagesCollectionView.messagesDisplayDelegate = self
        messageInputBar.delegate = self
    }

    var currentSender: SenderType {
        return Sender(senderId: "anon", displayName: "Anonymous")
    }

    // MARK: - MessagesDataSource
    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messages.count
    }

    func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
        return messages[indexPath.section]
    }

    // MARK: - InputBarAccessoryViewDelegate
    func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
        showDatePicker(messageContent: text)
    }

    // MARK: - Firestore Handling
    private func listenForMessages() {
        let db = Firestore.firestore()
        // メッセージに変更があったらリスニング
        messageListener = db.collection("messages")
            .order(by: "createdTime")
            .addSnapshotListener { snapshot, error in
                if let error = error {
                    print("Error fetching messages: \(error)")
                    return
                }
                
                guard let documents = snapshot?.documents else {
                    print("No documents")
                    return
                }
                
                // "isDisplayed"がtrueのものだけに絞り込む
                let filteredDocuments = documents.filter { document in
                    if let isDisplayed = document.get("isDisplayed") as? Bool {
                        return isDisplayed == true
                    }
                    return false
                }
                
                self.processDocuments(filteredDocuments)
            }
    }
    
    private func processDocuments(_ documents: [QueryDocumentSnapshot]) {
        var newMessages: [Message] = []

        for document in documents {
            if let message = Message(document: document) {
                newMessages.append(message)
            }
        }

        self.messages = newMessages

        DispatchQueue.main.async {
            self.messagesCollectionView.reloadData()
            self.messagesCollectionView.scrollToLastItem(animated: true)
        }
    }

    private func saveMessageToFirestore(messageContent: String, scheduledTime: Date) {
        let db = Firestore.firestore()
        let newMessage = Message(
            messageContent: messageContent,
            scheduledTime: scheduledTime,
            createdTime: Date(),
            isDisplayed: false
        )

        db.collection("messages").addDocument(data: newMessage.toDictionary()) { error in
            if let error = error {
                print("Error saving message: \(error)")
            } else {
                print("Message successfully saved!")
            }
        }
    }

    // MARK: - DatePicker Handling
    private func showDatePicker(messageContent: String) {
        let datePicker = createDatePicker()
        let alertController = createDatePickerAlertController(datePicker: datePicker, messageContent: messageContent)
        present(alertController, animated: true, completion: nil)
    }

    private func createDatePicker() -> UIDatePicker {
        let datePicker = UIDatePicker()
        datePicker.datePickerMode = .dateAndTime
        datePicker.preferredDatePickerStyle = .wheels
        return datePicker
    }

    private func createDatePickerAlertController(datePicker: UIDatePicker, messageContent: String) -> UIAlertController {
        let alertController = UIAlertController(title: "時間を指定", message: nil, preferredStyle: .actionSheet)
        alertController.view.addSubview(datePicker)

        // AutoLayoutを設定
        datePicker.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            datePicker.topAnchor.constraint(equalTo: alertController.view.topAnchor, constant: 30),
            datePicker.centerXAnchor.constraint(equalTo: alertController.view.centerXAnchor),
            alertController.view.heightAnchor.constraint(equalToConstant: 350)
        ])

        let setAction = UIAlertAction(title: "設定", style: .default) { _ in
            self.saveMessageToFirestore(messageContent: messageContent, scheduledTime: datePicker.date)
        }
        alertController.addAction(setAction)
        alertController.addAction(UIAlertAction(title: "キャンセル", style: .cancel, handler: nil))

        return alertController
    }
}
```

ChatViewControllerはメッセージのリスニングとUIの更新のみの機能を持つようになります。isDisplayedフィールドの更新は、バックエンドのCloudFunctionで処理されます。

## MessagesTableViewControllerの修正
```
import UIKit
import Firebase
import FirebaseFirestore

class MessagesTableViewController: UIViewController {
    
    // MARK: - Properties
    @IBOutlet weak var tableView: UITableView!
    private var messages: [Message] = []
    private var messageListener: ListenerRegistration?
    
    // MARK: - Lifecycle Methods
    override func viewDidLoad() {
        super.viewDidLoad()
        configureTableView()
        listenForMessages()
    }
    
    deinit {
        // リスナーを削除
        messageListener?.remove()
    }
    
    // MARK: - Configuration
    private func configureTableView() {
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "MessageCell")
    }
    
    // MARK: - Firestore Listening
    private func listenForMessages() {
        let db = Firestore.firestore()
        messageListener = db.collection("messages").order(by: "createdTime", descending: false).addSnapshotListener { [weak self] snapshot, error in
            if let error = error {
                print("Error fetching messages: \(error)")
                return
            }
            
            guard let documents = snapshot?.documents else {
                print("No documents found")
                return
            }
            
            // メッセージを更新してTableViewをリロード
            self?.messages = documents.compactMap { Message(document: $0) }
            self?.reloadTableView()
        }
    }
    
    private func reloadTableView() {
        DispatchQueue.main.async {
            self.tableView.reloadData()
        }
    }
    
    // MARK: - Date Formatting Helper
    private func formatDate(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .short
        return formatter.string(from: date)
    }
}

// MARK: - UITableViewDataSource & UITableViewDelegate
extension MessagesTableViewController: UITableViewDataSource, UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        var cell = tableView.dequeueReusableCell(withIdentifier: "MessageCell", for: indexPath)

        // セルが `Subtitle` スタイルを持つようにする
        if cell.textLabel == nil || cell.detailTextLabel == nil {
            cell = UITableViewCell(style: .subtitle, reuseIdentifier: "MessageCell")
        }

        let message = messages[indexPath.row]

        // Cellのカスタマイズ
        cell.textLabel?.text = message.messageContent
        cell.detailTextLabel?.numberOfLines = 0 // 行数を制限しない
        cell.detailTextLabel?.text = "Created: \(formatDate(message.createdTime))\nScheduled: \(formatDate(message.scheduledTime))"

        return cell
    }
}
```
メッセージ送信時にリアルタイムで表示される仕様に変更しました。

# isDisplayedが正しく更新されない場合
## CloudFunctionの実行状況を確認する
1. GoogleCloudConsoleにアクセス
    - GoogleCloudConsoleにアクセスし、ログインします
    - 該当するプロジェクトを選択します。 
1. CloudFunctionsページに移動
    - 検索バーからCloudFunctionsを検索し、選択します。ここで作成した全ての関数のリストが表示されます。
1. 関数の詳細を確認
    - updateIsDisplayedの名前をクリックして、詳細ページに移動します。
    - 詳細ページには、関数が何回実行されたか、直近の実行状況、エラーメッセージなどが表示されます。
1. 関数の実行履歴の確認
    - 「ログ」タブを選択すrと、関数がトリガーされた際の実行ログが表示されます。
    - 実行が成功したか、エラーが発生したか、どのようなメッセージが記録されたかを確認できます。

ログに含まれる情報：
- 成功した場合のメッセージ：通常、console.logで出力したメッセージが表示されます。今回の場合は、"Messages updated: <number>"。
- エラーが発生した場合：console.errorまたは他のエラーメッセージが記録されます。例えば、Firesotreへのアクセス失敗やネットワークの問題などがここで確認できます。

## CloudFunctions「Error: 9 FAILED_PRECONDITION」
今回発生したエラーとその解決方法について

### ログに表示されたエラー
以下のようなエラーが表示されていました。
```
updateIsDisplayedfg2hwxpjgh52 Error: 9 FAILED_PRECONDITION: The query requires an index. You can create it here: https://console.firebase.google.com/v1/r/project/ontimebeta/firestore/indexes?create_composite=Cktwcm9qZWN0cy9vbnRpbWViZXRhL2RhdGFiYXNlcy8oZGVmYXVsdCkvY29sbGVjdGlvbkdyb3Vwcy9tZXNzYWdlcy9pbmRleGVzL18QARoPCgtpc0Rpc3BsYXllZBABGhEKDXNjaGVkdWxlZFRpbWUQARoMCghfX25hbWVfXxAB
    at callErrorFromStatus (/workspace/node_modules/@grpc/grpc-js/build/src/call.js:31:19)
    at Object.onReceiveStatus (/workspace/node_modules/@grpc/grpc-js/build/src/client.js:359:73)
    at Object.onReceiveStatus (/workspace/node_modules/@grpc/grpc-js/build/src/client-interceptors.js:323:181)
    at /workspace/node_modules/@grpc/grpc-js/build/src/resolving-call.js:129:78
    at process.processTicksAndRejections (node:internal/process/task_queues:77:11)
```
### 解決方法
このエラーメッセージは、Firestoreで実行したクエリに対して、必要なインデックスが存在しないために発生しています。Firestoreでは複数のフィールドに対する条件付きクエリ（where句を複数使用する場合など）を実行する際、特定のインデックスが必要になります。
1. エラーメッセージのリンクを開く：
エラーメッセージにインデックスを作成するためのリンクが表示されています。リンクをクリックしてFirebaseコンソールにアクセスします。
例：
```
https://console.firebase.google.com/v1/r/project/{project-id}/firestore/indexes?create_composite={encoded-index-info}
```
リンクをクリックすると、Firestoreのインデックス作成画面にリダイレクトされ、該当クエリに必要なインデックスの作成手順が表示されます。
1. インデックスを作成：
リンク先のインデックス作成画面で、指示に従ってインデックスを作成します。インデックス作成には数分かかることがあります。
1. 再度関数を実行：
インデックスの作成が完了したら、再度Cloud Functionをトリガーしてみてください。エラーが解消され、クエリが正常に実行されるはずです。

### なぜインデックスが必要なのか？
Firestoreでは、効率的な検索のために複合クエリ（複数のフィールドに対するwhere句やorder by句のクエリ）を実行する際、カスタムインデックスが必要です。今回のケースでは、scheduledTimeとisDisplayedフィールドを組み合わせたクエリを実行しているため、Firestoreはその2つのフィールドに対応するインデックスを作成する必要があります。

### 補足情報
インデックス作成後も、クエリに変更を加えた場合や新しいフィールドを条件に追加した場合、再度インデックスが必要になることがあります。その際も、エラーメッセージにリンクが表示されるので、同じ手順でインデックスを作成してください。