1、SPI机制简介
SPI 全称是 Service Provider Interface,是一种 JDK 内置的动态加载实现扩展点的机制,通过 SPI 技术我们可以动态获取接口的实现类,不用自己来创建。这个不是什么特别的技术,只是 一种设计理念。
在面向对象的设计原则中,一般推荐模块之间基于接口编程,通常情况下调用方模块是不会感知到被调用方模块的内部具体实现。一旦代码里面涉及具体实现类,就违反了开闭原则。如果需要替换一种实现,就需要修改代码。
为了实现在模块装配的时候不用在程序里面动态指明,这就需要一种服务发现机制。Java SPI 就是提供了这样一个机制:为某个接口寻找服务实现的机制。这有点类似 IOC 的思想,将装配的控制权移交到了程序之外。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
2、SPI原理
Java SPI 实际上是基于接口的编程+策略模式+配置文件组合实现的动态加载机制。
系统设计的各个抽象,往往有很多不同的实现方案,在面向的对象的设计里,一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。如果代码中引用了特定的实现类,那么就违反了可插拔的原则。为了进行实现的替换,需要对代码进行修改。需要一种服务发现机制,以实现在模块装配时无需在程序中动态指定。
Java SPI提供了一种机制,可以寻找与某个接口相关的服务实现。在模块化设计中,一种类似于IOC思想的机制被广泛使用,即将组件的装配控制权转移到程序之外。所以SPI的核心思想就是解耦。
3、使用场景
- JDBC驱动,加载不同数据库的驱动类
- Spring中大量使用了SPI,比如:对servlet3.0规范对ServletContainerInitializer的实现、自动类型转换Type Conversion SPI(Converter SPI、Formatter SPI)等
- Dubbo中也大量使用SPI的方式实现框架的扩展, 不过它对Java提供的原生SPI做了封装,允许用户扩展实现Filter接口
- Tomcat 加载 META-INF/services下找需要加载的类
- SpringBoot项目中 使用@SpringBootApplication注解时,会开始自动配置,而启动配置则会去扫描META-INF/spring.factories下的配置类
4、源码论证
SPI机制的核心就是ServiceLoader类。其主要的属性如下:
// 指出接口配置文件的位置,也就是为什么要在META-INF/services/下创建接口的全限定名文件的原因
private static final String PREFIX = "META-INF/services/";
// 正在被加载的类(接口)的class对象
private final Class<S> service;
// 加载使用的类加载器
private final ClassLoader loader;
// 创建 ServiceLoader 时采用的访问控制上下文
private final AccessControlContext acc;
// 缓存已经加载的实现, 按实例化顺序缓存
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
// The current lazy-lookup iterator
private LazyIterator lookupIterator;
load()方法的实现如下:
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 使用当前线程的ClassLoader进行加载待加载的实现类
return ServiceLoader.load(service, cl);
}
public static <S> ServiceLoader<S> load(Class<S> service,ClassLoader loader){
// load 方法本质是创建一个ServiceLoader对象
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();
// 根据接口类型(父类)和类加载器初始化LazyIterator
lookupIterator = new LazyIterator(service, loader);
}
private LazyIterator(Class<S> service, ClassLoader loader) {
this.service = service;
this.loader = loader;
}
跟踪load()方法发现其本质是创建了一个ServiceLoader对象,其共有两个参数,分别是代加载的类父类(接口)Class类对象和类加载器。在构造方法中完成了两件事,一个是变量赋值,一个是调用reload()方法。reload()方法则根据接口类型(父类)和类加载器初始化LazyIterator
当执行ServiceLoader#iterator()时,会创建java.util.Iterator匿名内部类实现:
public Iterator<S> iterator() {
return new Iterator<S>() {
Iterator<Map.Entry<String,S>> knownProviders
= providers.entrySet().iterator();
public boolean hasNext() {
if (knownProviders.hasNext())
return true;
return lookupIterator.hasNext();
}
public S next() {
if (knownProviders.hasNext())
return knownProviders.next().getValue();
return lookupIterator.next();
}
public void remove() {
throw new UnsupportedOperationException();
}
};
}
当执行hasNext() 方法时,会先去providers查找已经加载的缓存实现,如果不存在,则会继续调用LazyIterator#hasNext()用于发现尚未加载的实现,最后的实现在LazyIterator#hasNextService()中
LazyIterator的关键属性
// 缓存所有需要查找jar包(文件)路径,
Enumeration<URL> configs = null;
// 缓存所有被查找到的实现类全限定名
Iterator<String> pending = null;
// 迭代器使用,下一个需要被加载的类全限定名
String nextName = null;
hasNextService()实现核心如下:
// 获取所由需要扫描的包路径
configs = loader.getResources(fullName);
// 循环扫描configs中所有的包路径,解析META-INF/services中的指定文件(上例中的cn.bmilk.chat.spi.Registry文件)
//
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
// pending缓存了所有查找到的类全限定名
pending = parse(service, configs.nextElement());
}
在知道是否存在接口的实现后,就是通过next()方法获取实现,核心功能由nextService()贡献,核心实现如下:
// 获取一个实现类全限定名
String cn = nextName;
// 加载这个类
Class<?> c = Class.forName(cn, false, loader);
// 使用反射创建对象
c.newInstance()
hasNextService()完成堆配置文件的读取,nextService()完成类的加载和对象的创建,这个一切都没有在ServiceLoader创建时完成,这也是体现了延迟Lazy的一个含义。
load()与loadInstalled():
loadInstalled()和load()一样,本质都是创建了一个ServiceLoaderd对象,不同点是使用的加载器不同,load()使用的是Thread.currentThread().getContextClassLoader()当前线程的上下文加载器, loadInstalled()使用的是ExtClassLoader加载器来加载
具体实现如下:
public static <S> ServiceLoader<S> loadInstalled(Class<S> service) {
ClassLoader cl = ClassLoader.getSystemClassLoader();
ClassLoader prev = null;
while (cl != null) {
prev = cl;
cl = cl.getParent();
}
return ServiceLoader.load(service, prev);
}
使用这个方法将只扫描JDK安装目录jre/lib/ext下的jar包中指定的实现,我们应用程序类路径下的实现将会被忽略掉
5、示例
1、定义接口
public interface UserService {
/**
* 获取用户信息
* @return
*/
String getUserInfo();
}
2、创建两个实现类
public class DataBaseUserService implements UserService {
@Override
public String getUserInfo() {
return "从数据库中获取用户信息";
}
}
public class FileUserService implements UserService {
@Override
public String getUserInfo() {
return "从文件中获取用户信息";
}
}
3、配置文件
创建两个文件夹:META-INF
、services
,在创建一个普通文件即可:pers.zhang.UserService
注意:一定是全限定名
pers.zhang.DataBaseUserService
pers.zhang.FileUserService
4、加载实现类
public class SPITestDemo {
public static void main(String[] args) {
ServiceLoader<UserService> services = ServiceLoader.load(UserService.class);
Iterator<UserService> iterator = services.iterator();
while (iterator.hasNext()) {
UserService service = iterator.next();
System.out.println(service.getUserInfo());
}
}
}
//从数据库中获取用户信息
//从文件中获取用户信息
6、优缺点
优点
- 解耦 使得第三方服务模块的装配控制的逻辑与调用者的业务代码分离,而不是耦合在一起,应用程序可以根据实际业务情况启用框架扩展或替换框架组件。相比使用提供接口jar包,供第三方服务模块实现接口的方式,SPI的方式使得源框架,不必关心接口的实现类的路径
缺点
- Java SPI虽然使用了懒加载机制,但是其获取一个实现时,需要使用迭代器循环加载所有的实现类
- 当需要某一个实现类时,需要通过循环一遍来获取
这个两个问题,在Dubbo实现自己的SPI机制时进行了增强,可以仅加载自己想要的扩展实现。