Skip to content

SPI 扩展点使用

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 注解值升序排序。

两种用法:聚合贡献 vs 互斥单选

同一套三件套支撑两种截然不同的消费方式,区别只在框架取扩展时调用了 Spider 的哪个方法:

用法查询方法语义代表扩展点
聚合贡献getAllExtensions()遍历所有实现,把每个实现的产出汇聚到一起ScanPackageProvider
互斥单选getExtension(name) / getDefaultExtension()选出唯一一个实现生效方言、鉴权提供方等需要单一实现的场景

下面分别用 ScanPackageProvider(聚合贡献)和 AuthWhiteListProvider(聚合贡献的另一例)演示如何新增扩展。

TIP

判断该用哪种用法,先问一句:这个扩展点是「越多越好、各管一段」(聚合),还是「只能有一个说了算」(单选)。包扫描、接口白名单天然是聚合;数据库方言、登录鉴权实现天然是单选。

用法一:聚合贡献 ScanPackageProvider

ScanPackageProvider 让每个框架模块自洽地声明「我这一摊代码在哪些包下」,框架启动时把所有实现贡献的包汇聚成一份总扫描清单,统一参与 @Module / @Model 扫描。

扩展点接口定义如下:

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

框架内置的两个实现是最好的范本。系统模块贡献自身包:

java
// 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 模块),需要把它纳入扫描,按以下三步操作。

第一步,实现接口。新建实现类,返回要贡献的包名:

java
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 工厂后处理阶段会自动发现并聚合所有贡献者,伪代码形如:

java
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 中打开框架启动日志:

yaml
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

放行免登录接口同样走聚合贡献路线:每个模块声明自己需要放行的接口,框架启动时遍历所有 AuthWhiteListProvider 实现,把各自贡献的白名单合并成全局放行清单。

TIP

AuthWhiteListProviderScanPackageProvider 共用同一套三件套,差别只在接口类型和 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 扩展点设计