☰
Java SPI机制深度解析:ServiceLoader源码、JDBC与Spring Boot自动装配实战
2026/10/6 4:29:40 网站建设 项目流程

如果让你设计一个接口,这个接口有多个实现类,但调用方根本不知道具体类名,程序跑起来后才由框架自动找到合适的实现加载进来——你会怎么设计?Java里有个存在了很久的机制,专门干这件事,就是SPI(Service Provider Interface,服务提供者接口)。凡是搞Java的人,不管是日常开发还是准备面试八股文,几乎都绕不开它。JDBC驱动加载、Spring Boot自动装配、SLF4J日志门面的底层,全都和SPI脱不了干系。

这篇文章我不会只贴一套官方定义就完事,而是会从为什么需要SPI、它的核心约定、ServiceLoader源码到底怎么干活、再到手写一个轻量级实现、最后把高频踩坑点都捋一遍。目标是一篇可以直接拿来当复习资料、也可以照着动手敲的实战总结。

1. 为什么你需要认识SPI机制

1.1 从一次数据库驱动加载经历说起

先说我刚入行时遇到的一件小事。那时候项目用JDBC连MySQL,代码就这么写的:

Class.forName("com.mysql.cj.jdbc.Driver"); Connection conn = DriverManager.getConnection(url, user, password);

我当时的理解是,Class.forName是手动把驱动类加载进JVM,所以后续DriverManager才能拿到这个驱动。这个理解对了一半,但有个问题:用JDBC 4.0之后的版本,Class.forName这一行其实删掉也能正常连上数据库。因为DriverManager内部会在初始化时,通过SPI机制自动从classpath下的META-INF/services/java.sql.Driver文件里读取驱动实现类。

换句话说,MySQL驱动包穿了一条“服务者注册”的暗线。你在代码里显式加载驱动,这只是“明线”;真正起作用的,是驱动包在打包时就通过SPI规范声明了自己。这给我很大冲击:原来Java早就有一套让第三方实现类“自报家门”的标准方式,而且它不依赖任何Spring容器。

1.2 SPI与API的本质差异

理解SPI之前,必须先分清SPI和API这两个概念。网上很多资料把两者混着讲,但实际含义正好是相反的。

API(Application Programming Interface)是“我提供接口,你来调用”。比如你定义了一个PayService接口,调用方写代码调payService.pay(),接口的实现和接口定义都在你这边,调用方指向你约定的行为。

SPI(Service Provider Interface)是“我定义接口,你来扩展”。接口定义在框架侧,但实现类是第三方或者业务方自己写的,框架在运行时通过约定方式找到这些实现,反过来调用它们。所以一个形象的说法是:API是车道上的行驶规则,SPI是各个汽车厂商按规则造的车,车道只负责让车跑起来。

放到实际场景里,JDBC的java.sql.Driver就是一个SPI接口,MySQL驱动、PostgreSQL驱动、Oracle驱动都是服务提供者。你的业务代码从来不需要直接new一个驱动对象,而是通过DriverManager.getConnection()拿连接。谁把驱动实现类递到DriverManager手里的?就是SPI机制。

2. Java SPI机制核心原理剖析

2.1 SPI的约定与配置文件格式

SPI机制本身没有任何高深的技术,它本质是一套“按路径找文件、按文件读类名、按类名加载实例”的约定。Java官方把约定写在了ServiceLoader类的注释里,核心规则有三条。

第一,在classpath下创建目录META-INF/services/。

第二,在该目录下创建一个文件名与SPI接口全限定名一致的文件,比如接口是com.demo.spi.Logger,那么文件名就是com.demo.spi.Logger,注意没有.properties或.txt后缀。

第三,文件内容是一行行的实现类全限定名,每个类名占一行,#开头的行为注释,空行会被忽略。

拿一个最简单的例子演示,接口长这样:

package com.demo.spi; public interface Logger { void log(String message); }

两个实现类:

package com.demo.spi.impl; public class ConsoleLogger implements Logger { @Override public void log(String message) { System.out.println("[Console] " + message); } }
package com.demo.spi.impl; public class FileLogger implements Logger { @Override public void log(String message) { // 省略文件写入逻辑 System.out.println("[File] " + message); } }

然后在资源目录里新建META-INF/services/com.demo.spi.Logger文件,内容:

com.demo.spi.impl.ConsoleLogger com.demo.spi.impl.FileLogger

加载时只用一行代码:

ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class); for (Logger logger : loader) { logger.log("hello spi"); }

运行后会依次输出两条日志。整个过程没有new、没有Spring、没有反射工厂类,实现类的实例化完全交托给ServiceLoader。

2.2 ServiceLoader加载流程

我们直接从java.util.ServiceLoader的核心逻辑开始看。它的入口方法:

public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return new ServiceLoader<>(service, cl); }

注意这里用的是线程上下文类加载器,而不是ServiceLoader自己的类加载器。这个细节非常关键,后面讲类加载器问题时我会专门展开。

接着看构造函数,它做了两件事:缓存接口Class对象和类加载器,然后清理缓存。

private ServiceLoader(Class<S> svc, ClassLoader cl) { service = Objects.requireNonNull(svc, "Service interface cannot be null"); loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; acc = System.getSecurityManager() != null ? AccessController.getContext() : null; reload(); }

真正的核心在reload()方法里:

public void reload() { providers.clear(); lookupIterator = new LazyIterator(service, loader); }

providers是一个LinkedHashMap,用来缓存已经实例化过的服务对象。LazyIterator是内部迭代器,也是整个机制的灵魂。

2.3 源码级解读LazyIterator

LazyIterator的名字已经透露了设计意图:懒加载。它不是一次性把文件里所有类全部实例化,而是按照迭代顺序逐个读取、逐个加载,第一次next()时才真正创建对象。

先看hasNext()触发的逻辑:

private boolean hasNextService() { if (nextName != null) { return true; } if (configs == null) { try { String fullName = PREFIX + service.getName(); if (loader == null) { configs = ClassLoader.getSystemResources(fullName); } else { configs = loader.getResources(fullName); } } catch (IOException x) { fail(service, "Error locating configuration files", x); } } while ((pending == null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending = parse(service, configs.nextElement()); } nextName = pending.next(); return true; }

PREFIX的值就是"META-INF/services/"。它会用类加载器去查找接口全限定名对应配置文件。注意这里调的是getResources,不是getResource——后者只找第一个,前者返回所有匹配资源。这样设计是因为classpath下可能有多个jar包都声明了同一个接口的SPI配置,比如多个数据库驱动jar都有META-INF/services/java.sql.Driver文件。

再看next()时做了什么:

private S nextService() { if (!hasNextService()) { throw new NoSuchElementException(); } String cn = nextName; nextName = null; Class<?> c = null; try { c = Class.forName(cn, false, loader); } catch (ClassNotFoundException x) { fail(service, "Provider " + cn + " not found", x); } if (!service.isAssignableFrom(c)) { fail(service, "Provider " + cn + " not a subtype", x); } try { S p = service.cast(c.getConstructor().newInstance()); providers.put(cn, p); return p; } catch (Throwable x) { fail(service, "Provider " + cn + " could not be instantiated", x); } throw new Error(); }

这段代码透露了几个重要信息:

第一,用Class.forName(cn, false, loader)加载实现类,第二个参数false表示只加载、不执行静态初始化块。

第二,加载后通过service.isAssignableFrom(c)做类型检查,防止配置文件里写了个完全不相关的类,导致运行时ClassCastException。

第三,通过c.getConstructor().newInstance()反射调用无参构造器来创建对象。这意味着SPI实现类必须有一个public的无参构造器,否则直接报NoSuchMethodException。后面“常见问题”部分我还会回到这个坑上。

第四,实例化成功后会放进providers缓存,同一个ServiceLoader实例第二次迭代时直接走缓存,不会重复创建对象。

3. 领域中的经典应用

3.1 JDBC DriverManager的动态驱动发现

JDBC是最典型的SPI实践,没有之一。java.sql.Driver是接口,DriverManager是调用方。在JDBC 4.0之前,驱动需要显式Class.forName注册,因为DriverManager只认识自己显式注册过的驱动类。

JDBC 4.0之后,DriverManager的静态初始化块里多了一段逻辑:

static { loadInitialDrivers(); println("JDBC DriverManager initialized"); }

loadInitialDrivers()方法第一步就是在SPI配置中查找java.sql.Driver实现:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class); Iterator<Driver> driversIterator = loadedDrivers.iterator();

虽然JDK 9之后模块化改动了一些实现细节,但整个结构性思路没有变。现在你连数据库,只需要确保驱动依赖在classpath上,DriverManager会在初始化时自动完成驱动类的发现和注册。

这也是为什么现在很多项目里Class.forName("com.mysql.cj.jdbc.Driver")可以去掉。如果你在老项目里看到有人还留着这一行,也不能说错,顶多算“历史惯性”。真正要注意的是,如果你的classpath下同时有多个数据库驱动的jar包(比如MySQL和Oracle的驱动都在),DriverManager.getConnection会根据jdbc url前缀匹配到正确的驱动。这个匹配逻辑不是SPI管的,而是Driver接口的acceptsURL(String url)方法决定的。SPI只负责“把司机都找过来”,具体谁会接到订单,由司机自己说了算。

3.2 Spring Boot自动装配里的SPI影子

很多人以为Spring Boot的自动装配和Java SPI没啥关系,其实它们底层都遵循一个思想:扫描约定目录下的配置,按配置加载扩展实现。

Spring Boot的做法是通过@AutoConfigurationImportSelector读取所有jar包里的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。JDK 9以前的老版本是读spring.factories。这个文件和META-INF/services/下的SPI配置文件高度相似,都是“一个资源路径 + 一堆类全限定名”。区别在于Spring Boot的配置文件还可以带属性值,支持key=value结构,而Java SPI只能一行一个类名。

平时写starter时,你只需要提供自动配置类,然后把它写进AutoConfiguration.imports,Spring Boot启动时通过SpringFactoriesLoader加载。SpringFactoriesLoader就是Spring对SPI的一种扩展实现,它的资源路径、解析逻辑都和原生ServiceLoader很接近,但功能更强大,因为支持自定义类名以及按类型过滤。

埋个伏笔:理解了这个关系,你再看那些奇怪的“spring boot自动装配失效”问题,大部分都能定位到配置文件路径拼写、jar包打包缺资源、类名写错这几类原因上。排查思路和SPI配置出错是相通的。

3.3 日志门面与校验框架的SPI应用

除了JDBC和Spring Boot,SPI最常见的应用还有两个:日志门面和Bean Validation。

SLF4J是日志门面,你代码里用的是org.slf4j.Logger,但真正干活的是底层绑定器,比如Logback、Log4j2。SLF4J 1.8版本之前,是通过StaticLoggerBinder类在classpath下寻找绑定实现。1.8版本之后引入SLF4JServiceProvider接口。虽然它们的实现机制不完全等同原生SPI,但思想是一样的:门面不直接依赖具体日志框架,启动时自动找到唯一匹配的Provider。

这里有个经典问题:如果你classpath下同时引入了Logback和Log4j2,SLF4J会提示找到多个绑定,然后警告,但通常不会报错。它会按照classpath扫描顺序选择第一个绑定器,这实际上埋了隐患。

Bean Validation(比如Hibernate Validator、Apache BVal)也是同样的套路。jakarta.validation.Validation在初始化时通过ServiceLoader或类似机制加载ValidationProviderResolver,然后找到classpath下的具体Provider。所以使用API时,你可以直接写:

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();

不用关心底层是Hibernate Validator还是别的实现,反正哪个jar在classpath上,就能被自动发现。

4. 手写一个轻量级SPI框架

4.1 需求定义与接口设计

理论说得再多,不动手敲一遍始终差点意思。这一章我会带你从零写一个简化版的ServiceLoader,名字就叫SimpleSpiLoader。它不追求和JDK源码完全一致,但会保留核心特征:扫描META-INF/services/、懒加载、缓存、类型校验、错误提示。

需求分析一下,我们的加载器需要支持这几个方法:

public class SimpleSpiLoader<T> implements Iterable<T> { // 通过接口类型获取加载器 public static <S> SimpleSpiLoader<S> load(Class<S> service); // 获取所有实现类的实例 public List<T> getProviders(); // 获取第一个实现类实例 public T getFirstProvider(); }

先定义接口和两个实现类。为了贴近真实场景,这次我们模拟一个支付网关:

package com.demo.spi; public interface PaymentGateway { String channelName(); void pay(BigDecimal amount); }
package com.demo.spi.impl; public class AlipayGateway implements PaymentGateway { @Override public String channelName() { return "Alipay"; } @Override public void pay(BigDecimal amount) { System.out.println("使用支付宝支付:" + amount + "元"); } }
package com.demo.spi.impl; public class WechatPayGateway implements PaymentGateway { @Override public String channelName() { return "WechatPay"; } @Override public void pay(BigDecimal amount) { System.out.println("使用微信支付:" + amount + "元"); } }

配置文件META-INF/services/com.demo.spi.PaymentGateway:

com.demo.spi.impl.AlipayGateway com.demo.spi.impl.WechatPayGateway

4.2 核心代码实现

SimpleSpiLoader的实现思路是这样的:构造函数接收接口类型和类加载器;初始化时枚举所有META-INF/services/{接口名}资源;解析文件内容得到类名列表;提供内部类ProviderIterator实现懒加载迭代。

完整代码如下:

package com.demo.spi.loader; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Enumeration; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; public class SimpleSpiLoader<T> implements Iterable<T> { private static final String PREFIX = "META-INF/services/"; private final Class<T> service; private final ClassLoader classLoader; private final Map<String, T> providers = new LinkedHashMap<>(); private LazyIterator lookupIterator; private SimpleSpiLoader(Class<T> service, ClassLoader classLoader) { this.service = service; this.classLoader = classLoader; reload(); } public static <S> SimpleSpiLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); if (cl == null) { cl = SimpleSpiLoader.class.getClassLoader(); } return new SimpleSpiLoader<>(service, cl); } public void reload() { providers.clear(); lookupIterator = new LazyIterator(service, classLoader); } public List<T> getProviders() { List<T> result = new ArrayList<>(); for (T provider : this) { result.add(provider); } return result; } public T getFirstProvider() { Iterator<T> it = iterator(); if (it.hasNext()) { return it.next(); } throw new NoSuchElementException("No provider found for " + service.getName()); } @Override public Iterator<T> iterator() { return new Iterator<T>() { @Override public boolean hasNext() { return lookupIterator.hasNext(); } @Override public T next() { return lookupIterator.next(); } }; } private class LazyIterator implements Iterator<T> { private final Class<T> service; private final ClassLoader classLoader; private Enumeration<URL> configs; private Iterator<String> pending; private String nextName; private LazyIterator(Class<T> service, ClassLoader classLoader) { this.service = service; this.classLoader = classLoader; } @Override public boolean hasNext() { if (nextName != null) { return true; } if (configs == null) { try { String fullName = PREFIX + service.getName(); configs = classLoader.getResources(fullName); } catch (IOException e) { throw new RuntimeException("Error locating configuration files", e); } } while ((pending == null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending = parseConfig(configs.nextElement()); } nextName = pending.next(); return true; } @Override public T next() { if (!hasNext()) { throw new NoSuchElementException(); } String className = nextName; nextName = null; try { Class<?> clazz = Class.forName(className, true, classLoader); if (!service.isAssignableFrom(clazz)) { throw new RuntimeException("Provider " + className + " is not a subtype of " + service.getName()); } T instance = service.cast(clazz.getConstructor().newInstance()); providers.put(className, instance); return instance; } catch (ClassNotFoundException e) { throw new RuntimeException("Provider " + className + " not found", e); } catch (Exception e) { throw new RuntimeException("Provider " + className + " could not be instantiated", e); } } private Iterator<String> parseConfig(URL url) { List<String> names = new ArrayList<>(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { int commentIndex = line.indexOf('#'); if (commentIndex >= 0) { line = line.substring(0, commentIndex); } String trimmed = line.trim(); if (!trimmed.isEmpty()) { names.add(trimmed); } } } catch (IOException e) { throw new RuntimeException("Error reading configuration file", e); } return names.iterator(); } } }

4.3 验证与运行

写一个Main验证效果:

package com.demo.spi; import com.demo.spi.loader.SimpleSpiLoader; import java.math.BigDecimal; public class Main { public static void main(String[] args) { SimpleSpiLoader<PaymentGateway> loader = SimpleSpiLoader.load(PaymentGateway.class); System.out.println("开始遍历所有支付通道:"); for (PaymentGateway gateway : loader) { gateway.pay(new BigDecimal("100.00")); } System.out.println("获取第一个通道:"); PaymentGateway first = loader.getFirstProvider(); System.out.println(first.channelName()); } }

运行结果:

开始遍历所有支付通道: 使用支付宝支付:100元 使用微信支付:100元 获取第一个通道: Alipay

整个过程没有引入任何第三方依赖,就是纯JDK实现。你可以试试把配置文件里的类名改成一个不存在的类,看看会不会抛异常;再把一个非PaymentGateway实现类放进去,看看类型检查是否生效。

4.4 定制扩展点与实现选择策略

手写SPI框架的价值不只是教学,它给你一个思考的起点:原生的ServiceLoader读取配置文件后,按文件内顺序一个一个返回,但你完全有理由控制顺序。

比如支付场景要求优先使用支付宝,如果支付宝不可达,再降级到微信支付。原生ServiceLoader给不了你把“权重”也放进去的配置方式。这时候你可以给配置文件增加一个自定义格式,比如用=区分优先级:

alipay=com.demo.spi.impl.AlipayGateway wechat=com.demo.spi.impl.WechatPayGateway

然后在parseConfig里解析key=value结构,按key的字典序排序,或者按数值字段排序。这就是“基于SPI扩展出的框架能力”。很多中间件,比如Dubbo,它的ExtensionLoader就比JDK SPI多做了很多事,包括按名称加载、按条件激活、依赖注入、自动包装。而Dubbo的配置文件资源路径仍然是META-INF/dubbo或META-INF/services,你可以把Dubbo的扩展机制看作JDK SPI的加强版。

5. 常见问题与排查实录

5.1 配置文件命名拼写问题

SPI配置最常见的报错就是ServiceConfigurationError,提示类似:

java.util.ServiceConfigurationError: com.demo.spi.Logger: Provider com.demo.spi.impl.FileLogger not found

出现这个错误时,第一反应不应该是“类不存在”,而是先检查资源文件名是不是和接口类的全限定名完全一致。接口是com.demo.spi.Logger,配置文件名也必须是com.demo.spi.Logger,少写一个包路径、多一个.properties后缀、文件在META-INF/service(少了复数s)都会导致解析不到。

排查口诀:路径是META-INF/services/,文件名是接口全限定名,文件内容是类全限定名每行一个。三个环节错了哪个,类加载器看一眼就能告诉你。

这类问题的检查小技巧是:代码里打印classpath下所有资源路径,看能不能找到该文件。

Enumeration<URL> urls = Thread.currentThread().getContextClassLoader() .getResources("META-INF/services/com.demo.spi.Logger"); while (urls.hasMoreElements()) { System.out.println(urls.nextElement()); }

5.2 多个SPI实现时的顺序问题

ServiceLoader的迭代顺序和配置文件里的类名顺序保持一致,但当classpath下存在多个配置文件时,顺序会变得难以预测。它取决于classpath的jar扫描顺序,而classpath顺序又取决于构建工具、IDE配置和应用服务器。

我踩过的一个真实场景是:某次上线后日志突然从Logback输出变成了Log4j2输出,开发环境完全复现不了。最终发现是生产环境某条依赖传递带来了Log4j2的jar,而日志门面在多个Provider里挑选了它。这就是SPI机制的一个隐性问题:当框架认为多个Provider地位等价时,你很难通过配置去强制某一个。

解决办法有几类:如果是SLF4J这种明确需要唯一Provider的场景,使用maven-enforcer-plugin或者dependencyManagement把不需要绑定的日志实现排除掉,保证classpath上只有一个Provider。

对于自己开发的SPI扩展点,如果对优先级有要求,在设计阶段就要加权重概念,或者在加载时对实现类进行过滤,而不是依赖文件写入顺序。

5.3 缺无参构造器导致实例化失败

SPI实现类必须提供一个public无参构造器。这句话在官方注释里写得明明白白,但实际开发中总是有人踩坑,尤其是那些把依赖注入交给Spring管理的类。

举个例子,你想让某个Logger的SPI实现类通过Spring管理它的依赖:

@Component public class SpringLogger implements Logger { private final SomeDependency dependency; @Autowired public SpringLogger(SomeDependency dependency) { this.dependency = dependency; } }

这样写肯定报错,因为ServiceLoader通过getConstructor().newInstance()创建实例时,找不到无参构造器。即使你在类上加了@Component注解也没用,ServiceLoader不认识Spring的注解。

正确的做法是:SPI实现类本身保持无参构造器,在构造器里手动完成最简单环境初始化;复杂的依赖通过ServiceLoader拿到实例后,再由Spring容器做后续包装。很多框架的设计是SPI只负责“发现扩展点”,实例的完整生命周期和依赖管理交给容器,这样两者就不冲突了。

5.4 类加载器问题

SPI涉及类加载器的细节经常被忽略,但它恰恰是很多框架扩展失效的深层原因。ServiceLoader.load()默认使用线程上下文类加载器,这个加载器在Web应用服务器里一般能拿到应用自己的类,包括jar里的SPI配置文件。但如果某些场景下线程上下文类加载器是null或者被设置成了容器类加载器,就可能发生“接口类在父加载器里,配置的资源在子加载器里”的错位。

举个典型例子:你在OSGi环境或者某些自定义类加载器环境下使用SPI,getResources可能找不到另一个模块的资源。因为模块之间的类加载器是隔离的,SPI本来就不太适合在强模块化环境下工作。JDK的模块化系统对SPI做了专门的适配(通过provides语句),不再是裸配置文件,思路就完全不同了。

排查类加载器问题的方法很简单:在SPI加载前打印一下Thread.currentThread().getContextClassLoader(),再打印一下目标jar包所在的类加载器,看是不是同一个,或者是否是父子关系。

6. SPI机制进阶与面试要点

6.1 SPI的懒加载设计带来的性能优势

ServiceLoader的懒加载特性在日常代码中不太容易感知,但深入想一下很有味道。它没有在load()时立刻把所有实现类都加载进内存,而是等到hasNext()之后才逐个读取配置、加载类、实例化。

这种设计带来的好处是:如果classpath下有很多SPI实现类,但业务只需要第一个,后续类就不会被加载。这在内存敏感、启动时间敏感的中间件场景下是有意义的。JDBC的DriverManager加载所有驱动类是特殊情况,它确实会遍历完所有实现,เพื่อ找acceptsURL能匹配的那个。

面试中如果被问到“ServiceLoader和普通反射加载有什么区别”,你顺着这个思路答就能得分:SPI按约定路径找配置、按配置反射加载实现、用Iterator实现懒加载、用Map做缓存、支持多实现共存。比起业务代码里Class.forName直连,SPI把“有哪些实现”这个信息从代码里剥离到配置文件里,让扩展变得不用改动已有代码。

6.2 SPI与依赖注入、Spring.factories的对比

很多人会把SPI和Spring的依赖注入搞混。区别在于:依赖注入是你在代码里声明“我需要某个接口”,然后容器把实例“注入”进来;SPI是框架在启动时自动扫描“有哪些Provider”,然后拿来注册自己用。打个比方,DI是病人点餐,服务员按菜单送菜;SPI是餐厅自动把当天所有时蔬列出来,厨师选其中几种做菜。

SpringFactoriesLoader和原生ServiceLoader的关系是“同一思想的两套实现”。原生SPI的配置文件里只能写类名,而SpringFactoriesLoader的spring.factories里可以写多组key=value,每个key下可以有多条实现类名。比如:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.demo.autoconfigure.DemoAutoConfiguration,\ com.demo.autoconfigure.HelperAutoConfiguration

它还能通过loadFactories(Class<T> type, ...)方法对写入的类做类型校验,再通过loadFactoryNames先拿到类名列表。这一点和ServiceLoader的isAssignableFrom异曲同工。

面试里如果把这两者的区别答到位,基本能把“对Spring和JDK底层都有理解”的印象立起来。但注意不要故作高深:它们只是配置格式和加载实现略有不同,核心思想都是“接口与实现的运行时发现”。

6.3 手写Dubbo式扩展的灵感

聊到SPI进阶,就逃不开Dubbo的ExtensionLoader。Dubbo的SPI在JDK SPI基础上做了不少增强,了解它的设计能让你彻底明白SPI的边界在哪里。

第一,JDK SPI没有“按名称获取指定实现”的能力,它遍历出来什么就返回什么。Dubbo的@SPI注解配合@Extension可以按URL参数里的key选择具体扩展实现,比如协议是dubbo还是rest,由调用时指定。

第二,Dubbo SPI可以对扩展实现做自动包装(Wrapper),比如ProtocolFilterWrapper会把多个扩展包装成责任链,实现切面增强。这已经远超原生SPI的能力。

第三,Dubbo SPI实现了扩展点的依赖注入。当扩展类内部需要其他扩展时,它不是new出来,而是通过ExtensionLoader获取。

说这些不是要你现在就去读Dubbo源码,而是提供一个参考坐标:原生SPI是地基,你可以在地基上盖不同的楼。理解了原理,日后看到一个陌生框架的扩展机制,基本都能一眼看穿它是不是SPI思想的变种。

写在最后的一点体会

我到现在还记得第一次在IDE里展开ServiceLoader源码时的那种感觉——原来Java里还有这么一块看似简单的生态位。它没有注解、没有XML,就靠一个文本文件和一行行类名,硬生生支撑起了JDBC、日志、校验、甚至是微服务框架的扩展体系。后来排查线上日志框架冲突问题,顺着这个机制一层层定位,才真正把SPI从面试题变成了工作里的下意识反射。

如果你正在学习Java进阶,我强烈建议亲手敲一遍上面那个简化版SimpleSpiLoader,再跟着跑一遍JDK的ServiceLoader源码。不需要背代码,把“配置文件路径 → 资源枚举 → 类名解析 → 反射实例化 → 类型校验 → 缓存”这条线在脑子里过一遍,比背十遍概念强得多。如果之后遇到框架加载扩展类莫名失败,先检查META-INF/services下文件和classpath,再看类加载器,八成问题就出在这两处。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询