Kesinの知見置き場

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

Firestoreをもっと手軽に使えるfirestore-simpleがバージョン2になりました

追記: v4についてのエントリはこちら

この記事はFirebase Advent Calendar 2018 3日目の記事です

今年の6月にfirestore-simpleというjsからFirestoreを使うときに使いやすくするモジュールをリリースしていました。

Firestoreをもっと手軽に使えるfirestore-simpleを作った
GitHub - Kesin11/Firestore-simple: A simple wrapper for Firestore

リリース後、自分の趣味プロジェクトで使い続けていたのですが、TypeScriptの型付けが中途半端で惜しくも今ひとつ使いにくい感じでした。そこでTypeScriptの型についてちゃんと勉強をして、Genericsなどを活用してもっといい感じに型を扱えるように改良したバージョン2をリリースしました。 🎉

サンプルコードはこんな感じです。ぜひFirestoreのドキュメントと見比べてみてください。素のFirestoreよりもかなりシンプルなコードで書けることが分かると思います。

# install
$ npm i firestore-simple
// 以下のコードはTypeScriptです
const firestore = admin.firestore()
firestore.settings({ timestampsInSnapshots: true })

interface User {
  id: string,
  name: string,
  age: number,
}

const main = async () => {
  // Firestoreからフェッチしたときにマッピングしたい型をGenericsに指定する
  // pathはFirestoreのコレクションパス
  const dao = new FirestoreSimple<User>({ firestore, path: 'user' })

  // addの戻り値はFirestoreによって付与されたidを含むUser型
  const user: User = await dao.add({ name: 'bob', age: 20 })
  console.log(user)
  // { name: 'bob', age: 20, id: '3Y5jwT8pB4cMqS1n3maj' }

  // fetchの戻り値の型は User | undefined
  let bob: User | undefined = await dao.fetch(user.id)
  console.log(bob)
  // { id: '3Y5jwT8pB4cMqS1n3maj', age: 20, name: 'bob' }
  if (!bob) return

  // update
  bob.age = 30
  bob = await dao.set(bob)

  // add or set
  // idがあればset、なければadd
  let alice: User = await dao.addOrSet({ name: 'alice', age: 22 })
  console.log(alice)
  // { name: 'alice', age: 22, id: 'YdfB2rkXoid603nKRX65' }

  alice.age = 30
  alice = await dao.addOrSet(alice)
  console.log(alice)
  // { name: 'alice', age: 30, id: 'YdfB2rkXoid603nKRX65' }

  // delete
  const deletedId = await dao.delete(bob.id)
  console.log(deletedId)
  // 3Y5jwT8pB4cMqS1n3maj

  await dao.delete(alice.id)

  // `buldSet`と`bulkDelete`はWriteBatchを使いやすくしたラッパー
  const _bulkSetBatch = await dao.bulkSet([
    { id: '1', name: 'foo', age: 1 },
    { id: '2', name: 'bar', age: 2 },
  ])

  // コレクションに含まれるデータを全てfetch
  const users: User[] = await dao.fetchAll()
  console.log(users)
  // [
  //   { id: '1', name: 'foo', age: 1 },
  //   { id: '2', age: 2, name: 'bar' },
  // ]

  // fetch by query
  const fetchedByQueryUser: User[] = await dao.where('age', '>=', 1)
                                .orderBy('age')
                                .limit(1)
                                .get()
  console.log(fetchedByQueryUser)
  // [ { id: '1', name: 'foo', age: 1 } ]

  // multi delete
  const _deletedDocBatch = await dao.bulkDelete(users.map((user) => user.id))
}
main()

コンセプト

firestore-simpleのコンセプトはFirestoreをよりjsらしく扱いやすくするというものです。

そもそも作った動機は、素のFirestoreのAPIは単にgetやsetするだけの単純な用途であっても、jsから使いづらいところがあったからです。毎回ラッパーを書くことになりそうだと思ったので、ならば最初にシンプルなAPIで汎用的に使えるものを作ろうと思いました。 このあたりの詳しい内容はバージョン1をリリースしたときのエントリを見てもらえればと思います。

競合との比較

競合として、vue-firestoreや、pring.tsなどが既にあります。

vue-firestoreは特定のライブラリと密結合しているのが個人的には好みではないのと、Cloud FunctionsのようなVue.jsの外の世界では結局使えないところが自分のニーズと合いませんでした。

pring.tsはサンプルコードを見た感じではAPIが簡潔で自分がやりたかったことに近かったのですが、最初にクラスの中でアノテーションを付けてプロパティを定義する方法がSwiftやJavaっぽくて自分のjs感とマッチしませんでした。

firestore-simpleはFirestoreの薄いラッパーであり、メインの機能はFirestoreから取得したドキュメントをjsのプレーンなオブジェクトにするだけです。そのまま使っても便利ですが、いわゆるModelやRepositoryに組み込んで生のFirestoreの代わりに使うのも便利だと思います。自分のように疎結合なクラス設計をしたい人向けだと思います。
他のライブラリと密結合していないため、ブラウザ上のjsからFirestoreを使う場合、nodejsのAdmin SDK、Cloud Functionsのいずれでも利用可能です。

バージョン2で大きく変わったポイント

Genericsを活用して型を扱いやすくしたところがバージョン2の目玉の変更点ですが、それに加えてAPIの破壊的な変更を含めた変更点も紹介します。

1. TypeScriptでGenericsを活用した型情報が付けられるようになった

TypeScriptからFirestoreを使うときに辛かったのが、Firestoreからfetchしたときに型情報が付かないことでした。そのため、fetchしたオブジェクトは即座にクラスやインターフェースにマッピングして型を付けていたのですが、毎回書くのはとてもめんどくさいことでした。

バージョン1ではfirestore-simpleを使ってfetchしてもオブジェクトにidというプロパティが存在していることだけを保証するという中途半端な型情報しかなかったのですが、バージョン2ではGenericsを活用することでfetchしたときに自動的に型情報が付くようになりました!

interface User {
  id: string,
  name: string,
  age: number,
}

const dao = new FirestoreSimple<User>({ firestore, path: 'user' })

// 戻り値はUser型
const user = await dao.fetch('bob')

Firestoreからfetchした結果をクラスにマッピングせず、単にオブジェクトに型が欲しいだけであればこれだけです。インターフェースの型をGenericsで指定するだけで済み、簡潔に書くことができます。

2. Firestoreに保存、Firestoreから取得するときのマッピング処理を自由に書けるようになった

バージョン1ではjs <-> Firestoreを行き来するときのプロパティ名の変換ルール(created_at <-> createdAtなど)だけは定義しておくことができました。バージョン2ではFirestoreに保存するタイミングをencode, Firestoreから取り出すタイミングをdecodeとして、ここにマッピングの処理を自由に書けるようになりました。

class Book {
  public id: string
  public title: string
  public created: Date

  constructor ({ title, created }: { title: string, created: Date }) {
    this.id = title
    this.title = title
    this.created = created
  }
}

const dao = new FirestoreSimple<Book>({ firestore, path: 'user',
  // Firestoreにadd, setするタイミングで呼ばれる
  // 戻り値のオブジェクトのプロパティでFirestoreに保存される
  encode: (book) => {
    return {
      id: book.id,
      book_title: book.title, // Firestoreではbook_titleというプロパティ名になる
      created: book.created,
    }
  },
  // Firestoreからfetchするタイミングで呼ばれる
  // decodeの戻り値の型はGenericsと一致している必要がある
  decode: (doc) => {
    return new Book({
      // Firestore上ではbook_titleというプロパティ名だったが、Bookではtitleというプロパティ名に変換
      title: doc.book_title,
      // Firestoreの仕様上、Dateで保存していた値はfetchしたときにFirestoreのTimestamp型になっているのでDate型に変換
      created: doc.created.toDate(),
    })
  },
})

// マッピング処理がencode/decodeで完結するので実際の処理がシンプルになる
const book = new Book({ title: 'foobar', created: new Date() })
await dao.set(book)
await dao.fetch(book.id)

注意点としては、encodeのときにidはオプショナルで、逆にdecodeのときはidを必須にしていることです。 これは、addするときにはidが必須ではないですが、逆にfetchするにはidが必須であるためです。

このidにまつわる型付けは型プログラミングで頑張って実現しているのですが、コンパイルエラーのエラーメッセージは少し長くなってしまいました。 もしコンパイルエラーになったときには少し注意深くエラーメッセージを読む必要があるかもしれません。

3. サブクラスベースな方法もサポート

ここまでのサンプルコードで示したように、FirestoreSimpleクラスのインスタンスを作成するのが基本的な想定した使い方ですが、IndexdDBのラッパーであるDexie.jsのようにサブクラスを作成して使う方法も可能にしました。

class Book {
  public id: string
  public title: string
  public created: Date

  constructor ({ title, created }: { title: string, created: Date }) {
    this.id = title
    this.title = title
    this.created = created
  }
}

class BookDao extends FirestoreSimple<Book> {
  constructor ({ firestore }: { firestore: Firestore }) {
    super({ firestore, path: 'example/ts_admin/book' })
  }
  // override
  public encode (book: Book) {
    return {
      id: book.id,
      book_title: book.title,
      created: book.created,
    }
  }
  // override
  public decode (doc: {id: string, [props: string]: any}) {
    return new Book({
      title: doc.book_title,
      created: doc.created.toDate(),
    })
  }
}

const dao = new BookDao({ firestore })

const book = new Book({ title: 'foobar', created: new Date() })
await dao.set(book)
await dao.fetch(book.id)

FirestoreSimpleを継承したクラスを定義します。Genericsの型とpathを固定することでBookDaoのインスタンスを作るときに引数をへらすことができます。 さらにencodedecodeも親クラスであるFirestoreSimpleのメソッドをオーバーライドしてしまうことで、コンストラクタで渡す必要がなくなりました。

従来の方法と、サブクラスベースな方法で実現できることに差異はありません。好みの方法を選択してください。

未サポートな機能

firestore-simpleは、自分のFirestoreの使い方に特化してデザインしているところが強いため、残念ながらFirestoreの全ての機能をまだサポートしているわけではありません。

リファレンスとサブコレクションのサポートは(今のところ)無い

firestore-simpleはFirestoreから取得したドキュメントをプレーンなjsのオブジェクトに変換してしまうため、残念ながらリファレンスとサブコレクションについてはサポートしていません。

// userのサブコレクションにitemsがあるとする /user/:user_id/items/:item_id
// このようにfetchしたuserのサブコレクションを簡単に辿ることはできない
const user = await dao.fetch(user_id)
const user_items = await user.items.fetchAll()

自分がリファレンスとサブコレクションを使っていないためにまだその機能をデザインしていない、というのがサポートできていない主な理由です。自分が使うようになったタイミングでサポートする可能性はあると思います。

transactionのサポートは(今のところ)無い

トランザクションが必要なのは複数のコレクションにまたがった操作をアトミックに行いたいケースでしょう。firestore-simpleはそのインスタンスごとに1つのコレクションの操作に特化するようにデザインしてしまったため、transactionをいい感じに扱うAPIのデザインが考えきれておらずまだ実装していません。

自分がFirestoreを使う開発においてtransactionが欲しいと思った場面に直面していないので優先度はあまり高くないのですが、transactionは用途によっては必須レベルだと思いますのでそのうちサポートしたいなとは考えています。

onSnapshotは部分サポート

Firestoreのウリの1つであるonSnapshotですが、これを他のメソッドのように生のFirestoreのAPIをラップするのはかなり大変そうだったため部分サポートとしています。実際にサンプルコードを見てもらうのが早いでしょう。

dao.where('age', '>=', 20)
  .onSnapshot((querySnapshot, toObject) => {
    // querySnapshotはFirestoreの生のonSnapshot()の引数と同じもの
    querySnapshot.docChanges.forEach((change) => {
      if (change.type === 'added') {
        // toObjectはfirestore-simpleが提供する、オブジェクトに変換するためのメソッド
        // changeDocの型はdaoを作るときに指定したGenericsの型と同じになる
        const changedDoc = toObject(change.doc)
      }
    })
  })

Firestoreのリアルタイムアップデートのドキュメントと比較してもらうと、toObjectがコールバックの引数に増えただけというのが分かると思います。

toObjectは、firestore-simpleが生のFirestoreからfetchしたドキュメントをオブジェクトに変換する、内部で使用しているのと同じメソッドです。onSnapshotの処理の中でtoObject()を通すことでfirestore-simpleの他のメソッドの戻り値と同じ型にできます。
他のAPIのように生のFirestoreよりも使い勝手がそれほど良くなるわけではないですが、実用上はそれほど問題ないかなと思います。

まとめ

Firestoreを便利に、そしてシンプルにjs/tsから使うためのfirestore-simpleを紹介しました。

未サポートな機能の節で紹介したように、自分のFirestoreの使い方に特化してデザインしてるため、今のところはユースケースが限られてしまうかもしれません。
一方で、firestore-simpleは非常に小さなライブラリで、本体のコードはまだ200行程度しかありません。必要であれば中身のコードを読んで把握し、継承して独自に拡張してもらうことも容易だと思います。

まだまだ発展途上なところが多いですが、サンプルコードを見ていいじゃん!と思った方はぜひGitHubのスターや、pull-reqを頂けると嬉しいです!