search
LoginSignup
59

More than 1 year has passed since last update.

posted at

updated at

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でよろしくお願いします。

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
What you can do with signing up
59