一、为什么要拆解这个轮子
项目里用了 QLExpress 做规则引擎,配置满减规则、积分计算之类的需求。一直有几个疑问:
疑问1:它不生成 class 文件,怎么执行 if 语句?
java
String script = "if (amount > 100) amount * 0.8";
Object result = runner.execute(script); // 直接就能跑?
疑问2:它怎么调用我自己写的 Java 类?
java
runner.addFunctionOfClassMethod("折扣", MyMath.class.getName(), "discount", ...);
// 脚本里就能用:折扣(amount, 0.8)
疑问3:运算符优先级怎么保证?
java
"2 + 3 * 4" // 怎么知道先算乘法?
周末正好有时间,花了一天手写了一个 700 行的极简版,把这些问题都搞清楚了。
二、整体流程:从字符串到结果
先看全局,QLExpress 把脚本执行分成三个阶段:
Lexer] B --> C[Token流] C --> D[语法分析
Parser] D --> E[AST树] E --> F[解释执行
Interpreter] F --> G[结果]
举个例子:
java
输入脚本:if (amount > 100) amount * 0.8
上下文变量:amount = 150
阶段一:词法分析(把字符串切成词)
scss
输入:"if (amount > 100) amount * 0.8"
输出:[IF] [LPAREN] [IDENTIFIER:amount] [GREATER] [NUMBER:100] [RPAREN]
[IDENTIFIER:amount] [MULTIPLY] [NUMBER:0.8]
就像中文分词,把一句话切成一个个单词。代码里用状态机实现,逐个字符扫描。
阶段二:语法分析(把词组装成树)
scss
Token流 → AST树
IfNode
/ \
条件节点 执行节点
(amount>100) (amount*0.8)
| |
BinaryOpNode BinaryOpNode
/ \ / \
amount 100 amount 0.8
就像造句,把单词组成有结构的句子。代码里用递归下降实现,一层层往下解析。
阶段三:解释执行(遍历树算结果)
ini
遍历 IfNode:
1. 先执行条件节点:150 > 100 = true
2. 因为条件为 true,执行 then 分支
3. 执行 then 节点:150 * 0.8 = 120
4. 返回结果:120
不生成字节码,直接用 Java 代码遍历树执行。
三、核心原理一:if 语句的秘密
关键问题
QLExpress 的 if 到底是什么?是 JVM 的 if 指令吗?
答案:用 Java 的 if 包装
先看 Groovy 的做法(编译成字节码):
java
// Groovy 会把脚本编译成真正的 class
public class Script123 {
public Object run() {
if (amount > 100) { // ← 这是 JVM 的 if 指令
return amount * 0.8;
}
}
}
再看 QLExpress 的做法(用 Java 代码模拟):
java
// 1. Parser 解析时,识别 if 关键字
if (peek().getType() == TokenType.IF) {
return parseIfStatement(); // 构造 IfNode
}
// 2. 构造 AST 节点
IfNode {
condition: BinaryOpNode(amount, >, 100)
thenBranch: BinaryOpNode(amount, *, 0.8)
}
// 3. Interpreter 执行时
private Object interpretIf(IfNode node, Context context) {
// 先计算条件表达式
Object condResult = interpret(node.getCondition(), context);
// 用 Java 的 if 判断
if (condResult instanceof Boolean && (Boolean) condResult) {
return interpret(node.getThenBranch(), context); // 执行 then 分支
}
return null;
}
核心差异:
Groovy: 脚本的 if → 编译成 JVM 的 if 指令
QLExpress:脚本的 if → 构造 IfNode → 用 Java 的 if 模拟执行
执行流程图
关键代码:
java
// Parser.java - 如何识别 if 语句
private ASTNode parseIfStatement() {
consumeToken(TokenType.IF); // 消费 'if'
consumeToken(TokenType.LPAREN); // 消费 '('
ASTNode condition = parseExpression(); // 递归解析条件(重点!)
consumeToken(TokenType.RPAREN); // 消费 ')'
ASTNode thenBranch = parseExpression();// 递归解析 then 分支(重点!)
return new IfNode(condition, thenBranch, null);
}
四、核心原理二:如何调用自定义方法
关键问题
脚本里怎么调用 MyMath.discount 这个我自己写的方法?
答案:注册 + 反射
第一步:注册方法
java
// 用户代码
runner.register("MyMath", MyMath.class);
// QLExpress 内部
public void register(String name, Class<?> clazz) {
context.registerFunction(name, clazz); // 存到 Map 里
}
第二步:解析函数调用
java
// 脚本:"MyMath.discount(amount, 0.8)"
// Parser 识别到:IDENTIFIER.IDENTIFIER(...)
private ASTNode parseFunctionCall(String className, String methodName) {
List<ASTNode> args = new ArrayList<>();
// 解析参数列表
do {
args.add(parseExpression()); // 递归解析每个参数
} while (match(TokenType.COMMA));
return new FunctionCallNode(className, methodName, args);
}
第三步:反射调用
java
// Interpreter.java
private Object interpretFunctionCall(FunctionCallNode node, Context context) {
// 1. 从注册表找到类
Class<?> clazz = context.getFunctionClass(node.getClassName());
// 2. 准备参数
Object[] argValues = new Object[node.getArguments().size()];
for (int i = 0; i < argValues.length; i++) {
argValues[i] = interpret(node.getArguments().get(i), context);
}
// 3. 反射调用
Method method = clazz.getMethod(node.getMethodName(), getArgTypes(argValues));
return method.invoke(null, argValues); // 调用静态方法
}
执行流程图
为什么安全?
arduino
只能调用注册过的类(白名单机制)
脚本里写 System.exit(0) 也没用,因为没注册
五、核心原理三:运算符优先级如何保证
关键问题
java
"2 + 3 * 4" // 结果是 14,说明先算乘法
Parser 怎么知道先算哪个?
答案:递归下降 + 调用链层次
调用链设计(优先级从低到高):
java
parseExpression() // 入口
↓
parseConditional() // if 语句(优先级最低)
↓
parseComparison() // 比较运算 >, <, ==
↓
parseTerm() // 加减运算 +, -
↓
parseFactor() // 乘除运算 *, /
↓
parsePrimary() // 基本元素(优先级最高)
为什么这样设计?
看个例子:2 + 3 * 4
scss
1. parseExpression() 调用 parseTerm()
2. parseTerm() 先调用 parseFactor() 解析左边
parseFactor() 解析 "2",返回 LiteralNode(2)
3. parseTerm() 看到 PLUS,继续解析右边
4. 右边调用 parseFactor() 解析 "3 * 4"
5. parseFactor() 看到 "3",再看到 MULTIPLY
它会继续解析,构造 BinaryOpNode(3 * 4)
6. parseTerm() 拿到右边的结果:BinaryOpNode(3 * 4)
7. 最终构造:BinaryOpNode(2 + (3 * 4))
核心思想:
优先级高的运算符,在递归调用的更深层
所以会先被解析、先被计算
代码展示
java
// Parser.java
private ASTNode parseTerm() {
ASTNode left = parseFactor(); // 先解析高优先级的
while (match(TokenType.PLUS, TokenType.MINUS)) {
Token operator = previous();
ASTNode right = parseFactor(); // 递归解析右边
left = new BinaryOpNode(operator.getType(), left, right);
}
return left;
}
private ASTNode parseFactor() {
ASTNode left = parsePrimary(); // 解析最高优先级的
while (match(TokenType.MULTIPLY, TokenType.DIVIDE)) {
Token operator = previous();
ASTNode right = parsePrimary(); // 递归解析右边
left = new BinaryOpNode(operator.getType(), left, right);
}
return left;
}
解析树对比
markdown
错误的解析(如果不分优先级):
+
/ \
2 *
/ \
3 4
正确的解析(parseFactor 先处理乘法):
+
/ \
2 *
/ \
3 4
六、递归:贯穿始终的设计
整个 QLExpress 的实现,到处都是递归:
递归一:解析时的递归下降
java
ASTNode parseExpression() {
if (当前是if) {
ASTNode condition = parseExpression(); // 递归解析条件
ASTNode thenBranch = parseExpression(); // 递归解析分支
return new IfNode(condition, thenBranch, null);
}
return parseComparison(); // 继续递归
}
好处:
css
条件可以是任意复杂的表达式
if ((a > 10 && b < 20) || c == 30) ...
不需要单独处理,递归自动搞定
递归二:执行时的递归遍历
java
Object interpret(ASTNode node, Context context) {
if (node instanceof IfNode) {
Object cond = interpret(node.getCondition(), context); // 递归
if ((Boolean) cond) {
return interpret(node.getThenBranch(), context); // 递归
}
} else if (node instanceof BinaryOpNode) {
Object left = interpret(node.getLeft(), context); // 递归
Object right = interpret(node.getRight(), context); // 递归
return calculate(left, op, right);
}
// ...
}
好处:
树有多深,自动递归多深
代码简洁,逻辑清晰
递归调用栈示例
scss
脚本:if (amount > 100) amount * 0.8
执行时的调用栈:
interpret(IfNode)
├─ interpret(BinaryOpNode >)
│ ├─ interpret(VariableNode amount) → 150
│ └─ interpret(LiteralNode 100) → 100
│ └─ 返回 true
└─ interpret(BinaryOpNode *)
├─ interpret(VariableNode amount) → 150
└─ interpret(LiteralNode 0.8) → 0.8
└─ 返回 120
七、关键代码展示
词法分析器(Lexer)
java
public List<Token> tokenize(String script) {
List<Token> tokens = new ArrayList<>();
int position = 0;
while (position < script.length()) {
char c = script.charAt(position);
if (Character.isWhitespace(c)) {
position++;
} else if (Character.isDigit(c)) {
tokens.add(readNumber(script, position));
} else if (Character.isLetter(c)) {
Token token = readIdentifier(script, position);
// 检查是否是关键字
if (token.getText().equals("if")) {
tokens.add(new Token(TokenType.IF, "if"));
} else {
tokens.add(token);
}
} else if (c == '>') {
tokens.add(new Token(TokenType.GREATER, ">"));
position++;
}
// ...
}
return tokens;
}
语法分析器(Parser)
java
public ASTNode parse() {
return parseExpression();
}
private ASTNode parseExpression() {
return parseConditional();
}
private ASTNode parseConditional() {
if (peek().getType() == TokenType.IF) {
return parseIfStatement();
}
return parseComparison();
}
private ASTNode parseIfStatement() {
consumeToken(TokenType.IF);
consumeToken(TokenType.LPAREN);
ASTNode condition = parseExpression(); // 递归
consumeToken(TokenType.RPAREN);
ASTNode thenBranch = parseExpression(); // 递归
return new IfNode(condition, thenBranch, null);
}
解释执行器(Interpreter)
java
public Object interpret(ASTNode node, Context context) {
if (node instanceof IfNode) {
return interpretIf((IfNode) node, context);
} else if (node instanceof BinaryOpNode) {
return interpretBinaryOp((BinaryOpNode) node, context);
} else if (node instanceof FunctionCallNode) {
return interpretFunctionCall((FunctionCallNode) node, context);
} else if (node instanceof VariableNode) {
return context.getVariable(((VariableNode) node).getName());
} else if (node instanceof LiteralNode) {
return ((LiteralNode) node).getValue();
}
throw new RuntimeException("Unknown node type");
}
private Object interpretBinaryOp(BinaryOpNode node, Context context) {
Object left = interpret(node.getLeft(), context); // 递归
Object right = interpret(node.getRight(), context); // 递归
switch (node.getOperator()) {
case PLUS: return (Double) left + (Double) right;
case MULTIPLY: return (Double) left * (Double) right;
case GREATER: return (Double) left > (Double) right;
// ...
}
}
八、三板斧总结
回到最开始的问题,QLExpress 的核心就是三板斧:
第一斧:词法分析 + 语法分析 + AST
字符串 → Token流 → AST树
技术:手写状态机 + 递归下降
目的:把脚本变成可操作的树形结构
第二斧:栈式递归执行
遍历 AST 树,递归计算每个节点
技术:访问者模式 + 递归遍历
目的:用 Java 代码模拟脚本执行
第三斧:反射调用
javascript
通过注册表 + 反射调用自定义方法
技术:Map 存储 + Java 反射
目的:安全地扩展脚本能力
九、对比真实的 QLExpress
手写版本和真实的 QLExpress 有什么区别?
| 维度 | 手写版 | QLExpress |
|---|---|---|
| 词法分析 | 手写状态机 | ANTLR 自动生成 |
| 语法分析 | 递归下降 | ANTLR 自动生成 |
| 执行方式 | AST 解释执行 | 指令虚拟机(QVM) |
| 代码量 | 700 行 | 20000+ 行 |
| 功能完整性 | 基础功能 | 企业级完整 |
| 依赖 | 零依赖 | antlr4-runtime |
真实 QLExpress 的额外特性:
- 变量赋值
- 循环语句(for/while)
- 函数定义
- 异常处理
- 性能优化(指令缓存、对象池)
但核心思想是一样的:不生成 class,用 Java 代码模拟执行。
十、完整测试
java
public class Main {
public static void main(String[] args) {
MiniQLExpress runner = new MiniQLExpress();
runner.register("MyMath", MyMath.class);
runner.getContext().addVariable("amount", 150.0);
test(runner, "2 + 3 * 4");
test(runner, "if (amount > 100) amount * 0.8");
test(runner, "MyMath.discount(100, 0.8)");
test(runner, "if (amount > 100) MyMath.discount(amount, 0.8)");
}
static void test(MiniQLExpress runner, String script) {
Object result = runner.execute(script);
System.out.println("Result of \"" + script + "\": " + result);
}
}
运行结果:
rust
Result of "2 + 3 * 4": 14.0
Result of "if (amount > 100) amount * 0.8": 120.0
Result of "MyMath.discount(100, 0.8)": 80.0
Result of "if (amount > 100) MyMath.discount(amount, 0.8)": 120.0
全部通过。
十一、收获
花一天时间手写 700 行代码,搞清楚了几个问题:
1. QLExpress 的 if 不是 JVM 的 if
它是用 Java 的 if 包装出来的
所以不需要编译成字节码
2. 反射不是魔法
javascript
提前注册 → 存 Map
脚本调用 → 从 Map 取 → method.invoke
3. 递归是灵魂
解析时递归 → 可以处理任意嵌套
执行时递归 → 可以处理任意复杂度
4. 不是所有轮子都需要 ANTLR
简单场景,手写 Parser 足够了
代码清晰,容易调试
十二、代码获取
完整代码已开源(700 行):
- Lexer.java(词法分析)
- Parser.java(语法分析)
- Interpreter.java(解释执行)
- AST 节点定义
- 完整测试用例
可以直接运行,零依赖。
写在最后
拆轮子的目的不是重复造轮子,而是理解原理。
下次用 QLExpress 的时候,心里就有底了:
- 知道它为什么快(不编译)
- 知道它为什么安全(白名单)
- 知道它的局限(功能相对简单)
周末愉快。