Appearance
设计原理 05 · SPI 设计——接口的边界在哪里
系列:jeeflow 工作流引擎设计原理
1. 设计原则
引擎的 SPI 划分遵循一个原则:
引擎只依赖抽象,不依赖实现;只有"换不掉"的才做必选 SPI,其余全部可选。
| SPI | 必须/可选 | 理由 |
|---|---|---|
ProcessRepository | 必须 | 引擎读写数据的唯一通道,不可绕过 |
UserProvider | 可选 | 不注入则变量里没有用户信息,引擎照跑 |
IDGenerator | 可选 | 默认时间戳方案,业务方想用雪花可替换 |
ExpressionEvaluator | 可选 | 不用决策表达式就不需要 |
TransactionTemplate | 可选 | 内存测试不需要事务 |
JsonProvider | 可选(Java 内建) | JSON 解析内部有默认实现 |
2. ProcessRepository——为什么它是唯一的必须 SPI
2.1 接口边界
interface ProcessRepository:
# 流程定义
findDefineById(id)
saveDefine(def) / updateDefine(def) / updateDefineState(id, state) / removeDefine(id) # v1.0.1:定义写操作
# 流程实例
findInstanceById(id) / saveInstance(inst) / updateInstance(inst)
# 任务
findTaskById(id) / saveTask(task) / updateTask(task)
findDoingTasks(instanceId, taskNames?) / findDoneTasks(...)
findHistoryTasks(instanceId)
# 参与者
findTaskActors(taskId) / addTaskActor(taskId, actors) / removeTaskActor(taskId, actors)
# 抄送
createCcInstance(instanceId, creator, ...actorIds) / updateCcStatus(instanceId, actorId)v1.0.1(集成反馈):
saveDefine / updateDefine / updateDefineState / removeDefine让集成方不再需要 直写wf_process_define表做定义增删改/启停(设计器 deploy、启停端点直接走仓储)。updateInstance(inst)会级联持久化聚合根内任务状态变更(撤回/挂起/废弃等随同落库, 与实例更新同连接保证一致)——聚合根内任务副本需反映最新状态。
2.2 设计要点
a) 面向"聚合"而非"表"
接口操作的是 ProcessInstance 整体和 ProcessTask 整体,不是 updateState() 这种字段级方法。仓储实现内部映射到 5 张表,引擎不感知表结构——这正是"同一套表可被不同语言共享"的前提(SPEC §2)。
b) 读方法返回"完整聚合"
findInstanceById 返回实例 + 全部任务 + 参与者(引擎执行推进时不需要再查任务列表):
findInstanceById(id):
instance = 查 wf_process_instance
tasks = 查 wf_process_task where instance_id = id(含 actorIds)
return instance(聚合完整)c) 没有分页/统计方法
分页、统计是"查询视图",属于业务层(demo 的 page/todoList 端点自己遍历内存仓库实现)。引擎核心只做执行所需的读写——接口最小化,实现才简单(一个内存 Map 就能实现,测试零成本)。
2.3 内存仓储的价值
内置内存仓储(四版都有)不是玩具,它让:
- 引擎单测零配置(不依赖数据库)
- demo 开箱即用
- 仓储接口的"可实现性"始终被验证(内存实现能跑通,接口就没设计过度)
3. UserProvider——为什么可选
interface UserProvider:
getUser(userId): UserInfo # userId/realName/deptId/...引擎在每次操作(启动/完成任务)时调用它,把用户信息注入流程变量(u_userId / u_realName / u_deptId 等)。
可选的代价:不注入 UserProvider,u_* 变量缺失,决策表达式若引用了会取不到值。但引擎核心路径(创建任务/状态转换)完全不依赖用户信息——所以可选是安全的。
注:变量的 key 前缀 u_ 与 mldong 框架完全一致,保证决策表达式在 jeeflow 与 mldong 框架 间可移植(见 06-契约约定)。
4. ExpressionEvaluator——为什么可选
interface ExpressionEvaluator:
eval(expr, vars): any决策表达式(amount > 1000)的求值:字符串 → 结果。
为什么不做内置实现?表达式语言是典型的"可以有很多答案"的问题:Java 可用 SpEL / MVEL / Aviator;Python 可用 eval(危险)/ simpleeval;Go 有 govaluate。引擎不做选择,业务方选自己熟悉的。
可选的代价:没有求值器时,决策节点只能走 Registry/扩展处理器,或第一个无 expr 的出边作为默认分支。
5. 事务边界(TransactionTemplate)
interface TransactionTemplate:
execute(action): void # 在事务中执行 action引擎内部不带 @Transactional:引擎方法是"编排",跨多个仓储调用,事务应该由集成层包一层:
java
// 集成层(业务方)
transactionTemplate.execute(() -> engine.executeProcessTask(taskId, operator, args));为什么这样设计:
- 内存仓储/测试不需要事务(裸执行)
- 不同存储(MySQL / PostgreSQL / NoSQL)事务语义不同,引擎不绑死
- JDBC 版 jeeflow-store-jdbc 提供默认 TransactionTemplate(给 DataSource 即可)
6. 依赖注入:构造器 + 注册表
引擎的 SPI 全部通过构造器注入(显式、可测),扩展点通过 setExtensions / HandlerRegistry 注入:
new EngineImpl(repo, userProvider?, idGenerator?, exprEvaluator?) # 必选+可选
engine.setExtensions(ext) # 拦截器/事件/扩展处理器
engine.setRegistry(registry) # 命名处理器不需要服务定位器(ServiceLocator)——全局单例的隐式依赖是测试地狱。
7. 四版实现对照
| SPI | Java | Go | Node.js | Python |
|---|---|---|---|---|
| 仓储 | spi/IProcessRepository.java | spi/spi.go | src/spi.ts | jeeflow/spi.py |
| 用户 | spi/IUserProvider.java | spi/spi.go | src/spi.ts | jeeflow/spi.py |
| 表达式 | spi/IExpressionEvaluator.java | spi/spi.go | src/spi.ts | jeeflow/spi.py |
| ID | spi/IIdGenerator.java | spi/spi.go | src/spi.ts | jeeflow/spi.py |
| 内存仓储 | test 模块 | memory/repository.go | src/memory.ts | jeeflow/memory.py |
命名约定:Go 导出方法 PascalCase、Python snake_case,语义与 Java camelCase 一一对应(完整对照表见 SPEC §7)。