周末拆解:QLExpress 如何做到不编译就能执行?

一、为什么要拆解这个轮子

项目里用了 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 把脚本执行分成三个阶段:

graph LR A[脚本字符串] --> B[词法分析
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 模拟执行

执行流程图

graph TB A[脚本: if amount>100] --> B[Lexer识别关键字 IF] B --> C[Parser.parseIfStatement] C --> D[构造 IfNode] D --> E[存储 condition 和 thenBranch] E --> F[Interpreter.interpretIf] F --> G[先执行 condition 节点] G --> H{结果是 true?} H -->|是| I[执行 thenBranch 节点] H -->|否| J[返回 null] I --> K[返回结果]

关键代码

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);  // 调用静态方法
}

执行流程图

graph TB A[注册: MyMath.class] --> B[存入 Map] C[脚本: MyMath.discount] --> D[Parser识别函数调用] D --> E[构造 FunctionCallNode] E --> F[Interpreter执行] F --> G[从Map取出 MyMath.class] G --> H[准备参数: 150, 0.8] H --> I[反射: method.invoke] I --> J[返回结果: 120]

为什么安全?

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 的时候,心里就有底了:

  • 知道它为什么快(不编译)
  • 知道它为什么安全(白名单)
  • 知道它的局限(功能相对简单)

周末愉快。

相关推荐
一个不知名程序员www2 小时前
算法学习入门--- 树(C++)
c++·算法
222you2 小时前
Spring框架的介绍和IoC入门
java·后端·spring
用户6151265617332 小时前
Java生态新纪元:虚拟线程、模式匹配与未来的编程范式
后端
如竟没有火炬2 小时前
四数相加贰——哈希表
数据结构·python·算法·leetcode·散列表
风雨同舟的代码笔记2 小时前
Java并发编程基石:深入解析AQS原理与应用实战
后端
曾富贵2 小时前
【后端进阶】并发竞态与锁选型
后端
安当加密2 小时前
Oracle数据库透明加密实践:基于TDE架构的安全加固方案
数据库·oracle·架构
背心2块钱包邮2 小时前
第9节——部分分式积分(Partial Fraction Decomposition)
人工智能·python·算法·机器学习·matplotlib
仰泳的熊猫2 小时前
1148 Werewolf - Simple Version
数据结构·c++·算法·pat考试