LoginSignup
4
3

Firestoreのページング方法3選

Last updated at Posted at 2023-11-30

GDSC Japan Advent Calender 2023 1日目

はじめに

先日、Firestore を使用してとあるアプリを開発していたところ、以下の機能が必要になりました。

  • データを1画面に複数件表示し、複数ページに分ける
  • ページ番号を指定して表示する
  • 作成日でソートし、昇順・降順を切り替える
  • どの期間のデータを表示するかを指定可能

この機能を実装するためにあれこれ調べたので、これを例としてFirebase Firestoreでページングを実装する方法を3パターン紹介します。

ところで: RDBではどう実装するのか?

FirestoreはNoSQLですが、実装方法を考えるためにMySQLなどのRDBでページングを実現する方法も調べてみました。
どうやら以下の2つの方法があるようです。

  1. オフセット法
  2. シーク法

オフセット法は、単純にデータをある順番に並び替えたあと、前から n 番目以降を m 件取得というクエリを発行する方法です。メリットは実装が単純なこと、デメリットは読み出し速度が遅いこと。

シーク法は、前ページの最後のデータをキーにしてクエリを発行する方法です。メリットは読み出しが早いこと、デメリットは実装が複雑なことと任意のページに飛んで表示するのが面倒なこと。

参考1: offsetでページネーションは遅い。これからはシーク法だ!
参考2: で、オフセット法に比べてシーク法のページネーションはどれだけ早いの?RDB毎に。

以上を参考に、Firestoreのページングについて考察していきます。

案1: オフセット法

Firebase Admin SDK (サーバーが使うやつ) には, MySQLのような offset が実装されています。
API Reference

const firestore = getFirestore();
firestore.collection('foo').limit(10).offset(20);

メリット

  • 実装が割と簡単 (なように見えて、Admin SDKなのでバックエンドのAPIを用意しないといけないため割と面倒)

デメリット

  • 読み飛ばした分も課金対象になる
  • Admin SDK はセキュリティルールが (恐らく?) 適用されないため、DBのアクセス権限を別途で実装しなければいけない
  • 作成日降順でソートしているとき、あるページを読み込んだ後に新しいデータが追加されると次ページを表示したときに同じデータが表示される
    詳しくはこちらを参照してください。
コスト 手軽さ パフォーマンス
☆☆☆ ★★☆ ?

案2: シーク法もどき

Firestore公式は (シーク法という言葉は使っていないものの) シーク法のアルゴリズムでページングすることを推奨しています。
ただ、シーク法には任意のページだけを取得する、言い換えると前ページのデータを取得していない状態でページを取得することが難しいという欠点があります。
MySQL等では各ページの最後の値だけを取得するクエリを発行できますが、現状Firestoreではそのようなクエリを発行できません。

解決策として、Firestoreのドキュメントにindex (ソートされた状態で前から数えた番号)を持たせます。
例えば作成日順でソートする場合、新規作成データのindex = 最新データのindex + 1 となります。
読み取り時は startAt() でオフセット分だけずらして取得します。以下は読み取りコードの例です。

// 昇順の場合
const limPerPage = 10; // 1ページあたりに表示する件数
const page = 2; // 表示したいページ番号
const fooCollectionRef = collection(db, 'foo');
const offset = (page - 1) * limPerPage; // ここがキモ、indexをページ分ずらす
const ascQuery = query(fooCollectionRef, limit(limPerPage), 
    orderBy('index', 'asc'),  startAt(offset));
// 降順の場合
const limPerPage = 10; // 1ページあたりに表示する件数
const page = 2; // 表示したいページ番号
const maxIndex = 100; // コレクション内で最大ののindex, 事前に取得しておく
const fooCollectionRef = collection(db, 'foo');
const offset = (page - 1) * limPerPage; // ここがキモ、indexをページ分ずらす
const ascQuery = query(fooCollectionRef, limit(limPerPage), 
    orderBy('index', 'desc'),  startAt(maxIndex - offset));

ある期間に作成されたデータを取得したい場合、事前にその期間の最初または最後にあるデータの index を取得してクエリに渡します。

// ある期間以降の最初のデータのindexを取得する
const from: Timestamp = // Timestampで期間を指定
const fooCollectionRef = collection(db, 'foo');
const q = query(fooCollectionRef, limit(1), 
    orderBy('created_at', 'asc'),  startAt(from)); // created_at はドキュメントの作成日
const snapshot = await getDocs(q);

if (!snapshot.empty) { // クエリに一致するドキュメントが存在すれば
    const fooData = snapshot.docs[0].data();
    const startAtIndex = fooData.index;
}

メリット

デメリット

  • 作成順以外でソートしたい場合、データが追加されるたびに既存のドキュメントの index を再設定する必要がある。
    例えば、以下のようなコレクションに新しく金額が¥150のデータを追加する場合、金額が¥200, ¥400のデータのindexに1加算しなければなりません。

    index 金額
    1 ¥100
    2 ¥200
    3 ¥400
  • キーとなる index をクライアント側で保存する必要がある

  • 読み取り時、最大の index を取得するために余分に1だけ読み取りが発生する

コスト 手軽さ パフォーマンス
★★★ ★☆☆ ★★★

案3: BigQueryを使う

BigQueryはDWH(データウェアハウス)の一つで、標準SQLでクエリを書けます。
また、Firestoreの"Stream Firestore to BigQuery"というExtensionを使用して、FirestoreのデータをBigQueryに流すことができます。

RDBとDWHは微妙に違うようで、MySQLともSQLの仕様が少し異なるらしく、先に述べたRDBでシーク法を実装するSQL文は使えませんでした。
(アドベントカレンダーの公開まで間に合わないので) 実際のクエリは省略します。

(BigQuery慣れてないので嘘言ってたらごめんなさい)

参考: BigQueryの料金
対象 料金 無料枠
ストリーミング挿入 $0.012/200MB なし
クエリ(オンデマンド) $6.00 per TB 毎月 1 TB
コスト 手軽さ パフォーマンス
★★☆ ★☆☆ ?

まとめ

以上の3つをページングの手法として挙げましたが、個人的にはシーク法(もどき)をおすすめします。
要件や規模にもよりますが、任意ページ目を取得する機能を諦めるならソートするフィールドによらずにページングを実装できます。

そもそも複雑な複合クエリが必要となるシステムでは、NoSQLではなくRDBを採用するでしょうからね。
Firestoreを使ったシステムならシーク法で十分でしょう。

コスト 手軽さ パフォーマンス
オフセット法 ☆☆☆ ★★☆ ?
シーク法もどき ★★★ ★☆☆ ★★★
BigQuery ★★☆ ★☆☆ ?

参考サイト

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3