| X | GitHub | RSS

TypeScript製の軽量Firestoreクライアント「firestore-repository」を作った

firestore-repository

特徴

作った理由

Firestoreの公式クライアントライブラリを生で使うだけだと、以下のような点が不便に感じました。

公式ライブラリという立場だとこれらはしょうがないというか、公式として提供できるものは限られている(unopinionatedでないといけない)だろうとも思ったので、それならその辺を解消するための簡単なwrapperを自分で書いてみようと思いました。

使い方

インストール

# バックエンドで利用する場合
npm install firestore-repository @firestore-repository/google-cloud-firestore

# フロントエンドで利用する場合
npm install firestore-repository @firestore-repository/firebase-js-sdk

スキーマを定義

まずはcollectionのスキーマを定義します。このスキーマ定義はバックエンド/フロントエンドで共有できます。
スキーマを共有することで、フロントエンドとバックエンドそれぞれで同じコレクションのドキュメントを一貫性を保った形でread(+write)できます。

import { mapTo, data, rootCollection } from 'firestore-repository/schema';

// define a collection
const users = rootCollection({
  name: 'Users',
  id: mapTo('userId'),
  data: data<{
    name: string;
    profile: { age: number; gender?: 'male' | 'female' };
    tag: string[];
  }>(),
});

備考: スキーマ定義においてiddataに指定している値の実態は、単にfrom toという2つの関数をペアにしただけのものです。fromはFirestoreからアプリレイヤに取り出す際の変換の関数で、toはアプリレイヤからFirestoreに保存する際のデータの変換の関数です。この双方向の変換定義について、汎用的によく使うであろうものはライブラリでutility的に提供していますが、独自の変換処理をしたい場合は自前で好きに変換ロジックを書くこともできます。
上記の話を踏まえると、このライブラリにおけるスキーマ定義というのは、単にFirestoreのドキュメントの各コンポーネント(ID、データ、コレクションパス)それぞれの双方向変換の定義を寄せ集めただけのものと言えます。

Repositoryインスタンスの作成

前のステップで定義したスキーマと、公式ライブラリのDBオブジェクトを使って、Repositoryのインスタンスを作成します。

// For backend
import { Firestore } from '@google-cloud/firestore';
import { Repository } from '@firestore-repository/google-cloud-firestore';
const db = new Firestore();

// For web frontend
import { getFirestore } from '@firebase/firestore';
import { Repository } from '@firestore-repository/firebase-js-sdk';
const db = getFirestore();

const repository = new Repository(users, db);

使うための準備としては以上で、あとは作成したRepositoryインスタンスを通じて各種操作が可能です。

基本のread/write操作

一通りの基本的なドキュメントの読み込み/書き込み操作を型がついた状態で行えます。リアルタイムアップデートの購読も可能です。
getsetの返り値や引数のデータ型はスキーマ定義に沿って導出されたシンプルなPOJOとなるため、アプリケーションコードとFirestoreを疎結合に保つことができます。

Repositoryのインターフェースもバックエンドとフロントエンドで共通です。(バックエンド側にはバックエンド専用のメソッドが追加されていたりといった増分は存在)

// Set a document
await repository.set({
  userId: 'user1',
  name: 'John Doe',
  profile: { age: 42, gender: 'male' },
  tag: ['new'],
});

// Get a document
const doc = await repository.get({ userId: 'user1' });
// doc = {
//   userId: 'user1',
//   name: 'John Doe',
//   profile: { age: 42, gender: 'male' },
//   tag: ['new'],
// }

// Listen a document
repository.getOnSnapshot({ userId: 'user1' }, (doc) => {
  console.log(doc);
});

// Delete a document
await repository.delete({ userId: 'user2' });

クエリ

クエリについても、公式のFirestoreクライアントでサポートされているような条件指定や集計操作は一通り行えます。
リアルタイムアップデートの購読も可能です。

クエリ定義についても、バックエンド/フロントエンドで共有可能です。

import { condition as $, limit, query } from 'firestore-repository/query';

// Define a query
const q = query(
  users,
  $('profile.age', '>=', 20),
  $('profile.gender', '==', 'male'),
  limit(10)
);

// List documents
const docs = await repository.list(q);
console.log(docs);

// Listen documents
repository.listOnSnapshot(q, (docs) => {
  console.log(docs);
});

// Aggregate
const result = await repository.aggregate(q, {
  avgAge: average('profile.age'),
  sumAge: sum('profile.age'),
  count: count(),
});
console.log(`avg:${result.avgAge} sum:${result.sumAge} count:${result.count}`);

なお、クエリ条件の指定にも型がつくようになっています。
フィールド名には補完が効き、存在しないフィールド名を指定した場合にはきちんとコンパイルエラーが発生するようになっています。
また、指定した値がフィールドの型と合っていない場合にもコンパイルエラーになります。

const validQuery = query(users, $('profile.age', '>=', 20));

const invalidQuery1 = query(users, $('profile.foo', '>=', 20));
//                                    ~~~~~~~~~~~ スキーマで定義されていないフィールド名のためコンパイルエラー

const invalidQuery2 = query(users, $('profile.age', '>=', 'foo'));
//                                                         ~~~ 数値型でないためコンパイルエラー

バッチ操作

一括でのsetdeleteができるメソッドを用意しています。
batchGet については現状バックエンド側のみサポートしています[1]

// Set multiple documents
await repository.batchSet([
  {
    userId: 'user1',
    name: 'Alice',
    profile: { age: 30, gender: 'female' },
    tag: ['new'],
  },
  {
    userId: 'user2',
    name: 'Bob',
    profile: { age: 20, gender: 'male' },
    tag: [],
  },
]);

// Delete multiple documents
await repository.batchDelete([{ userId: 'user1' }, { userId: 'user2' }]);

// Get multiple documents (backend only)
const users = await repository.batchGet([
  { userId: 'user1' },
  { userId: 'user2' },
]);

また、setdeleteなどの各種書き込み系のメソッドの第二引数では tx パラメータに公式ライブラリのバッチオブジェクトを渡せるようになっており、それを利用することで複数種別の書き込み操作を1つのバッチにまとめることが可能です。

// For backend
const batch = db.writeBatch();
// For web frontend
import { writeBatch } from '@firebase/firestore';
const batch = writeBatch();

await repository.set(
  {
    userId: 'user3',
    name: 'Bob',
    profile: { age: 20, gender: 'male' },
    tag: [],
  },
  { tx: batch }
);
await repository.batchSet(
  [
    /* ... */
  ],
  { tx: batch }
);
await repository.delete({ userId: 'user4' }, { tx: batch });
await repository.batchDelete([{ userId: 'user5' }, { userId: 'user6' }], {
  tx: batch,
});

await batch.commit();

トランザクション

バッチと同じ要領で、各種メソッドの第二引数で tx パラメータに公式ライブラリのトランザクションオブジェクトを渡せるようになっています。

// For web frontend
import { runTransaction } from '@firebase/firestore';

// Or, please use db.runTransaction for backend
await runTransaction(async (tx) => {
  // Get
  const doc = await repository.get({ userId: 'user1' }, { tx });

  if (doc) {
    doc.tag = [...doc.tag, 'new-tag'];
    // Set
    await repository.set(doc, { tx });
    await repository.batchSet(
      [
        { ...doc, userId: 'user2' },
        { ...doc, userId: 'user3' },
      ],
      { tx }
    );
  }

  // Delete
  await repository.delete({ userId: 'user4' }, { tx });
  await repository.batchDelete([{ userId: 'user5' }, { userId: 'user6' }], {
    tx,
  });
});

まとめ

という感じで、一通りのことができる便利なFirestoreクライアントライブラリを作れました。
よければ使ってみてください。

残課題


  1. フロントエンド側については、一括取得は公式クライアントとしても未サポートとなっている https://github.com/firebase/firebase-js-sdk/issues/1176 ↩︎