解释器模式:让代码自己“读懂”规则

解释器模式,这个名字听起来有点学术。说白了,就是给程序定义一套“语言”,然后让程序能解析并执行这套语言写的“句子”。

我刚开始接触这个模式时,觉得它很鸡肋——谁会没事在代码里再塞一门语言进去?直到后来做规则引擎项目,才明白它的价值。你想想看,如果业务规则频繁变动,每次都要改代码、重新部署,那得多痛苦?

解释器模式的结构

解释器模式的核心思想,是把“语法规则”抽象成对象。每个语法规则对应一个类,然后组合这些类来解析表达式。

它的结构其实很清晰,就四个角色:

  • 抽象表达式(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就是最好的例子。但别滥用,小语法用解释器,大语法用引擎。

解释器模式核心结构 抽象表达式 interpret(Context) 终结符表达式 数字、变量、字面量 非终结符表达式 加法、乘法、方法调用 上下文 (Context) 变量映射、全局状态 语法树:终结符是叶子节点,非终结符是树枝节点 递归遍历树,每个节点调用 interpret() 求值

这张图把解释器模式的结构画得很清楚了。抽象表达式定义接口,终结符和非终结符各自实现。非终结符内部会引用其他表达式,形成树状结构。上下文贯穿整个求值过程,提供变量等全局信息。

嗯,最后说一句。解释器模式虽然不常用,但理解它对理解编译原理、表达式引擎非常有帮助。下次你用SpEL的时候,可以想想背后那棵语法树——代码其实没那么神秘。


公众号:蓝海资料掘金营,微信deep3321