Zenn
💎

Algoliaで実現するファセットナビゲーション: 実装と課題解決

2025/03/31に公開

レンティオでエンジニアをしているサヤマです。

2025年1月にレンティオの商品一覧画面(分類画面)をリニューアルしました。月間数百万PVのサイトで商品一覧ページをベストプラクティスに沿って改善した話でいざ実装しようとなった時の過程を記載します!

1. 調査

まずは要件とワイヤーフレームがあったのですが、実装イメージが湧かなかったのでそこから調査を始めました。
デザイナーを交えてプロトタイプデザインを作りました。

プロトタイプデザイン
プロトタイプデザインはこんな感じでした

リニューアル前は商品一覧画面(分類画面)ではAlgoliaを使って商品情報をReactで表示させていました。次の調査はそれをファセットナビゲーションにできるのか?を確認していきます。

ナビゲーション自体はAlgoliaが公開しているJavaScriptを使えば簡単に実現できることはわかったのですが、SEO対策として商品もSSRで取れるとうれしい!というのと、ファセットナビゲーションの表示部分で選択された場合にページ遷移させたいという話があったので、これは合いませんでした。残念!

なので、レンティオのServerからAlgoliaのAPIコールして商品一覧を表示させる方向に落ち着きました。
もともとGemでalgoliasearch-railsを使っていたので、簡単な検証を行い問題ないことがわかりました。

# 簡単な検証
irb(main):001> json = Variant.raw_search("", { hitsPerPage: 1, maxValuesPerFacet: 1000, filters: "searchable:true", facets: ["taxon_self_and_ancestors", "has_promotion"] })
=> 
{"hits"=> # ここに商品情報が入ってきます
...

# 分類として、genreには16159商品・makerには14957商品と取得できています
irb(main):002> json.dig("facets", "taxon_self_and_ancestors") # taxon_self_and_ancestorsが一つのfacet
=>
{"genre" => 16159,
 "maker" => 14957,
 "tag" => 7274,
 "scene" => 5507,
 "genre/camera" => 2972,
 "scene/subject-for-photography" => 1291,
 "genre/kaden" => 1217,
 "genre/pc" => 1167,
 "tag/sensor-size" => 1153,
 "scene/wedding" => 1127,
 "tag/lens-type" => 1110,
 "genre/audio" => 1085,
 "genre/camera/lens" => 1078,
 "scene/buyer-select-first-month-free" => 1052,
 "maker/panasonic" => 820,
 "genre/kimono" => 769,
 "maker/sony" => 767,
 "scene/outlet" => 764,
 "genre/suit" => 714,
 "genre/dress" => 660,
 "genre/kitchen" => 643,
 "scene/overseas-trip" => 615,
Preparing full inspection value...

分類とそれに紐つく商品数が取得できています!良さそうですね!
ただ、AlgoliaではAPIで取得可能な分類の数は1,000までという制約があり、つまり1つのfacetでファセットナビゲーションを実現させようとすると、分類は1,000以上表示できなくなります。(プロトタイプデザインの左側のナビゲーションの部分です)
そもそもそんなに必要か?という話にもなりますが、分類も階層構造(genre/cameraにgenre/camera/lensなどがぶら下がっている状態)になっているので、すべてを合わせると1,000は超えてしまいます。
これは対策する必要がありますね。
あと、Algoliaの契約プランは従量課金のプランなので、APIコール数を気にする必要があることもわかってきました。

ある程度調査ができて、やることがわかってきたら今度はタスク分解して実装を進めていきます。

2. いざ実装へ

タスク分解も終わったので、いよいよタスク単位で実装を進めます。
しかし、いざ実装を進めていくといくつか問題が見つかりました。以下にその一部を記載します。

ファセットナビの順番をどう管理するか

Algoliaから分類を取得すると、デフォルトでは、商品数の降順で取得されます

irb(main):001> json = Variant.raw_search("", { hitsPerPage: 0, maxValuesPerFacet: 1000, filters: "searchable:true", facets: ["genre_self_and_ancestors", "has_promotion"] })
irb(main):002> json.dig("facets", "genre_self_and_ancestors")
=>
{
 "genre" => 16159,                           # ジャンル
 "genre/camera" => 2972,                     # カメラ
 "genre/camera/lens" => 1078,                # カメラレンズ
 "genre/kitchen" => 643,                     # キッチン家電
 "genre/camera/ichigan" => 561,              # 一眼レフカメラ
 "genre/mobile-wifi" => 501,                 # モバイルWiFi
 "genre/kitchen/rice-cooker" => 96,          # 炊飯器
 "genre/kitchen/compact-refrigerator" => 66, # 冷蔵庫
}

しかし、表示はこのようにしたい

ジャンル(16159)
├── カメラ(2972)
    ├── カメラレンズ(1078)
    ├── 一眼レフカメラ(561)
├── キッチン家電(643)
    ├── 炊飯器(96)
    ├── 冷蔵庫(66)
├── モバイルWiFi(501)

そこでソートをうまく使い、上から表示したい順に取得するように対応しました。
あとは表示側で子階層だったらインデントつけるとかで階層表示ができますね!

irb(main):001* def self_and_ancestors_permalinks(permalink)
irb(main):002*   paths = permalink.split("/")
irb(main):003*   Array.new(paths.count) { |i| paths[0..i].join("/") }
irb(main):004> end
irb(main):005> taxons = json.dig("facets", "genre_self_and_ancestors")
irb(main):006>* taxons.map do |k,v|
irb(main):007>*   permalinks = self_and_ancestors_permalinks(k)
irb(main):008>*   sort_key = permalinks.flat_map { |path| [-taxons[path].to_i, path] }
irb(main):009>*   OpenStruct.new(k:, v:, sort_key:)
irb(main):010> end.sort_by(&:sort_key).pluck(:k,:v).to_h
=>
{"genre" => 16159,
 "genre/camera" => 2972,
 "genre/camera/lens" => 1078,
 "genre/camera/ichigan" => 561,
 "genre/kitchen" => 643,
 "genre/kitchen/rice-cooker" => 96,
 "genre/kitchen/compact-refrigerator" => 66,
 "genre/mobile-wifi" => 501}

viewはどう管理するのがよいか

レンティオでは、テンプレートエンジンとしてSlimを使っています。
そのまま使おうとするとSlimコードの肥大化になったり、可読性が低下するという懸念がありました。

そこで、ViewComponentを採用しました。
ViewComponentで分けることで、分類独自のテンプレートを作り込むことができ、条件分岐も最小限で済むようになりました。

view_componentを採用して得られたメリット

  1. コンポーネント単位での管理
    UIをコンポーネント単位で分割することで、コードの再利用性が向上しました。たとえば、ファセットナビゲーションの各分類(ジャンル、メーカー、シーンなど)を独立したコンポーネントとして管理することで、変更や追加が容易に
    ※ 今回のケースでは、ジャンルとほかの分類で表示が異なるので、2つのview_componentで分類部分を分けられる(TopComponentを含めると3つ)

view_component
view_componentの使い分け

  1. 可読性の向上
    複雑なロジックをテンプレートから切り離し、コンポーネント内に閉じ込めることで、テンプレート自体がシンプルになり、可読性が大幅に向上

  2. テストの容易さ
    各コンポーネントを独立してテストできるため、UIの動作確認が効率的に行えるようになりました。特に、ファセットナビゲーションのような動的なUIでは、コンポーネント単位でのテストが非常に有効

  3. メンテナンス性の向上
    コンポーネントごとに責務を分離することで、特定のUI部分を修正する際にほかの部分への影響を最小限に抑えられる

ファセットナビゲーションのURLをどうやって実現させるか

ファセットナビゲーションで、ソニーで検索後・カメラと検索します。
ファセットナビゲーションでの検索は、検索したい項目をすべて選択後に検索するバッチフィルタと検索したい項目を選択したら即検索するインタラクティブフィルタとで分かれており、
レンティオのPCサイトはインタラクティブフィルタを採用しています。(SPはバッチフィルタ)

商品一覧画面と分類の表示に特化した分類画面とがあります。
分類ページでは、カメラを選択後、「パナソニック」を追加で選択した場合でも逆にパナソニックを選択した後に追加で「カメラ」を選択した場合でも同じURLにする配慮が求められました。これらは表示内容が一緒であることから、インデックスされるページ数が必要以上に増加することを防ぐためです。
PCサイトのファセットナビゲーションはリンクにする必要があったので、ページ表示時にリンク先を設定しておく必要があります。URLが異なるならCanonicalでも良いのかもしれませんが、管理しづらい点とアンチパターンということもあったので、同じURLになる様に進めました。

URLパスは必ず(ジャンル・メーカー・シーン・シリーズ・タグ)という順番になる対応をしました。
選択された場合・解除された場合のリンクをどう生成すればよいか?が課題でした。
リファクタリングを重ねた結果、以下のような形に落ち着きパスの順番は保たれる様になりました。

# 商品一覧・分類画面のURLパス生成
# taxonsにはパスを生成する必要のある分類が設定されています
# ここでは、選択された分類を元にURLパスを生成するロジックを記述しています
def build_taxons_path(taxons:, query_parameters: {})
  if taxons.blank? || duplicate_taxonomy_exists?(taxons)
    products_path(t: taxons, **query_parameters) # 商品一覧へのURLパス。大分類が同じ子孫の分類が複数選択されている場合はパラメータにtaxonを持たせる管理になっています
  else
    nested_taxons_path(id: nested_taxons_permalinks(taxons).join("/"), **query_parameters) # 分類ページのURLパス
  end
end

private

def nested_taxons_permalinks(taxons)
  %w(genre maker scene series tag).filter_map { |t| taxons.detect(&:"#{t}?")&.permalink }
end

Algolia APIコール数の増大をどう改善するか

AlgoliaではAPIで取得可能な分類が1,000件までという制約があり、facetを分けることでこの問題を回避しました。しかし、その代償としてAlgoliaへのAPIコール数が増大し、コストが嵩むという新たな課題が発生しました。

この課題に対して、以下のような取り組みをしました

  • フラグメントキャッシュの導入:特定の条件を元にフラグメントキャッシュすることで、APIコールの削減をしました
  • APIコール部分の見直し:どの部分でAlgoliaのAPIコールがされているかをログを取りながら徹底的に洗い出し、効率化を図りました
  • robots.txtの見直し:クローラからのアクセスがあり、そのたびにAlgoliaのAPIコールがされていたので、重要なページがインデックスされるように設定を見直しました

何もしなかったらリニューアル前よりも5〜6倍に増えてしまうAlgoliaへのAPIコール数が、これらの取り組み後は3倍程度に落ち着いています。
まだ改善の余地は残されていますが、今回の対応で大きな前進を果たせたと感じています。

AlgoliaのAPIコール数
AlgoliaのAPIコール数の月間推移

3. リリース

開発が完了した後は、徹底した検証作業を行いました。ほかの部署の協力を得ながら、細かなバグを潰していきました。(やはり検証は重要ですね!)
その結果、無事にリリースを迎えることができました。この瞬間は、チーム全員の努力が実を結んだと感じられる、非常に感慨深いものでした。
今回のプロジェクトを通じて得た知見は、今後の開発にも大いに活かせると確信しています。

4. まとめ

以上となります。今回のリニューアルではアクセス数が順調に伸び、具体的な成果を実感できました。このプロジェクトは約6ヵ月という長い開発期間をかけて取り組んだもので、多くの課題を乗り越えた分、非常に思い入れのあるリリースとなりました。

今後も、ユーザー体験を向上させるためにファセットナビゲーションの改善を続けていきます。また、この記事が同様の課題に直面している方々の参考になれば幸いです。

レンティオでは、今回のような挑戦を通じて、より良いサービスを提供するための開発を続けています。しかし、まだまだやりたいことがたくさんあり、エンジニアやPdM、SEOディレクターなどの仲間を必要としています。私たちと一緒に、これからのレンティオをさらに成長させていきませんか?

少しでも興味を持たれた方は、ぜひ以下のページをご覧ください!

Discussion

ログインするとコメントできます