Webiny Headless CMS OpenSearch 过滤模块 DI 重构:ExecFiltering 特征化改造全解析
Webiny Headless CMS OpenSearch 过滤模块 DI 重构:ExecFiltering 特征化改造全解析
导读
本文围绕 Webiny 开源仓库 docs/.bruno/research/opensearch/filtering.md 这份 DI(依赖注入)分析文档,深入剖析 Headless CMS 中 OpenSearch 查询过滤模块(ExecFiltering)的职责拆分、依赖图谱与容器化改造方案。文档结论明确:该模块应当从"工厂函数 + 局部注入"形态迁移为完整 DI Feature。文章将结合仓库源码(以 packages/api-headless-cms-utils-os 的 features/ 目录为主)逐层还原过滤管线的真实实现、注册链路与迁移前后差异,帮助读者掌握 Webiny 以 createAbstraction/createImplementation/createFeature 为核心的 DI 重构套路,并可直接复用于其他存储层模块。
一、过滤模块现状:五个文件,五重职责
文档开篇即给出了过滤模块的完整结构清单。该模块位于 packages/api-headless-cms-utils-os/src/operations/entry/elasticsearch/filtering/,由 5 个文件构成。重构之后,这些职责被迁移并沉淀为 src/features/CmsEntryOpenSearchExecFiltering/ 目录下的特征组件(目录清单):
| 文件 | 导出 | 职责 | 现状(重构后) |
|---|---|---|---|
exec.ts |
createExecFiltering() |
编排整个过滤管线,递归处理 CmsEntryListWhere |
CmsEntryOpenSearchExecFiltering.ts |
applyFiltering.ts |
createApplyFiltering() |
执行单条过滤:值转换、字段路径解析、调用操作符插件 | applyFiltering.ts |
path.ts |
createFieldPathFactory() |
计算字段存储路径(basePath / path / keyword) | 独立为 CmsEntryOpenSearchFieldPathFactory 特征 |
populated.ts |
getPopulated() |
清理 bool 查询中的 undefined / 空数组 | populated.ts |
values.ts |
getWhereValues() |
校验 AND/OR 条件的数组格式,非法输入抛错 | values.ts |
exec.ts(主执行器)递归消费CmsEntryListWhere:空对象直接返回;AND块构造子查询并入query.filter;OR块构造子查询并入query.should并调用assignMinimumShouldMatchToQuery;values键以isValues: true递归进入值空间过滤;其余键由parseWhereKey拆出字段与操作符后,经字段类型查找到对应 Filter 插件执行(见 CmsEntryOpenSearchExecFiltering.ts)。applyFiltering.ts承担单条过滤的"最后一公里":先按操作符名在operators<a href="https://link.gitcode.com/i/271f4603e0465c89234b692e2c41fb66" target="_blank">operator]中查找插件(缺失即抛PLUGIN_MISSING),再由valueTransformer完成字段类型相关的值转换,用hasKeyword()判断字段是否存在 keyword 变体,最后通过fieldPathFactory生成路径并调用plugin.apply(query, { name, basePath, path, value, keyword })([applyFiltering.ts)。path.ts(字段路径工厂)是纯工厂、无副作用:优先委托CmsEntryOpenSearchValueSearchRegistry中的字段类型专用路径构建器,取不到时回退到字段的storageId(并支持field.path函数式或静态值),最终把父级storageId拼接成basePath,若启用 keyword 则在末尾追加.keyword得到最终path(CmsEntryOpenSearchFieldPathFactory.ts)。populated.ts与values.ts是零依赖的纯工具:前者过滤出 bool 查询中"有值"的子句(undefined与空数组一律剔除,populated.ts);后者负责 AND/OR 入参校验,非数组或空数组均抛出MALFORMED_<AND|OR>_CONDITION错误(values.ts)。
二、依赖全景:注册表已 DI 化,交叉依赖仍走直接调用
文档的"依赖分析"章节是理解本模块的关键。过滤模块的依赖分三层:
-
已由 DI 管理的注册表(注入进
exec.ts):OpenSearchQueryBuilderOperatorRegistry.Interface:操作符插件集合,经createOperatorPluginList生成(在api-opensearch包中,CmsEntryOpenSearchOperatorList将其包装为getAll(),见 CmsEntryOpenSearchOperatorList.ts);CmsEntryOpenSearchValueSearchRegistry.Interface:字段类型级的值搜索路径构建器(同时驱动FieldPathFactory与ValueTransformer);CmsEntryOpenSearchFilterRegistry.Interface:字段类型级过滤器执行器,注册了DefaultFilter、ObjectFilter、RefFilter三个实现(feature.ts),按getBaseFieldType匹配、兜底FILTER_DEFAULT(CmsEntryOpenSearchFilterRegistry.ts)。
-
模块内部交叉依赖(文档给出调用链图示):
exec.ts ──createApplyFiltering──▶ applyFiltering.ts ──createFieldPathFactory──▶ path.ts │ │ └──getWhereValues──▶ values.ts CmsEntryOpenSearchValueSearchRegistry ◀──┘ └──getPopulated────▶ populated.ts即
exec.ts → applyFiltering.ts、exec.ts → values.ts、exec.ts → populated.ts、applyFiltering.ts → path.ts均为模块内的直接工厂/函数调用,尚未纳入容器。 -
非 DI 的外部纯函数依赖(从工具层直接 import):
transformValueForSearch()、hasKeyword()、assignMinimumShouldMatchToQuery()来自 utils;createBaseQuery()来自initialQuery.ts;parseWhereKey()来自@webiny/api-opensearch库。 这些纯函数在重构后原样保留,未做包装(符合仓库"工具保持普通函数、DI 只留给注册表与修改器"的既有惯例,见同目录 sort.md 的结论)。
三、调用方:两个直接消费者与一组遗留测试
文档指出过滤模块仅有 2 个直接调用方,均在 api-headless-cms-utils-os 包内:
-
body.ts(查询体构建入口)——重构前通过filtering/index.js导入createExecFiltering,在execFiltering({ where, query })处调用;重构后body.ts已演化为CmsEntryOpenSearchBodyBuilder特征,其build()内部直接注入CmsEntryOpenSearchExecFiltering.Interface,在排序、全文搜索等步骤之后执行过滤(CmsEntryOpenSearchBodyBuilder.ts)。值得注意的是:body.ts同时是过滤模块的调用方,也是 sort 模块唯一的调用方,属于 DI 改造链上的枢纽。 -
sort.ts(排序字段路径计算)——重构前导入createFieldPathFactory;重构后sort.ts被并入CmsEntryOpenSearchBodyBuilder/sort.ts,排序路径计算改为注入CmsEntryOpenSearchFieldPathFactory.Interface完成(CmsEntryOpenSearchBodyBuilder.ts)。 -
测试文件(
api-headless-cms-ddb-es/__tests__/filtering/):文档特别提醒,这些是另一包变体的遗留测试,并非本模块的直接调用方。不过从源码看,它们已经升级为通过 DI 容器解析CmsEntryOpenSearchExecFiltering并调用impl.execute({ model, fields, where, query })(mocks/filtering.ts),例如eq.test.ts直接断言values.title.keyword的 term 查询与exists否定查询结构(eq.test.ts)。这恰好印证了文档"可测试性提升"的论点:mock 注册表注入后,行为断言变得直接且稳定。
四、DI 候选评估:为什么是"强烈 YES"
文档从 7 个维度论证了将过滤模块转换为 DI Feature 的必要性,源码均可逐一印证:
- 注册表模式已就位:
exec.ts当前签名接收CmsEntryOpenSearchFilterRegistry.Interface与CmsEntryOpenSearchValueSearchRegistry.Interface,说明"部分 DI 已经存在"——欠的只是把工厂函数整体收编进容器。 - 工厂模式天然契合 DI:全部导出均为工厂函数(
createExecFiltering、createApplyFiltering、createFieldPathFactory),这是本仓库 DI 风格的惯用形态(@webiny/feature/api的createAbstraction/createImplementation)。 - 调用方有限、改造范围可控:仅
body.ts、sort.ts两个直接调用方,且同属一个包。 - 与包内既有模式一致:
CmsEntryOpenSearchFilter、CmsEntryOpenSearchValueSearch、CmsEntryOpenSearchBodyBuilder等已完全按该模式落地,过滤模块应当对齐。 - 封装实现细节:将
populated.ts、values.ts收进特征,可防止下游绕过注册表直接 import 内部工具。 - 依赖显式化:容器解析让每个函数"需要什么"一目了然,避免误用错误的注册表实例。
- 可测试性:特征可用 mock 注册表注册;旧工厂方式在测试中必须手工传参,DI 后则通过
testContainer.resolve(...)一步到位(见上述测试代码)。
约束与权衡(文档明示,须如实交代):
populated.ts、values.ts是纯工具,保持原样或包进特征皆可,包装开销极小但提升可发现性;sort.ts对createFieldPathFactory的依赖,必须改为注入工厂或经由服务容器访问;- 向后兼容要求:
body.ts、sort.ts需要从"直接 import"迁移到"容器解析"。
五、目标设计:ExecFiltering 特征化落地方案
文档给出了完整的目标结构——重构后的实际仓库结构与之一一对应:
src/features/CmsEntryOpenSearchExecFiltering/
├── abstractions.ts # CmsEntryOpenSearchExecFiltering 抽象(Interface + Params)
├── CmsEntryOpenSearchExecFiltering.ts # 实现类 + createImplementation + DI 注册
├── applyFiltering.ts # 单条过滤执行器(exec 内部工厂)
├── populated.ts # bool 查询清理
├── values.ts # AND/OR 校验
├── feature.ts # createFeature 注册
└── index.ts # 公开导出
抽象定义(abstractions.ts)——CmsEntryOpenSearchExecFiltering 抽象仅暴露一个 execute(params) 方法,入参为 { model, fields, where, query };同文件以 namespace 导出 Interface 与 Params 类型,与文档中 ICmsEntryOpenSearchExecFiltering 的设计一致。类似的 CmsEntryOpenSearchFieldPathFactory 抽象则暴露 create(params): { basePath, path }(abstractions.ts)。
实现注册——实现类通过构造函数声明四个依赖(OperatorList、ValueTransformer、FieldPathFactory、FilterRegistry),并由 createImplementation 声明依赖数组完成容器装配(CmsEntryOpenSearchExecFiltering.ts):
export const CmsEntryOpenSearchExecFiltering =
CmsEntryOpenSearchExecFilteringAbstraction.createImplementation({
implementation: CmsEntryOpenSearchExecFilteringImpl,
dependencies: [
CmsEntryOpenSearchOperatorList,
CmsEntryOpenSearchValueTransformer,
CmsEntryOpenSearchFieldPathFactory,
CmsEntryOpenSearchFilterRegistry
]
});
Feature 聚合——feature.ts 通过 createFeature 把实现注册进容器(feature.ts):
export const CmsEntryOpenSearchExecFilteringFeature = createFeature({
name: "Cms/Entry/OpenSearch/ExecFilteringFeature",
register: container => {
container.register(CmsEntryOpenSearchExecFiltering);
}
});
更高层地,CmsEntryOpenSearchUtilsFeature 将 FieldPathFactoryFeature、ExecFilteringFeature 与 FieldIndex、Filter、IndexCreate、BodyBuilder、ValueSearch 等特征统一编排注册,构成包级 DI 门面(CmsEntryOpenSearchUtilsFeature.ts)。
消费者改造模式——文档提供了前后对比:
// 改造前(body.ts)
const execFiltering = createExecFiltering({
model, fields: modelFields, operatorRegistry,
valueSearchRegistry, filterRegistry
});
execFiltering({ where, query });
// 改造后(容器感知上下文)
const execFiltering = container.get(CmsEntryOpenSearchExecFiltering);
execFiltering.execute({ where, query });
在 CmsEntryOpenSearchBodyBuilder 中,这一模式体现为构造注入:bodyBuilder 一次性拿到 ExecFiltering、FieldPathFactory 等全部依赖,build() 内直接 this.execFiltering.execute({ model, fields, where, query })(CmsEntryOpenSearchBodyBuilder.ts)。
sort.ts 的两种可选方案(文档给出):
// Option A:注入工厂本身(保留灵活性)
class ElasticsearchSortFactory {
constructor(
private readonly fieldPathFactory: CmsEntryOpenSearchFieldPathFactory.Factory
) {}
}
// Option B:注入构建器实例(更简单)
class ElasticsearchSortFactory {
constructor(
private readonly fieldPath: CmsEntryOpenSearchFieldPathFactory.Interface
) {}
// fieldPath.create(...) 被直接调用
}
实际仓库选择了 Option B 的变体:createElasticsearchSort 直接接收 fieldPathFactory: CmsEntryOpenSearchFieldPathFactory.Interface 参数(CmsEntryOpenSearchBodyBuilder.ts),排序逻辑维持为确定性的普通函数,行为变化仍交由 CmsEntryOpenSearchSortModifier 修改器链承担——这与 sort.md 中"排序保持普通工具函数"的决策一脉相承。
六、特征化收益与迁移路径
设计收益(文档清单,仓库已兑现):
- 与包内既有特征模式一致,抽象边界清晰;
- 支持注册表级 mock,测试/模拟成本显著下降;
- 杜绝
populated、values等内部工具的意外泄漏; - 依赖需求显式化,容器解析即文档;
- 为未来"插件式过滤策略覆盖"(按模型/租户替换过滤实现)预留了扩展位。
迁移路径(文档 7 步,与仓库实际演进吻合):
- 创建特征目录与抽象接口(
abstractions.ts); - 将实现对齐接口(
CmsEntryOpenSearchExecFiltering.ts等); - 在
feature.ts中注册; - 更新
body.ts使用容器解析的ExecFiltering(演化为CmsEntryOpenSearchBodyBuilder特征); - 更新
sort.ts使用容器解析的FieldPathFactory; - 清理旧
filtering/目录(当前仓库已无该目录,职责全部落入features/); - 更新
filtering/index.ts指向特征命名空间导出。
结语
filtering.md 给出的判断——"功能可用但尚未 DI 管理,隔离的工具函数 + 工厂模式;候选评级为强 YES"——在仓库演进中已得到完全验证:CmsEntryOpenSearchExecFiltering、CmsEntryOpenSearchFieldPathFactory 等特征目前均已落地于 features 目录 并由 CmsEntryOpenSearchUtilsFeature 统一注册,调用方从 body.ts/sort.ts 的直接导入收敛为容器解析。改造复杂度低(2 个调用方、接口抽取直接、特征注册模板现成),而测试性、一致性、可发现性与可扩展性(未来插件化过滤)收益显著。对于 api-headless-cms-ddb-es、api-headless-cms-pg-os 等存储层变体而言,这是一套可复制的 DI 迁移范本:注册表驱动依赖、工厂函数封装行为、特征统一编排注册,三者结合即可让查询构建管线保持清晰、可测、可替换。