GitHub - alibaba/QLExpress: QLExpress is a powerful, lightweight, dynamic language for the Java platform aimed at improving developers’ productivity in different business scenes.

引入依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>qlexpress4</artifactId>
    <version>4.1.0</version>
</dependency>

环境要求:

  • JDK 8 或更高版本

第一个 QLExpress 程序

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
Map<String, Object> context = new HashMap<>();
context.put("a", 1);
context.put("b", 2);
context.put("c", 3);
Object result = express4Runner.execute("a + b * c", context, QLOptions.DEFAULT_OPTIONS).getResult();
assertEquals(7, result);

添加自定义函数与操作符

最简单的方式是通过 Java Lambda 表达式快速定义函数/操作符的逻辑:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
// custom function
express4Runner.addVarArgsFunction("join",
    params -> Arrays.stream(params).map(Object::toString).collect(Collectors.joining(",")));
Object resultFunction =
    express4Runner.execute("join(1,2,3)", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS).getResult();
assertEquals("1,2,3", resultFunction);

// custom operator
express4Runner.addOperatorBiFunction("join", (left, right) -> left + "," + right);
Object resultOperator =
    express4Runner.execute("1 join 2 join 3", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS).getResult();
assertEquals("1,2,3", resultOperator);

如果自定义函数的逻辑比较复杂,或者需要获得脚本的上下文信息,也可以通过继承 CustomFunction 的方式实现。

比如下面的 hello 自定义函数,根据租户不同,返回不同的欢迎信息:

package com.alibaba.qlexpress4.test.function;

import com.alibaba.qlexpress4.runtime.Parameters;
import com.alibaba.qlexpress4.runtime.QContext;
import com.alibaba.qlexpress4.runtime.function.CustomFunction;

public class HelloFunction implements CustomFunction {
    @Override
    public Object call(QContext qContext, Parameters parameters)
        throws Throwable {
        String tenant = (String)qContext.attachment().get("tenant");
        return "hello," + tenant;
    }
}
Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
express4Runner.addFunction("hello", new HelloFunction());
String resultJack = (String)express4Runner.execute("hello()",
    Collections.emptyMap(),
    // Additional information(tenant for example) can be brought into the custom function from outside via attachments
    QLOptions.builder().attachments(Collections.singletonMap("tenant", "jack")).build()).getResult();
assertEquals("hello,jack", resultJack);
String resultLucy =
    (String)express4Runner
        .execute("hello()",
            Collections.emptyMap(),
            QLOptions.builder().attachments(Collections.singletonMap("tenant", "lucy")).build())
        .getResult();
assertEquals("hello,lucy", resultLucy);

QLExpress4还支持通过QLExpress脚本添加自定义函数。需要注意的是,在函数外定义的变量(如示例中的defineTime)在函数定义时就已初始化完成,后续调用函数时不会重新计算该变量的值。

Express4Runner express4Runner =
    new Express4Runner(InitOptions.builder().securityStrategy(QLSecurityStrategy.open()).build());
BatchAddFunctionResult addResult = express4Runner.addFunctionsDefinedInScript(
    "function myAdd(a,b) {\n" + "    return a+b;" + "}\n" + "\n" + "function getCurrentTime() {\n"
        + "    return System.currentTimeMillis();\n" + "}" + "\n" + "defineTime=System.currentTimeMillis();\n"
        + "function defineTime() {\n" + "    return defineTime;" + "}\n",
    ExpressContext.EMPTY_CONTEXT,
    QLOptions.DEFAULT_OPTIONS);
assertEquals(3, addResult.getSucc().size());
QLResult result = express4Runner.execute("myAdd(1,2)", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
assertEquals(3, result.getResult());

QLResult resultCurTime1 =
    express4Runner.execute("getCurrentTime()", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
Thread.sleep(1000);
QLResult resultCurTime2 =
    express4Runner.execute("getCurrentTime()", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
assertNotSame(resultCurTime1.getResult(), resultCurTime2.getResult());

/*
 * The defineTime variable is defined outside the function and is initialized when the function is defined;
 * it is not recalculated afterward, so the value returned is always the time at which the function was defined.
 */
QLResult resultDefineTime1 =
    express4Runner.execute("defineTime()", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
Thread.sleep(1000);
QLResult resultDefineTime2 =
    express4Runner.execute("defineTime()", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
assertSame(resultDefineTime1.getResult(), resultDefineTime2.getResult());

建议尽可能使用Java方式定义自定义函数,这样可以获得更好的性能和稳定性。

校验语法正确性

在不执行脚本的情况下,单纯校验语法的正确性,其中包含了操作符的限制校验,调用 check 并且捕获异常,如果捕获到 QLSyntaxException,则说明存在语法错误

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
try {
    express4Runner.check("a+b;\n(a+b");
    fail();
}
catch (QLSyntaxException e) {
    assertEquals(2, e.getLineNo());
    assertEquals(4, e.getColNo());
    assertEquals("SYNTAX_ERROR", e.getErrorCode());
    // <EOF> represents the end of script
    assertEquals(
        "[Error SYNTAX_ERROR: mismatched input '<EOF>' expecting ')']\n" + "[Near: a+b; (a+b<EOF>]\n"
            + "                ^^^^^\n" + "[Line: 2, Column: 4]",
        e.getMessage());
}

你可以使用 CheckOptions 配置更精细的语法校验规则,主要支持以下两个选项:

  1. operatorCheckStrategy: 操作符校验策略,用于限制脚本中可以使用的操作符

  2. disableFunctionCalls: 是否禁用函数调用,默认为 false

示例1:使用操作符校验策略(白名单)

// Create a whitelist of allowed operators
Set<String> allowedOps = new HashSet<>(Arrays.asList("+", "*"));

// Configure check options with operator whitelist
CheckOptions checkOptions =
    CheckOptions.builder().operatorCheckStrategy(OperatorCheckStrategy.whitelist(allowedOps)).build();

// Create runner and check script with custom options
Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
runner.check("a + b * c", checkOptions); // This will pass as + and * are allowed

示例2:禁用函数调用

// Create a runner
Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);

// Create options with function calls disabled
CheckOptions options = CheckOptions.builder().disableFunctionCalls(true).build();

// Script with function call
String scriptWithFunctionCall = "Math.max(1, 2)";

// Use custom options to check script
try {
    runner.check(scriptWithFunctionCall, options);
}
catch (QLSyntaxException e) {
    // Will throw exception as function calls are disabled
}

解析脚本所需外部变量

脚本中使用的变量有的是脚本内生,有的是需要从外部通过 context 传入的。

QLExpress4 提供了一个方法,可以解析出脚本中所有需要从外部传入的变量:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
Set<String> outVarNames =
    express4Runner.getOutVarNames("int a = 1, b = 10;\n" + "c = 11\n" + "e = a + b + c + d\n" + "f+e");
Set<String> expectSet = new HashSet<>();
expectSet.add("d");
expectSet.add("f");
assertEquals(expectSet, outVarNames);

更多脚本依赖解析工具:

  • getOutFunctions: 解析所有需要从外部定义的函数

  • getOutVarAttrs:解析所有需要从外部传入变量及其涉及的属性,getOutVarNames 的增强版本

高精度计算

QLExpress 内部会用 BigDecimal 表示所有无法用 double 精确表示数字,来尽可能地表示计算精度:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
Object result = express4Runner.execute("0.1", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS).getResult();
assertTrue(result instanceof BigDecimal);

通过这种方式能够解决一些计算精度问题:

比如 0.1+0.2 因为精度问题,在 Java 中是不等于 0.3 的。 而 QLExpress 能够自动识别出 0.1 和 0.2 无法用双精度精确表示,改成用 BigDecimal 表示,确保其结果等于0.3

assertNotEquals(0.3, 0.1 + 0.2, 0.0);
assertTrue((Boolean)express4Runner.execute("0.3==0.1+0.2", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS)
    .getResult());

除了默认的精度保证外,还提供了 precise 开关,打开后所有的计算都使用BigDecimal,防止外部传入的低精度数字导致的问题:

Map<String, Object> context = new HashMap<>();
context.put("a", 0.1);
context.put("b", 0.2);
assertFalse((Boolean)express4Runner.execute("0.3==a+b", context, QLOptions.DEFAULT_OPTIONS).getResult());
// open precise switch
assertTrue((Boolean)express4Runner.execute("0.3==a+b", context, QLOptions.builder().precise(true).build())
    .getResult());

安全策略

QLExpress4 默认采用隔离安全策略,不允许脚本访问 Java 对象的字段和方法,这确保了脚本执行的安全性。如果需要访问 Java 对象,可以通过不同的安全策略进行配置。

假设应用中有如下的 Java 类:

package com.alibaba.qlexpress4.inport;

/**
 * Author: DQinYuan
 */
public class MyDesk {

    private String book1;

    private String book2;

    public String getBook1() {
        return book1;
    }

    public void setBook1(String book1) {
        this.book1 = book1;
    }

    public String getBook2() {
        return book2;
    }

    public void setBook2(String book2) {
        this.book2 = book2;
    }
}

脚本执行的上下文设置如下:

MyDesk desk = new MyDesk();
desk.setBook1("Thinking in Java");
desk.setBook2("Effective Java");
Map<String, Object> context = Collections.singletonMap("desk", desk);

QLExpress4 提供了四种安全策略:

1. 隔离策略(默认)

默认情况下,QLExpress4 采用隔离策略,不允许访问任何字段和方法:

// default isolation strategy, no field or method can be found
Express4Runner express4RunnerIsolation = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
assertErrorCode(express4RunnerIsolation, context, "desk.book1", "FIELD_NOT_FOUND");
assertErrorCode(express4RunnerIsolation, context, "desk.getBook2()", "METHOD_NOT_FOUND");

2. 黑名单策略

通过黑名单策略,可以禁止访问特定的字段或方法,其他字段和方法可以正常访问:

// black list security strategy
Set<Member> memberList = new HashSet<>();
memberList.add(MyDesk.class.getMethod("getBook2"));
Express4Runner express4RunnerBlackList = new Express4Runner(
    InitOptions.builder().securityStrategy(QLSecurityStrategy.blackList(memberList)).build());
assertErrorCode(express4RunnerBlackList, context, "desk.book2", "FIELD_NOT_FOUND");
Object resultBlack =
    express4RunnerBlackList.execute("desk.book1", context, QLOptions.DEFAULT_OPTIONS).getResult();
Assert.assertEquals("Thinking in Java", resultBlack);

3. 白名单策略

通过白名单策略,只允许访问指定的字段或方法,其他字段和方法都会被禁止:

// white list security strategy
Express4Runner express4RunnerWhiteList = new Express4Runner(
    InitOptions.builder().securityStrategy(QLSecurityStrategy.whiteList(memberList)).build());
Object resultWhite =
    express4RunnerWhiteList.execute("desk.getBook2()", context, QLOptions.DEFAULT_OPTIONS).getResult();
Assert.assertEquals("Effective Java", resultWhite);
assertErrorCode(express4RunnerWhiteList, context, "desk.getBook1()", "METHOD_NOT_FOUND");

4. 开放策略

开放策略允许访问所有字段和方法,类似于 QLExpress3 的行为,但需要注意安全风险:

// open security strategy
Express4Runner express4RunnerOpen =
    new Express4Runner(InitOptions.builder().securityStrategy(QLSecurityStrategy.open()).build());
Assert.assertEquals("Thinking in Java",
    express4RunnerOpen.execute("desk.book1", context, QLOptions.DEFAULT_OPTIONS).getResult());
Assert.assertEquals("Effective Java",
    express4RunnerOpen.execute("desk.getBook2()", context, QLOptions.DEFAULT_OPTIONS).getResult());

注意:开放策略虽然提供了最大的灵活性,但也带来了安全风险。建议只在受信任的环境中使用,不建议用于处理终端用户输入的脚本。

策略建议

建议直接采用默认策略,在脚本中不要直接调用 Java 对象的字段和方法。而是通过自定义函数和操作符的方式(参考 添加自定义函数与操作符),对嵌入式脚本提供系统功能。这样能同时保证脚本的安全性和灵活性,用户体验还更好。

如果确实需要调用 Java 对象的字段和方法,至少应该使用白名单策略,只提供脚本有限的访问权限。

至于黑名单和开放策略,不建议在外部输入脚本的场景使用,除非确保每个脚本都会经过审核。

调用应用中的 Java 类

假设应用中有如下的 Java 类(com.alibaba.qlexpress4.QLImportTester):

package com.alibaba.qlexpress4;

public class QLImportTester {

    public static int add(int a, int b) {
        return a + b;
    }

}

在 QLExpress 中有如下两种调用方式。

1. 在脚本中使用 import 语句导入类并且使用

Express4Runner express4Runner = new Express4Runner(InitOptions.builder()
    // open security strategy, which allows access to all Java classes within the application.
    .securityStrategy(QLSecurityStrategy.open())
    .build());
// Import Java classes using the import statement.
Map<String, Object> params = new HashMap<>();
params.put("a", 1);
params.put("b", 2);
Object result =
    express4Runner
        .execute("import com.alibaba.qlexpress4.QLImportTester;" + "QLImportTester.add(a,b)",
            params,
            QLOptions.DEFAULT_OPTIONS)
        .getResult();
Assert.assertEquals(3, result);

2. 在创建 Express4Runner 时默认导入该类,此时脚本中就不需要额外的 import 语句

Express4Runner express4Runner = new Express4Runner(InitOptions.builder()
    .addDefaultImport(
        Collections.singletonList(ImportManager.importCls("com.alibaba.qlexpress4.QLImportTester")))
    .securityStrategy(QLSecurityStrategy.open())
    .build());
Object result =
    express4Runner.execute("QLImportTester.add(1,2)", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS)
        .getResult();
Assert.assertEquals(3, result);

除了用 ImportManager.importCls 导入单个类外,还有其他更方便的导入方式:

  • ImportManager.importPack 直接导入包路径下的所有类,比如 ImportManager.importPack("java.util") 会导入 java.util 包下的所有类,QLExpress 默认就会导入下面的包

    • ImportManager.importPack("java.lang")

    • ImportManager.importPack("java.util")

    • ImportManager.importPack("java.math")

    • ImportManager.importPack("java.util.stream")

    • ImportManager.importPack("java.util.function")

  • ImportManager.importInnerCls 导入给定类路径里的所有内部类

  • ImportManager.importClsAlias 为类指定别名,特别适用于代码混淆场景。该方法直接使用传入的 Class 对象,而不是通过类名加载,可以避免类名编译后被混淆

InitOptions initOptions = InitOptions.builder()
    .securityStrategy(QLSecurityStrategy.open())
    .addDefaultImport(Arrays.asList(ImportManager.importClsAlias(Aa.class, "User"),
        ImportManager.importClsAlias(Bb.class, "Order")))
    .build();
Express4Runner express4Runner = new Express4Runner(initOptions);

QLResult result = express4Runner
    .execute("user = new User(); user.name = 'jack'; " + "order = new Order(); order.amount = 100; "
        + "user.name + ':' + order.amount", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
assertEquals("jack:100", result.getResult());

自定义 ClassLoader

QLExpress4 支持通过自定义 ClassSupplier 来指定类加载器,这在插件化架构、模块化应用等场景中非常有用。通过自定义类加载器,可以让 QLExpress 脚本访问特定 ClassLoader 中的类。

下面的示例展示了如何与 PF4J 插件框架集成,让 QLExpress 脚本能够访问插件中的类:

// Specify plugin directory (test-plugins directory under test resources)
Path pluginsDir = new File("src/test/resources/test-plugins").toPath();
PluginManager pluginManager = new DefaultPluginManager(pluginsDir);
pluginManager.loadPlugins();
pluginManager.startPlugins();

// Get the PluginClassLoader of the first plugin
PluginWrapper plugin = pluginManager.getPlugins().get(0);
ClassLoader pluginClassLoader = plugin.getPluginClassLoader();

// Custom class supplier using plugin ClassLoader
ClassSupplier pluginClassSupplier = clsName -> {
    try {
        return Class.forName(clsName, true, pluginClassLoader);
    }
    catch (ClassNotFoundException | NoClassDefFoundError e) {
        return null;
    }
};

InitOptions options = InitOptions.builder()
    .securityStrategy(QLSecurityStrategy.open())
    .classSupplier(pluginClassSupplier)
    .build();
Express4Runner runner = new Express4Runner(options);

String script = "import com.alibaba.qlexpress4.pf4j.TestPluginInterface; TestPluginInterface.TEST_CONSTANT";
Object result = runner.execute(script, Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS).getResult();

Assert.assertEquals("Hello from PF4J Plugin!", result.toString());

自定义 ClassSupplier 的典型应用场景:

  • 插件化架构:让脚本能够访问插件中定义的类和接口

  • 模块化应用:在 OSGi 等模块化框架中,让脚本访问特定模块的类

  • 动态类加载:从远程仓库或动态生成的字节码中加载类

  • 类隔离:使用不同的 ClassLoader 来实现类的隔离

表达式缓存

通过 cache 选项可以开启表达式缓存,这样相同的表达式就不会重新编译,能够大大提升性能。

注意该缓存没有限制大小,只适合在表达式为有限数量的情况下使用:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
// open cache switch
express4Runner.execute("1+2", new HashMap<>(), QLOptions.builder().cache(true).build());

但是当脚本首次执行时,因为没有缓存,依旧会比较慢。

可以通过下面的方法在首次执行前就将脚本缓存起来,保证首次执行的速度:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
express4Runner.parseToDefinitionWithCache("a+b");

注意该缓存的大小是无限的,业务上注意控制大小,可以调用 clearCompileCache 方法定期清空编译缓存。

清除 DFA 缓存

QLExpress 使用 ANTLR4 作为解析引擎,ANTLR4 在运行时会构建 DFA (确定有限状态自动机) 缓存来加速后续的语法解析。这个缓存会占用一定的内存空间。

在某些内存敏感的场景下,可以通过调用 clearDFACache 方法来清除 DFA 缓存,释放内存。

重要警告: 清除 DFA 缓存会导致编译性能大幅下降,正常情况下不建议使用此方法。

适用场景

  • 内存敏感型应用: 当内存使用是关键考虑因素,且可以容忍较慢的编译时间时

  • 脚本不经常变更: 当脚本相对稳定且不会频繁重新编译时

最佳实践

在解析并缓存表达式后立即调用此方法,并确保后续所有执行都打开缓存选项以避免重新编译。示例代码如下:

/*
 * When the expression changes, parse it and add it to the expression cache;
 * after parsing is complete, call clearDFACache.
 */
Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
runner.parseToDefinitionWithCache(exampleExpress);
runner.clearDFACache();

/*
 * All subsequent runs of this script must enable the cache option to ensure that re-compilation does not occur.
 */
for (int i = 0; i < 3; i++) {
    runner.execute(exampleExpress, ExpressContext.EMPTY_CONTEXT, QLOptions.builder().cache(true).build());
}

通过这种方式,可以在保证性能的同时,最大限度地降低内存占用。

设置超时时间

可以给脚本设置一个超时时间,防止其中存在死循环或者其他原因导致应用资源被过量消耗。

下面的示例代码给脚本给脚本设置了一个 10ms 的超时时间:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
try {
    express4Runner.execute("while (true) {\n" + "  1+1\n" + "}",
        Collections.emptyMap(),
        QLOptions.builder().timeoutMillis(10L).build());
    fail("should timeout");
}
catch (QLTimeoutException e) {
    assertEquals(QLErrorCodes.SCRIPT_TIME_OUT.name(), e.getErrorCode());
}

注意,出于系统性能的考虑,QLExpress 对于超时时间的检测是不准确的。特别是在回调Java代码中(比如自定义函数或者操作符)出现的超时,不会立刻被检测到。只有在执行完,回到 QLExpress 运行时后才会被检测到并中断执行。

扩展函数

利用 QLExpress 提供的扩展函数能力,可以给Java类中添加额外的成员方法。

扩展函数是基于 QLExpress 运行时实现的,因此仅仅在 QLExpress 脚本中有效。

下面的示例代码给 String 类添加了一个 hello() 扩展函数:

ExtensionFunction helloFunction = new ExtensionFunction() {
    @Override
    public Class<?>[] getParameterTypes() {
        return new Class[0];
    }

    @Override
    public String getName() {
        return "hello";
    }

    @Override
    public Class<?> getDeclaringClass() {
        return String.class;
    }

    @Override
    public Object invoke(Object obj, Object[] args) {
        String originStr = (String)obj;
        return "Hello," + originStr;
    }
};
Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
express4Runner.addExtendFunction(helloFunction);
Object result =
    express4Runner.execute("'jack'.hello()", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS).getResult();
assertEquals("Hello,jack", result);

// simpler way to define extension function
express4Runner.addExtendFunction("add",
    Number.class,
    params -> ((Number)params[0]).intValue() + ((Number)params[1]).intValue());
QLResult resultAdd = express4Runner.execute("1.add(2)", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS);
assertEquals(3, resultAdd.getResult());

Java类的对象,字段和方法别名

QLExpress 支持通过 QLAlias 注解给对象,字段或者方法定义一个或多个别名,方便非技术人员使用表达式定义规则。

下面的例子中,根据用户是否 vip 计算订单最终金额。

用户类定义:

package com.alibaba.qlexpress4.test.qlalias;

import com.alibaba.qlexpress4.annotation.QLAlias;

@QLAlias("用户")
public class User {

    @QLAlias("是vip")
    private boolean vip;

    @QLAlias("用户名")
    private String name;

    public boolean isVip() {
        return vip;
    }

    public void setVip(boolean vip) {
        this.vip = vip;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

订单类定义:

package com.alibaba.qlexpress4.test.qlalias;

import com.alibaba.qlexpress4.annotation.QLAlias;

@QLAlias("订单")
public class Order {

    @QLAlias("订单号")
    private String orderNum;

    @QLAlias("金额")
    private int amount;

    public String getOrderNum() {
        return orderNum;
    }

    public void setOrderNum(String orderNum) {
        this.orderNum = orderNum;
    }

    public int getAmount() {
        return amount;
    }

    public void setAmount(int amount) {
        this.amount = amount;
    }
}

通过 QLExpress 脚本规则计算最终订单金额:

Order order = new Order();
order.setOrderNum("OR123455");
order.setAmount(100);

User user = new User();
user.setName("jack");
user.setVip(true);

// Calculate the Final Order Amount
Express4Runner express4Runner =
    new Express4Runner(InitOptions.builder().securityStrategy(QLSecurityStrategy.open()).build());
Number result = (Number)express4Runner
    .executeWithAliasObjects("用户.是vip? 订单.金额 * 0.8 : 订单.金额", QLOptions.DEFAULT_OPTIONS, order, user)
    .getResult();
assertEquals(80, result.intValue());

关键字,操作符和函数别名

为了进一步方面非技术人员编写规则,QLExpress 提供 addAlias 给原始关键字,操作符和函数增加别名。让整个脚本的表述更加贴近自然语言。

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
// add custom function zero
express4Runner.addFunction("zero", (String ignore) -> 0);

// keyword alias
assertTrue(express4Runner.addAlias("如果", "if"));
assertTrue(express4Runner.addAlias("则", "then"));
assertTrue(express4Runner.addAlias("否则", "else"));
assertTrue(express4Runner.addAlias("返回", "return"));
// operator alias
assertTrue(express4Runner.addAlias("大于", ">"));
// function alias
assertTrue(express4Runner.addAlias("零", "zero"));

Map<String, Object> context = new HashMap<>();
context.put("语文", 90);
context.put("数学", 90);
context.put("英语", 90);

Object result = express4Runner
    .execute("如果 (语文 + 数学 + 英语 大于 270) 则 {返回 1;} 否则 {返回 零();}", context, QLOptions.DEFAULT_OPTIONS)
    .getResult();
assertEquals(0, result);

支持设置别名的关键字有:

  • if

  • then

  • else

  • for

  • while

  • break

  • continue

  • return

  • function

  • macro

  • new

  • null

  • true

  • false

注意:部分大家熟悉的用法其实是操作符,而不是关键字,比如 in 操作符。而所有的操作符和函数默认就是支持别名的

宏是QLExpress中一个强大的代码复用机制,它允许用户定义一段可重用的脚本片段,并在需要时进行调用。与简单的文本替换不同,QLExpress的宏是基于指令回放的机制实现的,具有更好的性能和语义准确性。

宏特别适用于以下场景:

  • 代码复用:将常用的脚本片段封装成宏,避免重复编写相同的逻辑

  • 业务规则模板:定义标准的业务规则模板,如价格计算、权限检查等

  • 流程控制:封装复杂的控制流程,如条件判断、循环逻辑等

  • DSL构建:作为构建领域特定语言的基础组件

宏可以通过两种方式定义:

1. 在脚本中使用 macro 关键字定义

macro add {
  c = a + b;
}

a = 1;
b = 2;
add;
assert(c == 3);

2. 通过Java API添加

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
express4Runner.addMacro("rename", "name='haha-'+name");
Map<String, Object> context = Collections.singletonMap("name", "wuli");
Object result = express4Runner.execute("rename", context, QLOptions.DEFAULT_OPTIONS).getResult();
assertEquals("haha-wuli", result);

// replace macro
express4Runner.addOrReplaceMacro("rename", "name='huhu-'+name");
Object result1 = express4Runner.execute("rename", context, QLOptions.DEFAULT_OPTIONS).getResult();
assertEquals("huhu-wuli", result1);

宏与函数的区别:

特性

函数

参数传递

无参数,依赖上下文变量

支持参数传递

性能

指令直接插入,无调用开销

有函数调用开销

作用域

共享调用者作用域

独立的作用域

适用场景

代码片段复用

逻辑封装和参数化

宏特别适合那些不需要参数传递、主要依赖上下文变量的代码片段复用场景,而函数更适合需要参数化和独立作用域的场景。

QLExpress4 相比 3 版本,宏特性的变化

  • 4 的宏实现更加接近通常编程语言中宏的定义,相当于将预定义的代码片段插入到宏所在的位置,与调用点位于同一作用域,宏中的 return, continebreak 等可以影响调用方的控制流。但是 3 中的实现其实更加接近无参函数调用。

  • 4 的宏无法作为变量使用,只有单独作为一行语句时才能被宏替换。因为宏可以是任意脚本,不一定是有返回值的表达式,作为变量时会存在语义问题。3 的宏本质是一个无参函数调用,所以常常被作为变量使用

如果想兼容 3 中的宏特性,建议使用 动态变量

动态变量

常规的 “静态变量”,是 context 中和 key 关联的固定的值。而动态变量可以是一个表达式,由另外一些变量计算而得。动态变量支持嵌套,即动态变量可以依赖另一个动态变量计算得到。

示例如下:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);

Map<String, Object> staticContext = new HashMap<>();
staticContext.put("语文", 88);
staticContext.put("数学", 99);
staticContext.put("英语", 95);

QLOptions defaultOptions = QLOptions.DEFAULT_OPTIONS;
DynamicVariableContext dynamicContext =
    new DynamicVariableContext(express4Runner, staticContext, defaultOptions);
dynamicContext.put("平均成绩", "(语文+数学+英语)/3.0");
dynamicContext.put("是否优秀", "平均成绩>90");

// dynamic var
assertTrue((Boolean)express4Runner.execute("是否优秀", dynamicContext, defaultOptions).getResult());
assertEquals(94,
    ((Number)express4Runner.execute("平均成绩", dynamicContext, defaultOptions).getResult()).intValue());
// static var
assertEquals(187,
    ((Number)express4Runner.execute("语文+数学", dynamicContext, defaultOptions).getResult()).intValue());

表达式计算追踪

如果打开相关选项,QLExpress4 就会在返回规则脚本计算结果的同时,返回一颗表达式追踪树。表达式追踪树的结构类似语法树,不同之处在于,它会在每个节点上记录本次执行的中间结果。

比如对于表达式 !true || myTest(a, 1),表达式追踪树的结构大概如下:

        || true
       /      \
    ! false  myTest
    /        /   \
 true       a 10     1

可应用于多种场景:

  • 方便业务人员对规则的计算结果进行分析排查

  • 对线上判断为 false 的规则进行采样归类

  • AI 自动诊断和修复规则

节点计算结果会被放置到 ExpressionTrace 对象的 value 字段中。如果中间发生短路导致部分表达式未被计算,则 ExpressionTrace 对象的 evaluated 字段会被设置为 false。代码示例如下:

Express4Runner express4Runner = new Express4Runner(InitOptions.builder().traceExpression(true).build());
express4Runner.addFunction("myTest", (Predicate<Integer>)i -> i > 10);

Map<String, Object> context = new HashMap<>();
context.put("a", true);
QLResult result = express4Runner
    .execute("a && (!myTest(11) || false)", context, QLOptions.builder().traceExpression(true).build());
Assert.assertFalse((Boolean)result.getResult());

List<ExpressionTrace> expressionTraces = result.getExpressionTraces();
Assert.assertEquals(1, expressionTraces.size());
ExpressionTrace expressionTrace = expressionTraces.get(0);
Assert.assertEquals("OPERATOR && false\n" + "  | VARIABLE a true\n" + "  | OPERATOR || false\n"
    + "      | OPERATOR ! false\n" + "          | FUNCTION myTest true\n" + "              | VALUE 11 11\n"
    + "      | VALUE false false\n", expressionTrace.toPrettyString(0));

// short circuit
context.put("a", false);
QLResult resultShortCircuit = express4Runner.execute("(a && true) && (!myTest(11) || false)",
    context,
    QLOptions.builder().traceExpression(true).build());
Assert.assertFalse((Boolean)resultShortCircuit.getResult());
ExpressionTrace expressionTraceShortCircuit = resultShortCircuit.getExpressionTraces().get(0);
Assert.assertEquals(
    "OPERATOR && false\n" + "  | OPERATOR && false\n" + "      | VARIABLE a false\n" + "      | VALUE true \n"
        + "  | OPERATOR || \n" + "      | OPERATOR ! \n" + "          | FUNCTION myTest \n"
        + "              | VALUE 11 \n" + "      | VALUE false \n",
    expressionTraceShortCircuit.toPrettyString(0));
Assert.assertTrue(expressionTraceShortCircuit.getChildren().get(0).isEvaluated());
Assert.assertFalse(expressionTraceShortCircuit.getChildren().get(1).isEvaluated());

// in
QLResult resultIn = express4Runner
    .execute("'ab' in ['cc', 'dd', 'ff']", context, QLOptions.builder().traceExpression(true).build());
Assert.assertFalse((Boolean)resultIn.getResult());
ExpressionTrace expressionTraceIn = resultIn.getExpressionTraces().get(0);
Assert
    .assertEquals(
        "OPERATOR in false\n" + "  | VALUE 'ab' ab\n" + "  | LIST [ [cc, dd, ff]\n" + "      | VALUE 'cc' cc\n"
            + "      | VALUE 'dd' dd\n" + "      | VALUE 'ff' ff\n",
        expressionTraceIn.toPrettyString(0));

注意,必须在新建 Express4Runner 时将 InitOptions.traceExpression 选项设置为 true,同时在执行脚本时将 QLOptions.traceExpression 设置为 true,该功能才能生效。

也可以在不执行脚本的情况下获得所有表达式追踪点:

Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
TracePointTree tracePointTree = express4Runner.getExpressionTracePoints("1+3+5*ab+9").get(0);
Assert.assertEquals("OPERATOR +\n" + "  | OPERATOR +\n" + "      | OPERATOR +\n" + "          | VALUE 1\n"
    + "          | VALUE 3\n" + "      | OPERATOR *\n" + "          | VALUE 5\n" + "          | VARIABLE ab\n"
    + "  | VALUE 9\n", tracePointTree.toPrettyString(0));

支持的表达式追踪点类型以及对应子节点的含义如下:

节点类型

节点含义

子节点含义

OPERATOR

操作符

两侧操作数

FUNCTION

函数

函数参数

METHOD

方法

方法参数

FIELD

字段

取字段的目标对象

LIST

列表

列表元素

MAP

字段

IF

条件分支

condition表达式,then逻辑块和else逻辑块

SWITCH

switch分支

switch表达式,各个case分支条件和代码块,以及default代码块

RETURN

返回语句

返回表达式

VARIABLE

变量

VALUE

字面值

DEFINE_FUNCTION

定义函数

DEFINE_MACRO

定义宏

PRIMARY

暂时未继续下钻的其他复合值(比如字典,if等等)

STATEMENT

暂未继续下钻的其他复合语句(比如 while, for 等等)

与 Spring 集成

QLExpress 并不需要专门与 Spring 集成,只需要一个 Express4Runner 单例,即可使用。

这里提供的 “集成” 示例,可以在 QLExpress 脚本中直接引用任意 Spring Bean。

这种方式虽然很方便,但是脚本权限过大,自由度太高。不再推荐使用,还是建议在 context 只放入允许用户访问的对象。

核心集成组件:

  • QLSpringContext: 实现了 ExpressContext 接口,提供了对 Spring 容器的访问能力。它会优先从传入的 context 中查找变量,如果找不到则尝试从 Spring 容器中获取同名的 Bean。

  • QLExecuteService: 封装了 QLExpress 的执行逻辑,集成了 Spring 容器,方便在 Spring 应用中使用。

假设存在一个 Spring Bean, 名为 helloService

package com.alibaba.qlexpress4.spring;

import org.springframework.stereotype.Service;

/**
 * Spring Bean example service class
 */
@Service
public class HelloService {

    /**
     * Hello method that returns a greeting string
     * @return greeting string
     */
    public String hello(String name) {
        return "Hello, " + name + "!";
    }
}

在脚本中调用该 Bean:

package com.alibaba.qlexpress4.spring;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import java.util.HashMap;
import java.util.Map;

/**
 * HelloService unit test class
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = SpringTestConfig.class)
public class SpringDemoTest {

    @Autowired
    private QLExecuteService qlExecuteService;

    @Test
    public void qlExecuteWithSpringContextTest() {
        Map<String, Object> context = new HashMap<>();
        context.put("name", "Wang");
        String result = (String)qlExecuteService.execute("helloService.hello(name)", context);
        Assert.assertEquals("Hello, Wang!", result);
    }
}