大迭代(四):统一规则 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),感兴趣可以参考。
系列完。