☰
解释器模式实战:从if-else泥潭到可扩展规则引擎
2026/10/16 3:53:30 网站建设 项目流程

前阵子接了一个改造任务,结算系统里积攒了三年的规则判断,最外层一个方法能堆出两百多行分支。需求方还在不断追加类似“金额区间在A和B之间,并且客户等级不低于C,或者存在某种优惠标记”的组合条件。每次加规则都是在原有判断体上再补一层逻辑,时间一长,代码已经没法读了。这种场景下,我想到了设计模式里的解释器模式,把规则从硬编码的if-else中抽出来,变成一组可以解析、可以组合、可以单独测试的对象。这篇文章就把我对这个模式的完整理解和实操过程梳理一遍。

解释器模式是什么?一句话概括:给一套简单的语言定义文法,再把语言中的每一条文法规则映射成一个类,运行时由这些类协同完成对输入内容的理解和计算。规则本身变成了程序里的一等公民,可以被动态组装、被加载、被替换。

谁适合关注它?正在做配置化规则系统、自定义公式计算、动态筛选条件、报表维度定义或者小型DSL的人,这个模式有很强的参考价值。如果你平时只写CRUD接口,那它离你有点远;但只要业务规则开始从代码里“长”出来,解释器模式就是那条不太显眼的逃生通道。

1. 遇到组合规则时,为什么别继续堆if-else

1.1 硬编码规则最大的问题不是“长”,而是“没法独立变化”

if-else写规则,表面上是在写业务逻辑,实际上是把“规则本身”和“规则的执行流程”焊死在了一起。每一条新规则都要读懂原有的变量状态、判断顺序、分支结构才能下手,改一处出租率相关的条件,都可能影响旁边七八个判断。这种代码最大的痛点不是行数多,而是规则没有独立的生命形态。

解释器模式的思路是把规则字符串作为输入,通过词法分析和语法分析把它转换成一棵由表达式对象组成的树。执行的时候,树上的每个节点都知道自己如何解释当前上下文。这带来的直接好处是:规则可以存放在数据库或配置文件里,业务变化时只改配置,不动代码;同时每一条规则背后的表达式类都可以单独写单元测试,问题定位范围瞬间缩小。

从维护视角看,硬编码规则是“规则长在代码里”,解释器模式是“规则长在对象树上”。前者修改规则要重新发布,后者修改规则只需要修改被解析的那段字符串。如果你经历过那种“为了改一个折扣计算逻辑,要在一个巨型方法里排查半天”的日子,就能理解这个转变有多重要。

1.2 四个核心角色,其实不复杂

解释器模式的角色结构非常稳定,无论拿到什么语言,归根结底就是四类东西:

  • AbstractExpression(抽象表达式):定义interpret方法,是所有表达式节点的统一接口。
  • TerminalExpression(终结符表达式):文法中的最小单位,比如一个数字、一个变量名、一个字符串字面量。
  • NonterminalExpression(非终结符表达式):代表组合规则,比如AND、OR、NOT、加减乘除,内部持有一棵子树。
  • Context(上下文):保存运行时的变量值、配置项或者外部查询接口,解释过程中供所有节点访问。

这四者的关系可以用句子做类比:终结符是单词,非终结符是语法结构,Context是词典,整个解释过程就是按照语法规则把单词组合成句子,再对照词典理解含义。

很多教材把Terminal和Nonterminal放在UML图里,看起来有点抽象。实际上,Terminal就是树上不会再往下分的叶子节点,Nonterminal是内部节点,它持有左右子树,interpret时递归调用子节点的interpret。这和组合模式是同一个递归骨架,只是组合模式组织的是对象结构,解释器模式组织的是语法树结构。

1.3 树形结构天然解决了“优先级”和“组合顺序”

为什么用语法树而不是直接用字符串做判断?因为树的结构本身就编码了运算顺序和组合关系。比如表达式“3 + 5 × 2”,如果解析成AddExpression(NumberExpression(3), MultiplyExpression(NumberExpression(5), NumberExpression(2))),解释时先进入Add的interpret,它先要求左子树返回3,再要求右子树返回5×2的结果,乘法自然先于加法执行。

这里不用给乘除法单独设定优先级变量,也不用做运算符比较,优先级的差异被吸收进了树的构造过程。同样道理,AND和OR的组合关系也由树的位置决定:(A and B) or C 和 A and (B or C) 会生成不同形状的树。

正是因为这种特性,解释器模式非常适合表达那些“规则之间有关联、有层级、有优先级”的场景。单纯用if-else表达优先级,需要额外维护一个运算符优先级表;用解释器模式,只需要把树建对。

1.4 它在真实系统里出现过,只是你未必注意

很多人觉得解释器模式很冷门,因为日常开发不常自己写一个解释器。但正则表达式就是一个运行了几十年的解释器体系。SQL也是,数据库引擎把SQL文本解析成语法树再执行。模板引擎里的表达式解析、报表工具里的计算公式、规则引擎里的条件文件,底层都有解释器模式的影子。

这些都属于“重实现”。对于大多数业务系统,不需要自己从零写一套SQL引擎,但在某个局部范围内自定义一种小语言,解释器模式就非常顺手。

2. 手写一个能跑起来的迷你计算器表达式引擎

2.1 从接口和两个终结符开始

先看一个最简单的计算器场景:支持整数加减乘除,支持变量引用。这里不涉及字符串解析,先手工组装语法树,借此看清模式本身的类结构。

抽象接口定义如下:

public interface Expression { int interpret(Map<String, Integer> context); }

一个数字是终结符,它的解释结果就是自身保存的值:

public class NumberExpression implements Expression { private final int value; public NumberExpression(int value) { this.value = value; } @Override public int interpret(Map<String, Integer> context) { return value; } }

变量也是终结符,它的值需要从Context里取,所以解释时必须依赖上下文参数:

public class VariableExpression implements Expression { private final String name; public VariableExpression(String name) { this.name = name; } @Override public int interpret(Map<String, Integer> context) { if (context.containsKey(name)) { return context.get(name); } throw new RuntimeException("未定义的变量: " + name); } }

注意这里Number和Variable都是叶子节点,不会继续调用其他Expression,所以它们都是终结符。区别在于Number的结果是静态的,Variable的结果依赖外部环境。

2.2 非终结符的递归组合

加减乘除属于非终结符,它们内部持有左右两个子表达式。以加法为例:

public class AddExpression implements Expression { private final Expression left; private final Expression right; public AddExpression(Expression left, Expression right) { this.left = left; this.right = right; } @Override public int interpret(Map<String, Integer> context) { return left.interpret(context) + right.interpret(context); } }

减法、乘法、除法结构完全一样,只是符号和运算逻辑不同,完全可以照猫画虎。这里的关键点是:AddExpression是一个非终结符,因为它不是终结,它需要继续让左右子树分别解释,得到两个值后再做加法运算。

手工组装一个表达式树就能演示整个模式的运转:

Map<String, Integer> context = new HashMap<>(); context.put("price", 10); context.put("count", 5); context.put("fee", 2); // price * count + fee Expression root = new AddExpression( new MultiplyExpression( new VariableExpression("price"), new VariableExpression("count")), new VariableExpression("fee")); int result = root.interpret(context); System.out.println(result); // 输出 52

当interpret被调用,根节点Add先递归去求左右子树的值,左子树是Multiplication,继续递归求price和count,整个过程像水往下流一样清晰。这就是解释器模式的核心形态:一个由节点组成的递归树,执行路径就是树的深度遍历。

2.3 解析器把字符串变成语法树

手工组装语法树只适合教学。实际业务中用户不可能写Java代码来拼对象,他们输入的是字符串,比如“price * count + fee”或者带括号的“(a - b) * 3”。

所以需要两个前置步骤:词法分析和语法分析。词法分析负责把字符串拆成Token,语法分析负责按照文法把Token组装成Expression对象。

Token可以设计成简单的类型加文本:

public class Token { public enum Type { NUM, VAR, OP, LPAREN, RPAREN, EOF } public Type type; public String text; public Token(Type type, String text) { this.type = type; this.text = text; } @Override public String toString() { return text; } }

词法分析器按字符扫描,把连续数字识别成NUM,把连续字母识别成VAR,把+ - * / ( )识别成对应的运算符类型:

public class Lexer { private final String input; private int pos = 0; public Lexer(String input) { this.input = input; } public Token next() { while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) { pos++; } if (pos >= input.length()) { return new Token(Token.Type.EOF, ""); } char c = input.charAt(pos); if (Character.isDigit(c)) { StringBuilder sb = new StringBuilder(); while (pos < input.length() && Character.isDigit(input.charAt(pos))) { sb.append(input.charAt(pos)); pos++; } return new Token(Token.Type.NUM, sb.toString()); } if (Character.isLetter(c)) { StringBuilder sb = new StringBuilder(); while (pos < input.length() && Character.isLetter(input.charAt(pos))) { sb.append(input.charAt(pos)); pos++; } return new Token(Token.Type.VAR, sb.toString()); } if ("+-*/".indexOf(c) >= 0) { pos++; return new Token(Token.Type.OP, String.valueOf(c)); } if (c == '(') { pos++; return new Token(Token.Type.LPAREN, "("); } if (c == ')') { pos++; return new Token(Token.Type.RPAREN, ")"); } throw new RuntimeException("无法识别的字符: " + c); } }

语法分析用递归下降比较直观。关键要处理运算优先级和括号。标准做法是分层:表达式parseExpr负责加减,项parseTerm负责乘除,因子parseFactor负责数字、变量和括号。

public class Parser { private final Lexer lexer; private Token current; public Parser(Lexer lexer) { this.lexer = lexer; this.current = lexer.next(); } public Expression parse() { Expression e = parseExpr(); if (current.type != Token.Type.EOF) { throw new RuntimeException("表达式末尾存在多余内容: " + current.text); } return e; } private Expression parseExpr() { Expression expr = parseTerm(); while (current.type == Token.Type.OP) { String op = current.text; if (op.equals("+") || op.equals("-")) { advance(); Expression right = parseTerm(); expr = op.equals("+") ? new AddExpression(expr, right) : new SubtractExpression(expr, right); } else { break; } } return expr; } private Expression parseTerm() { Expression expr = parseFactor(); while (current.type == Token.Type.OP) { String op = current.text; if (op.equals("*") || op.equals("/")) { advance(); Expression right = parseFactor(); expr = op.equals("*") ? new MultiplyExpression(expr, right) : new DivideExpression(expr, right); } else { break; } } return expr; } private Expression parseFactor() { if (current.type == Token.Type.NUM) { Expression e = new NumberExpression(Integer.parseInt(current.text)); advance(); return e; } if (current.type == Token.Type.VAR) { Expression e = new VariableExpression(current.text); advance(); return e; } if (current.type == Token.Type.LPAREN) { advance(); Expression e = parseExpr(); if (current.type != Token.Type.RPAREN) { throw new RuntimeException("缺少右括号"); } advance(); return e; } throw new RuntimeException("无法解析的标记: " + current.text); } private void advance() { current = lexer.next(); } }

这样输入字符串“(price - fee) * 2”,会得到一棵以MultiplyExpression为根的树。执行interpret时,左子树先计算括号内的减法结果,再和右子树数字2相乘。

这个解析过程的巧妙之处在于,乘法优先级不是靠运行时比较实现的,而是靠parseExpr先调parseTerm、parseTerm先调parseFactor这个调用顺序实现的。递归下降的层级顺序就是运算符优先级的顺序。

2.4 边界情况与验证

解析器跑通之后,需要补一批边界场景。负数需要额外处理,比如“-3 + 5”里的一元负号;除零需要明确报错;连续运算符像“1 + -2”要决定是否支持。我的建议是最初版本不要贪多,把整数、变量、四则运算、括号这几样做稳,再做扩展。

验证示例:

  • “3 + 5 * 2”应得到13,树结构为Add(3, Multiply(5, 2))。
  • “(3 + 5) * 2”应得到16,乘法根节点的左子树是Add。
  • “price * count + fee”,当price=10、count=5、fee=2时得到52。
  • 变量未定义时要抛异常,不要返回0,否则规则会悄悄失效。

当时我把变量未定义设计成返回0,结果某个月的费用计算全变成了空值叠加,排查了半天才发现是规则引擎把未命中变量当作0处理了。这种错误不会崩溃,但数据结果会失真,非常坑。

3. 实战:一个可扩展的条件过滤规则引擎

3.1 需要什么样的规则语言

现在把问题升级:要做一个类似于动态条件过滤的引擎。输入是形如“age > 18 and city == 'beijing' or not vip”的条件字符串,要求对集合中的每条数据做过滤判断。

这比单纯四则运算更接近真实业务。我为此定义了一个很小的文法:

  • 条件由布尔表达式组合而成,支持AND、OR、NOT
  • 比较操作支持 ==、!=、>、>=、<、<=
  • 操作数支持字段名、数字、字符串、布尔值
  • AND优先级高于OR,NOT优先级最高,括号可改变组合顺序

这套文法规模控制在合理范围内,类不会爆炸,也能覆盖大量实际过滤需求。

3.2 抽象表达式与比较表达式

接口定义为:

public interface ConditionExpression { boolean interpret(Map<String, Object> row); }

终结符有两种:字段引用和字面量。字段引用从row里取数据,字面量直接返回自身保存的值。比较表达式是核心的非终结符,它持有左右两个操作数表达式和一个比较运算符:

public class CompareExpression implements ConditionExpression { private final Expression left; private final Expression right; private final String operator; @Override public boolean interpret(Map<String, Object> row) { Object leftValue = left.interpret(row); Object rightValue = right.interpret(row); if (leftValue instanceof Number && rightValue instanceof Number) { double l = ((Number) leftValue).doubleValue(); double r = ((Number) rightValue).doubleValue(); switch (operator) { case ">": return l > r; case ">=": return l >= r; case "<": return l < r; case "<=": return l <= r; case "==": return l == r; case "!=": return l != r; default: throw new RuntimeException("不支持的比较运算符: " + operator); } } // 字符串和布尔值场景 switch (operator) { case "==": return leftValue.equals(rightValue); case "!=": return !leftValue.equals(rightValue); default: throw new RuntimeException("非数值类型不支持此运算符: " + operator); } } }

AND、OR、NOT是比较简单的非终结符,内部持有子条件:

public class AndExpression implements ConditionExpression { private final ConditionExpression left; private final ConditionExpression right; @Override public boolean interpret(Map<String, Object> row) { return left.interpret(row) && right.interpret(row); } }

这里的短路求值是免费的,因为Java的&&运算天然满足左边的false不会触发右边求值。对条件判断类引擎来说,这恰好符合预期,甚至可以做性能优化:只要左条件不满足,右条件根本不必执行。

3.3 把规则字符串解析成条件树

解析器的写法和四则运算解析器如出一辙,只是多出比较运算符和逻辑关键字。词法层面用正则匹配会更省事:把“and”、“or”、“not”、“==”、“!=”、“>=”、“<=”、“>”、“<”、“(”、“)”、“字符串字面量”、“布尔字面量”、“数字”、“字段名”统一识别为Token。

语法层面的递归下降层级需要调整为:parseOr -> parseAnd -> parseNot -> parseComparison -> parseOperand。这个顺序保证AND优先级高于OR。

部分核心代码如下:

private ConditionExpression parseOr() { ConditionExpression expr = parseAnd(); while (current.text.equals("or")) { advance(); ConditionExpression right = parseAnd(); expr = new OrExpression(expr, right); } return expr; } private ConditionExpression parseAnd() { ConditionExpression expr = parseNot(); while (current.text.equals("and")) { advance(); ConditionExpression right = parseNot(); expr = new AndExpression(expr, right); } return expr; } private ConditionExpression parseNot() { if (current.text.equals("not")) { advance(); ConditionExpression inner = parseNot(); return new NotExpression(inner); } return parseComparison(); }

这样输入“(age >= 18 and city == 'sh') or not vip”,会被解析成OrExpression根节点,左子树是AndExpression,右子树是NotExpression。执行过滤时对每行数据都调用根节点的interpret,返回true的数据进入结果集。

3.4 开闭原则的真相与边界

解释器模式经常被称赞为符合开闭原则,新增一种操作符只需要新增一个表达式类。但有一个细节,很多人没讲透:新增操作符时,Parser的分支也要同步加。这不是完全零修改,而是把修改限制在“解析层的分发逻辑”和“对应的表达式类”两处。

真正稳定的部分是结构骨架。比如从只支持比较运算扩展到支持正则匹配、前缀匹配、模糊匹配,Expression接口不需要变,Context传递方式不需要变,递归下降的框架也不需要变。改动集中在Token识别和对应的新节点类,这对中大型规则系统来说已经是巨大的维护优势。

3.5 性能视角:解析成本和解释成本要分开看

条件引擎性能最大的浪费点往往不在interpret的执行,而在每次过滤前都对字符串重复解析。实际做法是开启引擎时将规则字符串解析成AST并缓存起来,后续每条数据只需要反复遍历这棵树。这和使用数据库中的预编译语句是同一个思路。

如果单条规则要跑几十万条数据,还能做第二层优化:把AST转成更紧凑的谓词函数或者脚本字节码。但这对大多数业务系统不是必须的。AST解释执行在十万数据量级表现足够,只有到百万、千万级别时才需要认真考虑编译执行。优化前一定要先用数据说话,不要为了百分之一的场景牺牲掉模式本身的清晰度。

4. 解释器模式的雷区:这些时候别硬上

4.1 类数量膨胀的速度比你想象得快

每一条文法规则对应一个类,非终结符一个类,终结符一个类,再加上组合类型,文法有十种结构,类就可能冲到二十个以上。如果文法本身还在演进,类的数量和彼此之间的组合关系会迅速让工程变得笨重。

所以我的经验是控制文法规模。自定义小语言只解决一个局部问题,不要把整个业务系统都设计成一套语言。规则覆盖到“配置化、高频变化、组合复杂”这三个特征,才值得引入解释器模式。

4.2 两三层的简单判断用模式就是过度设计

见过有人对一个status字段判断收到几十个值的情况,用解释器模式套了一个大大的规则引擎,最后维护成本比原来的if-else还高。其实那种场景更适合用策略模式加枚举表。判断逻辑简单、分支固定、没有组合关系时,直接用if-else或者开关语句最直白。

解释器模式的投入产出比,取决于规则的“组合性”。如果规则是平面排列、没有嵌套、没有优先级,就不需要语法树。

4.3 与策略模式、状态机、规则引擎的分工

四者的定位完全不同:

场景推荐方案理由
算法可以替换,但规则形态固定策略模式只需要切换实现类
行为依赖状态迁移,状态节点固定状态机状态转移是核心,规则写在转移表
规则复杂、频繁变化、有大量事实数据参与规则引擎中间件内置解释器,带会话管理、规则冲突处理等能力
自定义小语言,解析和执行都希望自己完全可控解释器模式类结构直观,行为透明,容易定制

选择的关键在于“语言复杂度”和“功能范围”。规则引擎功能强,但引入成本高,学习成本高,运行环境也重。解释器模式轻量、透明,适合在系统内部做一个可维护的局部DSL。

4.4 左递归是递归下降解析的头号杀手

写文法时很容易写出这样的左递归规则:

expr -> expr + term

如果解析器顺着这个规则不断调用parseExpr去匹配前一个expr,再进入下一层parseExpr,就会无限递归直到栈溢出。正确写法是改写成右递归或迭代形式:

expr -> term ((+ | -) term)*

这算是我在实际写解析器时踩过最深的坑。第一次写完也没运行,因为逻辑看起来极其合理,结果一跑直接StackOverflowError。后来才意识到必须消除左递归。设计文法时,一定要先写BNF再写解析器,把递归方向想清楚。

5. 常见问题与调试技巧实录

5.1 栈溢出

嵌套深度极大的表达式会触发栈溢出。比如解析一个一万层括号嵌套的字符串,递归下降天然不适合这种极端输入。实际业务中我会限制表达式长度和括号层数,同时显式抛出提示,而不是让JVM抛一个莫名其妙的StackOverflowError。

5.2 优先级结果和预期不一致

这是最常见的Bug。多半原因是解析器的层级顺序搞反了。排查时可以打印AST结构,用一个简单的树形输出方法辅助观察:

AndExpression |> CompareExpression(age > 18) |> CompareExpression(city == 'beijing')

只要二叉树形态清晰,优先级问题往往一眼就能看出来。

5.3 Context中字段值类型不匹配

row里的值可能是Integer、Long、Double、String,比较表达式做类型转换时必须统一。我的做法是所有数值先转double比较,字符串比较用equals,布尔值特殊处理。遇到null字段要有明确的规则,比如变量不存在时是否允许短路。

5.4 AST的线程安全性

AST构建完成后应当视为不可变对象,所有解释过程不修改节点内部状态。如果表达式节点持有每次执行保存的结果字段,并发环境下就会互相踩踏。Context每次执行单独传入,节点内不缓存运行时数据,这是底线。

5.5 常见问题速查表

问题现象原因对策
栈溢出深层嵌套表达式解析失败递归深度过大限制长度/层数,显式报错
优先级错误2+3*4结果算成20parseExpr和parseTerm顺序反了递归层级对齐优先级
变量未定义返回值异常或静默为0Context取值处理不当明确定义缺省策略,建议抛异常
类型不一致比较结果永远false数值/字符串类型未统一统一类型转换入口
线程数据错乱并发执行结果互相污染节点保存了运行时状态节点保持无状态,Context单独传

5.6 调试时的一个小技巧

在interpret方法里临时加一段日志,打印当前节点类型和操作数结果,是排查解释器问题最快的手段。因为语法树是一层一层递归的,节点执行顺序就是树的后序遍历顺序,日志输出基本能还原整棵树的执行轨迹。

6. 我在实践里的使用心得

解释器模式最大的价值不是执行效率,而是让规则从代码里浮出来。规则变成字符串存储在配置中心或数据库后,产品调整条件不需要再催研发改代码,运营人员可以直接修改规则文本。这个变化在实际项目里带来的维护收益,比任何微优化都明显。

我个人的习惯是:先写BNF定义语言范围,再做词法,再做语法,最后写表达式类。别一上来就定义一堆类,顺序反了会越写越乱。另外,每一步都要配测试,终结符测试、非终结符测试、组合表达式测试各写一组。表达式引擎这类东西,一旦出错很难通过肉眼发现,扎实的测试网格比什么都重要。

如果你正准备改造一段长了很久的规则代码,试着把规则语言先限定在一个很小的范围内,用解释器模式建一棵真正的语法树,你会重新体会到“面对变化”的从容感。

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

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

立即咨询