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

0 阅读10分钟

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

场景很常见:一个 Python 服务(FastAPI / Django / Flask 写的中台、内部系统),产品说要加审批流。请假、报销、采购,单子从申请人出发走到部门领导,复杂一点的要会签、按比例通过、退回发起人改材料、抄送一把手。

你去搜 python workflow,会搜到 Airflow、Prefect、Dagster(数据管道编排),再往外还有 Celery(任务队列)、Temporal(长时任务)、Camunda 的 Python 客户端。这些名字都很强,但花一个下午读下来你会发现,它们的主场分别是数据管道任务编排 / 长时状态机——把它们请进一个 CRUD 系统审批三张单子,等于为了批三张单子,先部署一套调度平台、再学一套 DAG 或 workflow-as-code。

而你真正想要的,其实是件小事:一段 OA 审批语义,嵌进自己的 Python 服务,用自己已有的 MySQL 或 PostgreSQL,配一个能画流程的前端——最好别惊动你的部署拓扑。

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

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

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

对 Python 开发者最特别的一句介绍:这是一个全异步引擎——公开方法全是 async defawait 到底。装它就一行:

pip install jeeflow

下一节直接跑,零依赖这件事我会在第四节单独拎出来讲。

二、pip install 一行,5 分钟跑通一条审批流

四步实录总览

光说不练是伪代码,下面是一个全新 venv 的真实记录——pip install 从 PyPI 拉 jeeflow,到一条请假审批走完,全程 5 分钟。

$ python -m venv .venv
$ .venv/Scripts/pip install jeeflow
Successfully installed jeeflow-1.8.28

注意这行输出:Successfully installed jeeflow-1.8.28——就它一个包,没有"Collecting aiomysql…"、"Collecting pydantic…"那一串。装进来的 wheel 77 KB,依赖列表是空的。先把流程跑通,账我第四节拆。

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

完整代码如下,一个 main.py 能跑:

import asyncio
import json
from jeeflow import EngineImpl, MemoryRepository
from jeeflow.model import ProcessDefine

# flows/01-simple.json 裁掉画布坐标后的骨架
simple_flow = {
    "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": {}},
    ],
}

async def print_doing(repo, inst_id):
    ts = await repo.find_doing_tasks(inst_id)
    if not ts:
        print("    待办: (无,流程已结束)")
        return
    for t in ts:
        actors = await repo.find_task_actors(t.id)
        print(f"    待办: 任务id={t.id} 节点={t.taskName}({t.displayName}) 参与人={actors}")

async def main():
    repo = MemoryRepository()
    engine = EngineImpl(repo)

    # 1. 注册流程定义(id 留 0,看 memory 仓储自动分配)
    d = ProcessDefine(name="simple", displayName="简单审批流程", type="approval",
                      state=1, content=json.dumps(simple_flow))
    repo.add_define(d)
    print(f"[1] 流程定义已注册 id={d.id}(id 留 0,memory 仓储自动分配)")

    # 2. 张三发起流程
    inst = await engine.start_process_instance_by_id(d.id, "张三")
    print(f"[2] 张三 发起流程:实例id={inst.id} state={inst.state}")
    print("    注意:start 不会替你办申请节点——")
    await print_doing(repo, inst.id)

    # 3. 张三完成申请节点
    doing = await repo.find_doing_tasks(inst.id)
    inst2 = await engine.execute_process_task(doing[0].id, "张三")
    print(f"[3] 张三 提交申请:state={inst2.state}")
    await print_doing(repo, inst.id)

    # 4. leader 审批 → 流程结束
    doing = await repo.find_doing_tasks(inst.id)
    inst3 = await engine.execute_process_task(doing[0].id, "leader")
    print(f"[4] leader 审批通过:state={inst3.state}(10进行中 20已结束 45已驳回)")
    await print_doing(repo, inst.id)

asyncio.run(main())

python main.py 的真实输出:

[1] 流程定义已注册 id=1(id 留 0,memory 仓储自动分配)
[2] 张三 发起流程:实例id=1789398521769 state=10
    注意:start 不会替你办申请节点——
    待办: 任务id=1789398522282 节点=apply(发起申请) 参与人=['张三']
[3] 张三 提交申请:state=10
    待办: 任务id=1789398522467 节点=task1(上级审批) 参与人=['leader']
[4] leader 审批通过:state=20(10进行中 20已结束 45已驳回)
    待办: (无,流程已结束)

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

全程 async/await,没有同步版start_process_instance_by_id / execute_process_task 都是协程,最外层 asyncio.run(main()) 收口。这是刻意设计:审批这种 IO 密集型负载,异步引擎放进 FastAPI 的 async def 路由里是零成本的(await engine.execute_process_task(...) 直接写);Flask / Django 这类同步框架也不怕,一次 asyncio.run() 就能桥接。方法名是完整蛇形命名(start_process_instance_by_id),没有缩写——Python 的地盘守 Python 的规矩。

EngineImpl(repo) 一个参数就够:构造签名是 (repo, user_prov=None, id_gen=None, expr_eval=None),后面三个 SPI 全可省。不传 id 生成器,实例 id 退化成"毫秒时间戳 + 随机数"(所以你看到 13 位数字);不传表达式求值器,线性流程照样跑,但条件分支会踩坑——Python 版的坑和 Node 版不是同一种,下面细说。生产环境建议注入自带的 TsIDGenerator(),雪花 id 和其他语言版本同库共享不撞。

ProcessDefine 是个 dataclass,不是 dict。这是我的第一个真实坑:我把流程定义当字典传给 repo.add_define({...}),当场 AttributeError: 'dict' object has no attribute 'id'。引擎的模型全是带类型的 dataclass(ProcessDefine / ProcessInstance / ProcessTask),字段名沿用契约里的驼峰(displayNameprocessInstanceId)——为了和流程 JSON、跨语言契约逐字对齐,这里刻意不入乡随俗。只有流程内容本身(content)是 JSON 字符串,json.dumps 喂进去。

参与人=['张三'] 是引擎解析出来的:申请节点上写的 "assignee": "applicant" 是 mldong 契约的特殊值,引擎建任务时把它解析成流程发起人;assignee 里写 ${变量}、逗号分隔多人都认,还支持注册 assignment handler 按名字取人(部门领导这种要查组织架构的场景,7 个内置 handler 开箱即用)。

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

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

第二个,decision 节点带表达式、却没配求值器——Python 不报错,但会走错分支。第 21 篇讲 Node 版踩这个坑是"静默卡死":单子杵在那不前进。Python 版实测是另一副面孔。同一条 amount > 1000 走大额审批的分支流程,不传 expr_eval 发起一笔 amount=6666 的单:

不配求值器 amount=6666:state=10,待办=['taskA(小额审批)']
配了求值器 amount=6666:state=10,待办=['taskB(大额审批)']

看到了吗——不配求值器,流程照样往前走,只是 6666 元被分到了"小额审批"。Python 版的 decision 求值有个回退链:先按表达式匹配,匹配不上就走第一条没有表达式的边,再不行就走第一条边。两种失败模式摆在一起看很有意思:Node 卡死,单子"凭空消失",发起方一眼就能发现;Python 走错分支,单子看起来一切正常,钱算错了——后者其实更危险。用条件分支,就把第四个参数填上,它只是一个实现了 async def eval(expr, vars_) 的普通类:

class PyExprEval:
    async def eval(self, expr: str, vars_: dict):
        return eval(expr, {}, vars_)   # 生产环境换成受控的表达式实现

engine = EngineImpl(repo, expr_eval=PyExprEval())

同一个坑,两副面孔

越权会被引擎直接拦住。我新开一单张三发起,第一张待办参与人是张三,然后让 leader 去批——

ValueError: operator leader not allowed

Python 引擎在 execute_process_task 内部就做了参与人硬校验,非参与者直接抛 ValueError。直接调引擎方法也躲不掉,是物理闸门。(顺带一个冷知识:flow.admin / flow.auto 这两个特殊操作人会被放行——系统自动任务和超管的钥匙。)

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

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

和 Node 版逐字一致——这套错误语义是八门语言对齐的契约,不是哪个语言的即兴发挥。

三、生产姿势:换数据库仓储、上统一门面

内存仓储适合测试和内嵌,生产换成数据库仓储。Python 版同时给了 MySQL 和 PostgreSQL 两个适配,驱动按 extras 选装:

$ pip install "jeeflow[mysql]"      # 多装 aiomysql
$ pip install "jeeflow[postgres]"   # 多装 asyncpg
import aiomysql
from jeeflow import EngineImpl, TsIDGenerator
from jeeflow.repository import JdbcRepository, MySqlAdapter

pool = await aiomysql.create_pool(
    host="localhost", user="root", password="***", db="wf",
    autocommit=True,   # ⚠️ 必须:引擎在 with_tx 内显式 begin/commit,池上不开自动提交会错乱
    charset="utf8mb4",
)
repo = JdbcRepository(MySqlAdapter(pool), TsIDGenerator())
engine = EngineImpl(repo)

autocommit=True 这一行不是可选项——MySQL 适配器要求连接池以自动提交创建,无事务时每条语句立即提交,引擎自己管事务边界。这是从 Go 版 database/sql 语义对齐过来的约定。表结构是 wf_ 前缀的核心五张表(wf_process_define / wf_process_instance / wf_process_task / wf_process_task_actor / wf_process_cc_instance),和 Java 版完全一致,建表 SQL 在仓库 tests/schema/ 里,幂等可重复执行。

再往上,如果你不想记引擎的方法名,直接用统一门面——这是 mldong 系框架接工作流的标准姿势,40+ 个 action、一个入口:

from jeeflow import JeeflowFacade

facade = JeeflowFacade(engine, repo)

# 发起并自动完成申请节点(startAndExecute = start + 办申请)
r = await facade.flow("processDefine/startAndExecute",
                      {"processDefineId": d.id, "operator": "user1"})

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

{"code": 0, "msg": "成功", "data": {"processInstanceId": "1789398613846"}}

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

{"code": 0, "msg": "成功", "data": {"pageNum": 1, "pageSize": 10, "recordCount": 1, "totalPage": 1,
  "rows": [{"id": "1789398612928", "processInstanceId": "1789398613846", "taskName": "task1",
            "displayName": "上级审批", "taskState": 10, "createTime": "2026-09-14 23:10:12", ...}]}}

两个契约细节,跨语言都一样:code: 0 是成功(失败是 99999999,比如调一个不存在的 action,会拿到 {"code": 99999999, "msg": "未知 action: ..."});信封里的 ID 全部字符串化"1789398613846")。

说到大数 id,Python 有个天然优势值得一讲。第 21 篇 Node 版专门防了"前端把雪花 id 当 number 传"的坑:JS 的 number 超过 2^53 就丢精度,2096621342496391168 传到引擎面前已经是 ...1200 了,Node 版为此做了显式报错的硬校验。Python 没这个问题——int 是任意精度,同样的大数原样传进门面,不丢一位:

>>> await facade.flow("processTask/detail", {"id": 2096621342496391168})
{"code": 99999999, "msg": "任务不存在"}   # 数字原样精确,只是这个任务真的不存在

不过别高兴太早:信封里的 id 依然是字符串契约,因为前端(JS)要消费这些 JSON。Python 服务端不丢精度,不等于你的 Vue/React 前端不丢——浏览器里 JSON.parse 那一刻就四舍五入了。所以前后端交互照旧把 id 当字符串传,Python 帮你守住的只是服务端这一段。

40+ 个 action 覆盖流程定义部署/版本管理、发起、审批、跳转、撤回、委托代理、抄送、候选人、高亮路径、审批记录,以及统计三件套(overview/trend/group)。演示站的后端就是这套门面套了个 FastAPI 壳:

Python 演示站

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

四、零依赖不是营销词:依赖列表真的是空的

这一段把第二节的"就它一个包"的账拆掉,也是这一篇和 C#/Go/Node 篇最大的不同。

先看 PyPI 元数据,包的依赖声明是真的空

$ pip show jeeflow | grep Requires
Requires:

引擎核心(EngineImpl / MemoryRepository / JeeflowFacade,也就是 from jeeflow import ... 主入口给你的东西)运行时零第三方依赖,JSON 用标准库 json,异步用标准库 asyncio。数据库驱动不在依赖里,而在两个 extras 里:jeeflow[mysql]aiomysqljeeflow[postgres]asyncpg。你为"用 MySQL"这件事付出的成本是显式的:一行 extras,多装且只多装一个驱动包——不像某些"全家桶"把驱动硬塞进默认依赖,也不用像 npm 那样靠 --omit=optional 才能把可选依赖挡在外面。

验证一下裸装裸 import:

$ pip install jeeflow   # 干净 venv
$ python -c "import jeeflow; print(jeeflow.__doc__)"
jeeflow — lightweight async workflow engine (Python)

这个"裸装后能直接 import"值得单独说一句,因为它不是一直都这样。1.8.27 及更早的版本,裸装之后 import jeeflow 会当场炸出 ModuleNotFoundError: aiomysql——MySQL 适配器在模块顶层 import 了可选依赖,而开发环境永远装着全套 dev 依赖,测试全绿照样翻车,用户第一次 pip install 就撞墙。1.8.28 把顶层 import 改成了惰性引用(不装驱动也能导入,真连库时才加载),并加了一条回归测试专门模拟"裸装环境"钉死这个行为。这个修复对我来说不是轶事——这篇文稿的初稿就是在 1.8.27 上踩到它才当场升级重装的。

这比 Go 篇说的"编译期零第三方"是另一种实现路径:Go 是按包裁剪、不进二进制;Python 是包管理器层面的依赖列表为空pip install jeeflow 对你的环境的影响面就是那 77 KB 本体,extras 把选择权留给你。

零依赖包结构

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

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

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

对 Python 用户的实际意义,有两层。第一层是不锁语言:今天服务是 Python,明天加一个 Java 或 Go 服务,流程定义原样搬走,审批记录里的状态码一个都不用改。第二层是生态位:Python 最大的应用开发栈 FastAPI 是异步的,jeeflow-python 也是全异步的,await 直接写进路由函数;演示站的 Python 后端就是一个纯 FastAPI 应用,从引擎到 HTTP 接口中间没有任何 ORM 框架,jeeflow-ui 开源前端 ?lang=python 直连可用。

测试基线(写稿当日实测):pytest 106 个用例全绿,0.92 秒跑完——全程内存仓储,不碰任何数据库;另有需要真库的 JDBC 集成测试(MySQL/PostgreSQL 双库)单独跑。这个"引擎契约测试不依赖数据库"本身也是个设计质量的信号:仓储是 SPI,测试全部跑在内存实现上。

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

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

你的需求建议
Python 服务里嵌审批流:请假/报销/采购,会签、退回、委托、抄送正解。五张表 + 一个用户 SPI + 一个门面,jeeflow-ui 直连可用
FastAPI / 异步栈服务正中靶心。全异步引擎,await 直接写进路由,无桥接层
Django / Flask 同步服务能用,一次 asyncio.run() 桥接;但要是团队对 async 有心理阴影,先想清楚
前端还没有流程设计器用 jeeflow-ui(开源,Vue3),?lang=python 就是给 Python 后端留的档位
多语言技术栈,流程定义要共用同一份 LogicFlow JSON 八门语言跑,迁移引擎/混合栈不锁语言
ETL / 数据管道 / 定时调度别用,去 Airflow/Prefect,它们是那个赛道的
长时编排、任务重试、Saga 补偿、跨服务状态机别用,去 Temporal,它们是那个赛道的
数据库不是 MySQL/PostgreSQL自己实现 ProcessRepository SPI(接口在 jeeflow.spi,内存仓储可参考,几百行的事)

pip install jeeflow,Apache-2.0,引擎核心零第三方依赖、数据库驱动按 extras 选装,Python 3.10+。装之前想先玩,演示站在跑着;想看代码,仓库和文档站都在下面。

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

参考资料

  • jeeflow 仓库(2026-09-14 核对):PyPI 当前版本 1.8.28(2026-09-11 发布);pyproject.toml 依赖声明为空、aiomysql/asyncpg 为 extras(jeeflow[mysql] / jeeflow[postgres]);40+ action 统一门面见仓库 jeeflow/facade.py;信封 id 全程字符串化;裸装 import 回归测试 tests/test_bare_import.py;测试矩阵 pytest 106 用例(当日实测 0.92s 全绿,不含需真库的 JDBC 集成测试)
  • 系列前篇:第 3 篇《工作流引擎的"灵魂":状态机与 submitType》、第 6 篇《"applicant" 契约:退回发起人的闭环设计》、第 17 篇《C# 开发者也有自己的轻量工作流引擎了》、第 18 篇《Go 开发者也有自己的轻量工作流引擎了》、第 20 篇《AI Agent 不能自己签字:Python 工作流引擎当人类闸门》、第 21 篇《Node 开发者也有自己的轻量工作流引擎了》
  • Python 在线演示站(可直接玩):jeeflow-demo.mldong.com/?lang=pytho…
  • GitHub 仓库:github.com/mldong/jeef…
  • PyPI 包:pypi.org/project/jee…
  • jeeflow-ui 前端仓库:github.com/mldong/jeef…
  • 文档站:jeeflow-doc.mldong.com
  • 开源演示站:jeeflow-demo.mldong.com
  • 集成演示站:jeeflow-pro.mldong.com