资讯动态

解析器模式实战:从规则字符串到语法树的优雅实现

发布时间:2026/10/17 7:04:37 来源:尧图企业网站定制
前阵子接了个规则配置平台的需求底层的过滤条件要支持运营在后台直接配置类似“活跃天数大于30 AND (积分等级高于3 OR 白名单用户)”这种表达式。第一版我用 if-else 写硬逻辑产品改了三轮规则之后代码已经没法看后来重构时把规则字符串解析成语法树才真正吃透了设计模式里的解析器模式Interpreter Pattern。解析器模式的本质是把一个自定义语言的文法规则映射为一组对象再用组合结构表达“一条规则的组成部分”最后由每个对象各自完成解释动作。它特别适合体量不大、语法相对稳定、但是业务规则经常变化的场景。这篇就结合实际代码聊聊这个模式的落地方式包括角色拆解、AST构建、递归下降解析以及几个我跳过的坑。适合已经写过业务代码、想系统提升设计能力的同学也适合准备把“配置化”落到实处的后端开发。1. 解析器模式的本质把“语法规则”变成“对象结构”1.1 从一段规则字符串说起假设运营后台配置了这样一条规则活跃天数 30 AND (积分等级 3 OR 白名单用户)计算机拿到这条字符串不能直接执行。它需要做两层处理第一层是词法分析把字符串切分成有意义的单词和符号第二层是语法分析按照预定义的文法生成抽象语法树AST。解析器模式的核心工作就是围绕这棵语法树的构建和解释展开。这里说的 AST你可以把它理解成一颗“表达式的树”。根节点是整条规则内部节点是运算符比如 AND、OR、大于叶子节点是变量、常量比如“活跃天数”“30”。判断一条记录是否符合规则时就从根节点开始递归往下求值左子树算一遍右子树算一遍再根据当前节点的类型做一次合并。整个过程很自然因为树的递归结构和表达式的递归定义天然对应。1.2 四个核心角色AbstractExpression、Terminal、NonTerminal、Context经典设计模式书里把解析器模式拆成四个角色我建议不要死记角色名直接对应到代码里会更清楚。角色职责在我的规则引擎里对应的东西AbstractExpression抽象表达式定义统一的解释接口Expression 接口TerminalExpression终结符表达式表达叶子节点比如变量、常量VariableExpression、NumberNodeNonTerminalExpression非终结符表达式表达组合节点比如 AND、OR、大于AndExpression、OrExpression、CompareExpressionContext上下文保存解释过程中的全局信息MapString, Object 变量绑定表在实现时我会尽量不硬套 Context 类。如果只是保存变量绑定一个 Map 就够了。只有当解释器需要注册函数、保存类型信息、或者表达式之间需要共享中间结果时才值得单独抽一个 Context 类。设计模式是思想不是教条怎么简单怎么来。还有一个很容易被忽略的点新增一种语法规则往往只需要新增一个表达式类以及对应的一小段解析逻辑。这就是开闭原则在这类场景里的体现——对扩展开放对修改关闭。老代码不用动新规则可以加。这也是解析器模式最大的价值。1.3 和正则、硬编码相比到底赢在哪里很多人看到这种需求的第一反应是“用正则”。正则确实是好东西但它擅长的是线性文本匹配不是递归结构。比如(A AND (B OR C)) AND D这种多层嵌套正则写起来会非常痛苦因为它本质上没有“递归展开”的能力。更别提规则里还要做类型转换、字段映射、运行时变量绑定这些事。硬编码 if-else 也不是不行规则少的时候最快。但规则一多每个条件分支都要写一遍排列组合会爆炸。产品说“加一个积分等级判断”你就要钻进业务类里改一坨 if 的长链测完上线过两天又改。那时的维护成本已经不亚于写一个简单解析器了。方案表达能力维护成本适合场景正则只能处理线性文本递归嵌套吃力中低简单格式校验、文本提取硬编码 if-else强但组合规则越多越难维护高规则数量少且几乎不变解析器模式强天然支持嵌套和组合中语法稳定、规则经常变化的 DSL我自己的判断标准是如果规则数量超过 10 条、且未来大概率会持续增加或者规则语法要支持嵌套和优先级那就应该认真考虑解析器模式。2. 设计思路拆解先有文法再写代码2.1 语法树为什么天然是组合模式要理解解析器模式先要理解组合模式。组合模式的核心思想是把“叶子节点”和“组合节点”都看作是同一种东西客户端可以一致地对待它们。在我们的规则表达式里叶子节点是变量、常量组合节点是 AND、OR、NOT 这些运算符。不管当前节点是变量还是 AND 表达式对外都只需要提供一个一样的动作解释求值。变量的解释动作是“查上下文返回布尔值”AND 解释动作是“先算左子树再算右子树两个都真才返回真”。由于接口完全一致求值函数就能递归地完成整棵树的遍历。这是一个典型的递归结构一个 AND 节点内部可以包含另一个 AND 节点再里面还可以包含 OR 节点。组合模式恰恰擅长表达这种“整体-部分”的递归层级。所以解析器模式本质上是在组合模式提供的结构之上增加了一层“解释执行”的语义。2.2 先写文法BNF 定义与优先级设计很多人拿到需求直接写代码写一半发现括号不知道丢哪里去了运算符优先级处理得稀烂。我的建议是动手前先把文法写出来。我这里用的是扩充的巴科斯范式也就是常说的 BNF。不需要深入了解理论把它理解成“描述语言文法的公式”就行。对于布尔规则我可以这样定义rule : orExpr EOF; orExpr : andExpr ( OR andExpr )*; andExpr : notExpr ( AND notExpr )*; notExpr : NOT notExpr | primary; primary : ( orExpr ) | true | false | VARIABLE;这段文法的意思很简单一条完整的规则是一个 orExpr 然后结束or 表达式由一个 and 表达式后面跟零个或多个 OR 组成and 表达式由一个 not 表达式后面跟零个或多个 AND 组成not 表达式是 NOT 后面跟一个 not 表达式或者直接是一个 primaryprimary 可以是括号括起来的 or 表达式也可以是布尔常量还可以是一个变量名。优先级完全靠这个层层嵌套的引用关系实现NOT 比 AND 高AND 比 OR 高。这个设计后面会直接映射到递归下降解析器的几个函数上parseOr 调 parseAndparseAnd 调 parseNotparseNot 调 parsePrimary。先花十分钟把 BNF 写清楚后面写 Parser 只需要照着翻译。2.3 解析器模式不是“写个 parse 方法”就完了我遇到过不少同学把解析器模式理解成“写一个 parse 方法处理字符串”。这个理解是偏的。parse 或者 Parser 只是前半段——把字符串变成语法树真正的解释执行是让这棵语法树自己会“跑”。换句话说解析器模式包含两个重要阶段解析阶段把字符串变成 AST解释阶段对 AST 递归求值得到结果。很多讲设计模式的资料只盯着“抽象表达式”和“终结符表达式”讲忽略了最关键的 Parser。结果就是读者学会了四个角色但拿到一条真实规则字符串还是不知道从哪下手。所以我在实际落项目时会重点把 Tokenizer、Parser、Expression 三者一起设计出来。这样代码既有模式的味道又能真正跑起来。3. 核心实现规则引擎从零落地附代码3.1 抽象表达式与终端表达式先定义统一接口我用 Java 来演示但思路是语言无关的。先定义接口public interface Expression { boolean interpret(MapString, Boolean context); }这个接口是所有语法树节点的统一入口。这里有个设计上的关键点接口方法接收 context但不要求在表达式内部保存这个 Map。这样设计的好处是表达式对象本身是无状态的多个线程可以共享同一个 AST解释时只要分别传入各自的 context 就行。终端表达式先来一个变量表达式对应文法里的 VARIABLEpublic class VariableExpression implements Expression { private final String name; public VariableExpression(String name) { this.name name; } Override public boolean interpret(MapString, Boolean context) { Boolean value context.get(name); if (value null) { throw new IllegalStateException(未绑定变量: name); } return value; } }解读 DOM Element 的时候叶子节点要读自己的值这里就直接从 context 里查。查不到就抛异常比静默返回 false 更容易在排障时发现配置错误。3.2 非终端表达式用组合实现 AND、OR、NOT接着是三个组合节点。先说 ANDpublic 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, Boolean context) { return left.interpret(context) right.interpret(context); } }OR 和 NOT 也是一样的套路public class OrExpression implements Expression { private final Expression left; private final Expression right; public OrExpression(Expression left, Expression right) { this.left left; this.right right; } Override public boolean interpret(MapString, Boolean context) { return left.interpret(context) || right.interpret(context); } } public class NotExpression implements Expression { private final Expression inner; public NotExpression(Expression inner) { this.inner inner; } Override public boolean interpret(MapString, Boolean context) { return !inner.interpret(context); } }看到没每个类都极度简单只有一个字段、一个构造方法、一个 interpret 方法。这就是解析器模式的核心风格把复杂的语法规则拆解成一个个很小的类每个类只负责一种规则的语义解释。如果哪一天需要加一个 XOR 表达式就再新建一个类实现同样的接口把解释逻辑写进去。老类完全不用碰。3.3 Tokenizer从字符流到 Token 序列有了表达式类还差最关键的“把字符串变成树”的环节。第一步是词法分析我习惯先写一个 Tokenizer。public enum TokenType { VARIABLE, AND, OR, NOT, LPAREN, RPAREN, EOF } public class Token { private final TokenType type; private final String text; public Token(TokenType type, String text) { this.type type; this.text text; } public TokenType type() { return type; } public String text() { return text; } } public class BooleanTokenizer { public ListToken tokenize(String input) { ListToken tokens new ArrayList(); int i 0; while (i input.length()) { char c input.charAt(i); if (Character.isWhitespace(c)) { i; continue; } if (Character.isLetter(c)) { int start i; while (i input.length() Character.isLetter(input.charAt(i))) { i; } String word input.substring(start, i); switch (word.toUpperCase()) { case AND - tokens.add(new Token(TokenType.AND, word)); case OR - tokens.add(new Token(TokenType.OR, word)); case NOT - tokens.add(new Token(TokenType.NOT, word)); default - tokens.add(new Token(TokenType.VARIABLE, word)); } } else if (c () { tokens.add(new Token(TokenType.LPAREN, ()); i; } else if (c )) { tokens.add(new Token(TokenType.RPAREN, ))); i; } else { throw new IllegalArgumentException(无法识别的字符: c); } } tokens.add(new Token(TokenType.EOF, )); return tokens; } }Tokenizer 解决的是“单词边界”问题。字符串A AND (B)如果不切开AND和A到底是一个变量名还是两个变量切开之后Parser 的工作就轻松很多它不再关注空格只看 Token 类型。3.4 递归下降解析器按文法逐层构建 ASTTokenizer 切完 Token 之后就轮到 Parser 上场。这里用的是递归下降Recursive Descent解析核心原则是文法的每一个产生式对应一个解析函数。public class BooleanParser { private final ListToken tokens; private int pos; public BooleanParser(ListToken tokens) { this.tokens tokens; } public Expression parse() { Expression expr parseOr(); if (peek().type() ! TokenType.EOF) { throw new IllegalArgumentException(多余的内容: peek().text()); } return expr; } private Expression parseOr() { Expression left parseAnd(); while (peek().type() TokenType.OR) { next(); Expression right parseAnd(); left new OrExpression(left, right); } return left; } private Expression parseAnd() { Expression left parseNot(); while (peek().type() TokenType.AND) { next(); Expression right parseNot(); left new AndExpression(left, right); } return left; } private Expression parseNot() { if (peek().type() TokenType.NOT) { next(); return new NotExpression(parseNot()); } return parsePrimary(); } private Expression parsePrimary() { Token token next(); if (token.type() TokenType.VARIABLE) { return new VariableExpression(token.text()); } if (token.type() TokenType.LPAREN) { Expression expr parseOr(); if (next().type() ! TokenType.RPAREN) { throw new IllegalArgumentException(缺少右括号); } return expr; } throw new IllegalArgumentException(意外的 Token: token.text()); } private Token peek() { return tokens.get(pos); } private Token next() { return tokens.get(pos); } }这段代码就是照着 BNF 硬翻译的。parseOr 管 ORparseAnd 管 ANDparseNot 管 NOTparsePrimary 管变量和括号。优先级通过函数调用层级天然实现parseOr 调 parseAnd所以 AND 会比 OR 先被约束在一起。这里有一个很关键的工程点文法里我没有写直接左递归。递归下降解析器最怕遇到expr : expr OP expr这种左递归文法因为会无限递归下去。所以我在 BNF 里用*循环来表示同一层级的连续运算在代码里用 while 循环处理。这是初学者最容易踩的坑。3.5 一个可运行的最小闭环现在把所有东西串起来。假设要解析规则A AND (B OR NOT C)测试代码如下MapString, Boolean context new HashMap(); context.put(A, true); context.put(B, false); context.put(C, false); String rule A AND (B OR NOT C); ListToken tokens new BooleanTokenizer().tokenize(rule); Expression expression new BooleanParser(tokens).parse(); System.out.println(expression.interpret(context)); // 输出 true算一下NOT C 为 trueB OR true 为 trueA AND true 为 true所以结果是 true。为了在项目里排查问题方便我还会给表达式类重写一个 toString 或专门的 treeString 方法。比如 AndExpression 的展示就是(AND left right)NotExpression 是(NOT inner)。这样把规则解析成 AST 之后可以先打印出来看看结构对不对再做后续求值。注意打印 AST 是非常好用的调试习惯。字符串解析这东西肉眼看到树长什么样比猜来猜去高效得多。3.6 扩展一让规则支持比较运算真实项目里没那么多纯布尔变量常见的是score 60 AND level 3。扩展方式其实很简单。把接口改为更通用的形式public interface Expression { Object eval(MapString, Object context); }或者保持一个返回 Object 的语义让数值表达式返回数值让比较表达式返回 Boolean让逻辑表达式再组合 Boolean。比如 GreaterExpressionpublic class GreaterExpression implements Expression { private final Expression left; private final Expression right; public GreaterExpression(Expression left, Expression right) { this.left left; this.right right; } Override public Object eval(MapString, Object context) { double l ((Number) left.eval(context)).doubleValue(); double r ((Number) right.eval(context)).doubleValue(); return l r; } }再到 Tokenizer 里把、、、识别成对应的比较运算符在 Parser 里加一层处理比较运算的 parseComparison。AndExpression 稍微调整一下左右两边都当成 Object 求值再统一强转成 Boolean 做逻辑运算。这样一套完整的可配置规则引擎就立起来了。这种扩展方式恰恰是解析器模式最值得称道的地方对已有核心类改动极小全部能力都是靠新增表达式类和新增解析分支堆出来的。3.7 扩展二数学表达式与优先级处理再举一个经典例子数学表达式计算器。比如3 5 * 2如果按从左到右的朴素方式会得到(35)*216不对。必须让乘法先算于是需要处理运算符优先级。处理优先级有两个选择继续用递归下降或者用调度场算法Shunting Yard把中缀表达式转成后缀表达式再构建 AST。我平时更常用后者因为它绕开了递归下降里比较多见的左递归问题思路很直白。调度场算法的核心逻辑public ListToken toPostfix(ListToken infixTokens) { DequeToken ops new ArrayDeque(); ListToken output new ArrayList(); for (Token token : infixTokens) { if (token.type TokenType.NUM) { output.add(token); } else if (token.type TokenType.LPAREN) { ops.push(token); } else if (token.type TokenType.RPAREN) { while (!ops.isEmpty() ops.peek().type ! TokenType.LPAREN) { output.add(ops.pop()); } ops.pop(); } else if (isOperator(token.type)) { while (!ops.isEmpty() precedence(ops.peek().type) precedence(token.type)) { output.add(ops.pop()); } ops.push(token); } } while (!ops.isEmpty()) { output.add(ops.pop()); } return output; }顺序很清晰数字直接进入输出运算符先比较栈顶优先级栈顶优先级不低于当前运算符就先把栈顶弹出去左括号直接压栈右括号把栈顶弹出直到遇到左括号。拿到后缀表达式比如3 5 2 * 后再遍历一遍构建 ASTDequeExprNode stack new ArrayDeque(); for (Token token : postfix) { if (token.type TokenType.NUM) { stack.push(new NumberNode(Double.parseDouble(token.text()))); } else { ExprNode right stack.pop(); ExprNode left stack.pop(); stack.push(new BinaryNode(token.type, left, right)); } } ExprNode root stack.pop();最后每个节点都实现一个eval()方法递归求值即可。这个例子的意义在于说明解析器模式不止能做布尔规则凡是“给一段文本按文法求出一个值”的场景它都能派上用场。四则运算、自定义脚本语言、简单的查询条件底层都是同一套思路。4. 实操中的常见问题与性能调优4.1 并发环境下的 Context 陷阱我踩过的第一个坑是在表达式节点内部保存了中间结果。当时的 AndExpression 是这样的boolean leftResult false; boolean rightResult false;两个线程同时跑同一套规则结果互相覆盖数据全乱了。后来改成无状态设计所有中间值都放在调用栈里也就是在 interpret 方法内部用局部变量接收子节点返回值问题立刻消失。这给了一个明确结论表达式对象应当是一种“无状态策略”的形态。它只描述结构和行为至于某个变量的值是多少完全由调用时传入的 context 决定。如果非要让 context 在多个表达式之间共享也要确保 context 本身是线程封闭的。最好是一次请求一个 context请求结束就丢弃。4.2 性能优化缓存 AST 而不是重复解析有人担心解释执行比硬编码慢这个担心要拆开看。解析字符串、构建 AST 的耗时和解释求值的耗时是两回事。如果一条规则被频繁使用完全没有必要每次请求都重新 parse。把 AST 缓存起来只重复执行 interpret/eval 就好。实际操作中我会把规则字符串作为 key解析后的 Expression 对象作为 value建一个并发安全的缓存。规则不变AST 就不变。真正每时每刻发生的是对 AST 的递归求值。递归求值虽然比硬编码慢一些但对绝大多数业务场景来说根本不是瓶颈慢个几微秒到几十微秒用户完全感知不到。注意如果缓存了 AST一定要确保 AST 是无状态的。一旦你在某个表达式节点里保存了上一次的运行结果缓存就会变成 bug 制造器。4.3 错误处理词法、语法、语义三个层面的提示解析器模式的实践里错误处理特别影响可用性。如果用户配置了一条错误规则系统却只回一句“规则不合法”那运营同学根本没法排查。我会把错误分成三个层面错误阶段典型情况提示信息建议词法错误出现不认识的中文符号、表情、非法字符指明位置: “第 5 个字符无法识别”语法错误缺少右括号、两个运算符连在一起、表达式为空指明期望什么: “缺少右括号”语义错误引用未绑定的变量、类型不匹配、除零指明变量名和类型: “未绑定变量 score”为了做到这一点Token 里可以记录行号和列号Parser 抛异常时把当前位置带出去。错误信息越具体排查时间就越短。这算是我在实际项目里觉得性价比最高的一项投入。4.4 极端情况递归深度与表达式长度递归下降解析和递归求值都会消耗调用栈。正常情况下表达式层级几十层没问题但如果用户生成了一段非常长的规则字符串嵌套括号几千层递归深度就可能触顶。我自己项目里遇到过类似情况处理方式是加一层保护在解析前限制输入字符串长度比如不超过 2000 字符解析时限制递归深度超过 200 层直接抛错。对于业务配置型 DSL 来说这个限制完全合理规则复杂到“阈值都顶到天”的时候问题的根源多半不是性能而是规则本身已经失去可维护性。5. 适用边界与替代方案5.1 场景评估什么情况下值得用解析器模式不是所有需求都值得上解析器模式。我在项目里会做这么几个判断语法是否相对稳定如果语法三天两头变说明设计阶段还没收敛先不要急着做通用解析引擎规则数量是否会持续增长少于 10 条用 if-else 可能更划算是否希望业务人员动态配置规则如果是字符串化的规则是基本前提解析器模式几乎是必然选择团队是否愿意维护这套代码解析器模式不是零成本新增语法要新增表达式类和解析分支。如果这四条的答案大多是“是”那就可以放心做。如果答案大多是“否”那就别为了用模式而用模式。5.2 和组合模式、策略模式、访问者模式的分工经常有人把解析器模式和几个邻近模式搞混。组合模式解决的是“树形结构”的表达问题解析器模式在组合模式之上增加了“解释执行”的语义。你可以理解成组合模式提供了骨架解析器模式让骨架动起来。策略模式解决的是“算法族互换”的问题。比如规则引擎里解析策略可以根据需求替换但这不是解析器模式的核心关注点。访问者模式则经常和解析器模式配合使用。当 AST 的节点结构稳定但对树的操作越来越多时比如既要打印 AST又要做类型检查又要做求值访问者模式可以把这些操作从节点类里剥离出来。遇到复杂 DSL 时解析器模式 访问者模式是一对很常见的搭档。5.3 什么时候该放弃手写解析器手写解析器适合的是“小而精”的 DSL比如十几个运算符、几十行文法。一旦语法规模大到像 SQL 或脚本语言那样手写解析器的维护成本会急剧上升这时候我不建议自己硬写。判断信号其实很明确当你发现自己开始在里面写类型推断、作用域管理、函数注册表的时候就该考虑换更成熟的方案了。成熟的解析器生成工具能自动化处理词法、语法、语法树生成社区生态也完善。自己做语言设计的成本远比你想象中高。但反过来说像布尔规则、四则运算、简单查询条件这种体量手写完全可控。这也是为什么我仍然推荐大家认真掌握解析器模式——它能把“小 DSL”这件事做得优雅又经济。6. 写到最后一点个人体会和建议6.1 我的踩坑记录如果让我总结自己最值钱的一条教训那就是写解析器模式之前一定要先逼自己把 BNF 写出来。没有 BNF 就动手写 Parser大概率会在优先级、括号、左递归这些地方反复返工。我最早做数学表达式计算器时就是直接写代码结果优先级处理出错改来改去浪费了一晚上。后来老老实实先把文法写清楚代码反而一次性通过。另一个坑是“过度封装”。我见过有人给规则引擎套了层层抽象一个表达式接口下面七八个抽象类美其名曰“为了扩展性”。结果真正的语法还没支持几个代码已经没人愿意维护了。解析器模式的优雅在于简单不在于复杂。一个接口几个叶子节点几个组合节点一个 Parser足够了。6.2 给初学者的练习路径想彻底掌握解析器模式我建议按这个顺序练先写一个布尔表达式解析器支持 AND、OR、NOT、括号、变量再加比较运算比如大于、小于、等于再做数学表达式计算器体验优先级和调度场算法最后尝试做一个小的自定义 DSL比如告警规则、权限策略、工作流条件。每一步都跑通你就会发现所谓设计模式本质是前人把一类常见问题的解法沉淀成结构化的经验。解析器模式尤其如此——它的边界清晰、角色稳定、场景明确一旦掌握很多“配置化”“规则引擎”“DSL”类需求都可以轻松接住。别把它想得太高大上它就是一门“把文本变成树再把树变成结果”的手艺。这门手艺值得每个做后端和做业务系统的人花时间练一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑