Spree 5.4 析取式分面(Disjunctive Faceting):商品列表页选项值过滤计数的工作原理
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
本篇指南围绕 Spree 5.4 的设计文档 Disjunctive Option Value Faceting 展开,讲清商品列表页(PLP)选项值过滤中"选中 Blue 后,Light Blue 的计数从 18 变成 1"这一经典分面计数错误的成因与解法:后端把选中的选项值按选项类型分组,再针对每个选项类型分别计算"排除本类型条件"的析取式计数。读完你可以完整理解 Database 与 Meilisearch 两套 SearchProvider 的对应实现(FiltersAggregator与merge_disjunctive_facets),以及为什么整个方案不需要改动任何 API、SDK 或 Storefront 代码。
问题本质:分面计数不能对"已被过滤的集合"计算
电商 PLP 侧边栏的选项过滤器(颜色、尺寸等)通常依赖facet计数展示每个候选值下还有多少商品。如果计数直接基于"应用全部过滤条件之后的结果集"计算,那么一旦用户在 Color 下选中了 Blue,侧边栏里同属于 Color 的 Light Blue、Red 等值的计数就会塌缩为 0 或 1——因为结果集中只包含蓝色商品。文档给出的典型症状是:选中 Color 下的 "Blue" 后,Light Blue 的计数显示为 1 而不是 18。
正确的行为(业界称为 disjunctive faceting,与 Algolia 的 N+1 分面模式同源)是:
- 同一选项类型内部取析取(OR):在 Color 维度内,Blue、Red、Light Blue 的计数互不压制,各自回答"若我改为选中这个值,还剩多少商品";
- 不同选项类型之间取合取(AND):Size 维度的计数仍然要反映 Color=Blue 的约束(选了蓝色后,"S" 的计数只统计蓝色 S 码商品)。
关键设计决策:不改 API,服务端按选项类型分组
文档明确了三条"不讨论不偏离"的决策,这也是理解整个实现的框架:
| 决策 | 内容 |
|---|---|
| 不变更 API | with_option_value_ids保持扁平数组参数。后端用单条OptionValue.where(id:).group_by(&:option_type_id)查询在服务端把值按选项类型分组 |
| Meilisearch 采用 N+1 multi-search | 1 个命中查询 + 每个活跃选项类型 1 个分面查询(该查询排除本类型值),通过/multi-search单次 HTTP 调用批量下发 |
| Database 端由 FiltersAggregator 承担 | 接收按类型分组的选项值,针对"排除本类型过滤"的 scope 逐类型计算计数 |
这个设计带来一个直接好处:请求参数、SDK 的ProductListParams.with_option_value_ids、Storefront 的ActiveFilters.optionValues: string[]、过滤响应结构全部保持不变,对 5.4 之前的客户端完全透明。参数形态例如:
q[with_option_value_ids][]=optval_blue&q[with_option_value_ids][]=optval_small注意值使用带前缀的 ID(optval_+ Sqids 编码),解码统一走Spree::OptionValue.decode_prefixed_id。
服务端分组:一次查询得到{ option_type_id => [option_values] }
两个 Provider 共用的第一步是"分组"。Meilisearch Provider 中的实现位于 group_option_values_by_type:
# Group prefixed option value IDs by option type (single DB query). # Returns { option_type_id => ['optval_abc', 'optval_def'], ... } def group_option_values_by_type(prefixed_ids) prefixed_ids = prefixed_ids.flatten.compact.select { |id| valid_prefixed_id?(id) } return {} if prefixed_ids.empty? raw_ids = prefixed_ids.filter_map { |id| Spree::OptionValue.decode_prefixed_id(id) } Spree::OptionValue.where(id: raw_ids).group_by(&:option_type_id).transform_values { |ovs| ovs.map(&:prefixed_id) } end与文档草图相比,实际实现多了前缀 ID 合法性校验(valid_prefixed_id?匹配/\A[a-z]+_[A-Za-z0-9]+\z/),并把值统一转回前缀 ID 以便直接拼进 Meilisearch 过滤条件。产物是{ option_type_id_1 => [ov_a, ov_b], option_type_id_2 => [ov_c] }这样的哈希——足以分别构造"含全部类型"的命中过滤与"逐类型排除"的分面过滤。
Meilisearch Provider:1 + N 的 multi-search 与分面合并
核心逻辑在 execute_search。当请求带有选项值过滤(extract_and_delete(filters, 'with_option_value_ids')取出)且需要返回分面时,构造多路查询:
if return_facets && grouped_options.any? queries = [{ indexUid: index_name, q: query.to_s, **search_params }] option_type_ids_ordered = grouped_options.keys option_type_ids_ordered.each do |option_type_id| without_this = build_grouped_option_conditions(grouped_options.except(option_type_id)) queries << { indexUid: index_name, q: query.to_s, filter: base_conditions + without_this, facets: ['option_value_ids'], page: 1, hitsPerPage: 0 } end results = client.multi_search(queries) ms_result = results['results'][0] facet_distribution = merge_disjunctive_facets(ms_result, results['results'][1..], option_type_ids_ordered) else ms_result = client.index(index_name).search(query.to_s, search_params) facet_distribution = ms_result['facetDistribution'] || {} end三个要点:
- 主查询携带全部过滤条件(类型内 OR、跨类型 AND),负责命中列表与
totalHits; - 每个活跃选项类型追加一条分面查询:
filter用build_grouped_option_conditions(grouped_options.except(option_type_id))生成——即"去掉本类型选中值、保留其他类型约束",hitsPerPage: 0表示只要facetDistribution不要命中; - 所有查询经
client.multi_search(queries)一次 HTTP 往返完成,这正是文档所说"Single HTTP call via/multi-search",把 N+1 的 RTT 开销压成 1。
分面结果在 merge_disjunctive_facets 中合并:
def merge_disjunctive_facets(ms_result, disjunctive_results, option_type_ids_ordered) main_ov_dist = ms_result.dig('facetDistribution', 'option_value_ids') || {} # ... 解码各分面查询的 option_value_ids 分布,建立 prefixed_id => option_type_id 映射 merged_ov_dist = main_ov_dist.dup disjunctive_dists.each do |option_type_id, dist| dist.each do |pid, count| merged_ov_dist[pid] = count if prefixed_to_type[pid] == option_type_id end end (ms_result['facetDistribution'] || {}).merge('option_value_ids' => merged_ov_dist) end合并规则是只信任"排除本类型"的那条查询:某个选项值属于哪个选项类型,就用对应排除查询里的计数覆盖主查询的计数。主查询的option_value_ids分布因此只作为"未选中任何值时"的基线,选中值之后会被逐类型覆盖——这保证了 Red 显示的是"其他类型约束下含 Red 的商品数",而不是"Blue AND Red"的数量。价格、库存、类目等其他分面不受影响,继续来自主查询。
两个工程细节值得注意:
- 单类型快速路径:文档约束里提到"若只有 1 个选项类型活跃可跳过 multi-search"。从源码结构看,实际条件是
return_facets && grouped_options.any?才走 multi-search;而当只有单一类型活跃时,主查询本身(它没有对本类型做排除……严格地说单类型场景主查询的 facet 分布对"其他类型"无影响),单条查询的分面计数对其他选项类型仍是准确的,实现通过grouped_options.any?的分支天然覆盖了文档描述的优化意图; - 跨类型 AND 必须落在同一个 variant 上:
build_grouped_option_conditions(L483-L490)在轴数 ≥ 2 时会使用索引侧预写的option_value_combination_ids组合 token(每整组组合一个 token,而非成对),避免"蓝 S 与红 L 分属两个 variant 也能被 (Blue OR Red) AND (S OR L) 命中"的跨 variant 假阳性。若索引中没有足够宽度的 token(如自定义 presenter 未写 token),option_exact?判定失败,search_and_filter会把这批 ID 存入@inexact_option_value_ids,随后用数据库侧的scope.with_option_value_ids精确收窄,Meilisearch 只起预过滤作用。
Database Provider:scope_before_options与FiltersAggregator
Database 路径的实现分布在两处。先说入口:Spree::SearchProvider::Database#filters 在应用文本搜索与 Ransack 过滤之后、选项值过滤之前截取了中间 scope:
def filters(scope:, query: nil, filters: {}) # ... option_value_ids = Array(filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)) # 应用文本搜索 + ransack 过滤(不含选项值) scope_before_options = apply_search_and_filters(scope, query: query, filters: filters) # 选项值过滤应用于最终 scope scope_with_options = if option_value_ids.present? scope_before_options.with_option_value_ids(option_value_ids) else scope_before_options end filter_facets = build_facets(scope_with_options, category: category, sort_order: collection&.sort_order, option_value_ids: option_value_ids, scope_before_options: scope_before_options) # ... end注意这与文档草图中的apply_option_filters(逐类型多次调用scope.with_option_value_ids)不同:实际实现复用了 Spree::Product.with_option_value_ids 这一统一 scope。该 scope 内部同样做了"按选项类型分组",并要求跨类型的 AND 落在同一个 variant 上——用GROUP BY variant.id HAVING COUNT(DISTINCT option_type_id) = 分组数精确表达"一个 variant 同时覆盖所有选中轴":
def self.with_option_value_ids(*ids) # ... grouped = OptionValue.where(id: actual_ids).group_by(&:option_type_id) return none if grouped.empty? matching_product_ids = Variant.where(deleted_at: nil). joins(option_value_variants: :option_value). where(OptionValue.table_name => { id: actual_ids }). group(Variant.arel_table[:id]). having(OptionValue.arel_table[:option_type_id].count(true).eq(grouped.size)). select(:product_id) where(id: matching_product_ids) end相关测试 验证了三层语义:同类型 OR(Blue OR Red 命中全部 3 个产品)、跨类型 AND(Blue AND S 只命中同时具备两者的产品)、混合((Blue OR Red) AND S 排除 Blue+L)。这也印证了文档"Constraints"一节的第一条:with_option_value_ids的 Ransack 兼容 scope 必须对直接 Ransack 使用场景继续有效——它现在是独立于 SearchProvider 的模型级能力。
析取式计数的核心在 Spree::Api::V3::FiltersAggregator。构造函数新增两个参数(L11-L18):option_value_ids(当前选中的前缀 ID)与scope_before_options(未应用选项值过滤的 scope),后者缺省回退为scope以兼容旧调用方。计数分发在 option_type_filters 中按"是否已有选中值"分两条路径:
# Batch counts if grouped_selected_options.empty? counts = batch_option_value_counts(@scope_before_options, all_ov_ids) else scope_groups = option_types.group_by { |ot| disjunctive_scope_for(ot) } counts = {} scope_groups.each do |scope, types| ov_ids = types.flat_map { |t| ov_rows_by_type[t.id]&.map(&:first) || [] } counts.merge!(batch_option_value_counts(scope, ov_ids)) end end其中grouped_selected_options(L237-L246)与文档的group_option_values_by_type一一对应:解码前缀 ID 后Spree::OptionValue.where(id: decoded).group_by(&:option_type_id),一条查询完成分组并缓存。
真正的析取语义由 disjunctive_scope_for 实现——它返回"应用了除本选项类型外所有选中类型"的 scope:
# Returns the scope with all option type filters EXCEPT the given one applied. # This gives disjunctive counts: selecting Blue still shows Red's true count. def disjunctive_scope_for(option_type) return @scope_before_options if grouped_selected_options.empty? other_groups = grouped_selected_options.except(option_type.id) # If this type has selections but no other types do, use scope before any option filters return @scope_before_options if other_groups.empty? # Rebuild: start from scope before options, apply only other option types scope = @scope_before_options other_groups.each_value do |ov_ids| matching = Spree::Variant.where(deleted_at: nil) .joins(:option_value_variants) .where(Spree::OptionValueVariant.table_name => { option_value_id: ov_ids }) .select(:product_id) scope = scope.where(id: matching) end scope end这里有两个分支精确对应文档与实现的语义:没有任何选中值时退回全量 scope(普通 faceting);某类型有选中值但其他类型没有时,也退回scope_before_options——因为此时"排除本类型"等于没有任何选项约束,单条分面查询就足够(这正是数据库侧的"单类型跳过"优化)。计数本身由 batch_option_value_counts 完成:对每个候选 scope 用一条带INNER JOIN variants ... AND variants.deleted_at IS NULL的分组 COUNT 查询批量算出该 scope 内所有选项值 ID 的商品数,避免逐值查询。代码里还有一处性能设计:scope_groups = option_types.group_by { |ot| disjunctive_scope_for(ot) }会把"析取 scope 相同"的选项类型归并(例如所有未被选中的类型共享同一个scope_before_options衍生 scope),相同的 scope 只发一条批量计数查询。
用测试固化语义:Blue 之下 Red 必须显示真实计数
filters_aggregator_spec.rb 的 'disjunctive option facet counts' 小节 是这套方案的行为契约。测试构造了 Blue+S 与 Red+M 两个产品,然后:
# 断言 1:同类型内取析取 —— 过滤 Blue 后,Red 的计数是 1 而不是 0 filtered_scope = scope.with_option_value_ids([blue.prefixed_id]) aggregator = described_class.new( scope: filtered_scope, currency: currency, category: nil, option_value_ids: [blue.prefixed_id], scope_before_options: scope ) red_option = color_filter[:options].find { |o| o[:name] == 'red' } expect(red_option[:count]).to eq(1) # 断言 2:跨类型取合取 —— 过滤 Blue 后,Size 只统计蓝色产品 # S 计数 = 1(blue_product 是 Blue+S);M 根本不在结果中(无 Blue+M 产品) expect(s_option[:count]).to eq(1) expect(m_option).to be_nil这两条断言完整刻画了"同类型 OR、跨类型 AND"的边界:析取只发生在被选中的那个选项类型内部。响应组装上,build_option_type_results 会用next if count.zero?丢弃计数为 0 的选项值,所以"Blue+M 不存在"直接体现为 M 选项消失。
不变的部分与迁移路径
文档强调的"什么保持不变"在代码中可以逐一核对:
- API 参数:仍是扁平的
q[with_option_value_ids][]数组(见 docs/api-reference/store-api/querying.mdx 对 Store API 查询参数的说明); - SDK:
ProductListParams.with_option_value_ids未变,可参考 packages/sdk/examples/products/list.ts; - 响应结构:filters 数组中
type: 'option'的项依然是{ id, name, label, kind, options: [{ id, name, label, position, color_code, image_url, count }] },只是count的含义从合取改为按类型析取。
迁移路径(文档 Migration Path 一节)与仓库现状一致:Meilisearch 端拦截with_option_value_ids→ 分组 → N+1 multi-search → 合并分面;Database 端拦截同参数 → 保存scope_before_options→ 交给FiltersAggregator按类型计算析取计数;SDK/Storefront/API 零改动。
边界与约束小结
- 索引宽度限制:Meilisearch 的组合 token 深度由 presenter 的
MAX_COMBINATION_AXES决定(max_combination_axes读取自presenter_class),过滤轴数超过 token 宽度时退回"逐轴并集 + 数据库精确收窄"的兜底,代价是页数可能偏少(文档接受该折衷); - Ransack 直用兼容:
with_option_value_ids作为Spree::Product的类级方法保留,直接 Ransack/AR 使用不受 SearchProvider 影响; - 分面查询成本:每多选中一个选项类型,多一条
hitsPerPage: 0的轻量查询,且与主查询合并为一次/multi-search往返;Database 侧则通过 scope 去重(group_by { |ot| disjunctive_scope_for(ot) })把批量计数查询压到"不同析取 scope 数"条; - 适用前提:以上行为属于 Spree 5.4 引入、当前仓库主干中的实现;选项值必须开启
filterable(option_type_filters只取Spree::OptionType.filterable),且 Meilisearch 路径要求索引包含option_value_ids、option_value_combination_ids等内置 filterable 属性(见 BUILT_IN_FILTERABLE_ATTRIBUTES)。
参考
- 设计文档:docs/plans/5.4-disjunctive-option-faceting.md(本文骨架来源,含 Key Decisions 与 Constraints 原文)
- 前置依赖:5.4-search-provider.md(SearchProvider 接口定义)
- Database 端实现:FiltersAggregator、Database SearchProvider、with_option_value_ids scope
- Meilisearch 端实现:SearchProvider
- 行为测试:FiltersAggregator 析取计数 spec、Database 过滤语义 spec
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考