Firebase による開発 Tips 集

Firebase における開発環境まわりのセットアップで調べても探しづらかったことをメモ的に書き留めておく。
随時更新予定。
**なお深い検証はしていない**ため、参考程度に見ていただければ幸いである。

## 1. Cloud Functions

### 1-1. ~ビルド

#### 1-1-1. TypeScriptのエイリアス設定

##### ビルド時 `module not found` となる。
エイリアスにより import 文に指定するパスを簡潔にすることはよくある。
以下の設定において、`src` ディレクトリに対して `@/` というエイリアスを設定する。
```tsconfig.json
{
  "compilerOptions": {
    ..
    "baseUrl": "./src",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
```
関数の定義
```fuga.ts
export const hoge = () => console.log('hoge');
```
関数の呼び出し
```index.ts
import { hoge } from '@/fuga';　// エイリアスを用いた関数の import

hoge(); // 関数の呼び出し
```
この状態で Cloud Functions を起動すると、以下のエラーとなる。

```sh
...
Cannot find module '@/fuga'
```

##### 修正
必要なパッケージをインストールする。

```sh
$ npm install module-alias
$ npm install -D @types/module-alias
```

適当なファイル(e.g. `fix-ts-paths.ts`)を作成し、以下の記述を行う

```fix-ts-paths.ts
const moduleAlias = require('module-alias');

moduleAlias.addAlias('@', __dirname + '/');
```

エントリーポイント(`index.ts`)にて以下の記述を追加する。

```index.ts
+ import './fix-ts-paths';
import { hoge } from '@/fuga'

hoge(); // 関数の呼び出し
```
これで Cloud Functions を起動すると、成功する。

#### 1-1-2. 環境構成

環境に依存する様々な変数などを設定する。以下の2種類の方法が用意されている。[^1]

[^1]: 第1世代では、`functions.config` によるランタイム環境構成が利用可能

1. `.env` に環境変数を記述し、このファイルをデプロイ対象に含める。
2. パラメータ化された構成

1 は、プログラム中で `process.env.{VARIABLE}` によりアクセスするお馴染みの方法であるが、**ランタイム時のみ利用可能となり、Cloud Functions 関数構成段階では利用不可となる**。そこで今回は、2 による手順を紹介する。

参考: https://firebase.google.com/docs/functions/config-env?hl=ja&gen=2nd#params

##### Pub/Sub をトリガーに Cloud Functions 関数を実行する場合

以下のコードは、Pub/Sub トピックにメッセージがあった時に関数が発行される例である。
`defineString` で指定した `PUBSUB_TOPIC_NAME` がトピック名を表す変数であり、これはランタイム環境変数ではなく、**デプロイ時に設定されているべき変数である。**

```index.ts
import { defineString } from 'firebase-functions/params';

const PUBSUB_TOPIC_NAME = defineString('PUBSUB_TOPIC_NAME');
const { onMessagePublished } = require('firebase-functions/v2/pubsub');

exports.pubsubFunction = onMessagePublished({ topic: PUBSUB_TOPIC_NAME }, async (event: any) => {
    // do something
}
```

デプロイコマンドを実行すると、`PUBSUB_TOPIC_NAME` に対する値(string)の入力を促される。

```sh
$ firebase deploy
...
i  functions: Loaded environment variables from .env.{projectId}.
? Enter a string value for PUBSUB_TOPIC_NAME: my-topic #ここに入力
```

その結果、`.env.{projectId}` というファイルが生成され、その中にキーバリューが格納された状態となる。(`projectId`は適宜読み替え)

```.env.{projectId}
PUBSUB_TOPIC_NAME=my-topic
```

デプロイが完了すると、`my-topic` という Pub/Sub トピックをトリガーに関数が実行されるようになる。

また、本手順による環境構成では、ランタイム時にも変数へのアクセスが可能である。
その場合、以下のように変数に`.value()`を追加する。

```index.ts
...
exports.pubsubFunction = onMessagePublished({ topic: PUBSUB_TOPIC_NAME }, async (event: any) => {
    // ランタイム時にもアクセスできる
    console.log(`トピック:${PUBSUB_TOPIC_NAME.value()}が実行されました。`)// トピック:my-topicが実行されました。
}
```

:::note info
上記手順では、デプロイ時に変数をインタラクティブに設定し、それを元に .env が生成した。
一方で、**最初から .env とその中身を用意しておくと、デプロイ時には入力を促されることなくそのままデプロイが完了し、デプロイ時およびランタイムでアクセス可能な変数となる。**
ちなみに、前者の方法では生成されるのは `.env.{projectId}` だが、後者の最初から用意しておく方法だと `.env` でも良い。（`.{projectId}`がなくても良い）
:::

### 1-2. デプロイ
#### 1-2-1. デプロイ直前に処理
`firebase init` によるプロジェクトの初期化時に自動で生成される `npm run deploy` スクリプトでデプロイを実行する際、諸々がサーバーにアップロードされる直前に任意の処理を走らせることができる。

```.firebase.json
{
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint",
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ],
    ..
  }
}
```

`functions.predeploy[]` に所望のコマンドを複数指定できる。
この例では、デプロイ直前に lint と ビルドを行なっている。

#### 1-2-2. デプロイ直後に処理
デプロイ処理終了の直後に、任意の処理を走らせることができる。

```.firebase.json
{
  "functions": {
    "postdeploy": [
      "rm -rf lib"
    ],
    ..
  }
}
```

`functions.postdeploy[]` に所望のコマンドを複数指定できる。
この例では、デプロイ直後に lib フォルダを削除している。

#### 1-2-3. デプロイ対象からの除外
デプロイ時、アップしたくない任意のファイル/ディレクトリをその対象から除外する。
`source` で指定したディレクトリからの相対パスで指定する。
ワイルドカードによる指定が可能。

```.firebase.json
{
  "functions": {
    "source": "functions",
    "ingore": [
      "**/src/**",
      ".*",
      "package-lock.json",
      "**/*.log"
    ],
    ..
  }
}
```
この例では、以下の項目がデプロイ対象から外される：
- `src` ディレクトリ以下全てのファイルおよびディレクトリ
- `functions/` 配下の `.` プレフィックスのついたファイル
- `functions/` 配下の package-lock.json
- `.log` サフィックスのついたファイル

#### 1-2-4. デプロイ失敗時の状況確認
以下のコマンドが役に立つ。

```sh
$ firebase functions:log
```
逆に言うと、デプロイコマンド `firebase deploy —only functions` が失敗した時の出力は役に立たない。

#### 1-2-5. デプロイと関数の諸設定
関数を削除してから再度デプロイする場合の
コンソール上の操作で Cloud Functions をデプロイすると、

#### 1-2-6. グループ関数
`{プレフィックス-関数名}`と言う形で関数をデプロイすることができる。
プレフィックスの部分は関数間で共通となる。
これにより、関数のデプロイや管理作業をシンプルにすることができる。

```func.ts
export const subFuncA = …
export const subFuncB = …
export const subFuncC = …
```

```index.ts
exports.func = require('./func');
...
```
この結果、デプロイされる関数は次の3つとなる：
- func-subFuncA
- func-subFuncB
- func-subFuncC

まとめてデプロイする場合は `firebase deploy --only functions:func` となり、
個別にデプロイ(e.g. subFuncA)する場合は、`firebase deploy --only functions:func-subFuncA` となる。

#### 1-2-７. Node.js ランタイムの指定

関数の Node.js ランタイムは時の経過とともに非推奨になる時がやってくる。
`functions/package.json` に設定を追加してランタイムのバージョンを指定することができる。

参考: https://firebase.google.com/docs/functions/manage-functions?gen=2nd#set_nodejs_version

```js:package.json
{
  "engines": {
    "node": "18"// 20 or 16 or..
  }
}
```




### 1-3. Cloud Functions 関数のエミュレート
#### 1-3-1. Pub/Sub トリガーによる関数の実行

前準備として、Pub/Sub エミュレーターを有効化する。

```sh
$ firebase init
# その後、メッセージと選択肢が表示されるため、 Emulators > Pub/Sub Emulator の順番でチェックを入れてエンター

# エミュレーターを立ち上げる
$ firebase functions:shell
firebase >
```
この状態で、export している関数を呼び出す。

```sh
# `firebase > ` の後に `関数名()` を入力してエンター
firebase > helloWorld()
# 以下のメッセージが表示されれば成功
'Successfully invoked function.'

# message payload を追加する
firebase > helloWorld({data: Buffer.from('{"key":"value"}')})
# payload が value 単体の場合はこのような形である
firebase > helloWorld({data: Buffer.from('value')})

# プログラム中ではバッファーからオブジェクトに変換して利用する
# const obj = String.fromCharCode.apply(null, [...new Uint16Array(event.data.message.data?.data)]);
# > {"key":"value"}
# const val = JSON.parse(obj).key;
# > value


**訂正: 下記は HTTPS 関数のトリガー時に有効であり、 Pub/Sub トリガーによる関数実行では不可**
# 単に`関数名()`とした場合は get メソッドで呼び出されるが、以下のように各種メソッド呼び出しに対応している。
#firebase > helloWorld.post()
#firebase > helloWorld.put()
#firebase > helloWorld.delete()
#...

# サブパスを指定もできる
#firebase > helloWorld.get('/sub-path')

# リクエストにデータを含めることもできる
#firebase > helloWorld.post().form( {foo: 'bar' })
```

:::note warn
`firebase functions:shell` 実行時、 次のようなエラーが出た場合は、プロジェクトがビルドされていない可能性があるため、先に `npm run build`　して確かめてみることをお勧めする。

```
$ firebase functions:shell
⬢  functions: Failed to load function definition from source: FirebaseError: There was an error reading functions/package.json:

 functions/lib/index.js does not exist, can't deploy Cloud Functions
i  functions: Loaded functions: 
No functions emulated.
```

:::

#### 1-3-2. Google APIs の認証方法
##### 認証に必要な key をコンソールから取得する。
https://firebase.google.com/docs/functions/local-emulator#set_up_admin_credentials_optional

##### 予約された環境変数に取得した key の相対パスを指定する。

```sh
export GOOGLE_APPLICATION_CREDENTIALS=<path/to/key.json>
```
ターミナルを起動する度にコマンドを打つのは非効率なので、スクリプトに入れてしまった方が良いかもしれない。

```package.json
{
  ...
  "scripts": {
    "serve": "export GOOGLE_APPLICATION_CREDENTIALS='<path/to/key.json>' && firebase emulators:start --only functions"
    ...
  }
}
```

API を使用する。

```ts
import { google } from 'googleapis';

const auth = await google.auth.getClient({
  scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});

const sheetsAPI = google.sheets({ version: 'v4', auth });
...
```

#### 1-3-3. Google Cloud Storage(GCS) へのファイルアップロードをトリガーにした関数の実行

前準備として、Storage エミュレーターを有効化する。

```sh
$ firebase init
# その後、メッセージと選択肢が表示されるため、 Emulators > Storage Emulator の順番でチェックを入れてエンター
```

GCS のどのバケットに対するアップロードを対象にするかを決める必要があるが、エミュレーター環境では **default-bucket** というバケットとなる。これをトリガー呼び出しの際に指定する。[^2]

[^2]: このページに記述があった: https://github.com/firebase/firebase-tools/issues/4014#issuecomment-1021609657
また、Storage エミュレーター起動時に生成される`firebase-debug.log`にもそれらしき証拠がひっそりとあった。

```index.ts
import * as functions from 'firebase-functions';

exports.foo = functions.storage
  .bucket('default-bucket')// ここにバケット名を指定する
  .object()
  .onFinalize(async (object) => {
    // do something

  });
```

本番環境など実際 GCP 上にデプロイして使用する際は、`functions.storage.bucket()`の引数は無しで良い。
その場合は、実行される Cloud Functions のデフォルトのバケットが設定される(`{projectId}.appspot.com`のような形となる)。
ちなみに、onFinalize 以外にも、onArchive, onDelete, onMetadataUpdate などのバリエーションがある。

```sh
# エミュレーターを立ち上げる
$ npm run serve

...
> View Emulator UI at http://localhost:4000  
```

ブラウザで Emulator UI にアクセスし、Storage emulator の画面に遷移し、そこに適当なファイルをドラッグ&ドロップすると上記の関数が実行される。


### 1-4. 実行

#### 1-4-1. デバッグ時に ts ファイルによるスタックトレースの表示

必要なパッケージをインストールする。
```sh
$ npm i source-map-support
```

`tsconfig.json`にて sourcemap を有効にする。

```json
{
  "compilerOptions": {
    "sourceMap": true,
  }
}
```

以下のどちらかをソースコードに下記を追加する。
```ts
// for ES6
import 'source-map-support/register';

// for commonJS
require('source-map-support').install();
```

これでエラー発生時、コンパイル前の ts ファイルで原因箇所を特定できるようになる。

#### 1-4-2. Cloud Functions の再試行

再試行(retry)を有効化するには、以下のような方法がある。
- Cloud Functions コンソール上で設定
- Cloud Functions 関数ソースに記述(**推奨**)

retry を設定する上で考慮すべき点としては[こちら](https://firebase.google.com/docs/functions/retries?hl=ja#set_an_end_condition_to_avoid_infinite_retry_loops)。

> 再試行を使用する場合は、関数が連続ループに陥らないように保護することをおすすめします。そのためには、明確に定義された終了条件を含めてから関数の処理を開始します。この手法が機能するのは、関数が正常に開始し、終了条件を評価できる場合のみです。

つまり、最初のイベントが発生してからどれくらい後まで retry を許容するかを設定しておくのがベターである。

Pub/Sub トリガーによる Cloud Functions 関数で retry の設定を記述する。

```index.ts
export const pubsub = functions
  .runWith({ failurePolicy: true }) // **retry を有効化**
  .pubsub.topic("pub_sub_topic_name")
  .onPublish(async (message, context) => {
    // retry 期限を2分間とする処理
    const eventAgeMs = Date.now() - Date.parse(context.timestamp);
    const eventMaxAgeMs = 1000 * 60 * 2;
    if (eventAgeMs > eventMaxAgeMs) {
      console.log(`Dropping event '${context.eventId}' with age[ms]: ${eventAgeMs}`);
      return;
    }
    // do something
```

:::note warn
Cloud Functions 関数内でエラー発生時に catch していると、retry がされないため注意する。
また、イベントドリブンではない、http 起動の関数に対してはそもそも retry されない。
:::

### 1-5. その他

#### 1-5-1. ローカル環境でのプロジェクトの切り替え

ログイン中の google アカウントに紐づくプロジェクトが複数ある場合、それらを切り替える。

```sh
# プロジェクトID が <project_id> のプロジェクトに切り替える
$ firebase use <project_id>
```

プロジェクトID その他のプロジェクトに関する情報は以下のコマンドで確認可能

```sh
$ firebase projects:list
```

出力される内容のうち、**Project ID** に `current` が付加されているものが現在 Firebase CLI で利用しているプロジェクトである。

また、current プロジェクトは変更せず、単体のコマンドにのみ特定のプロジェクトを指定したい場合は、以下のようにコマンドにオプション(`--project`)を付与して実行する。

```sh
# プロジェクトID が <project_id> のプロジェクトに Cloud Functions をデプロイする
$ firebase deploy --only functions --project=<project_id>
```