稍微复杂一点的业务,验证时都不是"调一个接口":注册、进件、下单、查单,后一步要用前一步返回的客户号;中间某个分支失败,还要知道到底断在哪。
这篇讲一个「场景编排」功能是怎么做出来的:用一张有向图描述执行顺序,后端按依赖调度,节点之间用变量传值。尽量不假设你熟悉图论或工作流引擎,先把概念讲清楚,再讲实现和坑。
0. 先说结论
| 要解决的问题 | 做法 |
|---|---|
| 多个请求按顺序执行 | 用「节点 + 连线」描述依赖,本质是一张有向无环图(DAG) |
| 有分支、有并行 | 连线可以带条件,节点可以标记为异步旁路 |
| 上一步的结果传给下一步 | 用 ${节点ID:$.字段} 引用上游输出 |
| 某一步失败要能定位 | 首个失败立即中止,未执行的节点标「跳过」,整体判失败 |
| 一条链路太长要复用 | 支持「子场景」,像函数调用一样嵌套 |
如果你只想记一句话:场景编排 = 一张会按依赖自动往下走的流程图,节点之间靠变量传递数据。
1. 从一次联调说起
假设要验「下单」这条链路:
- 调进件接口,拿到
custNo; - 用
custNo调下单接口; - 等两秒,调查单接口确认状态;
- 最后检查订单金额是不是 100。
只用单接口用例,会碰到三个很实际的问题。
数据要手抄。 第 1 步返回的 custNo 是一串数字,复制到第 2 步的参数里。抄错一位,下单会报"客户不存在",然后花半小时怀疑下游服务挂了。
顺序靠人记。 先跑哪个、后跑哪个、中间等多久,这些知识只在某个人的操作习惯里。换个人接手,就得重新问一遍。
失败现场留不住。 第 3 步失败了,但前两步的响应没有保存。只能去翻日志,还不一定翻得到。
场景编排就是把这三件事固化下来:顺序写进图里,取值写成引用,每一步的输入输出自动留档。
2. 三个基础概念:节点、边、DAG
2.1 节点和边
把每个请求看成一个节点 ,把"先做 A 再做 B"看成一条边。
css
[进件] ──► [下单] ──► [查单] ──► [断言金额]
这就是一张图。节点负责"做什么",边负责"先后关系"。
2.2 什么是有向无环图
- 有向:边有方向,A → B 表示 A 先于 B;
- 无环:沿着箭头走,永远回不到起点。
为什么必须无环?因为执行顺序要能排出来。如果 A 等 B、B 又等 A,那谁也没法先跑------程序会卡死,逻辑上也没有意义。
这种"有方向、没有环"的图,英文叫 DAG(Directed Acyclic Graph,有向无环图)。构建工具、任务调度、电子表格的公式依赖、Git 的提交历史,底层都是它。
2.3 拓扑排序:给节点排个序
有了 DAG,还需要回答"先跑谁"。拓扑排序就是找出一个线性顺序,保证每个节点都排在它的所有前驱之后。
最直观的算法叫 Kahn 算法,可以理解为「剥洋葱」:
- 找出所有没有前驱的节点(入度为 0),先执行;
- 执行完一个,就把它的出边删掉;
- 删完之后又出现新的入度为 0 的节点,继续;
- 如果最后还有节点没被剥掉,说明图里有环。
平台在保存场景时就会跑一遍这个检查,发现环直接拒绝保存。错误要在保存时暴露,不要等到执行时才发现。
3. 数据长什么样:一张图,不是一组步骤
场景存成一个 JSON,包含 nodes(节点)和 edges(边):
json
{
"nodes": [
{
"id": "n1",
"type": "httpCase",
"data": { "label": "进件", "config": { "mode": "case", "caseId": 101 } }
},
{
"id": "n2",
"type": "dubbo",
"data": {
"label": "下单",
"config": {
"mode": "direct",
"direct": {
"interfaceName": "com.example.OrderService",
"methodName": "create",
"parameterTypes": ["java.lang.String", "java.math.BigDecimal"],
"parameterValues": ["${n1:$.data.custNo}", 100]
}
}
}
}
],
"edges": [
{ "source": "n1", "target": "n2", "data": { "condition": "input.httpStatus == 200" } }
]
}
节点类型是固定的几种:
| 类型 | 作用 |
|---|---|
start / end |
起点/终点,可选,最多各一个 |
httpCase |
调 HTTP 接口 |
dubbo |
调 Dubbo 接口 |
mqCase |
发 MQ 消息并校验 |
uiCase |
跑一条 UI 回放用例 |
script |
执行一段 Groovy 脚本 |
subScene |
调用另一个场景 |
httpCase / mqCase / dubbo 都有两种模式:
case:复用已经保存的用例,改参数要回用例里改;direct:参数直接写在节点里,适合这条链路专用的临时请求。
为什么不用「步骤数组」
用 steps: [A, B, C] 也能表达顺序,但表达不了分支和并行。真实链路里「风控通过才下单」「短信和邮件同时发」很常见。一旦要支持分支,数组方案就得发明 if、goto、parallel 之类的新语法,最后还是会变成一张图------不如一开始就用图。
4. 一次执行是怎么跑起来的
4.1 启动前:校验 + 编译
点击「运行」后,后端先做一次结构校验:
- 节点
id不能为空、不能重复; - 类型必须在白名单里;
- 每条边的两端必须存在,不能自己连自己;
start不能有上游,end不能有下游;- 用拓扑排序检查有没有环。
校验通过后,把 JSON「编译」成内存里的节点表,并提前算好每个节点的全部祖先集合。这一步是为了后面做引用检查:一个节点只能引用它的上游。
4.2 变量:运行前填写的参数
场景可以定义变量:
json
[
{ "name": "uid", "type": "string", "required": true },
{ "name": "amount", "type": "number", "defaultValue": 100 },
{ "name": "token", "type": "string", "secret": true }
]
启动时把用户填的值和默认值合并,按类型转成字符串/数字/布尔/JSON。标记为 secret 的变量只存在于本次执行的内存里,落库的运行记录写 ******。
4.3 调度:什么时候轮到某个节点
这是整个引擎最核心、也最容易被误解的地方。
一个节点要开始执行,需要同时满足两个条件:
- 所有「阻塞型前驱」都已经结束(成功、失败、跳过都算结束);
- 至少有一条入边被「激活」。
什么叫激活?如果边上没写条件,前驱成功就算激活;如果写了条件,就执行这段条件表达式,结果为 true 才算激活。
java
boolean hasBlocking = false;
boolean activated = false;
for (String p : node.predecessors) {
NodeExecution pn = st.nodes.get(p);
if (pn == null || pn.asyncNode) continue; // 异步旁路不参与阻塞
hasBlocking = true;
if ("SUCCESS".equals(pn.status) && edgeActivated(st, p, node, pn)) {
activated = true;
break; // 任意一条满足即可
}
}
if (!hasBlocking || activated) startNode(st, node);
else finishNode(st, node, "SKIPPED", null, "前置条件未满足", null);
注意这里的语义:多个前驱之间是「或」,不是「与」。 只要有一条入边被激活,节点就会执行。
这样做的好处是表达灵活:多个分支汇合时,任意一条走通就能继续。代价是它不保证「A 和 B 都成功」。如果你的场景要求两个上游都完成,需要额外加一个汇聚节点,在节点里显式检查 outputs。
4.4 异步旁路:不挡路,但也不放过
节点可以标记 async: true,意思是「启动后立刻放行下游,不要等我」。
适合做什么?比如主流程继续跑,同时异步发一条通知、打一条埋点。下游不需要等它。
但要注意两件事:
- 异步节点的结果不参与下游的激活判断;
- 场景结束前仍然会等它收敛。它失败了,整个场景也会失败。
也就是说,async 只改变「是否阻塞下游」,不改变「失败是否传播」。
4.5 延迟与超时
- 延迟:节点执行前等待一段时间,用于等异步落库、等缓存过期;
- 超时:每个节点有硬超时,默认 120 秒,子场景默认 600 秒。
超时的实现是给节点任务加一个定时器,到点后把结果标成 FAILED,并调用 Future.cancel(true) 发中断信号。
这里有一个必须说清楚的边界:中断不等于强制停止。 如果节点正阻塞在 socket 读、数据库查询上,中断信号未必能让它立刻退出。所以节点结果会先落成失败,任务本身可能还会在后台跑一小会儿。节点终止入口做了幂等保护,后到的结果不会覆盖先到的终态。
4.6 延迟为什么要切片
java
long remain = Math.min(node.delayMs, MAX_DELAY_MS);
while (remain > 0) {
if (stoppedOrAborted(st)) return false;
long slice = Math.min(200L, remain);
Thread.sleep(slice);
remain -= slice;
}
如果直接 Thread.sleep(60_000),用户点停止后要等一分钟才有反应。切成 200ms 一片,每一片都检查一次停止标志,最坏 200ms 就能退出。
5. 数据怎么在节点之间流动
5.1 四种引用写法
| 写法 | 含义 |
|---|---|
${inputs.uid} |
场景变量 |
${vars.uid} |
同上,给 Groovy 脚本习惯用 |
${n1:$.data.custNo} |
取 n1 节点输出里的 custNo |
${nodes.n1.data.custNo} |
同上,简化写法 |
$.data.custNo 是 JSONPath ,一种在 JSON 里定位字段的表达式。可以把它理解成「JSON 版的 XPath」:$ 表示根,.data 取字段,[0] 取数组第一个元素。
5.2 只允许引用上游
引用检查用的是编译阶段算好的祖先集合:
java
Set<String> ups = st.upstreams.getOrDefault(node.nodeId, Collections.emptySet());
if (!ups.contains(refId)) {
throw new SceneNodeFailedException("节点 " + node.labelOrId() + " 引用了非上游节点: " + refId);
}
这条限制很重要。没有它,A 可以引用 B、B 也可以引用 A,运行时只能拿到 null,最后报一个和根因无关的错误。加上它之后,数据流也变成了一张 DAG,不存在循环依赖。
5.3 缺变量直接报错,不静默变空串
${inputs.uid} 在变量表里不存在时,直接抛「场景变量不存在」。
为什么不默认成空串?因为空串是一个"看起来合法"的参数。下游会带着空 uid 继续跑,最后报一个业务错误,排查的人根本想不到是变量名写错了。早失败、报得准,比"能跑完"重要。
5.4 标量和 JSON 的嵌入方式
替换结果不是无脑转字符串:
- 字符串原样嵌入;
- 数字、布尔直接转文本;
- 对象、数组转成紧凑 JSON。
这样 ${n1:$.data} 放进请求体时得到的是合法 JSON,而不是 Java 的 {a=1} 风格字符串。
6. 断言和失败传播
6.1 什么是断言
断言就是「我期望这个值满足某个条件」。比如:
- HTTP 状态码等于 200;
- 返回 JSON 里
$.code等于0; - 数组长度等于 3;
- 异常类型是某个业务异常。
每条断言执行后都会留下结果:期望值、实际值、是否通过、失败信息。
6.2 失败之后发生什么
节点执行完,先把断言结果挂到节点上,再统一检查:
java
node.result.setAssertions(resp.getAssertionResults());
node.result.setAssertionPassed(hasAssertions(...) ? resp.isAssertionPassed() : null);
checkAssertions(resp.getAssertionResults(), resp.isAssertionPassed());
只要有一条断言没过,就抛异常,节点标记为 FAILED。然后:
- 第一个失败的节点抢占「中止权」;
- 所有还在
PENDING的节点立即标SKIPPED; - 已经在跑的节点不强行杀,等它自然结束;
- 所有节点结束后,统一计算整次运行的状态。
6.3 为什么「跳过」也算失败
如果只是某个分支条件没满足,节点被跳过,看起来不算错。但整次运行仍然判 FAILED,原因是:你无法区分「设计上就该跳过」和「因为上游挂了所以没跑」。
把跳过算失败,可以保证"绿色"只代表一种含义:所有节点都按预期执行并通过了断言。宁可多一次人工确认,也不要让一条链路绿得可疑。
6.4 为什么失败原因要延迟写
失败原因先写进内部字段 failureError,等所有节点都终止后才落到对外的 error。
因为前端是轮询的。如果提前把运行状态置成 FAILED,前端会在"还有节点在跑"的中间态就停止刷新,用户看到的是一个不完整的结果页。
7. 子场景:把一条链路当成一个节点
subScene 节点允许调用另一个场景。可以把它理解成编程里的函数调用:
- 父场景传变量进去;
- 子场景完整执行自己的图;
- 子场景结束后,把输出返回给父节点;
- 父节点根据成功/失败决定自己是否失败。
几个约束:
- 同步:父节点会等子场景跑完;
- 最多 3 层:防止无限嵌套;
- 调用链检测循环:A 调 B、B 又调 A,保存时就报错;
- 变量是副本 :子场景里修改
vars,父场景看不到,需要的数据通过输出显式返回; - 不单独落运行记录:子场景属于父节点的一次执行,避免运行记录爆炸。
还有一个实现上的细节:子场景用独立的线程池。父节点占着主线程池的线程等子场景,如果子场景也用同一个池,几层嵌套就能把池子占满,互相等待到死锁。
8. 运行状态、停止和持久化
8.1 运行中读内存,结束后读数据库
执行中的状态放在内存的 ConcurrentHashMap 里,查询直接读内存;执行结束后写进数据库,查询读快照。
为什么不一直读数据库?因为节点状态变化很频繁,每个节点开始、结束、断言结果都要更新,实时写库既慢又没必要。运行结束后做一次完整快照就够。
8.2 停止是「协作式」的
用户点停止后,引擎不会强行杀掉线程,而是:
- 置一个停止标志;
- 所有还没开始的节点标
SKIPPED; - 已经在跑的节点继续跑完;
- 延迟中的节点每 200ms 检查一次,尽快退出。
为什么不强杀?因为一个正在写数据库的节点被突然中断,可能留下半条数据。当前的选择是「不制造脏数据优先」,长耗时节点靠超时兜底。
8.3 增量落库只写终态
每有一个节点结束,就把结果写进数据库,但只写已经到达终态的节点。PENDING 和 RUNNING 不落库,避免把中间态当成结果。
9. 常见误区
- 以为多前驱是「并且」。 默认是「或」,需要全部成功就自己加汇聚节点。
- 以为
async失败不影响主流程。 它不阻塞下游,但失败仍会让整个场景失败。 - 以为边条件写错会报错。 条件抛异常、返回非布尔值,一律按「未激活」处理,会静默走另一条分支。
- 以为超时一定能停住任务。 它只是发中断信号,阻塞 IO 未必响应。
- 以为停止会中断正在跑的节点。 不会,只是不再启动新节点。
- 以为可以引用任意节点的结果。 只能引用祖先节点,兄弟分支要先汇聚。
- 以为子场景改的变量会回传。 不会,变量是副本。
10. 小结
场景编排看起来是"画流程图",真正需要设计清楚的是四件事:
- 顺序:用 DAG 表达依赖,保存时用拓扑排序把环挡掉;
- 取值 :用
${节点:$.字段}引用上游输出,用祖先集合阻止前向引用; - 失败:首个失败即中止,跳过也算失败,结果要能定位到具体节点和断言;
- 生命周期:延迟切片、超时中断、协作式停止、终态增量落库。
这四件事只要有一样含糊,联调现场就会变成猜谜。工具的价值不在于能画多漂亮的图,而在于同一条链路,换个人、换个时间跑,结果和失败原因都是一样的。