如何为 Spree 编写自定义搜索 Provider 接入 Typesense 或 Algolia?
【免费下载链接】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
当你自带的产品搜索需要接入 Typesense、Algolia 这类外部搜索引擎(而不是 Spree 内置的 Meilisearch),就需要给 Spree 写一个自定义搜索 Provider。Spree 的 Store API 请求会先由控制器构建一个用于安全与可见性的 ActiveRecord scope(store.products.active(currency).accessible_by(ability)),然后把搜索、过滤、排序、分面查询全部委托给 Provider。也就是说,只要你的 Provider 实现了规定的方法契约,前端和 API 客户端不需要任何改动——同样的q[search]、过滤参数和排序选项在任何 Provider 下都可用。
适用前提:你已经理解 Spree 的搜索与过滤机制。如果 Meilisearch 能满足需求,不必自写 Provider,按内置Meilisearch 集成文档配置即可(这是文档明确给出的替代路径,不是本文主路径)。
准备条件
- 一个 Spree 应用(Rails 环境),能修改
config/initializers/spree.rb; - 搜索引擎的 Ruby 客户端,例如 Typesense 需要
gem 'typesense'(代码示例中 Provider 初始化时会require 'typesense',缺 Gem 会直接报Add gem 'typesense' to your Gemfile); - 引擎中已准备好对应的 collection/index。
Provider 基类与契约定义在 Spree::SearchProvider::Base 中,indexing_required?默认返回false,索引、删除、批量重建等索引方法默认是 no-op——这些都要在你的子类中覆盖。
第一步:创建 Provider 类
类必须继承Spree::SearchProvider::Base并实现search_and_filter。以 Typesense 为例(文档示例路径为app/models/my_app/search_provider/typesense.rb):
module MyApp module SearchProvider class Typesense < Spree::SearchProvider::Base # Enable background indexing jobs def self.indexing_required? true end def initialize(store) super require 'typesense' rescue LoadError raise LoadError, "Add `gem 'typesense'` to your Gemfile" end end end endindexing_required?返回true会让SearchIndexableconcern 在产品创建、更新、销毁的after_commit时入队后台索引任务(IndexJob/RemoveJob)。这个 concern 挂在 Product 模型上,实现见 SearchIndexable。
第二步:实现search_and_filter
这是核心方法。它接收一个已经做好安全过滤的 base scope,必须返回一个SearchResult:
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25) page = [page.to_i, 1].max limit = limit.to_i.clamp(1, 100) # 1. Query your search engine with locale/currency filtering results = client.collections[index_name].documents.search({ q: query || '*', query_by: 'name,description,sku,option_values,category_names,tags', filter_by: build_filters(filters), sort_by: build_sort(sort), page: page, per_page: limit, facet_by: 'in_stock,price,category_ids,option_value_ids' }) # 2. Extract product IDs (documents have composite IDs, extract product_id) product_ids = results['hits'].map { |h| h['document']['product_id'] }.uniq raw_ids = product_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) } # 3. Intersect with AR scope (safety net for authorization) products = raw_ids.any? ? scope.where(id: raw_ids).reorder(nil) : scope.none # 4. Build Pagy for pagination metadata require 'pagy' pagy = Pagy::Offset.new(count: results['found'], page: page, limit: limit) # 5. Return a SearchResult Spree::SearchProvider::SearchResult.new( products: products, filters: build_facet_response(results['facet_counts']), sort_options: %w[price -price name -name best_selling -available_on].map { |id| { id: id } }, default_sort: 'manual', total_count: results['found'], pagy: pagy ) end文档中有一条明确的警告:务必在搜索引擎一侧按locale、currency、store_ids、status='active'、discontinue_on过滤,而不能只依赖 AR scope,否则分页计数不准确。AR scope 只是授权安全网,不是主过滤器。
SearchResult的构造字段(来自文档):
Spree::SearchProvider::SearchResult.new( products: ar_relation, # ActiveRecord::Relation filters: [...], # Array of facet hashes sort_options: [{ id: 'price' }, ...], # Array of sort option objects default_sort: 'manual', # Default sort string total_count: 150, # Total before pagination pagy: pagy_object # Pagy::Offset or Pagy::Meilisearch )其中total_count和pagy示例数值(150 等)是文档示例,不是固定预期。
第三步:实现单产品索引与删除
产品按market × locale组合索引,每个组合一篇文档,由ProductPresenter自动处理:
def index(product) documents = Spree::SearchProvider::ProductPresenter.new(product, store).call documents.each do |doc| client.collections[index_name].documents.upsert(doc) end end def remove(product) remove_by_id(product.prefixed_id) end def remove_by_id(prefixed_id) # Delete all locale/currency variants of this product client.collections[index_name].documents.delete( filter_by: "product_id:=#{prefixed_id}" ) endProductPresenter#call返回数组而非单个文档。例如一个拥有 US(USD/英语)和 EU(EUR/德语+法语)市场的商店,一个产品会生成 3 篇文档,每篇带有扁平的name、price、locale、currency字段(文档示例):
# => [ # { prefixed_id: "prod_abc_en_USD", product_id: "prod_abc", locale: "en", # currency: "USD", name: "Blue Shirt", price: 29.99, ... }, # { prefixed_id: "prod_abc_de_EUR", product_id: "prod_abc", locale: "de", # currency: "EUR", name: "Blaues Hemd", price: 27.50, ... } # ]注意remove_by_id接收的是 prefixed ID(如prod_abc),因为调用时产品记录可能已被删除。
第四步:实现批量重建索引
用preload_associations_lazily避免 N+1 查询:
def reindex(scope = nil) scope ||= store.products ensure_index_settings! scope.reorder(id: :asc) .preload_associations_lazily .find_in_batches(batch_size: 500) do |batch| documents = batch.flat_map { |p| presenter_class.new(p, store).call } index_batch(documents) end end def index_batch(documents) client.collections[index_name].documents.import(documents, action: 'upsert') end def ensure_index_settings! # Create/update your collection schema here end文档特别指出:这里必须用flat_map而不是map,因为ProductPresenter#call每个产品返回的是文档数组。
ensure_index_settings!是可选方法(文档契约表中标记为 No),用于创建或更新索引 schema,会被reindex和 rake 任务调用。
方法契约速查
| 方法 | 是否必须 | 说明 |
|---|---|---|
search_and_filter(scope:, query:, filters:, sort:, page:, limit:) | 必须 | 搜索、过滤、排序、分页并返回 facets,必须返回SearchResult |
self.indexing_required? | 必须 | 返回true以启用后台索引任务 |
index(product) | 必须 | 索引单个产品 |
remove(product) | 必须 | 从索引删除该产品的全部 locale/currency 变体 |
remove_by_id(prefixed_id) | 必须 | 按 prefixed ID 删除(产品可能已不存在) |
reindex(scope) | 必须 | 批量重建,配合ensure_index_settings!+ 批量索引 |
index_batch(documents) | 必须 | 索引一批已序列化的文档 |
ensure_index_settings! | 可选 | 配置索引 schema |
第五步:注册 Provider 并重建索引
在config/initializers/spree.rb中按类名注册:
Spree.search_provider = 'MyApp::SearchProvider::Typesense'然后执行重建:
rake spree:search:reindex这个 rake 任务定义在 search.rake:它会遍历所有 Store,对每个 store 实例化 Provider 并调用provider.reindex(store.products.preload_associations_lazily),控制台会输出类似Reindexing {store_name} ({total} products) using {provider}...和完成行Done. ... documents enqueued.(具体数值随你的目录变化)。
另外两点约束:
- 必须使用 prefixed ID(
ctg_abc、prod_xyz、opt_abc)做索引,永远不要用原始数据库 ID——Spree 支持 UUID 主键。 - 在
MetafieldDefinition上标记为searchable/sortable的产品 metafield 会通过Spree::SearchProvider::MetafieldSchema投影进索引(provider 与ProductPresenter共用)。用 Meilisearch 时改完 definition 后要跑rake spree:search:reindex;cf_*字段的文档整形依赖search_product_presenter。
验证结果
按以下顺序核对(均为文档给出的方式):
- 预览将要索引的文档:
product.search_presentation返回即将发送到 provider 的文档内容,可显式传 store:product.search_presentation(store)。这也是调试索引内容的首选手段。 - 同步索引/删除(不走后台任务):
product.add_to_search_index和product.remove_from_search_index,适合导入数据或即时校验 provider 是否真正收到写入。 - 重建任务输出:
rake spree:search:reindex正常完成并打印上述完成信息。 - Store API 行为:由于任何 Provider 下参数不变,直接请求产品列表验证:
curl 'https://api.mystore.com/api/v3/store/products?q[search]=tote+bag&limit=12' \ -H 'X-Spree-API-Key: pk_xxx'curl 'https://api.mystore.com/api/v3/store/products/filters' \ -H 'X-Spree-API-Key: pk_xxx'其中pk_xxx是文档示例中的占位值,替换为你自己的 Store API key;返回结构(filters、sort_options、default_sort、total_count等)见搜索与过滤文档。
关于 Algolia 及其他引擎
上面的示例代码(Typesense 的query_by/facet_by/filter_by语法、client.collections调用方式)是 Typesense 引擎特定的写法,但 自定义搜索 Provider 指南 的适用对象明确是 "Typesense, Algolia, or Elasticsearch" 这类外部搜索引擎:Provider 契约(方法名、SearchResult结构、prefixed ID、ProductPresenter用法、注册与 reindex 流程)与引擎无关。接入 Algolia 时,需要把search_and_filter、index、index_batch等内部的引擎请求语法换成 Algolia 客户端的等价写法,但对外接口、索引生命周期和验证步骤不变。
索引生命周期小结
| 触发点 | 行为 |
|---|---|
产品 create/update 的after_commit | indexing_required?为true时入队IndexJob |
产品 destroy 的after_commit | 入队RemoveJob(用prefixed_id,此时记录可能已删除) |
product.add_to_search_index | 同步内联索引,用于批量导入或需要即时生效的场景 |
product.search_presentation | 预览将要索引的文档 |
rake spree:search:reindex | 全量重建所有 store 的索引 |
后台任务机制见 SearchIndexable concern,基类契约见 Base。完成 Provider 后,如果后续目录规模或需求匹配内置方案,也可以改回Spree.search_provider = 'Spree::SearchProvider::Meilisearch'(Meilisearch 集成),切换本身不需要改动任何客户端或 API 调用。
【免费下载链接】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),仅供参考