Firestoreをサーバサイドで使うTips

# はじめに
Firestore、便利ですよね。大好きなGoogle Cloudプロダクトのひとつです。
ネイティブモードで利用して、**Web or モバイルアプリからmBaaS的に使うのが鉄板**だと思います。

https://firebase.google.com/products/firestore/?hl=ja

ですが例えば**バッチジョブでデータを洗い替えたりする場合**等、サーバから直接触りたくなる状況もちょくちょくあるかと思います。

直近、サーバからFirestoreを触る機会がそこそこあったので、その中で得た備忘録的なものをまとめました。
**サンプルコードはGo**ですが、他の言語でも参考になる部分も多いかと思います。
思いつき次第随時更新いたします👨‍💻

# Tips一覧
## 1. ローカル環境からFirestoreを利用する

Firestoreを組み込んだサーバアプリケーションをローカルで開発する場合、直接開発環境のFirestoreに繋ぎ込む方法と、Firestoreエミュレータを利用する方法があります。

### 開発環境のFirestoreに直接接続する

手元の環境から直接Firestoreに繋ぎ込む際のクレデンシャルは、サービスアカウントキーを利用する方法よりも、`gcloud auth application-default login`を利用するのがセキュリティ上健全かなと思います。
以前書いた下記の記事が参考になると思います。

https://qiita.com/kawamou/items/5e71c0b2124f6650bc46

### ② Firestoreエミュレータと接続する

Firebaseプロダクトにはローカルで実行できるエミュレータがあり、Firestoreも例に漏れず、サーバアプリケーションから利用可能です。

https://firebase.google.com/docs/emulator-suite/connect_firestore?hl=ja#admin_sdks

エミュレータのデータは全てメモリ上に載っており、立ち上げるたびに初期化されてしまいます（[永続化する方法](https://qiita.com/tanabee/items/2ce4d50e5ea320beb0cf)もアリ）。
そのため**Firestoreとの繋ぎ込みをモックする用途等で利用できます**。

繋ぎ方は簡単で、環境変数`FIRESTORE_EMULATOR_HOST`で指定したホストにエミュレータを立ち上げれば、SDKが環境変数を勝手に読み込み接続してくれます。

```sh:example
export FIRESTORE_EMULATOR_HOST="localhost:8000"
```

以前作ったWeb APIのサンプルコードが下記です。
DBとしてFirestoreエミュレータを利用しています。
Firestoreエミュレータを8000番に立ち上げて、8080番に立ち上げたGoのアプリケーションから`FIRESTORE_EMULATOR_HOST`越しに接続するようになっています。

https://github.com/kawamou/my-layered-architecture

参考までに、同リポジトリから`Dockerfile`と`docker-compose.yml`の一部を転記します。

```Dockerfile.firestore
FROM node:alpine
RUN apk add openjdk11
RUN npm install -g firebase-tools
WORKDIR /app
CMD [ "firebase", "emulators:start", "--only", "firestore" ]
```

FirestoreエミュレータのプロジェクトIDは何でもOKです。
**適当な任意の値（hogeでもfooでも）を入れておけば動きます**。
Goのアプリケーションには、`environment`として`PROJECT_ID`と`FIRESTORE_EMULATOR_HOST`を渡しています。

```docker-compose.yml

version: "3.8"
services:
  firestore:
    build:
      context: .
      dockerfile: Dockerfile.firestore
    ports:
      - 8000:8000
    volumes:
      - .:/app
  app:
    build:
      context: .
      target: dev
      dockerfile: Dockerfile
    ports:
      - 8080:8080
    environment:
      PROJECT_ID: foo
      FIRESTORE_EMULATOR_HOST: firestore:8000
    volumes:
      - .:/go/src/app
```

## 2. 指数バックオフを組み込む

サーバアプリケーションでは**エラーが生じた際のリトライが重要**です。
Google CloudのSDKでは透過的にリトライしてくれるエラーもありますが、例えばAPI上限に達した際の`ResourceExhausted`等は自前でリトライを組み込む必要があったりします。
リトライの戦略としては指数バックオフが一般的です。

>指数バックオフは、失敗したリクエストをクライアントが再試行する際、失敗するごとに次の再試行までの待ち時間を増やしていく処理です。これは、ネットワークアプリケーションに使われる標準的なエラー処理方法です。

[指数バックオフの実装](https://developers.google.com/tag-manager/api/v2/errors?hl=ja#backoff)

Cloud TasksやCloud Monitoring等のクライアントライブラリの場合、指数バックオフに関するオプションを引数で設定できます。

```go:example
ListTasks(ctx, req, gax.WithRetry(DefaultRetryOption))
```

https://blog.lufia.org/entry/2021/02/20/222427

一方、Firestoreのクライアントライブラリには指数バックオフのオプションがありません。
そのため**Firestoreからのレスポンスを検証し任意のエラー時にリトライするような処理**を実装する必要があります。
頑張って自前で実装するか、先人が作った指数バックオフのパッケージを利用すると良いかと思います。
Goの場合、`github.com/cenkalti/backoff`等があります。

https://christina04.hatenablog.com/entry/exponential-backoff

指数バックオフを組み込む方法はググればいくらでも出てくるのでサンプルコードは割愛します。
実装の基本方針は、

- Firestoreから返ってきたエラーを[gRPC status code](https://qiita.com/Hiraku/items/0549e4cf7079d22b27e8)に変換する（`code := status.Code(err)`）
- リトライ対象のコードだった場合リトライ（`if code == codes.ResourceExhausted`）

といった感じです。

Firestoreのリトライすべきエラーは公式ドキュメントに載っているので、実装の際、参考になるかと思います。

[<img width=640 src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/e5b83a67-c600-3105-b6cc-8173c8cc044a.png">](https://firebase.google.com/docs/firestore/use-rest-api?hl=ja#error_codes)


https://firebase.google.com/docs/firestore/use-rest-api?hl=ja#error_codes

## 3. 大量データを高速で書き込む

Firestoreはシリアルにデータを書き込むより`Batch`で書き込んだ方が早いです。
更に速度を目指す場合、**並列で書き込む必要があります**。
シリアル書き込み < Batch書き込み <<< 並列Batch書き込みです。

> データを一括入力するには、並列化された個別の書き込みでサーバー クライアント ライブラリを使用します。バッチ書き込みは、シリアル化された書き込みより優れたパフォーマンスを発揮しますが、並列書き込みほど優れてはいません。一括データ オペレーションには、モバイル / ウェブ SDK ではなく、サーバー クライアント ライブラリを使用する必要があります。

https://cloud.google.com/firestore/docs/manage-data/transactions?hl=ja#batched-writes

下記の記事にあるように、API制限に気を付けつつ、指数バックオフで適切にエラーを再試行しながら並列で書き込むのが正攻法です。
Goであれば、`goroutine`の並列数をチャネル等で制御しつつ大量投入を行うことになるかと思います。

https://qiita.com/syagi/items/298efd47c6757843e0eb

## 4. 接続周りのオプションを設定する

FirestoreのgRPC接続オプションは`firebase.NewApp()`のコンストラクタ時に引数で渡せます。
自分は下記の記事を参考に、コネクションを同期的に張るために`grpc.WithBlock()`を渡す等しています。

[![thumbnail](https://files.speakerdeck.com/presentations/f88e0e08d82343e9883f5719d182346d/slide_54.jpg)](https://speakerdeck.com/110y/introduce-cloud-run?slide=55)
[Introduce Cloud Run](https://speakerdeck.com/110y/introduce-cloud-run?slide=55)

その他`KeepAlive`に関する設定等も渡すことができます。
必要に応じて設定すると良さそうです。

https://tomokazu-kozuma.com/what-to-do-when-a-connection-error-occurs-with-a-service-using-grpc-such-as-cloud-pubsub/

## 5. パフォーマンスを測定する

FirestoreのパフォーマンスはKey Visualizerで分析できます。
手前味噌ですが、Key Visualizerに関する記事を以前書きましたので参考になるかもです。

https://qiita.com/kawamou/items/dbb81473d63bdedf2967

## 6. 監査ログを取得する

Firestoreに誰がアクセスしたかは、監査ログを追うことで確認できます。
こちらも手前味噌ですが、以前の記事です。よろしければ！

https://qiita.com/kawamou/items/11a05aeeeee40cceb20e

## 7. デフォルトのタイムアウトを回避する（Go限定）

Firestoreに限らずなのですが、Google CloudのGo SDKにはデフォルトのタイムアウトが設定されています。
下記にFirestoreの`Commit`メソッドについて抜粋しますが、60,000ミリ秒のタイムアウトが設定されています。

```go
func (c *gRPCClient) Commit(ctx context.Context, req *firestorepb.CommitRequest, opts ...gax.CallOption) (*firestorepb.CommitResponse, error) {
	if _, ok := ctx.Deadline(); !ok && !c.disableDeadlines {
		cctx, cancel := context.WithTimeout(ctx, 60000*time.Millisecond)
		defer cancel()
		ctx = cctx
	}
	...
}
```

https://github.com/googleapis/google-cloud-go/blob/0300d0cf7e473a4a23d1089b3c8069ef28638f4c/firestore/apiv1/firestore_client.go#L650

大量書き込み等時間のかかるリクエストを行なった場合、稀にタイムアウト時間に達する場合があります。
回避の方法は公式ドキュメントにも記載されており、自分でタイムアウトを設定するか（自分で設定したタイムアウトがデフォルトタイムアウトを上書く）、環境変数`GOOGLE_API_GO_EXPERIMENTAL_DISABLE_DEFAULT_DEADLINE`を設定する必要があります。

```go
os.Setenv("GOOGLE_API_GO_EXPERIMENTAL_DISABLE_DEFAULT_DEADLINE", "true")
```

https://cloud.google.com/go/docs/reference/cloud.google.com/go/latest#timeouts-and-cancellation

# 最後に

Key Visualizerがリリースされたり監査ログが充実する等、運用面の機能も充実してきたので、今後もしっかりキャッチアップしながらFirestoreと付き合っていこうと思います！