搜索 K
Appearance
Appearance
forge 框架的内置模块(系统模块、权限模块……)需要把自己的扫描包、方言实现、鉴权实现「贡献」给框架内核,而内核不应该反过来认识每一个模块。本篇解释 forge 为什么用一套基于 META-INF/services 的 SPI 设施来承载这种贡献,以及它如何在「互斥单选」与「聚合贡献」两类场景间复用同一套加载逻辑。
如果你想知道怎么落地一个新的扩展点、写一个 ScanPackageProvider,请直接看 应用架构使用指南;本篇只讲为什么这么设计。
一句话概括
内核只认识接口(@SPI),实现者通过 META-INF/services 把自己挂上来,Spider 负责加载与排序。内核与模块之间没有编译期依赖的「反向箭头」。
最朴素的做法是:在启动器里写死一份包列表、一份方言映射、一份鉴权实现。问题在于,框架内核(forge-module、forge-metadata)位于依赖图的底层,而 forge-boot、forge-auth、各 forge-dialects 位于上层。硬编码意味着底层启动代码要 import 上层模块的类,形成方向错误的依赖,每新增一个模块都要回头改内核。
forge 的选择是把这种「上层向下层登记」的关系交给 SPI:内核只定义接口,实现散落在各模块的 META-INF/services 里,启动时由 Spider 统一发现。
注意所有「编译期依赖」的实线都从上层模块指向内核接口,Spider 在运行期才把实现拉回来。这正是开闭原则的体现:内核对修改关闭,对扩展开放。
forge 的 SPI 设施位于 forge-spi 模块,由三个类构成清晰的分层:
| 角色 | 类 | 职责 |
|---|---|---|
| 标记 | SPI | 注解,标在可扩展接口上;内嵌 @SPI.Service 用于给实现命名 |
| 加载与排序 | SpiLoader | 基于 java.util.ServiceLoader 读取 META-INF/services,按 @Order 升序排序 |
| 门面 | Spider | 对外暴露 getDefaultExtension / getExtension(name) / getAllExtensions 三个静态入口 |
@SPI 注解本身只标记类型,并内嵌一个 @SPI.Service 用于声明实现名称:
@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)(按名称精确选择),从候选里挑出唯一胜者,其余实现被忽略。
另一些扩展点的语义是「人人有份」:每个内置模块都想把自己的包加进扫描范围,谁都不该被忽略。ScanPackageProvider 正是这种模式——框架遍历所有实现,把每个实现 contributePackages() 返回的列表全部并集进来。
@SPI
public interface ScanPackageProvider {
/**
* 贡献需纳入扫描的包名列表(如 List.of("cn.cvking.forge.auth"))。
* 不可返回 null
*/
List<String> contributePackages();
}Spider 的聚合入口非常薄:
public static <T> List<T> getAllExtensions(Class<T> type) {
return SpiLoader.getLoader(type).getAllExtensions();
}模式与契约要对齐
聚合贡献模式下,实现方法不可返回 null(见 ScanPackageProvider 注释),否则并集时会触发空指针。这是「全部生效」语义对实现者的强约束——它不像单选那样可以靠 @Order 兜底挑一个。
下表把两类模式的关键差异并排呈现:
| 维度 | 互斥单选 | 聚合贡献 union |
|---|---|---|
| 典型扩展点 | SqlDialect、AuthProvider | ScanPackageProvider |
Spider 入口 | getDefaultExtension / getExtension(name) | getAllExtensions |
| 多实现共存时 | 只选一个,其余忽略 | 全部生效,结果并集 |
@Order 作用 | 决定默认胜者 | 决定遍历/合并顺序 |
| 实现返回 null | 不一定致命(可被其它实现取代) | 危险,破坏并集语义 |
| 设计意图 | 在互斥能力间二选一 | 让分散的贡献汇成一份全集 |
扫描包的聚合是 forge 里最典型的 union 场景,也最能说明 SPI 相对硬编码的价值。启动时 BFPP 需要一份「该扫描哪些包找 @Model / @Module」的清单:业务应用包由 Spring Boot 的 AutoConfigurationPackages 提供,而框架内置模块的包(如 cn.cvking.forge.boot.sys、cn.cvking.forge.auth)则通过 ScanPackageProvider 聚合进来。
落到代码事实上,权限模块通过 forge-auth 的 META-INF/services/cn.cvking.forge.module.spi.ScanPackageProvider 文件登记了 ForgeScanPackageProviderImpl,系统模块通过 forge-boot 的同名文件登记了 SysScanPackageProvider。BFPP 不知道这两个类的存在,只对 ScanPackageProvider 接口编程。
| 选项 | 选择 | 理由 |
|---|---|---|
自研注册中心 vs 复用 ServiceLoader | ServiceLoader | JDK 标准约定,IDE/构建工具天然识别,无需额外发现机制 |
| 硬编码包列表 vs SPI 聚合 | SPI 聚合 | 新模块只加 META-INF/services 文件即可参与扫描,内核零改动 |
| 内核反向依赖各模块 vs 各模块依赖内核接口 | 各模块依赖接口 | 依赖箭头统一指向底层,避免内核成为「认识所有模块」的中央枢纽 |
| 单一入口 vs 单选/聚合双入口 | 双入口 | getDefaultExtension/getExtension 服务互斥能力,getAllExtensions 服务 union 贡献,语义不混淆 |
实现命名靠类名 vs @SPI.Service | @SPI.Service(缺省 JavaBean 派生) | 单选场景需按稳定名称选择实现,与类全限定名解耦 |
业务应用包是「应用自己的事」,Spring Boot 已经通过 @SpringBootApplication 标记并由 AutoConfigurationPackages 暴露,forge 直接复用即可。SPI 解决的是「框架内置模块如何把自己的包贡献给内核」这一跨模块协作问题,两者职责不同、来源不同,在 BFPP 里合并成同一份扫描清单。
把这套机制串起来看:forge-spi 提供 @SPI / SpiLoader / Spider 三件套作为统一基建;任何需要被外部扩展的能力(包贡献、SQL 方言、鉴权实现)都声明成 @SPI 接口;实现者各自在 META-INF/services 登记;调用方按「需要一个」还是「需要全部」选择 Spider 的不同入口。新增扩展点不必新造加载逻辑,新增实现不必触碰内核——这正是 forge 把 SPI 当作框架级基建而非一次性技巧的原因。
关于 BFPP 如何消费聚合后的包清单、模块如何拓扑排序、生命周期如何编排,参见同模块的 模块化与启动编排设计;若涉及反射与包扫描的底层机制,参见 前置知识。