Skip to content

SPI 聚合机制设计

forge 框架的内置模块(系统模块、权限模块……)需要把自己的扫描包、方言实现、鉴权实现「贡献」给框架内核,而内核不应该反过来认识每一个模块。本篇解释 forge 为什么用一套基于 META-INF/services 的 SPI 设施来承载这种贡献,以及它如何在「互斥单选」与「聚合贡献」两类场景间复用同一套加载逻辑。

如果你想知道怎么落地一个新的扩展点、写一个 ScanPackageProvider,请直接看 应用架构使用指南;本篇只讲为什么这么设计。

一句话概括

内核只认识接口(@SPI),实现者通过 META-INF/services 把自己挂上来,Spider 负责加载与排序。内核与模块之间没有编译期依赖的「反向箭头」。

为什么不直接硬编码

最朴素的做法是:在启动器里写死一份包列表、一份方言映射、一份鉴权实现。问题在于,框架内核(forge-moduleforge-metadata)位于依赖图的底层,而 forge-bootforge-auth、各 forge-dialects 位于上层。硬编码意味着底层启动代码要 import 上层模块的类,形成方向错误的依赖,每新增一个模块都要回头改内核。

forge 的选择是把这种「上层向下层登记」的关系交给 SPI:内核只定义接口,实现散落在各模块的 META-INF/services 里,启动时由 Spider 统一发现。

注意所有「编译期依赖」的实线都从上层模块指向内核接口,Spider 在运行期才把实现拉回来。这正是开闭原则的体现:内核对修改关闭,对扩展开放。

SPI 设施的三个角色

forge 的 SPI 设施位于 forge-spi 模块,由三个类构成清晰的分层:

角色职责
标记SPI注解,标在可扩展接口上;内嵌 @SPI.Service 用于给实现命名
加载与排序SpiLoader基于 java.util.ServiceLoader 读取 META-INF/services,按 @Order 升序排序
门面Spider对外暴露 getDefaultExtension / getExtension(name) / getAllExtensions 三个静态入口

@SPI 注解本身只标记类型,并内嵌一个 @SPI.Service 用于声明实现名称:

java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface SPI {
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @interface Service {
        String value() default "";  // 实现名称,缺省按 JavaBean 规范派生
    }
}

SpiLoader 没有自己造轮子去解析配置文件,而是复用 JDK 标准的 java.util.ServiceLoader:实现者把全限定类名写进 META-INF/services/<接口全限定名> 即可被发现。这带来一个直接好处——任何 IDE、构建工具、jar 校验器都天然认识这套约定,不需要额外的注册中心。SpiLoader 在标准 ServiceLoader 之上只补了两件事:按 Spring 的 @Order 值升序排序,以及按名称索引(名称来自 @SPI.Service(value),缺省按 JavaBean 规范派生)。

两类扩展模式

同一套加载逻辑,Spider 用三个入口覆盖了两类语义截然不同的扩展场景。

互斥单选

某些扩展点在一次运行中只能有一个实现生效:一个应用连的是 MySQL 还是别的库(SqlDialect)、用 SaToken 还是 Spring Security 做鉴权(AuthProvider)。这类场景调用 getDefaultExtension()(取 @Order 最小的实现)或 getExtension(name)(按名称精确选择),从候选里挑出唯一胜者,其余实现被忽略。

聚合贡献(union)

另一些扩展点的语义是「人人有份」:每个内置模块都想把自己的包加进扫描范围,谁都不该被忽略。ScanPackageProvider 正是这种模式——框架遍历所有实现,把每个实现 contributePackages() 返回的列表全部并集进来。

java
@SPI
public interface ScanPackageProvider {
    /**
     * 贡献需纳入扫描的包名列表(如 List.of("cn.cvking.forge.auth"))。
     * 不可返回 null
     */
    List<String> contributePackages();
}

Spider 的聚合入口非常薄:

java
public static <T> List<T> getAllExtensions(Class<T> type) {
    return SpiLoader.getLoader(type).getAllExtensions();
}

模式与契约要对齐

聚合贡献模式下,实现方法不可返回 null(见 ScanPackageProvider 注释),否则并集时会触发空指针。这是「全部生效」语义对实现者的强约束——它不像单选那样可以靠 @Order 兜底挑一个。

下表把两类模式的关键差异并排呈现:

维度互斥单选聚合贡献 union
典型扩展点SqlDialectAuthProviderScanPackageProvider
Spider 入口getDefaultExtension / getExtension(name)getAllExtensions
多实现共存时只选一个,其余忽略全部生效,结果并集
@Order 作用决定默认胜者决定遍历/合并顺序
实现返回 null不一定致命(可被其它实现取代)危险,破坏并集语义
设计意图在互斥能力间二选一让分散的贡献汇成一份全集

为什么用 SPI 让内置模块贡献扫描包

扫描包的聚合是 forge 里最典型的 union 场景,也最能说明 SPI 相对硬编码的价值。启动时 BFPP 需要一份「该扫描哪些包找 @Model / @Module」的清单:业务应用包由 Spring Boot 的 AutoConfigurationPackages 提供,而框架内置模块的包(如 cn.cvking.forge.boot.syscn.cvking.forge.auth)则通过 ScanPackageProvider 聚合进来。

落到代码事实上,权限模块通过 forge-authMETA-INF/services/cn.cvking.forge.module.spi.ScanPackageProvider 文件登记了 ForgeScanPackageProviderImpl,系统模块通过 forge-boot 的同名文件登记了 SysScanPackageProvider。BFPP 不知道这两个类的存在,只对 ScanPackageProvider 接口编程。

设计取舍

选项选择理由
自研注册中心 vs 复用 ServiceLoaderServiceLoaderJDK 标准约定,IDE/构建工具天然识别,无需额外发现机制
硬编码包列表 vs SPI 聚合SPI 聚合新模块只加 META-INF/services 文件即可参与扫描,内核零改动
内核反向依赖各模块 vs 各模块依赖内核接口各模块依赖接口依赖箭头统一指向底层,避免内核成为「认识所有模块」的中央枢纽
单一入口 vs 单选/聚合双入口双入口getDefaultExtension/getExtension 服务互斥能力,getAllExtensions 服务 union 贡献,语义不混淆
实现命名靠类名 vs @SPI.Service@SPI.Service(缺省 JavaBean 派生)单选场景需按稳定名称选择实现,与类全限定名解耦
为什么业务包不走 SPI,而走 AutoConfigurationPackages

业务应用包是「应用自己的事」,Spring Boot 已经通过 @SpringBootApplication 标记并由 AutoConfigurationPackages 暴露,forge 直接复用即可。SPI 解决的是「框架内置模块如何把自己的包贡献给内核」这一跨模块协作问题,两者职责不同、来源不同,在 BFPP 里合并成同一份扫描清单。

一处加载,多处复用

把这套机制串起来看:forge-spi 提供 @SPI / SpiLoader / Spider 三件套作为统一基建;任何需要被外部扩展的能力(包贡献、SQL 方言、鉴权实现)都声明成 @SPI 接口;实现者各自在 META-INF/services 登记;调用方按「需要一个」还是「需要全部」选择 Spider 的不同入口。新增扩展点不必新造加载逻辑,新增实现不必触碰内核——这正是 forge 把 SPI 当作框架级基建而非一次性技巧的原因。

关于 BFPP 如何消费聚合后的包清单、模块如何拓扑排序、生命周期如何编排,参见同模块的 模块化与启动编排设计;若涉及反射与包扫描的底层机制,参见 前置知识