我有必要先说说为什么今天要聊这个。很多Java开发干了三五年,天天跟Spring打交道,各种注解用得飞起,但你去问他spring.factories是怎么被加载的、@EnableAutoConfiguration到底靠什么把几百个自动配置类收集起来的,他多半只会告诉你“这是Spring Boot的SPI机制”。然后你再追问一句“那跟JDK自带的SPI有什么区别”,场面往往就会安静下来。
说白了,SPI这个东西就像一门“接口驱动开发”的必修课,你未必天天手写,但框架源码里到处都是它。尤其现在面试八股文卷得厉害,Java SPI几乎成了必问题。如果只是背概念,不去实际写一遍、不去源码里挖一遍,很容易被问到细节就露馅。这篇博客我打算用最直接的方式,带你把Java SPI和Spring SPI这两兄弟彻底捋清楚,不光讲它们是什么,还会写Demo、翻源码、聊底层原理,最后把面试里最爱挖的坑一个个填上。
1. 先搞清楚SPI到底是个什么玩意儿
不扯官方定义,我用自己的话给你解释。SPI全称是Service Provider Interface,中文叫服务提供者接口。它的核心思想就一句话:把接口的定义和实现彻底解耦,让框架在运行时动态发现并加载实现类。
你可以完全想象成一个“插座-插头”模型。接口就是墙上的插座,定义好了规格(方法签名);实现类就是各种品牌的插头,只要符合规格就能插上去用。问题在于,传统写法里插座不知道有哪些插头存在,要么你手动把插头递给它(new一个实现类传给接口引用),要么它通过配置文件去查市面上有哪些插头符合规格。SPI做的就是后面这件事:框架只定义插座规范,然后在启动时自动去扫描“插头目录”,发现一个接一个,发现谁用谁。
1.1 Java SPI的“约定优于配置”是怎么约定的
JDK原生的SPI机制,约定非常朴素,就两条:
- 在
classpath下的META-INF/services/目录里,创建一个以“接口全限定名”命名的文件。 - 文件内容就是该接口所有实现类的全限定名,一个类占一行。
就这两条,没了。ServiceLoader会扫描所有META-INF/services目录下的文件,找到与接口名匹配的那个文件,然后逐行读取类名,通过反射实例化。
这里有个很多人忽略的点:文件目录是死的,META-INF/services是写死的,不能改;文件名是死的,必须和接口全限定名一致;文件内容反而比较灵活,一行一个实现类,支持多个实现类。也就是说,SPI的扩展点不是一个类,而是一组类,你想提供多个实现,就往文件里写多行。
1.2 为什么说SPI是“反向控制”的典型
很多人觉得SPI和IOC很像,确实,它们都有“别找我,我来找你”的味道。但IOC强调的是对象创建和依赖关系的反转,容器帮你new好对象再注入;SPI强调的是服务发现机制的倒置,调用方根本不知道有哪些实现类,全靠约定好的目录去自动发现。
举一个生活中的例子帮助理解:你出差住酒店,想吃饭但不知道附近有哪些外卖商家。传统做法是你自己打开地图App搜“附近美食”,然后一家一家挑,再打电话下单——这就是API(你主动找实现方)。SPI的做法是:你给前台打个电话,说“我要吃的”,前台自动把合作过的、能配送的餐厅清单拉出来,直接帮你下单——这就是SPI(调用方不问“有哪些”,而是问“谁能提供”,由服务目录自动匹配)。
JDBC就是最典型的SPI应用。java.sql.Driver是接口,MySQL驱动、PostgreSQL驱动各自实现这个接口,把自己注册到META-INF/services/java.sql.Driver文件里。当你用DriverManager.getConnection()时,它内部会通过ServiceLoader去加载所有驱动实现类,根本不需要你在代码里显式Class.forName("com.mysql.cj.jdbc.Driver")——虽然老程序员以前确实这么写过,但那是因为JDBC 4.0之前的版本还不支持SPI自动加载。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手把手实现一个JDK SPI完整示例
光看不练假把式。咱们写个最简版本的支付网关场景,用SPI实现不同支付渠道的动态加载。这个案例我在面试辅导里反复用过,因为它足够贴近真实业务,又能清楚展示SPI的完整链路。
2.1 定义接口和实现类
先定义一个统一的支付接口:
java复制public interface PaymentService {
/**
* 支付方法
* @param amount 金额,单位分
* @return 支付结果
*/
boolean pay(long amount);
/**
* 渠道名称,比如 WECHAT、ALIPAY、UNIONPAY
*/
String channelName();
}
然后写两个实现类,一个模拟微信支付,一个模拟支付宝支付:
java复制public class WechatPaymentService implements PaymentService {
@Override
public boolean pay(long amount) {
System.out.println("微信支付成功,金额:" + amount + " 分");
return true;
}
@Override
public String channelName() {
return "WECHAT";
}
}
java复制public class AlipayPaymentService implements PaymentService {
@Override
public boolean pay(long amount) {
System.out.println("支付宝支付成功,金额:" + amount + " 分");
return true;
}
@Override
public String channelName() {
return "ALIPAY";
}
}
注意,我这里故意让两个实现类都放在主工程里,待会儿会把它们拆成独立的jar包演示真正的插件化扩展。不过第一步,先用最简单的方式跑通。
2.2 创建SPI配置文件
在src/main/resources目录下新建META-INF/services文件夹,然后创建名为com.example.spi.demo.PaymentService的文件(接口全限定名),内容如下:
code复制com.example.spi.demo.impl.WechatPaymentService
com.example.spi.demo.impl.AlipayPaymentService
这里插一句我在实际项目中踩过的坑:文件名和接口全限定名必须一字不差,大小写、包路径都不能错,否则ServiceLoader执行后返回的集合是空的,而且不报任何异常。它的逻辑是“找不到就不加载”,而不是“找不到就报错”,这点非常容易被忽略,新手经常在这里卡半天。
2.3 用ServiceLoader加载并调用
加载代码很简单:
java复制import java.util.ServiceLoader;
public class PaymentDemo {
public static void main(String[] args) {
ServiceLoader<PaymentService> services = ServiceLoader.load(PaymentService.class);
for (PaymentService service : services) {
System.out.println("发现支付渠道:" + service.channelName());
service.pay(1000);
}
}
}
运行结果:
code复制发现支付渠道:WECHAT
微信支付成功,金额:1000 分
发现支付渠道:ALIPAY
支付宝支付成功,金额:1000 分
就这么简单,SPI最基础的用法就把链路跑通了。但如果你以为这就结束了,那还远远不够。这个例子能跑,跟真正框架里的SPI还差得远,它的局限也很明显:
- 每次
ServiceLoader.load()都会重新创建实例,没有缓存,性能瓶颈明显。 - 无法指定加载哪个实现,想要按需加载必须自己过滤。
- 没有优先级概念,加载顺序只跟配置文件的书写顺序有关。
- 实例化方式受限,只能通过无参构造器反射创建。
这也是为什么Spring要在JDK SPI的基础上自己再造一套轮子,接下来咱们慢慢拆。
2.4 实战拆解JDK SPI在框架中的应用
刨开业务代码,去看看真实世界JDK SPI被用在哪些场景。最典型的有四个:
第一个是JDBC驱动加载。刚才说了,java.sql.Driver的实现在各数据库驱动的jar包里,每个jar的META-INF/services/java.sql.Driver文件指向自己的驱动类。DriverManager初始化时会用ServiceLoader扫描当前classpath下所有jar里的这个文件,然后把驱动类挨个注册到自己的驱动列表里。这就是为什么现代JDBC代码不需要Class.forName的原因。
第二个是Common-Logging日志门面。Apache的commons-logging在运行时需要决定底层用Log4j还是JUL,它就会通过SPI去扫描org.apache.commons.logging.LogFactory这个接口的实现。谁在classpath里提供实现,就用谁。
第三个是Validator校验框架。javax.validation的Validation.byProvider()方法会通过ValidationProviderResolver去查找META-INF/services/javax.validation.spi.ValidationProvider文件,从而发现Hibernate Validator等具体实现。
第四个是各种框架的扩展点。比如Dubbo的扩展机制虽然不直接用JDK SPI,但思想是一脉相承的;又比如Jackson的Module自动注册、SLF4J的StaticLoggerBinder绑定,背后都是这类思路。
3. Spring SPI:为什么Spring要自己再造一套轮子
接下来进入Spring的地盘。Spring框架里其实没有使用JDK的ServiceLoader机制,而是自己实现了一套名为SpringFactoriesLoader的SPI机制。这也是面试里最高频的对比点:JDK SPI和Spring SPI的区别是什么?
最核心的区别在“配置内容的形态”上。JDK SPI是一个接口对应一个文件,文件里只能写这个接口的实现类;Spring SPI则是一个spring.factories文件可以配置多个接口的实现,文件采用Properties格式,以接口全限定名=实现类1,实现类2,实现类3的形式组织。
我画个粗暴的对比帮助记忆,JDK SPI的配置长这样:
code复制META-INF/services/com.example.PaymentService
内容:
com.example.impl.WechatPaymentService
com.example.impl.AlipayPaymentService
Spring SPI的配置长这样:
code复制META-INF/spring.factories
内容:
com.example.PaymentService=com.example.impl.WechatPaymentService,com.example.impl.AlipayPaymentService
com.example.OtherService=com.example.impl.OtherServiceImpl
一个文件管多个接口,这个特性决定了Spring SPI在大量组件自动注册场景下的高效率。
3.1 SpringFactoriesLoader懒加载机制与源码走读
SpringFactoriesLoader是Spring内部的一个工具类,包路径是org.springframework.core.io.support。它的核心方法有两个:
java复制// 获取所有实现类实例
public static <T> List<T> loadFactories(Class<T> factoryType, @Nullable ClassLoader classLoader)
// 获取所有实现类名
public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader)
我先带你读一下loadFactoryNames的源码逻辑,读懂它你就彻底理解Spring SPI的加载流程了。源码简化版如下:
java复制public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
ClassLoader classLoaderToUse = (classLoader != null ? classLoader : SpringFactoriesLoader.class.getClassLoader());
// 这里传的是 springFactoriesLoader,实际配置文件名是 spring.factories
Map<String, List<String>> result = loadSpringFactories(classLoaderToUse);
return result.getOrDefault(factoryType.getName(), Collections.emptyList());
}
真正干的活都在loadSpringFactories方法里:
java复制private static Map<String, List<String>> loadSpringFactories(ClassLoader classLoader) {
// 先从缓存里拿
Map<String, List<String>> result = cache.get(classLoader);
if (result != null) {
return result;
}
Enumeration<URL> urls = classLoader.getResources(FACTORIES_RESOURCE_LOCATION);
result = new LinkedHashMap<>();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
Properties properties = PropertiesLoaderUtils.loadProperties(new UrlResource(url));
for (Map.Entry<?, ?> entry : properties.entrySet()) {
String factoryTypeName = ((String) entry.getKey()).trim();
String[] factoryNames = StringUtils.commaDelimitedListToStringArray((String) entry.getValue());
// 挨个加入 List,并且用 List 存储该接口对应的所有实现类
result.computeIfAbsent(factoryTypeName, key -> new ArrayList<>()).addAll(Arrays.asList(factoryNames));
}
}
cache.put(classLoader, result);
return result;
}
这里有几个关键点值得你记在脑子里:
-
classLoader.getResources(FACTORIES_RESOURCE_LOCATION)会返回Classpath下所有jar中匹配到的META-INF/spring.factories文件。注意是getResources,不是getResource,它扫描的是全路径,不是某一个。这意味着每个starter jar都能往classpath里“塞”自己的spring.factories文件,最终由Spring Boot统一收集。 -
结果会被缓存到
static final Map<ClassLoader, Map<String, List<String>>> cache里。这里也带来了一个经典问题:多个spring.factories文件如果包含同名的key,后加载的jar实现类列表会追加到前一个的List后面,不会覆盖。 -
loadFactories方法最终也会调用loadFactoryNames拿到类名列表,然后通过反射createFactory实例化。实例化时使用的是BeanUtils.instantiateClass,底层要求实现类必须有可访问的无参构造器,否则直接抛异常。
3.2 Spring.factories里面到底一般放哪些东西
结合Spring Boot的源码,spring.factories最常见的内容可以归为几类。我列个表你就明白了:
| 配置Key | 类型 | 代表示例 |
|---|---|---|
org.springframework.boot.autoconfigure.EnableAutoConfiguration |
自动配置类 | DataSourceAutoConfiguration、RedisAutoConfiguration |
org.springframework.context.ApplicationContextInitializer |
应用上下文初始化器 | SpringApplicationRunListener相关、LoggingApplicationListener的兄弟类 |
org.springframework.boot.env.EnvironmentPostProcessor |
环境后处理器 | RandomPortEnvironmentPostProcessor |
org.springframework.boot.SpringApplicationRunListener |
启动监听器 | EventPublishingRunListener |
org.springframework.boot.autoconfigure.AutoConfigurationImportListener |
自动配置导入监听器 | ConditionEvaluationReportAutoConfigurationImportListener |
写starter时,绝大多数场景只需要配置EnableAutoConfiguration这一个Key就够了。比如你写一个自定义starter,想在Spring Boot启动时自动装配一个UserService,你的spring.factories文件大概长这样:
properties复制org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.starter.UserAutoConfiguration
然后UserAutoConfiguration类上打上@Configuration和@ConditionalOnClass等条件注解,Spring Boot启动时就会根据条件决定是否加载这个配置类。这也是Spring Boot“自动配置”的底层原动力。
3.3 深入源码:Spring Boot如何通过SpringFactoriesLoader加载自动配置类
Spring Boot启动时会走到SpringApplication.run() -> refreshContext() -> invokeBeanFactoryPostProcessors()这条路,最终进入ConfigurationClassPostProcessor。在这个处理器中,有个很关键的解析方法叫processDeferredImportSelectors,它负责处理@Import进来的DeferredImportSelector,而自动配置类的核心入口AutoConfigurationImportSelector正好就是一个DeferredImportSelector。
核心链路如下:
@SpringBootApplication注解里组合了@EnableAutoConfiguration。@EnableAutoConfiguration通过@Import(AutoConfigurationImportSelector.class)导入了一个选择器。AutoConfigurationImportSelector.getCandidateConfigurations()方法会调用SpringFactoriesLoader.loadFactoryNames(EnableAutoConfiguration.class, classLoader)。SpringFactoriesLoader扫描所有META-INF/spring.factories文件,找到org.springframework.boot.autoconfigure.EnableAutoConfiguration这个Key对应的所有配置类。- 拿到这些配置类后,再经过
@Conditional等条件注解过滤、排序(@AutoConfigureBefore、@AutoConfigureAfter、@AutoConfigureOrder)、去重,最终注册为正式的Bean定义。
这里有一个面试时很值钱的细节:在Spring Boot 2.7之前,自动配置类就在spring.factories里面,用上面这个Key;Spring Boot 3.0(对应Spring Framework 6.0)之后,自动配置类和普通Spring SPI做了分离,自动配置类不再写在spring.factories里,而是写在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports这个新文件里。为什么要这么改?因为Spring Boot团队想把自动配置类的加载和普通的spring.factories加载解耦,减少spring.factories文件体积,也让自动配置类的加载更精准。如果你现在用Spring Boot 3.x做开发,新建starter时记得用新的AutoConfiguration.imports文件格式,每行一个配置类全限定名,不需要Key=Value结构。
4. 手撸一个Spring Boot Starter来验证Spring SPI
说到Spring SPI,光讲不写容易飘。我带你做一个非常迷你的自定义starter,走一遍完整的Spring.factories配置和加载流程。你不用引入任何复杂的第三方依赖,只需要一个空的Spring Boot工程就能跑。
4.1 工程结构和依赖准备
新建一个模块叫demo-spring-factories,引入最基础的Web依赖就行(其实不引入Web也可以,只要spring-boot-starter就够)。为了方便看到效果,我加一个简单的定时打印功能。
4.2 编写自动配置类
首先写一个普通的配置类,里面注册一个UserService的Bean:
java复制public class UserService {
private String defaultName = "default-user";
public String getUserName() {
return defaultName;
}
}
再写自动配置类:
java复制@Configuration
public class UserAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public UserService userService() {
System.out.println("UserService 已通过自动配置创建");
return new UserService();
}
}
注意@ConditionalOnMissingBean这个注解,它表示当容器里没有UserService这个Bean的时候才去创建,这就是Spring Boot自动配置最常见的“谦让”机制——你手动定义了,我就退出;你没定义,我来接管。
4.3 配置spring.factories文件
在src/main/resources/META-INF目录下新建spring.factories文件:
properties复制org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.config.UserAutoConfiguration
然后启动主类,控制台会打印:
code复制UserService 已通过自动配置创建
这就算跑通了。接着你可以写个接口验证Bean能用:
java复制@RestController
public class TestController {
@Autowired
private UserService userService;
@GetMapping("/user")
public String getUser() {
return userService.getUserName();
}
}
访问/user就能看到default-user。到这里,一个基于Spring SPI的starter雏形就完成了。
4.4 Spring Boot 3.x下新写法怎么配
如果你用的是Spring Boot 3.x,建议走新的AutoConfiguration.imports路线。文件路径是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容是纯文本,每行一个全限定类名:
code复制com.example.config.UserAutoConfiguration
同时,如果你的自动配置类里需要用到@ConditionalOnClass之类的条件控制,写法和以前完全一样,只是在注册通道上从spring.factories搬到了新文件。Spring Boot源码里,AutoConfigurationImportSelector.getCandidateConfigurations()方法在3.x版本下读的就是这个新的imports文件路径。
这里有个重要的兼容性提示:如果想让你的starter同时兼容Spring Boot 2.x和3.x,一个最常见的做法是同时维护spring.factories和AutoConfiguration.imports两个文件。但这会带来一个问题:3.x下Spring Boot也会去扫描spring.factories,对EnableAutoConfiguration这个Key会做重复读取,然后通过类名去重来解决。所以同时配置的情况下,2.x用spring.factories那份,3.x用imports那份,两边各取所需,不会冲突,因为3.x里已经删除了对spring.factories中EnableAutoConfiguration的读取逻辑。
4.5 自定义starter的最佳实践内容补充
这块其实值得展开说,因为很多人第一次写starter都会踩低级坑。我给你总结四个要点:
第一,自动配置类不要放在被扫描的根包下。如果你的starter模块里的自动配置类被业务工程通过@ComponentScan扫到了,那就会导致多次加载、依赖顺序错乱。正确做法是把自动配置类放到单独的子包,让业务工程的@SpringBootApplication默认扫描无法触及,然后通过AutoConfiguration.imports精准导入。
第二,自动配置类要加条件注解控制生效范围。不加@ConditionalOnClass、@ConditionalOnMissingBean这种条件判断的自动配置类,会在每一个使用了该starter的项目里无脑加载,很容易和用户自定义的Bean冲突。
第三,配置属性要用@ConfigurationProperties做绑定。比如你定义了一个UserServiceProperties类,用prefix = "demo.user"绑定配置项,然后在自动配置类里通过@EnableConfigurationProperties(UserServiceProperties.class)启用属性绑定,这样用户就能通过application.yml里的demo.user.defaultName=xx覆盖默认值。
第四,文件编码以及属性行末尾的反斜杠不要乱写。spring.factories采用的是Java Properties格式,写多行配置时要用\结尾续行。如果你是从别处复制来的,很可能带进来空格或特殊字符,轻则加载不了,重则启动报解析异常。
5. JDK SPI与Spring SPI的核心区别总结
面试官最爱问的就是“JDK SPI和Spring SPI有什么区别”。我把它整理成一张对比表,背下来基本能应付绝大多数回答:
| 对比维度 | JDK SPI | Spring SPI |
|---|---|---|
| 配置文件名 | META-INF/services/{接口全限定名} |
META-INF/spring.factories |
| 配置格式 | 一个接口一个文件,纯类名列表 | 一个文件可以配多个接口,Key=Value格式 |
| 核心加载器 | java.util.ServiceLoader |
SpringFactoriesLoader |
| 缓存机制 | JDK 9之前无缓存,每次load都重新加载 | 使用static缓存,按ClassLoader维度缓存结果 |
| 加载顺序 | 按文件内容顺序 | 按List顺序,但可通过排序注解调整 |
| 类注解 | 无 | 无 |
| 是否支持多个相同接口的实现 | 支持,多个都在文件里 | 支持,逗号分隔列出 |
| Spring Boot中的自动配置场景 | 很少直接使用 | 配合@EnableAutoConfiguration等实现自动配置 |
但光背表不够,面试官还可能追问更深,“为什么会这样设计”。我的理解是:JDK的SPI设计得比较克制,追求最小约定,因为它是通用标准,必须考虑各种使用场景;而Spring SPI是框架内部机制,它要服务于Spring Boot这种“大量组件自动注册”的场景,所以把多个接口的实现塞进同一个文件,可以减少IO开销、简化打包配置,同时用一个缓存Map提升加载效率。
还有一个隐藏比较深的设计差异:JDK SPI是逐个加载配置文件,中间任何一个文件内容错误都会影响整体的加载结果;而Spring SPI用Properties格式统一加载到一个Map里,解析容错性更好。Properties本身对内容格式有很强的容错能力,个别条目写错了可能被跳过,不会像JDK SPI那样读取一行失败直接抛异常。
6. 高级扩展场景:Dubbo的自定义SPI机制为何不直接用JDK SPI
这个问题要是不讲,光聊JDK和Spring两家的SPI感觉还是少点味道。我快速串一下Dubbo的扩展机制,它对SPI思想做了进一步演进,这也是面试时很加分的扩展知识。
Dubbo里也有@SPI注解,比如Protocol接口上就标了@SPI("dubbo"),表示这是扩展点,默认实现是DubboProtocol。它的配置文件路径是META-INF/dubbo/和META-INF/services/,但内容格式跟JDK SPI完全不一样,是key=class格式,比如dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol,支持给每个实现起名。
为什么Dubbo要自己搞一套,而不是直接用JDK SPI或者Spring SPI?直接原因有三点:
- JDK SPI不能按名解析,想指定加载某个实现类的话,只能遍历所有实现类然后自己判断。Dubbo在运行时需要根据URL参数里的协议名动态选择对应的实现,比如从
dubbo://解析出协议名dubbo,再从扩展点文件里精确定位到DubboProtocol。这种需求JDK SPI做不到。 - JDK SPI没做好不好扩展的容错机制。Dubbo框架里依赖大量第三方组件,如果目标实现类加载失败,Dubbo不希望直接拖垮对外服务,所以它用
ExtensionLoader做了一层包装,捕获异常并抛出友好提示。 - Dubbo还支持自动包装扩展类(Wrapper)、自动加载依赖注入(IOC)、扩展点自适应(
@Adaptive),这些都不是JDK SPI几个接口能覆盖的。
虽然这个知识点和Spring SPI不是直接相关,但从思维上能帮你看清楚:SPI是个大方向,具体实现完全可以自研延伸。
7. 常见问题与避坑指南
最后写点实际工作中一定会用到的排错经验。下面的问题都是我或身边同事真实踩过的坑,不是编的。
7.1 配置文件写对了但加载不到实现类怎么办
排查路径是先验证三件事:
- 文件路径对不对?JDK SPI的文件必须放在
META-INF/services/下,Spring SPI放在META-INF/spring.factories下面,少一层目录都完蛋。 - 文件名是不是接口的全限定名?Spring SPI不存在这个问题,因为它是Key=Value,但JDK SPI的文件名必须和接口名一字不差。
- 类加载器对吗?在多模块或容器环境下,
ServiceLoader和SpringFactoriesLoader使用不同的类加载器可能扫不到jar里的配置。解决方法是用Thread.currentThread().getContextClassLoader()而不是默认的ServiceLoader类加载器。
7.2 多个同名配置文件到底谁生效
先说JDK SPI:多个jar里如果都存在META-INF/services/同一个接口名文件,ServiceLoader会把它们的所有实现类都加载进来。加载顺序取决于ClassLoader.getResources返回的URL顺序,而这个顺序在不同容器(Tomcat、Jetty、FatJar)下可能不一样。这是一个常见的隐性坑,因为“jar包加载顺序不确定”本身就会导致行为不一致。
Spring SPI也存在类似问题。多个jar都有spring.factories时,不同jar里同Key下List会合并,谁在classpath的前面谁先被合并进去。如果后续逻辑里对第一个生效有依赖,就可能出问题。尤其写EnvironmentPostProcessor、ApplicationContextInitializer这类全局组件时,顺序错了排查起来很痛苦。
7.3 实现类无法实例化怎么办
JDK SPI和Spring SPI在实例化时都要求实现类有无参构造器。很多同学在实现里写了一堆@Autowired或者带参构造,导致反射创建时直接抛NoSuchMethodException。这里有一个很容易搞混的常识:Spring SPIloadFactories返回的对象还没有经过Spring容器管理,它只是一个被反射创建出来的普通对象,不是增强代理,也没有依赖注入功能。要想到Spring容器里,得把它注册成Bean Definition,而不是直接new完塞进ApplicationContext。
7.4 Spring Boot 2.7和3.x的配置差异问题
前面已经带过这个话题,这里再强调一遍。如果新老项目混着维护,你已经把代码从Spring Boot 2.7升级到3.x,但之前写的自定义starter里还留着spring.factories里的自动配置项,你会极其沮丧地发现自动配置不生效了。升级过程中必须把EnableAutoConfiguration相关内容迁移到新的AutoConfiguration.imports里。如果你的自动配置类里还引用了旧版的废弃类或javax.*命名空间,迁移操作可能还不止改个配置路径那么简单。
7.5 面试中关于SPI的七大高频追问
既然这类内容经常跟面试挂钩,我顺手列一下高频追问,方便你自查:
- SPI和API有什么区别?
- JDK SPI是如何加载实现类的?懒加载还是启动加载?
- ServiceLoader为什么性能较差?
- SpringFactoriesLoader的缓存key是什么?
- Spring Boot自动配置为什么不直接用
@Component扫描,而要另走spring.factories? - Spring Boot 3.x的自动配置导入文件路径是什么?
- 如果自定义starter同时被两个项目引用,配置类会不会重复注册?
每个问题你都能用前面讲到的知识回答上来,那就说明这块基础是过关的。
8. 动手实践:从零打造一个完全解耦的插件体系
讲了这么多,最后我想建议你做个综合练习,把学到的东西真正用起来。我之前在给团队设计轻量级规则引擎的时候,就基于SPI思想做了一个插件式的规则加载器。整体思路如下,你可以照着扩展:
8.1 设计阶段
定义规则接口Rule,所有规则插件必须实现这个接口:
java复制public interface Rule {
boolean evaluate(Object context);
void execute(Object context);
}
接着定义规则引擎RuleEngine,它不关心具体有哪些Rule,只负责加载并执行所有可用的规则。
8.2 用JDK SPI实现第一版(简单但不可控)
把每个规则作为独立插件jar,jar内部提供META-INF/services/com.example.rule.Rule文件,列出该jar中所有规则实现类。RuleEngine通过ServiceLoader.load(Rule.class)加载所有规则。这个版本10分钟内能搞定,但实际使用中你会发现无法指定规则顺序,也无法给规则起名字,扩展性和可运维性都很差。
8.3 用Spring SPI实现第二版(可控可配置)
换成Spring容器来管理规则Bean,每个规则类上加@Component,或者通过starter的自动配置类统一注入。RuleEngine通过注入List<Rule>拿到容器中所有规则。配合@Order注解控制执行顺序;配合@ConditionalOnProperty实现动态开关。这套方案才是大多数业务系统实际在使用的方案。
所以你看,SPI的演进过程其实就是“简单能用”到“可控好用”的进化史。JDK SPI提供了最低成本的实现思路,Spring SPI提供了面向容器、面向配置的增强形态,而Dubbo这类框架又根据自身场景加上了名字路由、依赖注入、自适应扩展这些能力。搞懂了这条线,以后再遇到任何“XXX SPI”的东西,你能更快地抓住它的设计主线。
我在实际项目里还保留的一个习惯是,每次新建一个需要多实现扩展的模块,都会先停下来想三秒钟:直接用List<Interface>注入够不够?需要动态扩展吗?要允许第三方单独加jar包实现么?只有第三个问题的答案是“是”,才需要考虑真正的SPI机制。这东西虽好,但也不是万金油,别一上来就是spring.factories满天飞,属于在小项目上过度设计,结果只会给自己添堵。最后说一句,多实现场景下的首要原则不是“怎么加载”,而是“谁来决定加载哪些”。想清楚这个问题,很多架构设计上的纠结都会迎刃而解。
