资讯详情

解释器模式详解:让程序读规则、执行规则,灵活应对业务变化

📅 2026/10/18 4:08:41 | 华诺云谱 👁 阅读
解释器模式详解:让程序读规则、执行规则,灵活应对业务变化
这是设计模式系列的第 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右边是 MultiplyExpressionMultiplyExpression 内部再挂 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在标准解释器模式里的作用是存放解释过程中需要读取或修改的信息。最简单的实现就是一个 HashMappublic class Context { private final MapString, 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 ListToken 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 77 * 2 1410 - 14 -4。代码结构和这个手算过程一一对应这就是解释器模式在可读性上的优势。如果要新增一个一元函数比如取绝对值ABS(-5)只需要新增一个AbsExpression在parseFactor里加一个关键字判断其他所有类原封不动。这种“加一类、改一处、其余不动”的扩展方式正是解释器模式让我觉得舒服的地方。你不需要像改 if else 那样小心翼翼地去梳理各个分支的影响关系因为 AST 的每个节点边界非常清楚。4. 从计算器到业务规则一个促销规则引擎的落地样本4.1 回到那个满减需求计算器案例只是热身真实项目中解释器模式最常见的落地场景是规则计算。回到开头那个满减需求“满 200 减 30满 500 减 100与折扣券互斥”。如果用解释器模式建模可以设计一个ThresholdDiscountExpressionpublic 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 结构”写清楚。写测试的过程中你会重新审视一遍语法定义很多设计上的疏漏在这个时候就能被发现。我个人的体会是解释器模式属于“看着很美用起来要克制”的模式。项目里有三五个表达式节点时它简单清爽代码读起来非常舒服。一旦节点超过二十个复杂度会迅速上升维护成本也会直线增加。真正大规模的业务规则系统通常已经不需要手写解释器了而会走向规则引擎统一管理。不过理解解释器模式依然很有价值因为你能看穿文本规则背后的本质它从来都不是字符串故事而是一棵对象树。能把树建好、遍历好规则就尽在掌握了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑