这篇要解决什么问题
我熟悉 TypeScript、组件状态和异步调用,但第一次看 Flow Graph 时,容易被 DAG、typed port、拓扑排序、barrier 等术语同时淹没。
这篇只解决一个小问题:如何把一组有类型的 TypeScript 函数,组织成一张能在运行前校验、按依赖顺序执行、并留下 trace 的图。
我们用一个虚构的工业视觉流程贯穿全文:
1 | 启动 -> 模拟采集图像 -> 模拟缺陷检测 -> 计算调整量 -> 安全检查 -> 模拟执行 |
所有数据都是固定 fixture,不连接相机、PLC 或机器人。最终产物是一个最小批式 DAG 执行器,不是假装已经可用于生产的设备控制系统。
1. 先用熟悉的 TypeScript 理解端口
先不谈图。下面是普通函数调用:
1 | function detect(frame: ImageFrame): DetectionResult { |
可以把 Flow Graph 中的概念理解成:
| Flow Graph | 熟悉的 TypeScript 概念 |
|---|---|
| node(节点) | 函数 |
| input port(输入端口) | 有类型的函数参数 |
| output port(输出端口) | 有类型的返回值 |
| edge(边) | 把一个函数的返回值传给另一个函数 |
如果 capture 输出 ImageFrame,detect 输入也要求 ImageFrame,这条边可以连接。若把 DetectionResult 接到要求 trigger 的端口,就像把对象传给只接收布尔信号的函数,应该在运行前报错。
图比手写函数调用多做四件事:
- 校验:端口、类型和必填输入是否正确。
- 调度:根据依赖计算执行顺序。
- 观察:记录哪个节点成功、失败和耗时。
- 持久化:把节点和边保存成 JSON,而不是把流程写死在调用栈中。
2. DAG、数据流图和状态机不是一回事
“都是方框和连线”不代表运行语义相同。
| 模型 | 边表示什么 | 业务数据在哪里 | 是否允许环 |
|---|---|---|---|
| DAG 工作流 | 先做 A,再做 B | 常放在共享 context | 否 |
| Typed dataflow | A 的某个输出端口给 B 的某个输入端口 | 沿有类型的边传递 | 顶层通常不允许 |
| 状态机 | 满足条件后从状态 A 转到状态 B | 状态对象 | 通常允许 |
本文做的是 typed dataflow(有类型的数据流图),同时把顶层限制为 DAG(Directed Acyclic Graph,有向无环图)。
判断一张图是否真正表达了 typed dataflow,可以问:
- 边是否精确连接到端口,而不只是连接节点?
- 端口是否声明数据类型?
- 下游是否直接消费上游产物,而不是从全局对象里猜变量名?
- 图加载时能否发现类型错误和漏接输入?
3. 本文要实现的工业视觉流程
1 | flowchart LR |
示例数据固定为:
- 图像编号:
frame-001。 - 缺陷分数:
0.92。 - 建议调整量:
0.6 mm。 - 允许的最大调整量:
2 mm。
这里的 safety check 只是解释数据流和 fail-closed(条件不满足就拒绝继续)的最小示例,不代表真实机器人系统只需要比较一个数值。
4. 初始化实验目录
以下代码按 Node.js 24 编写,利用其直接运行可擦除 TypeScript 类型的能力:
1 | mkdir flow-graph-lab |
创建 tsconfig.json:
1 | { |
相对 import 显式带 .ts 后缀,既能通过上述配置检查,也能由 Node 24 直接执行。
5. 定义图、节点、端口和边
新建 src/flow/types.ts:
1 | export type PortDataType = |
这里刻意没有 any。示例规模小,先要求类型完全相等,能更清楚地观察类型校验的价值。
RunContext 只保存运行控制和日志能力,不用来传 ImageFrame 等业务数据。业务数据沿边传递,才能看清来源和去向。
6. 用注册表统一节点契约
图实例只保存节点 ID、节点类型和配置。某种节点有哪些端口、如何运行,由注册表统一定义。
新建 src/flow/registry.ts:
1 | import type { NodeDefinition } from "./types.ts"; |
再新建 src/flow/example-nodes.ts。这些节点全部使用固定数据,只用于模拟:
1 | import { NodeRegistry } from "./registry.ts"; |
注册表是契约的单一真源:校验器读取端口,执行器读取 runner,将来编辑器也可以据此生成节点面板。
7. 在运行前校验图
可靠 Flow Graph 的第一价值不是拖线,而是把错误从“设备运行到一半”提前为“图加载失败”。
1 | flowchart LR |
新建 src/flow/validate.ts:
1 | import type { NodeRegistry } from "./registry.ts"; |
前端可以在拖线时立即检查类型,改善体验;运行端仍必须重新校验,因为 JSON 也可能来自文件、CLI 或旧版本客户端。
8. 编译为静态执行计划
图只在加载时变化,执行器不必每次都扫描所有边和重复计算顺序。编译阶段完成两件事:
- 用 Kahn 算法得到稳定的拓扑顺序。
- 把每个节点的入边预先绑定好。
1 | flowchart LR |
新建 src/flow/compile.ts:
1 | import type { NodeRegistry } from "./registry.ts"; |
“编辑是 Flow,执行是静态计划”并不矛盾。Flow 是用户看见和保存的语义;静态计划是运行端为了确定性和效率生成的内部结构。
9. 实现确定性执行器
执行器按计划依次运行节点,把上游输出放入目标输入端口,并记录最小 trace。
新建 src/flow/executor.ts:
1 | import type { ExecutionPlan } from "./compile.ts"; |
这个版本刻意顺序执行。先把输入收集、错误传播和结果可重复做正确,再讨论并行与流式,否则很难判断错误来自基础语义还是并发调度。
10. 运行完整示例
新建 src/main.ts:
1 | import { compileGraph } from "./flow/compile.ts"; |
运行:
1 | node src/main.ts |
输出重点如下:
1 | simulation only: no hardware command sent { frameId: 'frame-001', offsetMm: 0.6 } |
11. 用测试锁定行为
新建 src/flow/flow.test.ts:
1 | import assert from "node:assert/strict"; |
运行类型检查和测试:
1 | npx tsc |
五个测试分别证明:正常链路顺序稳定、类型错误会被拒绝、必填输入不能漏接、端口名不能写错、顶层图不能有环。
12. 当前版本没有解决什么
这个执行器已经形成 schema -> validate -> compile -> execute -> trace 闭环,但它仍然只是最小批式 DAG:
- 没有 branch 的
skipped语义。 - 没有并行执行和 fan-in barrier 的运行时状态。
- 没有多消息流、有界队列和 backpressure(背压)。
- 没有 timeout、retry 和真实 I/O 的取消传播。
- 没有运行时 payload schema,只校验端口声明的类型名称。
- 没有任何真实设备能力。
这些不是“少写几段代码”的问题,而是需要先定义清楚运行语义。下篇会逐项拆开。
验收与下一步
完成本文后,应能独立回答:
- typed edge 相比共享黑板解决了什么问题?
- 为什么前端拖线校验不能替代运行端校验?
- 为什么图要先编译再反复运行?
RunContext为什么不应该承载主要业务数据?- 当前执行器为什么还不能连接真实机器人?
练习:
- 增加重复 edge ID 校验,并写一个失败测试。
- 增加无硬件能力的
manual_review节点,输出人工确认结果。 - 为
ImageFrame和DetectionResult增加运行时 payload 校验。
这份最小实现能证明 typed dataflow、图校验和确定性调度的基础能力。它对工业可视化、机器人应用软件和系统集成岗位有价值,但不证明真实设备调试或运动控制能力。