设计模式修炼之路 ㉕ 解释器模式——让程序读懂规则
GoF 收官、规则即数据零发版、文法即代码、递归下降解析器、短路求值白送、解释 vs 编译 vs 配置表
目录
行为型第十二站(终章)。
㉔ 的文档能把结构导出成 Markdown 文本——那是"结构到文本"的单行道。这一篇开反向车道:给程序一段文本,比如促销规则 "vip && age >= 18",让它自己解析出结构、算出 true / false。
先看痛点。促销规则是全公司变得最勤的东西:周一"VIP 且成年可买",周三"新用户零单送券",周五运营半夜拍脑袋"60 岁以上或 VIP 打八折"。规则硬编码在 Java 里,意味着每改一条规则都要走完提需求 → 排期 → 开发 → 测试 → 发版的流水线——程序员在给运营当人肉规则编辑器。
把规则写成一行字符串,让程序自己读懂它、算出结果——自己实现一个小规则解释器——解释器模式,GoF 二十三式的最后一位,压轴登场。
一、解释器模式是什么
GoF 的原始定义:
Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language. (给定一门语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。)
拆开读,定义里藏着三件事:先发明一门小语言(本篇的规则语法:vip && age >= 18);再给这门语言一个文法表示(把字符串解析成语法树);最后让解释器拿着语法树对具体数据求值。所谓"解释",就是递归地走一遍这棵树。
五个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| AbstractExpression(抽象表达式) | 声明 interpret 操作 | Expression 接口 |
| TerminalExpression(终结符表达式) | 语法树的叶子:直接查环境、算出结果 | FlagExpression / ComparisonExpression |
| NonterminalExpression(非终结符表达式) | 语法树的枝:组合子表达式 | AndExpression / OrExpression / NotExpression |
| Context(环境) | 解释现场,装着所有变量的值 | Map\ |
| Client(客户端) | 构建(解析)语法树并启动解释 | RuleParser + InterpreterTest |
眼熟吗?终结符是叶子、非终结符是枝干、统一接口递归求值——解释器模式几乎总是组合模式的实例:Expression 树就是 ⑨ 里那棵 Component 树,只是把"display 挨个执行"换成了"interpret 求值"。⑨ 的回头客,这次是真的要动手建树了。
二、优点与缺点
优点:
- 规则即数据:加一条规则 = 传一个字符串,零代码、零编译、零发版——运营半夜上线,程序员的枕头是干的。
- 文法与代码一一对应:一条文法规则一个类(或一个解析函数),看 BNF 就知道代码长什么样,改文法就是改那一处。
- 扩展运算符零侵入:想支持
>、<之外再来个contains?加一个终结符类 + 文法里添一笔,现有表达式类全部不动——开闭原则。 - 语法错误前置:
"vip && & age"这种手滑,解析当场报"位于 7",而不是默默跑出一个诡异的业务结果。
缺点:
- 文法复杂则类爆炸:本篇
&&、||、!、比较、括号就 6 个表达式类;再上算术、函数调用、字符串操作……类文件能铺满一屏。GoF 原文自己就劝退:文法复杂时,别手写,用解析器生成器(ANTLR 一类)。 - 性能有税:每次求值都要递归走整棵树,每次解析都要从字符串建树。高频调用场景这笔账得算清楚(可缓存 AST 缓解,见第六节)。
- 等于开了个迷你编程接口:规则文本若是外部可传入的,
"a && a && a && ……(一万层)"就能把你递归栈干爆——解释器天生要做长度与深度防护。 - 团队门槛:出了 bug 得有人会看语法树、会读文法。全组只会 CRUD 的日子,一去不返。
三、什么时候用
判断标准:文法简单且稳定、规则文本变化频繁、求值频率不至于性能敏感。
- 促销 / 风控 / 权限规则引擎:规则天天变,语法十年不变——本文场景
- 日志/监控查询语言:Kibana 的 Lucene 查询串、Grafana 的告警条件表达式
- 模板与占位符:
"亲爱的${name},您的订单${orderId}已发货"——最朴素的单遍解释器 - 配置里的动态表达式:开关平台"满足条件的用户才灰度"那串表达式
反面对照:规则只是参数在变(满 100 减 20 变成满 200 减 50)——一张配置表就够,别上解释器,自由度过剩也是浪费;规则复杂到本身就是业务(信贷审批全流程)——那是业务代码,写 Java 去;每秒百万次求值——递归树撑不住,编译执行或者硬编码。
四、实现方式:迷你规则引擎
4.1 反面教材:规则硬编码
每条规则一个 case:
public class BadRuleEngine {
public static boolean check(User user, String ruleName) {
switch (ruleName) {
case "vip-adult" -> { return user.isVip() && user.getAge() >= 18; }
case "new-user" -> { return !user.isVip() && user.getOrderCount() == 0; }
default -> throw new IllegalArgumentException(
"规则 " + ruleName + " 不存在:请提需求、排期、开发、测试、发版");
}
}
}
规则库里两条规则,第三条"black-friday"上线时,default 分支就是程序员的周末。更要命的是:规则的本质是数据,却被写成了代码——数据和代码的区别,就是"改起来要不要发版"的区别。
4.2 正解:表达式树 + 递归下降解析器
目标效果:规则是字符串,规则是数据:
User zhangsan = new User(true, 35, 42, 3); // vip、35 岁、42 单、level 3
User tianqi = new User(false, 16, 0, 1); // 非 vip、16 岁、0 单、level 1
Map<String, Object> zhangsanCtx =
Map.of("vip", true, "age", 35, "orderCount", 42, "level", 3);
Expression rule = RuleParser.parse("(vip || level >= 3) && age >= 18");
rule.interpret(zhangsanCtx); // → true
第一步:定义小语言。 四条文法,用 BNF 写出来:
orExpr := andExpr ('||' andExpr)* // 或,优先级最低
andExpr := unary ('&&' unary)* // 与
unary := '!' unary | primary // 非
primary := '(' orExpr ')' | 标识符 运算符 数字 | 标识符 // 括号 / 比较 / 标志位
第二步:表达式族。 接口只有一行:
public interface Expression {
boolean interpret(Map<String, Object> context);
}
终结符两个。vip 这种标志位:
class FlagExpression implements Expression {
private final String name;
FlagExpression(String name) { this.name = name; }
@Override
public boolean interpret(Map<String, Object> context) {
Object value = context.get(name);
if (value instanceof Boolean b) {
return b;
}
throw new IllegalArgumentException("属性 " + name + " 不是布尔值");
}
@Override
public String toString() { return name; }
}
age >= 18 这种比较:
class ComparisonExpression implements Expression {
private final String name;
private final String op; // ">"、">="、"<"、"<="、"=="
private final int target;
// 构造器略
@Override
public boolean interpret(Map<String, Object> context) {
int actual = (Integer) context.get(name);
return switch (op) {
case ">" -> actual > target;
case ">=" -> actual >= target;
case "<" -> actual < target;
case "<=" -> actual <= target;
case "==" -> actual == target;
default -> throw new IllegalArgumentException("未知运算符 " + op);
};
}
@Override
public String toString() { return name + " " + op + " " + target; }
}
非终结符三个,全是一个模子:抱着两棵(或一棵)子树,求值时递归下问。以 AndExpression 为例:
class AndExpression implements Expression {
private final Expression left;
private final Expression right;
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);
}
@Override
public String toString() { return "(" + left + " && " + right + ")"; }
}
OrExpression 同款换成 ||;NotExpression 抱一棵子树返回 !inner.interpret(context)。至此,语法树的"词汇表"集齐——"(vip || level >= 3) && age >= 18" 解析出来是这么一棵树:
AndExpression
├── OrExpression
│ ├── FlagExpression("vip")
│ └── ComparisonExpression("level >= 3")
└── ComparisonExpression("age >= 18")
第三步:解析器,把文本变树。 RuleParser 是个迷你递归下降解析器,核心思路:每条文法规则,翻译成一个解析函数:
/**
* 迷你递归下降解析器:把规则文本变成语法树。
* 文法(优先级从低到高):
* orExpr := andExpr ('||' andExpr)*
* andExpr := unary ('&&' unary)*
* unary := '!' unary | primary
* primary := '(' orExpr ')' | 标识符 运算符 数字 | 标识符
* 每个文法规则对应一个解析函数,解析层次越深优先级越高。
*/
public class RuleParser {
private final String input;
private int pos;
public static Expression parse(String rule) { /* 见下 */ }
private Expression parseOr() {
Expression left = parseAnd();
while (match("||")) {
left = new OrExpression(left, parseAnd());
}
return left;
}
private Expression parseAnd() {
Expression left = parseUnary();
while (match("&&")) {
left = new AndExpression(left, parseUnary());
}
return left;
}
private Expression parseUnary() {
if (match("!")) {
return new NotExpression(parseUnary());
}
return parsePrimary();
}
private Expression parsePrimary() {
if (match("(")) {
Expression expression = parseOr();
expect(")");
return expression;
}
String name = readIdentifier();
skipWhitespace();
String op = tryReadOp(); // 读得到运算符就是比较式
if (op == null) {
return new FlagExpression(name); // 读不到就是标志位
}
skipWhitespace();
return new ComparisonExpression(name, op, readInt());
}
// match/expect/readIdentifier/readInt 等词法小工具略
}
parse("vip && age >= 18") 走一遍:parseOr 调 parseAnd,parseAnd 读到 vip(FlagExpression),match("&&") 命中,再读 age >= 18(ComparisonExpression),包成 AndExpression 还给 parseOr——树建好了。
第四步:收钱。 跑测试:
evaluate("vip && age >= 18", zhangsanCtx, tianqiCtx);
evaluate("!vip && orderCount == 0", zhangsanCtx, tianqiCtx);
evaluate("(vip || level >= 3) && age >= 18", zhangsanCtx, tianqiCtx);
// 运营半夜上线新规则:
evaluate("age >= 60 || vip", zhangsanCtx, tianqiCtx);
private static void evaluate(String rule, Map<String, Object> ctx1, Map<String, Object> ctx2) {
Expression expression = RuleParser.parse(rule);
System.out.println(" [规则] \"" + rule + "\"");
System.out.println(" 语法树:" + expression);
System.out.println(" 张三 → " + expression.interpret(ctx1)
+ ",田七 → " + expression.interpret(ctx2));
}
运行输出:
========== 反面教材:规则硬编码在代码里 ==========
[vip-adult] 张三 → true
[new-user] 田七 → true
[翻车] 规则 black-friday 不存在:请提需求、排期、开发、测试、发版
========== 正解:规则即文本,解释器读懂它 ==========
[规则] "vip && age >= 18"
语法树:(vip && age >= 18)
张三 → true,田七 → false
[规则] "!vip && orderCount == 0"
语法树:(!vip && orderCount == 0)
张三 → false,田七 → true
[规则] "(vip || level >= 3) && age >= 18"
语法树:((vip || level >= 3) && age >= 18)
张三 → true,田七 → false
—— 运营半夜上线新规则「age >= 60 || vip」:零代码、零发版,传个字符串就行 ——
[规则] "age >= 60 || vip"
语法树:(age >= 60 || vip)
张三 → true,田七 → false
[语法报错] 规则 "vip && & age" →
语法错误:期待属性名,位于 7
第四条规则上线时,程序员在睡觉。最后一行的语法报错也接住了手滑——错误在解析期暴露,带着坐标,而不是求值期给出一个莫名其妙的结果。
4.3 三处细节
细节一:优先级 = 解析函数的嵌套深度。 为什么 vip || level >= 3 && age >= 18 里 && 比 || 结合得紧?因为 parseOr(处理 ||)在最外层,它先调 parseAnd(处理 &&)——越深层的函数,抱得越紧。文法四行、函数四个、优先级三层,一张嵌套结构图就说明白,不需要任何"优先级表"这种额外数据结构。想让某个子表达式脱离默认优先级?primary 里的 ('(' orExpr ')') 规则兜住括号,直接回到最外层重新解析——括号的本质,就是人工指定解析层次。
细节二:短路求值是白送的。 AndExpression 的求值就一行 left.interpret(context) && right.interpret(context)——右边复用了 Java 自己的 &&。左边 false,右边那棵子树根本不会被碰。解释器模式的"递归求值"天然对齐宿主语言的求值语义:你想短路,就借用宿主的短路;你想严格求值,换成 & 即可。求值策略藏在那一行字眼里。
细节三:双字符运算符必须排在单字符前面。 tryReadOp 的候选顺序是 ">=", "<=", "==", ">", "<"。若把 ">" 放前面,"age >= 18" 会先吃掉 >,剩下 = 18 直接语法报错。这叫最长匹配(贪心匹配),是所有词法分析器的第一课——同一个位置可能有多种读法时,永远选最长的那个。一个字符顺序,决定引擎能不能跑。
五、在框架里找它
-
Spring Expression Language(SpEL):
@Value("#{user.age > 18}")、@PreAuthorize("hasRole('ADMIN') and #id == authentication.principal.id")——注解里那串字符串就是一门小语言,Spring 启动时解析成 AST,调用时对着上下文求值。本文的 ComparisonExpression、AndExpression 在 SpEL 里全有对应物,只是词法豪华了一百倍。 -
java.util.regex.Pattern:
Pattern.compile("\\d+")干的事就是本文的 RuleParser.parse——把文本编译成内部表示(NFA/DFA),之后matcher.matches()反复解释输入串。每个正则引擎都是一台解释器,而且演示了正确的性能姿势:compile 一次,匹配多次。 -
MyBatis 的动态 SQL 与 OGNL:
<if test="name != null and name != ''">——test 属性里那串表达式由 OGNL 解释执行,决定 SQL 片段拼不拼进来。全国最大的"配置里的规则解释器"部署现场。 -
Drools / ANTLR:文法复杂时的正规军。Drools 是成熟的规则引擎(
when … then …整门语言);ANTLR 是解析器生成器——文法文件进,Java 解析器代码出。GoF 定义里那句"文法复杂请绕行",指的就是把方向盘交给它们。
六、解释 vs 编译 vs 配置表——规则该放哪
同一个需求(规则要常变)的三种解法摊开对账:
| 方案 | 改一条规则的成本 | 能表达什么 | 性能 |
|---|---|---|---|
| 硬编码 | 发版全流程 | 任意 Java 逻辑 | 最好 |
| 配置表 | 改一行数据 | 只能填模板的空(满 X 减 Y) | 好 |
| 解释器 | 传一个字符串 | **文法内任意组合** | 每次解析 + 递归求值 |
看清差别了吗?配置表只能变参数,解释器能变逻辑结构。满 100 减 20 变满 200 减 50,配置表足矣;"VIP 且成年,或高等级且成年"这种结构变化,只有解释器接得住。自由度每升一级,实现成本也升一级——按需购买,不要囤货。
最后补一个生产必修课:本文 RuleParser.parse(rule) 每次调用都重新建树,等于每个请求都在重新编译。正确姿势是把 parse 结果(AST)缓存进 Map——规则字符串不变,树就不变,这其实就是最朴素的"编译":解释器把解析与求值拆成两步,中间那段就是留给缓存的缓冲带。Pattern.compile 的 API 设计(compile 与 matches 分离)早把这条路铺好了,本文的 parse 与 interpret 同款。再进一步,把 AST 直接翻译成字节码,就从解释器进化成了编译器——JIT、V8 走的都是这条路。解释和编译不是两个世界,是同一条路的两个站点。
写在最后
- 文法简单、规则常变、性能不敏感——解释器的三张准入证
- 一条文法规则一个类/一个函数,文法即代码,看 BNF 就能读出实现
- 终结符查环境,非终结符抱子树——AST 是一棵组合树(⑨ 的回头客)
- 短路求值白送:递归求值对齐宿主语言语义
- 双字符运算符排前面——贪心匹配,词法分析第一课
- 解析与求值分离,中间留给缓存——解释器到编译器只差一步
至此,GoF 二十三式全部集齐:创建五式(②③④⑤⑥)、结构七式(⑦—⑬)、行为十一式(⑭—㉕),二十三颗龙珠在手里了。可回头看看真实的企业应用:请求从浏览器涌进来,谁在门口分发?业务逻辑谁替前端挡脏活?数据落地谁牵线搭桥?——这些问题,单个模式接不住,得一群模式排兵布阵。从 ㉖ 起,本系列进入最后一片大陆:J2EE 企业应用模式——第一站,MVC。