ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从告警规则引擎看解释器模式:定义、实现与工程落地

从告警规则引擎看解释器模式:定义、实现与工程落地

前阵子接了一个监控告警平台的需求,运营同学希望在页面上直接填一段规则文本,比如cpu_usage > 80 && (mem_usage > 90 || response_time > 3000),系统就能按这段规则实时判断是否需要告警,而且规则不能写死在代码里。最开始我下意识想用一堆 if-else 把逻辑串起来,结果发现规则组合一多根本没法维护。直到我把设计模式系列写到第十六篇,回头再看解释器模式,才发现当初那个需求的最佳答案其实就摆在这里——为规则定义一种"小语言",再写一个解释器去解释它。

这篇文章按惯例聊解释器模式的定义、案例分析、特点和缺点,但我不会只把概念念一遍。我会从告警规则引擎这个真实需求出发,完整走一遍"定义文法→设计抽象语法树→实现递归下降解析器→落地求值"的过程,再把实际项目中踩过的坑和选型建议也一并交代清楚。想弄懂 DSL、规则引擎,或者只是学过设计模式但不知道怎么落到代码里的朋友,这篇应该对你有用。

1. 从告警规则需求说起:什么样的场景会想到解释器模式

1.1 规则模块的需求原貌:字符串规则从天而降

我先把这个需求讲细一点,因为它天然就是解释器模式的"理想考试题"。

平台当时的核心能力是监控服务器指标,包括 CPU 使用率、内存使用率、端口响应时间等。运营人员希望自己配置告警规则,比如:

cpu_usage > 80 && (mem_usage > 90 || response_time > 3000)

规则有两个关键特征。第一,它是动态的,运营今天想用 A 组合,明天想加一个括号,后天又想把阈值从 80 改成 90,这些都不能靠研发发版来解决。第二,它是有结构的,不是简单的一个布尔条件,而是由若干基础条件通过&&、||、括号等组合起来的表达式。

如果规则只有三五条,确实可以硬写。我在最初的原型里就是这么干的:为每一种条件写一个方法,然后套一个巨大的 if-else 结构。伪代码大概长这样:

if (ruleName.equals("cpu_high")) { if (cpuUsage > 80) { notify(); } } else if (ruleName.equals("cpu_and_mem")) { if (cpuUsage > 80 && memUsage > 90) { notify(); } } else if (ruleName.equals("complex_rule")) { if ((cpuUsage > 80 || memUsage > 90) && responseTime > 3000) { notify(); } }

问题很快就暴露了:条件组合几乎是无限的,你想把每一种组合都写成一段 if-else,代码会以肉眼可见的速度腐败。更可怕的是,运营同学在配置页面上自由填规则时,后端根本不知道应该匹配哪个 if 分支。这个方向从根上就是错的。

1.2 为什么这类问题不能靠简单条件判断硬堆

继续硬堆 if-else 会引发三个连锁问题。

其一,组合爆炸。规则语言里只要支持&&、||、>、<、括号,可能的表达式数量就是无限的,而有限的 if-else 只能覆盖已经枚举过的场景。每当运营想要一个"之前没想到的组合",代码都要改动一次,这个循环没有尽头。

其二,核心逻辑被污染。条件判断、规则匹配、告警通知全部耦合在一个方法里。今天想支持一个新的比较操作符,比如between,你就要找到所有可能用到它的地方,分别修改,违反开闭原则是小事,改漏了才是灾难。

其三,语义丢失。"cpu_usage > 80 && (mem_usage > 90 || response_time > 3000)"这串文本本身是有层级、有优先级的,硬编码 if-else 之后,这段文本对应的树形结构就消失了。而正确的做法应该是:把文本解析成一棵树,然后按树的结构去求值。

解释器模式解决的就是"如何把一段符合某种文法的文本,翻译成可执行结构并求值"的问题。它为这个问题提供了一套角色划分和实现框架,比我自己在 if-else 里打滚要优雅得多。

2. 解释器模式的定义与四个核心角色:先看懂"语言"这个概念

2.1 定义与直观理解:给句子写翻译器

解释器模式的经典定义是:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。

这句话的书面味很重,我换个说法。假设你面前有个外国人,说了一句 "The cat eats fish"。你要理解这句话,心里其实做了两件事:先按语法规则把句子拆成主语、谓语、宾语,再根据每个词的含义理解整句话。解释器模式就是把你需要支持的规则文本定义成一种"小语言",然后为这种语言专门写一个翻译器。程序拿到一句规则文本,先按文法拆结构,再对每个零件求值,最后算出整句规则的结果。

前面告警模块里的cpu_usage > 80 && (mem_usage > 90 || response_time > 3000)就是这种"小语言"的一个句子。cpu_usage > 80是"主语 + 谓语 + 宾语"级别的原子条件,&&和||是连接这些原子条件的语法符号。解释器要做的事,就是把这个句子对应成一棵树,然后从树叶往树根计算。

2.2 四个关键角色与一张关系表

在《设计模式》原书里,解释器模式有四个主要角色,外加一个客户端。理解这四个角色,比死记定义有用得多。

角色中文常规叫法核心职责告警引擎里的对应物
AbstractExpression抽象表达式声明解释操作的接口,通常定义interpret方法Expression接口
TerminalExpression终结符表达式文法中的叶子节点,没有子表达式,直接返回结果变量表达式、常量表达式
NonterminalExpression非终结符表达式文法中的组合节点,持有子表达式并组合它们的解释结果与、或、比较表达式
Context上下文存放解释过程需要的全局信息,通常保存变量值承载 CPU 使用率等指标值的容器
Client客户端构建语法树并调用根节点的解释方法规则模块的调用入口

从表里能看到一个关键点:解释器模式本身就是一棵树。终结符表达式是树叶,非终结符表达式是树枝。interpret方法从树叶开始调用,一层层向上聚合,最终在树根得到整个句子的结果。

2.3 从句子到抽象语法树:解释器模式与 AST 的关系

抽象语法树(AST)这个词看着唬人,但说白了就是把有优先级的文本拉直成一棵有层级的树。

继续用cpu_usage > 80 && (mem_usage > 90 || response_time > 3000)举例。去掉括号抽象后,这棵树的根节点是&&节点,它的左子树是cpu_usage > 80这个比较节点,左子树的叶子分别是变量cpu_usage和常量80;根节点的右子树是||节点,右子树的左孩子是mem_usage > 90,右孩子是response_time > 3000。

解释器模式里的"解释"过程,本质上就是一次后序遍历:先算左子树的值,再算右子树的值,最后在根节点做布尔合并。括号只是决定树形结构的东西,一旦树建好了,括号就不需要了。这也是为什么我在后面实现解析器时,要把括号当成影响树结构的符号,而不是树里的一个节点。

3. 亲手实现一个可运行的告警规则表达式引擎:完整案例分析

3.1 文法设计:先把规则语言的定义写清楚

动手写代码前,第一件事不是写Expression接口,而是定义文法。没有文法的解释器就是空中楼阁。我用一种简化版 BNF 来描述告警规则语言:

expression := orExpr orExpr := andExpr ('||' andExpr)* andExpr := compareExpr ('&&' compareExpr)* compareExpr := '(' expression ')' | identifier op number op := '>' | '<' | '>=' | '<=' | '==' | '!='

这个文法翻译成人话就是:一条表达式由一个或多个通过||连接的"与表达式"组成;一条与表达式由一个或多个通过&&连接的"比较表达式"组成;一条比较表达式要么是括号包起来的整个表达式,要么是"变量 操作符 常量"这样的原子条件。

为什么要先写文法?因为它把"哪些句子合法、哪些句子非法"定义清楚了。后面所有代码,包括解析器和表达式类,都是对这个文法的映射。文法里出现一次规则,代码里通常就对应一个非终结符类。

3.2 抽象表达式与终结符表达式:先定义树上的叶子

文法定义好之后,代码就是水到渠成的事。先定义抽象表达式接口:

public interface Expression { Object interpret(Context ctx); }

注意interpret的返回类型我用了Object。原因在于树的不同层次返回的东西不一样:变量表达式返回的是数值,比较表达式返回的是布尔值,如果接口写死boolean,变量表达式就没办法实现了。

接着定义终结符表达式。终结符没有子节点,是整棵树的最底层。这里有两个:一个表示变量(从上下文里取指标值),一个表示常量(阈值)。

public class VariableExpression implements Expression { private final String name; public VariableExpression(String name) { this.name = name; } @Override public Object interpret(Context ctx) { return ctx.getVariable(name); } } public class ConstantExpression implements Expression { private final double value; public ConstantExpression(double value) { this.value = value; } @Override public Object interpret(Context ctx) { return value; } }

上下文Context其实就是一个变量容器,我这里用简单封装避免后续扩展时改签名:

public class Context { private final Map<String, Object> variables = new HashMap<>(); public void setVariable(String name, Object value) { variables.put(name, value); } public Object getVariable(String name) { return variables.get(name); } }

3.3 非终结符表达式:比较、与、或的实现

有了叶子,就该有树枝了。先从比较表达式写起,它是这棵树的"原子判断节点"。

public class ComparisonExpression implements Expression { private final Expression left; private final String op; private final Expression right; public ComparisonExpression(Expression left, String op, Expression right) { this.left = left; this.op = op; this.right = right; } @Override public Object interpret(Context ctx) { Object leftValue = left.interpret(ctx); Object rightValue = right.interpret(ctx); if (leftValue instanceof Number && rightValue instanceof Number) { double a = ((Number) leftValue).doubleValue(); double b = ((Number) rightValue).doubleValue(); switch (op) { case ">": return a > b; case "<": return a < b; case ">=": return a >= b; case "<=": return a <= b; case "==": return a == b; case "!=": return a != b; default: throw new IllegalArgumentException("Unsupported op: " + op); } } throw new IllegalArgumentException("Both operands should be numbers"); } }

ComparisonExpression的左右两个孩子是终结符表达式,它负责取出左右两边的值,比较大小,返回布尔结果。这里我用double做初版计算,但实话实说,这个初版是有问题的,后面专门讲坑的时候我会说为什么必须换成BigDecimal。

组合表达式实现更简单,就是把两个布尔结果做逻辑运算:

public class AndExpression implements Expression { private final Expression left; private final Expression right; public AndExpression(Expression left, Expression right) { this.left = left; this.right = right; } @Override public Object interpret(Context ctx) { return (Boolean) left.interpret(ctx) && (Boolean) right.interpret(ctx); } } public class OrExpression implements Expression { private final Expression left; private final Expression right; public OrExpression(Expression left, Expression right) { this.left = left; this.right = right; } @Override public Object interpret(Context ctx) { return (Boolean) left.interpret(ctx) || (Boolean) right.interpret(ctx); } }

到这里,语法树的所有节点类型都齐了:变量、常量是比较表达式的叶子;比较表达式、与表达式、或表达式是树枝。接下来还差最后一个环节——把规则字符串变成这棵树。

3.4 递归下降解析器:把字符串变成语法树

解释器模式原书里的例子通常会假设客户端已经拿语法树了,但真实世界的输入是字符串。所以我需要一个解析器,把"cpu_usage > 80 && (mem_usage > 90 || response_time > 3000)"变成一棵由上面表达式对象构成的树。

我采用递归下降解析法。核心思想很简单:文法的每一层规则对应一个解析方法,方法调用层级天然体现了优先级。

先做词法分析,把字符串切成一堆 token:

public class RuleParser { private final List<String> tokens; private int pos; public RuleParser(String rule) { this.tokens = tokenize(rule); this.pos = 0; } private List<String> tokenize(String rule) { List<String> result = new ArrayList<>(); int i = 0; while (i < rule.length()) { char c = rule.charAt(i); if (Character.isWhitespace(c)) { i++; } else if (c == '(' || c == ')') { result.add(String.valueOf(c)); i++; } else if (c == '&' && i + 1 < rule.length() && rule.charAt(i + 1) == '&') { result.add("&&"); i += 2; } else if (c == '|' && i + 1 < rule.length() && rule.charAt(i + 1) == '|') { result.add("||"); i += 2; } else if (c == '>' || c == '<' || c == '!' || c == '=') { if (i + 1 < rule.length() && rule.charAt(i + 1) == '=') { result.add(rule.substring(i, i + 2)); i += 2; } else { result.add(String.valueOf(c)); i++; } } else { StringBuilder sb = new StringBuilder(); while (i < rule.length() && !Character.isWhitespace(rule.charAt(i)) && "()&|><!=".indexOf(rule.charAt(i)) < 0) { sb.append(rule.charAt(i)); i++; } result.add(sb.toString()); } } return result; } }

然后是语法分析的核心三个方法,一层管一层:

public Expression parse() { Expression expr = parseOr(); if (pos != tokens.size()) { throw new IllegalArgumentException("Unexpected token: " + tokens.get(pos)); } return expr; } private Expression parseOr() { Expression expr = parseAnd(); while (match("||")) { expr = new OrExpression(expr, parseAnd()); } return expr; } private Expression parseAnd() { Expression expr = parseComparison(); while (match("&&")) { expr = new AndExpression(expr, parseComparison()); } return expr; } private Expression parseComparison() { if (match("(")) { Expression expr = parseOr(); expect(")"); return expr; } String leftToken = next(); String op = next(); String rightToken = next(); Expression left = new VariableExpression(leftToken); Expression right = new ConstantExpression(Double.parseDouble(rightToken)); return new ComparisonExpression(left, op, right); } private boolean match(String token) { if (pos < tokens.size() && tokens.get(pos).equals(token)) { pos++; return true; } return false; } private String next() { if (pos >= tokens.size()) { throw new IllegalArgumentException("Unexpected end of expression"); } return tokens.get(pos++); } private void expect(String token) { if (!match(token)) { throw new IllegalArgumentException("Expected token: " + token); } } }

这段代码的精髓在方法嵌套顺序上。parseOr调用parseAnd,parseAnd调用parseComparison,而parseComparison遇到左括号时又会调用parseOr。这就保证了&&的优先级天然高于||,括号能让子表达式回到最顶层去解析。整个逻辑其实就是把 3.1 小节那段 BNF 文法照着抄成了代码。

3.5 客户端组装与运行效果验证

解析器和表达式类都齐了,最后写一个 Demo 验证整条链路:

public class Demo { public static void main(String[] args) { Context ctx = new Context(); ctx.setVariable("cpu_usage", 85); ctx.setVariable("mem_usage", 95); ctx.setVariable("response_time", 2000); String rule = "cpu_usage > 80 && (mem_usage > 90 || response_time > 3000)"; Expression expression = new RuleParser(rule).parse(); System.out.println("规则计算结果: " + expression.interpret(ctx)); } }

运行结果:

规则计算结果: true

把cpu_usage改成 60 再跑一次,结果是false。再验证一下优先级:把规则改成cpu_usage > 80 || mem_usage > 90 && response_time > 3000,由于&&优先级高,实际等价于cpu_usage > 80 || (mem_usage > 90 && response_time > 3000),解析器输出的树结构会体现这一点。

到这里,一个最小可用引擎已经可以工作了。运营配置的规则字符串经过解析变成 AST,每次判断时从Context里取出当前指标值,走一遍树,得到 true 或 false。

4. 真实工程里最容易踩的坑:优先级、类型、容错和缓存

4.1 运算优先级与括号处理:为什么必须分层解析

这个坑是我在初版实现里踩过的。当时偷懒,觉得规则就是从左到右算,于是先按&&和||把 token 草草切分,然后线性遍历。结果碰到cpu_usage > 80 || mem_usage > 90 && response_time > 3000这种规则,引擎给出了和数学直觉完全相反的结果。

原因是运算优先级没有体现出来。&&的优先级高于||,所以上面的规则必须解释成"左边一坨,或者右边一坨(这坨内部是个与关系)",而不是简单的从左到右。我后来改成递归下降的标准分层,问题立刻消失。

这个坑想避开的办法很简单:文法的层级顺序,就是优先级的顺序。低优先级的操作符一定要放在高优先级操作符的外层,也就是在解析树上离根更近的位置。这就是我在RuleParser里让parseOr调用parseAnd、parseAnd调用parseComparison的原因。括号则是强制回到最外层解析的开关。切记不要用"先找 && 再找 ||"这种平面思路,遇到嵌套括号就直接崩了。

4.2 比较运算的类型陷阱:浮点精度和变量类型乱入

初版引擎用double比较,结果告警判断偶尔不准。查了一圈发现是经典浮点精度问题:某个业务指标经过比计算得到0.30000000000000004,阈值是0.3,0.30000000000000004 > 0.3竟然返回了true。告警这个东西最怕误报,不能忍。

后来我把比较逻辑改成统一转BigDecimal,用compareTo做比较:

import java.math.BigDecimal; BigDecimal a = new BigDecimal(leftValue.toString()); BigDecimal b = new BigDecimal(rightValue.toString()); int cmp = a.compareTo(b); switch (op) { case ">": return cmp > 0; case "<": return cmp < 0; case ">=": return cmp >= 0; case "<=": return cmp <= 0; case "==": return cmp == 0; case "!=": return cmp != 0; }

注意BigDecimal构造时绝对不要用new BigDecimal(0.3),要传字符串"0.3",否则精度问题只是换了个地方冒出来。

还有一个问题来自interpret返回Object的设计。比较节点左右两边如果有一边不是Number,比如运营把变量值传成了字符串版本号,或者变量压根没存在Context里,初版直接抛异常或者诡异返回 false。我的处理是在比较表达式里做类型守卫,非数值类型统一抛带变量名的可读异常,而不是让 NPE 在不知道哪一层冒出来。

4.3 外部用户输入的容错:别把栈溢出抛给前端

告警规则是运营在页面上自己填的,输入质量可想而知。空字符串、括号不匹配、只有操作符没有操作数、嵌套四五十层括号,这些情况都真实出现过。

递归下降解析器遇到这些非法输入时,最容易产生两类错误:一是数组越界或空指针,二是嵌套太深导致的栈溢出。数组越界的根因是next()在 token 耗尽时没有兜底;栈溢出的根因是递归深度不受控。

我的处理方案分两层。第一层,Parser 里的next()和expect()统一抛IllegalArgumentException,异常信息包含"当前位置 + 期望的 token + 实际内容",这样前端能直接把错误提示展示给运营。第二层,在进入解析前检查规则长度和括号深度,比如限制规则最长 500 字符、嵌套深度最多 20 层,超过就直接拒绝。别小看这个限制条件,它是阻止正则炸弹和递归栈溢出的最简单手段。

4.4 性能优化:语法树必须缓存,不能每次重新解析

告警平台通常是定时任务跑一批规则,假设每 30 秒检查一次,3000 条规则,每条规则字符串在每次执行时都重新new RuleParser(rule).parse(),这开销不是一般的大。规则解析虽然是一次性的美元成本,但扛不住高频重复。

我后来加了一个简单的编译缓存:以规则字符串为 key,缓存解析好的Expression对象。规则不变就直接取树来求值,规则一变,字符串变了自然重新编译。

private final ConcurrentHashMap<String, Expression> expressionCache = new ConcurrentHashMap<>(); public Expression compile(String rule) { return expressionCache.computeIfAbsent(rule, r -> new RuleParser(r).parse()); }

这段代码的收益非常明显。解析阶段占了整个引擎大约 70% 的耗时,缓存之后,定时任务每次都只剩一次树遍历,性能提升几乎是立竿见影的。这其实也说明了一个通用经验:解释器模式本身擅长的是"解释一次、运行多次"的场景,如果每条规则只运行一次,解析成本会相对扎眼。

5. 解释器模式的特点与缺点:好用的地方和不好用的地方

5.1 优点:扩展性好、规则与业务解耦、组合灵活

解释器模式最大的优点,是扩展规则符合开闭原则。

假设运营需要新增一个操作符between,判断某个指标是否落在区间内,代码上只需要新增一个BetweenExpression非终结符类,然后让parseComparison在识别到between时构造这个节点即可。已有的变量表达式、常量表达式、与表达式、或表达式,一个都不用改。

这种扩展性不是碰巧的,它来自"每一种语法规则对应一个类"的结构。新增规则就是新增类,修改规则就是修改类的内部逻辑,互相不干扰。同时,因为规则文本是在运行时解释的,业务人员和代码之间的耦合彻底断开。运营改规则不需要找研发,研发加规则类型不需要碰业务判断逻辑,职责边界非常干净。

5.2 缺点一:类数量失控与文法膨胀

解释器模式的第一个显著缺点,是类数量爆炸。

每当文法增加一个产生式,代码里通常就要增加一个新的表达式类。以告警引擎为例,支持一个比较操作符可能只需要一个类,但一旦把between、in、startsWith、not in、取反!、百分比单位换算全部加进去,表达式类数量会以肉眼可见的速度膨胀到几十个。这个时候,"模块清晰"就变成了"类海战术",新同学接手时看着满包的表达式类,别说维护,光搞懂每个类是干嘛的就要花掉好几天。

所以说,解释器模式适合的是文法规模可控的场景。语法规则一旦多到接近一门通用编程语言,手写解释器模式就是在给自己挖坟。这也是为什么我在 6.2 节里强调自研前要评估规则规模。

5.3 缺点二:性能瓶颈与递归风险

第二个缺点,在 4.4 节其实已经露过头了,就是性能。

解释器模式在求值时,必须递归遍历整棵语法树,每个节点都要经历一次虚拟方法调用。如果规则本身复杂、规则数量又多,这个开销会被放大。尤其需要注意的是递归深度,一个括号嵌套 50 层的规则,能让线程直接抛出StackOverflowError,这不是慢不慢的问题,而是稳不稳定。

更麻烦的是,这种性能问题很难通过局部优化根治。你可以做缓存、可以限制深度、可以精简节点设计,但本质上"树遍历"的成本一直都在。如果单条规则单次判断的耗时到了毫秒级别,而你又需要在极短时间内完成大量规则判断,解释器模式可能不是最优解,需要考虑把规则编译成更底层的可执行结构,或者直接用现成的表达式引擎。

5.4 与组合模式、策略模式的边界辨析

很多读者会把解释器模式和组合模式搞混,因为两者都长着一棵递归树的样子。但它们的关注点完全不同。

组合模式关心的是"部分-整体"的树形结构,让客户端可以用统一方式处理单个对象和组合对象,重点是"透明性"。解释器模式也有一棵树,但树的每个节点都有明确的文法含义,而且interpret必须依赖Context才能解释出结果。换句话说,组合模式的树是用来"表示"结构的,解释器模式的树是用来"解释"句子的,多了一层语义。

策略模式则更远。策略模式关注的是在同一个任务上,如何用不同的算法完成同一个动作,比如排序策略、折扣策略。它描述的是"算法的替换",不是"语言的解释"。如果只是想在几种告警策略之间切换,策略模式就够了,不需要上升到解释器模式的高度。

这三者的选择其实是个判断题:如果问题本质是"多种算法替换",用策略;如果是"统一处理单个对象和组合对象",用组合;如果问题是"解析并执行符合文法的文本",才轮到解释器。

6. 从实践角度谈选型:什么时候该用,什么时候果断放弃

6.1 能直接用现成表达式引擎,就不要手写

这是我在告警模块后期最大的顿悟。其实我在手写引擎跑通之后,回头调研了一遍市面上的表达式引擎,发现像 JEXL、SpEL、Aviator 这类库早就把语法解析、优先级处理、类型转换、错误提示这些问题都解决得非常成熟了。

JEXL 和 SpEL 更偏"在 Java 环境里做复杂表达式的动态求值",Aviator 在性能上有明显优势。它们内部很多就把 AST、解释器、编译器那套思路落地得很漂亮。如果项目没有特殊的环境限制,规则引擎类的需求优先考虑这些库,比从零手写解释器模式要节省大量时间。

那话说回来,这篇文章手写一遍是不是就没价值了?恰恰相反。正是因为亲手写了一遍,我才真正看懂了那些库的文档在讲什么。理解了抽象语法树和递归下降,再看表达式引擎的 API 和扩展点,几乎是一通百通。设计模式是工具,理解它的最佳方式永远是动手做一次,但生产环境选型要冷静。

6.2 自研解释器的三个合理前提

做完这个项目,我给自己列了一个自研解释器的判断清单。满足以下至少两点,才值得手写:

  1. 语法规模有限且固定。就支持那么几个操作符,明确不会演化成一门通用语言,类数量和递归深度都能控制在安全范围。
  2. 引入外部依赖受限。目标环境不允许随便引第三方库,或者离线环境根本拉不到依赖,只能自己实现。
  3. 有深度定制解析行为的需求。标准表达式库满足不了between、单位换算、自定义函数之类的特殊语法,而扩展它们反而比从零写还别扭。
  4. 学习目的。想搞懂编译原理、DSL、解析器,亲手写一遍是最好的学习路径,但别把学习代码直接搬进生产。

如果以上四点一条都不满足,直接用现成表达式引擎。这个建议不是说我推荐偷懒,而是成熟工具在边界情况和性能上踩过的坑,远比自己重构要全得多。

6.3 我做完告警规则模块后的真实体会

这个引擎最终在测试环境跑通了,也支撑了最初运营配置规则的需求。但到生产上线前,我做了个决定:换成成熟的表达式引擎,只保留自己的操作符扩展层。解释器模式的价值在于,它让我把"规则语言"和"引擎实现"的边界画得清清楚楚,因此换引擎时改动范围很小——只需要把Expression接口的适配层重写,业务规则语法完全没有变。

如果非要总结一句经验,那就是:解释器模式最好的使用方式,不是永远手写解释器,而是用它的思维设计好规则语言和可替换的解释层。练手时完整实现一遍,生产时按成本选型,这才是攻读设计模式最务实的态度。至于你想用什么技术栈实现、要不要缓存 AST、比较时用BigDecimal还是double,这些细节都只是树上的叶子,主干永远是那条——为小语言定义文法,再让解释器沿着树把句子算出来。

文章写到这儿,正好也是这个设计模式系列的中间篇章。后面如果再碰到类似的 DSL 需求,我会优先翻出这篇的实现思路来参考,也建议你把案例代码亲手跑一遍。跑通之后,很多之前觉得很玄的"解析器""AST"概念,应该都会变得非常具体。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进