解释器模式实战:从硬编码if-else到可配置规则引擎

发布时间:2026/10/18 20:59:21
解释器模式实战:从硬编码if-else到可配置规则引擎 我干了一件自以为很聪明的事把一套几十行的嵌套 if-else 逻辑改成了「可以配置的规则字符串」然后花了一整个下午写解析器。当时觉得优雅极了直到运营同学拿着一组带括号的表达式走过来我才意识到——这不就是教科书里那个解释器模式能解决的问题吗。如果你也是一个整天被「改需求就是在改代码」折磨的后端开发或者你需要在项目里支持自定义规则、动态配置某个业务逻辑甚至正在设计一套轻量级 DS L那这篇内容应该能帮上忙。我会从一个条件过滤表达式的真实场景出发把解释器模式的核心结构、完整代码、以及我在实战中踩过的坑都掰开揉碎讲一遍。需要先说清楚的是解释器模式并不适合所有场景它的价值集中在「语法规则相对固定、但组合方式千变万化」这类问题上。你用得好它能省掉无数硬编码的分支判断用不好它就是一台邪恶的类膨胀机器。怎么判断、怎么写、怎么避坑下面逐个展开。1. 这个模式到底在解决什么别再让业务逻辑长在代码分支里1.1 一个每天都在发生的尴尬场景假设你在做一个后台运营系统管理员要配置人群筛选条件。需求第一版很简单年龄大于 18 且等级不低于 3。大多数人的第一反应是简单写个方法判断一下不就行了。public boolean match(User user) { return user.getAge() 18 user.getLevel() 3; }第二周需求变了要支持「或者」逻辑有人满足「注册时间早于某天」也进人群。于是你在方法里加了一堆||。第三周运营说要在后台自己配条件不想每次找你改代码。第四周配置里有括号优先级搞乱了。第五周那个方法已经长成了这个样子public boolean match(User user, String rule) { if (rule.equals(ruleType1_AND)) { return user.getAge() 18 ... } else if (rule.equals(ruleType2_OR)) { return user.getAge() 30 || ... } // ... 后面还有七八个 if }这套代码的问题不是某个 if 写错了而是规则的多样性已经超过了硬编码能承受的上限。每来一个新规则组合你就得改一次代码、重新发一次版。这在业务上叫「需求回溯」在工程上叫「把变化写死在了代码里」。1.2 解释器模式的破局思路让表达式自己解释自己解释器模式的核心思想可以概括成一句话定义一种文法然后用对象去表达文法中的每一个规则最后让这些对象通过组合来支撑任意复杂的句子。有点抽象。用刚才的例子翻译一下「年龄大于 18」是一条规则我们用一个「大于比较表达式」对象来表示。「等级不低于 3」是另一条规则我们也用一个「大于等于比较表达式」对象来表示。「并且」是一个逻辑连接用一个And表达式对象来表示。And这个对象里装了两个子表达式它自己又作为一个表达式存在。最后不管你配置的是age 18 AND level 3还是(age 18 OR regTime 2024-01-01) AND level ! 0解析器构建出来的都是这样一棵由各种表达式对象组成的树。计算匹配结果时从根节点出发递归地把每个子树算出来就行了。这就是它和「硬编码分支」最本质的区别分支逻辑一旦写死每加一种规则都是修改代码而解释器模式下新增一种规则通常等于新增一个表达式类已有的组合方式不变新规则可以随意嵌进任何位置。1.3 你什么时候真的需要它三条判断依据解释器模式不是万金油引用我的经验满足以下条件时它才值得引入有一类问题语言足够小且规则相对固定。比如条件表达式、布尔运算、数学公式、正则表达式其实正则引擎内部就是一个解释器、模板引擎语法。规则本身不会频繁大改但由这些规则组成的句子千变万化。执行频率可接受。解释器模式天然有递归调用和对象创建开销。如果你的规则每秒被调用百万次优先考虑预编译和缓存策略第 5 节专门讲。你确实需要「动态」。管理员在后台配置规则字符串系统实时解析执行这种场景下硬编码已经注定失败只有解释器类方案可行。反之如果业务规则就三五条一两年都不变那老老实实写 if-else 反而是最可靠的方案。不要为了设计而设计模式是工具不是勋章。2. 核心骨架拆解抽象表达式、终结符与非终结符的分工2.1 四个角色一张图可以看穿解释器模式里只有四个关键参与者理解了它们的职责代码就顺理成章角色职责典型对应抽象表达式AbstractExpression定义统一的解释接口通常就是interpret(Context)Expression接口终结符表达式TerminalExpression文法中的叶子节点没有子表达式直接计算结果比较表达式age 18非终结符表达式NonterminalExpression组合其他表达式自己也是一个表达式AND、OR、括号内的组合上下文Context存放解释过程中需要的外部数据比如用户的属性集合MapString, Object这里最容易被忽略的是 Context 的角色。它不一定是一个单纯的数据容器它还可以承担「解释过程中的共享状态」职责。比如你要在表达式里支持函数调用Context 里就得放函数表要支持变量Context 里就得有变量绑定。2.2 抽象表达式与两类节点的接口设计我们用 Java 来写。首先定义统一接口public interface Expression { boolean interpret(MapString, Object context); }终结符表达式它负责「读到字段值跟目标值做个比较」这一步。public class ComparisonExpression implements Expression { private final Operator operator; private final String field; private final Object expectedValue; public ComparisonExpression(Operator operator, String field, Object expectedValue) { this.operator operator; this.field field; this.expectedValue expectedValue; } Override public boolean interpret(MapString, Object context) { Object actualValue context.get(field); if (actualValue null) { return false; } return operator.apply(actualValue, expectedValue); } }非终结符表达式它不直接判断大小它只负责把左右两个子表达式的结果「合并」起来。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(MapString, Object context) { return left.interpret(context) right.interpret(context); } }看到这里你应该发现了——AndExpression自己就是一个表达式它又包含两个表达式。这种「自己包含自己」的结构就是递归也是构建语法树的地基。每个非终结符表达式的 interpret 方法本质上是把计算分发给子表达式然后合并结果。2.3 为什么一定要区分终结符与非终结符很多初学者觉得这个区分很啰嗦。不都是个类吗直接统一成一个不行吗不行。因为终结符是语法树的「叶子」叶子没有孩子它的计算是原子性的非终结符是「树枝」它必须有孩子它存在的意义就是组织孩子之间的关系。如果强行合并最终你会给类加一个children数组再在 interpret 里判断 children 为空就走叶子逻辑、不为空就走合并逻辑。这种「一个类同时承担两种职责」的做法短期看着省事一旦表达式嵌套层级变深这个类的分支逻辑会迅速爆炸而且新加一种连接符意味着要在同一个类里加新方法开放封闭原则直接被打破。所以虽然案例代码里ComparisonExpression是唯一的终结符它的实现不应该被「优化」进AndExpression里。叶子就是叶子树枝就是树枝这个边界不能破。3. 完整案例实操做一个支持 AND / OR / 括号的规则解析器3.1 需求定义与文法设计要做的东西输入一段规则文本比如age 18 AND (level 3 OR vip true)同时传入一个用户数据 Map系统解析规则返回这个用户是否匹配。注意规则的优先级必须符合常识——括号优先级最高其次是比较运算再次是AND最后是OR。为了不引入额外依赖我手写一个简易的词法解析器和递归下降解析器。首先定义文法为便于理解用伪 EBNF 描述expression : orExpr orExpr : andExpr ( OR andExpr )* andExpr : primary ( AND primary )* primary : comparison | ( expression ) comparison : field op value op : | | | | | !这套文法的巧妙之处在于每一层规则都只负责自己那一级的优先级优先级靠「递归层级」天然实现。orExpr里调andExprandExpr里调primaryprimary再调回expression形成了一个完整的递归下降链。3.2 上下文工具字段值的归一化读取在真正写解析器之前先解决一个小问题比较操作里的值类型。规则里写的可能是数值18、字符串active、布尔true而用户数据里存的往往也是各种类型。为了比较的时候不出错我在 Context 里做一个统一的取值方法public class RuleContext { private final MapString, Object data; public RuleContext(MapString, Object data) { this.data data; } public Object get(String field) { return data.get(field); } }实际项目里这里可以做得更复杂比如支持嵌套字段user.address.city、支持类型自动转换字符串里的18转成数字但核心思路一致Context 屏蔽字段读取细节解析器和表达式只关心业务判断。3.3 词法分析与递归下降解析器先写一个基础的对 token 进行拆解的类逻辑相对直观从左到右扫描字符串把空格跳过遇到标识符、数字、运算符和括号分别识别成不同的 token 类型。之后解析器根据 token 类型做分支建树。public class RuleParser { private final ListToken tokens; private int pos; public RuleParser(ListToken tokens) { this.tokens tokens; } public Expression parse() { Expression expr parseOr(); if (pos ! tokens.size()) { throw new IllegalArgumentException(存在无法解析的token: tokens.get(pos)); } return expr; } 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 parsePrimary(); while (match(TokenType.AND)) { Expression right parsePrimary(); left new AndExpression(left, right); } return left; } private Expression parsePrimary() { if (match(TokenType.LPAREN)) { Expression inner parseOr(); expect(TokenType.RPAREN); return inner; } return parseComparison(); } private Expression parseComparison() { Token fieldToken expect(TokenType.IDENTIFIER); Token opToken expect(TokenType.OPERATOR); Token valueToken expect(TokenType.VALUE); return new ComparisonExpression( Operator.fromSymbol(opToken.getText()), fieldToken.getText(), valueToken.getRawValue() ); } }核心逻辑其实就集中在parseOr和parseAnd这两个方法里。parseAnd会不断吞掉AND并构建左结合的AndExpressionparseOr同理。括号在parsePrimary里被处理成递归调用parseOr于是任何层级的嵌套都能解析。左结合的问题值得多说一句。像a AND b AND c我们按从左到右的顺序构建(a AND b) AND c。对于布尔表达式结合律是成立的结果一样但如果你扩展这个解析器去支持减法和除法左结合的语义就必须严格保留因为10 - 5 - 2和10 - (5 - 2)结果完全不同。写解析器时凡是涉及优先级和结合性的地方都得格外谨慎。3.4 客户端调用与结果验证解析器写好后调用方的代码就简单了构建 AST 并递归解释public class RuleClient { public static void main(String[] args) { String ruleText age 18 AND (level 3 OR vip true); Tokenizer tokenizer new Tokenizer(ruleText); RuleParser parser new RuleParser(tokenizer.tokenize()); Expression expr parser.parse(); MapString, Object user1 new HashMap(); user1.put(age, 20); user1.put(level, 2); user1.put(vip, true); System.out.println(用户1匹配结果: expr.interpret(user1)); MapString, Object user2 new HashMap(); user2.put(age, 16); user2.put(level, 5); user2.put(vip, false); System.out.println(用户2匹配结果: expr.interpret(user2)); } }运行结果用户1匹配结果: true 用户2匹配结果: false用户1 虽然等级只有 2但 vip 为 true满足括号里的 OR用户2 虽然等级 5但年龄不满足第一个比较整体为 false。规则被正确解释。关于 Tokenizer我只做一个非常简化的实现重点在解释器模式本身。如果你的规则文本格式复杂建议直接依赖成熟的词法工具或者直接采用第三方表达式引擎。手写词法的适合场景是「够用就行」别为了一两个符号引入整套依赖。4. 实战经验与踩坑记录这套设计在真实项目里的脾气4.1 递归深度与栈溢出的隐患解释器模式的核心是递归。递归就有深度限制。如果你允许用户配置一个特别深的括号嵌套比如(((((((...))))) ))那么 parse 阶段和 interpret 阶段都可能出现StackOverflowError。我在最早的那个后台系统里遇到的第一个线上事故就是这个。运营同学也不知道哪来的耐心配了一个 30 多层嵌套的规则结果 JVM 直接抛出栈溢出导致一次接口调用失败。解决办法分两层解析时限制递归深度。在parsePrimary进入递归前检查当前嵌套深度超过比如 20 层直接抛业务异常规则表达式嵌套过深请简化。解释时改用显式栈。如果确实有很深嵌套的需求不太常见可以把递归解释改成手动维护栈的循环解释方式但这会显著增加代码复杂度普通业务完全没必要。给个实用建议在项目经理层面跟运营同学约定好「规则可编程程度」的上限别让规则编辑器变成一门图灵完备语言的入口不然后面要擦的屁股远不止栈溢出。4.2 文法膨胀速度远超预期4 个类变成 40 个类这是解释器模式最被人诟病的问题。最开始只有AND、OR、比较表达式一共 4 个类。后来要支持NOT加 1 个。再后来要支持IN、LIKE、BETWEEN每个都往终结符表达式上加。再后来想支持字段之间的比较又得额外处理。一年之后表达式相关类超过了 30 个每个类的注释都写着「不要动动了别的也会挂」。我来聊聊怎么抢救。首先纯面向对象的解释器模式允许每个操作符一个类但实际工程里完全可以灵活调整同一类操作的多个运算符放一个类里用枚举或策略入口分别处理。我上面写的ComparisonExpression内部就承载了、、等一组运算符把运算符作为枚举传入。这不是模式上的背叛而是对「变化点」的准确判断——运算符的种类可能扩展但「比较」这个语法规则是稳定的。其次如果语法规模真的持续扩大就该考虑「从手写解释器迁移到通用方案」。比如方案优点缺点手写解释器本模式无第三方依赖、完全可控、类层级清晰文法复杂时类数量多、性能需要自己优化Aviator / SpEL功能完备、性能优化、复杂表达式开箱即用引入依赖、语法不可控、需要对引擎本身有一定认知数据驱动规则存目录、存 JSON 结构最简单、交互友好表达能力有限只适合结构化数据我的建议是如果你的 DSL 规则数控制在 10 个操作符以内且运行时可预编译手写解释器是非常体面的方案一旦超过这个规模立刻评估成熟引擎别等类爆炸之后再重构。4.3 与策略模式、访问者模式的边界为什么老是搞混解释器模式、策略模式、访问者模式这三者在行为型设计模式里算是最容易混淆的一组。策略模式解决的是「算法族切换」同一个接口多种实现运行时挑选一个。典型例子是排序算法的切换。它和解释器模式最大的区别策略模式不处理组合一个策略对象自身就是完整的算法。访问者模式解决的是「在不改变类结构的前提下增加操作」它把操作从数据结构中抽离出来。你要是想给 AST 增加一个「打印、优化、统计节点数」这类操作访问者模式会特别顺手。解释器模式把「解释」这个操作直接挂在每个表达式类里访问者模式则是把操作放到外部 Visitor 里。解释器模式真正的核心是「文法 组合 递归」。它和组合模式的亲缘关系反而更近一些一棵树、叶子与树枝统一接口从根节点递归调用。什么时候能从解释器模式切换到访问者模式典型信号是你的 AST 已经建好了但你要对它做多种不同的事情——画语法树、做静态检查、做分析统计。这时候逐个改 Expression 类加方法会破坏「开闭原则」写一个 Visitor 反而清清爽爽。不过大部分业务项目根本走不到这一步等遇到了再引入完全来得及。5. 性能、预编译与工程化建议从能用走向好用5.1 预编译把解析和解释彻底分开这是解释器模式工程化最关键的优化手段没有之一。回顾前文的调用链路规则字符串 - Token列表 - AST - 解释执行其中「规则字符串 - AST」这个阶段是纯 CPU 计算且只依赖规则文本本身和用户数据无关。如果一个规则会被成千上万个用户分别匹配让每个用户都从头解析一遍字符串就是严重的性能浪费。正确做法public class RuleEngine { private final ConcurrentHashMapString, Expression cache new ConcurrentHashMap(); public boolean match(String ruleText, MapString, Object context) { Expression expr cache.computeIfAbsent(ruleText, this::compile); return expr.interpret(context); } private Expression compile(String ruleText) { Tokenizer tokenizer new Tokenizer(ruleText); RuleParser parser new RuleParser(tokenizer.tokenize()); return parser.parse(); } }这样同一条规则只需要解析一次。我原来那个后台系统每日请求量几百万优化前高峰期接口 P99 在 800ms 以上加了编译缓存之后直接降到 120ms 以内。当然缓存也会带来内存占用但这属于常规的空间换时间策略。要注意缓存 key 必须严格等于规则字符串不能做任何字符串修剪否则会有语义漂移的隐患。5.2 解释阶段的性能细节别忽略短路求值AndExpression和OrExpression的实现里用的是和||它们自带短路特性——左边结果已经能决定整体时右边的子表达式不会执行。这既是一种性能优化也是一种语义保障。比如表达式age ! 0 AND 100 / age 2如果age为 0短路求值能避免除零异常。你可能会说这不是 Java 语言特性吗跟我设计模式有什么关系。关系在于如果你在AndExpression里写的是left.interpret() right.interpret()单短路就没了两边总会执行不仅多了性能损耗还可能让被隐藏的错误暴露出来。写解释器时多个子表达式的求值顺序和是否短路必须当作文法语义的一部分来设计和测试而不是留给运气。5.3 什么时候该放弃手写解释器引入成熟工具写到这里你可能觉得手写一个解释器还挺有意思。但我要泼一盆冷水解释器模式是 GoF 设计模式里最容易被「过度使用」的模式之一很多场景根本不需要自己造轮子。碰到下面这些情况我强烈建议你直接上成熟方案表达式语法复杂你已经需要支持函数调用、数组、字符串拼接、正则、三元运算甚至 lambda手写解析器工作量巨大。性能要求极高且输入不可控用户自定义了复杂的数学公式每次调用都要解析执行这时候成熟引擎有字节码编译、表达式缓存、手工优化的解析器秒杀手写版本。团队维护成本是关键手写解析器的代码再优雅也是团队里少数几个人才敢动的「黑盒子」。成熟的表达式引擎有文档、有社区、有 issue 答案库新人上手成本反而是低的。反过来说当你的语法足够稳定、规则数量有限、而且你需要细粒度的控制比如安全校验、字段权限、字段的动态映射手写解释器的价值就体现出来了。我自己在实战里的取舍是业务 DSL 尽量手写可控、轻量、好做权限通用计算类表达式优先依赖成熟方案不因个人喜好给团队留坑。5.4 安全性与数据边界解释器模式的另一片暗礁还有一件容易被忽略的事既然系统接受外部输入的规则文本就必须考虑恶意输入或误输入。超长文本一条几万字符的规则防御分词阶段就会消耗大量内存。建议在 parse 之前对规则文本长度做限制比如最长不超过 2000 字符。未知字段ComparisonExpression里我写了if (actualValue null) return false;这是默认策略。但真实业务里字段名可能打错这个静默失败会让运营同学排查到崩溃。更好的做法是Context 记录一次「本次解释中所有访问过的字段」若某个字段在数据源中不存在规则解析阶段就可以给一个可读的警告。类型隐式转换规则文本里全是字符串而数据可能是数字、布尔值。比较时18和18是否相等true和布尔真能否比较这需要一套显式的类型转换规范和测试用例。不要把转换逻辑散落在各个表达式类里最好集中到 Context 或专门的类型工具里统一处理。写解释器模式的工程实践真正决定它好坏的不是那四个角色本身而是你如何设定文法边界、如何做缓存与并发、如何防御非法输入。设计模式给你的是一个精密的骨架血肉全靠业务洞察去长。希望这篇分享能让你少走几步弯路尤其别再像我第一次实战那样满怀信心地以为解析器写完就万事大吉最后被坑得深夜改代码。