こんにちは、キカガク for Business のエンジニアをしている 上野 です。
今回は数万ユーザー規模の会社への導入に備えて Firestore を使用したアプリケーションのパフォーマンス改善に取り組んだので、その紹介記事になります。Firestore を使用したアプリケーションでデータ取得に時間がかかっている方、Firestore を使用したアプリケーションでページネーションを実現したい方の参考になれば幸いです。
現状と課題
まずはキカガク for Business の現状を説明させて頂きます。
アプリケーションの構成としてはフロントエンドに Next.js、データベースには Firestore を使用しており、バックエンドアプリケーションは存在しません。 また、画面表示の実装は必要なデータを全て Firestore から取得してきてからフロントエンドでページネーションや絞り込み、並び替えをして表示しています。
以下画面はメンバー一覧画面ですが、こちらの画面だと最初に 313 件のユーザーを一括で取得し、(赤矢印)そのデータに対してフィルターやソート、氏名、メールアドレスでの部分一致検索(青矢印)をしています。
この 1 年でおかげさまでキカガク for Business は急拡大しており、ユーザー数が数万単位となることが想定される会社へ導入が決まっております。
そうなると、現状のままでは Firestore からユーザー一覧画面では数万ユーザー、別画面では十数万のデータを一括で取得することになり、初期描画だけで数十秒から数分かかってしまい、とてもWebアプリケーションとして使える状態ではなくなってしまいます。 そこで数万人のユーザーに対応できるように以下の改善を行いました。
取り組んだこと
取り組んだことはとてもシンプルで、データの一括取得をやめ、表示する分のデータだけを取得するようにしました。以下はそれぞれのケースでの実装方法と実装例の紹介です。
limit を使用し、表示するデータのみを取得する
キカガク for Business ではページネーションで 1 ページあたり 50 件のデータを表示しているので 50 件だけデータを Firestore から取得します。
const users = await query(collection(db, '/users'), [limit(50)]);
startAfter, endBefore を使用し、ページネーションの実装
上記の limit を使用した実装だけでは最初の 50 件しか取得できず、ページネーションができないので、 startAfter, endBefore を使用してページネーションを実装しました。
次のページがクリックされた場合、前回取得した最後のドキュメントスナップショットを startAfter に指定してクエリを実行しました。 2 ページ先までクリックができる仕様なので、2 ページ先をクリックしたときは limit の引数を 100 にし、100 件データを取得して後半の 50 件を表示するようにしました。
const users = await query( collection(db, '/users'), [startAfter(前回取得した最後のドキュメントスナップショット), limit(50)] )
前のページがクリックされた場合は、前回取得した最初のドキュメントスナップショットを endBefore に指定し、クエリを実行しました。
ただし、startAfter と違い、endBefore のときは limit ではなく、limitToLast を使用して最後からデータを取得するようにしています。 こちらも次のページの時と同様に 2 ページ先までクリックできる仕様なので、その際は limitToLast に 100 を指定し、100 件データを取得したのち、前半の 50 件を表示するように実装しました。
const users = await query( collection(db, '/users'), [endBefore(前回取得した最初のドキュメントスナップショット), limitToLast(50)] )
orderBy を使用し、並び替えを実装
テーブルのヘッダーをクリックすることで該当フィールドで昇降順の並び替えができるので、それは orderBy を使用して実装しました。 以下は部署名で並び替えた実装です。
const users = await query( collection(db, '/users'), [orderBy('departmentName', 'asc'), limit(50)] )
並び替えをした状態で、ページネーションで次のページがクリックされた時は以下のようなクエリでデータを取得しています。
const users = await query( collection(db, '/users'), [ orderBy('departmentName', 'asc'), startAfter(前回取得した最後のドキュメントスナップショット), limit(50) ] )
where を使用し、絞り込みを実装
ユーザー情報に含まれる部署名、グループ、権限、ステータスで絞り込みができるのでそれは where を使用して実装しました。(並び替えを指定しない場合は氏名の昇順で表示しています)
const users = await query( collection(db, '/users'), [ where('departmentName', '==', '部署1'), where('groupName', '==', 'グループ1'), where('role', 'in', ['受講兼管理者', '受講者']), where('status', 'in', ['受検中', '招待中']), orderBy('name', 'asc'), limit(50) ] )
絞り込みをした状態でページネーションの次のページがクリックされた時は以下のようなクエリでデータを取得しています。
const users = await query( collection(db, '/users'), [ where('departmentName', '==', '部署1'), where('groupName', '==', 'グループ1'), where('role', 'in', ['受講兼管理者', '受講者']), where('status', 'in', ['受検中', '招待中']), orderBy('name', 'asc'), startAfter(前回取得した最後のドキュメントスナップショット), limit(50) ] )
辛かったこと
インデックスの管理
取り組んだことで紹介した内容を組み合わせて使用するには複合インデックスを作成する必要があります。具体的には比較演算子を使用する複合クエリや別フィールドでのソートをしたいときに必要になります。(詳しくは https://firebase.google.com/docs/firestore/query-data/index-overview?hl=ja#queries_supported_by_composite_indexes )
インデックスが必要な場合は以下のようにエラーを吐いてくれるのでリンクから簡単に作成することができます。
ただ、今回だと絞り込み条件で 4 要素の組み合わせ分、ソートも多いものでは 7 要素ほどあったのでこれら全てのインデックスを作成するのは現実的ではなかったため、インデックスマージ(参考:https://firebase.google.com/docs/firestore/query-data/index-overview?hl=ja#taking_advantage_of_index_merging)を活用しました。
インデックスマージを活用したのですが、それでもかなりの数のインデックスを作成しました。今後インデックスの管理に苦労しそうで何か仕組み化したいなと思っています。
部分一致検索
これは辛かったというより、 Firestore では実現が難しそうだったので諦めた機能になります。
本来だと氏名、メールアドレスで部分一致検索ができる仕様なのですが、 Firestore では前方一致検索しか実現が難しそうでした。(もし方法があればコメント頂けると嬉しいです) 以下は前方一致検索の実装例です。
const users = await query( collection(db, '/users'), [ orderBy('name'), startAt('山田'), endAt('山田\uf8ff'), limit(50) ] );
終わりに
現在、キカガク for Business では更なる大規模導入や今後のデータ活用に備えて Firestore からの離脱プロジェクトを進めております。 冒頭にバックエンドアプリケーションはないと記載したのですが、今後実装していく予定です! ご興味がある方、是非キカガクの採用ページへお越しください!
まずはカジュアル面談で、ざっくばらんにお話しましょう。