Firestore で Algolia を使わず「複数フィールド・複数カテゴリの検索」を実装する


はじめに

こちらの記事を読みました。

Firestore だけで Algolia を使わず全文検索

非常に面白い記事で、生のFirestoreでAlgoliaを使わずに全文検索を実現する方法が紹介されています。

今回は上記の記事で紹介されている方法を転用して、Firestoreだけでの実現が難しい「複数のフィールドーに対して複数のカテゴリを指定して検索」する機能の実装方法を紹介します。

TL;DR

  • in,array-contains-anyを使って実現するのは難しそうだ
  • 検索用のデータ構造を設計すれば、実現できる
  • orderByは使えないけど、documentIDを使えば単一条件でページングも実装できる

Firestoreでの検索

Firestoreでは、Documentのフィールドの条件指定や並び替えをしてデータを取得する為のQueryが提供されています。ただし、サーバーサイドでSQLを使用するのと比較すると、細かな検索はできません。

2020/05/18現在では、全文検索やOR句による検索などは提供されていませんし、提供されているクエリの使用時にもいくつか制限事項があります。詳しくは以下をご覧ください。

参考
- データのクエリとフィルタ #制限事項
- データのクエリとフィルタ #クエリの制限事項

Firestoreでのカテゴリ検索

今回行いたいのは「複数フィールドに対する複数カテゴリによる絞り込み」です。

例えば、あるコミュニティサービスがあったとして、ユーザーの「趣味」と「好きな音楽ジャンル」で検索をかけたい、として実現方法を考えていきます。

前提条件

  • 各フィールドに登録可能なカテゴリ数は有限数とします。実際に運用するサービスだとすると、ユーザーが登録できるカテゴリ数の合計は100程度だとします。
    • Firestoreの制限で、単一ドキュメントに保存できるデータの容量の制限があるので注意してください。無限に増え続けるようなデータは本記事では想定していません。

以下のようにusersコレクションにUserドキュメントが格納されているとします。

userDocument.json
{
   "users":[
      {
         "userID":{
            "hobbies":[
               "soccer",
               "shopping"
            ],
            "favoriteMusicGenre":[
               "jpop",
               "rock"
            ]
         }
      }
   ]
}

(上記では、soccerrockなど直接カテゴリ名を格納していますが、IDとかでもいいです)

素直にクエリする方法を考えてみる

inクエリかarray-contains-anyを使う

Firestoreでは、arrayフィールドに指定した値が含まれているかを条件とするinクエリと、指定した配列内の要素がフィールドの値にマッチするかを条件とするarray-contains-anyクエリが提供されています。

要件にも依りますが、これらのクエリを使って以下のような実装方法が考えられます。

各フィールドのwhere句の条件に値を一つ指定する
queryByValue.ts
const snapshot = await usersCollection
    .where("hobbies", "in", "soccer")
    .where("favoriteMusicGenre", "in", "jpop")
    .get()
各フィールドのwhere句の条件に配列を指定する
queryByArray.ts
const snapshot = await usersCollection
    .where("hobbies", "array-contains-any", ["soccer", "shopping"])
    .where("favoriteMusicGenre", "array-contains-any", ["jpop", "rock"])
    .get()

上手くいきそうですが、上記はどちらも使えません。次の理由です。

  • クエリの制限事項により、in,array-contains-anyは一つのクエリにつき一回しか使えない
  • そもそもinは、配列フィールドに対しては使えない

クエリ用のデータ構造を考える

上記のように、素直に「複数フィールドに対する複数カテゴリによる絞り込み」を実装するのは難しい、と分かりました。
ここで、もちろんAlgoliaを使うのもありですが、今回はFirestoreだけを使って実現する方法を考えてみます。

Firestore だけで Algolia を使わず全文検索を参考に、クエリする為に以下のようなデータ構造を考えました。

下記のデータ構造では、当初のusersコレクションの設計ではarrayだったhobbiesfavoriteMusicGenreを、各要素をkeyとしてvalueにtrueを格納するMapにしています。

userQueries.json
{
   "userQueries":[
      {
         "userQueryID":{
            "userID": "Xjkfaik3fkadkfjst"
            "hobbies":[
               {
                  "soccer":true,
                  "shopping":true
               }
            ],
            "favoriteMusicGenre":[
               {
                  "jpop":true,
                  "rock":true
               }
            ]
         }
      }
   ]
}

実際のクエリ

上記のようなデータ構造にすることで、以下のようなクエリで検索ができます。

categoryQuery.ts
// 検索条件
const hobbyParams = ["soccer", "shopping"]
const musicGenreParams = ["jpop", "rock"]

let query = userQueriesCollection as Query

// クエリ追加処理
hobbyParams.forEach((hobby) => {
    query = query.where(`hobbies.${hobby}`, "==", true)
})
musicGenreParams.forEach((genre) => {
    query = query.where(`favoriteMusicGenre.${genre}`, "==", true)
})

const snapshot = await query.get()

ページング

今回の方法では、Firestoreでよく用いられる、ドキュメントを最新更新時刻でorderByするような方法は使えません。
 
しかし、ドキュメントはID順に取得されるので、各ドキュメントのdocumentIDをTimestamp値を元にして作成しておけば、Timestamp順に取得することが出来ます。

(その他の順序で取得したい場合は、用途ごとに新たにコレクションを作成して、取得したい順序にdocumentIDを調整する必要があります。)

where句でFieldPath.documentIdを指定しつつlimitと組み合わせれば、ページングが可能です。

categoryQueryPaging.ts
let query = userQueriesCollection as Query

// 一つ前の例のクエリ追加処理
...

// 一度読み込んだドキュメントがあれば、最後に取得したドキュメントIDより後を指定
if(lastDocumentID){
    query = query.where(FieldPath.documentId(), ">", lastDocumentID)
}

// 取得数を制限するlimitを追加
const snapshot = await query.limit(20).get()

このように複数のフィールドに対して、複数のカテゴリを指定した検索をすることができます。

クエリ用のコレクションを用意する

上記のデータ構造を用いることで「複数のフィールドに対して、複数のカテゴリを指定した検索」ができますが、実際のアプリケーションで表示ロジックなどに用いる際には非常に扱いにくいです。(データ構造が検索ロジックに引きずられているのも良くないはず)

なので

ユーザー情報を格納するコレクションとは別で、クエリ用のコレクション(e.g.userQueries)を用意するのが良いと思います。

クエリ用のコレクションを別にするので、以下を前提として設計するとすっきり作れそうです。

  • 検索機能でのユーザー情報取得はクライアントサイドジョインで行う
    • クエリ用ドキュメント取得後userIDフィールドを用いてuserデータを取得してジョインするようにする。
  • Cloud Functionsのトリガーを利用する
    • usersコレクションに変更があったときに、onUpdate/onCreate トリガーでクエリ用のサブクレクションを更新するようにします。

上記のようにすることで、クライアントアプリはクエリの書き込みをしなくて良くなり、また、検索ロジックを外部サービスに移行することになったとしても、比較的少ないコストで移行できるはずです。

実装方法の説明は以上です。

この方法の pros & cons

Firestore だけで Algolia を使わず全文検索で紹介されているpros/consとほとんど同じです。

cons
  • Firestoreでよく用いるorderByを用いた並べ替えができません。
    • ただdocumentIDを工夫すれば、条件ごとに並び順を調整することは出来そうです。
pros
  • 速い
    • 体感かなり速いです
    • コレクション内のドキュメント数が多い場合のパフォーマンスは不明です

まとめ

以上、Firestore だけで Algolia を使わず複数フィールド複数カテゴリでの検索方法について、ご紹介しました。
最後まで読んでくださって有り難うございました。
 
今回の方法には欠点もありますが、個人での開発などAlgoliaやElasticsearchを用いるのはコストが見合わない場面もあるので、こちらの方法を使っていきたいと思います。

ご意見・質問はコメントかTwitterでよろしくお願いします。