TypeScript
Firebase
cloudfunctions
Firestore
FirebaseDay 12

Firebase CloudFunctionsでRealtimeDBからFirestoreに切り替えていく時の勘所

More than 1 year has passed since last update.

Firebase Advent Calendar 2017の12日目の記事です。

最近携わっているプロジェクトにて、DatabaseをRealtimeDBからFirestoreに移行し、併せてCloudFunctionsでの処理も移行したのでその時に得られた知見を、before/after踏まえて共有しようと思います

事前に

基本的にはTypeScriptで書いていて、以下2つをimportしている事を前提としています。
JavaScriptの場合は適宜置き換えてお読み下さい。

import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'

また、Promiseではなくてasync/awaitを使っている点もご留意下さい。

database -> firestore

DBのあるpathに対する変更をトリガーとしてfunctionを定義する場合は、database->firestore(と使う関数を少し)変更してあげるだけで済みます。onCreateといったイベント等はそのままです。

before
export const userCreated = functions.database.ref('/v1/user/{userID}').onCreate(async event => {
  // ...
})
after
export const userCreated = functions.firestore.document('/version/1/user/{userID}').onCreate(async event => {
  // ...
})

ただ、Firestoreの場合注意点があり、documentで指定するpathの階層は偶数個でないとだめです。
これはFirestoreの特性でもありますが、 (Sub)Collection->Document の順でpathが構成されるため、firestore.document()の中で、beforeと同様に/v1/user/{userID}としてしまうと機能しなくなります。
これ、 deploy時に気付かずうっかりやってしまうのでは... いえ、そんなことはありません。 firebase deploy コマンド実行時に、このpathがおかしかったりすると400エラーが返却され、deployに失敗するので、うっかり気づかずデプロイしてしまった!ということは起きません。

参考: Cloud FirestoreのSubCollectionとQueryっていつ使うの問題

指定したpathのドキュメントを取得したい

指定したpathのデータ(ドキュメント)を取得したい場合は、以下のようになります

before
const userID = 'xxx'
const user = await admin.database().ref(`/v1/user/${userID}`).once('value').then(s => s.val())
console.log(user)
after
const userID = 'xxx'
const user = await admin.firestore().doc(`/version/1/user/${userID}`).get().then(s => s.data())
console.log(user)

一部が置き換わる、pathの扱いに気をつけるだけで、ほぼ変わりません。

ネストしたデータを書き込む

例えば、taskというモデルに、何かしらエラーが発生した場合に、errorというkeyにたいして、エラーコードと詳細を書き込むことを想定してみます。

RealtimeDBでネストしたデータを保つ場合だと、taskのrefからerrorというchildRefを作り、そこにsetする形になります。

before
taskRef.child('error').set({
  code: 100,
  description: 'The operation could not be completed.'
})

taskRefがv1/task/{taskID} を表していたとすると、今回の追加で、 v1/task/{taskID}/error のpath(ref)が増えたことになります。

対してFirestoreは、DocumentのFieldにネストしたデータを置く場合は、ネストしたObject(json)の形式でsetしてあげるだけです。
RealtimeDBと違って、ネストした分がpathになってしまうこともなく、document内で完結します。

after
// NOTE: `taskDocRef` is `DocumentReference`.
taskDocRef.set({
  error: {
    code: 100,
    description: 'The operation could not be completed.'
  }
})

document以下に値を追加したい/削除したい

RealtimeDBの場合、特定のrefに対して、set/update/removeを呼ぶ場合は以下のようになります。

before
// set
userRef.child('name').set('Taro')

// update
userRef.update({name: 'Taro'})

// remove
userRef.child('country').remove()

Firestoreの場合は、まず、Documentの持つFieldに対しては以下のようになります。

after
// NOTE: `userDocRef` is `DocumentReference`.

// set
userDocRef.set({name: 'Taro'})

// update
userDocRef.update({age: 28})

// remove
userDocRef.update({country: Firestore.FieldValue.delete()})

特筆すべきはremoveのときで、remove()が用意されていないので、updateで、削除したいfieldのkeyの対して、 Firestore.FieldValue.delete() を指定します。

Document自体の削除は、refに対してdelete()を呼ぶだけです。

after(document)
// remove document
userRef.delete()

フィールドの値の変更に関するトリガーがない

RealtimeDBの場合、特定のrefに対してonCreate|Update|Delete|Writeを張ることができました。
例えば、v1/user/{userID}/name新たに 追加されたタイミングで何かするのを仮定すると...

before
export const userNameAdded = functions.database.ref('/v1/user/{userID}/name').onCreate(async event => {
  const name = event.data.val()
  console.log(name)
})

こんな感じになります。

一方Firestoreの場合は、これがFieldに該当する訳ですが、Fieldの値の変更をトリガーにすることはできません。

after
// 注: これは正しくないコードです
export const userNameAdded = functions.firestore.ref('/version/1/user/{userID}/name').onCreate(async event => {
  const name = event.data.data()
  console.log(name)
})

ではどうやって、userドキュメントにnameが追加されたのをトリガーにして処理を書けば良いのでしょうか。
1つは、userドキュメントのonUpdateを拾い、event.dataをゴニョゴニョ調べる方法があります。

event.data.previous を見る

Firestoreにあって、RealtimeDBにない特徴として、event.datapreviousを持つ場合があるというのが挙げられます。
もし、event.dataが更新されたりした場合には、その1つ前の状態が、previousプロパティに格納されます。
なので、先程の例で、user.nameが追加されたか調べるには、以下のようにします。

after
export const userNameAdded = functions.firestore.ref('/version/1/user/{userID}').onUpdate(async event => {
  const previousName =  event.data.previous && data.previous.data().name
  const currentName = event.data.data().name
  if (!previousName && currentName) {
    // user.nameが追加された
  }
})

ちなみに、event.data.previous は、onCreate等のタイミングではundefinedになるので、そのチェックを怠ると、 data.previous.data() でエラーになります。

...やや大変なのと、これを毎回書くのはしんどいですよね。

event.data.data()のあるkey(property)の変更があったかどうか判定する処理を作る

ということで、以下のような処理を準備して、event.data.data() のあるプロパティに関して前後で値がどう変化したか見れるようにします。

after
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { DeltaDocumentSnapshot } from 'firebase-functions/lib/providers/firestore'

export enum ValueChangesResult {
  none = 0,
  new = 1,
  updated = 2,
  removed = 3
}

export class ValueChanges {
  static for(key: string, data: DeltaDocumentSnapshot): ValueChangesResult {

    const currentValue = data.data()[key]
    const previousValue = data.previous && data.previous.data()[key]

    if (!previousValue && currentValue) {
      return ValueChangesResult.new
    }

    if (previousValue && !currentValue) {
      return ValueChangesResult.removed
    }

    if (currentValue === previousValue) {
      return ValueChangesResult.none
    } else {
      return ValueChangesResult.updated
    }
  }
}

先程のコードを変更するとこのようになります.

after
export const userNameAdded = functions.firestore.ref('/version/1/user/{userID}').onUpdate(async event => {
  if (ValueChanges.for('name', event.data) === ValueChangesResult.new) {
    // user.nameが追加された
  }
})

previousとcurrentの関係から、それぞれnone,new,updated,removedを判断します。

prev current (condition) result
null null - none
null some - new
some some === none
some some !== updated
some null - removed

もう一つの解決策

これは設計次第になりますが、例えばnameを例に取った場合は、以下のようにuserドキュメントから更にコレクションとドキュメントを生やすという手段もあります。

もう一つの解決策
export const userNameAdded = functions.firestore.ref('/version/1/user/{userID}/_name/{nameID}').onCreate(async event => {
  const name = event.data.data().name
  console.log(name)
})

こうすると、onCreateをトリガーに扱うことができるので、先程の方法より、関数の呼び出し数は減るのでだいぶマシにはなりそうです。
が、構造が深くなる問題もあるので、設計とコストとうまく見て判断したいところです。

この項についてよりベストプラクティスあるよって場合は是非教えてください。

まとめ

RealtimeDBとfirestoreでそこまで大きく変化することはないですが、DBとしての特性が変わることにより、一部扱いやすくなる/にくくなる部分がでてくるので、移行していくときには十分注意が必要かと思います。
まだまだ僕含め開発チームでも日々取り組んでいるところではあるので、また知見が得られましたらぼちぼち共有しようかなと思います。

余談

本当は以前firebase.yebisu#1で発表した内容をより深掘りして投稿しようかなと思いましたが、最近触ってるホカホカなネタをと思い舵切りしました。