Go 开发者也有自己的轻量工作流引擎了:go get 一行,5 分钟跑通一条审批流

一、搜"Go 工作流引擎",你会先搜到什么

场景很常见:公司一个 Go 服务(订单、工单、内部管理后台),产品说要加审批流。请假、报销、采购,单子从申请人出发,走到部门领导,复杂一点的要会签、按比例通过、退回发起人改材料、抄送一把手。

你去搜,会搜到 Temporal、Cadence、Asynq,再往外还有 Camunda。这些名字都很强,但花一个下午读下来你会发现,它们的主场是编排:长事务、任务重试、分布式补偿、跨服务的状态机。把它们请进一个 CRUD 系统里审批三张单子,等于为了走三张单子,先部署一套独立编排平台、再学一套 workflow-as-code。

而你真正想要的,其实是件小事:一段审批语义,嵌进自己的服务,用自己已有的 MySQL,配一个能画流程的前端

jeeflow 就是做这件小事的引擎:串行/并行/按比例会签、一票否决、退回发起人、委托代理、抄送------这套 OA 审批语义,引擎核心自己扛。它已经在 Java/Go/Python/Node/PHP/Rust/MoonBit/C# 八门语言上各有一份实现,同一份 LogicFlow 流程 JSON 八门语言通用

先把"它不是什么"说清楚,省得你装错:

  • 不是 BPM 平台:不带用户体系、不带表单引擎,审批人是谁要你接一个用户接口告诉它;
  • 不带 UI :但有配套开源前端 jeeflow-ui,?lang=go 直连 Go demo 可用;
  • 数据库目前是 MySQL:内存仓储开箱即用(测试/内嵌场景),生产走 MySQL 仓储;
  • 不碰你的业务表:表单数据落哪张表、哪些字段谁可见,由你配置,引擎只管流程本身。

对 Go 开发者多说一句最有感的:引擎核心三个包 engine/memory/model 编译期零第三方依赖 ------go.mod 里干干净净,你二进制里不会因为一个审批流多拖进任何一处间接依赖。装它就一行:

bash 复制代码
go get github.com/mldong/jeeflow-go

下一节直接跑。

二、go get 一行,5 分钟跑通一条审批流

光说不练是伪代码,下面是一个全新工程 的真实记录------go mod init 之后从 pkg.go.dev 拉 jeeflow-go,到一条请假审批走完,全程 5 分钟。

bash 复制代码
$ go mod init quickstart
go: creating new go.mod: module quickstart
$ go get github.com/mldong/jeeflow-go
go: github.com/mldong/jeeflow-go@v1.8.26 requires go >= 1.25.0; switching to go1.26.8
go: upgraded go 1.24.1 => 1.25.0
go: added github.com/mldong/jeeflow-go v1.8.26

一个细节:引擎当前要求 Go ≥ 1.25go get 会自动帮你切工具链)。装完看一眼 go.mod

go 复制代码
module quickstart

go 1.25.0

require github.com/mldong/jeeflow-go v1.8.26

require 区只有它自己。这不是文案修饰------engine(引擎核心)、memory(内存仓储)、model(域模型) 三个包 import 的是纯标准库,JSON 解析用 encoding/json,雪花 ID 自己生。你 import 的是哪几个包,编译进二进制的就只有哪几个包,Go 的依赖是按包算的。

流程用联邦共享测试资产里最简单的一条(01-simple.json,这里裁掉画布坐标展示骨架,设计器导出的完整文件原样也能用):开始 → 申请(assignee=applicant)→ 上级审批(assignee=leader)→ 结束

完整代码如下,一个文件能跑:

go 复制代码
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/mldong/jeeflow-go/engine"
	"github.com/mldong/jeeflow-go/memory"
	"github.com/mldong/jeeflow-go/model"
)

// flows/01-simple.json 裁掉画布坐标后的骨架
const simpleFlowJSON = `{
  "name": "simple", "displayName": "简单审批流程", "type": "approval",
  "nodes": [
    {"id": "start", "type": "snaker:start", "properties": {}},
    {"id": "apply", "type": "snaker:task", "properties": {"assignee": "applicant", "taskType": 0, "performType": 0}, "text": {"value": "发起申请"}},
    {"id": "task1", "type": "snaker:task", "properties": {"assignee": "leader", "taskType": 0, "performType": 0}, "text": {"value": "上级审批"}},
    {"id": "end",   "type": "snaker:end", "properties": {}}
  ],
  "edges": [
    {"id": "e0", "sourceNodeId": "start", "targetNodeId": "apply", "properties": {}},
    {"id": "e1", "sourceNodeId": "apply", "targetNodeId": "task1", "properties": {}},
    {"id": "e2", "sourceNodeId": "task1", "targetNodeId": "end",   "properties": {}}
  ]
}`

func printDoing(repo *memory.Repository, instID int64) {
	doing, err := repo.FindDoingTasks(context.Background(), instID, nil)
	if err != nil {
		log.Fatal(err)
	}
	if len(doing) == 0 {
		fmt.Println("  待办: (无,流程已结束)")
		return
	}
	for _, t := range doing {
		fmt.Printf("  待办: ID=%d 节点=%s(%s) 参与人=%v\n", t.ID, t.TaskName, t.DisplayName, t.ActorIDs)
	}
}

func main() {
	repo := memory.New()
	eng := engine.New(repo, nil, nil, nil)
	ctx := context.Background()

	// 1. 注册流程定义(Content 就是设计器导出的 LogicFlow JSON)
	def := &model.ProcessDefine{Name: "simple", DisplayName: "简单审批流程",
		Type: "approval", State: 1, Content: []byte(simpleFlowJSON)}
	repo.AddDefine(def)
	fmt.Printf("[1] 流程定义已注册 ID=%d(memory 仓储自动分配,不用自己赋值)\n", def.ID)

	// 2. 张三发起流程
	inst, err := eng.StartProcessInstanceByID(ctx, def.ID, "张三", nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("[2] 张三 发起流程:实例ID=%d 状态=%d\n", inst.ID, inst.State)
	fmt.Println("    注意:start 不会替你办申请节点------")
	printDoing(repo, inst.ID)

	// 3. 张三完成申请节点
	doing, _ := repo.FindDoingTasks(ctx, inst.ID, nil)
	inst2, err := eng.ExecuteProcessTask(ctx, doing[0].ID, "张三", nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("[3] 张三 提交申请:当前状态=%d\n", inst2.State)
	printDoing(repo, inst.ID)

	// 4. leader 审批 → 流程结束
	doing, _ = repo.FindDoingTasks(ctx, inst.ID, nil)
	inst3, err := eng.ExecuteProcessTask(ctx, doing[0].ID, "leader", nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("[4] leader 审批通过:实例状态=%d(10进行中 20已结束 45已驳回)\n", inst3.State)
	printDoing(repo, inst.ID)
}

go run . 的真实输出:

text 复制代码
[1] 流程定义已注册 ID=2(memory 仓储自动分配,不用自己赋值)
[2] 张三 发起流程:实例ID=1788883290920362000 状态=10
    注意:start 不会替你办申请节点------
  待办: ID=1788883290920362000 节点=apply(发起申请) 参与人=[张三]
[3] 张三 提交申请:当前状态=10
  待办: ID=1788883290920362000 节点=task1(上级审批) 参与人=[leader]
[4] leader 审批通过:实例状态=20(10进行中 20已结束 45已驳回)
  待办: (无,流程已结束)

四步,一条审批流走完。几个真实细节值得停一停:

engine.New(repo, nil, nil, nil) 四个 nil 是什么 :依次是用户、ID 生成器、表达式求值器三个 SPI(除仓储外)。全传 nil 引擎也能跑------ID 退化为 time.Now().UnixNano(),所以你看到实例 ID 和第一张待办 ID 是同一个数:同一纳秒创建的两条记录。生产环境建议注入自己的 ID 生成器 (一个 NextID() int64 接口的事),表达式求值器在用到条件分支(decision 节点)时必须给。

参与人=[张三] 是引擎解析出来的 :申请节点上写的 assignee: "applicant" 是 mldong 契约的特殊值,引擎建任务时把它解析成流程发起人;assignee 里写 ${变量}、逗号分隔多人都认,还支持注册 assignmentHandler 按名字取人(部门领导这种要查组织架构的场景)。但注意------引擎只解析、不拦人ExecuteProcessTask 信任你传的 operator,"只有参与者能办"这层校验在门面层/你的应用层做。分工是清楚的:引擎管流转,权限管在你手里。

两个我亲手踩的坑,给你垫上

第一个,start 不会替你办申请节点。张三 StartProcessInstanceByID 之后,第一张待办是"发起申请",停在张三自己桌上------你要再 ExecuteProcessTask 一次才算真正提交。这是刻意设计(mldong 契约的 applicant 约定:申请节点也是节点,退回发起人时它就是退回的目的地),但第一次用很容易以为发起=已提交。上面的输出里我特意把这一步打出来了。

第二个,退回发起人不是一个参数。我拿着 mldong 契约里 submitType=6(退回发起人)传给 ExecuteProcessTask 的 args,结果流程直接走到结束------ExecuteProcessTask 不吃这个参数。Go 引擎把"退回发起人"做成了独立方法:

go 复制代码
// 王五把单子退回给发起人:回到第一个任务节点,参与者强制改为发起人
inst2, err := eng.ExecuteAndJumpToFirstTaskNode(ctx, taskID, "王五", nil)

在我这条流程上的真实效果:李四发起并提交、单子到王五,王五执行退回后------

text 复制代码
[6] 王五 退回发起人:实例状态=10,单子回到李四的申请节点
  待办: ID=1788883290920362000 节点=apply(发起申请) 参与人=[李四]

单子回到申请节点,参与人换成李四,实例状态还是 10(进行中)。不想记两个方法?走下一节的统一门面,它按 submitType 自动路由。

边界报错长什么样(引擎层原生错误,负向实测):

text 复制代码
重复审批同一单:  task not doing: 20
审批不存在的任务:task not found: 99999999
用不存在的定义发起:define not found: 42

三、生产姿势:换 MySQL 仓储、上统一门面

内存仓储适合测试和内嵌,生产换 MySQL 仓储(repository/jdbc 包,表结构是 wf_ 前缀五张表,和 Java 版完全一致)。再往上,如果你不想记引擎的方法名,直接用统一门面------这是 mldong 系框架接工作流的标准姿势,45 个 action、一个入口:

go 复制代码
repo := memory.New()                     // 生产换 jdbc 仓储
ext := memory.NewExt()
eng := engine.New(repo, userProv, nil, nil)
f := facade.New(eng, repo, ext).
    SetUserSearch(userSearch).           // 选人搜索(候选人体量大的分页场景)
    SetOrgUserProvider(orgProvider)      // 组织接口(部门领导/角色取人)

// 发起并自动完成申请节点(startAndExecute = start + 办申请)
r := f.Flow("processDefine/startAndExecute", map[string]interface{}{
    "processDefineId": 2,
    "operator":        "张三",
})

f.Flow(action, args) 返回统一的 {code, msg, data} 信封。真实的返回长这样:

json 复制代码
startAndExecute => {"code":0,"data":{"processInstanceId":"1788882940681114100"},"msg":"成功"}

leader 查自己的待办列表(processTask/todoList,operator 过滤):

json 复制代码
{"code":0,"data":{"pageNum":1,"pageSize":10,"totalPage":1,"recordCount":1,
  "rows":[{"taskName":"task1","displayName":"上级审批","processDefineDisplayName":"简单审批流程",
           "processInstanceId":"1788882940681114100","taskState":10,"createTime":"2026-09-08 23:55:40",
           ...}],"totalPage":1},"msg":"成功"}

两个契约细节,跨语言都一样:code:0 是成功;ID 全部字符串化"1788882940681114100")------雪花 ID 超过 JS 的 2^53,不转字符串前端会丢精度,这是整个联邦吃过的亏后统一钉死的约定。

45 个 action 覆盖流程定义部署/版本管理、发起、审批、跳转、撤回、委托代理、抄送、候选人、高亮路径、审批记录,以及 1.8.25 新增的统计三件套(overview/trend/group)。名字全部带斜杠前缀按资源分组(processDefine/processTask/processInstance/...),和一个 HTTP 风格的 REST demo(GoFrame 实现,demo/ 目录)------演示站就是它跑出来的:

想先玩再装:jeeflow-demo.mldong.com/?lang=go (右上角可以切八门语言后端,前端是同一个)。

四、同一份流程 JSON,八门语言都能跑

这一段给不熟悉这个系列的新读者,老读者可以跳过。

jeeflow 是一个多语言联邦:Java 是参考实现,Go/Python/Node/PHP/Rust/MoonBit/C# 各有一份对齐实现,八门语言共享同一套流程定义 JSON(15 个模板,从最简单的线性审批到会签+分支+委托混合模式)、同一套 {code,msg,data} 契约、同一组状态码语义。升级走"参考实现先行 + 契约测试对齐",Java 发了新能力,各语言在下一版跟上,版本节奏当前都停在 1.8.x/1.0.x 线(Go 当前 v1.8.26)。

对 Go 用户的实际意义:不锁语言。今天服务是 Go,明天公司战略转 Java 或加一个 Python 服务,流程定义原样搬走,审批记录里的状态码一个都不用改。

测试基线(写稿当日 go test ./... 实测):115 个用例全绿,含引擎合规测试(C01~C22 场景)、门面契约、MySQL 仓储集成测试。

五、什么时候用它,什么时候别用

最后摆正预期,这张表比任何吹捧都有用:

你的需求 建议
Go 服务里嵌审批流:请假/报销/采购,会签、退回、委托、抄送 正解。五张表 + 一个用户 SPI + 一个门面,jeeflow-ui 直连可用
前端还没有流程设计器 用 jeeflow-ui(开源,Vue3),?lang=go 就是给 Go 后端留的档位
多语言技术栈,流程定义要共用 同一份 LogicFlow JSON 八门语言跑,迁移引擎/混合栈不锁语言
长时编排、任务重试、Saga 补偿、跨服务状态机 别用,去 Temporal/Cadence,它们是那个赛道的
数据库不是 MySQL 等后续版本,或自己实现 spi.ProcessRepository(接口在 spi 包里,PgSQL 仓储大概是几百行 database/sql 的事)

go get github.com/mldong/jeeflow-go,Apache-2.0,引擎核心三个包编译期零第三方依赖。装之前想先玩,演示站在跑着;想看代码,仓库和文档站都在下面。

审批流的复杂度,值得一个 import 就能带走的引擎来扛,而不是一套独立的编排平台。

参考资料

  • jeeflow-go 仓库(2026-09-08 核对):pkg.go.dev 当前版本 v1.8.26(2026-09-04 发布),要求 Go ≥ 1.25;引擎核心 engine/memory/model 零第三方依赖;45 action 门面与统计三件套见仓库内 facade/;测试矩阵 115 用例(go test ./... 当日实测)
  • 系列前篇:第 3 篇《工作流引擎的"灵魂":状态机与 submitType》、第 6 篇《"applicant" 契约:退回发起人的闭环设计》、第 9 篇《一条命令,十分钟:jeeflow 工作流应用的六语言一键部署》、第 17 篇《C# 开发者也有自己的轻量工作流引擎了》
  • Go 在线演示站(可直接玩):jeeflow-demo.mldong.com/?lang=go
  • GitHub 仓库:github.com/mldong/jeef...
  • Go 文档:pkg.go.dev/github.com/...
  • jeeflow-ui 前端仓库:github.com/mldong/jeef...
  • 文档站:jeeflow-doc.mldong.com
  • 开源演示站:jeeflow-demo.mldong.com
  • 集成演示站:jeeflow-pro.mldong.com
相关推荐
BingoGo7 小时前
PHP clone 之后,为什么改副本会影响原对象?
后端·php
JaguarJack8 小时前
PHP clone 之后,为什么改副本会影响原对象?
后端·php·服务端
小灰灰搞电子8 小时前
Rust+Slint 实现动态消息提示框源码分享
开发语言·后端·rust
小奏技术8 小时前
10 MB 的 Postman 替代品,启动不到 1 秒
后端
东风破_8 小时前
Text2SQL :用自然语言操作 SQLite 数据库
人工智能·后端
IT_陈寒12 小时前
Python的多线程就是个假把式,我算是体验到了
前端·人工智能·后端
码事漫谈12 小时前
如果 AI 要圈养人类,它可能不需要笼子
后端
newerp13 小时前
Golang 调度循环:Go runtime 如何永不停歇地找人干活
后端·程序员·go