[Firebase]Cloud Functionsを学ぶ過程を記録してみた

# 始めに
Cloud Functionsを使用して、ドキュメント削除時に、サブコレクションも削除できるようにしたい。
まず、重要なポイントを押さえてから、実践に入っていく。

# 重要なポイント
## 参考1
https://firebase.google.com/docs/functions/video-series?hl=ja

* 最初にこれをみると、イメージが湧きやすかった
* ローカル環境で設定をしてデプロイするまでの流れを見れる
* HTTPを使う場合と、バックグラウンド関数を使う場合の流れを見れる
* Cloud Functionsの環境をローカルに整える→処理をコードで書く→本番環境にデプロイする

## 参考2
https://firebase.google.com/docs/functions/firestore-events?hl=ja&gen=2nd#function_triggers

* 参考1の後に見ると、書き方のイメージがつく
* ドキュメントの書き込み、削除、更新をトリガーとしたイベントが記載されている
* サンプルコードが載っているのでわかりやすい
* 単一ドキュメント（特定のドキュメント）を指定する方法
```Node.js:
import {
  onDocumentWritten,
  Change,
  FirestoreEvent
} from "firebase-functions/v2/firestore";

exports.myfunction = onDocumentWritten("users/marie", (event) => {
  // Your code here
});
```
* ワイルドカードを使用してドキュメントを指定する方法
```Node.js:
import {
  onDocumentWritten,
  Change,
  FirestoreEvent
} from "firebase-functions/v2/firestore";

exports.myfunction = onDocumentWritten("users/{userId}", (event) => {
  // If we set `/users/marie` to {name: "Marie"} then
  // event.params.userId == "marie"
  // ... and ...
  // event.data.after.data() == {name: "Marie"}
});
```
ワイルドカードに一致した部分がドキュメント パスから抽出され、`event.params`に保存される。明示的なコレクションまたはドキュメント ID に置き換えるワイルドカードは、必要な数だけ定義できる。
```Node.js:
import {
  onDocumentWritten,
  Change,
  FirestoreEvent
} from "firebase-functions/v2/firestore";

exports.myfunction = onDocumentWritten("users/{userId}/{messageCollectionId}/{messageId}", (event) => {
    // If we set `/users/marie/incoming_messages/134` to {body: "Hello"} then
    // event.params.userId == "marie";
    // event.params.messageCollectionId == "incoming_messages";
    // event.params.messageId == "134";
    // ... and ...
    // event.data.after.data() == {body: "Hello"}
});
```
ワイルドカードを使用する場合でも、トリガーは常にドキュメントを指している必要がある。たとえば、`{messageCollectionId}`はコレクションであるため、`users/{userId}/{messageCollectionId}`は無効になる。その一方で、`{messageId}`は常にドキュメントを指すため、`users/{userId}/{messageCollectionId}/{messageId}`は有効になる。

* その他の更新時に処理をしたい場合、更新後のデータを読み取りたい場合、更新前のデータを読み取りたい場合などたくさんのケースでサンプルコードが載っているので参考にする

## 参考3
https://qiita.com/AyakoKataoka/items/664828926cc22365d0b4

* 参考1の動画で紹介されていた内容が詳しく書かれている
* 動画を見てからこのサイトを見るとわかりやすい
* 作成からデプロイまでの方法が記載されている

## 参考4
https://tech.playground.style/firebase/cloud-functions-cases/

* Cloud Functionsで実装した例が記載されているのでイメージがつきやすい

# 実践
## 実践1
URLにアクセスすることで`HelloWorld`の文字が表示されるFunctionを作成する。

基本的に下記のURLを参考に進めていけばFunctionを作成することはできる。流れがわからないと何をしているのかが理解できないので、参考1に添付したURLにある動画を見てからやるとわかりやすい。

https://qiita.com/AyakoKataoka/items/664828926cc22365d0b4

今回は書いたコードは以下になる。
```Node.js:
const {onRequest} = require("firebase-functions/v2/https");
const logger = require("firebase-functions/logger");

exports.helloWorld = onRequest((request, response) => {
  logger.info("Hello logs!", {structuredData: true});
  response.send("Hello from Firebase!");
});
```

<完成図>
URLにアクセスすると、以下の画面が表示される。
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1640998/4883b122-28b1-b358-d442-720cba90183d.jpeg
">

### 躓いた箇所を記録
* ESLintはエラーが出るので無効にした
`firebase deploy`を実行した時に以下のようのエラーが表示された。
```:
i  deploying firestore, functions, hosting
Running command: npm --prefix "$RESOURCE_DIR" run lint
```
解決策は以下になる。

https://sumito.jp/2019/12/11/firebase-deploy%E6%99%82%E3%81%AEeslint%E3%82%92%E7%84%A1%E5%8A%B9%E3%81%AB%E3%81%99%E3%82%8B/

* バージョンエラーが出た
`firebase init functions`を実行したところ以下のエラーが出た。
```:
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR! 
npm ERR! While resolving: functions@undefined
npm ERR! Found: firebase-admin@undefined
npm ERR! node_modules/firebase-admin
npm ERR!   firebase-admin@"^11.8.0" from the root project
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer firebase-admin@"^10.0.0 || ^11.0.0" from firebase-functions@4.4.1
npm ERR! node_modules/firebase-functions
npm ERR!   firebase-functions@"^4.3.1" from the root project
npm ERR! 
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
```
バージョンの違いによるエラーのよう。強制実行は不備が出ると思ったので、解決策を探したところ、以下を実行したらエラーは出なくなった。
```:
npm install firebase-admin@latest firebase-functions@latest
```
参考は下記になる。

https://qiita.com/NaokiHaba/items/eaa9ace619d4313b8c16

* Firebase Admin SDKを作成しないとデプロイ時にエラー出る
`【プロジェクト名】-firebase-adminsdk-xxxxxxxxxxxxx.json`ファイルを作成してフォルダに置くようにと記載されているが、これをしないとデプロイ時にエラーになるので、注意！

:::note info
Firebase Admin SDKとは？
ChatGPTに聞いてみた。
> Firebase Admin SDKは、GoogleのFirebaseプラットフォームをサーバーサイドから利用するためのライブラリです。Firebaseは、モバイルアプリやウェブアプリの開発を助ける一連のツールとサービスを提供しています。これには、認証、データベース、ストレージ、ホスティングなどが含まれます。
> 
> 
> Firebase Admin SDKを使用すると、開発者はサーバー側からこれらの機能にアクセスし、Firebaseを管理することができます。たとえば、Admin SDKを使用してユーザーアカウントを作成したり、認証トークンを検証したり、データベースの読み取りと書き込みを行うことができます。これにより、開発者は自分のバックエンドシステムからFirebaseのサービスを直接管理することができます。
> 
> Admin SDKは複数のプログラミング言語（Node.js、Java、Python、Go、C#など）で利用でき、Firebaseの機能を利用してサーバーサイドのロジックを簡単に実装することができます。
>
:::

* Firebase functionsで 403 error "Your client does not have permission to get URL /** from this server"のエラーが出る
デプロイ後にURLにアクセスしたところ、エラーが出た。権限を付与しないとアクセスできないみたい。参考は下記になる。

https://qiita.com/toshiaki_takase/items/ce65cd5582a80917b52f

しかし、この方法ではできなかった。原因は、Cloud Functions（第 1 世代）とCloud Functions（第 ２ 世代）で対応が異なるため。第 ２ 世代の場合の、対処法は下記に書いてある。

https://cloud.google.com/functions/docs/securing/authenticating?hl=ja#generating_tokens_programmatically

* デフォルトで表示されていたコードについて
以下のコードについて理解が乏しかったので調べてみる。
```Node.js:
// ①
import {onRequest} from "firebase-functions/v2/https";
// ②
import * as logger from "firebase-functions/logger";

export const helloWorld = onRequest((request, response) => {
  logger.info("Hello logs!", {structuredData: true});
  response.send("Hello from Firebase!");
});
```
<details><summary>①について</summary>
ChatGPTに聞いてみた。

> `onRequest` は `firebase-functions/v2/https` モジュールからインポートされた関数で、HTTPリクエストに基づいてFirebase Cloud Functions（Firebaseのサーバレスコンピューティング機能）をトリガーするために使用されます。
> 
> 
> 具体的には、`onRequest` は、HTTPリクエストとレスポンスオブジェクトを引数とするコールバック関数を引数に取ります。このコールバック関数はHTTPリクエストが届いたときに実行され、そのリクエストに対するレスポンスを生成します。
> 
> 以下は `onRequest` を使用した簡単な例です：
> 
> ```Node.js:
> import { onRequest } from "firebase-functions/v2/https";
> 
> export const myFunction = onRequest((request, response) => {
>     response.send("Hello, world!");
> });
> 
> ```
> 
> このコードは、`myFunction` という名前のFirebase Cloud Functionを定義しています。この関数は、HTTPリクエストが来たときに "Hello, world!" というテキストをレスポンスとして送り返します。
>

</details>

<details><summary>②について</summary>
ChatGPTに聞いてみた。

> firebase-functions/logger モジュールを logger としてインポートしています。Firebase Functionsのロギングを管理するためのモジュールです。これにより、クラウド関数の実行中にログを出力することができます。
</details>

## 実践2
ドキュメント削除時にサブコレクションを削除する。
デプロイする前にエミュレーターでテストを実行する。

今回書いたコードは以下になる。
Todosコレクションのドキュメントを削除した場合に、サブコレクションのRecordsコレクション内にあるドキュメントが全て削除されるようになっている。
```Node.js:
// ①
const admin = require('firebase-admin');
// ②
const functions = require('firebase-functions');
// ③
const _ = require("lodash")
// ④
const serviceAccount = require('xxx.json');

// ⑤
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

exports.deleteRecords = functions.firestore
  .document("Todos/{todosID}")
  .onDelete(async (snap, context) => {
    const recordsRef = snap.ref.collection('Records');
    const snapshot = await recordsRef.get();
    console.log(`削除開始`);

    const limit = 500
    // Run the delete operation in batches
    // ③
    for (const chunkedRecords of _.chunk(snapshot.docs, limit)) {
      // ⑥
      const batch = admin.firestore().batch();
      chunkedRecords.forEach(doc => batch.delete(doc.ref));
      await batch.commit();
    };
    console.log(`削除完了`);
  });
```
<details><summary>①について</summary>

`admin`はFirebaseの各種サービス（データベース、認証、クラウドメッセージングなど）をサーバサイドから管理するためのNode.js SDK ([参考](https://www.npmjs.com/package/firebase-admin))

</details>

<details><summary>②について</summary>

`functions`は、`Firebase Cloud Functions`を作成するための`Node.js`ライブラリで、Firebase Cloud Functionsは、Firebaseのさまざまなイベント（HTTPリクエストの受信、Firebaseデータベースの特定のエントリの変更など）に対するレスポンスとして実行されるサーバレス関数を作成できる。([参考](https://www.npmjs.com/package/firebase-functions))

</details>

<details><summary>③について</summary>

`chunk`を使用するためにインポートしている。バッチは500までの制限があるので、もし500以上になってしまった場合にエラーになる。そのため、500以上の場合でも動くようにする工夫が必要。chunkはlimitで一つの配列として扱うことで、500以上になった場合でもエラーなく動くようになるかつ、カウント数を保持する必要がないのでとても便利。([参考](https://zenn.dev/sgr_ksmt/articles/b5fa4f8b9e0a33ccf609))

</details>

<details><summary>④について</summary>

`【プロジェクト名】-firebase-adminsdk-xxxxxxxxxxxxx.json`ファイルを設定。これはAdmin SDKで作成をしたもの。Admin SDKはサーバー側からFirebaseを扱うのに必須。

</details>

<details><summary>⑤について</summary>

コードが何をしているのかをChatGPTに聞いてみた。

> このコードは、Google CloudのFirebase Admin SDKを初期化しています。この処理はFirebaseの各種機能（Realtime Database、Cloud Firestore、Cloud Messaging、Authenticationなど）をサーバーサイドから操作するための準備を行っています。
> 
> 
> 具体的には、以下の2つの操作を行っています。
> 
> 1. `admin.initializeApp()`： FirebaseのAdmin SDKを初期化しています。Firebaseを操作するためのエントリポイントを設定しています。
> 
> 2. `credential: admin.credential.cert(serviceAccount)`：Firebaseへの認証情報を設定しています。ここでは、サービスアカウントの秘密鍵を使ってFirebaseにログインします。この`serviceAccount`は、事前にGoogle Cloud Consoleから取得したJSON形式のサービスアカウントの秘密鍵を指すと思われます。
> 
> これにより、Firebaseのサービスをバックエンドのサーバーサイドから操作できるようになります。例えば、ユーザー認証情報の管理や、データベースの読み書き、プッシュ通知の送信などが可能となります。
>
</details>

<details><summary>⑥について</summary>

`batch`は大量のものを一つにまとめて処理することができる。
`await batch.commit();`で処理を行っている。([参考1](https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja#batched-writes), [参考2](https://zenn.dev/cho_kin/articles/firestore-batched-writes))
</details>

### 躓いた箇所を記録
* FirestoreとFunctionsをEmulatorで実行する
`Functions`については、`index.js`に関数を書いて保存すると自動で適用してくれた。
アプリで動作した際にEmulatorにアクセスして欲しかったので下記のように`AppDelegate`にコードを記述したら、FirestoreとFunctionsは期待する動きをしてくれた。([参考](https://firebase.google.com/docs/emulator-suite/connect_functions?hl=ja))
```swift: AppDelegate
let settings = Firestore.firestore().settings
settings.host = "127.0.0.1:8080"
settings.isPersistenceEnabled = false
settings.isSSLEnabled = false
Firestore.firestore().settings = settings
        
Functions.functions().useEmulator(withHost: "localhost", port: 5001)
```

## 実践3
`recursiveDelete`を実行すると、自前で再帰関数を作成しなくてもサブコレクションが削除できるようなので試してみる。実践2と同様にTodosコレクションが削除された際にRecordsコレクションが削除されるよう設定する。
デプロイする前にエミュレーターでテストを実行する。

`recursiveDelete`を使用した方法の参考は下記になる。

https://zenn.dev/mochiya98/articles/105a5372276605

https://github.com/googleapis/nodejs-firestore/pull/1494

今回書いたコードは下記になる。
```Node.js:
const admin = require('firebase-admin');
const functions = require('firebase-functions');
const serviceAccount = require('xxx.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});
// ①
const firestore = admin.firestore();

exports.deleteRecords = functions.firestore
  .document("Users/{userID}/Goals/{goalsID}/Todos/{todosID}")
  .onDelete(async (snap, context) => {
    const docRef = snap.ref;
    // ②
    const bulkWriter = firestore.bulkWriter();
    bulkWriter.onWriteError((error) => {
      if (
        error.failedAttempts < MAX_RETRY_ATTEMPTS
      ) {
        return true;
      } else {
        console.log('Failed write at document: ', error.documentRef.path);
        return false;
      }
    });
    // ③
    await firestore.recursiveDelete(docRef, bulkWriter);
  });
```

<details><summary>①について</summary>

`admin.initializeApp`の初期化前に行ってしまうとエラーが出るので注意する。

</details>

<details><summary>②について</summary>

`BulkWriter`は大量の書き込みを並行して実行するために使用できる。

`onWriteError(shouldRetryCallback)`は、BulkWriter操作が失敗するたびに呼び出されるコールバックである。`true`を返すと操作が再試行されるので、最大10回までの再試行が完了していない場合はtrueを返すように設定をしている。`false`を返すと、再試行ループが停止する。([参考](https://googleapis.dev/nodejs/firestore/latest/BulkWriter.html#onWriteError))

</details>

<details><summary>③について</summary>

`recursiveDelete (ref、bulkWriter)`は、指定したレベル以下の全てのドキュメントとサブコレクションを再帰的に削除する。成功/エラー コールバックを追加するには、カスタム`BulkWriter`インスタンスを渡す。

`recursiveDelete`について([参考1](https://googleapis.dev/nodejs/firestore/latest/Firestore.html#recursiveDelete), [参考2](https://github.com/googleapis/nodejs-firestore/pull/1494))
</details>

### 躓いた箇所
*　firebase-toolsで再帰的に削除するメソッドがあるみたい
最初は以下のサイトを真似してfirebase-toolsで削除を実行しようとしたのだが、型を定義する必要があったりと、面倒臭かった。同じことがAdmin SDKでできるので、firebase-toolsを使用しなくても大丈夫そう‥。

https://zenn.dev/nananaoto/articles/c26965c5d518e4b235c7

## 実践4
`Authentication`に登録されているアカウントを削除した際に、アカウントに関連した`Firestore`のデータと、`Storage`のデータを削除する。

今回書いたコードは下記になる。
```Node.js:
const admin = require('firebase-admin');
const functions = require('firebase-functions');
const serviceAccount = require('xxx.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  // ①
  storageBucket: 'xxx.appspot.com'
});

const firestore = admin.firestore();
// ①
const bucket = admin.storage().bucket();
  // ②
  exports.deleteAdmin = functions.auth.user().onDelete(async (user) => {
    const uid = user.uid;
    deleteFocus(uid);
    deleteStorage(uid);
    await deleteUsers(uid);
  });

const deleteUsers = async (uid) => {
  const docRef = firestore.collection('Users').doc(uid);
  const bulkWriter = firestore.bulkWriter();
  bulkWriter.onWriteError((error) => {
    if (
      error.failedAttempts < MAX_RETRY_ATTEMPTS
    ) {
      return true;
    } else {
      console.log('Failed write at document: ', error.documentRef.path);
      return false;
    }
  });
  await firestore.recursiveDelete(docRef, bulkWriter);
};

const deleteFocus = (uid) => {
  const docRef = firestore.collection('Focuses').doc(uid);
  docRef.delete();
};

const deleteStorage = (uid) => {
  // ③
  bucket.deleteFiles({
    prefix: uid
  })
};

```

<details><summary>①について</summary>

①ではStorageの保存場所を指定している。バケット内には、ユーザーがアップロードした任意の数のオブジェクトを保存することができる。`xxx.appspot.com`には、Firebaseプロジェクトが使用するStorageバケットの名前が入る。(Storageにアクセスした際の、`gs://`以下の値をコピーしてくればOK)[(参考)](https://qiita.com/kira_puka/items/8743c2b1a012a934ef92#%E3%81%84%E3%82%8D%E3%81%84%E3%82%8D%E3%82%84%E3%81%A3%E3%81%A6%E3%82%8B%E3%82%B3%E3%83%BC%E3%83%89)


</details>

<details><summary>②について</summary>

`functions.auth.user().onDelete`を定義することによって、アカウント削除時に削除したuserを取得できる。`user.uid`によって、uidを取得し、StorageとFirestoreから削除対象を特定するようにしている。

</details>

<details><summary>③について</summary>

フォルダをuidで登録しているので、フォルダを検索して、フォルダ以下全てのデータを削除している。[(参考)](https://qiita.com/HorikawaTokiya/items/99f68b0b13d46e787510)


</details>


# 終わりに
これで少しは使えるようになったのかなと思います。。
情報を残してくれる先輩方に感謝です🙇

(いいねを押していただけると、励みになるので何卒とぞ‥😳)