1. SPI是什么:一次“插槽式”升级带来的认识
我在给一个内部组件换数据库驱动时,代码里没有改过一行 import,只是往 classpath 里多扔了一个 jar 包,服务重启后日志里赫然出现了新驱动的加载痕迹。说实话,那是我第一次被 Java 的 SPI 机制震到——原来一个接口的实现可以不需要被 new 出来、不需要写工厂类,只需要遵守一个约定,就能被容器自动发现和加载。这个约定就是 Java 里常说的 SPI,全称 Service Provider Interface,也就是服务提供者接口。
SPI 不是新的语法,也不是什么黑科技,它是一套“发现服务实现”的规则。调用方只依赖接口,具体实现由第三方 jar 包通过固定路径暴露出来,JDK 在运行时用 ServiceLoader 把这些实现扫出来、实例化、返回给调用方。想给系统加一个新实现,不需要改动调用方代码,只要实现接口,并按规则放到 classpath 里,SPI 就会自动把它“插”进来。用生活中的场景类比,SPI 就像电脑主板上的扩展插槽,主板厂商不知道你会插显卡还是声卡,但只要你遵守同一套总线规范,插上去就能被识别。
这个机制解决的是软件设计里最老生常谈又最难做好的问题:解耦。接口定义方和实现提供方可以是两个不同团队,甚至发布周期完全不同。接口方只定义能力,不关心谁来实现;实现方只负责把自己注册进去,不关心调用方何时使用。SPI 在 JDK 自带的数据库驱动加载、日志门面、各种序列化库、以及很多框架的自动配置里都扮演着核心角色。对做后端开发、中间件开发,或者想弄懂框架启动原理的人来说,理解 SPI 不只是学会一个工具类,而是理解一整类“扩展架构”的起点。
1.1 SPI 到底解决的是什么问题
先把问题说透。假设你现在写了一个日志接口,接口里定义了 info、warn、error 三个方法,然后工程里有两个实现,一个是控制台输出,一个是文件输出。最朴素的写法是调用方直接 new 一个实现类,或者通过工厂类来手动切换。但这种写法有个天然瓶颈:每增加一个新输出方式,都要改一次调用方代码。今天加控制台,明天加文件,后天加数据库,代码越来越臃肿,而且调用方被迫知道所有实现类的存在。
SPI 把这件事倒过来了。调用方只面向接口编程,实现方各自在自己的 jar 包中声明“我是这个接口的提供者”,JDK 在运行时统一把能找到的提供者加载出来。调用方完全不需要知道有多少个实现、实现类叫什么、从哪个 jar 包来。以后新增一个实现,只需要把新 jar 包丢进 classpath,重启服务,功能自动生效。这是我理解里 SPI 最核心的价值:让扩展点从“代码配置”变成“物理存在”。
从架构层面看,SPI 还承担着“插件化”的底层职责。很多软件在发布时并不知道用户会用哪个实现,比如日志门面不知道用户用的是原生输出还是第三方适配,数据库驱动不知道用户连的是哪种数据库。interface 是稳定的,实现是变化的,SPI 让“稳定”和“变化”之间只剩下一个约定。这种思想在系统设计里非常重要,因为它把扩展的主动权从平台方交给了生态方。
1.2 SPI 与 API:一个字之差,方向完全相反
很多新人分不清 API 和 SPI,甚至以为是一回事。其实它们虽然都由接口构成,但设计方向是反过来的。
先说 API,Application Programming Interface。接口定义方把能力封装好,调用方按照定义去调用。比如你写一个支付接口,别人传订单号进来,你返回支付结果,定义方是在“提供能力”,调用方是在“使用能力”。调用方需要主动依赖接口,并且通常由定义方提供默认实现。
SPI 则完全不同。Service Provider Interface 的出发点是“我要为某个接口寻找外部实现”,定义方只描述这个能力应该长什么样,不提供具体的、唯一的实现,或者即使提供了默认实现,也希望让第三方能覆盖它。调用方在运行时反向去寻找实现方,主动权从调用方转移到了实现方。一句话概括:API 是“我给你什么你就用什么”,SPI 是“你做了什么我就用什么”。
下面这个表格可以很直观地看出区别:
| 对比维度 | API | SPI |
|---|---|---|
| 设计目的 | 对外提供固定能力 | 对外暴露扩展点 |
| 接口控制方 | 接口定义方主导 | 实现提供方参与 |
| 调用方向 | 调用方主动调接口 | 调用方被动发现实现 |
| 典型例子 | 业务方法调用 | 数据库驱动加载 |
| 扩展成本 | 通常要改调用方代码 | 加一个 jar 包即可 |
当然,实践中 API 和 SPI 并不是非黑即白。同一个接口在某个场景下可以既当 API 又当 SPI,比如一套对外输出 API 的组件,同时允许第三方实现其内部扩展点,这就是常见的设计手法。理解清楚它们的方向差,后面看各种源码时就会轻松很多。
2. JDK 原生 SPI 的运行机制与最小实现
JDK 从 1.3 开始引入 ServiceLoader,在 1.6 之后被广泛用于各种框架。它不像 Spring 那种复杂容器需要一堆注解和扫描器,而是完全基于一个“目录约定”来工作。只要 classpath 里有符合约定的资源文件,ServiceLoader 就能把它加载进来。这个约定简单到有点不可思议,但恰恰因为简单,才被大量框架接受并模仿。
2.1 目录规则:META-INF/services 下面发生了什么
JDK SPI 的约定只有一条:在 jar 包的 META-INF/services 目录下,创建一个以“接口全限定名”命名的文件,文件内容是这个接口的实现类的全限定名,一行一个。如果接口有多个实现,就写多行。听起来很朴素,但无数框架的自动发现机制都是从这一条规则发展出来的。
我用一个最简单的例子来演示。假设接口叫com.example.spi.Logger,有两个实现类ConsoleLogger和FileLogger,那么你的 jar 包目录结构应该长这样:
META-INF/services/ └── com.example.spi.Logger这个文件里的内容,每一行是一个实现类全限定名:
com.example.spi.ConsoleLogger com.example.spi.FileLogger重点提醒三点。第一,文件名必须严格等于接口的全限定名,包括大小写和包名,少写一个字母都会导致加载失败。第二,文件内容每行一个实现类全限定名,行与行之间用换行符分隔,允许在 # 号后面写注释,但实际项目中我很少看到有人写注释。第三,文件编码推荐使用 UTF-8,否则实现类名里如果包含非 ASCII 字符,在某些平台上会读取乱码,进而抛异常。
有了这个文件,剩下的工作就交给 ServiceLoader。调用方只需要写一行代码:
ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class); for (Logger logger : loader) { logger.log("hello spi"); }第一次通过迭代器访问时,ServiceLoader 会去扫描 classpath 下所有 jar 包中的 META-INF/services/com.example.spi.Logger 文件,把里面的类名解析出来,然后逐个实例化。整个过程对调用方完全透明,你不需要知道实现类在哪个 jar 包,也不需要知道类名叫什么。
2.2 ServiceLoader 的加载原理:为什么实现会被自动发现
只看那点文件规则,可能还是有人会疑惑:ServiceLoader 是怎么做到“自动”的?底层原理其实不复杂,它本质上是一个基于 ClassLoader 的资源查找 + 反射实例化过程。
第一步,ServiceLoader.load 会获取当前线程的上下文类加载器,或者你显式传入的 ClassLoader。这个类加载器负责去 classpath 所有 jar 包里查找资源。第二步,它用classLoader.getResources("META-INF/services/" + 接口全限定名)拿到所有 jar 包里匹配的资源 URL。第三步,逐个读取文件内容,按行解析出实现类全限定名。第四步,用反射Class.forName加载实现类,再通过newInstance或者构造函数创建对象。最后,把这些实例缓存在内部,延迟到调用方迭代时才全部创建。
这段流程里有几个关键点值得注意。ServiceLoader 是懒加载的,调用load方法时什么都不干,真正的类解析和实例化发生在你第一次调用iterator()之后。如果只 load 但不遍历,实现类一个都不会被创建。这一点在排查问题时很有用,很多人以为 load 之后实现类就已经实例化了,结果在实现类的构造函数里断点没生效,折腾半天发现是没触发迭代。
再说类加载器。SPI 加载最典型的翻车场景,就是跨类加载器访问。接口由系统类加载器加载,但实现类却由子类加载器负责,这时Class.forName找不到实现类。JDK 里针对这个情况的经典解法是线程上下文类加载器,ServiceLoader.load 的默认行为就是优先使用它。这也是为什么很多 Web 容器里用 SPI 经常需要注意线程上下文类加载器是否被篡改,一旦它变成 null 或指向错误加载器,SPI 就瞬间失效。
2.3 一次完整的最小实践:做一个自定义日志扩展点
说了这么多原理,不如直接上手跑一遍。我平时在给新同学讲 SPI 时,总喜欢让他们写一个“日志门面”,因为场景足够经典,够简单,又能体现扩展价值。
先定义接口:
package com.example.spi; public interface Logger { void info(String message); void error(String message); }再写一个控制台实现:
package com.example.spi; public class ConsoleLogger implements Logger { @Override public void info(String message) { System.out.println("[console-info] " + message); } @Override public void error(String message) { System.err.println("[console-error] " + message); } }再写一个文件实现,为了简化,这里只打一行标记:
package com.example.spi; public class FileLogger implements Logger { @Override public void info(String message) { System.out.println("[file-info] " + message); } @Override public void error(String message) { System.out.println("[file-error] " + message); } }然后在资源目录下新建文件META-INF/services/com.example.spi.Logger,内容写两行:
com.example.spi.ConsoleLogger com.example.spi.FileLogger最后是调用方:
package com.example.spi; import java.util.ServiceLoader; public class Main { public static void main(String[] args) { ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class); for (Logger logger : loader) { logger.info("spi demo"); } } }运行这个 main 方法,控制台会同时出现两条输出,分别来自两个实现。这就是 SPI 的基本用法:你定义了一个扩展点,实现了两个提供者,调用方完全不知道实现细节,但都执行了。
注意:如果你在 IDE 里直接运行,需要确认资源目录被正确标记为 resources,并且 target/classes 目录下真的生成了 META-INF/services 结构。我自己就见过同学把文件放到了 src/main/java 下面,结果编译后文件直接丢失。
2.4 加载过程怎么观察:调试 SPI 的实用技巧
SPI 的加载过程对新手来说有点像一个黑盒,看不到日志,也没有明显报错时,可能实现类根本没被加载。我的建议是先在每个实现类构造函数里加一行输出,这是最快验证方式。
public ConsoleLogger() { System.out.println("load ConsoleLogger"); }只要遍历 ServiceLoader,构造函数里的输出就会打出来。如果没打出来,优先检查三件事:文件路径是否在 META-INF/services 下,文件内容有没有多空格或空行,jar 包是否真的被 classpath 包含。
更进阶一点,可以在 ServiceLoader.load 之后手动调用loader.reload(),这会清空缓存,重新加载一遍实现。调试时可以故意改掉文件的某个实现类名,再调 reload,观察异常抛出时机,能帮你更直观地理解“load 只是取资源,遍历才真正解析类”这一点。另外,如果你在同一个工程里既有接口定义又有实现,并且是 IDEA 直接运行,需要注意 IDEA 可能只是把当前模块的 resources 目录加入 classpath,而不会像打包后那样把多个 jar 包合并扫描。所以调试 SPI 时,尽量用标准的 Maven 或 Gradle 构建后再跑,避免本地运行结果和生产结果不一致。
3. 影响范围:从 JDBC 到各类框架的 SPI 变体
JDK 原生 SPI 只是一个起点。它的设计思想被各种框架继承、改造、发扬光大,几乎成了 Java 生态里做“可扩展性”的一个通用范式。看不懂这一点,很多框架的自动配置和插件机制在你眼里就是玄学,看懂了就会发现它们全在玩同一个套路:约定一个位置,扫描实现,按规则装配。
3.1 JDBC 驱动加载:SPI 最著名的实战案例
如果你用过 JDBC,一定写过类似Class.forName("com.mysql.jdbc.Driver")的代码。老一辈开发者可能会告诉你这是“注册驱动”的标准写法,但到了 JDBC 4.0 之后,这行代码其实可以省略了。原因就是 SPI。
java.sql.Driver就是那个被预留出来的接口,各个数据库厂商在自己的驱动 jar 包里的 META-INF/services/java.sql.Driver 文件中,写下自己驱动实现类的全限定名。当DriverManager初始化时,它会使用 ServiceLoader 加载所有 Driver 实现,并自动注册到驱动列表里。所以现代 Java 项目连接数据库,只要 classpath 里有驱动包,DriverManager.getConnection就能自动找到合适的驱动,不需要再手动Class.forName。
从这你能看出 SPI 对“隔离变化”有多重要。业务代码面对的是统一的java.sql.Connection、Statement、ResultSet接口,至于底层是 MySQL、PG 还是别的数据库,完全由驱动 jar 包决定。换数据库就是换 jar 包,业务代码几乎不动。这就是 SPI 机制对数据库生态最深远的影响——它把数据库驱动和业务代码的耦合,降到了历史最低点。
3.2 容器与框架中的“类 SPI”设计
JDK 的 SPI 有个局限:它只能按接口全限定名找实现类,不能传参、不能排序、不能依据条件过滤。于是很多框架在吸收这个思想时,都会在自己的扩展机制上做一层增强。
常见的一种做法是“固定配置文件 + 接口扫描”。我看过很多框架的源码,启动阶段会固定扫描 classpath 下某个已知文件,比如 META-INF/auto-configuration.imports,里面一行一个配置类名,然后对这些配置类做条件判断,满足条件的才装载。它跟 JDK SPI 的关系非常明显:同样是“在约定位置声明实现类”,只不过配置文件名不再跟着接口名走,而是统一固定,并且多了条件化装载能力。
另一种做法是“按名字取扩展实?”。JDK SPI 是把所有实现类全部加载,让调用方自己遍历选择,而有些框架为了性能和控制性,会允许在配置里给实现起一个短名字,运行时根据用户配置的名字精确加载某一个。这种变体避免了“把几十个实现全部 new 一遍”的开销,也支持了更细腻的扩展选择。
还有条件式扩展点。框架在加载注册的实现时,会先看一组谓词条件,比如当前环境是否满足某个配置、某个类是否存在于 classpath,满足才注册。这一点比 JDK SPI 更聪明,因为 JDK SPI 只要能加载,你就会出现在迭代结果里,如果实现方和当前环境不匹配,应用还得自己想办法跳过。
3.3 SPI 与扩展机制生态位对比
看完上面的变体,可以整理一个更清晰的对照表:
| 机制类型 | 加载方式 | 核心能力 | 适用场景 |
|---|---|---|---|
| JDK 原生 SPI | 接口名找文件 | 自动发现 | JDBC 驱动、日志门面 |
| 固定配置文件 | 约定文件名扫描 | 批量注册 | 框架自动配置 |
| 按名字加载 | 配置短名映射 | 精确选择 | RPC 插件、序列化扩展 |
| 条件装配 | 环境判断后加载 | 动态过滤 | 多环境多实现切换 |
这些机制本质上都是 SPI 的延伸。理解了 JDK 原生那套约定,再去看框架源码里的各种 loader、registrar、imports 配置,你就不会陌生,因为它们把“扩展”这个概念落地的路径是一致的:定义接口、约定位置、运行时扫描、按策略装配。SPI 的影响范围也因此远远超出了一个 java.util 包下的工具类,它已经成了 Java 生态设计扩展架构的基本语法。
4. 从“能用”到“用好”:SPI 工程化落地细节
接完上一个 Demo,你以为你懂 SPI 了,但真正上线时还会遇到一堆问题。这一节是我觉得全篇最值钱的部分。因为官网文档只告诉你 ServiceLoader 怎么用,不会告诉你它在多模块打包时会有哪些坑,也不会告诉你多个实现之间的顺序该怎么定。
4.1 多个实现同时存在,怎么控制优先级
JDK SPI 对多个实现类的加载顺序没有任何官方保证。classpath 下 jar 包的扫描顺序、文件系统返回的资源顺序,都会影响最终迭代顺序。如果业务逻辑对此有依赖,就是一颗定时炸弹。
业界常见的做法是自己加一层“排序规则”。比如定义一个注解来标记优先级:
package com.example.spi; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface SpiOrder { int value() default 0; }在实现类上打上顺序:
@SpiOrder(100) public class ConsoleLogger implements Logger { // ... } @SpiOrder(200) public class FileLogger implements Logger { // ... }然后封装一个自己的加载工具:
package com.example.spi; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.ServiceLoader; public final class SpiLoader { private SpiLoader() { } public static <T> List<T> loadOrdered(Class<T> type) { List<T> result = new ArrayList<>(); for (T instance : ServiceLoader.load(type)) { result.add(instance); } result.sort(Comparator.comparingInt(SpiLoader::resolveOrder)); return result; } private static int resolveOrder(Object instance) { SpiOrder order = instance.getClass().getAnnotation(SpiOrder.class); return order == null ? 0 : order.value(); } }这样调用方拿到的 List 就是稳定有序的。优先级数值小的大概排前面,你可以按自己团队的习惯来定。这个方案虽然简单,但能解决大部分“多个实现都想抢首个执行位”的冲突。还有更复杂的方案,比如基于配置文件声明权重,或者基于运行环境动态选择,都需要结合业务场景设计,不要一上来就整过度设计。
4.2 类加载器问题:SPI 最常见翻车点
SPI 相关内容里,类加载器问题能占到排查量的一半。典型场景是在某些容器里,接口由父加载器加载,业务实现由子加载器加载,ServiceLoader 扫描时用错了加载器,结果就是ServiceConfigurationError,提示无法加载实现类。
解决办法通常是确保 ServiceLoader.load 使用的加载器和接口所在 jar 包的加载器是同一个体系,或者至少能向下观察到实现类。最简单的手段是在 load 时显式传入:
ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class, Logger.class.getClassLoader());如果还不行,就得考虑使用线程上下文类加载器:
ServiceLoader<Logger> loader = ServiceLoader.load( Logger.class, Thread.currentThread().getContextClassLoader() );但要注意,线程上下文类加载器不是银弹。它在某些框架里可能被业务代码临时切换,或者在高并发线程池复用场景下被污染。我的经验是:在线程入口处打印 ClassLoader 的 toString,观察它和接口、实现的加载器是不是父子关系;简单场景直接传接口自己的 ClassLoader,复杂场景再结合容器规范去调。
4.3 多 jar 包合并时,服务配置文件冲突怎么办
现代项目基本都靠构建工具管理依赖。假设你依赖了 A 库和 B 库,两个库都打包了同一个 META-INF/services 文件,那最终 classpath 中可能出现同名资源文件。构建工具在输出 jar 时只会保留其中一个,或者按规则合并,这会导致部分 SPI 实现丢失。
很多打包插件在聚合 jar 时提供了“资源转换器”的能力,可以把多个同名 META-INF/services 文件合并成一个,文件内容变成多行。如果不用这个能力,就会发生“明明依赖里有实现,但运行时找不到”的诡异情况。所以在使用构建插件制作可执行 jar 时,一定要检查是否有类似ServicesResourceTransformer的配置。
检查方法也不复杂:用解压工具打开最终生成的 jar,看看 META-INF/services 下的文件内容是否包含了所有期望的实现类。如果只看到一行,就要怀疑资源合并没生效。这个坑我踩过一次,排查了很久才发现不是代码问题,而是打包插件把两个服务文件互相覆盖了。
4.4 高频问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 遍历 ServiceLoader 没有输出 | 配置文件路径错误 | 检查 META-INF/services 文件名 |
| 报 ClassNotFoundException | 实现类没被 classpath 包含 | 检查依赖版本和打包配置 |
| 报 ServiceConfigurationError | 配置文件格式错误或类名拼错 | 检查文件编码、换行、注释 |
| 某些实现没加载 | 同名服务文件被覆盖 | 配置资源合并器 |
| 加载顺序不稳定 | 依赖 classpath 顺序 | 自定义排序机制 |
| 在容器里突然失效 | 类加载器上下文不对 | 显式传入正确 ClassLoader |
| 修改文件后不生效 | ServiceLoader 有缓存 | 调用 reload 或重启服务 |
这张表基本覆盖了团队里这些年遇到的绝大部分 SPI 问题。每次排查时,我建议从“文件是否存在、内容是否正确、jar 是否包含、加载器是否可见”四个维度依次过一遍,能省下大量时间去猜。
5. 设计方案取舍:SPI 使用的边界与思考
SPI 是好东西,但它不是万金油。不看场景乱用,也会引入新的复杂度。这一节我想聊一聊什么时候该上 SPI,什么时候该收着点,以及它和依赖注入、插件化这些概念之间到底是什么关系。
5.1 什么时候值得使用 SPI
我的判断标准很简单:如果系统存在一个明确的抽象能力,并且你预期会有多个外部实现,且这些实现的加入时机不可控,那就适合 SPI。
典型的场景是日志门面。日志框架的核心抽象是 Logger,市面上有各种输出实现,发布节奏不一致,用户还可能自定义实现,这天然就是 SPI 的场地。数据库驱动就不用说了,没有 SPI 之前每个驱动都要手动注册,有了 SPI 之后真的是一包即用。再比如序列化器,默认用 JSON,用户想接其他格式,SPI 可以做到只加依赖不违反开闭原则。
另一个适合上 SPI 的场景是“平台型项目”。你的系统要开放给第三方集成,但又不想因此形成一个强依赖的插件体系。SPI 提供了一套最轻量的开放方式:把扩展点定义好,文档写清楚,第三方自己打包实现类,放到约定的资源路径里即可。维护成本低,接入成本也低。
5.2 什么时候别急着上 SPI
如果接口只有唯一实现,而且你很清楚未来很长一段时间内不会有第二个实现,那上 SPI 就是过度设计。SPI 本身带来的运行时扫描、动态发现、调试干扰,都属于额外的认知成本。一个写着ServiceLoader.load的调用方,远不如直接构造实现类来得直白。
如果实现之间不是“独立可选”的关系,而是需要互相协作、形成链路,SPI 单靠 ServiceLoader 很难表达。每个实现都是独立实例,彼此之间没有引用关系,想在多个实现之间做 AOP 拦截、责任链传递,就很别扭。这种情况更适合用显式的容器装配,或者专门设计一套插件模型。
还有一个容易被忽略的坑:SPI 会让代码里出现“隐式依赖”。调用方看不到实现类,但运行结果又被实现类影响,新人接手时极难从代码里直接看出当前到底生效的是哪个实现。所以团队规范里一定要有清晰的约定,明确 SPI 配置文件的归属和维护方式。否则一个隐式扩展点,就成了谁都不敢动的雷区。
5.3 SPI 和依赖注入、插件化的边界
SPI 常常和依赖注入一起出现,但它们是两个层面的东西。依赖注入解决的是“对象如何被组装”,SPI 解决的是“实现如何被找到”。在容器框架里,可以先通过 SPI 发现候选实现,再利用依赖注入把实现注入到业务代码中。两者是协作关系,而不是替代关系。
插件化则是一个更完整的概念。一个成熟的插件系统通常包括扩展点定义、注册中心、生命周期管理、隔离环境、通信协议等,SPI 只是其中“发现”这一步。你别指望靠一个 ServiceLoader 做出一套热插拔的插件系统,它的能力边界就在于静态 classpath 下的自动发现。理解了这层边界,你就不会把 SPI 神话,也不会在它该出现的地方缺席。
5.4 我的几个实践心得
最后聊点个人体会。SPI 看起来简单,真正用得优雅并不容易。我建议所有做后端的人,都亲手写一个最小 SPI Demo,然后故意制造几个错误,比如文件名拼错、加载器传错、资源文件被覆盖,把这些报错全部踩一遍。踩过之后,你对日志里那些 ServiceConfigurationError 就会特别敏感,不会再对着堆栈发呆。
在我负责的某套基础组件里,我们把 SPI 选型放在一个很克制的范围内:只有真正需要外部实现的接口才会开放给 SPI,其余一律用普通接口加构造器注入。这样做的好处是,暴露扩展点的数量有限,维护者心里有数,调用方也不至于被一堆隐式行为搞晕。
如果你需要实现更精细的加载顺序,可以考虑把 SPI 和注解配合使用,也就是 4.1 里那个方案。如果你需要更强的插件能力,那就要跳出 JDK 原生机制,去看那些成熟的插件化框架。但不管怎么扩展,“定义接口、约定位置、运行时发现”这套底层逻辑,始终是你理解一切的锚点。
SPI 的价值不在于代码量多少,而在于它把“稳定与扩展”这对矛盾用一个极其简洁的约定化解了。希望这篇总结能帮你少走一些弯路,也欢迎你把这篇文章转给团队里刚接触这块内容的同事,让以后的 SPI 排查不再从零开始。