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

一、我们为什么讨论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

posted @ 2024-12-21 14:11  XinStar  阅读(18)  评论(0编辑  收藏  举报