🔖

Firestoreのマイグレーションツールを自作した

2024/12/14に公開

はじめに

株式会社スーパーハムスターでエンジニアをしているお茶です。
開発ではよくFirestoreを使うことがあります。
Firestoreで開発を行っている際、DB設計の変更などでスキーマが変わった時に既存のドキュメントとDB設計変更後のドキュメントに差異が生まれてしまうケースがよくあります。

RDBなどではマイグレーションを行えば解決しますが、Firestoreにはマイグレーションの仕組みがないので自作することにしました。

Firestore

FirestoreはGoogleが提供するドキュメント指向のNoSQLデータベースです。コレクションとドキュメントという入れ子構造のデータモデルでJSONのような形でデータを扱うことができます。

Firestoreにおけるスキーマの変更

NoSQLでJSONのような形式で柔軟にデータを突っ込めますが、やはり堅実な開発には適切にデータベース設計を行いセキュリティルールなどでスキーマを守った上で型安全に開発を行いたいです。
しかし、FirestoreはJSONのような形のドキュメントをどんどん追加していく形式のデータベースなのでDB設計の変更などでスキーマが変更された時に以下のような問題が起こります。

  • スキーマ変更以前のドキュメントには変更が反映されない
  • フロントエンド側で新規で生えたフィールドを参照した場合、変更前のドキュメントにはフィールドが存在しないので色々と壊れる

これらの解決策としてはフロントエンドの表示側で気合いで頑張るなどがあると思いますが、それも変更が重なるとしんどいです。

RDBなどではマイグレーションを行うことができますが、Firestoreではマイグレーション機能は提供されていないので自作します。

作ったもの

前提として、FirebaseAdminSDKを使いました。
FirestoreAdminSDKの導入やセットアップに関しては今回は省きますので各自調べてください

firestore-migration-tool
├── entites
│   └── Firestore.ts
├── infrastracture
│   └── FirestoreMigrationOperations
├── lib
│   └── firebaseAdmin.ts
├── migrations :マイグレーションファイル
│   └── YYYYMMDD_[collectionName]_add_[fieldName].ts
├── index.ts :エントリポイント
└──package.json

最終的にこんな感じになりました。

フィールドに入る型

新しいフィールドに入りうる型を定義します

entities/Firestore.ts
export type FirestoreFieldValue =
  | string
  | number
  | boolean
  | null
  | object
  | Array<FirestoreFieldValue>
  | Date
  | GeoPoint
  | DocumentReferenc

Firestoreとの通信

Firestoreとの通信系のオペレーション関数を書いていきます。
今回の場合、既存のドキュメント全てに変更を走らせるためまずは、任意のコレクション、サブコレクションのdocIdを全取得するものから書いていきます。

取得系

infrastructure/FirestoreMigrationOperations.ts
export const fetchAllDocIdsInCollectionOperation = async (
  collectionName: string,
): Promise<Array<string>> => {
  const snapshot = await db.collection(collectionName).get()

  return snapshot.docs.map((doc) => doc.id)
}

export const fetchAllDocIdsInSubCollectionOperation = async (
  collectionName: string,
  docId: string,
  subCollectionName: string,
): Promise<Array<string>> => {
  const snapshot = await db
    .collection(collectionName)
    .doc(docId)
    .collection(subCollectionName)
    .get()

  return snapshot.docs.map((doc) => doc.id)
}

フィールドの追加や変更

コレクションやサブコレクション配下のドキュメントへ具体的な変更を加えるものを書いていきます。
firebaseAdminSDKではバッチ処理を行えるのでバッチで一括書き込みを行うようにしています。
例としてコレクション配下、サブコレクション配下へのフィールドの追加処理を書きました。
同じ要領でフィールドの変更や削除も書いていきます。

FirestoreMigrationOperations.ts
export const addFieldToCollectionByBatchOperation = (
  batch: WriteBatch,
  collectionName: string,
  docId: string,
  fieldName: string,
  fieldValue: FirestoreFieldValue,
) => {
  const ref = db.collection(collectionName).doc(docId)

  batch.set(ref, { [fieldName]: fieldValue }, { merge: true })
}

export const addFieldToSubCollectionByBatchOperation = (
  batch: WriteBatch,
  collectionName: string,
  docId: string,
  subCollectionName: string,
  subDocId: string,
  fieldName: string,
  fieldValue: FirestoreFieldValue,
) => {
  const ref = db
    .collection(collectionName)
    .doc(docId)
    .collection(subCollectionName)
    .doc(subDocId)

  batch.set(ref, { [fieldName]: fieldValue }, { merge: true })
}

マイグレーションファイルを書く

行いたいマイグレーションに関するマイグレーションファイルを作成します。
先ほど作成したOperationsを使って任意のコレクション内のドキュメントを取得しバッチ処理で一括で変更処理を走らせてマイグレーションします。
今回は例としてsamplesコレクション配下のドキュメントにsampleFieldを追加してみます(初期値としてnull)。
firestoreのバッチ処理は一回につき500件の制約があるので500件ごとにコミットします。

export const migrate = async (
  batch: WriteBatch,
  operationCount: { count: number },
): Promise<WriteBatch> => {
  const collectionName = 'samples'

  const collectionDocIds =
    await fetchAllDocIdsInCollectionOperation(collectionName)

  for (const docId of collectionDocIds) {
    addFieldToCollectionByBatchOperation(
      batch,
      collectionName,
      docId,
      'sampleField',
      null,
    )

    operationCount.count++

    // バッチが500件ずつコミットしてログを表示 & 新しいバッチを作成
    if (operationCount.count % 500 === 0) {
      await batch.commit()
      console.log(
        `Committed:${operationCount.count}件ドキュメントを更新しました`,
      )
      batch = db.batch()
    }
  }

  return batch
}

エントリポイントを書く

あとはエントリポイントで任意のマイグレーションファイルを叩くだけです

index.ts
const runMigration = async (migrationFileName: string) => {
  try {
    const operationCount = { count: 0 }
    let batch = db.batch()

    const migrationFile: MigrationFile = await import(
      `~/migrations/${migrationFileName}`
    )
    batch = await migrationFile.migrate(batch, operationCount)
    await batch.commit()
    console.log(
      `Completed:マイグレーション完了 ${operationCount.count} 件のドキュメントを更新しました`,
    )
  } catch (e) {
    console.warn(e)
  }
}

const main = async () => {
  const migrationFile = process.argv[2]
  if (!migrationFile) {
    console.error('Please provide a migration file')
    process.exit(1)
  }

  await runMigration(migrationFile)
}

main()

まとめ

今回はFirestoreのマイグレーションツールを作ってみました
Firestoreを利用しているプロジェクトでは開発効率が上がる便利なツールになったと思います。

Discussion