设计模式预计阅读 22 分钟8660 字3389 次浏览

设计模式修炼之路 ㉕ 解释器模式——让程序读懂规则

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 求值"。⑨ 的回头客,这次是真的要动手建树了。

二、优点与缺点

优点:

  1. 规则即数据:加一条规则 = 传一个字符串,零代码、零编译、零发版——运营半夜上线,程序员的枕头是干的。
  2. 文法与代码一一对应:一条文法规则一个类(或一个解析函数),看 BNF 就知道代码长什么样,改文法就是改那一处。
  3. 扩展运算符零侵入:想支持 >、< 之外再来个 contains?加一个终结符类 + 文法里添一笔,现有表达式类全部不动——开闭原则。
  4. 语法错误前置:"vip && & age" 这种手滑,解析当场报"位于 7",而不是默默跑出一个诡异的业务结果。

缺点:

  1. 文法复杂则类爆炸:本篇 &&、||、!、比较、括号就 6 个表达式类;再上算术、函数调用、字符串操作……类文件能铺满一屏。GoF 原文自己就劝退:文法复杂时,别手写,用解析器生成器(ANTLR 一类)。
  2. 性能有税:每次求值都要递归走整棵树,每次解析都要从字符串建树。高频调用场景这笔账得算清楚(可缓存 AST 缓解,见第六节)。
  3. 等于开了个迷你编程接口:规则文本若是外部可传入的,"a && a && a && ……(一万层)" 就能把你递归栈干爆——解释器天生要做长度与深度防护。
  4. 团队门槛:出了 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 直接语法报错。这叫最长匹配(贪心匹配),是所有词法分析器的第一课——同一个位置可能有多种读法时,永远选最长的那个。一个字符顺序,决定引擎能不能跑。

五、在框架里找它

  1. Spring Expression Language(SpEL):@Value("#{user.age > 18}")、@PreAuthorize("hasRole('ADMIN') and #id == authentication.principal.id")——注解里那串字符串就是一门小语言,Spring 启动时解析成 AST,调用时对着上下文求值。本文的 ComparisonExpression、AndExpression 在 SpEL 里全有对应物,只是词法豪华了一百倍。

  2. java.util.regex.Pattern:Pattern.compile("\\d+") 干的事就是本文的 RuleParser.parse——把文本编译成内部表示(NFA/DFA),之后 matcher.matches() 反复解释输入串。每个正则引擎都是一台解释器,而且演示了正确的性能姿势:compile 一次,匹配多次。

  3. MyBatis 的动态 SQL 与 OGNL:<if test="name != null and name != ''">——test 属性里那串表达式由 OGNL 解释执行,决定 SQL 片段拼不拼进来。全国最大的"配置里的规则解释器"部署现场。

  4. 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。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论