📃

APIサーバーでFirestoreを使っているときのカーソルベースのページネーション手法

2021/08/17に公開1

前提

  • APIサーバーでFirestoreを利用している
  • OffsetベースではなくCursor(カーソル)ベースでのページネーションをAPIサーバーでおこないたい
    • クライアントからAPIサーバーにカーソル文字列が送られてきて、それを元に続きを取得して返却する

やりたいこと

  • APIサーバーでのFirestoreをデータソースとしたページネーション処理を共通化したい
  • cursorとlimitとFirestoreのQuery( firestore().collection('posts').where(..).orderBy(..) みたいなwhereとorderByだけは指定したやつ)を渡す
  • 結果として最低限、以下が返ってくる
    • 取得できたドキュメントの配列
    • 次のデータがあるか(hasNextPage)
    • 最後のドキュメントのカーソル

むずかしいこと

Firestoreでページネーションする際に利用する startAfter/startAt は 「orderBy で指定しているフィールドの値」または「ドキュメントのスナップショット」で指定できる。

前者の場合、orderByを何でしているかによって渡すカーソルの型が変わってくる。TypeScriptであれば string, number, firestore.Timestamp などになるが、APIのクライアントにカーソルとして返却する関係上は string にするのが現実的である(GraphQLでのRelayでのページネーションのカーソルは文字列になっている)。しかしページネーションの処理を共通化する上で「このクエリの場合のカーソルの型は○○だからこう変換しなければならない」といった処理は煩雑になってしまう。

後者(ドキュメントのスナップショット)の場合、スナップショットはオブジェクトでありそのままカーソル化(文字列化)するには向いていない。ここで思いついたのがドキュメントのpathをカーソル化すればいいのではないかということだ。

やりかた

TypeScriptでのコード例を以下に示す(ざっくりではあるがGraphQLのRelay styleのカーソルページネーションのイメージ)。

import { firestore } from 'firebase-admin'

// snapshotのpathをbase64エンコード
const encodeCursor = (snapshot: firestore.DocumentSnapshot | firestore.QueryDocumentSnapshot) => {
  return Buffer.from(snapshot.ref.path).toString('base64')
}

const decodeCursor = (cursor: string) => {
  return Buffer.from(cursor, 'base64').toString('utf8')
}

type Connection = {
  nodes: { id: string }[]
  pageInfo: {
    hasNextPage: boolean
    endCursor?: string | null
  }
}

export const paginateFirestore = async (query: firestore.Query, limit: number, cursor?: string | null): Promise<Connection> => {
  // hasNextPageのために1件多く取得する
  let q = query.limit(limit + 1)

  if (cursor) {
    // この部分がキモ

    // カーソルが渡された場合、pathに変換してドキュメントのsnapshotを取得
    const path = decodeCursor(cursor)
    const snap = await admin.firestore().doc(path).get()

    if (!snap.exists) {
      return { nodes: [], pageInfo: { hasNextPage: false } }
    }

    // startAfterにわたす
    q = q.startAfter(snap)
  }

  const snapshot = await q.get()
  const hasNextPage = snapshot.size > limit
  const docs = snapshot.docs.slice(0, limit)

  // 最後のドキュメントのpathをカーソル化する
  const endCursor = hasNextPage ? encodeCursor(docs[docs.length - 1]) : null

  return {
    nodes: docs.map(doc => ({ id: doc.id, ...doc.data() })),
    pageInfo: {
      hasNextPage,
      endCursor,
    },
  }
}

使い方は以下のような感じ。

const query = firestore().collection('posts').orderBy('createdAt', 'desc')
const connection = await paginateFirestore(query, 100, args.cursor)

メリット・デメリット

メリット

  • カーソルが必ずpathになるので処理がシンプルになる
  • 利用側は生成されたcursorを受け渡すだけでよくなる
  • orderByのフィールドでのページネーションよりもsnapshotによるページネーションのほうがより正確である

デメリット

  • 1件余分に取得していることによるオーバーヘッド

どうおもいますか?

今回たまたまFirestoreをDBとしたGraphQL APIサーバーのベースのコードを作って欲しいという依頼があって色々考えて作ってみたものになります(OSSとかで公開してもいいよって許可もらってるので今回は記事にした)。

1件余分に取得していることによるオーバーヘッドはgRPCではそんなに大きな影響じゃないんじゃないかと思って、コードのシンプルさを優先してこのように実装してみました。これについて思うことがあればぜひフィードバックいただけると嬉しいです!

リプライや引用RT、コメントなどお待ちしております〜🙏

https://twitter.com/_mogaming/status/1427432488192909312?s=21

Discussion

nuichinuichi

最新の firebase-admin v12.0.0 でも完全に動作しています。
素晴らしいアイデアを記事にて共有頂きありがとうございます!