从告警规则引擎看解释器模式:定义、实现与工程落地
发布时间:2026/10/18 4:03:26 锦皓数字建站

前阵子接了一个监控告警平台的需求运营同学希望在页面上直接填一段规则文本比如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 MapString, 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)变成一棵由上面表达式对象构成的树。我采用递归下降解析法。核心思想很简单文法的每一层规则对应一个解析方法方法调用层级天然体现了优先级。先做词法分析把字符串切成一堆 tokenpublic class RuleParser { private final ListString tokens; private int pos; public RuleParser(String rule) { this.tokens tokenize(rule); this.pos 0; } private ListString tokenize(String rule) { ListString 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调用parseAndparseAnd调用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.30.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 ConcurrentHashMapString, 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 自研解释器的三个合理前提做完这个项目我给自己列了一个自研解释器的判断清单。满足以下至少两点才值得手写语法规模有限且固定。就支持那么几个操作符明确不会演化成一门通用语言类数量和递归深度都能控制在安全范围。引入外部依赖受限。目标环境不允许随便引第三方库或者离线环境根本拉不到依赖只能自己实现。有深度定制解析行为的需求。标准表达式库满足不了between、单位换算、自定义函数之类的特殊语法而扩展它们反而比从零写还别扭。学习目的。想搞懂编译原理、DSL、解析器亲手写一遍是最好的学习路径但别把学习代码直接搬进生产。如果以上四点一条都不满足直接用现成表达式引擎。这个建议不是说我推荐偷懒而是成熟工具在边界情况和性能上踩过的坑远比自己重构要全得多。6.3 我做完告警规则模块后的真实体会这个引擎最终在测试环境跑通了也支撑了最初运营配置规则的需求。但到生产上线前我做了个决定换成成熟的表达式引擎只保留自己的操作符扩展层。解释器模式的价值在于它让我把规则语言和引擎实现的边界画得清清楚楚因此换引擎时改动范围很小——只需要把Expression接口的适配层重写业务规则语法完全没有变。如果非要总结一句经验那就是解释器模式最好的使用方式不是永远手写解释器而是用它的思维设计好规则语言和可替换的解释层。练手时完整实现一遍生产时按成本选型这才是攻读设计模式最务实的态度。至于你想用什么技术栈实现、要不要缓存 AST、比较时用BigDecimal还是double这些细节都只是树上的叶子主干永远是那条——为小语言定义文法再让解释器沿着树把句子算出来。文章写到这儿正好也是这个设计模式系列的中间篇章。后面如果再碰到类似的 DSL 需求我会优先翻出这篇的实现思路来参考也建议你把案例代码亲手跑一遍。跑通之后很多之前觉得很玄的解析器AST概念应该都会变得非常具体。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。