Kesinの知見置き場

知見を共有していきたいじゃないですか

Firestoreをもっと手軽に使えるfirestore-simpleを作った

Firebaseの新しいDBであるFirestoreは使っているでしょうか?
今まではどうしてもサーバー側の実装が必要だったDBへのデータの格納がクライアントだけで済むようになったので圧倒的に便利ですね。
そんなわけで自分もjsからFirestoreを使う機会が増えてきたのですが、毎回書くたびにAPIが微妙に使いづらいと感じていました。そこで、Firestoreをいい感じに扱えるfirestore-simpleというライブラリをリリースしました。

サンプルコード

まず、比較のためにFirestoreを素で使った場合のコードです。

const admin = require('firebase-admin')
const serviceAccount = require('./firebase_secret.javascripton')
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) })

const main = async () => {
  const firestore = admin.firestore()
  const collectionPath = 'users'
  await firestore.collection(collectionPath).add({ name: 'alice', age: 20 })
  await firestore.collection(collectionPath).add({ name: 'bob', age: 22 })

  const user_documents = await firestore.collection(collectionPath).get()
  const users = []
  user_documents.forEach((document) => {
    const data = document.data()
    users.push({ id: document.id, name: data.name, age: data.age })
  })
  // [ { id: 'lZ9HtQAEJvumL9grjGtc', name: 'bob', age: 22 },
  // { id: 'rFx0a6dZQZPIHEvXGGtO', name: 'alice', age: 20 } ]

  const batch = firestore.batch()
  users.forEach((user) => {
    const documentRef = firestore.collection(collectionPath).doc(user.id)
    batch.delete(documentRef)
  })
  await batch.commit()

}
main()

そしてこちらがfirestore-simpleを使った場合のコードです。

const admin = require('firebase-admin')
const { FirestoreSimple } = require ('firestore-simple')
const serviceAccount = require('./firebase_secret.json')
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) })

const main = async () => {
  const firestore = admin.firestore()
  const collectionPath = 'users'
  const dao = new FirestoreSimple(firestore, collectionPath)
  await dao.add({ name: 'alice', age: 20})
  await dao.add({ name: 'bob', age: 22 })

  const users = await dao.fetchCollection()
  // [ { id: '3mBYA0uXw0lw6b883jgk', age: 20, name: 'alice' },
  // { id: 'lE9SfP8f3H8w0Uwk0euQ', age: 22, name: 'bob' } ]

  const ids = users.map((user) => user.id)
  await dao.bulkDelete(ids)
}
main()

どうでしょうか。素のFirestoreに比べて短く、スッキリとしたコードで書けると思います。

firestone-simpleの特徴

1インスタンス1コレクション

素のFirestoreでは例えばuserにアクセスする場合、firestore.collection(‘user’)と書く必要があります。コードの各所からuserのドキュメントにアクセスしたい場合、毎回同じコードを書くのはイヤなので自分ならUserDaoとかUserModelみたいなクラスを作り、そのクラス経由でアクセスするようにしたいです。

firestore-simpleはコンストラクタにcollectionPathを渡してインスタンスを作ります。1インスタンス1コレクションという制約を設ける代わりに、collectionPathを指定するのは最初の1回だけで済みます。

普通のObjectを返す

Firestoreのドキュメントを取得するとき、得られるのはDocumentSnapshotなのでidを参照するにはdoc.id、それ以外の要素にアクセスするにはdoc.data()が必要です。 そのため、{title: ‘test’}というドキュメントをFirestoreから取り出すときには、いちいちdoc.data().titleとする必要があり地味に面倒くさいです。

firestore-simpleはfetchDocumentfetchCollectionの参照系だけではなくaddsetといった更新系も基本的にObjectを返します。このObjectにはドキュメントのデータに加えてidも追加されているためdoc.iddoc.titleというようにアクセスすることが可能です。

普通のArrayを返す

Firestoreでコレクションから複数のドキュメントを取得する場合、得られるのはQuerySnapshotです。ドキュメントのサンプルコードではforEachを使っているのでmapも使えると思いきや使えません。どうもQuerySnapshotのリファレンスを見る限りではforEachしか実装されていないようで、mapfilterが使えないので大変不便です。

firestore-simpleではfetchCollectionfetchByQueryといった複数のドキュメントを取得するメソッドはArrayを返します。従ってmapも普通に使うことが可能です。

キーの別名マッピングに対応

jsに限らずJSONでデータをやりとりするAPIのクライアントを書くときに地味に面倒くさいのが、キーの名前をsnake_caseやCamelCaseといったクライアント側の言語の流儀に合わせて変換することです。 この変換処理をコードの色々なところで繰り返したくないので、大抵はAPIクライアントかModelクラスにJSONのキーとのマッピングを書くことになると思います。

大規模なコードであればFirestoreのコレクションと1対1で対応するModelクラスを用意してもいいのですが、そこまでしたくない場合のためにfirestore-simpleではキーの別名マッピングに対応しています。

const dao = new FirestoreSimple(firestore, collectionPath, { mapping: {
  createdAt: "created_at"
}})
await dao.add({ name: 'alice', age: 20, createdAt: new Date() })

このようにすると、js側ではcreatedAtというキーで扱いますが、Firestore側のドキュメントではcreated_atというキーで保存されます。

メソッド

参照系

fetchDocument

1つのドキュメントをフェッチします。

await dao.fetchDocument(documentId)

fetchCollectoin

コレクション全てのドキュメントをフェッチします。

await dao.fetchCollection()

fetchByQuery

where, orderBy, limitなどを使ってコレクションから絞りこんだりソートしてフェッチします。

const query = dao.collectionRef
  .where('name', '==', 'alice')
  .orderBy('age')
  .limit(1)
await dao.fetchByQuery(query)

更新系

add

idを自動採番でFirestoreにドキュメントを追加します。

await dao.add({ name: 'alice', age: 20 })

set

引数のObjectのidに対応するFirestore側のドキュメントを更新します。
そのidに対応するドキュメントがまだFirestoreに存在しない場合はそのidで追加されるので、addによる自動採番ではなく任意のidで登録したい場合もsetを使います。

await dao.set({ id: '1111', name: 'bob', age: 22})

addOrSet

引数のオブジェクトにidが含まれていればset、含まれなければaddと同じ挙動になります。
元々のFirestoreには存在しないfirestore-simpleのオリジナル機能です。

await dao.addOrSet({ name: 'alice', age: 20 }) // add
await dao.setOrSet({ id: '1111', name: 'bob', age: 22}) // set

delete

引数のidに対応したドキュメントをFirestoreから削除します。

await dao.delete(documentId)

Bulk系

ドキュメントで一括書き込みと書かれている処理です。複数のドキュメントへの処理をアトミックに行いたい場合は使用します。

bulkSet

引数に渡した複数のオブジェクトでFirestoreのドキュメントを一括で更新します。

await dao.bulkSet([
  { id: '1111', name: 'alice', age: 20 },
  { id: '2222', name: 'bob', age: 22 }
])

bulkDelete

引数に渡した複数のidでFirestoreのドキュメントを一括で削除します。

await dao.bulkDelete(['1111', '2222'])

できないこと

update

Firestoreにはドキュメント更新の方法としてset以外にupdateも存在しますが、自分はupdateを使う必要性を感じなかったので実装していないです。

トランザクション

自分が使いたい用途としてはbulkSetbulkDeleteでほぼ十分だったことと、トランザクションをサポートするには実装が複雑になってしまうことから見送りました。

onSnapshot()

これも自分が今の所使う必要がなかったため未サポートです。 ただ、firestore-simpleのインスタンスからcollectionRefにアクセス可能なので、このようなコードでonSnapshot()を使うことは可能です。

dao.collectionRef.onSnapshot((snapshot) => {
  snapshot.docChanges.forEach((change) =>{
    console.log(change.type)
    console.log(change.doc.data())
  })
})

ReactNative

firestore-simpleはReactNativeFirebaseでも使用可能です。
ReactNativeFirebaseではfirestoreのインスタンスをゲットする方法が多少異なりますが、それ以後の使い方は全く同じです。

import { FirestoreSimple } from 'firestore-simple'
import firebase from 'react-native-firebase';

export default class App extends React.Component {
  // signInAnonymouslyAndRetrieveDataを使う場合はfirebaseのコンソールで匿名ログインをオンにする必要があります
  async getFirestore() {
    await firebase.auth().signInAnonymouslyAndRetrieveData()
    return firebase.firestore()
  }

  async componentDidMount() {
    const firestore = await this.getFirestore()
    const collectionPath = 'sample_react_native'
    const dao = new FirestoreSimple(firestore, collectionPath, {
      mapping: {
        createdAt: 'created_at'
      }
    })

    // ... other usage are same as nodejs client
  }
}

追記6/29: CloudFunction

CloudFunctionでも問題なく使えることを確認しました

const functions = require('firebase-functions');
const admin = require('firebase-admin')
const { FirestoreSimple } = require('firestore-simple')
admin.initializeApp()

exports.helloFirestore = functions.https.onRequest((request, response) => {
  const firestore = admin.firestore()
  const dao = new FirestoreSimple(firestore, 'cloud_function')
  dao.add({ name: 'alice', age: 20}).then(doc => {
    console.log(doc)
    return response.send("add { name: " + doc.name + ", age: " + doc.age + "}")
  })
})

TypeScript

firestore-simpleはTypeScriptで書かれているため、型定義も同梱されています。
逆にflowには対応していません。

まとめ

Firestoreをもっと手軽に扱えるfirestore-simpleを作りました。
簡単な用途ならfirestore-simpleを素のまま使っても便利だと思いますが、firestore-simpleを組み込むか、継承することでより重厚なFirestoreクライアントを自作したいというニーズにも応えられると思います。

自分はオリジナルのFirestoreのAPIを忘れてしまうぐらい便利に使いまくっているので、ぜひ使ってみてください。