☰
解释器模式实战:从硬编码if-else到可配置规则引擎
2026/10/18 20:59:05 网站建设 项目流程

我干了一件自以为很聪明的事,把一套几十行的嵌套 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 你什么时候真的需要它:三条判断依据

解释器模式不是万金油,引用我的经验,满足以下条件时它才值得引入:

  1. 有一类问题语言足够小且规则相对固定。比如条件表达式、布尔运算、数学公式、正则表达式(其实正则引擎内部就是一个解释器)、模板引擎语法。规则本身不会频繁大改,但由这些规则组成的句子千变万化。
  2. 执行频率可接受。解释器模式天然有递归调用和对象创建开销。如果你的规则每秒被调用百万次,优先考虑预编译和缓存策略(第 5 节专门讲)。
  3. 你确实需要「动态」。管理员在后台配置规则字符串,系统实时解析执行,这种场景下硬编码已经注定失败,只有解释器类方案可行。

反之,如果业务规则就三五条,一两年都不变,那老老实实写 if-else 反而是最可靠的方案。不要为了设计而设计,模式是工具,不是勋章。

2. 核心骨架拆解:抽象表达式、终结符与非终结符的分工

2.1 四个角色,一张图可以看穿

解释器模式里只有四个关键参与者,理解了它们的职责,代码就顺理成章:

角色职责典型对应
抽象表达式(AbstractExpression)定义统一的解释接口,通常就是interpret(Context)Expression接口
终结符表达式(TerminalExpression)文法中的叶子节点,没有子表达式,直接计算结果比较表达式age > 18
非终结符表达式(NonterminalExpression)组合其他表达式,自己也是一个表达式AND、OR、括号内的组合
上下文(Context)存放解释过程中需要的外部数据,比如用户的属性集合Map<String, Object>

这里最容易被忽略的是 Context 的角色。它不一定是一个单纯的数据容器,它还可以承担「解释过程中的共享状态」职责。比如你要在表达式里支持函数调用,Context 里就得放函数表;要支持变量,Context 里就得有变量绑定。

2.2 抽象表达式与两类节点的接口设计

我们用 Java 来写。首先定义统一接口:

public interface Expression { boolean interpret(Map<String, 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(Map<String, 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(Map<String, 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里调andExpr,andExpr里调primary,primary再调回expression,形成了一个完整的递归下降链。

3.2 上下文工具:字段值的归一化读取

在真正写解析器之前,先解决一个小问题:比较操作里的值类型。规则里写的可能是数值18、字符串"active"、布尔true,而用户数据里存的往往也是各种类型。为了比较的时候不出错,我在 Context 里做一个统一的取值方法:

public class RuleContext { private final Map<String, Object> data; public RuleContext(Map<String, 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 List<Token> tokens; private int pos; public RuleParser(List<Token> 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并构建左结合的AndExpression,parseOr同理。括号在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(); Map<String, Object> user1 = new HashMap<>(); user1.put("age", 20); user1.put("level", 2); user1.put("vip", true); System.out.println("用户1匹配结果: " + expr.interpret(user1)); Map<String, 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 ConcurrentHashMap<String, Expression> cache = new ConcurrentHashMap<>(); public boolean match(String ruleText, Map<String, 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 或专门的类型工具里统一处理。

写解释器模式的工程实践,真正决定它好坏的不是那四个角色本身,而是你如何设定文法边界、如何做缓存与并发、如何防御非法输入。设计模式给你的是一个精密的骨架,血肉全靠业务洞察去长。希望这篇分享能让你少走几步弯路,尤其别再像我第一次实战那样,满怀信心地以为解析器写完就万事大吉,最后被坑得深夜改代码。

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

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

立即咨询