大迭代(四):统一规则 DSL 的设计思路

问题回顾

大迭代的最后一个方向,也是最核心的问题:让标签嵌套规则也能配置化

在 [07] 篇里,我们描述了这个问题的本质:标签嵌套规则(如【十分推荐】= 【经典名著】OR(【好评如潮】AND【线上热销】))只能硬编码,每次新增都要改代码上线,耗时 2-3 天。

但在动手之前,先想清楚一件事:为什么不直接用现成的表达式引擎?

为什么不用 SpEL

系统里已经在用 SpEL 做打标判断,它功能强大,语法灵活,理论上可以直接扩展来支持嵌套规则。但这条路走不通,原因在于一个更深层的矛盾。

当前系统里,同一套标签规则需要在两个地方执行:一是打标时,对实体对象做布尔判断;二是搜索时,转译成 ES DSL 过滤文档。两套执行逻辑完全相同,却要维护两份代码——这本身就是一个隐患,一旦规则更新,两边都要改,很容易出现不一致。

SpEL 解决不了这个问题。它是一个"执行器",设计目标是让 Java 代码更动态,功能强大,但本质上是黑盒——它不对外暴露 AST 结构,开发者无法直接访问语法树节点,也就无法把同一套规则转译成 ES DSL。

所以问题的本质不只是"让规则可配置",而是:设计一套规则语言,让同一份规则既能直接执行,又能转译成 ES 查询。这是 SpEL 做不到的事情。

核心设计:AST 透明化

自己设计 DSL 的核心优势,在于可以完全掌控 AST(抽象语法树)的结构。

一段规则表达式经过词法分析和语法解析后,会生成一棵 AST。这棵树的每个节点代表一个操作:比较、逻辑与、逻辑或、集合包含……如果 AST 结构是公开的、可遍历的,那么只需要针对不同的目标语言实现不同的"遍历器",就能把同一棵 AST 转译成任意形式。

这就是访问者模式(Visitor Pattern)在这里的用武之地:

同一份规则表达式
    ↓ 解析
    AST
    ↓ 不同 Visitor 遍历
    ├── 布尔执行器 → 对实体对象求值,返回 true/false(用于打标)
    └── ES 转译器  → 生成 ES QueryBuilder(用于搜索过滤)

这个设计的好处是扩展性极强:如果将来需要转译成 HiveSQL,只需要新增一个 Visitor 实现,规则本身和其他转译器完全不需要改动。

标签嵌套:引用机制

解决了"一套规则多端转译"之后,还有第二个问题:标签嵌套规则如何表达

【十分推荐】= 【经典名著】OR(【好评如潮】AND【线上热销】)——这里的【经典名著】、【好评如潮】本身也是标签,各自有自己的规则表达式。如果把它们展开内联,规则会变得极其冗长,而且一旦某个基础标签的规则修改,所有引用它的组合标签都要跟着改。

更好的方式是引用:每个标签对应一个已注册的规则表达式,组合标签直接引用基础标签的名字,运行时再展开求值。

# 基础标签,各自独立注册
"好评如潮":  starLevel == 4 AND commentCount > 3000
"经典名著":  authors[dynasty IN ('唐朝','宋朝','明朝')]
"线上热销":  viewCount > 10000 AND stockQuantity > 0

# 组合标签,通过引用语法组合
"十分推荐":  #{经典名著} OR (#{好评如潮} AND #{线上热销})
"优质图书":  (#{最近热搜} OR #{最近热议}) AND NOT #{十分推荐}

这个引用机制让标签嵌套规则完全配置化:新增一个组合标签,只需要写一行表达式,存入数据库,无需上线。运行时按依赖顺序计算,结果自动传递给下一层。

语法设计的克制

自己设计 DSL,最大的风险是"功能蔓延"——不断往里加语法特性,最终变成一个难以维护的怪物。

这套 DSL 的语法设计刻意保持克制,只保留了标签规则真正需要的几类操作:基础比较(==><!=)、逻辑运算(AND / OR / NOT)、集合包含(IN、[])、嵌套查询(field[子表达式])、标签引用(#{标签名})。

不支持算术运算,不支持函数调用,不支持变量赋值。这些限制不是能力不足,而是主动选择——功能越少,AST 结构越简单,转译就越可靠

成本对比

有了这套 DSL,新增一个组合标签的流程变成了:

写一行表达式 → 存入数据库 → 生效

优化前:硬编码实现 + 单元测试 + 代码审查 + 上线,耗时 2-3 天。

优化后:写一行表达式 + 配置入库,耗时 10 分钟,无需上线。

维度优化前优化后
新增组合标签周期2-3 天10 分钟
标签嵌套支持硬编码完全配置化
代码上线必须无需
规则可读性隐藏在代码里一行表达式,一目了然
转译扩展性不支持新增 Visitor 即可

整个系列的演进总结

至此,大迭代的四个方向全部完成。回顾整个系统的演进历程:

[01] CQRS 基础架构
  ↓ 需要扩展稀疏属性
[02] EAV 模型
  ↓ 需要接入外部系统
[03] MQ 监听 + ES 异步更新
  ↓ 搜索项越来越多,需要解耦
[04] 搜索项配置化
  ↓ 搜索结果需要打标
[05] 搜索标签双重身份
  ↓ 每次新增属性都要写代码
[06] EAV 异构配置化
  ↓ 标签嵌套规则无法配置化,系统熵增到临界点
[07] 大迭代决策
  ↓
[08] 离线属性:HiveSQL 替代 Java 代码
[09] 实时属性:Handler 接口标准化接入
[10] ES 异构:责任链重构
[11] 标签规则:统一 DSL

每一步演进都不是凭空设计出来的,而是被真实的业务痛点逼出来的。这也是这个系列想传递的核心观点:好的架构不是一开始就设计好的,而是在业务压力下一步步演进出来的

关键在于两件事:在合适的时机用最小成本解决当前问题;在系统难以维护时,果断重构,控制熵增。


上述设计思路有一个完整的开源实现:咕噜表达式(gulu-dsl),感兴趣可以参考。

系列完。