简介:一套面向中小型项目的国产自研 warm-flow 工作流设计源码,主打简洁易扩展,仅依赖6张基础表即可完成流程设计、任务调度、审批流转与状态管理等核心能力。压缩包共435个文件,大小约1.84MB,其中272个Java源文件负责业务逻辑,54个XML配置可灵活调整系统行为,33个JS与11个Vue组件组成前端交互界面,12个SQL脚本支撑数据持久化,另有warm-flow-ui、warm-flow-plugin等扩展件,整体结构清晰、组件独立,便于按需裁剪和二次开发。目前已有1002人学习下载,适合需要在中小型项目中快速引入或自研工作流模块的Java后端/全栈开发者。通过该源码可以深入学习轻量级工作流的分层设计、基于6张表的精简建模思路,以及插件机制与可视化界面的扩展方式;同时完善的目录划分和开源规范文件(readme、license等)能帮助开发者降低理解成本,快速将工作流能力集成到实际业务系统中。
1. 简洁到只用6张表,warm-flow 凭什么敢叫自研工作流
如果只是做个请假审批,引入 Flowable 就像给自行车装上波音747的引擎。warm-flow 这个国产自研工作流引擎,用 6 张基础表就把流程定义、任务调度、状态管理串了起来,全部源码只有 215 个文件,其中 Java 源码 158 个,没有复杂的模块矩阵,也没有动辄几十张表的元数据模型。第一次拆它的包结构时,最直观的感受是它把“简单够用”和“可扩展”两件事平衡得很好。中小项目做 OA、报销、合同审批时,往往只需要一套能被业务代码调用的流程引擎,而不是 BPMN 2.0 全家桶。下面就从源码文件布局、自动装配机制、表达式策略、API 用法和并发优化几个方向,把 warm-flow 的骨架拆开,看它是怎么做到既轻量又能接入真实业务的。
2. 从文件清单看架构:spring.factories 与 ExpressionStrategy 如何支撑扩展性
2.1 文件布局:215个文件里的分层线索
拿到源码包后,目录结构与常规 Spring Boot 工程很接近。.env.development是前端环境的变量样例,.editorconfig统一了多 IDE 的代码风格,对多人协作很重要。com.warm.flow.core.expression.ExpressionStrategy这个类路径直接暴露了核心包的命名空间。整体文件可以分成三块:核心模块负责流程引擎和持久化,UI 模块提供可视化流程设计器,插件模块用来挂接监听器和扩展策略。
158 个 Java 文件并没有堆在同一个包里,而是按core、dao、service、entity、listener、expression等职责做了划分。这样当你只想要一个嵌入式流程引擎时,可以直接排除 UI 和插件依赖,核心模块依然能独立运行。对于中小项目,最后打出来的 jar 体积会比 Flowable 小一个数量级。
2.2 自动装配与组件隔离:spring.factories 的用法
warm-flow 能够做到“引入依赖即用”,核心是靠 Spring Boot 的自动装配。spring.factories中一般会写:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.warm.flow.config.WarmFlowAutoConfiguration启动时,Spring Boot 会加载WarmFlowAutoConfiguration,由它负责注入数据源、初始化流程引擎、注册默认策略。这个设计让使用者不用手动创建任何引擎对象,对象生命周期完全交给容器管理。
组件独立还体现在排除机制上。如果暂时用不到插件,可以直接去掉warm-flow-pluginjar,引擎核心功能不受影响。新项目如果基于 Spring Boot 3.x,官方推荐使用AutoConfiguration.imports文件,spring.factories虽然仍被兼容,但建议按照新方式迁移,避免将来升级时出现警告。
2.3 表达式策略:ExpressionStrategy 接口的设计要点
工作流的“下一步去哪”通常由条件表达式决定。warm-flow 没有把表达式引擎写死,而是抽象了一个策略接口。从包名com.warm.flow.core.expression.ExpressionStrategy能看到它属于核心能力,接口大致是这样的:
public interface ExpressionStrategy { String getType(); boolean evaluate(String expression, Map<String, Object> context); }getType()返回策略名,比如spel、amount、groovy;evaluate()根据表达式和上下文变量计算结果。流程定义里只需要保存策略名,运行时引擎会从 Spring 容器里收集所有ExpressionStrategy实现,按策略名派发。
内置的默认实现一般依赖 Spring 的SpelExpressionParser,适合大多数场景。如果项目里已经用了 Aviator 或者自研的轻量规则引擎,新增一个实现类并注册到 Spring 容器即可。这种方式规避了“表达式引擎升级时引发全量改动”的坑,也是 warm-flow 扩展性最直接的体现。
2.4 表结构与核心数据模型:六张基础表的职责划分
6 张表是 warm-flow 的核心卖点。下面是我在工程中比较认可的一套表模型,职责划分与 warm-flow 的设计思路基本一致:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| flow_definition | 流程定义 | id, flow_code, flow_name, version |
| flow_node | 流程节点 | id, definition_id, node_code, node_type, expression_strategy |
| flow_instance | 流程实例 | id, definition_id, business_key, status |
| flow_task | 待办任务 | id, instance_id, node_code, assignee, status, version |
| flow_history | 任务流转历史 | id, instance_id, task_id, operate_type, create_time |
| flow_variable | 流程变量 | id, instance_id, variable_key, variable_value |
这 6 张表的链路关系是:flow_definition定义流程长什么样,flow_instance记录某次业务走到哪,flow_task表示当前谁在处理,flow_history记录每一步审批痕迹,flow_variable存放表达式中用到的金额、申请人等动态数据。节点间跳转关系和条件全部放在flow_node中,省掉了单独一张“连线表”,这是表数量能压到 6 张的直接原因。
这种建模的取舍是把权限直接简化为assignee字段,不设计角色、用户、组关系表。如果业务需要组织架构,可以在外部系统维护,进入引擎前把成员列表算好传给 assignee。在引擎内部做复杂权限,恰恰是 Activiti 显得臃肿的原因之一。
2.5 数据库方言与 SQL 文件适配
源码包中带了 7 个 SQL 文件,这一点很关键。工作流引擎最容易踩的坑就是数据库方言:MySQL 分页用 LIMIT,Oracle 用 OFFSET FETCH,SQL Server 的写法又不一样。把建表脚本和 Mapper XML 按数据库拆分,是让引擎同时跑在多种数据库上的常见做法。
业务项目里,我通常只保留自己环境的 SQL。试用时如果启动后没有自动建表,优先确认init-sql是否配到了正确的classpath路径,或者手动执行一次建表脚本。注意不要重复执行,否则会主键冲突。表前缀最好在一开始确定下来,中途修改会导致 Mapper XML 里的 SQL 全部需要跟着调整。
3. 落地实现:Spring Boot 集成 warm-flow 并跑通第一个审批流
3.1 环境准备与依赖引入
先编译源码包,把它安装到本地 Maven 仓库。进入解压后的目录执行:
cd warm-flow mvn install -DskipTests执行成功后再在业务项目里引入核心依赖:
<dependency> <groupId>com.warm.flow</groupId> <artifactId>warm-flow-core</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency>需要可视化设计器和插件能力时,再分别引入warm-flow-ui和warm-flow-plugin。这里的版本号以你本地mvn install后输出的实际版本为准,不要照抄示例。
3.2 数据库初始化和配置参数
建好数据库后,执行对应数据库的 SQL 脚本。以 MySQL 为例,Spring Boot 配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/warm_flow username: root password: root warm-flow: table-prefix: flw_ init-sql: classpath:sql/mysql.sqltable-prefix用于多套系统共用一个数据库时区分表名,避免冲突。init-sql可以在启动时自动执行建表脚本,但有一个容易忽略的细节:脚本内部建表语句的表名不会自动加上前缀。如果你改了table-prefix,必须同步修改 SQL 文件里的表名,比如把flow_definition改成flw_flow_definition,否则启动后会报“表不存在”。
3.3 用代码定义一条“请假→审批”流程
warm-flow 的流程定义支持 XML 和流式 API 两种方式。我更推荐流式 API,因为编译期就能发现节点命名错误。一条最简单的请假审批流如下:
FlowDefinition definition = FlowBuilder.build("leave") .flowName("请假审批") .node("start").name("开始").type("start") .node("apply").name("员工申请").type("userTask") .assignee("${applyUser}") .node("manager").name("经理审批").type("userTask") .assignee("${managerUser}") .node("end").name("结束").type("end") .condition("amount > 1000", "apply", "manager") .condition("amount <= 1000", "apply", "end") .save();assignee("${applyUser}")使用的是流程变量占位符,发起时会从变量集合中把applyUser替换成实际工号。condition的语义是:当金额大于 1000 时,从apply节点跳转到manager节点,否则直接到end。条件表达式默认走 SpEL 策略,如果字符串里混入类型不匹配的数据,运行期会抛出转换异常。
3.4 发起流程与完成任务的核心 API
流程定义保存好后,业务侧发起一次申请:
FlowInstance instance = flowRuntimeService.startProcessInstance( "leave", "BIZ20250314001", Map.of("applyUser", "U1001", "managerUser", "M2001", "amount", 800));参数从左到右分别是流程编码、业务主键、流程变量。流程编码对应flow_definition.flow_code,业务主键用于后续回查业务表,流程变量参与节点条件判断和任务负责人分配。
完成任务时,需要一个任务 ID 和当前操作人:
flowTaskService.complete("T10001", "U1001", Map.of("amount", 800));complete执行前,引擎会校验任务是否待办、操作人是否与assignee匹配。实际业务中要避免硬编码任务 ID,正确做法是先查待办列表拿到任务 ID,再执行完成操作。
3.5 查询待办与历史记录
待办列表是工作流最高频的查询。接口风格类似 MyBatis-Plus:
FlowPage<Task> todoPage = flowTaskService.todoList( FlowPage.page(1, 10), Wrappers.lambdaQuery(Task.class) .eq(Task::getAssignee, "U1001") .ne(Task::getStatus, "done"));todoList会过滤掉已完成和已挂起的任务,直接贴合“待办”语义。第一个参数是分页对象,第二个参数是条件构造器,可继续拼接时间范围、节点编码等条件。
历史记录查询推荐直接读取flow_history:
List<History> historyList = flowHistoryService.list( Wrappers.lambdaQuery(History.class) .eq(History::getInstanceId, instance.getId()) .orderByAsc(History::getCreateTime));flow_history是只增不改的,每次任务创建、完成、驳回都会写入一条记录,不要对它做更新操作。前端的时间线可以直接用这个结果渲染。
下面是常用 API 的速查表:
| 方法 | 作用 | 关键参数 |
|---|---|---|
| startProcessInstance | 发起流程 | flowCode, businessKey, variables |
| complete | 完成任务 | taskId, operator, variables |
| todoList | 查询待办 | page, queryWrapper |
| historyList | 查询历史 | instanceId, order |
3.6 驳回与任意跳转的处理思路
只走直线流程的项目很少,驳回是刚需。warm-flow 的节点类型允许定义驳回目标,常见做法是在任务对象上加一个permittedRejectTargets字段,保存当前节点允许驳回的节点集合,接口调用时做合法性校验,避免用户乱跳。
如果需要在运行期强制改变实例方向,可以调用实例服务把当前节点切到目标节点。但这种操作在生产环境要非常谨慎,它会跳过原路径上的其他审批节点。建议在业务层封装一层操作日志,把每次强制跳转的操作者、原因、目标节点都记录到自有表里,故障时才能回溯。
4. 扩展点实战:从流程适配器到自定义条件策略
4.1 为什么说组件独立:模块依赖关系
“组件独立”意味着核心模块没有和 Spring Web 层、持久层实现强绑定。阅读源码时注意com.warm.flow.core下主要是接口和抽象类,MyBatis 的具体操作被拆到dao层,并注入SqlSessionTemplate。如果项目用 JPA 而不是 MyBatis,可以仿照 Mapper 接口重新实现数据操作,不需要改动引擎的调度逻辑。
这种依赖倒置关系是扩展性的基础。流程引擎只关心FlowTaskMapper接口,不关心背后是 MyBatis 还是 JdbcTemplate。很多自研工作流之所以难扩展,就是因为 service 层的大类里堆满了持久化细节。warm-flow 把接口与实现分离,替换存储层时只需要新写一套 mapper 实现。
4.2 实现一个自有表达式策略
假设你不喜欢 SpEL 的语法,希望直接支持amount>1000这种紧凑写法。定义一个策略实现ExpressionStrategy:
@Component public class AmountExpressionStrategy implements ExpressionStrategy { @Override public String getType() { return "amount"; } @Override public boolean evaluate(String expression, Map<String, Object> context) { String[] parts = expression.split(">"); BigDecimal actual = new BigDecimal(context.get(parts[0].trim()).toString()); return actual.compareTo(new BigDecimal(parts[1].trim())) > 0; } }然后在流程定义里通过重载指定策略名:
.condition("amount>1000", "apply", "manager", "amount")如果不传策略名,默认会走spel。这里特意用BigDecimal而不是Double,因为金额比较用浮点数会出精度问题。表达式解析失败时,优先检查流程变量是否真的传了对应 key,以及变量值是否能转换成预期类型。
4.3 通过插件机制接入业务逻辑
插件模块是 warm-flow 里常被忽略的亮点。它允许在任务创建、完成、驳回等生命周期节点插入业务行为。插件接口大致如下:
public interface FlowTaskListener { default void onTaskCreated(Task task) {} default void onTaskCompleted(Task task) {} default void onTaskRejected(Task task) {} }在 Spring Boot 工程中,实现类注册成 Bean,并通过spring.factories声明:
com.warm.flow.plugin.FlowTaskListener=\ com.example.listener.WeComNotificationListenerWeComNotificationListener里可以根据节点编码判断是否发送企微通知。业务通知逻辑和引擎完全隔离,后续去掉通知功能时,只需要移除插件依赖,引擎代码不受影响。这种方式比在 service 里写if (type.equals("notify"))干净得多。
4.4 多人会签的轻量实现
中小项目中常见的会签需求,不需要引入额外的流程引擎能力。我一般用flow_variable表存计数器,进入会签节点时初始化count和passed,每名审批人完成任务时更新两个变量,当passed达到阈值后自动跳转。监听器里可以实现这段逻辑:
public void onTaskCompleted(Task task) { Map<String, Object> variables = flowRuntimeService.getVariables(task.getInstanceId()); int passed = (int) variables.getOrDefault("passed", 0); variables.put("passed", ++passed); int count = (int) variables.getOrDefault("count", 1); if (passed >= count) { flowTaskService.complete(task.getId(), task.getAssignee(), variables); } }这个示例展示的是串行会签写法。如果要求并行会签,warm-flow 原生不支持并行网关时,需要拆成多个审批节点,并在最后一个节点完成时统一加签。这也是选型时要考虑清楚的边界。
4.5 与 Flowable/Activiti 怎么选
| 维度 | warm-flow | Flowable / Activiti |
|---|---|---|
| 表数量 | 6 张 | 70+ 张 |
| BPMN 2.0 | 部分或非严格 | 完整支持 |
| 学习曲线 | 低 | 高 |
| 扩展策略 | 接口式 | 配置驱动 + 事件 |
| 适合场景 | 中小项目、嵌入式 | 复杂流程平台 |
| 运行体积 | 小 | 重 |
如果团队里都是熟悉 Spring Boot 的工程师,但没有专门研究过工作流规范,用 warm-flow 可以在一周内把审批流跑起来。如果流程引擎未来要作为低代码平台底座,需要支持复杂的定时器、子流程、多实例会签,那 Flowable 仍然是更稳妥的选择。warm-flow 的价值不在于替代 Flowable,而是在它够用的场景里把复杂度降到最低。
5. 性能优化与排错技巧:从日志到并发控制
5.1 控制台日志与常见异常定位
使用 warm-flow 时,把包级别日志调到 DEBUG 能快速定位问题:
logging: level: com.warm.flow: debug最常见的异常有两种。第一种是找不到表,通常是init-sql没生效或者表前缀配置不一致,启动日志里会打印实际的 SQL 执行记录,对照检查即可。第二种是表达式解析异常,比如流程变量里传了字符串"abc",表达式却执行了数值比较,此时要回到流程定义处检查condition中的字段名和变量 key 是否完全一致。
5.2 六张表的索引设计建议
表数量少不等于不需要索引。下面三组索引是必须加的:
ALTER TABLE flow_instance ADD INDEX idx_instance_biz (business_key); ALTER TABLE flow_task ADD INDEX idx_task_assignee_status (assignee, status); ALTER TABLE flow_history ADD INDEX idx_history_instance_id (instance_id);理由很直接:流程实例经常用business_key反查,待办列表的查询条件基本是assignee + status,历史记录总是按instance_id聚合。不加索引时,流程量过万就会出现慢查询,尤其在任务表上。
5.3 任务重复提交与乐观锁处理
最后一个高频坑是重复提交。前端连点两次、MQ 重试都可能让同一个任务被complete两次。warm-flow 的flow_task表通常带有version字段,更新时把版本号作为条件:
int rows = flowTaskService.update() .eq(Task::getId, taskId) .eq(Task::getVersion, version) .set(Task::getStatus, "done") .set(Task::getVersion, version + 1) .update(); if (rows == 0) { throw new BusinessException("任务已被处理,请勿重复提交"); }影响行数为 0 说明版本号已经被其他请求修改,直接拒绝。这种乐观锁方案在任务更新场景下足够,不需要引入 select for update。如果并发量极高,再配合 Redis 分布式锁做一层请求级别的兜底,双保险后基本不会再出现重复审批。
本文还有配套的精品资源,点击获取