可扩展系统——基于SPI扩展

news/2024/12/21 14:16:29/文章来源:https://www.cnblogs.com/xy1997/p/18620727

一、我们为什么讨论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 {@Overridepublic List<String> query(String keyword) {System.out.println("This is Mysql Repo")}  
}public class RedisRepo  implements Repo {@Overridepublic 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%B5PrivilegedAction<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.RepoString fullName = PREFIX + service.getName();if (loader == null)configs = ClassLoader.getSystemResources(fullName);elseconfigs = 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 {@Overridepublic 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);}@Overridepublic 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;}@Overridepublic 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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/856350.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

大模型--采样技术 TopK TopP 惩罚系数--37

目录1. 参考2. 概述重复惩罚(Repetition Penalty) 1. 参考 https://mp.weixin.qq.com/s/mBZA6PaMotJw7WeVdA359g 2. 概述 大型语言模型(LLMs)通过“根据上下文预测下一个 token 的概率分布”来生成文本。最简单的采样方法是贪心采样(Greedy Sampling),它在每一步选择概率…

关于分布式锁的的思考

关于分布式锁的的思考 结论先行: 对于分布式锁我们在考虑不同方案的时候需要先思考需要的效果是什么?为了效率(efficiency),协调各个客户端避免做重复的工作。即使锁偶尔失效了,只是可能把某些操作多做一遍而已,不会产生其它的不良后果。比如重复发送了一封同样的 email(…

2024-12-21:从魔法师身上吸取的最大能量。用go语言,在一个神秘的地牢里,有 n 名魔法师排成一列。每位魔法师都有一个能量属性,有的提供正能量,而有的则会消耗你的能量。 你被施加了一种诅咒,吸

2024-12-21:从魔法师身上吸取的最大能量。用go语言,在一个神秘的地牢里,有 n 名魔法师排成一列。每位魔法师都有一个能量属性,有的提供正能量,而有的则会消耗你的能量。 你被施加了一种诅咒,吸收来自第 i 位魔法师的能量后,你会立即被传送到第 (i + k) 位魔法师。在这个…

平替兼容MFRC523|国产13.56MHz智能门锁NFC读卡芯片KYN523

NFC是一种非接触式识别和互联技术,可以在移动设备、消费类电子产品等设备间进行近距离无线通信。通过 NFC 可实现数据传输、移动支付等功能。 KYN523是一款高度集成的工作在 13.56MHZ 下的非接触读写器芯片,支持符合ISO/IEC 14443 TypeA、ISO/IEC 14443 TypeB 协议的非接触读…

redis-cli (error) NOAUTH Authentication required问题解决

1.查找redis-cli所在目录 which redis-cli2.切换到redis-cli目录3.切换到usr/bin 目录 执行以下命令redis-cli -h ip -p port 4. 验证redis登录密码 auth password5.获取redis数据

快速幂优化高精度乘法

NOI 1.6 12 题目描述题目给出的 \(n\) 最大可以取到 \(100\) ,即计算 \(2^{100}\) ,明显是超过 long long 的 \(2^{63}-1\),所以需要使用高精度来计算幂次方的乘法简单的高精度,即每次计算一个小整数乘上一个大整数循环 \(n\) 次,每次对上一次的积都乘以 \(2\) vector<…

Docker网络基础知识

Docker 网络是 Docker 容器之间以及容器与主机或其他网络资源之间通信的基础。Docker网络基础1.默认网络当你启动一个容器是,如果没有特别指定网络,它会自动连接到Docker的默认桥接网络(bridge network)。 这个默认的桥接网络通常被称为bridge,它允许容器之间通过IP地址相…

川土微代理商深圳|CA-IS3740,CA-IS3741,CA-IS3742高性能四通道数字隔离芯片

CA-IS3740,CA-IS3741,CA-IS3742产品特性 •信号传输速率:DCto150Mbps •宽电源电压范围:2.5Vto5.5V •宽温度范围:‐40Cto125C •无需启动初始化 •默认输出高电平和低电平选项 •优异的电磁抗扰度 •高CMTI:150kV/s(典型值) •低功耗,(典型值): ▪电流为1.5mA/通道(@5…

大学8086汇编debug——关于int3断点之后继续调试的方法

预先 在汇编中打入int 3,然后在debug中利用G,就可以一路运行到断点处。 正文 在断点上可以用U来查看上下代码的位置断点后运行 然后用-g=xxxx:xxxx可以运行到下一个断点,或是直接运行至结束 还可以用-t=xxxx:xxxx逐步运行 注意:xxxx:xxxx是int 3下一个命令的地址

sunny替换响应体

本文来自博客园,作者:__username,转载请注明原文链接:https://www.cnblogs.com/code3/p/18620658

AuthBy pg walkthrough Intermediate window

nmap └─# nmap -p- -A -sS 192.168.226.46 Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-21 01:01 UTC Stats: 0:01:06 elapsed; 0 hosts completed (1 up), 1 undergoing SYN Stealth Scan SYN Stealth Scan Timing: About 52.12% done; ETC: 01:04 (0:01:00 rem…