Skip to content

设计原理 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. 四版实现对照

SPIJavaGoNode.jsPython
仓储spi/IProcessRepository.javaspi/spi.gosrc/spi.tsjeeflow/spi.py
用户spi/IUserProvider.javaspi/spi.gosrc/spi.tsjeeflow/spi.py
表达式spi/IExpressionEvaluator.javaspi/spi.gosrc/spi.tsjeeflow/spi.py
IDspi/IIdGenerator.javaspi/spi.gosrc/spi.tsjeeflow/spi.py
内存仓储test 模块memory/repository.gosrc/memory.tsjeeflow/memory.py

命名约定:Go 导出方法 PascalCase、Python snake_case,语义与 Java camelCase 一一对应(完整对照表见 SPEC §7)。


下一篇06 · 契约约定——引擎与调用方的边界

jeeflow · 轻量级多语言工作流引擎