こんにちは、マネーフォワードケッサイでフロントエンドなエンジニアをしている@miki_tです。
社内向けサービスなど一部のサービスで、バックエンド開発を簡素化できる場合にFirebaseを技術選択しています。 ちょうどこのblogも、Firebase hostingで公開しています。
今日は、Firestoreのデータパッチについてちょうどいい仕組みが欲しく、考えてみたので書いてみようと思います。
Firestoreにデータパッチを当てたいモチベ
Firebaseを利用してコスパ良くアプリケーション開発する場合、データベースはFirestoreになると思います。 十分に高機能なクエリが書けますが、やはりNoSQLなのでRDBと同じ感覚でいるとハマる時があります。
例えば、既存のCollectionにFieldを追加する修正を行なった場合に、何もしなければ既存レコードにはFieldは追加されません。 また、新しいFieldでソートをかけても、当該Filedを持たないDocumentはヒットしません。
アジャイルに機能開発をしていくと、データ構造や既存データの変更をしたいなんてことは日常的に起こると思います。
- やっぱりFiledを追加したい
- documentの保存条件を変えよう、この条件の既存データは消したい
- バグで不正なデータを作ってしまった。修正しなければ…
開発環境やカジュアルに開発するシーンでは、FirebaseコンソールからGUIで直接編集したり、 強引に実行コード書き換えて更新したり 出来ます。
しかし、本番環境においてそのデータが最終的にユーザーへの提供価値に影響する可能性がある場合、安全にかつ証跡も残して…と考慮することも増えてきます。
MySQLのようにSQLで直接流せれば、これまでの社内フローと同様に出来ますが、firestoreとなると少し勝手が変わります。
本番のFirebaseプロジェクトの管理者権限をとって、ペアオペで慎重に…というのも、そわそわしてしまいます。 また、対象データが多い場合、GUIでポチポチし続けるわけにはいきません、人間はミスオペするものです。 (Firebaseコンソールと、GCPコンソールで、FirestoreのUIが微妙に違うのも個人的そわそわポイントです)
もっと、安心で楽な方法はないものでしょうか。
コスパ良いデータパッチ機構の要件
さて、安心してデータパッチする仕組みが欲しいところですが、私たちは楽をするためにFirebaseを使っているのです。 そう、限りなく手を抜いて安心を手に入れたいのです…!!
改めて要件を考えてみます。
- 証跡が残ること
- 実行ユーザー、実行時間、実行結果 が無期限で蓄積され、必要に応じて検索可能であること
- 簡単にデータパッチが実現できること
- 安心して実行できること
実装
全体像はこんな感じ。
Cloud Functions
Cloud Functions for Firebaseを利用します。 functions.https.onCallを使用してHTTPS呼び出し可能関数を定義すると、認証情報も取得できるので、実行ユーザーは残せそうです。
// functions/src/index.ts
export const runDatapatchScript = functionsOnTokyo
.runWith({ timeoutSeconds: 540 })
.https.onCall(async (data: DataPatchScriptParams, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'failed-precondition',
'The function must be called while authenticated.'
)
}
...
const patchScript = getDataPatchScript(data) // 対象のスクリプトを解決
const result = await patchScript.exec()
return result
})
Cloud Functionのtimeoutを考慮して、上限を延長しておきます。
Logging
ロギングはCloud Functions for Firebaseで 自動的にCloud Loggingへ出力されますが、 保存期間が有限なので、BigQueryへ転送させて無期限に蓄積します。
Sink設定するだけ。ポチッとな。 呼び出し口を、1つのonCall関数(runDatapatchScript)に絞ったので、フィルタ設定もシンプルです。
スクリプト機構(TypeScript)
あとは、実行したいスクリプトを書くだけと言いたいところですが、いくつか考慮する点があります。
- 正しくロギングすること
- できれば、実行前にお試しして本番環境での対象データを確認したい。(dryrun)
ここは、抽象クラス(abstract class)
を利用して、
めんどくさい実装を事前に定義しておきます。
最近はReactの文脈ではReact hooksの登場からclass構文の利用も減ってきましたが、 今回のように実装を強制したいシーンでabstract classは便利です。
少し長くて恐縮ですが、まるっと載せます。
import * as firebaseAdmin from 'firebase-admin'
import * as functions from 'firebase-functions'
export type DataPatchScriptParams = {
name: string
dryrun: boolean
}
export abstract class DataPatchScript {
protected readonly db
protected readonly isDryrun: boolean
constructor(params: DataPatchScriptParams) {
this.db = firebaseAdmin.firestore()
this.isDryrun = params.dryrun ?? true
}
private executionLogs = []
private logging(...logs: string[]) {
if (!this.isDryrun) {
functions.logger.log(...logs)
}
Array.prototype.push.apply(this.executionLogs, logs)
}
/**
* 更新対象のデータ(Doc)
*/
abstract targetDocsQuery(): FirebaseFirestore.Query<FirebaseFirestore.DocumentData>
private targetDocRefs: FirebaseFirestore.DocumentReference[] = []
/**
* データパッチしたい更新処理
* @param transaction
* @param snapshot
*/
abstract updateFunction(
transaction: FirebaseFirestore.Transaction,
snapshot: FirebaseFirestore.QuerySnapshot<FirebaseFirestore.DocumentData>
): Promise<unknown>
beforeLog(
documentSnapshots: FirebaseFirestore.DocumentSnapshot<FirebaseFirestore.DocumentData>[]
): void {
this.logSnapshot(documentSnapshots, 'before')
}
afterLog(
documentSnapshots: FirebaseFirestore.DocumentSnapshot<FirebaseFirestore.DocumentData>[]
): void {
// DELETEを行った場合、refに対してundefinedが返るので対象から外す。
const snapshots = documentSnapshots.filter((doc) => !!doc.data())
this.logSnapshot(snapshots, 'after')
}
private logSnapshot(
documentSnapshots: FirebaseFirestore.DocumentSnapshot<FirebaseFirestore.DocumentData>[],
step: 'before' | 'after'
): void {
const log: string[] = [`${step} snapshot is below:`]
documentSnapshots.forEach((doc) => {
log.push(
JSON.stringify({
documentId: doc.id,
data: doc.data(),
})
)
})
log.push(`total count: ${documentSnapshots.length}`)
this.logging(...log)
}
async exec(): Promise<string> {
this.logging(
`start: ${this.constructor.name}${this.isDryrun ? ' as dryrun' : ''}`
)
const snapshot = await this.targetDocsQuery().get()
this.targetDocRefs = snapshot.docs.map((doc) => doc.ref)
this.beforeLog(snapshot.docs)
if (!this.isDryrun) {
await this.db.runTransaction(
async (transaction) => await this.updateFunction(transaction, snapshot)
)
const afterSnapshot = await this.db.getAll(...this.targetDocRefs)
this.afterLog(afterSnapshot)
}
this.logging(
`finish: ${this.constructor.name}${this.isDryrun ? ' as dryrun' : ''}`
)
return this.executionLogs.join('\n')
}
}
abstract methodとして定義したのは、2つです。
- targetDocsQuery
- 対象のCollectionとDocを絞り込むwhere条件を記述
- updateFunction
- 1で抽出した対象に対して、実際にwrite処理をするデータパッチのメイン部分を記述
継承側の利用例は後述します。
UI
認証を挟みたいので、アプリケーションに簡易な管理画面を実装しました。 この画面には、開発者ユーザーのみアクセスできるように制御します。
UIは手を抜いてシンプルに。
onCall呼び出して、結果(実行ログ)を画面に返します。
const execDatapatch = async () => {
const runDatapatch = functions.httpsCallable('runDatapatchScript')
runDatapatch({ name: datapatchName, dryrun: dryRun })
.then((result) => {
setDatapatchResult(result.data)
})
.catch((e) => {
setDatapatchResult(e.message)
})
}
実際にDatapatchを適用する
さて、準備は整いました。
思ったより前置きが長くなりましたが、早速データパッチを実装してみたいと思います。
スクリプトを実装
今回は、既存のdocument全てから、不要になったold_field
を除去したいと思います。
import { DataPatchScript } from './abstractDatapatchScript'
import * as firebaseAdmin from 'firebase-admin'
export class UpdateMyCollection_20210910 extends DataPatchScript {
targetDocsQuery(): FirebaseFirestore.Query<FirebaseFirestore.DocumentData> {
return this.db
.collection('my_collection')
.orderBy('old_field')
}
async updateFunction(
transaction: FirebaseFirestore.Transaction,
snapshot: FirebaseFirestore.QuerySnapshot<FirebaseFirestore.DocumentData>
): Promise<void> {
snapshot.docs.forEach((doc) => {
transaction.update(doc.ref, {
old_field: firebaseAdmin.firestore.FieldValue.delete(),
})
})
}
}
ややこしい事は、abstract classに実装したので、「何に」「何を」だけの実装になります。
IDEでabstract methodは自動補完できるので、実際の記述は数行だけでした。
テストやコードレビューもしやすいですね。
UIから実行したいスクリプト名を文字列で渡すようにしたので、名前解決をします。 (ここはもう少し工夫の余地はありそうです…!)
export const getDataPatchScript = (
params: DataPatchScriptParams
): DataPatchScript | undefined => {
switch (params.name) {
case UpdateHogeHogeCollection.name:
return new UpdateHogeHogeCollection(params)
case UpdateMyCollection_20210910.name:
return new UpdateMyCollection_20210910(params)
// ...
default:
return
}
}
コードレビュー
実装したスクリプトをPRでレビューしてもらいます。 事前のコードレビュー(= ダブルチェック)と、可能な操作をスクリプトの実行のみに制限させたので、 本番適用のペアオペを不要にできました。
画面から実行
今回は、実行対象を間違えないよう、スクリプト名(class名)をわざわざ
入力させるUIにしました。
心配性なので、dryrunで意図通りの対象が拾えてるか確認します。
改めて、ポチっとな。
以上です。
おわりに
仕組みの構築にひと手間かかりましたが、これでもし変更が必要になっても、気軽に安心して変更できるという前提が手に入りました。
新しいFieldの追加や、既存Fieldの値の変更(castなど)、特定のdocumentの削除といったデータパッチが簡単に用意できるようになります。
これで、より機能開発のスピードをあげて、価値を届けていけそうです。
Firebase / Firestoreは、うまく扱うと開発を大幅に削減できるので、要件次第では強力なツールだと思います。 一方で、監査証跡を残す必要があるようなユースケースでは、ちょっとした工夫も必要になります。
マネーフォワードケッサイでは、「ユーザーに価値を届ける」ことに共感して、最適な技術を取り入れるエンジニアリングを進めています。
募集しているポジション情報もこちらで公開しています。 ご興味あれば見てみてください。カジュアル面談もご気軽にお待ちしてます。 https://mfkessai.co.jp/corp/recruit