你有没有想过?王者荣耀每次增加新英雄
为什么不用重启整个游戏服务器,也不用修改核心战斗代码
新英雄就能直接加入战场,还能完美适配普攻、技能释放、回城等所有基础功能?
这背后的逻辑,和 Java 里的 SPI 机制简直如出一辙!
王者的核心战斗系统就像一个 【战场规则制定者】
不用管具体是李白还是妲己
只需要定好 所有英雄必须有普攻、技能、回城 的统一规则(相当于 SPI 接口);
而每个新英雄(相当于服务提供者)只要遵守这个规则
再在游戏安装包的指定目录里 【登记】 一下(相当于 SPI 配置文件)
核心系统就能自动识别并加载这个英雄
这就是 SPI 机制的核心:接口定规则,实现做插件,配置来登记,系统自动认。
大家好,我是程序员强子。
今天我们来深入练习一下这个英雄:【SPI机制】
-
什么是SPI机制,为什么要用?
-
SPI原理
- SPI和API的区别是什么
- SPI机制实现原理
- SPI机制的缺陷
-
深入了解一下Spring中的SPI机制与传统的有什么区别,有哪些改进
什么是SPI机制
SPI(Service Provider Interface)是 Java 提供的一种服务发现机制
核心思想是 接口定义与实现分离:由服务使用者定义接口规范,服务提供者实现接口,通过约定的配置让框架自动加载实现类,从而实现模块解耦。
假设你开了一家餐厅,顾客来吃饭需要付钱,但你不想限制顾客只能用某一种支付方式(比如只收现金),希望支持支付宝、微信、银行卡等各种方式。
于是你定了一个「支付规则」(相当于「SPI 接口」):不管用什么方式付钱,必须能完成「接收金额 + 确认到账」这两个动作(接口里的方法)。
然后,支付宝、微信这些支付公司(相当于「服务提供者」)看到你的规则后,各自做了一套符合规则的支付工具(相当于「接口实现类」)
支付宝的工具能接收金额并确认到账,微信的也能。
然后他们提供一系列工具(比如商家二维码) 给到我们(对应到代码上,提供jar 给我们下载对接,jar 里面有对应的实现)。
使用SPI的核心价值: 【面向接口编程,屏蔽实现细节,动态扩展】
接下来深入了解SPI机制的原理~
SPI原理
SPI 与 API 的区别
| 维度 | API | SPI |
|---|---|---|
| 定义方 | 服务提供者(如 Spring 定义BeanFactory) | 服务使用者(如 JDBC 定义Driver) |
| 调用关系 | 使用者调用提供者的接口实现 | 提供者实现使用者定义的接口,供使用者调用 |
| 目的 | 提供功能给外部使用(如ArrayList) | 允许外部扩展自身功能(如 JDBC 适配多数据库) |
| 典型场景 | 开发中直接调用的工具类(如StringUtils) | 框架扩展点(如 Spring 的BeanPostProcessor) |
通俗比喻:
- API 是 你调用外卖平台的接口下单(平台定义接口,你用)。
- SPI 是 外卖平台开放商家入驻接口,商家实现后平台调用(平台定义接口,商家实现,平台用)。
SPI机制实现原理
SPI 机制能实现跨模块服务发现,核心依赖严格的配置文件约定,任何一环不符合约定都会导致服务加载失败。
SPI机制配置规则
具体规则如下:
第一步:确定配置文件的位置
- 本地开发时,配置文件需放在src/main/resources/META-INF/services/(编译后会被复制到classes/META-INF/services/,属于 classpath)
- 打包成jar包时,配置文件必须位于jar包根目录下的 META-INF/services
classpath 的具体含义?
JVM 的类加载路径,包括项目编译后的classes目录、依赖的jar包内部等
第二步:确定配置文件的命名
必须与服务接口的 “全限定类名” 完全一致
- 例如服务接口是com.example.pay.Payment,则配置文件名必须是com.example.pay.Payment,ServiceLoader会因找不到对应文件,导致实现类都无法加载
- 这个文件里面的内容:即实现类的 “全限定类名”(每行一个实现类全类名)
com.example.pay.impl.AlipayPayment # 支付宝实现
com.example.pay.impl.WechatPayment # 微信实现
ServiceLoader加载服务步骤
步骤 1:确定类加载器(Classloader)
ServiceLoader默认使用线程上下文类加载器(Thread.currentThread().getContextClassLoader())
若为null则使用加载ServiceLoader自身的类加载器(通常是应用类加载器 AppClassLoader)
为什么用线程上下文类加载器?
为了突破 JDK 类加载器的 双亲委派模型限制
SPI 按照上文所述,简单说就是 JDK 定义一个接口(比如 JDBC 的 Driver 接口),第三方(比如 MySQL)写实现类。
接口在 JDK 核心包(比如 java.sql),实现类在你的项目依赖里。
类加载器的 层级规矩(双亲委派):类加载器分三层,上层(父)不能主动找下层(子)要类
- 上层:Bootstrap 类加载器(加载 JDK 核心类,比如 SPI 接口);
- 下层:Application 类加载器(加载你项目里的依赖,比如 MySQL 驱动实现类)。
这里的核心矛盾就是:双亲委派搞不定 SPI 加载
- SPI 接口是 JDK 核心类,由上层的 Bootstrap 类加载器加载;
- SPI 实现类是项目依赖,由下层的 Application 类加载器加载;
- 按照双亲委派规矩,上层加载器(Bootstrap)不能 **向下找 **下层加载器(Application)要类
于是,线程上下文类加载器的 破局 作用
- 线程上下文类加载器,默认就是下层的 Application 类加载器
- 相当于绕开了 父不能找子 的限制,搭了一座桥,让核心类库中的 ServiceLoader 能拿到项目里的第三方实现类
步骤2:扫描所有 classpath 下的配置文件
ServiceLoader会遍历类加载器可访问的所有资源(目录、jar 包),查找META-INF/services/服务接口全类名文件。
若项目依赖了alipay.jar和wechat.jar,且两者都包含META-INF/services/com.example.pay.Payment文件,则ServiceLoader会读取这两个文件,合并所有实现类名
步骤3:解析文件,获取实现类名列表
对找到的每个配置文件,按行解析内容,过滤注释和空白行,得到所有实现类的全类名。
步骤 4:反射加载并实例化实现类
遍历实现类名列表,执行:
- 加载实现类的 Class 对象(实现类必须有public的无参构造方法)
- 校验:确保实现类确实实现了服务接口(否则抛出ServiceConfigurationError)。
- 实例化:通过实现类.newInstance()(调用无参构造方法)创建实例,缓存到ServiceLoader的providers集合中(LinkedHashMap,保证顺序)
TIP
避免在实现类的构造方法中做耗时操作,因为ServiceLoader在首次迭代时会实例化所有实现类,若某个实现类构造方法耗时,会导致整个服务加载变慢
步骤 5:迭代使用服务
ServiceLoader实现了Iterable接口,遍历它时会按配置文件中类名的顺序,返回所有实例化的服务对象
SPI 机制的缺陷
- 无法按需加载:ServiceLoader会一次性加载所有实现类,即使不需要的实现也会被初始化,浪费资源。(例如:若有 10 个支付实现,即使只需要支付宝,也会全部加载)
- 加载顺序不可控:遍历实现类的顺序由配置文件中类名的顺序决定,且无法通过代码指定优先级。
- 异常处理不友好:加载过程中若某个实现类初始化失败(如抛出异常),会导致整个加载过程中断。
- 无动态更新能力:无动态更新能力, 一旦加载完成,无法动态添加 / 移除实现类(需重新创建ServiceLoader)
- JDK 8 及之前的类路径冲突:多个 Jar 包可能包含同名配置文件,导致实现类覆盖或冲突。
那如何解决这个问题呢?答案:可以使用 Spring中自定义实现的SPI机制
Spring中自定义实现的SPI机制
Spring Boot 的META-INF/spring.factories文件是其自定义 SPI 机制的核心载体,用于实现框架扩展点的自动发现与加载。
它解决了 JDK 原生 SPI 的局限性(如加载顺序、灵活性不足),是 Spring Boot 自动配置 、开箱即用 能力的底层支撑。
spring.factories核心作用
回顾一下原生SPI存在什么问题?
- 仅支持 接口→实现类列表 的简单映射,无法满足 Spring 中 一个扩展点对应多个实现类,且需按优先级加载的需求;
- 加载逻辑固定(仅扫描META-INF/services/),缺乏灵活性;
- 异常处理粗糙,某个实现类加载失败会导致整体加载中断。
Spring Boot 为了更灵活地扩展框架(如自动配置、初始化器、监听器等),设计了spring.factories机制,核心目标是 :让第三方组件(如自定义 Starter)通过配置文件,向 Spring 容器注册自己的实现类,而无需使用者手动配置。
spring.factories格式
**键值对格式的 Properties 文件, **位于classpath:META-INF/目录下
- 键(Key) :扩展点接口 / 注解的全限定类名(如org.springframework.boot.autoconfigure.EnableAutoConfiguration);
- 值(Value) :该扩展点的实现类全限定类名,多个实现类用逗号分隔(支持换行,用反斜杠\续行);
- 支持#注释(注释行会被忽略)。
示例
# 自动配置类(最核心的扩展点)
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration,\
com.example.myapp.MyCustomAutoConfiguration
# 应用上下文初始化器
org.springframework.context.ApplicationContextInitializer=\
com.example.myapp.MyApplicationContextInitializer
# 应用监听器
org.springframework.boot.SpringApplicationRunListener=\
com.example.myapp.MyRunListener
spring.factories加载机制
Spring 通过SpringFactoriesLoader工具类加载spring.factories,核心逻辑比 JDK ServiceLoader更灵活、健壮。
第一步:扫描所有 classpath 下的spring.factories文件
SpringFactoriesLoader会遍历类加载器可访问的所有资源(如项目classes目录、依赖 Jar 包),查找META-INF/spring.factories文件。
第二步:合并配置项
对所有找到的spring.factories文件,按键分组合并值(相同 Key 的实现类会被合并为一个列表,去重处理)
第三步:加载并实例化实现类
根据指定的 键(如EnableAutoConfiguration),获取对应的实现类列表,通过反射(Class.forName() + 构造方法)实例化。
实际应用场景
spring.factories最经典的应用是自动配置(AutoConfiguration) ,这是 Spring Boot 零配置启动 的核心。
简单描述一下主要步骤:
第一步:Spring Boot 启动类标注@SpringBootApplication
其内部包含@EnableAutoConfiguration
@EnableAutoConfiguration通过导入AutoConfigurationImportSelector触发,
即@Import(AutoConfigurationImportSelector.class)触发自动配置类的导入;
第二步:AutoConfigurationImportSelector
这个类 内部调用 SpringFactoriesLoader 的方法 **loadFactoryNames **
实现 从spring.factories中读取所有EnableAutoConfiguration对应的实现类(即自动配置类)
这些自动配置类(如DataSourceAutoConfiguration)被 Spring 容器加载,根据条件注解(@Conditional 以及衍生的一系列的注解)判断是否生效,最终完成 Bean 的自动注册
两者对比
| 维度 | JDK 原生 SPI(ServiceLoader) | spring.factories(SpringFactoriesLoader) |
|---|---|---|
| 配置文件格式 | 纯文本,每行一个实现类(无键) | Properties 键值对(Key 为扩展点,Value 为实现类列表) |
| 实现类分隔方式 | 换行分隔 | 逗号分隔(支持换行续行) |
| 加载逻辑 | 懒加载(迭代时才实例化) | 主动加载(调用loadFactories时实例化) |
| 异常处理 | 某个实现类失败会中断整体加载 | 捕获异常,仅跳过失败的实现类,不影响其他类 |
| 扩展点灵活性 | 仅支持接口作为扩展点 | 支持接口、注解等作为扩展点(如EnableAutoConfiguration是注解) |
| 合并策略 | 按资源扫描顺序合并,无去重(可能重复) | 自动去重,相同实现类仅保留一个 |
总结
再看王者荣耀的新英雄上线 —— 不用重启服务器,不用改核心代码,靠的就是 规则 + 登记。
这恰是 SPI 的精髓:定好接口规则,实现类按规矩 登记,系统自动加载。
管它是 JDBC 还是 Spring ,都能像新英雄进峡谷一样,无缝融入又不打乱原有节奏。
下次选到新英雄时,或许会会心一笑:原来这和 Java 里的 SPI,是同一个的道理啊~