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

作者:mldong日期:2026/9/9

一、搜"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 里干干净净,你二进制里不会因为一个审批流多拖进任何一处间接依赖。装它就一行:

1go get github.com/mldong/jeeflow-go
2

下一节直接跑。

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

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

1$ go mod init quickstart
2go: creating new go.mod: module quickstart
3$ go get github.com/mldong/jeeflow-go
4go: github.com/mldong/jeeflow-go@v1.8.26 requires go >= 1.25.0; switching to go1.26.8
5go: upgraded go 1.24.1 => 1.25.0
6go: added github.com/mldong/jeeflow-go v1.8.26
7

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

1module quickstart
2
3go 1.25.0
4
5require github.com/mldong/jeeflow-go v1.8.26
6

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

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

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

1package main
2
3import (
4	"context"
5	"fmt"
6	"log"
7
8	"github.com/mldong/jeeflow-go/engine"
9	"github.com/mldong/jeeflow-go/memory"
10	"github.com/mldong/jeeflow-go/model"
11)
12
13// flows/01-simple.json 裁掉画布坐标后的骨架
14const simpleFlowJSON = `{
15  "name": "simple", "displayName": "简单审批流程", "type": "approval",
16  "nodes": [
17    {"id": "start", "type": "snaker:start", "properties": {}},
18    {"id": "apply", "type": "snaker:task", "properties": {"assignee": "applicant", "taskType": 0, "performType": 0}, "text": {"value": "发起申请"}},
19    {"id": "task1", "type": "snaker:task", "properties": {"assignee": "leader", "taskType": 0, "performType": 0}, "text": {"value": "上级审批"}},
20    {"id": "end",   "type": "snaker:end", "properties": {}}
21  ],
22  "edges": [
23    {"id": "e0", "sourceNodeId": "start", "targetNodeId": "apply", "properties": {}},
24    {"id": "e1", "sourceNodeId": "apply", "targetNodeId": "task1", "properties": {}},
25    {"id": "e2", "sourceNodeId": "task1", "targetNodeId": "end",   "properties": {}}
26  ]
27}`
28
29func printDoing(repo *memory.Repository, instID int64) {
30	doing, err := repo.FindDoingTasks(context.Background(), instID, nil)
31	if err != nil {
32		log.Fatal(err)
33	}
34	if len(doing) == 0 {
35		fmt.Println("  待办: (无,流程已结束)")
36		return
37	}
38	for _, t := range doing {
39		fmt.Printf("  待办: ID=%d 节点=%s(%s) 参与人=%v\n", t.ID, t.TaskName, t.DisplayName, t.ActorIDs)
40	}
41}
42
43func main() {
44	repo := memory.New()
45	eng := engine.New(repo, nil, nil, nil)
46	ctx := context.Background()
47
48	// 1. 注册流程定义(Content 就是设计器导出的 LogicFlow JSON)
49	def := &model.ProcessDefine{Name: "simple", DisplayName: "简单审批流程",
50		Type: "approval", State: 1, Content: []byte(simpleFlowJSON)}
51	repo.AddDefine(def)
52	fmt.Printf("[1] 流程定义已注册 ID=%d(memory 仓储自动分配,不用自己赋值)\n", def.ID)
53
54	// 2. 张三发起流程
55	inst, err := eng.StartProcessInstanceByID(ctx, def.ID, "张三", nil)
56	if err != nil {
57		log.Fatal(err)
58	}
59	fmt.Printf("[2] 张三 发起流程:实例ID=%d 状态=%d\n", inst.ID, inst.State)
60	fmt.Println("    注意:start 不会替你办申请节点——")
61	printDoing(repo, inst.ID)
62
63	// 3. 张三完成申请节点
64	doing, _ := repo.FindDoingTasks(ctx, inst.ID, nil)
65	inst2, err := eng.ExecuteProcessTask(ctx, doing[0].ID, "张三", nil)
66	if err != nil {
67		log.Fatal(err)
68	}
69	fmt.Printf("[3] 张三 提交申请:当前状态=%d\n", inst2.State)
70	printDoing(repo, inst.ID)
71
72	// 4. leader 审批  流程结束
73	doing, _ = repo.FindDoingTasks(ctx, inst.ID, nil)
74	inst3, err := eng.ExecuteProcessTask(ctx, doing[0].ID, "leader", nil)
75	if err != nil {
76		log.Fatal(err)
77	}
78	fmt.Printf("[4] leader 审批通过:实例状态=%d(10进行中 20已结束 45已驳回)\n", inst3.State)
79	printDoing(repo, inst.ID)
80}
81

go run . 的真实输出:

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

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

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 引擎把"退回发起人"做成了独立方法:

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

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

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

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

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

1重复审批同一单:  task not doing: 20
2审批不存在的任务:task not found: 99999999
3用不存在的定义发起:define not found: 42
4

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

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

1repo := memory.New()                     // 生产换 jdbc 仓储
2ext := memory.NewExt()
3eng := engine.New(repo, userProv, nil, nil)
4f := facade.New(eng, repo, ext).
5    SetUserSearch(userSearch).           // 选人搜索(候选人体量大的分页场景)
6    SetOrgUserProvider(orgProvider)      // 组织接口(部门领导/角色取人)
7
8// 发起并自动完成申请节点(startAndExecute = start + 办申请)
9r := f.Flow("processDefine/startAndExecute", map[string]interface{}{
10    "processDefineId": 2,
11    "operator":        "张三",
12})
13

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

1startAndExecute => {"code":0,"data":{"processInstanceId":"1788882940681114100"},"msg":"成功"}
2

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

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

两个契约细节,跨语言都一样: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 就能带走的引擎来扛,而不是一套独立的编排平台。

参考资料


Go 开发者也有自己的轻量工作流引擎了:go get 一行,5 分钟跑通一条审批流》 是转载文章,点击查看原文


相关推荐


别再靠 Code Review 守底线:我做了一个静态分析项目 RedLine
潜龙勿用之化骨龙2026/9/1

Android 开发中,协程几乎已经成为异步编程的默认选择。但“使用协程”并不等于项目天然具备一致、可靠的异步模型。 很多项目在长期演进过程中都会出现类似情况:新的代码使用 suspend 和 Flow,旧模块仍然依赖 Callback、LiveData、RxJava 或 Java Future;有些人使用 Mutex 管理共享状态,有些人继续使用 synchronized 或 ReentrantLock;方法虽然标记为 suspend,内部却调用 Thread.sleep()。 之前写过And


Go 编程实战:闭包 Closure——函数如何记住外部变量
程序员爱钓鱼2026/8/24

上一篇我们学习了匿名函数 Anonymous Function,知道函数不仅可以拥有名字,也可以直接写成: func() { fmt.Println("Hello Go") } 匿名函数还可以保存到变量、作为参数传递,甚至作为另一个函数的返回值。在学习匿名函数时,我们留下了一个非常重要的问题: count := 0 add := func() { count++ } add() add() fmt.Println(count) 最终输出: 2 为什么匿名函数内部可以访问并修改外部的 c


二级市场流动性“击鼓传花”全景分析:游戏逻辑、资金分工、周期规律与生存法则
初晴融雪-快雪时晴2026/8/11

核心定调:A股二级市场绝大多数短期题材炒作、热点轮动、妖股行情,本质不是价值投资,而是依托流动性的击鼓传花零和游戏。股价涨跌与企业基本面脱钩,只取决于“当下有没有人愿意用更高价格接盘”。鼓声不停、 风险提示:本文为市场规律复盘分析,不构成任何个股买卖、投资操作建议,投机炒作风险极高,普通投资者需谨慎参与。 一、基础认知:什么是股市击鼓传花游戏?(核心定义) 传统价值投资逻辑:股价=企业未来现金流折现,靠企业盈利增长、分红、估值修复赚钱。 击鼓传花投机逻辑:股价=流动性情绪+资金接力


大模型技术 提示词模板 概述
元Y亨H2026/8/2

在 LangChain 中,提示词模板(Prompt Template) 是构建大模型应用的核心基石。 如果把大模型比作一个“极其聪明但没有记忆的员工”,那么提示词模板就是一份标准化的“工作指南”。它将用户的动态输入与预设的指令、上下文、格式要求结合起来,确保大模型能输出高质量、符合预期的结果。 一、 为什么需要提示词模板? 逻辑与数据分离:你不需要在代码里硬编码复杂的提示语。你可以定义一个模板,只在运行时注入变量(如用户的问题、背景资料)。 复用性:同一个任务(如翻译、总结)的指令可以定义


我用 AI 编程半年,这 5 个 Prompt 技巧让我效率翻倍
吴琼琼2026/7/25

我用 AI 编程半年,这 5 个 Prompt 技巧让我效率翻倍 不是 AI 不行,是你的提示词太随便了。 半年前,我第一次用 AI 辅助编程的时候,体验非常糟糕。 那时候我让 AI 帮我写一个「用户管理系统」,它给我生成了 800 行代码,里面掺杂了 PHP、Python 和莫名其妙的伪代码,接口命名毫无规律,错误处理完全没有。我当时心想:这玩意儿也就这样吧。 转折点发生在一个深夜。我加班改一个数据迁移脚本,困得不行,决定再给 AI 一次机会。但这次,我没有像平时那样随口说一句「帮我写个


官方文档像天书?这本开源的 WorkBuddy"蓝皮书",或许更适合小白入门
程序员晓凡2026/7/17

它不翻译官方说明书,而是用一个个真实任务,带你从第一项工作走到一支 AI 团队。 腾讯推出的全场景 AI 办公工具 WorkBuddy算是国内AI Agent 比较优秀的产品了。 简单来说,它是一个桌面 AI Agent 工作台:你只需要用自然语言描述需求,它就能自主读取本地文件、拆解任务、调用工具,最终交付一份可验收的成果——文档、表格、PPT、数据分析报告,甚至一条完整的视频。 官方文档地址:www.codebuddy.cn/docs/workbu… 你会发现一个问题:官方文档更像是写给


如何使用 Winget 下载 Claude Code 并实现绿色便携安装
越重天2026/7/8

🧑 博主简介:CSDN博客专家,「历代文学网」(PC端可以访问:https://lidaiwenxue.com/#/?__c=1000)总架构师,首席架构师,也是联合创始人!16年工作经验,精通Java编程,高并发设计,分布式系统架构设计,Springboot和微服务,熟悉Linux,ESXI虚拟化以及云原生Docker和K8s,热衷于探索科技的边界,并将理论知识转化为实际应用。保持对新技术的好奇心,乐于分享所学,希望通过我的实践经历和见解,启发他人的创新思维。在这里,我希望能与志同道合的朋友


线程概念与控制(中)
无忧.芙桃2026/6/30

本篇目标: 1.线程库的引入与理解 2.验证之前的概念 3.知道如何创建线程,终止线程,等待线程和分离线程 一.Linux线程控制 1.引入线程库 1.1.创建线程 通过之前对线程的理解,我们已经知道OS中有这么个执行流了,那么如何验证它是真的存在呢?那么就需要接下来的操作 首先我们需要创建一个线程,就需要用到pthread_create函数,如代码: 原型: int pthread_create(pthread_t *thread, const pthread_attr_


Re:Linux系统篇(三十三)文件篇·六:一文讲透 Linux 文件系统:从 EXT2 物理布局到 VFS 源码级全景解析
小此方2026/6/21

◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。 ⭐️Linux系列个人专栏: 【主题曲】Linux ⭐️此方的GitHub: github_此方 ⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record) 文章目录 概要&序论一、 EXT2 文件系统的整体磁盘布局1.1 从物理磁盘到逻辑分区


Kimi版超级玛丽效果“惊人”,配额不足5厘米!
甲维斯2026/6/13

让 AI 一句话写一个《超级玛丽》的测试继续! 测完了国内最强的 GLM-5.1 之后,我们来看看国内第二强的 Kimi2.6。 Kimi 这次的表现可是“非常精彩”,我都有点不知道该如何展示! 我就先上一张图吧: 各位有何感想?这是超级玛丽?玛丽呢?管道呢?蘑菇呢? 好像已经没有什么好评了,这个结果....已经很难打分了! 我就不信这个邪了,可能真的是我运气太好了,直接抽到废卡。 我专门用它们的 Kimi Code 再跑了一遍。 这次结果好了一些,但是也很“抽象”! 能玩了,但是并不能玩多

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读