搜索 K
Appearance
Appearance
forge 框架的可插拔能力建立在一套统一的 SPI 三件套之上:注解 @SPI 标记扩展点接口,门面 Spider 提供查询入口,资源文件 META-INF/services/ 声明实现。本篇以宠物商店工程为背景,演示如何用这套机制为框架贡献扫描包、放行免登录接口,并给出两种典型用法的完整步骤。
底层加载原理与设计取舍见 SPI 扩展点设计,启动期如何消费这些扩展见 应用启动编排。
一个 forge SPI 扩展由三部分组成,缺一不可:
| 组成 | 角色 | 形态 |
|---|---|---|
@SPI | 标记扩展点接口 | 接口类型上的注解 |
Spider | 运行时查询门面 | Spider.getAllExtensions(Type.class) 等静态方法 |
META-INF/services/ | 实现声明 | 以接口全限定名为文件名,内含实现类全限定名 |
Spider 底层委托 SpiLoader,后者基于 Java 标准的 java.util.ServiceLoader 加载 META-INF/services/ 下声明的实现,并按 Spring 的 @Order 注解值升序排序。
同一套三件套支撑两种截然不同的消费方式,区别只在框架取扩展时调用了 Spider 的哪个方法:
| 用法 | 查询方法 | 语义 | 代表扩展点 |
|---|---|---|---|
| 聚合贡献 | getAllExtensions() | 遍历所有实现,把每个实现的产出汇聚到一起 | ScanPackageProvider |
| 互斥单选 | getExtension(name) / getDefaultExtension() | 选出唯一一个实现生效 | 方言、鉴权提供方等需要单一实现的场景 |
下面分别用 ScanPackageProvider(聚合贡献)和 AuthWhiteListProvider(聚合贡献的另一例)演示如何新增扩展。
TIP
判断该用哪种用法,先问一句:这个扩展点是「越多越好、各管一段」(聚合),还是「只能有一个说了算」(单选)。包扫描、接口白名单天然是聚合;数据库方言、登录鉴权实现天然是单选。
ScanPackageProvider 让每个框架模块自洽地声明「我这一摊代码在哪些包下」,框架启动时把所有实现贡献的包汇聚成一份总扫描清单,统一参与 @Module / @Model 扫描。
扩展点接口定义如下:
@SPI
public interface ScanPackageProvider {
/**
* 贡献需纳入扫描的包名列表(如 List.of("cn.cvking.forge.auth"))。
* 不可返回 null
*/
List<String> contributePackages();
}框架内置的两个实现是最好的范本。系统模块贡献自身包:
// cn.cvking.forge.boot.sys.SysScanPackageProvider
public class SysScanPackageProvider implements ScanPackageProvider {
@Override
public List<String> contributePackages() {
return List.of("cn.cvking.forge.boot.sys");
}
}权限模块同理贡献 cn.cvking.forge.auth。两者各自携带对应的 services 文件,互不干扰。
假设宠物商店把实体放在了一个非默认包扫描范围内的位置(例如独立的 starter 模块),需要把它纳入扫描,按以下三步操作。
第一步,实现接口。新建实现类,返回要贡献的包名:
package com.demo.petshop.config;
import cn.cvking.forge.module.spi.ScanPackageProvider;
import java.util.List;
public class PetShopScanPackageProvider implements ScanPackageProvider {
@Override
public List<String> contributePackages() {
// 宠物商店实体所在包:Pet、Owner、Category、PetProfile、Tag、PetTag 等
return List.of("com.demo.petshop.model");
}
}第二步,声明实现。在该模块的 src/main/resources/META-INF/services/ 下,新建一个以接口全限定名命名的文件:
src/main/resources/META-INF/services/cn.cvking.forge.module.spi.ScanPackageProvider文件内容写实现类的全限定名(一行一个,可多个):
com.demo.petshop.config.PetShopScanPackageProvider第三步,无需改动框架启动代码。框架在 Bean 工厂后处理阶段会自动发现并聚合所有贡献者,伪代码形如:
List<String> packages = new ArrayList<>(AutoConfigurationPackages.get(beanFactory));
for (ScanPackageProvider provider : Spider.getAllExtensions(ScanPackageProvider.class)) {
packages.addAll(provider.contributePackages());
}
// packages = [com.demo.petshop, com.demo.petshop.model, cn.cvking.forge.boot.sys, cn.cvking.forge.auth, ...]在 application.yml 中打开框架启动日志:
logging:
level:
cn.cvking.forge: debug重启应用,控制台会按贡献的包扫描并注册模块/模型,代表性日志行如下:
[bootstrap] registered module: system
[bootstrap] registered module: auth
[bootstrap] registered module: biz
[bootstrap] registered model: sys_module -> module=system (pkg=cn.cvking.forge.boot.sys)
[bootstrap] registered model: pet -> module=biz (pkg=com.demo.petshop.model)若你新增的包未被扫描到,pet 这类模型不会出现在 registered model 行中——这是排查贡献是否生效的第一信号。
放行免登录接口同样走聚合贡献路线:每个模块声明自己需要放行的接口,框架启动时遍历所有 AuthWhiteListProvider 实现,把各自贡献的白名单合并成全局放行清单。
TIP
AuthWhiteListProvider 与 ScanPackageProvider 共用同一套三件套,差别只在接口类型和 services 文件名。掌握了上面的三步,这里换个接口名照做即可。
宠物商店若有一个「公开宠物列表」接口需要免登录访问,新增贡献者的步骤与用法一完全对称:
第一步,实现 AuthWhiteListProvider,返回要放行的路径。
第二步,在 src/main/resources/META-INF/services/ 下新建以该接口全限定名命名的文件,写入实现类全限定名。
第三步,框架启动时通过 Spider.getAllExtensions(...) 聚合所有白名单贡献,无需改动鉴权核心代码。
这正是开闭原则的体现:放行规则随模块走,新增放行点只加文件、不改框架。
当一个扩展点只允许一个实现生效(如数据库方言、登录鉴权实现),框架不会遍历全部,而是通过名称或默认规则选出唯一实现:
Spider 委托的 SpiLoader.getDefaultExtension() 返回 @Order 值最小的实现;getExtension(name) 按名称查找,名称由 @SPI.Service(value) 显式指定,缺省则按 JavaBean 规范从实现类名派生。新增单选实现的三步骤与聚合一致(实现接口、写 services 文件、无需改框架),区别仅在:同一接口若声明了多个实现,最终只有一个会被选中生效,因此要么用 @Order 控制优先级,要么用 @SPI.Service 起好名字以便按名取用。
反例一:忘建 META-INF/services 文件
现象:实现类写好了、implements ScanPackageProvider 也没错,但启动日志里始终看不到对应的包被扫描,模型没注册上。
原因:Spider 底层是 java.util.ServiceLoader,它只认 META-INF/services/ 下的声明文件,不做包扫描。光有实现类、没有声明文件,等于没注册。
修复:在模块的 src/main/resources/META-INF/services/ 下,创建以接口全限定名为名(如 cn.cvking.forge.module.spi.ScanPackageProvider)的文件,把实现类全限定名写进去。注意文件名是接口名、内容是实现名,不要写反。
反例二:实现类没有公开无参构造
现象:启动期抛出 ServiceConfigurationError,提示无法实例化实现类。
原因:ServiceLoader 通过反射调用无参构造创建实例。若实现类只有带参构造(例如为了注入依赖加了构造参数),或把无参构造写成了非 public,加载就会失败。
修复:保证实现类有一个可访问的无参构造。SPI 实现应保持无状态、轻量;需要依赖时在方法内部按需获取,而不是通过构造注入。
反例三:services 文件内容写错位置或拼错全限定名
现象:日志无报错也无对应扫描,或直接抛 ClassNotFoundException。
原因:文件名写成了实现类名、或内容里包名拼写错误、或文件放进了 src/main/java 而非 src/main/resources,都会导致 ServiceLoader 找不到或加载失败。
修复:文件名 = 接口全限定名,内容 = 实现类全限定名,位置 = src/main/resources/META-INF/services/,逐项核对。
反例四:contributePackages() 返回 null
现象:启动期聚合包列表时抛 NullPointerException。
原因:ScanPackageProvider 接口约定 contributePackages() 不可返回 null。框架聚合时直接 addAll(...),传入 null 即崩。
修复:没有要贡献的包时返回 List.of()(空集合)而非 null。
更深入的加载顺序、@Order 排序与单选/聚合背后的设计权衡,见 SPI 扩展点设计。