Java 王者修炼手册【基础篇-SPI机制】:Java SPI让插件化开发这么简单

63 阅读10分钟

你有没有想过?王者荣耀每次增加新英雄

为什么不用重启整个游戏服务器,也不用修改核心战斗代码

新英雄就能直接加入战场,还能完美适配普攻、技能释放、回城等所有基础功能?

这背后的逻辑,和 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 的区别

维度APISPI
定义方服务提供者(如 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:反射加载并实例化实现类

遍历实现类名列表,执行:

  1. 加载实现类的 Class 对象(实现类必须有public的无参构造方法)
  2. 校验:确保实现类确实实现了服务接口(否则抛出ServiceConfigurationError)。
  3. 实例化:通过实现类.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,是同一个的道理啊~

323790.jpg