Kesinの知見置き場

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

v7をリリースしたので改めてfirestore-simpleを紹介します

firestore-simpleの過去のエントリ

以前から作り続けている、Firestoreを使いやすくするためのラッパーであるfirestore-simpleがv7になりました 🎉 (v6は事実上の欠番扱い)

firestore-simple

v7で以前から実現したかったjsのweb SDKに対応できました。admin/web SDKの両方に対応できたのは1つの節目だと感じているので、今回の修正点に加えて、そもそものfirestore-simpleのコンセプトを再紹介しようと思います。

BREAKING CHANGES

パッケージが今までのfirestore-simpleから、admin SDK用の@firestore-simple/adminとweb SDK用の@firestore-simple/webと2つに別れました。今までのfirestore-simpleはここでDEPRECATEDになります。

もしv6までのfirestore-simpleをお使いだった方は 、以下のようにマイグレーションをお願いします。

npm install @firestore-simple/admin
// old
import { FirestoreSimple } from 'firestore-simple'

// new
import { FirestoreSimple } from '@firestore-simple/admin'

追加機能

firestore-simpleは今までadmin SDKしか対応していなかったのですが、v7からweb SDKにも対応しました。これでadmin SDKを使ってFirestoreを使うサーバーやCloud Functions環境に加えて、クライアントからFirestoreにアクセスするコードでもfirestore-simpleを使うことができるようになりました。

インストールとimportは以下のようになります。

npm install @firestore-simple/web
import { FirestoreSimple } from '@firestore-simple/web'

exampleを見てもらえると分かるのですが、web SDKの方もAPIは基本的に今までのadmin SDK用のfirestore-simpleと全く一緒になっています。

ただし、web SDKの一部のメソッドが持つadmin SDKには存在しないオプションにまだ対応できていないところがあります。例えばget()が受け取れるGetOptionsなどです。これらは今後対応していく予定です。

改めてfirstore-simpleの機能紹介

ここまで細々と約2年ほど作り続けてきたので、v1から比べるとjsからTypeScriptベースに変更したこともあり昔のコードは全て置き換わりました。ですが「Firestoreをよりjsから扱いやすくする」というコンセプトは変わっていません。

v1から比べるとアピールしたいポイントもいくつか変わりましたので、改めてfirestore-simpleの代表的な特徴を紹介します。

データの取得と更新を簡潔に

素のFirestoreで1つのドキュメントを追加したり、データを取得する場合、このようなコードになります。

const id = await firestore.collection('users').add({ userId: 'alice' })

const doc = await firestore.collection('users').doc(id).get()
const data = doc.data()

正直、1つのドキュメントを追加したり、取得するために毎回このコードを書くのは面倒くさいと感じています。加えて、毎回collectionを指定する必要があるためコレクション名をtypoしてしまうだけで容易にバグとなります。

さらに、この短いコードの中だけでCollectionReference, DocumentReference, DocumentSnapshotという3つのFirestoreのクラスが登場しており、慣れるまでは複雑に感じるでしょう。ちなみに、data()のメソッドを持っているのはDocumentSnapshotですね。

これらに対して自分が初期に取ったアプローチは、以下のようにコレクション毎にラッパークラスを作ってメソッド1つでデータを取得できるようにしていました。

class UserCollection {
  constructor() {
    this.collection = firestore.collection('users')
  }
  async fetch(userId) {
    const docRef = await this.collection.doc(userId).get()
    return docRef.data()
  }
}

const userCollection = new UserCollection()
const user = await userCollection.fetch('alice') // id='alice'を渡すだけ

このようなクラスを自前で用意すると、CollectionReference, DocumentReference, DocumentSnapshotの区別を意識することなく、単純にdocumentのidだけを渡して中のデータを取得することが可能になります。実にシンプルです。

firestore-simpleはまさにこれを汎用化したものであり、このようなクラスをコレクション毎に自作する必要がなくなります。先述のコードと同等の機能はfirestore-simpleだとこのようになります。

const firestoreSimple = new FirestoreSimple(firestore)
const userCollection = firestoreSimple.collection<User>({ path: 'users' })

const id = await userCollection.add({ userId: 'alice' })
const user = await userCollection.fetch(id)

ActiveRecord風でははない

Firestoreに限らずDBのORMでよく見るのはActiveRecordのパターンだと思います。擬似コードですが、以下のようなイメージです。

const user = new User({ userId: 'alice' })
user.age = 20
await user.save() // ここでDBに書き込まれる

const alice = await User.find({ userId: 'alice') // DBからデータ取得

今どきでは非常にありふれたORM風のAPIですが、firestore-simpleでは2つの理由からあえてこのようなAPIにはしていません。

1つ目は単純に素のFirestoreのAPIから離れすぎないようにしたかったからです。素のFirestoreから使い方が離れすぎてしまうと何か問題が起きた場合にコードを追いにくくなってしまうため、あくまで薄いラッパーとなるように意識しています。

2つ目は、js界隈においては何か値を入れておく入れ物として、単なるObject({ foo: bar} のような形)が好まれていると感じているからです。TypeScriptもいかにこの単なるObjectに対して型を付けて扱うか、という点を重視している言語です。ActiveRecordパターンはクラスとインスタンスによって実現されるものであり、この思想と真逆であるためfirestore-simpleではそのようなAPIを提供していません。

強力な型推論と補完

Firestoreの微妙な点として、TypeScriptで使うときにCollectionReferenceやDocumentReferenceなどのFirestore自体には型が付いていますが、取得してきたドキュメントには型が一切付いていません。ほとんどの方はdoc.data() as Userなどと自分で型を付けているのではないでしょうか。

firestore-simpleではコレクションを定義するときにドキュメントの型を渡すことで、fetch()したドキュメント自動的に型が付きますし、add()などの更新系メソッドも型が正しいかチェックしてくれます。

const userCollection = firestoreSimple.collection<User>({ path: 'users' })
const user = await userCollection.fetch(id)
// userは自動的にUser型となる

実は、昨年Firestoreに追加されたwithConverter()を使うことで、素のFirestoreでも自動的に型が付くようになりました。

firestore-simpleはそれに加えて型情報を利用した補完が強力であり、where()update()もドキュメントのキーを補完してくれます。これについてはv4をリリースしたときの記事が詳しいので興味がある人は見てみてください。どのように補完されるのか、v4の記事からGIF動画こちらにも貼っておきます。

where_update.gif

batch系のAPIの使い勝手を向上

さらに素のFirestoreの微妙な点として、TransactionやBatchのAPIの使い勝手の悪さが挙げられます。

通常はdocumentReference.get()なところがTransactionの中ではtransaction.get(documentReference)APIが別物になってしまっているのです。これはBatchの方でも同様です。

さらに、そもそもなぜかTransactionとBatchで使い方が全く異なっています。

// 素のFirestoreを使ったコード

firestore.runTransaction(async(tx) => {
  // Transaction内での処理
}

const batch = firestore.batch()
// set(), update()などbatchでまとめて行いたい処理
await batch.commit()

firestore-simpleではbatchもTransactionと同じ使い勝手を実現するrunBatch()というメソッドを独自に追加しました。

// FirestoreSimpleを使ったコード

await firestoreSimple.runBatch(async (_batch) => {
  await userCollection.set({ userId: 'alice' })
}) // <- runBatchを抜けるタイミングで自動的にbatch.commit()される

お気づきかもしれませんが、素のFirestoreであればbatch.set()と書く必要があるところも、通常通りのset()の呼び出し方でOKです。firestore-simpleを使ったときのrunTransaction()も同様の挙動となるように改良しています。通常状態、Transaction内、Batch内のそれぞれの状態に応じてfirestore-simpleがメソッドを裏で呼び分けてこのような隠蔽を実現しています。

さらに、単に1つのコレクションに対して配列でまとめてadd, setしたいだけというユースケースに便利なbulkAdd(), bulkSet()を用意しました。逆にidの配列を渡すと複数のドキュメントをまとめて削除してくれるbulkDelete()もあります。

await userCollection.bulkAdd([
  { userId: 'alice' },
  { userId: 'bob' },
])

await userCollection.bulkSet([
  { id: '1', userId: 'alice' },
  { id: '2', userId: 'bob' },
])

await dao.bulkDelete(['1', '2'])

TransactionやBatchについてはv5をリリースしたときの記事サンプルコードにもう少し詳しい内容がありますので、気になった方はぜひ見てみてください。

テストはFirestoreのエミュレータを使用

v6まではテストを実行するときに本物のFirestoreを使用していましたが、v7でweb SDKとadmin SDKの両方でエミュレータを使用するように変更しました。本物のFirestoreでは特にonSnapshot系のテストの不安定さに悩まされてきましたが、エミュレータを使うことでだいぶテストが安定しました。

エミュレータによるテストへの置き換えは、CIでの実行方法も含めていくつかワークアラウンドな対応が必要だったので苦労しました。Firestoreのエミュレータを使ったテストに興味がある方はGitHubで公開されているコードのpackage.json__tests__/util.tsなどが参考になると思います。

今後の展望

v7で追加したweb SDKもほとんどの機能は対応済みですが、冒頭の方で書いたように一部のメソッドでadmin SDKには存在しなかったオプションが未対応なので、次のバージョンではこれを利用可能にします。

本家Firestoreが何かしら新機能を発表したらそれらも対応していきたいと考えていますが、そろそろ前々から欲しいと思っていたAPIドキュメントも整備していきたいと思っています。TypeScriptならいい感じのドキュメントが自動生成できるはず・・・?

FirestoreやFirebase自体がまだまだ進化のスピードが落ちていないため、firestore-simpleも引き続き開発・メンテをしていく予定です。firestore-simpleをぜひ使ってみてください!