解释器模式:让代码自己“读懂”规则
解释器模式,这个名字听起来有点学术。说白了,就是给程序定义一套“语言”,然后让程序能解析并执行这套语言写的“句子”。
我刚开始接触这个模式时,觉得它很鸡肋——谁会没事在代码里再塞一门语言进去?直到后来做规则引擎项目,才明白它的价值。你想想看,如果业务规则频繁变动,每次都要改代码、重新部署,那得多痛苦?
解释器模式的结构
解释器模式的核心思想,是把“语法规则”抽象成对象。每个语法规则对应一个类,然后组合这些类来解析表达式。
它的结构其实很清晰,就四个角色:
- 抽象表达式(AbstractExpression):声明一个解释操作,所有节点都要实现它。
- 终结符表达式(TerminalExpression):语法中的最小单元,比如数字、变量名。它没法再拆了。
- 非终结符表达式(NonterminalExpression):由多个子表达式组合而成,比如加法、乘法。
- 上下文(Context):存放全局信息,比如变量的值。
嗯,这里要注意:解释器模式本质上就是一棵“语法树”。每个节点要么是叶子(终结符),要么是树枝(非终结符)。
核心要点:解释器模式把“语法”变成了“对象结构”。你写一个表达式,它就被解析成一棵树,然后递归求值。
我曾经在一个配置中心项目里用过这个模式。当时需要支持用户自定义过滤条件,比如“age > 18 AND city == '北京'”。如果用if-else硬编码,那代码会膨胀到没法看。用解释器模式,每个条件就是一个表达式对象,组合起来非常灵活。
一个简单的算术表达式解释器
光说理论太干,我们直接看代码。下面是一个支持加减法的迷你解释器:
// 抽象表达式
interface Expression {
int interpret(Context ctx);
}
// 终结符:数字
class NumberExpression implements Expression {
private int number;
public NumberExpression(int number) { this.number = number; }
public int interpret(Context ctx) { return number; }
}
// 非终结符:加法
class AddExpression implements Expression {
private Expression left, right;
public AddExpression(Expression left, Expression right) {
this.left = left; this.right = right;
}
public int interpret(Context ctx) {
return left.interpret(ctx) + right.interpret(ctx);
}
}
// 上下文(这里简单起见,没用到)
class Context {
// 可以放变量映射表
}
// 使用示例
public class Demo {
public static void main(String[] args) {
// 构建表达式: 3 + 5
Expression three = new NumberExpression(3);
Expression five = new NumberExpression(5);
Expression add = new AddExpression(three, five);
Context ctx = new Context();
System.out.println(add.interpret(ctx)); // 输出 8
}
}
你看,每个节点只关心自己的事。加法节点只管把左右两个子节点的结果加起来,至于子节点是数字还是另一个加法,它不管。这就是组合模式的精髓。
个人习惯:我一般会把表达式解析(把字符串变成语法树)和表达式求值(遍历树计算)分开。这样代码更清晰,也方便做优化。
解释器模式在Spring SpEL中的应用
Spring的SpEL(Spring Expression Language)是解释器模式最经典的工业级应用。你平时写@Value("#{systemProperties['user.name']}"),背后就是SpEL在干活。
SpEL支持的东西很多:
- 字面量:数字、字符串、布尔值
- 变量引用:
#variableName - 方法调用:
'hello'.toUpperCase() - 集合操作:
list.?[#this > 3](过滤) - 运算符:算术、比较、逻辑
Spring是怎么实现的?核心就是一套表达式节点类。我翻过源码,大致结构是这样的:
// SpEL的核心抽象
public interface SpelNode {
TypedValue getValue(ExpressionState state);
}
// 数字字面量
class IntLiteral implements SpelNode {
private int value;
public TypedValue getValue(ExpressionState state) {
return new TypedValue(value);
}
}
// 属性访问
class PropertyOrFieldReference implements SpelNode {
private String name;
public TypedValue getValue(ExpressionState state) {
Object target = state.peekContext().getRootObject();
// 通过反射获取属性值
return new TypedValue(getProperty(target, name));
}
}
// 方法调用
class MethodReference implements SpelNode {
private String name;
private SpelNode[] arguments;
public TypedValue getValue(ExpressionState state) {
// 获取目标对象,反射调用方法
}
}
每个节点都实现getValue方法,传入一个ExpressionState上下文。这个上下文里装着当前作用域的变量、根对象等信息。
我记得有一次排查线上问题,发现SpEL表达式解析特别慢。后来定位到是表达式里嵌套了多层方法调用,每次解析都要反射。解决方案是加了一层缓存——把解析好的语法树缓存起来,同一个表达式只解析一次。
避坑指南:我曾经在项目里直接用SpEL解析用户输入的表达式,结果被注入攻击了。用户传了个new java.lang.ProcessBuilder('rm -rf /').start(),差点出事。后来加了白名单,只允许调用特定方法。SpEL默认是安全的,但如果你自己构造StandardEvaluationContext,一定要限制权限。
解释器模式的优缺点
| 优点 | 缺点 |
|---|---|
| 语法规则容易扩展——加一个新规则就是加一个新类 | 语法复杂时类会爆炸——每个规则一个类,几十条规则就几十个类 |
| 实现简单,适合小规模语法 | 执行效率低——递归遍历树,节点多了就慢 |
| 代码结构清晰,符合开闭原则 | 不适合语法频繁变化的场景——每次改语法都要改代码 |
说实话,解释器模式在实际项目中用得并不多。大多数时候,我们会用现成的表达式引擎,比如SpEL、OGNL、MVEL。只有当你需要定义一套非常特定、且规模可控的语法时,才值得自己手写解释器。
什么时候该用解释器模式?
我个人总结了几条经验:
- 语法规则稳定,不会三天两头变
- 语法规模小,终结符和非终结符加起来不超过20个
- 性能要求不高,解析频率低
- 你希望业务人员能自己写规则,而不是每次找你改代码
如果满足以上条件,解释器模式是个不错的选择。否则,还是老老实实用现成的表达式引擎吧。
一句话总结:解释器模式把“语法”变成了“对象”,让程序能自己读懂规则。Spring的SpEL就是最好的例子。但别滥用,小语法用解释器,大语法用引擎。
这张图把解释器模式的结构画得很清楚了。抽象表达式定义接口,终结符和非终结符各自实现。非终结符内部会引用其他表达式,形成树状结构。上下文贯穿整个求值过程,提供变量等全局信息。
嗯,最后说一句。解释器模式虽然不常用,但理解它对理解编译原理、表达式引擎非常有帮助。下次你用SpEL的时候,可以想想背后那棵语法树——代码其实没那么神秘。
公众号:蓝海资料掘金营,微信deep3321