本番環境のCloud Firestoreにデータパッチを当てる

こんにちは、マネーフォワードケッサイでフロントエンドなエンジニアをしている@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つです。

  1. targetDocsQuery
    • 対象のCollectionとDocを絞り込むwhere条件を記述
  2. 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