一、我们为什么讨论SPI?

为具有悠久历史的大型项目(屎山)添加新功能时,我们常常不太好评估变更的影响范围。因为原系统不具备良好的扩展性,导致修改整体发散,且不易单测。此时可以考虑使用接口来描述业务逻辑较为稳定的流程,并使用SPI机制来灵活的隔离加载实际的实现,来达到系统易于扩展的目的。本篇博客的目的是帮助读者了解SPI 机制的原理、应用场景以及如何在实际项目中运用它来提升代码的可扩展性与维护性。

二、SPI 是什么?

详细解释 SPI(Service Provider Interface)即服务提供者接口的概念,强调它是一种面向接口编程的设计模式扩展,用于在运行时动态加载不同的服务实现。其核心思想是解耦服务接口与服务实现,使得系统在不修改原有代码的基础上,能够方便地扩展功能或替换服务实现类。JDK提供了默认的SPI机制,通常需要将配置文件放在META-INF/services/目录下,java.util.ServiceLoader来提供动态加载的能力。

三、SPI的实际应用

  1. JDBC数据库驱动加载

  2. 日志框架扩展

  3. 插件化开发

四、SPI的常见实现

SPI的三要素,如何声明服务接口?如何定义实现与接口映射配置?如何运行时动态加载SPI实现?

  1. SPI接口

  2. 接口与实现映射配置文件

  3. 类加载机制

    1. e.g JVM 使用ServiceLoader类在运行时加载服务实现类。
  4. 基本上所有的方案都是围绕这三要素展开设计的

JDK SPI

JDK基于ServiceLoader,类进行类加载,同时配置文件统一在META-INF/services 文件夹下进行管理

  1. 如何使用JDK SPI ?

声明接口
public interface Repo {
public List<String> query(String keyword);
}
接口实现
public class MysqlRepo implements Repo {
@Override
public List<String> query(String keyword) {
System.out.println("This is Mysql Repo")
}
} public class RedisRepo implements Repo {
@Override
public List<String> query(String keyword) {
System.out.println("This is Redis Repo")
}
}

接下来可以在resources下新建META-INF/services/目录,然后新建接口全限定名的文件:xxxx.xxx.xx.Repo,文件中配置如下内容:

xxxx.xxx.xx.MysqlRepo
加载实现
public class TestCase {
public static void main(String[] args) {
ServiceLoader<Repo> s = ServiceLoader.load(Repo.class);
Iterator<Repo> iterator = s.iterator();
while (iterator.hasNext()) {
Repo repo = iterator.next();
repo.query("abc");
}
}
}

由于配置文件中没有配置RedisRepo的实现,此时控制台只会打印This is Mysql Repo

  1. 类加载原理

ServerLoad是如何通过迭代器加载SPI实现的呢?

首先ServiceLoader.load(Repo.class);方法实际上是返回了一个ServiceLoader类

// 1
public static <S> ServiceLoader<S> load(Class<S> service,
ClassLoader loader)
{
return new ServiceLoader<>(service, loader);
} // 2 上述方法调用了本私有构造器
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();
} // 3 reload是简单的生成了迭代器
public void reload() {
providers.clear();
lookupIterator = new LazyIterator(service, loader);
} // 当我们获得此迭代器,并进行迭代的时候才会进行类的动态加载

可以看到最核心的功能封装在LazyIterator中。Java中,迭代器都需要实现hasNext方法和next方法(按照注释中的数字序号阅读)

// 1. ok 发现这两个方法分别依赖的hasNextService 和nextService;
//
public boolean hasNext() {
if (acc == null) {
return hasNextService();
} else {
// 这段逻辑主要是判断是否要通过权限上下文做权限隔离,感兴趣的可以阅读一下https://www.cnblogs.com/qisi/p/security_manager.html#%E4%B8%80%E4%BA%9B%E6%A6%82%E5%BF%B5
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
} public S next() {
if (acc == null) {
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
} // 2. ok,直接来看hasNextService的逻辑
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
// 寻找项目中所有META-INF/services/${ServiceClass.getName}的配置文件,也就是上文中的xxxx.xxx.xx.Repo
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
// 返回配置文件中多行记录的iterator,Iterator<String>全限定路径
pending = parse(service, configs.nextElement());
}
// 将配置文件中的实现类的全限定路径解析到nextName中
nextName = pending.next();
return true;
} // 3.
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 根据路径加载类对象
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 {
// 实例化类对象,并放在providers中(一个hashMap)
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
}
  1. JDBC是如何使用SPI机制动态的加载数据库驱动的?

我们都知道JDBC是通过getConnection方法获得链接,并执行SQL的,但是连接时是如何确定具体的驱动实现的呢?

 private static Connection getConnection(
... 省略非重要部分... for(DriverInfo aDriver : registeredDrivers) {
// 可以看到是通过registeredDrivers这个变量拿到的,那么这个变量里的驱动是何时被注册的呢?
// If the caller does not have permission to load the driver then
// skip it.
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
} } else {
println(" skipping: " + aDriver.getClass().getName());
} } ... 省略非重要部分...
} }

注册_registeredDrivers_

public class DriverManager {

    // 静态代码块中会调用初始化驱动的方法
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
} private static void loadInitialDrivers() {
String drivers;
// 这里提供了两种加载机制,首先是基于配置的方式加载类,再者是基于SPI的自动加载方式
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// If the driver is packaged as a Service Provider, load it.
// Get all the drivers through the classloader
// exposed as a java.sql.Driver.class service.
// ServiceLoader.load() replaces the sun.misc.Providers() AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
// 这里使用SPI接口取实例化实现了JDBC接口的类(在实现类的静态代码中会调用)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator(); /* Load these drivers, so that they can be instantiated.
* It may be the case that the driver class may not be there
* i.e. there may be a packaged driver with the service class
* as implementation of java.sql.Driver but the actual class
* may be missing. In that case a java.util.ServiceConfigurationError
* will be thrown at runtime by the VM trying to locate
* and load the service.
*
* Adding a try catch block to catch those runtime errors
* if driver not available in classpath but it's
* packaged as service and that service is there in classpath.
*/
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
}); println("DriverManager.initialize: jdbc.drivers = " + drivers); if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
}

通过一个查看一个具体的实现,可以发现该实现在初始化的静态代码中会将自己注册到DriverManager

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
public Driver() throws SQLException {
} static {
try {
DriverManager.registerDriver(new Driver());
} catch (SQLException var1) {
throw new RuntimeException("Can't register driver!");
}
}
}

Spring SPI

Spring也提供了基于SPI的服务加载机制,并在自动装配的功能中广泛的使用。

使用demo

public interface MessageService {
void sendMessage(String message);
} public class EmailMessageService implements MessageService {
@Override
public void sendMessage(String message) {
System.out.println("Sending email with message: " + message);
}
}

约定配置,在resources/META - INF/spring.factories文件(这个文件位置是 Spring SPI 机制规定的)中配置接口和实现类的映射关系。(写过自定义的Spring-Boot-Starter的同学肯定对这个配置非常熟悉,自动装配的类基本都会配置在这个文件中)

com.example.MessageService=com.example.EmailMessageService

//多个实现可以按,分隔。
@SpringBootApplication
public class SpiDemoApplication implements ApplicationContextAware { private ApplicationContext applicationContext; public static void main(String[] args) {
SpringApplication.run(SpiDemoApplication.class, args);
} @Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
ListableBeanFactory beanFactory = applicationContext.getAutowireCapableBeanFactory();
Map<String, MessageService> messageServices = beanFactory.getBeansOfType(MessageService.class);
for (MessageService messageService : messageServices.values()) {
messageService.sendMessage("Hello from SPI!");
// 就可以愉快的加载了
}
}
}

使用SpringSPI和原来使用@Bean或者@Component的方式,再用getBeansOfType去获取有什么区别呢?最大的不同是SPI将关联关系和加载关系声明在了配置中,可以基于环境做不同的配置DIFF,以及在配置中选择性的加载类,将配置与代码本身解耦开。

Spring SPI原理

Todo 待补充

Dubbo SPI

如何使用?

最大的不同是通过key-value的形式配置实例类。接口和实现类的编写就不再赘述(需要在接口类上添加@SPI注解,方便Dubbo做一些增强功能),在配置类上,需要以接口类的全限定名作为文件名,并在其中声明key以及对应的实现类

dog=com.example.spi.impl.DogService
cat=com.example.spi.impl.CatService

public class SpiDemo {
public static void main(String[] args) {
// 通过ExtensionLoader加载AnimalService的SPI实现
ExtensionLoader<AnimalService> loader = ExtensionLoader.getExtensionLoader(AnimalService.class); // 获取名为dog的实现(对应DogService,这里默认实现类名小写作为扩展名,可配置改变)
AnimalService dogService = loader.getExtension("dog");
dogService.makeSound(); // 获取名为cat的实现(对应CatService)
AnimalService catService = loader.getExtension("cat");
catService.makeSound(); // 也可以获取所有扩展实现并遍历调用
loader.getSupportedExtensions().forEach(service -> {
service.makeSound();
});
}
}

getExtension方法中,如果当入参为 “true”时,会直接返回根据接口上 @SPI设置的默认值的实例

@SPI(value = "cat")
public interface AnimalService
配置优先级规则
  1. 在本地缓存中查询有无该入参对应实例

  2. loadExtensionClasses方法中 根据 DUBBO_INTERNAL_DIRECTORY、DUBBO_DIRECTORY,和SERVICES_DIRECTORY ... /META-INF/services 、/META-INF/dubbo 、 ....等等函数,读取配置文件中的key-value对值,并且将其存储在本地Map中;由于使用Map存储,但有多个文件路径,所以存在优先级问题,/META-INF/services为最高优先级,其中的值不会被覆盖。

  3. loadDirectory 方法 是在步骤二中完成的,原理和JDK的类型,通过路径+接口全类名的形式,读取配置文件的同时,返回其实例

  4. 在上述步骤中,会将读取到的所有实例、与实例名,通过key-value的形式本地缓存,extensionLoader.getExtension("red");直接在map中取出。

AOP

Dubbo的AOP机制,通过读取配置文件时,如果读取到了装饰接口方法的装饰类时,则会走到AOP的思路。

  1. 装饰类定义
public class AnimalAOP implements Animal{
private Animal animal;
public CarAOP(Animal animal){
this.animal = animal;
}
@Override
public void run(String word) {
System.out.println("aop加强");
animal.run(word);
System.out.println("aop加强完成");
}
}
  1. 实现接口、并且在定义接口属性的同时,一定要实现一个接口入参为第一个的构造函数。

  2. 添加配置文件属性

dog=com.example.spi.impl.DogService
cat=com.example.spi.impl.CatService
com.example.spi.impl.AnimalAOP

这样ExtensionLoader.getExtension()得到的实例,会自动被AOP加强,那么原理如何呢?

  1. 读取配置文件,读取到包装类时,判断该类是否为装饰类;判断原理,通过是否实现接口以及构造方法的第一个入参是否为接口类型。

  2. 如果是装饰类,则将其缓存到包装类缓存中。

  3. 如果包装类缓存不为空,则说明有对象需要被AOP加强,进入到AOP逻辑中

  4. 解析包装类@Wrapper注解

  5. 通过 instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance)); 循环包装,实例化出装饰类,并且将当前实例入参,覆盖掉原先的实例对象。

  6. 加入到本地缓存,返回实例。

Tips: 不需要返回AOP加强的实例,只需要

extensionLoader.getExtension("cat",false);
IOC

Dubbo的IOC注入,依靠的是Set方法注入。由于没有Spring的IOC容器,所以判断是否存在依赖注入,为判断读取配置文件时,当前类中有无@Adaptive修饰类的set方法。

所以在源码中,通过 injectExtension方法操作:

  1. 当前类是否有Set相关方法,如果没有则说明不涉及IOC

  2. 如果当前类被@DisableInject注释说明,说明不需要过度解析,不涉及IOC

  3. 找到当前类的set方法中第一参数为对象的方法,解析这个方法名,如果方法名为setCarInterface,则返回set后面CarInterface字符串。

  4. 获得这个字符串以及class组合的CarInterface$Adaptive Dubbo自定义对象。

  5. 调用方法,赋值完成IOC注入, CarInterface$Adaptive 则是通过 this.objectFactory.getExtension 解析出来的方法入参。

不难发现,IOC的注入方式在Spring中多用于依赖注入,而Dubbo则规范与Set注入形式。并且通过AdaptiveExtensionFactory和ExtensionFatocry 工厂,在注入的同时还可以兼容Spring容器使用。

Dubbo ExtensionLoader源码解析

  • todo

五、业务流程中的SPI落地应用

  • todo

六、SPI 机制的优缺点

  1. 优点

    1. 高扩展性、解耦性强、灵活性高
  2. 缺点

    1. 运行时加载性能开销:由于 SPI 机制在运行时通过类加载器动态加载服务实现类,可能会带来一定的性能开销,尤其是在大量服务实现类需要加载或者频繁加载的情况下。

    2. 错误处理相对复杂:使用 SPI 机制时,如果配置文件错误或者服务实现类存在问题(如类路径错误、缺少依赖等),可能导致运行时异常,且错误排查和处理相对复杂,需要对 SPI 机制的工作原理有深入理解。

    3. 缺乏编译时检查:由于服务实现类是在运行时加载,在编译阶段无法对服务实现类与接口的一致性进行全面检查,可能会在运行时出现接口不匹配等问题,增加了调试难度。

七、SPI QA

7.1 SPI和API的区别

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

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

  • 概念上更依赖调用方。

  • 组织上位于调用方所在的包中。

  • 实现位于独立的包中。

  • 常见的例子是:插件模式的插件。

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

  • 概念上更接近实现方。

  • 组织上位于实现方所在的包中。

  • 实现和接口在一个包中。


著作权归@pdai所有 原文链接:https://pdai.tech/md/java/advanced/java-advanced-spi.html

可扩展系统——基于SPI扩展的更多相关文章

  1. 设计一款可扩展和基于windows系统的一键处理表格小工具思路

    原创总结/朱季谦 设计一款可扩展和基于windows系统的一键处理表格小工具思路 日常开发当中,业务人员经常会遇到一些重复性整理表格的事情,这时候,就可以通过一些方式进行自动化程序处理,提高工作(摸鱼 ...

  2. dubbo源码分析之基于SPI的强大扩展

    https://blog.csdn.net/luoyang_java/article/details/86609045 Dubbo采用微内核+插件体系,使得设计优雅,扩展性强.那所谓的微内核+插件体系 ...

  3. 高扩展的基于NIO的服务器架构

    当你考虑写一个扩展性良好的基于Java的服务器时,相信你会毫不犹豫地使用Java的NIO包.为了确保你的服务器能够健壮.稳定地运行,你可能会花大量的时间阅读博客和教程来了解线程同步的NIO selec ...

  4. 理解 Dubbo SPI 扩展机制

    写在前面 最近接触了 gRPC 体会到虽然众多 RPC 框架各有各的特点但是他们提供的特性和功能有很多的相似之处 , 这就说明他们面对同样的分布式系统带来的问题.从 2016 年左右开始接触到 dub ...

  5. [转] 理解 Dubbo SPI 扩展机制

    写在前面 最近接触了 gRPC 体会到虽然众多 RPC 框架各有各的特点但是他们提供的特性和功能有很多的相似之处 , 这就说明他们面对同样的分布式系统带来的问题.从 2016 年左右开始接触到 dub ...

  6. 一个基于chrome扩展的自动答题器

    1.写在前面 首先感谢小茗同学的文章-[干货]Chrome插件(扩展)开发全攻略, 基于这篇入门教程和demo,我才能写出这款 基于chrome扩展的自动答题器. git地址: https://git ...

  7. Dubbo系列之 (一)SPI扩展

    一.基础铺垫 1.@SPI .@Activate. @Adaptive a.对于 @SPI,Dubbo默认的特性扩展接口,都必须打上这个@SPI,标识这是个Dubbo扩展点.如果自己需要新增dubbo ...

  8. Dubbo源码剖析六之SPI扩展点的实现之getExtensionLoader

    Dubbo SPI机制之三Adaptive自适应功能 - 池塘里洗澡的鸭子 - 博客园 (cnblogs.com)中,示例案例中自定义了扩展接口而不是使用Dubbo已提供的扩展接口.在案例中,主程序分 ...

  9. Solon2 开发之插件,二、插件扩展机制(Spi)

    插件扩展机制,是基于 "插件" + "配置申明" 实现的解耦的扩展机制(类似 Spring Factories.Java Spi):简单.弹性.自由.它的核心作 ...

  10. PHP扩展编写、PHP扩展调试、VLD源码分析、基于嵌入式Embed SAPI实现opcode查看

    catalogue . 编译PHP源码 . 扩展结构.优缺点 . 使用PHP原生扩展框架wizard ext_skel编写扩展 . 编译安装VLD . Debug调试VLD . VLD源码分析 . 嵌 ...

随机推荐

  1. Linux+Nginx+Php+MariaDB+Redis部署

    目录 工作机制 系统环境描述 部署Nginx 安装 启动 测试并访问 部署PHP 安装 启动 配置Nginx 测试 部署MariaDB 安装 启动 配置php支持 测试 部署Redis 安装 启动 配 ...

  2. 网页设计中常用的Web英文安全字体

    原文地址:https://www.openkee.com/post-176.html 在 Web 编码中,CSS 默认应用的 Web 字体是有限的,你能看到的字体别人未必看得到.虽然在新版本的CSS3 ...

  3. myBatis插入操作获取不到返回的自增id问题

    myBatis插入操作后想返回自增 id 有多种方式 其中一种使用率较高的就是: 在<insert></insert> 标签中添加 useGeneratedKeys 和 key ...

  4. 关于如何更改Cuda的版本的一些事情

    1. 网上说的很全面了,这里我把我遇到的一些问题和解决方案罗列出来,以便未来的学习和了解. 博客的好处就体现出来了,下次你再用这个东西,就直接打开你的博客照抄就行了,不用东搜西搜了,及其方便,这种碎片 ...

  5. 初识GO语言--基本数据类型

  6. 循环中拼接String不同方法性能耗时对比

    对比背景 Java中最常用的拼接字符串方法就是 + 或 +=,使用上简单方便.但如果拼接数量比较大,例如在循环中拼接字符串,可能会有性能问题: 测试数据 循环100000次进行String拼接,对比+ ...

  7. OSG开发笔记(三十一):OSG中LOD层次细节模型介绍和使用

    前言   模型较大的时候,出现卡顿,那么使用LOD(细节层次)进行层次细节调整,可以让原本卡顿的模型变得不卡顿.  本就是LOD介绍.   Demo      LOD 概述   LOD也称为层次细节模 ...

  8. 海外模组联网非常难?不往忘了APN配置…

    ​ 除了中国之外,国外的4G信号都比较差劲. 做海外的设备,如果忽视了射频的信号质量,肯定是要吃大亏的! 所以,海外模组的联网问题,会比国内要多不少. 客户在实际应用中或多或少都会遇到: 网络相关问题 ...

  9. 域渗透之利用WMI来横向渗透

    目录 前言 wmi介绍 wmiexec和psexec的区别 wmic命令执行 wmiexec.vbs wmiexec.py Invoke-WmiCommand.ps1 前言 上一篇打红日靶场拿域控是用 ...

  10. python 递归比较两个文件夹

    以下 import filecmp, os def compare_folders(folder1, folder2): dcmp = filecmp.dircmp(folder1, folder2) ...