系列定位 :jeeflow 系列第 7 篇(第二季「核心设计」第 4 篇) 平台 :掘金(代码密度高,原理讲透) 素材版本 :引擎 v1.8.16,源码取自 jeeflow-java 前置阅读 :第 5 篇 · DDD 聚合根与充血模型
一、98KB 的引擎,依赖了什么?
打开 jeeflow-core 的 pom.xml,运行时依赖只有一行:
xml
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
provided 意味着连 slf4j 都不打包------由集成方提供。junit、jackson、slf4j-simple 全部是 test scope,不进 JAR。
jeeflow-core 编译产物:零框架依赖。 不碰 Spring、不碰 MyBatis、不碰 Jackson、不碰任何 ORM。
一个工作流引擎,不依赖 JSON 库怎么解析流程定义?不依赖 ORM 怎么持久化?不依赖 Spring 怎么管理事务?
答案:SPI(Service Provider Interface)。引擎只定义接口,实现交给集成方。
二、SPI 全景图
jeeflow 定义了 8 个 SPI 接口,按必须/可选分为两档:
| SPI | 必须/可选 | 职责 | 不注册的后果 |
|---|---|---|---|
IProcessRepository |
必须 | 聚合仓储------引擎读写数据的唯一通道 | 引擎无法启动 |
IJsonProvider |
实际必须 | JSON 序列化/反序列化 | 流程定义无法解析 |
IUserProvider |
可选 | 用户信息提供者 | u_* 变量缺失,核心流转不受影响 |
IOrgUserProvider |
可选 | 组织维度用户提供者 | 部门领导/角色取人不可用 |
IExpressionEvaluator |
可选 | 条件表达式求值器 | 决策节点只能走默认分支 |
IIdGenerator |
可选 | ID 生成器 | 使用内置时间戳+序号方案 |
ITransactionTemplate |
可选 | 事务模板 | 裸执行(适合内存测试) |
IActionPermissionProvider |
可选 | 权限码映射 | 使用内置默认规则 |
设计原则 :只有"换不掉"的才做必选 SPI。IProcessRepository 是引擎与持久化的唯一边界,必须注册。其余全部可选------不注册就用默认行为,引擎照跑。
三、逐一拆解
3.1 IProcessRepository ------ 面向聚合,不面向表
java
public interface IProcessRepository {
// 流程定义 CRUD
ProcessDefine findDefineById(Long defineId);
void saveDefine(ProcessDefine define);
void updateDefine(ProcessDefine define);
// 聚合根操作
ProcessInstance findInstanceById(Long instanceId);
void saveInstance(ProcessInstance instance);
void updateInstance(ProcessInstance instance);
// 任务操作
ProcessTask findTaskById(Long taskId);
List<ProcessTask> findDoingTasks(Long instanceId, String[] taskNames);
List<ProcessTask> findDoneTasks(Long instanceId, String[] taskNames);
// 参与者操作
void addTaskActor(Long taskId, List<String> actors);
List<String> findTaskActors(Long taskId);
// 抄送
void createCcInstance(Long instanceId, String creator, String... actorIds);
// 前端分页查询(轻量行 DTO)
PageResult<TaskRow> pageTodoTasks(PageQuery query);
PageResult<InstanceRow> pageInstances(PageQuery query);
// ...
}
关键设计:
-
面向聚合而非表 ------
saveInstance(instance)保存的是整个聚合根(实例+任务+变量),不是单张表的一行。引擎不知道底层有几张表。 -
读方法返回完整聚合 ------
findInstanceById返回的ProcessInstance包含所有子任务和参与者,引擎拿到就能直接操作。 -
分页方法返回轻量 DTO ------
TaskRow/InstanceRow是扁平的行对象,不是领域实体。前端列表不需要加载完整聚合。 -
内存仓储验证接口 ------jeeflow-core 的测试包里有一个
MemoryProcessRepository(HashMap 实现)。如果内存实现能跑通全部测试,说明接口没有设计过度。
3.2 IJsonProvider ------ 引擎不绑定 JSON 库
java
public interface IJsonProvider {
String toJson(Object obj);
<T> T fromJson(String json, Class<T> type);
<T> T fromJson(String json, TypeReference<T> typeRef);
boolean isJson(String str);
}
引擎内部需要 JSON 的地方:
- 解析流程定义 JSON(
ProcessModel从 JSON 构建) - 序列化/反序列化流程变量(
FlowData) - 持久化时的变量存储
为什么不让引擎直接依赖 Jackson?
因为集成方可能用 Fastjson、Gson、甚至自研 JSON 库。引擎定义接口,集成方决定用什么 JSON 库------Spring Boot 集成用 Jackson,GoFrame 集成用 Go 标准库 encoding/json,PHP 集成用 json_encode/json_decode。
3.3 IUserProvider ------ 可选的用户信息注入
java
public interface IUserProvider {
/** 一次返回用户全部信息(避免多次 IO) */
UserInfo getUser(String userId);
class UserInfo {
String userId, realName, deptId, deptName, postId, postName;
static UserInfo of(String userId) { ... }
}
}
引擎在启动流程时调用 FlowUtil.addUserInfoToArgs(operator, args, userProvider),把用户信息注入到流程变量中:
java
// 注入后,流程变量里有:
// u_userId = "admin"
// u_realName = "蒙立东"
// u_deptId = "dept_rnd"
// u_deptName = "研发部"
这些变量可以在决策表达式中使用:u_deptId == 'dept_finance'。
不注册的代价 :u_* 变量全部缺失。决策表达式引用不到用户信息,但核心流转(发起→审批→结束)不受影响。
3.4 IExpressionEvaluator ------ 条件分支的大脑
java
public interface IExpressionEvaluator {
Object eval(String expression, Map<String, Object> context);
}
决策节点(snaker:decision)的边上挂着条件表达式:
json
{
"sourceNodeId": "decision1",
"targetNodeId": "task_manager",
"properties": { "expr": "amount > 1000" }
}
引擎调用 evaluator.eval("amount > 1000", context) 判断走哪条边。
Spring Boot 集成用 SpEL(Spring Expression Language):
java
public class SpelExpressionEvaluator implements IExpressionEvaluator {
public Object eval(String expression, Map<String, Object> context) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext ctx = new StandardEvaluationContext();
context.forEach(ctx::setVariable);
return parser.parseExpression(expression).getValue(ctx);
}
}
不注册的代价:决策节点只能走默认分支(第一条边)。简单审批流程不需要表达式,所以这个 SPI 是可选的。
3.5 IIdGenerator ------ ID 生成策略
java
public interface IIdGenerator {
long nextId();
}
引擎需要生成 ID 的地方:流程实例 ID、任务 ID、抄送记录 ID。
不注册的默认方案:时间戳 + 序号。简单可靠,但分布式场景下可能冲突。
Spring Boot 集成默认用雪花算法:
java
public class SnowflakeIdGenerator implements IIdGenerator {
// 标准雪花算法实现
public long nextId() { ... }
}
业务方可以替换为数据库序列、UUID、或任何自定义方案。
3.6 ITransactionTemplate ------ 事务由集成层包裹
java
public interface ITransactionTemplate {
<T> T execute(Supplier<T> action);
default void execute(Runnable action) { ... }
@FunctionalInterface
interface Supplier<T> { T get() throws Exception; }
}
引擎内部不带事务注解 。所有需要事务的操作通过 runInTx() 包裹:
java
private <T> T runInTx(Supplier<T> action) {
ITransactionTemplate tx = ServiceContext.find(ITransactionTemplate.class);
if (tx != null) return tx.execute(action); // 有事务包事务
return action.get(); // 无事务裸执行
}
Spring Boot 集成:
java
public class SpringTransactionTemplate implements ITransactionTemplate {
@Autowired
private PlatformTransactionManager txManager;
public <T> T execute(Supplier<T> action) {
return new TransactionTemplate(txManager).execute(status -> {
try { return action.get(); }
catch (Exception e) { throw new RuntimeException(e); }
});
}
}
设计哲学 :引擎不知道事务是什么。内存测试不需要事务,Spring 集成用 @Transactional,GoFrame 集成用数据库事务------引擎只调用 execute(),具体怎么事务由集成层决定。
四、SPI 注册机制:零依赖的 DI
jeeflow 没有用 Spring 的 DI 容器(核心不依赖 Spring),而是自己实现了一个轻量级的服务定位器:
4.1 Context 接口
java
public interface Context {
void put(String name, Object object);
void put(String name, Class<?> clazz);
<T> T find(Class<T> clazz);
<T> List<T> findList(Class<T> clazz);
}
4.2 SimpleContext ------ 零依赖实现
java
public class SimpleContext implements Context {
private final Map<String, Object> objects = new ConcurrentHashMap<>();
private final Map<String, Class<?>> classes = new ConcurrentHashMap<>();
public <T> T find(Class<T> clazz) {
// 按类型遍历匹配
for (Object obj : objects.values()) {
if (clazz.isInstance(obj)) return clazz.cast(obj);
}
return null;
}
}
一个 ConcurrentHashMap,按类型查找。零框架,零反射开销(除了 isInstance)。
4.3 ServiceContext ------ 静态门面
java
public final class ServiceContext {
private static volatile Context context;
public static <T> T find(Class<T> clazz) {
T result = context.find(clazz);
if (result == null) throw new JeeflowException("SPI_NOT_REGISTERED: " + clazz.getName());
return result;
}
}
全局静态访问点。引擎任何地方都能通过 ServiceContext.find(IProcessRepository.class) 拿到仓储实现。
4.4 Spring Boot 自动装配
java
@Configuration
@ConditionalOnClass(JeeflowEngine.class)
public class JeeflowAutoConfiguration {
@Bean @ConditionalOnMissingBean
IJsonProvider jeeflowJsonProvider() { return new JacksonJsonProvider(); }
@Bean @ConditionalOnMissingBean
IIdGenerator jeeflowIdGenerator() { return new SnowflakeIdGenerator(); }
@Bean @ConditionalOnMissingBean
ITransactionTemplate jeeflowTransactionTemplate(PlatformTransactionManager txm) {
return new SpringTransactionTemplate(txm);
}
@Bean
JeeflowEngine jeeflowEngine(ObjectProvider<IProcessRepository> repo, ...) {
repo.ifAvailable(r -> ServiceContext.put("repository", r));
// 所有 SPI 通过 ServiceContext.put() 注册
}
}
@ConditionalOnMissingBean 的魔力:业务方只需要声明一个同名 Bean,就能覆盖任意 SPI 的默认实现。零配置扩展。
五、AssignmentHandler ------ 第七个 SPI
除了上面 8 个基础设施 SPI,jeeflow 还有一个业务层面的 SPI:
java
public interface AssignmentHandler {
String assign(Execution execution);
}
参与者处理器决定"这个任务该分配给谁"。引擎内置了 7 个实现:
| Handler | 职责 | 依赖的 SPI |
|---|---|---|
OperatorAssignmentHandler |
流程发起人 | 无 |
ApplicantDeptLeaderAssignmentHandler |
发起人部门经理 | IOrgUserProvider |
ApplicantDeptMainLeaderAssignmentHandler |
发起人部门分管领导 | IOrgUserProvider |
DeptLeaderAssignmentHandler |
当前用户部门经理 | IOrgUserProvider |
DeptMainLeaderAssignmentHandler |
当前用户部门分管领导 | IOrgUserProvider |
FormFieldAssigneeHandler |
按表单字段值分配 | 无 |
TaskRoleAssigneeHandler |
按节点关联角色分配 | IOrgUserProvider |
关键设计 :组织维度的 handler(部门领导、角色取人)依赖 IOrgUserProvider SPI 取数据,handler 本身不直连组织服务。业务方只需要实现 IOrgUserProvider 的 3 个方法:
java
public interface IOrgUserProvider {
List<String> findDeptLeaders(String deptId);
List<String> findDeptMainLeaders(String deptId);
List<String> findByRole(String roleCode);
}
7 个 handler 自动复用这 3 个方法。业务方不用写 handler,只写数据接口。
六、为什么是 SPI 而不是插件?
有朋友问过:为什么不搞个插件市场,让社区贡献 SPI 实现?
因为 jeeflow 的 SPI 设计目标不是"可扩展",而是**"可替换"**。
- 插件:在引擎之上加功能(比如加一个钉钉通知插件)
- SPI:把引擎的依赖点暴露出来,让集成方用自己的实现替换
jeeflow 的 8+1 个 SPI 覆盖了引擎的所有外部依赖点:
- 数据存储 →
IProcessRepository - JSON 解析 →
IJsonProvider - 用户信息 →
IUserProvider - 表达式求值 →
IExpressionEvaluator - ID 生成 →
IIdGenerator - 事务管理 →
ITransactionTemplate
引擎核心不持有这些依赖的具体实现,所以它能跑在 Spring Boot 上、GoFrame 上、FastAPI 上、Laravel 上------换框架不换引擎。
结语
98KB 的引擎,零框架依赖,靠 8+1 个 SPI 接口撬动五语言生态。
SPI 不是架构洁癖,是工程刚需------五语言同构的前提是引擎核心不绑定任何语言特定的框架。Java 引擎不绑 Spring,Go 引擎不绑 Gin,Python 引擎不绑 FastAPI------它们共享同一套 SPI 契约。
下一篇预告:[第 8 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍](#第 8 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍 "#") ------ performType 和 countersignType 怎么组合出三种会签模式?一票否决(submitType=20)的级联废弃怎么做?
参考资料
- jeeflow GitHub 仓库
- jeeflow 文档站 · SPI 设计
- 开源演示站(五语言后端切换)
- 集成演示站(admin/123456)
- IProcessRepository 源码
- JeeflowAutoConfiguration 源码
下一篇预告 :[第 8 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍](#第 8 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍 "#") ------
performType和countersignType怎么组合出三种会签模式?一票否决(submitType=20)的级联废弃怎么做?