QLExpress 表达式执行归因可视化:从 ExpressionTrace 到可折叠计算树
QLExpress 表达式执行归因可视化:从 ExpressionTrace 到可折叠计算树
在规则引擎场景里,表达式最终返回 true 或 false 往往还不够。业务人员、测试人员和规则维护者更关心的是:结果为什么是这样?是哪一个变量、哪一次函数调用、哪一个子表达式把最终结果推到了当前状态?
例如表达式:
A + (B + C)
如果最终结果是 60,我们不仅希望知道最终值,还希望看到:
A = 10
B = 20
C = 30
B + C = 50
A + (B + C) = 60
这类能力可以称为表达式执行归因。本文介绍一个基于 QLExpress4 ExpressionTrace 的可视化方案:后端把表达式执行 trace 转换成通用 JSON,前端固定页面自动加载 JSON 并渲染成可折叠计算树。
背景:为什么需要表达式归因
在真实业务中,规则通常不是简单的四则运算,而是包含大量变量、函数调用、短路逻辑和阈值判断的组合表达式,例如:
((basePrice + shippingFee - coupon) * quantity + serviceFee + tax) > budget
&& (vipScore + repeatOrders * 2 + loyaltyBonus) >= minScore
当这条规则返回 false 时,如果只看到最终结果,排查过程会很慢:
- 是价格侧超过预算了吗?
- 是会员分不足吗?
- 是某个变量取值异常吗?
- 是某个自定义函数返回了意料之外的结果吗?
- 是短路导致后半段根本没有执行吗?
表达式归因可视化要解决的就是这个问题:把一次执行中的每个关键节点值展示出来,让使用者能沿着表达式树快速定位结果来源。
QLExpress 已有的基础能力:ExpressionTrace
QLExpress4 已经提供了表达式追踪能力。开启 trace 后,执行结果 QLResult 中可以拿到:
List<ExpressionTrace> traces = result.getExpressionTraces();
ExpressionTrace 本质是一棵树。每个节点记录了该节点在本次执行中的状态:
public class ExpressionTrace {
private final TraceType type;
private final String token;
private Object value;
private boolean evaluated;
private final List<ExpressionTrace> children;
private final int line;
private final int col;
private final int position;
}
几个关键字段含义如下:
| 字段 | 说明 |
| --- | --- |
| type | 节点类型,例如 OPERATOR、VARIABLE、FUNCTION。 |
| token | 原始 token,例如 +、A、score。 |
| value | 当前节点本次执行得到的值。 |
| evaluated | 当前节点本次是否实际执行。短路未执行时为 false。 |
| children | 子表达式节点。 |
| line / col / position | 当前节点在源码表达式中的位置。 |
以 A + (B + C) 为例,trace 结构可以理解为:
OPERATOR + value=60
VARIABLE A value=10
OPERATOR + value=50
VARIABLE B value=20
VARIABLE C value=30
这已经具备了可视化所需的核心信息。剩下的问题是:如何把 Java 对象转换成前端可稳定消费的数据协议,并用页面清晰展示出来。
总体方案:后端生成 JSON,前端固定渲染
本方案采用前后端解耦的方式:
QLExpress 执行表达式
-> QLResult
-> List<ExpressionTrace>
-> trace-data.json
-> 固定 index.html / app.js
-> 可折叠表达式树
设计重点有三个:
- 后端只负责执行表达式和生成 JSON。
- 前端
index.html/app.js是固定资源,不随每次执行变化。 - 每次执行后只需要刷新
trace-data.json,前端刷新页面即可展示新的可视化结果。
当前 Demo 运行后会生成:
target/expression-trace-demo/index.html
target/expression-trace-demo/app.js
target/expression-trace-demo/trace-data.json
target/expression-trace-demo/trace-data.js
其中:
index.html:固定 HTML 页面。app.js:固定前端渲染逻辑。trace-data.json:每次执行动态生成的数据协议。trace-data.js:为了本地file://打开页面的兼容包装,真实 Web 服务场景可以不需要。
后端:从 ExpressionTrace 转换出前端 JSON
Demo 中新增了一个转换器:
src/test/java/com/alibaba/qlexpress4/docs/ExpressionTraceVisualizationJson.java
它的职责很单一:
List<ExpressionTrace> -> List<Node>
核心调用方式:
List<ExpressionTraceVisualizationJson.Node> roots =
ExpressionTraceVisualizationJson.from(result.getExpressionTraces());
转换时会保留原始 trace 的关键信息:
typetokenvalueevaluatedlinecolpositionchildren
同时额外生成两个更适合前端展示的字段:
| 字段 | 说明 |
| --- | --- |
| label | 完整表达式,例如 A + (B + C)、score(currentAmount, currentDays)。 |
| displayLabel | 树节点短标题,例如 +、&&、score(...)。 |
为什么要区分 label 和 displayLabel?
长表达式如果直接展示在树节点上,会让页面变成文字墙。更好的交互是:树节点只显示短标题和值,完整表达式放在节点详情里。比如复杂运算节点在树上只显示:
+ 值: 360
点击后详情里再展示:
完整表达式:
((((basePrice + shippingFee) - coupon) * quantity) + serviceFee) + tax
这样既保留了完整归因信息,又不会牺牲树结构的可读性。
JSON 协议设计
trace-data.json 顶层是 case 数组。每个 case 表示一次表达式执行结果:
{
"name": "嵌套算术表达式",
"script": "A + (B + C)",
"context": {
"A": 10,
"B": 20,
"C": 30
},
"result": 60,
"roots": [
{
"id": 1,
"label": "A + (B + C)",
"displayLabel": "+",
"type": "OPERATOR",
"token": "+",
"value": 60,
"evaluated": true,
"line": 1,
"col": 2,
"position": 2,
"children": [
{
"id": 2,
"label": "A",
"displayLabel": "A",
"type": "VARIABLE",
"token": "A",
"value": 10,
"evaluated": true,
"children": []
}
]
}
]
}
前端只需要递归渲染 roots[].children,并在节点点击时展示当前节点的详情。
前端:固定 app.js 自动加载 JSON
前端资源放在:
src/test/resources/expression-trace-demo/index.html
src/test/resources/expression-trace-demo/app.js
Demo 执行时,Java 代码只负责把这两个固定文件复制到:
target/expression-trace-demo/
app.js 的加载策略是:
if (window.TRACE_DEMO_DATA) {
render(window.TRACE_DEMO_DATA);
return;
}
fetch('./trace-data.json')
.then(response => response.json())
.then(render);
为什么要优先读 window.TRACE_DEMO_DATA?
因为用户直接用 file:// 打开本地 HTML 时,部分浏览器会阻止 fetch('./trace-data.json')。为了让本地 Demo 不需要启动 Web 服务,测试会额外生成 trace-data.js:
window.TRACE_DEMO_DATA = [...]
真实业务系统里建议直接提供 HTTP JSON 接口即可,不需要 trace-data.js。
交互设计:可折叠树 + 右侧详情浮层
前端展示时采用几个策略:
- 默认全折叠,避免复杂表达式一打开就铺满页面。
- 节点短展示,只显示
displayLabel、type和当前值。 - 点击节点后,在浏览器右侧展示详情浮层。
- 详情浮层展示
label、value、type、token、源码位置和执行状态。 - 未执行节点用虚线和灰色展示,并显示
未执行。
节点靠右展示详情,而不是直接盖在节点上,是为了避免遮挡当前节点信息。对于很深的树,用户点击哪一层,浮层就更新为哪一层的详情,同时不会破坏左侧树的阅读路径。
关键场景一:短路表达式
短路表达式是归因里非常重要的一类情况。例如:
flag && expensive(A)
如果 flag=false,那么 expensive(A) 不会执行。trace 中会保留这个节点,但标记:
{
"label": "expensive(A)",
"displayLabel": "expensive(...)",
"type": "FUNCTION",
"token": "expensive",
"evaluated": false
}
这对测试和排查很重要。它能明确告诉用户:这个函数不是返回了某个值,而是根本没有执行。
关键场景二:自定义函数多次调用
另一个常见问题是同一个自定义函数在表达式中出现多次:
score(currentAmount, currentDays)
+ score(historyAmount, historyDays)
+ score(manualAmount, manualDays)
>= riskLimit
可视化结果中,每一次函数调用都是独立节点:
score(currentAmount, currentDays) = 230
score(historyAmount, historyDays) = 125
score(manualAmount, manualDays) = 120
这可以帮助用户定位到底是哪一次函数调用贡献了异常值,而不是只看到一个模糊的 score 函数名。
如何运行 Demo
在项目根目录执行:
mvn -Dtest=ExpressionTraceVisualizationDemoTest test
打开页面:
open target/expression-trace-demo/index.html
如果页面已经打开,重新执行 Maven 命令后刷新页面即可。
如何接入业务系统
推荐业务系统按下面的方式接入:
1. 后端执行表达式,并开启 traceExpression
2. 后端拿到 QLResult#getExpressionTraces()
3. 后端转换成 trace-data.json 同构结构
4. 前端加载 JSON,递归渲染 roots[].children
5. 点击节点展示 label/value/type/token/evaluated/position
后端接口可以设计成:
GET /api/expression-trace/{executionId}
返回结构可以是一个 case:
{
"name": "规则执行结果",
"script": "...",
"context": {},
"result": true,
"roots": []
}
也可以是多个 case 的数组,用于一次页面展示多条规则或多个测试样例。
方案边界
当前 Demo 是静态可视化方案,不引入 Web 服务,也不改变 QLExpress 的主运行时 API。
它适合验证以下事情:
ExpressionTrace能否表达每个步骤的执行结果。- 长表达式是否可以通过树形结构清晰阅读。
- 短路节点是否能被保留并标记未执行。
- 自定义函数多次调用是否能独立归因。
- 前后端是否可以通过 JSON 协议解耦。
如果后续要产品化,可以继续演进为:
- 把转换器移动到正式模块。
- 暴露稳定的 Java API,例如
ExpressionTraceVisualizer.toModel(...)。 - 前端组件化,提供 React / Vue / 原生 JS 版本。
- 支持在线输入表达式并实时执行。
- 支持节点搜索、路径高亮、异常节点标记和多次执行结果对比。
总结
QLExpress4 的 ExpressionTrace 已经提供了表达式执行归因所需的底层数据。本文方案做的是把这棵 trace 树转换成一个前端友好的 JSON 协议,再用固定前端资源渲染成可折叠表达式树。
这个设计有几个好处:
- 不改变 QLExpress jar 形态。
- 不引入 Web 服务。
- 前后端通过 JSON 解耦。
- 固定 HTML/JS 可以复用,每次只更新数据文件。
- 支持变量、运算符、自定义函数、短路表达式和复杂长表达式。
最终,使用者不再只看到表达式的最终结果,而是能看到结果是如何一步一步计算出来的。这对规则测试、线上问题排查和规则归因分析都很有价值。