这是设计模式系列的第 16 篇,聊解释器模式。解释器模式的核心价值,是让程序具备“读规则、执行规则”的能力。我第一次对它有强烈共鸣,是接手一个促销规则配置的需求。当时的业务规则长这样:单笔订单满 200 减 30,满 500 减 100;同一用户每天限领 1 张;不同会员等级叠加比例不一样。这些规则一环套一环,写成 if else 不是不行,只是每改一次规则就要发一次版,运营等不起,研发也被这种琐碎改动搞得很疲惫。把规则抽象成表达式,存到配置里,由程序在运行时解释执行,这就是解释器模式的典型应用场景。
我下面的内容会从“什么时候值得用解释器模式”讲起,拆解它的核心角色,再用一个四则运算计算器案例手把手带你把整个流程走通,然后延伸到真实业务中的规则引擎落地样本,最后盘点它的优缺点、适用边界,以及我踩过的坑和排查技巧。不管你是正在学设计模式的学生,还是在做规则引擎、低代码平台、报表计算引擎这类产品的开发,这篇都值得看完。
1. 当一个需求走进死角:为什么需要解释器模式
1.1 规则变化的速度远超代码发布节奏
很多系统一开始都是这么写判断逻辑的:
if (order.getAmount() >= 500) { coupon = order.getAmount() - 100; } else if (order.getAmount() >= 200) { coupon = order.getAmount() - 30; }这段代码没有错,短时间也很直观。但问题在于,促销规则不是一个“写完就不变”的东西。运营今天说满 200 减 30,明天可能改成满 199 减 25,后天又加一条“生鲜类目不参与”。每次改动都要走需求评审、开发、测试、发布的流程,哪怕只是改一个阈值数字,线上也要重新走一遍。如果规则继续膨胀,变成“满 200 减 30 且会员等级大于 2 且使用时间在 0 点到 6 点之间”,if else 就会迅速变成一坨很难维护的分支泥潭。
当初我们评估过两个方向:一个是把规则做成数据库配置表,另一个是直接上重型规则引擎。前者的问题是,规则之间的组合关系、优先级、嵌套条件很难用一张表说清楚,配置表结构会被迫越设计越复杂;后者的问题是,规则量级当时并不算大,引入一套完整的规则管理平台反而增加运维和学习成本。后来真正落地的思路,是把规则当作“表达式”来建模,在代码里维护一棵可执行的对象树,这就是解释器模式。
解释器模式适合的场景,核心就三条:语法规则清晰、规模不大、变化频繁。它的本质不是提升单次执行的性能,而是让你不用反复修改代码逻辑。打个比方,正则表达式就是文本处理领域的解释器,SQL 是数据库查询的 DSL。你写[0-9]{4}的时候,本质上就是在定义一种很小的文法并交给引擎执行。理解了解释器模式之后,你再看正则、模板语言,都会多一层视角:它们背后都藏着一棵树和一趟递归遍历。
1.2 面向“文本规则”而不是面向“对象逻辑”
解释器模式把一个大的复杂问题拆成两个阶段:第一阶段是“解析”,把一串文本按照定义的文法转成抽象语法树(AST);第二阶段是“解释”,让这棵树的每个节点自己完成对应的求值动作。大多数文章会把重点放在 AST 上,但我个人觉得更值得反复体会的是,“解释”阶段的设计思路:每个节点都实现同一个接口,知道自己怎么执行,然后通过递归组合,表达出任意复杂的规则。
你不需要把所有东西都设计成解释器。只有在“输入是文本、规则会变、结构可组合”这三个条件同时满足时,解释器模式才有碾压式优势。比如优惠券规则、科学计算器、复杂报表公式、灰度发布策略、流程引擎的条件判断,都很适合用解释器来表达。反过来,如果一个系统里只有那么两三个固定判断,硬上解释器就是典型的过度设计。
2. 解释器模式的核心定义与角色拆解
2.1 四个核心角色一看就懂
解释器模式的定义并不复杂,它给一类“特定文法”的句子定义了一个解释器,这个解释器用一个类层次来表示文法中的每一条规则。用官方一点的说法,叫“给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子”。
标准结构里有四个角色,我整理成一张表:
| 角色 | 作用 | 生活类比 |
|---|---|---|
| AbstractExpression(抽象表达式) | 定义解释操作的统一接口 | 员工手册里“所有员工都要会汇报工作” |
| TerminalExpression(终结符表达式) | 叶子节点,不包含子表达式 | 数字“200”、变量名“amount” |
| NonterminalExpression(非终结符表达式) | 组合子节点,内部包含子表达式 | 运算符“+”“-”“AND”“OR” |
| Context(上下文环境) | 存放解释过程需要的全局信息与变量值 | 会议室里的白板,记录当前值和变量映射 |
在代码里,AbstractExpression 通常只需要一个interpret(Context)方法。终结符负责完成基础取值,非终结符负责递归调用子节点的interpret并组合结果。递归是整个解释器模式的心脏,没有这一层递归,就不能把任意深度的规则翻译成可执行结构。
拿200 + 30 * 2举例,它会被构造成一棵树:根节点是 AddExpression,左边是数值 200,右边是 MultiplyExpression,MultiplyExpression 内部再挂 30 和 2。求值时,根节点让左边先求值得到 200,接着让右边进入乘法逻辑得到 60,最后相加得到 260。这个流程无论嵌套多少层,本质上都是同一套规则:节点对自己负责,然后把结果向上返回。
2.2 与组合模式、外观模式的关系:模式之间的连环扣
“一个设计模式”从来不是孤立存在的,解释器模式和几个兄弟模式有很深的关联,这里值得单独展开说一下。
第一层关联是组合模式。AST 本身就是一种组合模式的树形结构,非终结符表达式就是组合节点,终结符表达式就是叶子节点。因为两者有相同的接口,客户端可以用一致的方式对待单个节点和组合结构。这也是为什么很多教材把解释器模式放在组合模式后面讲,组合模式给了解释器模式骨架,解释器模式让组合出来的树“活”了起来,可以执行行为。
第二层关联是外观模式。一个规则系统对外暴露的往往只有一个入口,比如ExpressionUtils.parseAndEvaluate(String rule, Context ctx)。调用方不需要知道内部有 NumberExpression、AddExpression、AndExpression 这一堆类,只要传入规则文本和业务上下文就能拿到结果。这个对外入口就是外观模式的典型应用。它把系统的复杂内部细节藏了起来,对使用者极其友好。
第三层关联是策略模式。解释器树中有些节点需要对同一种规则采用不同的算法,比如折扣计算可细分为“满减”“折扣”“随机红包”,这种算法选择的灵活性可以用策略模式来实现。策略模式决定“用什么算法”,解释器模式决定“算法里的表达式怎么算”,两者并不冲突,反而可以一起工作。
3. 案例解析:用 Java 手写一个四则运算解释器
3.1 需求与抽象语法树设计
我先上一道经典的题:实现一个命令行计算器,能够计算10 - (3 + 4) * 2这样的四则运算,后续还要支持变量绑定,例如price - (3 + 4) * 2中price由外部传入。这类需求如果只是临时用一次,完全可以直接用现成的表达式求值库,净不到一杯咖啡的时间。但作为理解解释器模式的案例,手写一个会给你带来完全不同的体验,因为你能清晰看见“文本规则变成一棵树、再由树求值”的全过程。
抽象语法树的设计是:定义一个Expression接口,所有节点都实现它的interpret方法。终结符表达式包括 NumberExpression(数字)和 VariableExpression(变量),非终结符表达式包括 AddExpression、SubExpression、MultiplyExpression、DivideExpression。每个操作符表达式持有左右两个子树,构造器接收两个Expression参数。
这棵树一旦建好,结构和解释器模式的标准定义完全对应。新增一个运算类型时,只需要新增一个实现类,再在构建解析器的位置加一小段识别逻辑,其他所有节点都不需要改动,这就是解释器模式最吸引人的地方。
3.2 角色落地代码实现
先定义统一接口:
public interface Expression { int interpret(Context ctx); }终结符表达式实现,NumberExpression 直接返回字面量,VariableExpression 从上下文中取值:
public class NumberExpression implements Expression { private final int value; public NumberExpression(int value) { this.value = value; } @Override public int interpret(Context ctx) { return value; } } public class VariableExpression implements Expression { private final String name; public VariableExpression(String name) { this.name = name; } @Override public int interpret(Context ctx) { if (!ctx.has(name)) { throw new IllegalArgumentException("未定义变量: " + name); } return ctx.get(name); } }非终结符表达式的实现非常直白,比如加法就是把左右两个子树各自求值之后相加:
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(Context ctx) { return left.interpret(ctx) + right.interpret(ctx); } }减法、乘法、除法的代码结构完全一样,区别只是最后一步的运算符。除法这里建议额外加一个除零保护,不要等到运行时让 JVM 抛一个带着长堆栈的异常,那样对调用方很不友好。每个类只干一件事,每个操作符节点只负责组合左右两个子节点的结果,这既是组合模式的好处,也是解释器模式可读性强的根本原因。
3.3 上下文环境与变量绑定
Context在标准解释器模式里的作用是存放解释过程中需要读取或修改的信息。最简单的实现就是一个 HashMap:
public class Context { private final Map<String, Integer> variables = new HashMap<>(); public void bind(String name, int value) { variables.put(name, value); } public boolean has(String name) { return variables.containsKey(name); } public Integer get(String name) { return variables.get(name); } }Context的价值在于把“业务状态”和“表达式节点”解耦。表达式节点不关心变量从哪来,只知道自己要从 Context 里取一个叫price的值;外层业务也不关心 AST 长什么样,只要在解释前把需要的变量 bind 进去即可。后续如果要支持变量修改、作用域嵌套,都是在 Context 这一层做增强,不需要动表达式的类。
接下来是解析器部分。解析器负责把字符串文本变成一棵 Expression 树。这里的核心难点是运算符优先级和括号嵌套,常见的处理方法是递归下降解析。思路是:解析表达式时,把表达式拆成若干项(term),项拆成若干因子(factor),因子可能是数字、变量或括号包裹的子表达式。
public class Parser { private final List<Token> tokens; private int pos = 0; public Parser(String text) { this.tokens = Lexer.tokenize(text); } public Expression parse() { Expression expr = parseExpression(); if (pos != tokens.size()) { throw new IllegalArgumentException("存在无法解析的残留 token"); } return expr; } private Expression parseExpression() { Expression left = parseTerm(); while (match(TokenType.PLUS) || match(TokenType.MINUS)) { Token op = previous(); Expression right = parseTerm(); left = op.type == TokenType.PLUS ? new AddExpression(left, right) : new SubExpression(left, right); } return left; } private Expression parseTerm() { Expression left = parseFactor(); while (match(TokenType.STAR) || match(TokenType.SLASH)) { Token op = previous(); Expression right = parseFactor(); left = op.type == TokenType.STAR ? new MultiplyExpression(left, right) : new DivideExpression(left, right); } return left; } private Expression parseFactor() { if (match(TokenType.NUMBER)) { return new NumberExpression(Integer.parseInt(previous().value)); } if (match(TokenType.IDENTIFIER)) { return new VariableExpression(previous().value); } if (match(TokenType.LPAREN)) { Expression expr = parseExpression(); consume(TokenType.RPAREN, "缺少右括号"); return expr; } throw new IllegalArgumentException("无法识别的 token"); } }这里Lexer.tokenize负责把字符切成 Token 列表,比如10 - (3 + 4) * 2会被切成10, MINUS, LPAREN, 3, PLUS, 4, RPAREN, STAR, 2。真正的解释器模式部分从这个 Token 列表开始:它不关心 Token 是怎么来的,只负责把 Token 组装成 AST 并执行。把解析和解释分离之后,你可以把 AST 缓存起来复用,这是后面性能优化的关键基础。
3.4 手动校验与扩展点
运行效果如下:
Context ctx = new Context(); ctx.bind("price", 10); Expression expr = new Parser("price - (3 + 4) * 2").parse(); int result = expr.interpret(ctx); System.out.println(result); // -4手动算一遍:括号里 3 + 4 = 7,7 * 2 = 14,10 - 14 = -4。代码结构和这个手算过程一一对应,这就是解释器模式在可读性上的优势。
如果要新增一个一元函数,比如取绝对值ABS(-5),只需要新增一个AbsExpression,在parseFactor里加一个关键字判断,其他所有类原封不动。这种“加一类、改一处、其余不动”的扩展方式,正是解释器模式让我觉得舒服的地方。你不需要像改 if else 那样小心翼翼地去梳理各个分支的影响关系,因为 AST 的每个节点边界非常清楚。
4. 从计算器到业务规则:一个促销规则引擎的落地样本
4.1 回到那个满减需求
计算器案例只是热身,真实项目中解释器模式最常见的落地场景是规则计算。回到开头那个满减需求:“满 200 减 30,满 500 减 100,与折扣券互斥”。如果用解释器模式建模,可以设计一个ThresholdDiscountExpression:
public class ThresholdDiscountExpression implements Expression { private final Expression amountExpr; // 计算订单金额 private final int threshold; // 门槛 private final int discount; // 减免金额 private final Expression fallback; // 不满足时返回的结果 @Override public int interpret(Context ctx) { int amount = amountExpr.interpret(ctx); if (amount >= threshold) { return amount - discount; } return fallback.interpret(ctx); } }然后运营在后台配置一条规则文本,比如满200减30,系统解析后得到一棵ThresholdDiscountExpression树。订单结算时,把 order 金额 bind 到 Context,调用一次interpret,优惠金额就出来了。更复杂的规则,比如“满 200 减 30 且会员等级大于 2”,则可以组合出AndExpression(ThresholdDiscountExpression(...), MemberLevelExpression(...)),这种嵌套组合在解释器模式下是顺理成章的。
在实际业务中,这种设计给了我们很大的自由度。运营更新规则时不需要发版,配置中心推送一条新文本,系统重新解析并缓存新的 AST 即可。促销活动上线从“等研发排期”变成了“运营自助配置”,整条业务线都跑得更快。
4.2 解释器与策略模式配合工作
在落地过程中,我们还发现解释器模式和策略模式经常需要配合。一个促销系统往往有多套玩法:满减、折扣、随机红包、积分抵扣。这些玩法的整体算法差异很大,适合用策略模式选择。比如每个玩法一个策略类,订单会按照业务规则选择一个策略执行。但是策略内部的具体计算表达式,比如“折扣比例是 0.95 还是 0.9”“满多少减多少”,是经常变化的细节,这部分就交给了解释器模式。
这样拆开之后,系统结构非常清晰:策略模式决定“用什么算法”,解释器模式决定“算法里的表达式怎么算”。你在代码里可以看到,一个策略类内部持有Expression discountExpr,调用时构建一个 Context,充入订单金额、会员等级、商品品类等因素,然后让表达式自己去算。后续新增一个玩法,只需要新增一个策略类,并且定义好它的表达式文法;后续调整玩法参数,只需要修改表达式文本,两个维度完全解耦。
4.3 性能压测与文法校验的取舍
解释器模式有一个不可回避的问题:性能。每个订单都要解释一遍规则,如果每次请求都用原始字符串重新解析、重建 AST,性能会非常难看。我们当时做过一个压测,简单的满减规则每秒解析上千次都能扛住,但一旦规则复杂到几十个节点,再叠加高峰流量,解析开销就很扎眼了。排查之后发现parse阶段占了将近 70% 的时间,优化方案也非常直接:把 AST 缓存在内存中,仅在规则变更时重新解析。配置变更后通过配置中心推送一个版本号,本地缓存判断版本号不一致才重新构建。优化之后,整体性能直接提升了一个数量级。
另一个容易被忽略的点是安全校验。解释器模式本身不做语法合法性校验,如果你把用户输入的文本直接交给解析器,就可能遇到异常表达式、超长嵌套、恶意字符串等问题。我们的做法是在解析之前加一层“白名单校验”,限制表达式最大长度、最大嵌套深度,并过滤非法字符。保存规则的时候先解析一次,解析失败直接提示运营修改,不让坏规则进入线上环境。
5. 解释器模式的优点、缺点与适用边界
5.1 优点:扩展性好、语法规则表现力强
解释器模式最打动人的优点,是它把“规则的变化”和“代码的修改”彻底解耦了。新增一种操作符,只需要新增一个 Expression 的实现类,再在解析器对应位置加一小段逻辑,原有节点完全不受影响。语法规则通过树形组合表现出来,可以灵活描述复杂的条件组合、运算优先级和嵌套结构。
它的可读性也比一大段 if else 好很多。每个节点的interpret方法都极其简单:终结符取一个值,非终结符递归组合左右子树的结果。新人接手时,只要理解了“这棵树是怎么构建的”,就能顺着树的结构读懂整个规则。如果你在团队里分享过一次解释器模式的代码,很容易收获一种“原来规则还能这么写”的感叹。
5.2 缺点:类膨胀、性能衰减、维护成本
解释器模式的缺点同样明显,我在实际项目里体会最深的是类膨胀。四个基本运算符加上括号、变量、函数、逻辑判断,没几下就会多出十几个类。每个文法规则都对应一个类,当规则从 5 条变成 50 条,类的数量会膨胀到很难管理。类一多,包结构就开始显得凌乱,新人光看类清单就要花不少时间。
性能是另一个硬伤。递归解释执行、多级遍历、频繁创建对象,解释一个表达式往往比直接写死逻辑慢很多。如果一条计算路径每秒被调用上万次,就要非常认真地考虑 AST 缓存、预编译、甚至直接把表达式编译成可执行代码。
维护门槛更加现实。解释器模式要求团队里至少有人能理解“文法和递归下降解析”这套思路,否则一个优先级写错,排查起来非常费劲。运算优先级、括号嵌套、变量作用域、错误恢复,每一个问题都可能是隐藏的坑。这些问题的本质已经不是在写业务代码,而是在写一个小编译器。
5.3 适用边界自测清单
我在评估一个需求是否适合解释器模式时,会做一个快速自测,这里可以分享给你:
| 判断问题 | 回答“是” | 回答“否” |
|---|---|---|
| 语法规模是否有 3 类以上终结符 + 3 类以上非终结符? | 值得考虑 | 用 if else 更划算 |
| 规则是否需要长期变化? | 值得投资 | 一次性需求别折腾 |
| 团队是否有人能维护递归树? | 可行 | 慎入 |
| 高并发路径是否会反复解释执行? | 需要缓存设计 | 可接受直接解释 |
| 外部输入是否需要安全检查? | 需要额外做校验 | 内部可信场景可简化 |
按照这套标准,我最后会给一个非常直接的裁决意见:如果语法规模很小,别用解释器模式;如果语法规模很大、规则复杂到几十上百种,也别急着手写解释器,先去看看成熟规则引擎能不能覆盖需求;只有在“语法中等规模、规则需要频繁变化、团队能维护”的交叉点上,解释器模式才是最优解。
6. 实际项目中的常见问题与排查技巧
6.1 典型问题速查表
我用表格把常见问题集中列出来,方便当你遇到类似问题时直接定位:
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
10 - 3 - 2算出 9 | 解析时把减法处理成了右结合 | 打印 AST,观察减法节点左右顺序 | 改用左结合解析,循环处理同级运算符 |
| 变量值取错或串了 | Context 被多个线程共享且发生了修改 | 在解释执行前打印 Context 快照 | 使用不可变 Context,或每次解释时传入副本 |
| 输入较长时栈溢出 | 递归嵌套太深 | 限制输入长度或限制最大深度 | 解析前做长度和深度白名单校验 |
| 规则解析慢 | 每次请求都重新 parse | 用压测观察 parse 耗时占比 | 缓存 AST,版本变更时再失效重建 |
| 除零或未定义变量异常 | 解释器抛异常但业务未兜底 | 看调用链中异常来源 | 在操作符节点增加默认值,或统一异常体系 |
6.2 打印 AST 一眼定位灵异现象
解释器模式的调试难点在于,结果错误时你很难判断是解析阶段的问题还是解释阶段的问题。我强烈建议每个 Expression 实现类都覆盖toString,输出一个带括号的规范表达式。比如 AddExpression 的toString返回(" + left + " + " + right + ")",NumberExpression 返回数字字符串。调试时只要输出根节点的toString,就能看到这棵树的完整结构。几乎所有“运算结果不对”的问题,都能在这一步直接暴露出来:要么优先级错了,要么括号丢了,要么左右子树挂反了。
我看过不少同伴排查解释器相关 bug 时,盯着interpret方法看了很久也没头绪,后来打印了一下 AST 才发现是factor方法里少处理了一种 Token。所以调试习惯比调试技巧更重要,先把树拿出来看,别急着追数值。
6.3 用测试锁住语法,也是我的个人习惯
解释器模式的类虽然少,但每个节点都是独立单元,非常适合写参数化测试。我会给每种表达式写一组用例,包括正常场景、边界场景、异常场景,比如除零、未定义变量、空表达式、超长嵌套。测试的好处有两个:一个是防止后续新增操作符时把已有逻辑改坏,另一个是逼迫自己把“预期 AST 结构”写清楚。写测试的过程中,你会重新审视一遍语法定义,很多设计上的疏漏在这个时候就能被发现。
我个人的体会是,解释器模式属于“看着很美,用起来要克制”的模式。项目里有三五个表达式节点时,它简单清爽,代码读起来非常舒服。一旦节点超过二十个,复杂度会迅速上升,维护成本也会直线增加。真正大规模的业务规则系统,通常已经不需要手写解释器了,而会走向规则引擎统一管理。不过理解解释器模式依然很有价值,因为你能看穿文本规则背后的本质:它从来都不是字符串故事,而是一棵对象树。能把树建好、遍历好,规则就尽在掌握了。