Java SPI

SPI(Service Provider Interface),是一种服务提供发现机制,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,其核心是基于“面向接口编程+策略模式+配置文件”组合实现的动态加载机制

SPI是一种机制,Java SPI只是它的其中一种实现。

SPI整体机制如图:

img

当服务的提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类的全路径名。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的META-INF/services/中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行反射加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader

SPI和API的区别

在现今的微服务的框架下,我们最常见的是提供API服务,也就是接口和实现都部署在同一个集群中,向外通过Http/Rpc协议进行暴露,外部调用方通过引入jar包的方式,使用我们的服务,常用的框架,如:Dubbo,Feign,Ribbon等便是如此。

这里实际包含两个问题,第一个SPI和API的区别?第二个什么时候用API,什么时候用SPI?

SPI接口位于调用方所在的包中

  • 概念上更依赖调用方。
  • 组织上位于调用方所在的包中。
  • 实现位于独立的包中。
  • 常见的例子是:插件模式的插件。
  • 例如:
    • 数据库驱动加载接口实现类的加载 – JDBC加载不同类型数据库的驱动;
    • 日志门面接口实现类加载 – SLF4J加载不同提供商的日志实现类;
    • Spring – Spring中大量使用了SPI,比如:对servlet3.0规范对ServletContainerInitializer的实现、自动类型转换Type Conversion SPI(Converter SPI、Formatter SPI)等;
    • Dubbo – Dubbo中也大量使用SPI的方式实现框架的扩展, 不过它对Java提供的原生SPI做了封装,允许用户扩展实现Filter接口;

JDK中原生SPI接口,请参考博文:Java Core「6」反射与 SPI 机制

API接口位于实现方所在的包中

  • 概念上更接近实现方。
  • 组织上位于实现方所在的包中。
  • 实现和接口在一个包中。
    • 例如:微服务中提供的接口API,OpenFeign中的所有Controller层方法。

SPI 的使用

使用场景

SPI一般使用在一个接口有多个实现的场景,可以在外界无感的情况下实现接口实现的动态切换,内部会有一个类的动态加载的过程。

动态类加载

类的动态加载有两种方式:

  • 自定义类加载器ClassLoader在代码运行时来进行加载指定的类
    • ClassLoader 类:ClassLoader 是用于加载类的一个抽象类,Java 提供了多种实现,比如 URLClassLoader 和 AppClassLoader。使用 ClassLoader 加载类的方式更加灵活,可以从不同的位置加载类,比如本地文件系统、网络等等。
  • 反射(SPI是基于反射实现的)
    • Class.forName() 方法:该方法根据类的完整路径名加载类,返回对应的 Class 对象。需要注意的是,该方法可能会抛出 ClassNotFoundException 异常,需要进行捕获或声明抛出。

Class.forName() 和 ClassLoader 的区别

使用 Class.forName() 加载类时,会自动初始化该类,包括执行静态代码块和初始化静态成员变量。而使用 ClassLoader 加载类时,可以控制类的初始化时机,只有在需要使用类时才会进行初始化。

加载外部类和本地类的区别

Java 中的类可以分为两类:外部类本地类外部类是指存储在磁盘上的类文件,而本地类是指在当前程序中定义的类

加载外部类时需要指定类文件的路径,比如:

1
Class clazz = Class.forName("com.maple.MyClass", true, ClassLoader.getSystemClassLoader());

其中:第一个参数是类的完整路径名,第二个参数表示是否进行初始化,第三个参数是 ClassLoader。

加载本地类则可以直接使用目标类,这是利用Java类加载机制,默认是懒加载的,用到的时候才会进行加载:

1
MyClass myObj = new MyClass();

使用方法

定义一个顶层接口

1
2
3
4
5
6
7
/**
* @author Maple
* @date 2022/11/14
*/
public interface RobotInterface {
void sayHello();
}

定义一些底层实现
实现1:

1
2
3
4
5
6
7
8
9
10
11
12
import com.jdk.spi.intf.RobotInterface;

/**
* @author Maple
* @date 2022/11/14
*/
public class Bumblebee implements RobotInterface {
@Override
public void sayHello() {
System.out.println("Hello, I'm Bumblebee");
}
}

实现2:

1
2
3
4
5
6
7
8
9
10
/**
* @author Maple
* @date 2022/11/14
*/
public class OptimusPrime implements RobotInterface {
@Override
public void sayHello() {
System.out.println("Hello, I'm Optimus Prime.");
}
}

写一个配置文件,特定目录下,特定名称,特定内容
在类路径(Spring架构下是src/main/resources 下即可)下创建/META-INF/services文件夹,然后文件夹里创建以顶层接口全路径为名的文件,如图:

文件内容为每一个实现类的全路径为一行,如:

1
2
com.jdk.spi.impl.Bumblebee
com.jdk.spi.impl.OptimusPrime

测试SPI:

执行过程:根据入参获取META-INF/services/目录下的目标配置文件,一行一行读取配置内容得到所有实现类的全路径,最后通过反射进行实例化并调用方法。

1
2
3
4
5
6
7
public static void main(String[] args) {
// 默认使用的是线程上下文类加载器,但是JDK默认是没有具体的实现,而是使用系统类加载器作为线程上下文类加载器来使用.
// 到此也只是完成了目标类和对应的类加载器的封装 -- 懒加载迭代器.
ServiceLoader<RobotInterface> serviceLoader = ServiceLoader.load(RobotInterface.class);
// 执行迭代器的时候才进行类加载并通过反射实例化对象,最后调用目标方法.
serviceLoader.forEach(RobotInterface::sayHello);
}

输出:

1
2
Hello, I'm Bumblebee
Hello, I'm Optimus Prime.

SPI的优缺点

优点

使用Java SPI机制的优势是实现解耦,使得第三方服务模块的装配控制的逻辑与调用者的业务代码分离,而不是耦合在一起。应用程序可以根据实际业务情况启用框架扩展或替换框架组件。相比使用提供接口jar包,供第三方服务模块实现接口的方式,SPI的方式使得源框架,不必关心接口的实现类的路径,可以不用通过下面的方式获取接口实现类:

  • 代码硬编码import 导入实现类;
  • 指定类全路径反射获取:例如在JDBC4.0之前,JDBC中获取数据库驱动类需要通过Class.forName("com.mysql.jdbc.Driver"),类似语句先动态加载数据库相关的驱动,然后再进行获取连接等的操作
  • 第三方服务模块把接口实现类实例注册到指定地方,源框架从该处访问实例

通过SPI的方式,第三方服务模块实现接口后,在第三方的项目代码的META-INF/services目录下的配置文件指定实现类的全路径名,源码框架即可找到实现类。

缺点

注意到实现类的加载过程,是通过java.util.ServiceLoader进行实现。通过iterator,遍历每一个实现类,而没有按需加载。

  • 不能按需加载。虽然 ServiceLoader 做了延迟载入,但是基本只能通过遍历全部获取,也就是接口的实现类得全部载入并实例化一遍。容易造成资源浪费。
  • 获取某个实现类的方式不够灵活,只能通过 Iterator 形式迭代获取,不能根据某个参数来获取对应的实现类。
  • 多线程并发使用 ServiceLoader 类的实例存在安全隐患。
  • 实现类不能通过有参构造器实例化。

注意事项
接口实现类必须提供一个无参的构造器!,因为实例化接口实现类的时候是利用class.newInstance()方法,其是利用无参构造来实例化的。

源码分析

其实就是利用线程上下文类加载器来对指定路径下的文件进行类加载,然后通过反射来实例化对象,核心就是Class.forName(),并封装成一个Iterable对象(LazyIterator懒迭代器),实现遍历调用。
LazyIterator 懒迭代器在执行迭代的时候才进行反射操作,并通过反射实例化对象,最后调用目标方法来完成具体业务执行。

ServiceLoader.load()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 指定自定义类加载的路径
private static final String PREFIX = "META-INF/services/";

public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取线程线程的上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();

// 使用线程上下文类加载器(如果为null, 就是用系统类加载器)对目标service接口的所有实现类进行加载, 并封装成为Iterator对象.
return ServiceLoader.load(service, cl);
}

public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
return new ServiceLoader<>(service, loader);
}

private ServiceLoader(Class<S> svc, ClassLoader cl) {
service = Objects.requireNonNull(svc, "Service interface cannot be null");
loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; // 线程上下文类加载器为空就使用系统类加载器
acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
reload();
}

public void reload() {
providers.clear(); // 清空缓存
// 懒加载的迭代器,即在开始进行迭代时才会执行类加载动作并通过反射创建对象并调用目标方法
lookupIterator = new LazyIterator(service, loader);
}

LazyIterator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 利用反射进行类加载并获取到Class对象.
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,"Provider " + cn + " not a subtype");
}
try {
// 利用反射实例化对象并进行类型转换.
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,"Provider " + cn + " could not be instantiated",x);
}
throw new Error(); // This cannot happen
}

从上述源码可以看出,Java 应用运行的初始线程的上下文类加载器就是系统类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。这些 SPI 的接口由 Java 核心库来提供,如 JAXP 的 SPI 接口定义包含在 javax.xml.parsers包中。这些 SPI 的实现代码很可能是作为 Java 应用所依赖的 jar 包被包含进来,可以通过类路径(CLASSPATH)来找到,如实现了 JAXP SPI 的 Apache Xerces所包含的 jar 包。SPI 接口中的代码经常需要加载具体的实现类。如 JAXP 中的 javax.xml.parsers.DocumentBuilderFactory类中的 newInstance()方法用来生成一个新的 DocumentBuilderFactory的实例。这里的实例的真正的类是继承自 javax.xml.parsers.DocumentBuilderFactory,由 SPI 的实现所提供的。如在 Apache Xerces 中,实现的类是 org.apache.xerces.jaxp.DocumentBuilderFactoryImpl。而问题在于,SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的; SPI 实现的 Java 类一般是由系统类加载器来加载的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的父类加载器。也就是说,类加载器的代理模式无法解决这个问题。线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是系统类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。这就是JVM中常说的打破类加载的双亲委派模型。


参考文档:

Java 反射:动态类加载和调用教程

Java基础五大机制 —— SPI机制基础