规则解析与编译:从文本到可执行计划
规则引擎的核心,说白了就是「把人类能读懂的规则,变成机器能跑的东西」。
我刚开始做风控时,总觉得规则解析不就是个字符串处理吗?后来踩了坑才明白——这里面门道深着呢。今天咱们就聊聊,一条规则从文本到执行计划,到底经历了什么。
词法分析:把句子拆成单词
想象一下,你写了一条规则:
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 |
| ... | ... |
语法分析:把单词组成句子
词法分析完了,我们有一堆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);
}
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虽然好理解,但直接执行AST效率不高。为什么?
因为AST里有很多「元信息」——比如节点类型、Token位置、注释等。这些在运行时根本用不上,反而拖慢了速度。
规则编译,就是把AST「降级」成一个更高效的执行计划。
我常用的做法是:
- 常量折叠:把能提前算的表达式,在编译期就算掉。比如
2 + 3 > 4直接变成true - 短路优化:把AND/OR的短路逻辑,编译成条件跳转指令
- 索引预计算:把字段访问路径提前算好,运行时直接按索引取值
- 缓存策略:把频繁使用的子表达式,编译成缓存指令
举个例子,编译后的执行计划可能是这样的:
// 编译后的执行计划(伪代码)
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高一个数量级。
整体流程:一张图说清楚
说了这么多,咱们用一张图把整个流程串起来:
这张图把整个流程串起来了。你想想看,从一条文本规则,到最后能高效执行的指令序列,中间经历了多少道工序?每一步都有它的价值,缺一不可。
性能优化的几个关键点
说到性能,我总结了几个实战中最重要的点:
- 预编译:规则在部署时就编译好,运行时直接加载执行计划。别等到请求来了再编译,那太慢了。
- 指令缓存:相同的规则只编译一次,后续直接复用。我在项目中遇到过,同一个规则被重复编译了上千次,优化后性能提升了10倍。
- 批量编译:如果有大量规则要更新,别一条一条编译。批量编译可以利用多线程,还能做跨规则的优化(比如规则合并)。
- 增量编译:只重新编译有变化的规则。我曾经见过一个系统,每次更新规则都要全量编译,5000条规则编译了3秒钟——这在线上是不可接受的。
好了,规则解析与编译这块,核心内容就这些。从词法分析到语法分析,从AST构建到执行计划生成,每一步都有它的技术深度。但说白了,最终目的只有一个——让规则跑得更快、更稳。
公众号:蓝海资料掘金营,微信deep3321