mokajima.com

Cloud Firestore でのページネーションの実装

Cloud Firestore では複数のドキュメントを取得するにはクエリを作成し、実行します。

例えば、あるアンケートの回答を surveyResponses コレクションのドキュメントとして格納している場合を考えます。surveyResponses コレクションのドキュメントには回答者の名前と年齢が name フィールド、age フィールドにそれぞれ格納されているとします。このとき name フィールドの昇順で 20 件取得するクエリは次のように書くことができます。

// name フィールドの昇順で 20 件取得するクエリ
const query = surveyResponsesRef.orderBy('name').limit(20)

surveyResponsesRef は surveyResponses コレクションへの参照です。

orderBy()limit() はクエリを作成するためのメソッドで、返り値としてクエリを返します。orderBy() は並び順を指定するためのメソッド、limit() は取得件数を指定するためのメソッドです。

作成したクエリに対し get() を呼び出すとクエリを実行します。そして、返り値としてクエリに合致するドキュメントを返します。

// クエリを実行
const querySnapshot = await query.get()

Cloud Firestore ではこのようにして複数のドキュメントの取得を行うのですが、1つ注意点があります。それは offset の指定ができない、ということです。

それでは Cloud Firestore ではどのようにページネーションを実現するのかというと「クエリカーソル」と呼ばれるものを使います。クエリの開始点や終了点を指定することでページネーションを実現します。

クエリカーソルの例

クエリの開始点や終了点を指定するためのメソッドとして4つのメソッドがあります。

  • startAt(): クエリの開始点を指定(開始点を含む)
  • startAfter(): クエリの開始点を指定(開始点を含まない)
  • endAt(): クエリの終了点を指定(終了点を含む)
  • endBefore(): クエリの終了点を指定(終了点を含まない)

startAt()startAfter() はクエリの開始点を指定するためのものです。一方 endAt()endBefore() はクエリの終了点を指定するためのものです。At()After() Before() の違いはその点を含むか否かで、At() は含み、After() Before() は含みません。

これらのメソッドには引数としてドキュメントのフィールドの値ないしはドキュメントスナップショットを渡すことができます。

ドキュメントのフィールドの値を渡す場合

// ドキュメントのフィールド age の値が 20 以上となるドキュメントを年齢の昇順で 20 件取得するクエリ
const query1 = surveyResponsesRef.orderBy('age').limit(20).startAt(20)

// ドキュメントのフィールド age の値が 20 以下となるドキュメントを年齢の昇順で 20 件取得するクエリ
const query2 = surveyResponsesRef.orderBy('age').limit(20).endAt(20)

query1 はドキュメントのフィールド age の値が 20 以上となるドキュメントを年齢の昇順で 20 件取得するクエリです。20 という値は age フィールドの値を指しています。一方 query2 は ドキュメントのフィールド age の値が 20 以下となるドキュメントを年齢の昇順で 20 件取得するクエリです。

このように、orderBy() で指定したフィールド名の値を startAt()endAt() などに渡す必要があり、扱いがやや難しいものとなっています。

ドキュメントスナップショットを渡す場合

// 取得したいドキュメントの ID
const docId = // ...

// docId を ID に持つドキュメントのスナップショットを取得
const docSnapshot = await surveyResponsesRef.doc(docId).get()

// docSnapshot の age フィールドの値以上の age を持つドキュメントを取得するクエリ
const nextQuery = surveyResponsesRef.orderby('age').startAt(docSnapshot)

doc() は単一のドキュメントへの参照を返すメソッドで、引数としてドキュメントの ID を受け取ります。get() を呼び出すと返り値としてドキュメントスナップショットを返します。

これを startAt()endAt() などに渡すことで、具体的なフィールドの値を用いずにクエリの開始点や終了点を指定することができます。

例えば docSnapshot のドキュメントの age フィールドの値が 10 の場合、nextQuery は回答者の年齢が 10 歳以上のドキュメントを年齢の昇順で取得するクエリを表します。

ページネーションの実装

ページネーションのよくあるタイプとして「前後リンクの場合」「ページ番号リンクの場合」「前後リンクとページ番号リンクの場合」の3つが挙げられるかと思います。3つ目は1つ目と2つ目の組み合わせのため、ここでは1つ目と2つ目の実装方法についてご紹介します。

前後リンクを実装したい場合は startAfter()endBefore() を使います。

// クエリを実行
const querySnapshot = await query.get()

// 現在のクエリの直前の 20 件を取得するクエリ ※querySnapshot.docs[0] が存在する前提
const prevQuery = query.limit(20).endBefore(querySnapshot.docs[0])

// 現在のクエリの直後の 20 件を取得するクエリ ※querySnapshot.docs[querySnapshot.docs.length - 1] が存在する前提
const nextQuery = query.limit(20).startAfter(querySnapshot.docs[querySnapshot.docs.length - 1])

querySnapshot.docs はドキュメントスナップショットの配列です。prevQuery では endBefore()querySnapshot.docs の先頭の要素を受け取っているため、現在のクエリの直前の 20 件を取得するクエリを表します。一方 nextQuery では startAfter()querySnapshot.docs の最後の要素を受け取っているため、現在のクエリの直後の 20 件を取得するクエリを表します。

なお、前後リンクの実装は次のページ番号リンクの場合の実装方法でも実装可能です。

ページ番号リンクを実装したい場合、ソート用のフィールドをドキュメントに持たせることで、期待する挙動を実現することができます。

以下の例では surveyResponses コレクションのドキュメントにソート用のフィールドとして order フィールドを持たせ、それを orderBy() に指定しています。

// order は 0, 1, 2, ... のようなフィールド内でユニークな数値
// order の値が 0 以上の値を持つドキュメントを昇順で 20 件取得するクエリ
const query = surveyResponsesRef.orderBy('order').limit(20).startAt(0)

// クエリを実行
const querySnapshot = await query.get()

queryorder フィールドの値が 0 以上の値を持つドキュメントを昇順で 20 件取得するクエリです。order はアンケートの回答順にインクリメントするユニークな数値になるようにします。

もし閲覧するページ番号を変えたい場合は、ページ番号と1ページに表示するアンケートの回答の件数から order フィールドの値を算出し、それを startAt() に渡すようにします。

const LIMIT = 20
const order = (page - 1) * LIMIT // page は 1 以上の整数
const query = surveyResponsesRef.orderBy('order').limit(20).startAt(order)

ただし、この方法はドキュメントの削除が行われないことが前提となります。削除が行われる場合、order フィールドの値が飛び飛びになってしまい、あるページ番号で表示されているドキュメントが次のページ番号でも表示されてしまう、といったことが起こり得ます。

ちなみに

Firebase の Admin SDK では offset を指定することができます(!)。 ただし offset を指定した場合も、飛ばした分のドキュメントは読み取り数としてカウントされます。

const query = surveyResponsesRef.limit(20).offset(20)

例えば limit(20).offset(20) の場合、取得できるドキュメントは21番目以降となりますが、このとき 1〜20 番目のドキュメントも読み取りがあったとみなされ、課金対象となります。

そのため、公式では Admin SDK を使用する場合も offset() ではなくクエリカーソルを使用することを推奨しているようです。

参考