零依赖的秘密:8 大 SPI 设计

系列定位 :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);
    // ...
}

关键设计

  1. 面向聚合而非表 ------saveInstance(instance) 保存的是整个聚合根(实例+任务+变量),不是单张表的一行。引擎不知道底层有几张表。

  2. 读方法返回完整聚合 ------findInstanceById 返回的 ProcessInstance 包含所有子任务和参与者,引擎拿到就能直接操作。

  3. 分页方法返回轻量 DTO ------TaskRow/InstanceRow 是扁平的行对象,不是领域实体。前端列表不需要加载完整聚合。

  4. 内存仓储验证接口 ------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 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍 "#") ------ performTypecountersignType 怎么组合出三种会签模式?一票否决(submitType=20)的级联废弃怎么做?


参考资料


下一篇预告 :[第 8 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍](#第 8 篇 · 会签三兄弟:并行/串行/按比例的实现与取舍 "#") ------ performTypecountersignType 怎么组合出三种会签模式?一票否决(submitType=20)的级联废弃怎么做?

相关推荐
Diligently_3 小时前
Anolis 系统更新与密码设置&磁盘扩容
java·后端·spring
mqiqe3 小时前
线程调度与 Schedulers:Project Reactor 并发模型的核心引擎
java·开发语言·网络
我命由我123453 小时前
Android 开发问题:unexpected element <service> found in <manifest>
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
正在走向自律4 小时前
飞算JavaAI能搞定充电站故障处置吗?
java·ai编程·java开发·飞算javaai·java代码生成·ai coding模型·springboo
用户40966601317514 小时前
Guava 不止有 Lists 和 Maps:Cache、EventBus、Multimap 实战
java·后端
一只叫煤球的猫4 小时前
从 Java 21 到 Java 25:ThreadLocal 以外的选择—— ScopedValue
java·后端·架构
步行cgn5 小时前
MyBatis 多对一关联映射详解
java
仍然.5 小时前
SpringCloud---Eureka介绍
java·spring cloud·eureka