我有必要先说说为什么今天要聊这个。很多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
