☰
解释器模式实战:手写规则表达式引擎与语法树解析
2026/10/27 4:53:05 网站建设 项目流程

1. 先搞清楚解释器模式到底在解决什么问题

解释器模式(Interpreter Pattern)在行为型设计模式里算是一个存在感偏低、但一旦用对地方就极其舒服的模式。很多人背设计模式的时候,把二十几种模式从头到尾过一遍,到了解释器模式这里就卡住了——它不像策略模式那样一眼能看懂用途,也不像观察者模式那样天天在用。我第一次接触它的时候,教材上写的是"给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子",看完这段定义我更懵了。

所以这篇我不打算从定义讲起,咱们从实际遇到的问题切入。假设你手里有个需求:用户可以在一个输入框里写一些简单的规则表达式,比如age > 18 AND city = "北京",或者price * quantity > 500 OR vip = true,然后系统要能解析这段表达式,并对一批数据做筛选判断。这种需求在权限配置、风控规则、促销活动条件、监控告警阈值、表单动态校验这些场景里非常常见。

最容易想到的写法是什么?写一堆 if-else,或者用正则硬切,再或者找个脚本引擎把用户输入直接塞进去执行。前两种写法在表达式稍微复杂一点之后就彻底失控,第三种写法有安全风险而且不好控制执行过程。而解释器模式提供的思路是:把这段表达式的语法结构抽象成一棵语法树,每个语法节点对应一个类,每个类自己负责解释自己那部分语义,最后从根节点开始递归求值。

这个思路的价值在于,它把"语法的结构"和"语法的执行"彻底解耦了。表达式本身变成了一棵对象树,每个节点只关心自己的子和自己的运算规则,新增一种语法只需要新增一个节点类,不用动已有的代码。这就是典型的开闭原则落地方式。

我先把解释器模式里最核心的几个角色列清楚,因为后面的所有内容都围绕这几块展开:

角色职责在我的规则引擎例子里对应什么
AbstractExpression(抽象表达式)声明一个解释操作的接口Expression接口,含interpret(Context ctx)方法
TerminalExpression(终结符表达式)语法中最小的、不可再分的单元变量节点VariableExpression、常量节点LiteralExpression
NonterminalExpression(非终结符表达式)由其他表达式组合而成,内部持有子表达式与节点AndExpression、或节点OrExpression、比较节点CompareExpression
Context(上下文)存放解释过程中需要的全局信息EvaluationContext,存数据行、变量取值、临时结果
Client(客户端)构建语法树并触发解释解析器Parser+ 调用方

理解这张表的关键在于分清"终结符"和"非终结符"。用大白话讲:终结符就是语法树里的叶子节点,它不能再往下拆了,比如一个变量名、一个字面量数字;非终结符是树里的分支节点,它一定由若干个子节点拼起来,比如A AND B里的 AND 就是一个非终结符,它的语义是"我左边和右边都要为真"。这个区分不是学术上的洁癖,它直接决定了你的递归要在哪里停。

还有一个很多人会忽略的点:解释器模式的本质不是"解析表达式",而是"执行表达式"。解析(把字符串变成树)属于构建阶段,可以用递归下降、可以用栈、可以用现成的解析库,这都不算解释器模式的核心;真正属于模式的,是那棵树上每个节点自己知道怎么算出结果、怎么把结果往上冒泡。把这两个阶段混在一起讲,是很多人学不明白这个模式的根本原因。后面我会专门用一节来拆构建和执行这两件事。

2. 从最小可运行版本开始:手写一个规则表达式引擎

空谈角色分工没什么意义,直接上代码。我按自己实际写项目的习惯来,先把最小可运行版本跑通,再往上叠复杂度。这样你在自己的项目里复现的时候,能一级一级地验证,出问题也知道是哪一层的事。

2.1 上下文对象的设计往往被低估

先定义上下文。很多人写解释器模式的时候,把 Context 写成一个空壳,觉得它就是个传参容器,随便放个 Map 就行。我踩过的坑是:上下文的设计直接决定了你这个引擎能不能支持嵌套求值、能不能做短路优化、能不能在运行过程中收集中间信息。

public class EvaluationContext { // 当前待判断的数据行,相当于一个"记录" private final Map<String, Object> variables; // 表达式求值过程中的临时缓存,用于短路与去重 private final Map<String, Object> cache = new HashMap<>(); public EvaluationContext(Map<String, Object> variables) { this.variables = variables; } public Object lookup(String name) { if (!variables.containsKey(name)) { throw new IllegalArgumentException("未知变量: " + name); } return variables.get(name); } public void cache(String key, Object value) { cache.put(key, value); } public Object getCached(String key) { return cache.get(key); } }

这段代码看起来很朴素,但有几个决定是刻意的。第一,变量查不到的时候我选择抛异常而不是返回 null,因为规则表达式里写错变量名是个高频事故,静默返回 null 会让后续比较变成空指针或者意外通过,排查起来非常痛苦,早失败早暴露要好得多。第二,我加了 cache,是用来给非终结符节点做记忆化的——同一条数据行在同一个表达式上,某个子树可能被多次求值(尤其是在有 OR 分支、或者树被复用的场景下),缓存能省掉重复计算。第三,上下文是"每行数据一个实例"还是"所有数据共享一个实例",这个必须想清楚,我一开始写成共享的,结果多线程并发筛数据的时候变量串了,查了两天才定位到。

2.2 抽象表达式与两类节点的最小实现

接口就一个方法,参数是上下文,返回值我用boolean,因为规则引擎绝大多数场景就是做真假判断。如果你的场景需要返回数值或者字符串,把返回值泛型化也行,但泛型会让代码复杂不少,我建议先用boolean跑通再说。

public interface Expression { boolean interpret(EvaluationContext ctx); }

终结符先来两个。变量节点负责从上下文里取值,然后把它和期望值做比较——注意这里我省略了独立的"比较节点",把取值和比较合并了,因为在我的语法里比较总是一个变量对一个字面量,合并之后树更浅。如果你的语法支持(a + b) > c这种,那就必须把取值和比较拆成两个节点。

public class VariableExpression implements Expression { private final String variableName; private final String operator; private final Object expected; public VariableExpression(String variableName, String operator, Object expected) { this.variableName = variableName; this.operator = operator; this.expected = expected; } @Override public boolean interpret(EvaluationContext ctx) { Object actual = ctx.lookup(variableName); return compare(actual, expected); } private boolean compare(Object a, Object b) { switch (operator) { case "=": return Objects.equals(a, b) || String.valueOf(a).equals(String.valueOf(b)); case "!=": return !String.valueOf(a).equals(String.valueOf(b)); case ">": return toNumber(a) > toNumber(b); case "<": return toNumber(a) < toNumber(b); case ">=": return toNumber(a) >= toNumber(b); case "<=": return toNumber(a) <= toNumber(b); default: throw new IllegalArgumentException("不支持的运算符: " + operator); } } // toNumber 负责把 Integer/Long/Double/String 统一转成 double 比较 }

这里有个非常容易出事的细节:类型比较。从配置文件或者数据库里读出来的值,极大概率是字符串"18",而用户表达式里写的是数字18,直接equals永远为 false。我的做法是=和!=用字符串化比较兜底,大小比较统一走数值转换。这个策略不是最严谨的,但对规则引擎这种场景够用,且行为可预期。如果你的业务对精度有要求(金额、时间戳),千万别用 double,要么全转 BigDecimal,要么按类型严格分派,这个我吃过精确比较出错的亏。

非终结符我实现 AND 和 OR 两个,重点是短路求值:

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 boolean interpret(EvaluationContext ctx) { // 左边为假就不必算右边,既省算力也避免右侧的非法访问 return left.interpret(ctx) && 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 boolean interpret(EvaluationContext ctx) { return left.interpret(ctx) || right.interpret(ctx); } }

&&和||在 Java 里天然短路,所以我直接用了。但如果你把这段逻辑改成先求两边结果再合并,那就要自己写 if 判断,否则a = 1 OR b > 0这种表达式,即使 a 已经成立,右边那个引用未定义变量的子树依然会被执行,直接抛异常。这是个很隐蔽的 bug,我在一个告警规则引擎里被它坑过——用户配了个status = "error" OR retry > 3,结果系统里有些告警源没有 retry 字段,表达式在 status 已命中的情况下依然因为查不到 retry 报错。

2.3 客户端如何组装语法树

手工 new 一棵树长这样:

// (age > 18 AND city = "北京") OR vip = true Expression tree = new OrExpression( new AndExpression( new VariableExpression("age", ">", 18), new VariableExpression("city", "=", "北京") ), new VariableExpression("vip", "=", true) ); Map<String, Object> row = new HashMap<>(); row.put("age", 25); row.put("city", "北京"); row.put("vip", false); System.out.println(tree.interpret(new EvaluationContext(row))); // true

到这里最小版本就通了。但请注意:这个版本离能用还差得远,因为现实中没人会手工 new 树,用户是在输入框里敲字符串的。所以下一节必须解决字符串到树的构建问题——而这一部分,严格来说不属于解释器模式的范畴,属于解析器的工作,但实际项目里这两件事必须一起做,否则模式根本落不了地。

3. 字符串表达式怎么变成语法树:递归下降解析的实操拆解

这是实际项目里最费劲的部分,也是很多教程直接跳过的地方。你光有解释器节点类,没有解析器把它拼起来,等于只有零件没有装配线。我按"词法分析 → 语法分析 → 建树"三步走,用递归下降来写,因为这个方法对简单规则表达式来说最直观、最好调试。

3.1 词法分析:先把字符流切成有意义的记号

词法分析的目标是把age > 18 AND city = "北京"切成一组 token:[IDENT(age), GT, NUMBER(18), AND, IDENT(city), EQ, STRING(北京)]。这一步的难点不在切分本身,而在处理字符串字面量和边界字符。比如city = "北 京"这种带空格的字符串,如果你简单按空格 split,直接切碎。所以必须用逐字符扫描的方式。

public class Lexer { private final String src; private int pos = 0; public Lexer(String src) { this.src = src; } public List<Token> tokenize() { List<Token> tokens = new ArrayList<>(); while (pos < src.length()) { char c = src.charAt(pos); if (Character.isWhitespace(c)) { pos++; continue; } if (c == '"' || c == '\'') { tokens.add(readString(c)); continue; } if (c == '(' || c == ')') { pos++; tokens.add(new Token(c == '(' ? TokenType.LPAREN : TokenType.RPAREN, String.valueOf(c))); continue; } if (isOperatorStart(c)) { tokens.add(readOperator()); continue; } if (Character.isDigit(c)) { tokens.add(readNumber()); continue; } if (Character.isLetter(c) || c == '_') { tokens.add(readIdentOrKeyword()); continue; } throw new IllegalArgumentException("无法识别的字符: " + c + " 位置 " + pos); } return tokens; } // readString / readOperator / readNumber / readIdentOrKeyword 逐字符推进 }

几个实测经验。第一,运算符要按最长匹配来读,>=不能先读到>就返回,否则>=会被解析成>加一个非法字符=,报错信息会让用户莫名其妙。第二,待识别的标识符里要区分关键字(AND、OR、NOT、true、false)和普通变量名,最省事的做法是先统一读成标识符,然后在构造 token 的时候查一张关键字表做大写转换比对。第三,报错一定要带位置信息,用户写的表达式经常有笔误,你只告诉他"解析失败",他根本不知道自己第几个字符写错了。我在公司内部的规则配置台上加了列号高亮之后,规则配置错误的咨询量降了一大截。

3.2 递归下降的层级划分:优先级靠函数调用栈体现

语法分析这块,核心是把运算符的优先级编码到函数调用的层次里。表达式语法的优先级从低到高通常是:OR<AND<NOT< 比较运算 < 括号/原子。所以我的解析函数从最低优先级的入口开始,每一层负责自己那一级的运算,遇到高一级的就往下调。

public class Parser { private final List<Token> tokens; private int idx = 0; public Parser(List<Token> tokens) { this.tokens = tokens; } public Expression parse() { Expression e = parseOr(); if (idx < tokens.size()) { throw new IllegalArgumentException("表达式尾部有多余内容: " + tokens.get(idx).text); } return e; } private Expression parseOr() { Expression left = parseAnd(); while (match(TokenType.OR)) { Expression right = parseAnd(); left = new OrExpression(left, right); } return left; } private Expression parseAnd() { Expression left = parseNot(); while (match(TokenType.AND)) { Expression right = parseNot(); left = new AndExpression(left, right); } return left; } private Expression parseNot() { if (match(TokenType.NOT)) { return new NotExpression(parseNot()); } return parsePrimary(); } private Expression parsePrimary() { if (match(TokenType.LPAREN)) { Expression e = parseOr(); expect(TokenType.RPAREN); return e; } return parseComparison(); } private Expression parseComparison() { Token ident = expect(TokenType.IDENT); Token op = expectOperator(); Token value = expectValue(); return new VariableExpression(ident.text, op.text, value.value); } }

这段代码里有两个地方值得重点说。

第一处是parseAnd里的while循环,它让A AND B AND C这种链式表达能正确组成左结合的树((A AND B) AND C),而不是只解析出前两个就丢掉第三个。我最早写成if而不是while,结果用户写三个条件的时候,第三个条件被静默忽略,整个规则判断结果偏宽,问题上线后才发现,非常危险。

第二处是parsePrimary里遇到左括号之后,必须回到最低优先级的parseOr,而不是回到parseComparison。这一步是把括号内的内容当作一个完整的子表达式来处理,(A OR B) AND C才能正确变成"或的结果再与 C 与"。如果你回到parseComparison,遇到括号内的 OR 就会直接报错。这是递归下降里最经典的坑,写的时候一定要在纸上把"括号内应该走哪个入口"想清楚。

3.3 构建与执行分离带来的两个实际好处

把解析和解释拆成两套东西,除了职责清晰之外的实质收益,我自己感受最深的有两条。

第一条是可以缓存语法树。用户配置的规则表达式一旦保存下来,解析结果是可以复用的——同一段字符串每次筛数据都重新解析一遍纯属浪费。我的做法是在规则保存时解析成树并序列化存下来,运行时反序列化直接用,或者干脆在内存里挂一层表达式字符串到语法树的缓存。这个改动的收益很直接:我们那个规则引擎单次批量判断一万行数据,解析耗时从占比 30% 以上降到可以忽略。

第二条是可以单独对语法树做分析。比如产品要求"配置规则时提示用户涉及的字段",那我遍历这棵树收集所有VariableExpression里的变量名就行;要求"估算规则复杂度防止用户写太深的嵌套",我算树的深度就行;甚至在保存前做静态校验,检查引用字段是否真实存在于元数据里,也是遍历树。这些事情在纯字符串处理或者 if-else 里几乎没法优雅地做,但树结构天然适合做这类遍历。

我一般会给 Expression 接口加一个collectVariables之类的辅助方法,或者单独写一个 Visitor 遍历,取决于项目规模。规模小直接加方法,规模大或者树结构经常变,就用 Visitor,别为了模式而模式。

4. 把解释器模式真正用进业务时,绕不开的几个决策

代码跑通只是开始,真正难的是决定"哪些逻辑该做成分支节点""语法树要不要序列化""性能怎么兜住"。这几个决策我在不同项目里反复权衡过,下面把结论和理由摊开说。

4.1 节点粒度:合并还是拆分,取决于语法复杂度

前面我把"取值"和"比较"合并在了一个VariableExpression里,这在语法简单的时候省事。但如果你的表达式会出现这种形态:

  • price * quantity > 500(带算术运算)
  • lower(name) = "abc"(带函数调用)
  • createTime > now() - 7d(带时间和内置函数)

那节点就必须拆细:VariableExpression(取值)、LiteralExpression(字面量)、BinaryOpExpression(算术)、FunctionCallExpression(函数)、CompareExpression(比较)。节点拆细之后,每加一种新能力只需要加一个新类,符合开闭原则,这是解释器模式最核心的收益。

但拆细的代价是类的数量暴涨。我做过一个支持完整布尔+算术+函数的规则引擎,最后节点类有十几个,加上解析器里对应的解析函数,代码量不小。所以我的经验判断是:

场景特征建议粒度理由
只有单一字段与常量比较合并成一种比较节点类少,维护成本低
有算术、有函数、有嵌套拆成变量/常量/运算/函数/比较扩展性强,符合模式初衷
团队不熟悉设计模式先合并,遇到扩展痛点再拆避免过度设计引发的理解成本
需要给用户做规则可视化编辑拆细,且每个节点带元信息可视化需要按节点类型渲染不同控件

这里有个反直觉的结论:解释器模式的收益和你语法的复杂程度强相关,语法越简单,这个模式的性价比越低。如果你只是要判断a > 1,老老实实写个正则或者用SpEL之类的现成表达式库,别自己上模式。模式的价值在"你要自己控制语法、自己定制语义、自己扩展节点"的时候才体现。

4.2 语法树要不要持久化,怎么持久化

规则类系统的典型要求是"用户配置一次,之后长期用"。这意味着语法树最好能存下来,下次直接用,而不是每次从头解析。可选方案我列一下:

  • 存原始表达式字符串:最简单,但每次都要解析,大量规则并发判断时解析成本叠加明显;
  • 存 JSON 序列化的树结构:需要给每个节点写序列化/反序列化逻辑,或者用 Jackson 的多态反序列化,用type字段区分节点类型;
  • 存编译后的字节码:太重的方案,规则引擎这个量级不值当;
  • 内存缓存 + 存字符串兜底:我实际用得最多的方案。

Jackson 多态那块容易踩坑,我给你个能直接抄的写法:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = VariableExpression.class, name = "var"), @JsonSubTypes.Type(value = AndExpression.class, name = "and"), @JsonSubTypes.Type(value = OrExpression.class, name = "or"), @JsonSubTypes.Type(value = NotExpression.class, name = "not") }) public interface Expression { boolean interpret(EvaluationContext ctx); }

注意两点:一是接口上得能加@JsonTypeInfo,我在老项目里遇到过接口是第三方库定义、没法加注解的情况,只能改成抽象类或者写自定义Serializer/Deserializer;二是反序列化的时候节点类必须有无参构造和可访问的 setter 或字段,我的节点字段都是 final 的,所以最后老老实实写了带@JsonCreator的构造函数。这个坑花了我半天,报错信息极其不友好。

4.3 性能:短路的边界在哪里

解释器模式的一个隐含优势是天然支持短路,但这个优势能不能吃上,取决于你怎么写节点的interpret。前面AndExpression用&&是对的。但还有两种短路需要你自己处理:

一种是跨节点的公共子树。如果我解析出(a > 1 AND b < 2) OR (a > 1 AND c = 3),那a > 1会被求值两次。这种写法在某些自动生成的规则里会出现。解决办法是在上下文的 cache 里做记忆化,节点算完把结果记录下来,key 用节点的某种稳定标识(表达式片段或节点 ID)。注意这需要节点逻辑足够纯——不依赖外部可变状态,否则缓存会出错。

另一种是批量数据行共享的常量栈。如果表达式里有函数调用或者内置变量(比如now()),每行数据都调用一次是浪费,可以在上下文初始化时算一遍放进变量表。这类"解释期不变"的部分提前提取,是规则引擎性能优化里性价比最高的一招。

我做过一个粗略的压测对比,在同一台机器、一万行数据、规则树深度为 6 的情况下:

实现方式单万行耗时(粗测)主要瓶颈
每行都重新解析字符串再求值约 1200ms解析占大头
复用语法树,无缓存约 180ms节点遍历
复用语法树 + 子树记忆化约 95ms上下文查表
上面基础上常量预提取约 70ms数值比较

数据是粗略的,不同机器、不同语法差异很大,但趋势很清楚:解析和重复计算是两个最该先干掉的开销,而不是去抠节点方法调用那点开销。

5. 一处真实调试记录:规则"莫名通过"的排查全过程

这一节讲一个我没忘掉的坑,因为它把书面上不会写的排查链路完整暴露出来了。某次上线后,运营反馈某条促销规则"该拦的没拦住"——规则本来是"用户等级大于 3 且下单金额大于 200 才享受优惠",但很多等级为 2 的用户也享受了。

5.1 从现象往回倒推,先缩小怀疑范围

第一反应是数据问题,但抽了几个用户的订单确认数据没问题;第二反应是规则配错了,打开配置看到表达式是对的。于是我把怀疑点收在引擎本身。排查的动作是把这条规则拉出来单独跑,用那批用户的真实数据手动调用引擎,结果确实是 true——现象复现了。

接着我在上下文里加了临时的日志,打印每次lookup的变量名和值,日志显示level的获取是 2,amount是 300。那么问题就定位在:level > 3这段本该返回 false 的子表达式,最终没有让它拦住整个 AND 表达式。

5.2 定位到比较逻辑的类型转换分支

合看VariableExpression.compare的实现,问题立刻清楚了。level从数据库里读出来是Long类型,值是2;而规则里的3经过解析器时,被读成了 token 类型 NUMBER,readNumber里我统一用Integer.parseInt转成了Integer。于是在toNumber里,一个Long一个Integer——其实两个都能转 double,但我当时写的toNumber有个分支:先判断instanceof Integer就直接返回 intValue,不满足再判断instanceof Long,两个分支返回值一个是int一个是long,在自动拆箱和方法重载上出了偏差。更糟的是我在某次重构时把比较运算符的分支顺序打乱了,>和<的处理被调换传参顺序,导致2 > 3被算成3 > 2。

说到这里必须说明白:这个坑的根因不是解释器模式,而是类型转换和手写比较逻辑的不严谨。但解释器模式放大了它的危害——因为每个叶子节点的比较都走这段逻辑,一处错,所有规则全错,且错得很隐蔽(结果看起来总是"宽松一点",不会抛异常)。修法很简单:把toNumber统一收敛成返回BigDecimal的单一实现,所有数值比较都走它,然后在关键路径加单测覆盖Integer、Long、Double、String两两组合。

5.3 顺着这个坑补上的防御措施

修完 bug,我顺手做了三件事,后来证明都很有价值。

第一,给终结符节点加类型校验开关。规则保存时,拿字段的元数据(类型)和表达式里字面量的类型做一次校验,明显不匹配直接拒绝保存并提示。这一条把"上线后才发现的类型错"变成了"配置时就挡住"。

第二,加一层"规则试跑"能力。允许用户拿几条样例数据跑一下表达式,看到每个子表达式的求值结果。实现上是给interpret加一个可选的追踪模式,或者写个evaluateWithTrace返回中间结果列表。这个功能上线后,规则配置的答疑量明显下降,因为它把黑盒变成了可观察的过程。

第三,对比较节点的语义写清楚文档。哪种类型按字符串比较、哪种按数值比较、空值怎么处理、字段不存在怎么办,全部写成一张表贴在团队文档里。解释器模式的一个特点是语义完全由你的节点实现定义,没有语言标准替你兜底,所以你定义的语义必须自己说清楚,否则使用方会按 SQL 或者别的语言的直觉去理解,然后产生期望落差。

6. 与其他可选方案对比:什么时候不该用它

我一向认为讲模式必须讲边界,不然容易误导人去硬套。解释器模式的适用面其实相当窄,下面把它和几个常见替代方案摊开比。

方案适合的场景主要代价
解释器模式手写语法树语法由你定义、需要精细控制执行过程、需要按节点扩展能力解析器要自己写,节点类多,前期投入大
现成表达式引擎通用表达式求值、追求快速落地语法规则受制于人,安全边界要自己收,扩展难
规则引擎产品规则量大、需要规则管理界面和版本控制引入重,学习成本高,小场景过度设计
策略模式 + 硬编码规则种类固定且有限(十几种以内)新规则要改代码发版,无法让业务自助配置
直接脚本执行表达式极其灵活安全风险高,执行过程不可控,出错难排查

判断方法很简单,问自己三个问题:规则是业务方配的,还是开发写死的?语法会不会需要扩展新运算符或新函数?执行过程需不需要可观测(能拿到中间结果)?如果这三个答案都是"是",那解释器模式加自写解析器是对路子的;如果有任何一个是否,先考虑现成表达式库,别自己造。

反过来说,解释器模式最典型的误用,是把它当成"解析字符串"的银弹。很多人一看到需要解析表达式,就想上这个模式,结果花了三周写了一套只支持 AND/OR 的解析器,而项目里其实一个成熟表达式库两天就能接完。模式是用来解决"结构可扩展"的,不是用来炫技的。

还有一个常见的过度设计:把每个节点都做成可配置、可插件化、动态加载。除非你的系统本身就是给别人做二次开发的平台,否则节点类编译在代码里就够了,动态加载带来的调试复杂度远超收益。我在一个项目里试过用反射动态注册节点类型,结果一次重构后类名改了,配置里的旧节点名找不到类,线上规则全挂,回滚加别名才救回来。这种教训很实在。

7. 我在实际项目里留下的几条落地心得

最后把散落在各部分里的经验收一收,都是踩过的、书本上不会写的。

关于错误信息的设计。解释器模式的报错链路天然是深的:客户端 → 解析器 → 某个节点 → 上下文。如果不刻意设计,用户看到的就是最底层的异常,根本不知道对应表达式哪一段。我的做法是给每个节点加一个toString或者"回显片段"能力,异常往上抛的时候把片段带上,最终在客户端拼成"表达式第 N 个字符附近出错了,片段是 xxx"。这一条对配置类系统的体验影响极大。

关于空值与缺字段的语义。这是我被问得最多的问题。我的默认策略是:字段不存在就报错,字段存在但值为 null 则参与比较时按业务定义(数值比较视为不通过,字符串比较只和 null 相等)。但是这个默认值一定要在规则编辑器里明示,最好配一个"测试样例",因为业务方对 null 的理解千差万别,靠口头约定一定会出事故。

关于短路带来的副作用。短路是性能优势,但它会让"本应抛出的错误"被跳过。前面说status = "error" OR retry > 3的例子就是典型。解决办法有两个:一是在规则保存时做完整的静态检查,确保所有引用字段都存在;二是提供关闭短路的调试模式,遇到有疑问的规则跑一遍完整求值,看是不是某个分支藏着问题。

关于测试怎么写。解释器模式的测试关键不是测单个节点(那个太简单),而是测解析器和解释器的组合行为。我的做法是建一组数据驱动的用例表:表达式、数据行、期望结果。表驱动测试对这类场景效率最高,新增一种语法就加几行数据,跑一遍全绿就放心。

表达式数据期望
a = 1 AND b = 2a=1,b=2true
a = 1 AND b = 2a=1,b=3false
a = 1 OR b = 2 AND c = 3a=0,b=2,c=0false(验证优先级)
(a = 1 OR b = 2) AND c = 3a=0,b=2,c=3true(验证括号)
NOT (a = 1)a=2true
x > 10无 x 字段抛异常(验证缺字段策略)

关于 tree 的深度限制。用户手写的表达式可能嵌套几十层,递归求值有栈溢出风险。我在解析阶段加一个深度计数,超过比如 50 层直接拒绝。这个限制看起来粗暴,但实测下来真实业务表达式很少超过 8 层,限制一下不吃亏,还能挡住恶意构造。

关于和业务代码的边界。解释器节点只做"解释",不要在里面塞数据库查询、HTTP 调用这些副作用。我在早期版本里把"取用户信息"做成了一个节点,结果规则求值过程变成了远程调用链,超时、重试、事务全来了,彻底失控。正确的做法是把所有需要的数据在上下文构建阶段一次性喂进去,节点只做纯计算。这一条我是吃了大亏才记住的:解释器模式里的节点必须是纯函数式的,副作用留在上下文准备阶段。

这套东西我在两个不同项目里落地过,一个用在监控告警的阈值规则,一个用在内部配置平台的动态校验。规模都不算大,但因为语法要自己控制、要能追溯每次判断的过程,所以手写这套比接现成库更合适。如果你的场景也是这样——规则由业务配置、需要可解释、需要逐步扩展语法——那这套结构可以直接照着搭;如果你只是想快速判断一个表达式,别折腾,找个成熟库两天接完更划算。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询