目录:
一:什么是SPI机制?
二:SPI机制的广泛应用?
三:带着问题深入理解SPI机制?
一:什么是SPI机制
SPI 全称:Service Provider Interface
字面意思:服务提供者的接口
我们来拆开分析一下,首先它是一个接口,然后是提供服务调用者使用的。
为什们要使用spi:
在面向对象编程中,基于开闭原则和解耦的需要,一般建议用接口进行模块之间通信编程,通常情况下调用方模块是不会感知到被调用方模块的内部具体实现。
为了实现在模块装配的时候不用在程序里面动态指明,这就需要一种服务发现机制。Java SPI 就是提供了这样一个机制:为某个接口寻找服务实现的机制。这有点类似 IoC 的思想,将装配的控制权移交到了程序之外。
重新理解spi机制:SPI是专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
SPI整体机制图如下:
二:SPI机制的简单示例
这里分享一个我司做资源搜索的实现方案:
2.1 先定义一个搜索接口:
2.2 视频搜索实现:
2.2 试卷搜索实现:
2.3 在项目META-INF/services创建一个文本文件:名称为接口的“全限定名”,内容为实现类的全限定名:
2.4 编写测试类:
测试代码打印结果:
视频搜索!!!!
试卷搜索!!!!
2.5 ServiceLoader源码解析
便我们看一下ServiceLoader的源码信息,首先通过常量的定义,我们可以看到为什么要将文件配置在META-INF/services下了。
,简单介绍一下该类的基本操作流程。
- 通过ServiceLoader的load(Class<S> service)方法进入程序内部;
- 上面load方法内获得到ClassLoader,并再此调用内部的load(Class<S> service,lassLoader loader)方法,该方法内会创建ServiceLoader对象,并初始化一些常量。
- ServiceLoader的构造方法内会调用reload方法,来清理缓存,初始化LazyIterator,注意此处是Lazy,也就懒加载。此时并不会去加载文件下的内容。
- 当遍历器被遍历时,才会去读取配置文件。
5.关于读取META-INF/services下配置文件的核心代码如下:
同过以上代码我们会发现,其实ServiceLoader扫描了所有jar包下的配置文件。然后通过解析全限定名获得,并在遍历时通过Class.forName进行实例化。
三:SPI机制的广泛应用
3.1 SPI机制在JDBC DriverManager中的应用
在JDBC4.0之前,我们开发有连接数据库的时候,通常会用Class.forName("com.mysql.jdbc.Driver")这句先加载数据库相关的驱动,然后再进行获取连接等的操作。而JDBC4.0之后不需要用Class.forName("com.mysql.jdbc.Driver")来加载驱动,直接获取连接就可以了,现在这种方式就是使用了Java的SPI扩展机制来实现。
JDBC接口的定义
首先在java中只是定义了接口java.sql.Driver,具体的实现都是由不同厂商来提供的。
在mysql中的实现
在mysql的jar包mysql-connector-java-6.0.6.jar中,可以找到META-INF/services目录,该目录下会有一个名字为java.sql.Driver的文件,文件内容是com.mysql.cj.jdbc.Driver,这里面的内容就是针对Java中定义的接口的实现。
在postgresql中的实现
同样在postgresql的jar包postgresql-42.0.0.jar中,也可以找到同样的配置文件,文件内容是org.postgresql.Driver,这是postgresql对Java的java.sql.Driver的实现。
JDBC的SPI机制
首先来个简单的代码示例:
程序在加载DriverManager类时,会将MySQL的Driver对象注册进DriverManager中,这是SPI思想的一个典型的实现。得益于SPI思想,应用程序中无需指定类似"com.mysql.cj.jdbc.Driver"这种全类名,尽可能地将第三方驱动从应用程序中解耦出来。
源码分析:
DriverManager是管理Jdbc驱动的基础服务类,位于Java.sql包中,由boot类加载器来进行加载。加载该类时,会先执行如下代码块:
上面静态代码块会执行loadInitialDrivers()方法,用于加载各个数据库的驱动。代码如下:
- ServiceLoader.load(Driver.class)此方法会实例化一个ServiceLoader对象,并且注入线程上下文类加载器和Driver.class;
- loadedDrivers.iterator():此方法获得ServiceLoader对象的迭代器;
- driversIterator.hasNext():此方法用于查找Driver类;
- driversIterator.next():在实现的“next()”方法中进行类加载,使用上面的线程上下文类加载器。
ServiceLoader.load(Driver.class);代码及相关调用方法如下:
经过上述过程,使用成员变量private final ClassLoader loader;引用传入的类加载器,使用service接收Driver.class。同时,上述过程中实例化了一个LazyIterator对象,并用成员变量lookupIterator来引用。
执行ServiceLoader的“hasNext()”方法时最终会调用lookupIterator迭代器的“hasNext()”方法(此处暂且省略调用过程),如下:
上述过程通过configs = loader.getResources(fullName)来查找并实现Driver接口的类。
同样,ServiceLoader的迭代器的“next()”方法最终会调用lookupIterator迭代器的“next()”方法,如下:
可以看到,next()会最终调用到nextService()方法,并在此方法中通过c = Class.forName(cn, false, loader);执行类加载。此处的loader也是由ServiceLoader中的loader传入的,即为前文提到的线程上下文类加载器。
经历了上述ServiceLoader类中的一系列操作之后(包括服务发现和类加载),位于mysql驱动包中的Driver类会被初始化。该类如下所示
上述Driver类加载时,会执行静态代码块,即执行DriverManager.registerDriver(new Driver());方法向DriverManager中注册一个Driver实例。
我们再回到DriverManager类中,看看registerDriver方法:
会将该MySQL驱动添加到成员变量registeredDrivers中,该成员变量存放已注册的jdbc驱动列表,如下:
这样一来,服务发现、类加载、驱动注册便到此结束。接下来,应用程序执行数据库连接操作时,会调用“getConnection”方法,遍历registeredDrivers,获取驱动,建立数据库连接。
3.2 SPI机制在Common-Logging中的实现
commons-logging是Apache commons类库中的一员。commons-logging自带了日志实现类,但是功能比较简单,更多的是将其作为门面,底层实现依赖其它框架。commons-logging能够选择使用Log4j还是JDK Logging,但是它并不依赖Log4j或JDK Logging的API。commons-logging会自动检测项目classpath中包含的支持的框架,从而自动选择实现框架。使用commons-logging能否灵活地选择使用哪些日志框架,而且不需要修改源代码。
首先,日志实例是通过LogFactory的getLog(String)方法创建的:
Log接口部分代码:
LogFatory是一个抽象类,它负责加载具体的日志实现,分析其Factory getFactory()方法:
可以看出,抽象类LogFactory加载具体实现的步骤如下:
1.从vm系统属性org.apache.commons.logging.LogFactory
2.使用SPI服务发现机制,发现org.apache.commons.logging.LogFactory的实现
3.查找classpath根目录commons-logging.properties的org.apache.commons.logging.LogFactory属性是否指定factory实现
4.使用默认factory实org.apache.commons.logging.impl.LogFactoryImpl
LogFactory的getLog()方法返回类型是org.apache.commons.logging.Log接口,提供了从trace到fatal方法。可以确定,如果日志实现提供者只要实现该接口,并且使用继承自org.apache.commons.logging.LogFactory的子类创建Log,必然可以构建一个松耦合的日志系统
四:带着问题深入理解SPI机制
- SPI和API的区别是什么
API图解:
SPI图解:
如上面所示:
API依赖的接口位于实现者的包中,概念上更接近于实现方,组织上存在于实现者的包中,实现和接口同时存在在实现者的包中
SPI依赖的接口在调用方的包中,概念上更接近于调用方,组织上位于调用者的包中,实现逻辑在单独的包中,实现可插拔。
- SPI机制的缺陷
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
1.不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
2.获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。(Spring 的BeanFactory,ApplicationContext 就要高级一些了。)
3.多个并发多线程使用 ServiceLoader 类的实例是不安全的。
4.3 手撸一个 ServiceLoader
package edu.jiangxuan.up.service;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Constructor;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
public class MyServiceLoader<S> {
// 对应的接口 Class 模板
private final Class<S> service;
// 对应实现类的 可以有多个,用 List 进行封装
private final List<S> providers = new ArrayList<>();
// 类加载器
private final ClassLoader classLoader;
// 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。
public static <S> MyServiceLoader<S> load(Class<S> service) {
return new MyServiceLoader<>(service);
}
// 构造方法私有化
private MyServiceLoader(Class<S> service) {
this.service = service;
this.classLoader = Thread.currentThread().getContextClassLoader();
doLoad();
}
// 关键方法,加载具体实现类的逻辑
private void doLoad() {
try {
// 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名
Enumeration<URL> urls = classLoader.getResources("META-INF/services/" + service.getName());
// 挨个遍历取到的文件
while (urls.hasMoreElements()) {
// 取出当前的文件
URL url = urls.nextElement();
System.out.println("File = " + url.getPath());
// 建立链接
URLConnection urlConnection = url.openConnection();
urlConnection.setUseCaches(false);
// 获取文件输入流
InputStream inputStream = urlConnection.getInputStream();
// 从文件输入流获取缓存
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
// 从文件内容里面得到实现类的全类名
String className = bufferedReader.readLine();
while (className != null) {
// 通过反射拿到实现类的实例
Class<?> clazz = Class.forName(className, false, classLoader);
// 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例
if (service.isAssignableFrom(clazz)) {
Constructor<? extends S> constructor = (Constructor<? extends S>) clazz.getConstructor();
S instance = constructor.newInstance();
// 把当前构造的实例对象添加到 Provider的列表里面
providers.add(instance);
}
// 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。
className = bufferedReader.readLine();
}
}
} catch (Exception e) {
System.out.println("读取文件异常。。。");
}
}
// 返回spi接口对应的具体实现类列表
public List<S> getProviders() {
return providers;
}
}
主要的流程就是:
- 通过 URL 工具类从 jar 包的 /META-INF/services 目录下面找到对应的文件,
- 读取这个文件的名称找到对应的 spi 接口,
- 通过 InputStream 流将文件里面的具体实现类的全类名读取出来,
- 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象,
- 将构造出来的实例对象添加到 Providers 的列表中。
本文使用 文章同步助手 同步