Webiny Headless CMS OpenSearch 过滤模块 DI 重构:ExecFiltering 特征化改造全解析

原创2026-10-07 17:24:302,335 阅读
文章标签:CMS后端前端云原生AI 应用

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 化,交叉依赖仍走直接调用

文档的"依赖分析"章节是理解本模块的关键。过滤模块的依赖分三层:

  1. 已由 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)。
  2. 模块内部交叉依赖(文档给出调用链图示):

    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 均为模块内的直接工厂/函数调用,尚未纳入容器。

  3. 非 DI 的外部纯函数依赖(从工具层直接 import):

    • transformValueForSearch()、hasKeyword()、assignMinimumShouldMatchToQuery() 来自 utils;
    • createBaseQuery() 来自 initialQuery.ts;
    • parseWhereKey() 来自 @webiny/api-opensearch 库。 这些纯函数在重构后原样保留,未做包装(符合仓库"工具保持普通函数、DI 只留给注册表与修改器"的既有惯例,见同目录 sort.md 的结论)。

三、调用方:两个直接消费者与一组遗留测试

文档指出过滤模块仅有 2 个直接调用方,均在 api-headless-cms-utils-os 包内:

  1. body.ts(查询体构建入口)——重构前通过 filtering/index.js 导入 createExecFiltering,在 execFiltering({ where, query }) 处调用;重构后 body.ts 已演化为 CmsEntryOpenSearchBodyBuilder 特征,其 build() 内部直接注入 CmsEntryOpenSearchExecFiltering.Interface,在排序、全文搜索等步骤之后执行过滤(CmsEntryOpenSearchBodyBuilder.ts)。值得注意的是:body.ts 同时是过滤模块的调用方,也是 sort 模块唯一的调用方,属于 DI 改造链上的枢纽。

  2. sort.ts(排序字段路径计算)——重构前导入 createFieldPathFactory;重构后 sort.ts 被并入 CmsEntryOpenSearchBodyBuilder/sort.ts,排序路径计算改为注入 CmsEntryOpenSearchFieldPathFactory.Interface 完成(CmsEntryOpenSearchBodyBuilder.ts)。

  3. 测试文件(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 的必要性,源码均可逐一印证:

  1. 注册表模式已就位:exec.ts 当前签名接收 CmsEntryOpenSearchFilterRegistry.Interface 与 CmsEntryOpenSearchValueSearchRegistry.Interface,说明"部分 DI 已经存在"——欠的只是把工厂函数整体收编进容器。
  2. 工厂模式天然契合 DI:全部导出均为工厂函数(createExecFiltering、createApplyFiltering、createFieldPathFactory),这是本仓库 DI 风格的惯用形态(@webiny/feature/api 的 createAbstraction/createImplementation)。
  3. 调用方有限、改造范围可控:仅 body.ts、sort.ts 两个直接调用方,且同属一个包。
  4. 与包内既有模式一致:CmsEntryOpenSearchFilter、CmsEntryOpenSearchValueSearch、CmsEntryOpenSearchBodyBuilder 等已完全按该模式落地,过滤模块应当对齐。
  5. 封装实现细节:将 populated.ts、values.ts 收进特征,可防止下游绕过注册表直接 import 内部工具。
  6. 依赖显式化:容器解析让每个函数"需要什么"一目了然,避免误用错误的注册表实例。
  7. 可测试性:特征可用 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 步,与仓库实际演进吻合):

  1. 创建特征目录与抽象接口(abstractions.ts);
  2. 将实现对齐接口(CmsEntryOpenSearchExecFiltering.ts 等);
  3. 在 feature.ts 中注册;
  4. 更新 body.ts 使用容器解析的 ExecFiltering(演化为 CmsEntryOpenSearchBodyBuilder 特征);
  5. 更新 sort.ts 使用容器解析的 FieldPathFactory;
  6. 清理旧 filtering/ 目录(当前仓库已无该目录,职责全部落入 features/);
  7. 更新 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 迁移范本:注册表驱动依赖、工厂函数封装行为、特征统一编排注册,三者结合即可让查询构建管线保持清晰、可测、可替换。

登录后查看全文
webiny-js