返回 AI编程

QLExpress 表达式执行归因可视化:从 ExpressionTrace 到可折叠计算树

QLExpress 表达式执行归因可视化:从 ExpressionTrace 到可折叠计算树

在规则引擎场景里,表达式最终返回 truefalse 往往还不够。业务人员、测试人员和规则维护者更关心的是:结果为什么是这样?是哪一个变量、哪一次函数调用、哪一个子表达式把最终结果推到了当前状态?

例如表达式:

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 | 节点类型,例如 OPERATORVARIABLEFUNCTION。 |
| token | 原始 token,例如 +Ascore。 |
| 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 的关键信息:

  • type
  • token
  • value
  • evaluated
  • line
  • col
  • position
  • children

同时额外生成两个更适合前端展示的字段:

| 字段 | 说明 |
| --- | --- |
| label | 完整表达式,例如 A + (B + C)score(currentAmount, currentDays)。 |
| displayLabel | 树节点短标题,例如 +&&score(...)。 |

为什么要区分 labeldisplayLabel

长表达式如果直接展示在树节点上,会让页面变成文字墙。更好的交互是:树节点只显示短标题和值,完整表达式放在节点详情里。比如复杂运算节点在树上只显示:

+    值: 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

交互设计:可折叠树 + 右侧详情浮层

前端展示时采用几个策略:

  • 默认全折叠,避免复杂表达式一打开就铺满页面。
  • 节点短展示,只显示 displayLabeltype 和当前值。
  • 点击节点后,在浏览器右侧展示详情浮层。
  • 详情浮层展示 labelvaluetypetoken、源码位置和执行状态。
  • 未执行节点用虚线和灰色展示,并显示 未执行

节点靠右展示详情,而不是直接盖在节点上,是为了避免遮挡当前节点信息。对于很深的树,用户点击哪一层,浮层就更新为哪一层的详情,同时不会破坏左侧树的阅读路径。

关键场景一:短路表达式

短路表达式是归因里非常重要的一类情况。例如:

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 可以复用,每次只更新数据文件。
  • 支持变量、运算符、自定义函数、短路表达式和复杂长表达式。

最终,使用者不再只看到表达式的最终结果,而是能看到结果是如何一步一步计算出来的。这对规则测试、线上问题排查和规则归因分析都很有价值。