はじめに
こちらの記事を読みました。
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ドキュメントが格納されているとします。
{
"users":[
{
"userID":{
"hobbies":[
"soccer",
"shopping"
],
"favoriteMusicGenre":[
"jpop",
"rock"
]
}
}
]
}
(上記では、soccer
やrock
など直接カテゴリ名を格納していますが、IDとかでもいいです)
素直にクエリする方法を考えてみる
in
クエリかarray-contains-any
を使う
Firestoreでは、arrayフィールドに指定した値が含まれているかを条件とするin
クエリと、指定した配列内の要素がフィールドの値にマッチするかを条件とするarray-contains-any
クエリが提供されています。
要件にも依りますが、これらのクエリを使って以下のような実装方法が考えられます。
各フィールドのwhere
句の条件に値を一つ指定する
const snapshot = await usersCollection
.where("hobbies", "in", "soccer")
.where("favoriteMusicGenre", "in", "jpop")
.get()
各フィールドのwhere
句の条件に配列を指定する
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だったhobbies
とfavoriteMusicGenre
を、各要素をkeyとしてvalueにtrue
を格納するMapにしています。
{
"userQueries":[
{
"userQueryID":{
"userID": "Xjkfaik3fkadkfjst"
"hobbies":[
{
"soccer":true,
"shopping":true
}
],
"favoriteMusicGenre":[
{
"jpop":true,
"rock":true
}
]
}
}
]
}
実際のクエリ
上記のようなデータ構造にすることで、以下のようなクエリで検索ができます。
// 検索条件
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
と組み合わせれば、ページングが可能です。
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でよろしくお願いします。