规则解析与编译:从文本到可执行计划

规则引擎的核心,说白了就是「把人类能读懂的规则,变成机器能跑的东西」。

我刚开始做风控时,总觉得规则解析不就是个字符串处理吗?后来踩了坑才明白——这里面门道深着呢。今天咱们就聊聊,一条规则从文本到执行计划,到底经历了什么。

词法分析:把句子拆成单词

想象一下,你写了一条规则:

IF amount > 10000 AND riskLevel == 'high' THEN reject

计算机不认识这句话。它得先知道「IF」「amount」「>」「10000」这些分别是什么东西。

词法分析干的就是这个活——把一串字符,拆成一个个有意义的「词法单元」(Token)。

我习惯用正则表达式来做这件事。比如:

// 伪代码示例
Token[] tokenize(String input) {
    List<Token> tokens = new ArrayList<>();
    
    // 匹配关键字
    if (input matches "IF|THEN|ELSE|AND|OR") {
        tokens.add(new Token(TokenType.KEYWORD, match));
    }
    // 匹配数字
    else if (input matches "\d+") {
        tokens.add(new Token(TokenType.NUMBER, match));
    }
    // 匹配字符串
    else if (input matches "'[^']*'") {
        tokens.add(new Token(TokenType.STRING, match));
    }
    // 匹配操作符
    else if (input matches ">|<|==|!=") {
        tokens.add(new Token(TokenType.OPERATOR, match));
    }
    
    return tokens.toArray();
}

输出结果大概是这样:

Token类型 Token值
KEYWORD IF
IDENTIFIER amount
OPERATOR >
NUMBER 10000
KEYWORD AND
... ...
小技巧:词法分析时,我建议把位置信息也带上(行号、列号)。这样后面报错时,能精确告诉用户「第3行第5列出错了」,而不是笼统的「语法错误」。

语法分析:把单词组成句子

词法分析完了,我们有一堆Token。但Token本身没有结构——它只是一串扁平的列表。

语法分析要做的,就是根据语法规则,把这些Token组织成一棵树。这棵树就是AST(抽象语法树)。

举个例子,规则:

IF amount > 10000 AND riskLevel == 'high' THEN reject

解析成AST后,结构是这样的:

RuleNode
├── ConditionNode (AND)
│   ├── ComparisonNode (>)
│   │   ├── FieldNode (amount)
│   │   └── LiteralNode (10000)
│   └── ComparisonNode (==)
│       ├── FieldNode (riskLevel)
│       └── LiteralNode ('high')
└── ActionNode (reject)

我常用递归下降解析器来做这件事。为什么?因为它直观、好调试。

// 递归下降解析器示例
ASTNode parseCondition() {
    ASTNode left = parseComparison();
    
    while (currentToken.type == TokenType.AND 
           || currentToken.type == TokenType.OR) {
        Token op = consume();
        ASTNode right = parseComparison();
        left = new LogicalNode(op.value, left, right);
    }
    
    return left;
}

ASTNode parseComparison() {
    ASTNode left = parsePrimary();
    Token op = consume(TokenType.OPERATOR);
    ASTNode right = parsePrimary();
    return new ComparisonNode(op.value, left, right);
}
我曾经踩过的坑:运算符优先级!AND和OR的优先级不同,如果不处理,A AND B OR C会被解析成A AND (B OR C)还是(A AND B) OR C?结果完全不一样。一定要在语法分析阶段就处理好优先级。

AST构建:树形结构的艺术

AST(抽象语法树)是规则引擎的「骨架」。它把规则的逻辑结构,用树的形式表达出来。

一个好的AST设计,应该满足三点:

  • 可遍历:能方便地深度优先或广度优先遍历
  • 可修改:支持节点替换、删除、插入(比如做规则优化时)
  • 可序列化:能转成JSON或其他格式,方便存储和传输

我习惯这样设计AST节点:

// AST节点基类
abstract class ASTNode {
    Token token;        // 对应的Token
    List<ASTNode> children;  // 子节点
    int line;           // 行号
    int column;         // 列号
    
    abstract Object evaluate(Context ctx);
}

// 具体节点类型
class ComparisonNode extends ASTNode {
    String operator;    // >, <, ==, !=
    ASTNode left;
    ASTNode right;
    
    Object evaluate(Context ctx) {
        Object l = left.evaluate(ctx);
        Object r = right.evaluate(ctx);
        // 执行比较逻辑
        return compare(l, r, operator);
    }
}

class LogicalNode extends ASTNode {
    String operator;    // AND, OR
    ASTNode left;
    ASTNode right;
    
    Object evaluate(Context ctx) {
        boolean l = (boolean) left.evaluate(ctx);
        // 短路优化:如果是AND且左为false,直接返回
        if (operator.equals("AND") && !l) return false;
        // 如果是OR且左为true,直接返回
        if (operator.equals("OR") && l) return true;
        
        boolean r = (boolean) right.evaluate(ctx);
        return operator.equals("AND") ? (l && r) : (l || r);
    }
}
核心思路:AST的每个节点,都知道自己该怎么执行。这就是「解释器模式」的精髓。你想想看,这样一来,执行规则就变成了遍历AST并调用每个节点的evaluate方法。

规则编译:从AST到执行计划

AST虽然好理解,但直接执行AST效率不高。为什么?

因为AST里有很多「元信息」——比如节点类型、Token位置、注释等。这些在运行时根本用不上,反而拖慢了速度。

规则编译,就是把AST「降级」成一个更高效的执行计划。

我常用的做法是:

  1. 常量折叠:把能提前算的表达式,在编译期就算掉。比如 2 + 3 > 4 直接变成 true
  2. 短路优化:把AND/OR的短路逻辑,编译成条件跳转指令
  3. 索引预计算:把字段访问路径提前算好,运行时直接按索引取值
  4. 缓存策略:把频繁使用的子表达式,编译成缓存指令

举个例子,编译后的执行计划可能是这样的:

// 编译后的执行计划(伪代码)
ExecutionPlan plan = new ExecutionPlan();

// 指令序列
plan.add(new LoadFieldInstruction(0, "amount"));  // 加载amount字段
plan.add(new LoadConstantInstruction(10000));      // 加载常量10000
plan.add(new CompareInstruction(">"));             // 比较
plan.add(new JumpIfFalseInstruction(5));           // 如果false,跳到第5条指令
plan.add(new LoadFieldInstruction(1, "riskLevel"));// 加载riskLevel字段
plan.add(new LoadConstantInstruction("high"));     // 加载常量'high'
plan.add(new CompareInstruction("=="));            // 比较
plan.add(new JumpIfFalseInstruction(10));          // 如果false,跳到第10条指令
plan.add(new ActionInstruction("reject"));         // 执行reject
plan.add(new ReturnInstruction());                 // 返回

你看,编译后的执行计划,本质上就是一个「指令数组」。每条指令都极其简单,CPU缓存友好,执行效率比遍历AST高一个数量级。

我的经验:在编译阶段,我还会做「规则合并」。比如两条规则的条件部分完全相同,只是动作不同,我会把它们合并成一条规则,用switch-case来处理动作。这样能减少规则数量,提升匹配效率。

整体流程:一张图说清楚

说了这么多,咱们用一张图把整个流程串起来:

规则解析与编译流程 规则文本 IF amount > 10000... 词法分析 Token序列 [IF, amount, >, 10000, ...] 语法分析 AST 抽象语法树 编译 执行计划 指令数组 编译优化 执行 执行结果 reject / approve 整个流程:规则文本 → 词法分析 → Token序列 → 语法分析 → AST → 编译优化 → 执行计划 → 执行结果 编译优化环节:常量折叠 → 短路优化 → 索引预计算 → 缓存策略 → 规则合并

这张图把整个流程串起来了。你想想看,从一条文本规则,到最后能高效执行的指令序列,中间经历了多少道工序?每一步都有它的价值,缺一不可。

性能优化的几个关键点

说到性能,我总结了几个实战中最重要的点:

  • 预编译:规则在部署时就编译好,运行时直接加载执行计划。别等到请求来了再编译,那太慢了。
  • 指令缓存:相同的规则只编译一次,后续直接复用。我在项目中遇到过,同一个规则被重复编译了上千次,优化后性能提升了10倍。
  • 批量编译:如果有大量规则要更新,别一条一条编译。批量编译可以利用多线程,还能做跨规则的优化(比如规则合并)。
  • 增量编译:只重新编译有变化的规则。我曾经见过一个系统,每次更新规则都要全量编译,5000条规则编译了3秒钟——这在线上是不可接受的。
注意:编译优化不是越复杂越好。我见过有人把编译优化做得极其复杂,结果编译时间比执行时间还长。记住一个原则:编译是低频操作,执行是高频操作。编译阶段多花1毫秒,执行阶段省10微秒,这笔账是划算的。反过来就不行了。

好了,规则解析与编译这块,核心内容就这些。从词法分析到语法分析,从AST构建到执行计划生成,每一步都有它的技术深度。但说白了,最终目的只有一个——让规则跑得更快、更稳。


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