GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器

GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器

本文回答一个问题:对标 Flowable/Camunda 常用子集的 Go 工作流底座,引擎侧靠什么保证「流转正确」与「崩溃可恢复」,设计器侧为什么要在 BPMN 画布之外再做一个钉钉式审批树------两个模式怎么共存?

GoWind Flow 是 go-wind 生态的工作流底座,从 go-wind-oa / go-wind-im 的审批流引擎提取、泛化而来:Go 写的 BPMN 2.0 兼容引擎 + npm 发布的前端设计器三包(core/vue/react)。它的架构主张写在 README 第一段------引擎管流转,脚本管「流转之外的所有逻辑」。10 月底引擎 M0-M2 收官、前端包发布 npm,11 月初在 go-wind-oa 宿主接入实测中修掉了两类「静默失效」缺陷。这篇从引擎、存储、设计器三层拆。


一、引擎:持久化驱动的 Token 状态机

flow/ 核心包有一条铁律:零第三方依赖 (go.mod 无任何 import)------引擎本体不认识数据库驱动、不认识脚本引擎、不认识身份系统,全部走 SPI 接口(spi.go:ExpressionEvaluator / ScriptRunner / IdentityProvider / Store / Clock / IDGen)。积木式组合,宿主按需装配。

引擎的心脏是 walker.go 的 runToken 主循环------每个令牌逐步推进,所有状态变更即时落库:

go 复制代码
// runToken 推进令牌直至阻塞(用户任务/子流程/汇合等待)或实例终结。
// 所有状态变更即时落库(持久化驱动状态机)。
func (e *Engine) runToken(ctx context.Context, inst *Instance, tk *Token, def *Definition) error {
    for step := 0; step < e.maxSteps; step++ {
        node := def.Nodes[tk.NodeID]
        switch node.Type {
        case NodeUserTask:        return e.arriveAtUserTask(...)
        case NodeScriptTask:      e.execScriptTask(...); moveToken(...)
        case NodeExclusiveGateway: edge, _ := e.selectExclusive(...); moveToken(edge)
        case NodeParallelGateway, NodeInclusiveGateway:
            e.walkGateway(...); if tk.Status != TokenRunning { return nil } // 在汇合点等待
        case NodeTimerEvent:      // 中间定时器:令牌阻塞至到期;宿主 cron 驱动续跑
            tk.Status = TokenWaiting; tk.DueAt = &due; ...
        case NodeCallActivity:    return e.startSubprocess(...)
        case NodeEndEvent:        return e.finishTokenAtEnd(...)
        }
    }
    return fmt.Errorf("token %s exceeded max steps (%d); cyclic definition?", tk.ID, e.maxSteps)
}

这个模型有两个关键性质:

「每步落库」意味着崩溃可恢复 ------令牌停在哪、变量是什么,全是数据库里的状态而非进程内存,进程重启后 ResumeDueTimers / 重新推进即可续跑。「定义不可变 + 版本锁定」配合:运行中的实例永远引用部署时的定义版本,改流程定义不影响存量实例。

并发模型是「引擎无锁、宿主串行化」 ------engine.go 的并发约定注释只有一行:引擎自身无锁,宿主按 instanceID 串行化同一实例的操作。锁的粒度被推到了最外层(按实例分片),引擎内部因此可以全速推进------这是「谁拥有状态谁负责串行化」的经典取舍。

maxSteps 是环的最后一道闸------静态校验(部署期)加循环步数上限(运行时),双层防护。这与 admin Agent 引擎的「Tarjan 环校验 + agentMaxSteps」是同构思路:静态校验拦大多数,步数预算兜住漏网的。

引擎对宿主暴露的 API 面也值得列一遍------全部入口集中在 Engine 结构体上,按职责分组恰好是审批流的生命周期:

分组 方法
定义 Deploy(部署期静态校验)/ ValidateDefinition
实例 Start / Abort(终止)/ Withdraw(撤回)
任务 CompleteTask / Claim / Unclaim / ForwardTask(转办)/ ReturnTo(退回指定节点)/ AddTaskApprovers(加签)
事件与时间 SendMessage(消息解锁)/ CheckDueTasks(任务到期)/ ResumeDueTimers(定时器续跑)

节点类型清单(model.go)共 13 种,命名对齐 BPMN 2.0:startEvent / endEvent / userTask / scriptTask / serviceTask / 中间定时器 / 中间消息 / errorEndEvent / boundaryEvent / 三种网关 / callActivity(子流程)。没有独立的「合并网关」类型------汇合是网关的模式而非节点(见下节)。


二、网关语义:排他合并、并行汇合、包容网关的期望令牌数

三种网关的语义差异是工作流引擎的核心考点,GoWind Flow 的实现各有讲究。

排他网关的合并模式 :多入单出的排他网关不是错误,而是「审批树分支汇合」的底座。静态校验专门放行这个形态(validate.go):

go 复制代码
case NodeExclusiveGateway:
    // 汇合模式:多入单出(审批树分支汇合;唯一出边应标记 IsDefault)。
    if in >= 2 && len(out) == 1 { continue }
    if len(out) < 2 { return ... "must have ≥2 out edges (or be a merge gateway: ≥2 in / 1 out)" }

语义是先到先走、互不等待 ------唯一出边标 IsDefault,哪条分支先到就直接放行。这与并行/包容汇合的「等齐」语义形成对照:合并网关处理的是「几条互斥分支殊途同归」,天然不需要同步。

包容网关(inclusive)是最难写对的 :分叉时逐条求值条件、命中的全部走(多选多),汇合时必须只等「实际被派出的分支数」 ,而不是几何入度。实现用了一个「期望令牌数」约定(walker.go):

go 复制代码
if in >= 2 {
    need := in
    if node.Type == NodeInclusiveGateway {
        need = int(joinExpected(inst.Variables, node.ID))  // 分叉时记下的期望令牌数
        if need < 1 { need = in }
    }
    // 收集本节点 WAITING 令牌;len(waiting) < need 则继续等待
    // 吸收:保留本令牌,清除其余等待令牌。
}
// 分叉:命中的全部走;期望令牌数记在汇合节点名下(分叉与汇合并不同名,须结构定位)
if joinID := findJoinNode(def, node); joinID != "" {
    inst.Variables[joinExpectedKey(joinID)] = float64(len(out))
}

两个细节值得划线:

  1. findJoinNode 做结构定位------自分叉网关的各出边同时 BFS,返回所有分支首个共同到达的网关。因为 BPMN 里分叉和汇合是两个不同 id 的网关节点,「期望数」必须记在汇合点的名下才能在汇合时查到。
  2. 这里曾有一个教科书级 bug (90484e8 修复):最初的实现把期望令牌数记在分叉节点名下 ,汇合时却按汇合节点 查询------键永不匹配,need 永远回落到几何入度,条件分流下没被派出的分支永远等不到,汇合永久阻塞 。修复就是补上 findJoinNode。这类 bug 的成因不是「不会写网关」,而是「分叉/汇合同名」的直觉假设在 BPMN 的异名结构上失效。

fail-closed 的安全观 贯穿网关与办理人解析:条件求值失败不误走分支、排他网关全不命中且无默认出边时直接报错而非随机选一条、办理人解析失败不建任务------流转引擎的第一美德是「不确定时停下来」,宁可流程卡住等人介入,不可静默走错分支。


三、边界事件与定时器:引擎不睡觉,宿主管闹钟

边界事件挂接在 userTask / callActivity 上,语义分中断与非中断两型。到达用户任务时,引擎为每个挂接的边界定时器创建伴生令牌(walker.go):

go 复制代码
// 挂接的边界定时器:为每个创建边界令牌(WAITING + DueAt,
// 到期由 ResumeDueTimers 触发;主任务办结/终结时清理)。
btk := &Token{ NodeID: be.ID, Status: TokenWaiting, ParentID: tk.ID, DueAt: &due, ... }

触发时的语义分野写在 fireBoundaryTimer 的注释里:

  • 中断型:取消挂接节点的全部待办,接管流程------「24 小时没审批就自动升级到上级」;
  • 非中断型:派生升级分支令牌(克隆局部变量),原任务照常------「催办但不打断」,边界令牌一次性(v1 不支持周期触发)。

架构上最有意思的决定是:引擎没有任何内建计时器 。定时器只是「带 DueAt 的等待令牌」,谁把它叫醒?宿主的 cron------定时调 ResumeDueTimers 扫描到期令牌触发续跑(engine.go):

go 复制代码
// ResumeDueTimers 扫描到期的定时器令牌并触发(宿主 cron 驱动)。
//   - intermediateTimerEvent:令牌沿出边续走(引擎自驱);
//   - boundaryEvent:中断型取消挂接节点任务并接管流程,非中断型只派生升级分支。

这个决定和 admin 侧任务系统的「asynq 周期任务」是同一哲学:引擎不拥有时钟,时钟是宿主的基础设施 。好处是引擎保持零依赖(不 import cron 库),测试里可以用假 Clock;代价是宿主必须记得接 cron------examples/scheduler/ 专门给了独立 go.mod 的示例。任务超时动作(节点声明 Timer.Action)则由引擎以系统操作者 flow:timer 署名直接 AutoApprove/AutoReject------机器动作也要留署名,历史审计里分得清「人批的」和「超时自动批的」。


四、企业语义与存储:会签/加签/退回,三套 Store 各自独立

审批场景的企业语义是内建的:会签(ALL)/或签(ANY)、委托代办、转办、加签、退回指定节点、候选组(组内认领)。加签的实现(AddTaskApprovers)体现了「任务=令牌的投影」模型:

go 复制代码
// 令牌保持 WAITING,为每个新增用户创建同 TokenID 的并排 PENDING 任务
//   EventTaskApproverAdded 事件 + 历史

加签不动令牌状态,只是给同一个 WAITING 令牌并排多建几条 PENDING 任务------会签/或签的判定发生在任务办结时按 Strategy 统计,不需要为加签设计独立的状态机路径。

存储层是三个独立 go.mod 的子模块,按需 go get:

实现 定位
store/memory 进程内,嵌入式用法与单测
store/ent 生产推荐,WithTx 模式、嵌套事务安全
store/gorm MySQL/PostgreSQL/SQLite 主流方言,AutoMigrate 建表

gorm 实现曾有一个「映射函数漏拷」缺陷(同在 90484e8 修复):Token 模型有 locals 列(令牌级局部变量,并行分支隔离与多实例 elementVar 靠它),但读方向的映射函数漏拷了这个字段 ------写进去了、读出来永远是空。症状极其隐蔽:变量隔离在单机上看起来正常(内存里刚写的数据还在语义上「对」),一旦令牌跨事务/跨进程恢复,局部变量凭空消失。ORM 映射的双向性值得每次加字段时人工核对一遍,这类漏拷没有任何编译期信号。同一个提交还把 flow 聚合的测试覆盖率从 15.7% 拉到 86.1%------修复与覆盖一起做,缺陷才算真正关账。


五、设计器三包:bpmn-js 内核的「壳工程」

前端按框架拆三包发布 npm(tag 自动发布 CI):@go-wind/flow-designer-core / -vue / -react。core 是内核封装,直接站在 bpmn-js 上:

ts 复制代码
import Modeler from 'bpmn-js/lib/Modeler';
// ......
new Modeler({
    additionalModules: [BpmnPropertiesPanelModule, BpmnPropertiesProviderModule, FlowPropertiesModule],
    moddleExtensions: { flow: FlowModdleDescriptor },
})

三包的分工是「core 管引擎封装与主题,vue/react 管容器与框架语法糖」。设计器的完整形态------BPMN 画布(专业模式)加载「请假审批」示例流程,右侧 properties-panel 属性面板,左侧 palette 工具条:

切到暗色主题,画布、palette、属性面板整体切换------这正是 CSS 变量映射方案的实测效果(bpmn-js 的 diagram-js 原生变量被 token 驱动,无需改任何 JS):

值得学的是主题体系的实现方式 :不在 JS 层改造 bpmn-js 的 palette/context-pad,而是把自家的 design tokens 映射进 diagram-js 的原生 CSS 变量(theme.ts):

css 复制代码
--palette-background-color: var(--gwf-node-bg);
--palette-entry-hover-color: var(--gwf-accent);
--context-pad-entry-background-color: var(--gwf-node-bg);

三态主题(亮/暗/跟随系统)靠组件根元素上的 data-gwf-theme 属性切换整组 token,自定义主题色 applyAccent 只设一个 --gwf-accent 变量,弱色/高亮经 color-mix 自动派生。宿主无需自行适配暗色------这条对多宿主复用的组件库是刚需。


六、钉钉式审批树:80/20 子集与优雅回退

设计器最有产品思考的部分:BPMN 画布之外,另做了一个垂直审批树组件(钉钉式业务模式)。理由是 80/20:审批场景里绝大多数流程只用到四种节点------startEvent、userTask、排他网关(拆分+汇合)、endEvent。为这 80% 的用户呈现 BPMN 全量画布是过度设计,钉钉式的「树形勾选审批人」才是业务人员的心智模型。

关键设计在边界处理 :树模式只声明为「designer-json-v1 子集」的视图,超出子集的元素(并行网关、定时器、子流程......)在树导入时抛 UnsupportedError------宿主捕获后回退到 bpmn-js 画布模式 (tree.ts):

审批树的实际形态------「发起申请 → 财务初审(候选组)→ 条件分支(amount > 1000 走总监审批·或签 / 默认分支)→ 结束」,分支条件内联编辑、加号添加节点:

ts 复制代码
// 子集(审批场景 80/20):startEvent、userTask、exclusiveGateway(拆分+汇合)、endEvent。
// 子集之外......抛 UnsupportedError------宿回退到 bpmn-js 画布模式。

两模式靠「树 ↔ BPMN XML 双向互导」共存,Demo 里双视图 Tab 切换且都保持挂载 (display: none 切换而非卸载)------bpmn-js 实例不随 Tab 卸载,否则切回来画布空白(这是实测修过的坑,0891232)。

树组件的框架适配层也有一个 Vue 特有的坑值得记录(9cc1be8):树操作是纯函数 ------返回新树、不改旧树。React 版 setTree(newTree) 正确;Vue 版 commit 只 emit 了新树、没把它赋回 shallowRef 的 tree.value,组件永远渲染旧树,表现为「加号/全部树操作静默不生效」。纯函数式数据流在 React 里是默认正确,在 Vue 的 shallowRef 语义里需要显式写回------跨框架移植组件时,状态更新协议要逐条对齐,不能默认等价。


七、properties-panel v5 的两个「静默失效」与一份契约文档

go-wind-oa 宿主接入时爆出的前端缺陷(3940729)非常有代表性------两类都是静默失效(不报错、只是不工作):

缺陷一:审批配置组永远不渲染。 属性面板 provider 读取 propertiesPanel._modeling 私有属性来拿 modeling 服务------但 bpmn-js-properties-panel v5 的服务实例上根本没有这个属性 (grep 零命中),代码永远走不进渲染分支。修复:构造器声明 $inject = ['modeling', ...],由 diagram-js 注入器直接注入;失败时 warn 可见化,不再吞组。

缺陷二:条目渲染成 [object Object]。 v5 面板只渲染带 component 的条目对象,裸 { id, label, getValue, setValue }(v4 风格)渲染出来就是字面量 [object Object]。修复:全部条目改用 @bpmn-io/properties-panel 官方工厂(TextFieldEntry / SelectEntry)构建。

顺带还发现 style.css 只有 370 字节、不含 bpmn-js 基础样式------面板裸 HTML、调色板无图标字体。修复后四份基础样式随 dist 发布。

这次事故的沉淀是一份面板扩展契约文档 (docs/designer_contract.md §6):

md 复制代码
- **条目必须经官方工厂构建**:v5 面板只渲染带 `component` 的条目对象,
  裸 `{ id, label, getValue, setValue }` 会渲染成 `[object Object]`;
- **modeling 服务经 DI 注入**:不要读 `propertiesPanel._modeling`------v5 服务实例上
  不存在该私有属性,恒为 undefined(症状:分组静默丢失、无任何报错)。

这两条经验对所有基于 bpmn-js 二次开发的项目通用:大版本升级时,私有属性的消失不会报错,只会让分支永远走不进去------凡是「功能静默失效」的排查,先怀疑依赖的内部接口变了。


八、收尾:底座的边界感

回头看这个项目的边界划分,几乎每个决定都在回答「什么该内建、什么该外置」:

职责 归属 理由
Token 推进/网关语义/边界事件 引擎内建 流转正确性是引擎的存在意义
会签/或签/加签/退回 引擎内建 企业语义,绕不开
脚本执行/表达式求值 SPI 插拔(9 种引擎可选) 「流转之外的所有逻辑」不该锁死在引擎里
时钟/cron 宿主 引擎零依赖,测试可注入假钟
存储 三套独立子模块 按需 go get,不强卖 ORM
取数/身份/事务 宿主(server 层只做 gRPC 映射) 引擎不认识业务世界

还有一个诚实的现状交代:go-wind-admin 尚未接入 flow(go.mod 无依赖),实际的宿主接入实测发生在 go-wind-oa------README 里 admin 那条还是规划性表述。M0/M1/M2 已完成,下一站 M3「生态」(按 README 路线图)。

对想在 Go 私有化市场做审批流/业务流的团队,这个项目的参照价值不在「它有多全」,而在子集的选法:13 种节点、三种网关语义、80/20 审批树------每一样都刻意比 Flowable 小一号,但每一样都带着完整的企业语义(多租户、审计、fail-closed)与可测试性(86.1% 覆盖率)。小而完整,比大而破洞诚实。

仓库(引擎与设计器源码均可逐行核对):

相关推荐
喵个咪12 分钟前
GoWind CMS 拆解:搜索三板斧——高亮、联想、同义词
后端·搜索引擎·go
喵个咪15 分钟前
GoWind 拆解:AI Agent 编排引擎——波次并行、子图递归与人工审批
人工智能·后端·go
喵个咪28 分钟前
GoWind Admin 拆解:打印模板设计器——从画布到服务端 PDF 的完整打印链路
前端·后端·go
总要学点什么38 分钟前
第96篇:三维数据加载-城市建筑白模样式+着色器——让色带按体量上色,最白的那档却涂不上去
前端·gis·webgl·cesium·着色器·空间分析·白模
喵个咪1 小时前
GoWind Admin 拆解:BI 大屏设计器——schema 即 JSON、resolver 注入与三张快照表的口径谱系
大数据·后端·go
喵个咪1 小时前
GoWind Admin 拆解:AI 能力层——工具循环、pgvector 知识库与机器凭证
人工智能·后端·agent
Hilaku1 小时前
你们公司有 35 岁以上的前端吗?
前端·javascript·程序员
泡海椒1 小时前
jquick-pdf 柱状图(JBar)实战:Java 生成政务项目审批数量趋势 PDF
后端
徐小夕1 小时前
开源Vector-Graph-RAG:向量+图谱双引擎,打破纯向量RAG的效果天花板
前端·vue.js·github