深入浅出SPI机制
一、SPI概念
SPI 全称为 (Service Provider Interface) ,是JDK内置的一种服务提供发现机制。 目前有不少框架用它来做服务的扩展发现, 简单来说,它就是一种动态替换发现的机制, 举个例子来说, 有个接口,想运行时动态的给它添加实现,你只需要添加一个实现,而后,把新加的实现,描述给JDK知道就行啦(通过改一个文本文件即可)我们经常遇到的就是java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,mysql和postgresql都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现
二、SPI与API对比
Java中API和SPI是不同的概念,API直接被应用开发人员使用,SPI被框架扩展人员使用
API Application Programming Interface
大多数情况下,都是实现方来制定接口并完成对接口的不同实现,调用方仅仅依赖却无权选择不同实现。
SPI Service Provider Interface
而如果是调用方来制定接口,实现方来针对接口来实现不同的实现。调用方来选择自己需要的实现方。
在面向接口编程中,接口位于哪个包中,我们又三种选择:
- 接口位于实现方所在的包中
- 接口位于调用方所在的包中
- 接口位于独立的包中
1.接口位于调用方所在的包中
对于类似这种情况下接口,我们将其称为 SPI, SPI的规则如下:
- 概念上更依赖调用方。
- 组织上位于调用方所在的包中。
- 实现位于独立的包中。
常见的例子是:插件模式的插件。如:
- 数据库驱动 Driver
- 日志 Log
- dubbo扩展点开发
2.接口位于实现方所在的包中
对于类似这种情况下的接口,我们将其称作为API,API的规则如下:
- 概念上更接近实现方。
- 组织上位于实现方所在的包中。
3.接口位于独立的包中
如果一个“接口”在一个上下文是API,在另一个上下文是SPI,那么你就可以这么组织
三、SPI机制与策略模式的区别
- 从设计思想来看:SPI机制和策略模式思想是类似的,它们遵循开闭原则,对扩展开放,对修改关闭,都是通过一定的设计隔离扩展与修改。
- 从隔离级别来看:策略模式的隔离一般是类级别的隔离,而SPI机制一般是项目级别的隔离。
- 从使用场景来看:SPI机制一般在框架设计时使用,策略模式则一般在业务代码中使用。
四、Java SPI
在jdk6里面引进的一个新的特性ServiceLoader,从官方的文档来说,它主要是用来装载一系列的service provider。而且ServiceLoader可以通过service provider的配置文件来装载指定的service provider。当服务的提供者,提供了服务接口的一种实现之后,我们只需要在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。
五、SPI 机制的约定
- 在 META-INF/services/ 目录中创建以接口全限定名命名的文件,该文件内容为API具体实现类的全限定名
- 使用 ServiceLoader 类动态加载 META-INF 中的实现类
- 如 SPI 的实现类为 Jar 则需要放在主程序 ClassPath 中
- API 具体实现类必须有一个不带参数的构造方法
注意:配置文件编码采用UTF-8避免出错
六、jdk SPI案例
我们现在需要使用一个内容搜索接口,搜索的实现可能是基于文件系统的搜索,也可能是基于数据库的搜索。
先定义好接口
package com.cainiao.ys.spi.learn;
import java.util.List;
public interface Search {
public List<String> searchDoc(String keyword);
}
文件搜索实现
package com.cainiao.ys.spi.learn;
import java.util.List;
public class FileSearch implements Search{
@Override
public List<String> searchDoc(String keyword) {
System.out.println("文件搜索 "+keyword);
return null;
}
}
数据库搜索实现
package com.cainiao.ys.spi.learn;
import java.util.List;
public class DatabaseSearch implements Search{
@Override
public List<String> searchDoc(String keyword) {
System.out.println("数据搜索 "+keyword);
return null;
}
}
接下来可以在resources下新建META-INF/services/目录,然后新建接口全限定名的文件:com.cainiao.ys.spi.learn.Search,里面加上我们需要用到的实现类
com.cainiao.ys.spi.learn.FileSearch
然后写一个测试方法
package com.cainiao.ys.spi.learn;
import java.util.Iterator;
import java.util.ServiceLoader;
public class TestCase {
public static void main(String[] args) {
ServiceLoader<Search> s = ServiceLoader.load(Search.class);
Iterator<Search> iterator = s.iterator();
while (iterator.hasNext()) {
Search search = iterator.next();
search.searchDoc("hello world");
}
}
}
可以看到输出结果:文件搜索 hello world
如果在com.cainiao.ys.spi.learn.Search文件里写上两个实现类,那最后的输出结果就是两行了。
这就是因为ServiceLoader.load(Search.class)在加载某接口时,会去META-INF/services下找接口的全限定名文件,再根据里面的内容加载相应的实现类。
这就是spi的思想,接口的实现由provider实现,provider只用在提交的jar包里的META-INF/services下根据平台定义的接口新建文件,并添加进相应的实现类内容就好。
那为什么配置文件为什么要放在META-INF/services下面?
可以打开ServiceLoader的代码,里面定义了文件的PREFIX如下:
private static final String PREFIX = "META-INF/services/"
七、SPI的用途
数据库DriverManager、Spring、ConfigurableBeanFactory、Dubbo等都用到了SPI机制
八、Spring SPI
Spring中使用的类是SpringFactoriesLoader,在org.springframework.core.io.support包中
- SpringFactoriesLoader 会扫描 classpath 中的 META-INF/spring.factories文件。
- SpringFactoriesLoader 会加载并实例化 META-INF/spring.factories 中的制定类型
- META-INF/spring.factories 内容必须是 properties 的Key-Value形式,多值以逗号隔开。
8.1 使用
和 SPI 不同,由于 SpringFactoriesLoader 中的配置文件格式是 properties 文件,因此,不需要要像 SPI 中那样为每个服务都创建一个文件, 而是选择直接把所有服务都扔到 META-INF/spring.factories 文件中。
com.fsx.serviceloader.IService=com.fsx.serviceloader.HDFSService,com.fsx.serviceloader.LocalService
// 若有非常多个需要换行 可以这么写
// 前面是否顶头没关系(Spring在4.x版本修复了这个bug)
com.fsx.serviceloader.IService=\
com.fsx.serviceloader.HDFSService,\
com.fsx.serviceloader.LocalService
public static void main(String[] args) throws IOException {
List<IService> services = SpringFactoriesLoader.loadFactories(IService.class, Main.class.getClassLoader());
List<String> list = SpringFactoriesLoader.loadFactoryNames(IService.class, Main.class.getClassLoader());
System.out.println(list); //[com.fsx.serviceloader.HDFSService, com.fsx.serviceloader.LocalService]
System.out.println(services); //[com.fsx.serviceloader.HDFSService@794cb805, com.fsx.serviceloader.LocalService@4b5a5ed1]
}
使用细节:
- spring.factories内容的key不只能是接口,也可以是抽象类、具体的类。但是有个原则:=后面必须是key的实现类(子类)
- key还可以是注解,比如SpringBoot中的的key:org.springframework.boot.autoconfigure.EnableAutoConfiguration,它就是一个注解
- 文件的格式需要保证正确,否则会返回[](不会报错)
=右边必须不是抽象类,必须能够实例化。且有空的构造函数~ - loadFactories依赖方法loadFactoryNames。loadFactoryNames方法只拿全类名,loadFactories拿到全类名后会立马实例化
- 此处特别注意:loadFactories实例化完成所有实例后,会调用AnnotationAwareOrderComparator.sort(result)排序,所以它是支持Ordered接口排序的,这个特点特别的重要。
8.2 源码剖析
因为Spring的这个配置文件和上面的不一样,它的名字是固定的spring.factories,里面的内容是key-value形式,因此一个文件里可以定义N多个键值对。它比JDK的SPI是更加灵活些的。
它主要暴露了两个方法:loadFactories和loadFactoryNames
// @since 3.2
public final class SpringFactoriesLoader {
...
public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";
...
// 核心方法如下:
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
MultiValueMap<String, String> result = cache.get(classLoader);
if (result != null) {
return result;
}
try {
// 读取到资源文件,遍历
Enumeration<URL> urls = (classLoader != null ? classLoader.getResources(FACTORIES_RESOURCE_LOCATION) : ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
result = new LinkedMultiValueMap<>();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
// 此处使用的是URLResource把这个资源读进来~~~
UrlResource resource = new UrlResource(url);
// 可以看到,最终它使用的还是PropertiesLoaderUtils,只能使键值对的形式哦~~~ 当然xml也是被支持的
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
for (Map.Entry<?, ?> entry : properties.entrySet()) {
String factoryClassName = ((String) entry.getKey()).trim();
// 使用逗号,分隔成数组,遍历 名称就出来了~~~
for (String factoryName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
result.add(factoryClassName, factoryName.trim());
}
}
}
cache.put(classLoader, result);
return result;
} catch (IOException ex) {
throw new IllegalArgumentException("Unable to load factories from location [" +
FACTORIES_RESOURCE_LOCATION + "]", ex);
}
}
}
九、Dubbo SPI
我们首先通过 ExtensionLoader 的 getExtensionLoader 方法获取一个 ExtensionLoader 实例,然后再通过 ExtensionLoader 的 getExtension 方法获取拓展类对象。这其中,getExtensionLoader 方法用于从缓存中获取与拓展类对应的 ExtensionLoader,若缓存未命中,则创建一个新的实例。该方法的逻辑比较简单,本章就不进行分析了。下面我们从 ExtensionLoader 的 getExtension 方法作为入口,对拓展类对象的获取过程进行详细的分析。
- 对Dubbo进行扩展,不需要改动Dubbo的源码
- 自定义的Dubbo的扩展点实现,是一个普通的Java类,Dubbo没有引入任何Dubbo特有的元素,对代码侵入性几乎为零。
- 将扩展注册到Dubbo中,只需要在ClassPath中添加配置文件。使用简单。而且不会对现有代码造成影响。符合开闭原则。
- Dubbo的扩展机制设计默认值:@SPI(“dubbo”) 代表默认的spi对象
- Dubbo的扩展机制支持IoC,AoP等高级功能
- Dubbo的扩展机制能很好的支持第三方IoC容器,默认支持Spring Bean,可自己扩展来支持其他容器,比如Google的Guice。
- 切换扩展点的实现,只需要在配置文件中修改具体的实现,不需要改代码。使用方便。