Firebase Advent Calendar 2019 の17日目です。16日目はKesin11さんの「Firebase Emulator Suiteをフル活用してTDDで開発しよう」でした。
はじめに
FirebaseプロジェクトでCloud Firestoreを利用する時は通常Node.jsによるCloud Functionsでトリガーとなる処理を記述します。その他には関連するAPIサーバー、WebアプリのフロントエンドのSSR、バックエンドの非同期処理など、多くの場面でCloud Functionsが活用されています。
この開発→デプロイサイクルをお手軽に行ってくれるのがfirebase-toolsというnpmモジュールです。JavaScriptでFunctionを実装し、firebase deployコマンドを実行するだけでFirebaseプロジェクト用のCloud Functionsが自動で登録されます。
解決したい問題
firebase-toolsは便利に使っていたのですが、私たちのプロジェクトでは日々いくつものCloud Functionsのモジュールが増えてゆく過程で以下の問題が発生しました。
- デプロイ対象となるソースコードの量が増え、デプロイ完了までの時間がかかるようになった(--onlyオプションを付けても遅い)
- SSRを含むフロントエンド向けビルド、APIサーバーとして機能するFunction、Firebase固有の処理、などの依存が単一の package.json で管理されていてモジュール更新の影響が大きかった
- 複数のFirebaseプロジェクトでプライベートリポジトリにある自作モジュールを共有したかった
- Cloud Tasksや複数Functionへの分散した水平スケールでは実現できない、リアルタイムの大量データ処理を垂直方向にスケーリングしたかった
そこで私たちはまずはじめにfirebase-toolsの仕組みやCloud Functionsがどのように動作しているのかを理解して、最終的にfirebase-toolsで構成していたFunctionのビルドを徐々にマイクロなモジュールに分割していくことにしました。
Cloud Functions for Firebaseの仕組み
Cloud Functions for Firebase*1
Cloud Functionsは - リソース - イベント - 関数名
という組合せでFunctionを管理しています。特定のリソースAに発生したイベントαにアップロードしてビルド済みのプログラムから指定の関数を実行します。
/** * [Google Cloud Firestore トリガー](https://cloud.google.com/functions/docs/calling/cloud-firestore?hl=ja) */ const Firestore = require('@google-cloud/firestore'); const firestore = new Firestore({ projectId: process.env.GCP_PROJECT, }); exports.makeUpperCaseOne = (data, context) => { const {resource} = context; const affectedDoc = firestore.doc(resource.split('/documents/')[1]); const curValue = data.value.fields.original.stringValue; const newValue = curValue.toUpperCase(); console.log(`Replacing value: ${curValue} --> ${newValue}`); return affectedDoc.set({ original: newValue, }); };
という関数を持つ index.js のみのコードを用意して
# package.json を生成する npm init && npm i @google-cloud/firestore gcloud functions deploy makeUpperCaseOne --runtime nodejs8 \ --trigger-event providers/cloud.firestore/eventTypes/document.create \ --trigger-resource "projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{docId}"
というコマンドでデプロイすると(gcloudコマンドは別途セットアップします)
projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{docId}
のリソースに対して
providers/cloud.firestore/eventTypes/document.create
というイベントが発生した時に
関数 makeUppercaseOne()
をトリガーする。
というCloud Functionsのリソースが登録されます*2。
試しにコンソールからFirestoreのドキュメントを作成してみます
実行されドキュメントが更新されました。
同等の処理を行うFunctionをCloud Functions for Firebaseで実装してみることにします。
以下のようにfirebase-functionsモジュールを使ってFunctionを定義すると
const functions = require('firebase-functions'); exports.makeUppercase = functions.firestore.document('/messages/{documentId}') .onCreate((snap, context) => { const original = snap.data().original; console.log('Uppercasing', context.params.documentId, original); const uppercase = original.toUpperCase(); return snap.ref.set({uppercase}, {merge: true}); });
firebase deploy --only functions
の内部で先程のgcloudコマンド同様のリソースができるわけです。
gcloud functions deploy makeUpperCase --runtime nodejs8 \ --trigger-event providers/cloud.firestore/eventTypes/document.create \ --trigger-resource "projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{docId}"
元のFunction用のコードからソースコードをそのままに1つの関数だけを抽出して、フォルダ構成としてはこのような独立したnpmパッケージとして管理できるようになります
functions/makeUppercase/ ├── index.js ├── node_modules/ ├── package-lock.json └── package.json
Goランタイムへの置き換え
Cloud Functionsへ直接デプロイすることができたので、次にFunctionをGo言語で実装してCPUメモリあたりの実行速度の高速化を図ります。ボトルネックがI/Oではない典型的なデータ処理のFunctionはこれだけでスループットが向上が期待できます。
var projectID = os.Getenv("GCLOUD_PROJECT") var client *firestore.Client func init() { conf := &firebase.Config{ProjectID: projectID} ctx := context.Background() app, err := firebase.NewApp(ctx, conf) if err != nil { log.Fatalf("firebase.NewApp: %v", err) } client, err = app.Firestore(ctx) if err != nil { log.Fatalf("app.Firestore: %v", err) } }
更新した値をFirestoreに書き込み更新する場合、Firestore Clientの準備をします。
init()
はランタイムにより指定されているインスタンス毎の初期化処理で、Node.js版でいうグローバル変数にセットアップ済みの値を入れておく方法を似ています。
type FirestoreEvent struct { OldValue FirestoreValue `json:"oldValue"` Value FirestoreValue `json:"value"` UpdateMask struct { FieldPaths []string `json:"fieldPaths"` } `json:"updateMask"` } type FirestoreValue struct { CreateTime time.Time `json:"createTime"` Fields Message `json:"fields"` Name string `json:"name"` UpdateTime time.Time `json:"updateTime"` } type Message struct { Original struct { StringValue string `json:"stringValue"` } `json:"original"` } func MakeUpperCaseGo(ctx context.Context, e FirestoreEvent) error { fullPath := strings.Split(e.Value.Name, "/documents/")[1] pathParts := strings.Split(fullPath, "/") collection := pathParts[0] doc := strings.Join(pathParts[1:], "/") curValue := e.Value.Fields.Original.StringValue log.Printf("Uppercasing: %q", curValue) newValue := strings.ToUpper(curValue) data := map[string]string{"original": newValue} _, err := client.Collection(collection).Doc(doc).Set(ctx, data) if err != nil { return fmt.Errorf("Set: %v", err) } return nil }
Function本体の処理です。Go言語の型システムの仕様上Firestore内の値のパース処理を、Node.js版でいう firebase-functions
にあたるユーティリティがないためドキュメントパスの解決などを自分で実装する必要があります。
デプロイします。
gcloud functions deploy MakeUpperCaseGo --runtime go111 \ --trigger-event providers/cloud.firestore/eventTypes/document.write \ --trigger-resource "projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{documentId}"
func TestFunc(t *testing.T) { var projectID = os.Getenv("GCLOUD_PROJECT") jsonStr := `{ "original": {"stringValue": "hello"} }` var message Message var err error err = json.Unmarshal([]byte(jsonStr), &message) if err != nil { log.Fatal(err) } value := FirestoreValue{ Name: "projects/" + projectID + "/databases/(default)/documents/messages/1", Fields: message, } result := MakeUpperCaseGo(context.Background(), FirestoreEvent{Value: value}) if result != "HELLO" { t.Error() } }
動作確認をGoのユニットテストとして記述できます。
写真のリサイズFunctionをGoで実装してみる
Functionのイメージ*3
ユーザーがアプリケーションから写真を登録したらシステムで必要なサイズの画像を自動で生成するようなFunctionをGoに置き換えてみます。
- Document更新トリガで関数を実行
- パースしてきたURLから画像をダウンロードしてくる
- リサイズを実行
- 画像をCloud Storageに保存
という一連の流れです
conf := &firebase.Config{ProjectID: projectID} opt := option.WithCredentialsJSON([]byte(os.Getenv("SERVICE_ACCOUNT_JSON"))) app, err := firebase.NewApp(ctx, conf, opt)
Firebase Admin SDKの初期化時にクレデンシャルを含むJSONファイルのパスを指定するのではなく、環境変数から読み込むようにします(confも同じ形式にできるのですが、秘密情報を含まないためコードで指定しています)。
gcloud functions deploy Resizing --set-env-vars SERVICE_ACCOUNT_JSON=$(cat secretkey.json)
// var fbStorage *storage.Client fbStorage, err = app.Storage(ctx) if err != nil { log.Fatalf("app.Firestore: %v", err) }
Cloud Storageのクライアントも init() で初期化しておきます。
type User struct { ProfileImageUrl struct { StringValue string `json:"stringValue"` } `json:"profile_image_url"` }
ドキュメントの構造体をこのように定義しました。profile_image_url
というキーに画像がアップロードされたURLが保存されます。
func Resizing(ctx context.Context, e FirestoreEvent) error { url := e.Value.Fields.ProfileImageUrl.StringValue cli := http.Client{} resp, err := cli.Get(url) if err != nil { log.Fatal(err) } src, _, err:= image.Decode(resp.Body) if err != nil { log.Fatal(err) } size := 320 img := imaging.Resize(src, size, 0, imaging.Lanczos) encoded := &bytes.Buffer{} jpeg.Encode(encoded, &*img, nil) bucket, err := fbStorage.Bucket(bucketName) if err != nil { log.Fatal(err) } path := fmt.Sprintf("resized_images/%dx.jpg", size) obj:= bucket.Object(path) writer := obj.NewWriter(ctx) io.Copy(writer, encoded) defer writer.Close() return nil }
Function本体です。imaging(https://github.com/disintegration/imaging )を使い 320x
にリサイズしてアップロードします(別途Storageへの追加をトリガーにしてUserドキュメントにパスをセットします)
検証してみたところNode.js版での公式ドキュメントでの解説にあるようなImageMagickのconvertコマンドを外部で実行するような方法*4と比べて、ファイルに書き出しがない部分がうまく効いて大量のリサイズが一度の実行でできそうでした(リサイズ処理の品質に差がでるかもしれないので別途評価が必要です)。
デプロイ速度やFunction起動速度について
Cloud Functionsのデプロイはローカルにあるソースコードを対象として、依存モジュールが記述されている package.json
や go.mod
から自動的にクラウド環境でビルドが走るようです。
firebase-tools
を使ったデプロイを行っていた時は、functions/
にあるすべてのファイルが対象になり1つのFunctionのリソースへアップロードされていました(GCPコンソールからアップロード済みファイルが取得できるので確認できます)。
またFirebaseユーザーの間でFunctionの高速化テクニックとして環境変数から探索して、Node.jsの依存モジュールの動的読み込み制御する方法が知られています。*5
これらの方法と比べてアーキテクチャ的に改善する可能性はあるなと思いつつも、まだ安定性やアーキテクチャの評価中なので詳しくは比較できていない状態です。
ビルド+デプロイツールの改善
firebase-toolsを使わくなることで、複数のFunctionの依存を管理するための方法や開発やデプロイを楽にする方法を別途用意しなければいけません。
Functionを分割して複数の依存を管理するパッケージができたことで、ソースコードはmonorepoの状態になります。そのためlerna(https://lerna.js.org/ )やBazel(https://www.bazel.build/ )のようなツールが機能する環境になるかもしれません。
ただfirebase-toolsを使った環境は並行して維持できるので、段階的に移行して検討するつもりです。
GCPサービスを使ったさらなるFirebaseアプリケーションの拡張
※Firebase & Google Cloud Platform*6
Cloud FunctionsはGoランタイムの他にはPythonランタイムもあり、そちらでも同様にトリガーFunctionが書けるので何か活用法があるかもしれません。
また各FirebaseやGCPサービスには対応したREST APIが公開されていることも多く、SDK対応言語以外でもクライアントを自作して拡張することができます。
もちろんCloud Functionsですべてを行うことにこだわらずとも、HTTPリクエストをトリガーとしたAPIサーバーのFunctionや、SSRを実行して動的HTMLを返す常に待ち受けしているFunctionは、同時処理数に優れたCloud Runに移すことができそうです。
またCloud Run同士で連携して(RESTやunary gRPC)サーバー間通信でシステムを拡張の目的でも利用できそうです。
他にはCloud SchedulerとCloud Tasks、Cloud Dataflowを使ったバッチ処理。Firebase AnalyticsとCloud FirestoreをBigQueryにエクスポートして分析し、その結果をシステムに反映させるなどを私たちも既に行っています。
まとめ
このようにFirebaseはGCPの既存の仕組みを使い易くラップしたものなので、必要に応じてGCPのリソースを活用して最適化することができます。
Firebase Advent Calendar 2019 - Qiita
明日の担当はVexus2さんです。お楽しみに。