一、SPI的概念
1.1、什么是API?
API在我们日常开发工作中是比较直观可以看到的,比如在 Spring 项目中,我们通常习惯在写 service 层代码前,添加一个接口层,对于 service 的调用一般也都是基于接口操作,通过依赖注入,可以使用接口实现类的实例。
如上图所示,服务调用方无需关心接口的定义与实现,只进行调用即可,接口、实现类都是由服务提供方提供。服务提供方提供的接口与其实现方法就可称为API,API中所定义的接口无论是在概念上还是具体实现,都更接近服务提供方(实现方),通常接口与实现类在同一包中。
1.2、什么是SPI?
如果我们将接口的定义放在调用方,服务的调用方定义一个接口规范,可以由不同的服务提供者实现。并且,调用方能够通过某种机制来发现服务提供方,通过调用接口使用服务提供方提供的功能,这就是SPI的思想。
SPI
全称 Service Provider Interface
,是 Java
提供的一套用来被第三方实现或者扩展的 API
,它可以用来启用框架扩展和替换组件。
服务提供方按接口规范实现服务,服务调用方通过某种机制为这个接口寻找到这个服务, SPI的特点很明显:接口的定义(调用方提供)与具体实现是隔离的(服务提供方提供),使用接口的实现类需要依赖某种服务发现机制。
通过对比,我们可以看出接口在API与SPI中的含义还是有很大的不同,总的来说,API 中的接口是更像是服务提供者给调用者的一个功能列表,而 SPI 中更多强调的是,服务调用者对服务实现的一种约束。
二、为什么要使用SPI?
-
面向接口编程: 面向对象的设计与编程中,我们经常强调“依赖抽象而不是具体”,这样做就是为了实现高内聚、低耦合,提供代码灵活性和可维护性等等。
-
提供标准标准但没有具体实现的业务场景: SPI 机制的使用场景就是没有统一实现标准的业务场景。一般就是,服务调用方有定义好的标准接口,但是没有统一的实现,需要服务提供方提供其具体实现。
-
解耦: SPI 机制优势就是低耦合。将接口的定义以及具体实现分离,可以实现运行时根据业务实际场景启用或者替换具体实现类。
三、Java中如何使用SPI
接口定义、服务实现这些我们都轻车熟路,调用方直接依赖接口不依赖具体实现,这是依赖倒置原则,我们在Spring项目中使用API时,会使用Spring的依赖注入(DI)来实现“服务发现”,同样地,SPI的重点也是如何让调用方发现接口的具体实现,也就是上文提到的某种服务发现机制。
SPI的服务发现机制是由ServiceLoader提供,ServiceLoader是Java在JDK 6中引进的新特性,它主要是用来发现并加载一系列的service provider。当服务的提供者,提供了服务接口的一种实现之后,只需要在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件,该文件的内容就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并加载实现类,完成依赖的注入,这就是Java SPI的服务发现机制。
下面就结合一个示例来具体讲讲。若有这样一个需求,需要使用一个接口来完成内容查找服务,接口的具体实现交给其他服务提供方,实现可能是基于文件系统的查找,也可能是基于数据库的查找。
3.1、定义接口
提供一个查找服务标准接口,先定义调用方的内容查找方法:
// 查找服务接口
public interface Search {
// 按关键字查询内容方法
String searchDoc(String keyword);
}
这个接口就是给服务提供方来实现的,将它打包发布mvn clean install,确保maven仓库中有该jar包,之后提供者在项目中就可以引入这个 jar 包了。
3.2、服务实现
制定并发布完标准接口后,我们假设第一个服务提供方提供了一种文件查找的实现。新建项目search-file,并引入刚才发布的标准接口jar包:
<dependency>
<groupId>com.blblccc.search</groupId>
<artifactId>search-standard</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
实现定义好的接口:
public class FileSearch implements Search {
@Override
public String searchDoc(String keyword) {
return "文件查找:" + keyword;
}
}
并在项目的resources的目录下,创建META-INF/services目录,然后以前面定义的接口名com.blblccc.spi.learn.Search创建文件,并在文件中写入实现类的全限定名。
com.blblccc.file.search.FileSearch
一个服务方的简单实现就完成了,用maven打成 jar 包,发布到maven之后就可以提供给调用方使用了。
接着,按上述实现方式,再创建一个项目search-database使用数据库的实现接口:
public class DatabaseSearch implements Search {
@Override
public String searchDoc(String keyword) {
return "数据库查找:" + keyword;
}
}
同样,打包发布后就可以提供给调用方使用了。
3.1、服务发现
接下来关键的一步就是服务发现,服务发现需要依赖ServiceLoader的使用。创建一个新项目search-sever,引入上面打好的两个提供方的 jar 包。
<dependencies>
<dependency>
<groupId>com.blblccc.search</groupId>
<artifactId>search-file</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.blblccc.search</groupId>
<artifactId>search-database</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
虽然每个服务提供者对于接口都有不同的实现,但是作为调用者来说,它并不需要关心具体的实现类,我们要做的是通过接口来调用服务提供者实现的方法。
下面,就是关键的服务发现环节,使用ServiceLoader来加载具体的实现类,调用方只需调用对应接口方法即可。
public class SearchDoc {
public static void main(String[] args) {
new SearchDoc().searchDocByKeyWord("hello world");
}
public void searchDocByKeyWord(String keyWord) {
ServiceLoader<Search> searchServiceLoader = ServiceLoader.load(Search.class);
for (Search search : searchServiceLoader){
String doc = search.searchDoc(keyWord);
System.out.println(doc);
}
}
}
测试结果:
文件查找:hello world
数据库查找:hello world
可以看到,通过定义的Search发现了两个实现类。整段代码中没有出现过具体的服务实现类,操作都是通过接口调用。
四、SPI实现原理
4.1、Java的类加载器
首先Java的类加载器可被分为四类
启动类加载器Bootstrap ClassLoader
-
用于加载Java的核心类,由底层的 C++ 实现。启动类加载器不属于 Java 类库,无法被 Java 程序直接引用。
-
Bootstrap ClassLoader 的 parent 属性为 null
标准扩展类加载器 Extension ClassLoader
-
由 sun.misc.Launcher$ExtClassLoader 实现
-
负责加载 JAVA_HOME 下 libext 目录下的或者被 java.ext.dirs 系统变量所指定的路径中的所有类库
应用类加载器 Application ClassLoader
-
由 sun.misc.Launcher$AppClassLoader 实现
-
负责在 JVM 启动时加载用户类路径上的指定类库
用户自定义类加载器 User ClassLoader
-
当上述 3 种类加载器不能满足开发需求时,用户可以自定义加载器
-
自定义类加载器时,需要继承 java.lang.ClassLoader 类。如果不想打破双亲委派模型,那么只需要重写 findClass 方法即可;如果想打破双亲委派模型,则需要重写 loadClass 方法
4.2、双亲委派机制
-
双亲委派机制的关系链如下:
Bootstrap
引导类加载器 → Extension
拓展类加载器 → Application
系统类加载器 → User
自定义类加载器
-
双亲委派模式的好处:这种自下而上的层级设计,能避免类的重复加载,同时防止Java的核心API类在运行时被篡改,减少性能开销和安全隐患问题。
-
双亲委派模式的弊端: 无法做到不委派(必须首先上浮到最顶层),也无法向下委派(顶层优先)。
4.3、SPI为什么要打破双亲委派机制?
这里有一条重要原则:类加载器可见性原则
- 子类加载器能查看父类加载器加载的所有类,但是父类加载器不能查看子类加载器所加载的类
位于rt.jar包中的SPI接口,是由Bootstrap类加载器完成加载的,而classpath路径下的SPI实现类,则是App类加载器进行加载的。但往往在SPI接口中,会经常调用实现者的代码,所以一般会需要先去加载自己的实现类,但实现类并不在Bootstrap类加载器的加载范围内,而经过前面的双亲委派机制的分析,我们已经得知:子类加载器可以将类加载请求委托给父类加载器进行加载,但这个过程是不可逆的。也就是父类加载器是不能将类加载请求委派给自己的子类加载器进行加载的,所以此时就出现了这个问题:如何加载SPI接口的实现类?答案是打破双亲委派模型。
以JDBC举例来说:
ServiceLoader和DriverManager类以及SPI接口类都是rt核心包的系统类,它们都是启动类加载器bootstrap classloader加载的,而第三方jar包是在classPath下的,启动类加载器加载不了,也无法委派给父加载器加载,所以我们就需要破坏双亲委派机制,指定一个类加载器去加载。
4.4、SPI底层实现原理
要搞清楚这个问题的原因,得先确认我们使用SPI的入口:
ServiceLoader<Xxxx> serviceLoader = ServiceLoader.load(Xxxx.class);
进入该方法,寻找其实现的方式:
注意此处获取了当前线程的类加载器,而在线程中调用该类方法的是我们用户自己。那么这里就理解为获取到了用户的类加载器。
再往该方法中查找,找到该段代码:
注意该段代码中,cl为上一步获取到的类加载器,如果发现类加载器不存在,会再次获取系统默认加载器,这个系统默认加载器在常规情况下是用于加载启动类的加载器(jdk注释中解释),而启动类则是我们用户自己定义的类,这里毋庸置疑也会是应用类加载器。
从上面的代码中我们总结出来,ServiceLoader获取了我们的应用类加载器,至此load方法入口基本上没有其他内容可以细看。
为减轻文章阅读压力,直接跳转到该方法
java.util.ServiceLoader.LazyIterator#nextService
注意这里的loader是我们前面获取到的应用类加载器,这个方法中是获取到了具体需要实例化的实现类,即将对其进行实例化, 在这之前需要先获取到Class,这里使用Class.forName(class, false, ClassLoader)方法,这个方法的含义是使用指定的类加载器去加载指定的类。既然这里的类加载器是应用类加载器,那么类加载顺序自然就又回到了应用类加载器-->扩展类加载器-->BootStrap类加载器-->扩展类加载器-->应用类加载器,能加载到我们想要的类也就不奇怪了。
综上,Java SPI的实现是依靠ServiceLoader,ServiceLoader通过使用线程上下文类加载器来加载SPI接口实现类,实现类的全路径名需配置在META-INF/services/目录下,以接口名命名的文件内容中,ServiceLoader会读取文件中的全路径名,通过反射机制实例化接口实现类。