如何在项目中引入SPI

2022-04-22 09:53:09 浏览数 (1)

开闭原则是面向对象程序设计的终极目标,它使软件实体拥有一定的适应性和灵活性的同时具备稳定性和延续性。当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。SPI 就是开闭原则的一种实现。本文将带领同学们了解 SPI ,对比 Dubbo SPI 与 Java SPI ,同时用一个 Dubbo SPI 替换 Java SPI 的实践项目,来演示如何将 SPI 机制引入日常项目中。

什么是 SPI

SPI 全称是 Service Provider Interface,是一种将服务接口与服务实现分离以达到解耦、可以提升程序可扩展性的机制。引入服务提供者就是引入了 SPI 接口的实现者,通过本地的注册发现获取到具体的实现类,可以在运行时,动态为接口替换实现类,实现服务的热插拔。

Java SPI

Java SPI 中有四个重要的组件:

  1. 服务接口:一个定义了服务提供者实现类契约方法的接口或者抽象类。
  2. 服务实现:实际提供服务的实现类。
  3. SPI 配置文件:文件名必须存在于 META-INF/services 目录中。文件名应与服务提供商接口完全限定名完全相同。文件中的每一行都有一个实现服务类详细信息,即服务提供者类的完全限定名。
  4. ServiceLoader: Java SPI 关键类,用于加载服务提供者接口的服务。ServiceLoader 中有各种实用程序方法,用于获取特定的实现、迭代它们或再次重新加载服务。

小试牛刀

服务接口

现有一个压缩与解压服务接口,有一个压缩方法 compress ,一个解压方法 decompress,入参出参都是字节数组:

代码语言:java复制
package cn.ppphuang.demoserver.serviceproviders;

public interface Compresser {
    byte[] compress(byte[] bytes);
    byte[] decompress(byte[] bytes);
}
服务实现

有两个实现类,假设一个使用Gzip算法来实现:

代码语言:java复制
package cn.ppphuang.demoserver.serviceproviders;

import java.nio.charset.StandardCharsets;

public class GzipCompresser implements Compresser{
    @Override
    public byte[] compress(byte[] bytes) {
        return "compress by Gzip".getBytes(StandardCharsets.UTF_8);
    }
    @Override
    public byte[] decompress(byte[] bytes) {
        return "decompress by Gzip".getBytes(StandardCharsets.UTF_8);
    }
}

另一个使用Zip算法来实现:

代码语言:java复制
package cn.ppphuang.demoserver.serviceproviders;

import java.nio.charset.StandardCharsets;

public class ZipCompresser implements Compresser {
    @Override
    public byte[] compress(byte[] bytes) {
        return "compress by Zip".getBytes(StandardCharsets.UTF_8);
    }
    @Override
    public byte[] decompress(byte[] bytes) {
        return "decompress by Zip".getBytes(StandardCharsets.UTF_8);
    }
}
SPI 配置文件

然后在项目 META-INF/services 文件夹(如果 resources 目录下没有,创建该目录)下创建 cn.ppphuang.demoserver.serviceproviders.Compresser 文件,文件名是压缩服务接口类的全限定类名,两行内容分别是刚刚两个接口实现类的全限定类名,如下:

代码语言:java复制
cn.ppphuang.demoserver.serviceproviders.GzipCompresser
cn.ppphuang.demoserver.serviceproviders.ZipCompresser
通过 ServiceLoader 加载服务

main 方法中通过 ServiceLoader.load(Compresser.class) 获取该服务的所有实现类,遍历实例调用方法。

代码语言:java复制
public static void main(String[] args) {
  ServiceLoader<Compresser> serviceLoader = ServiceLoader.load(Compresser.class);
  for (Compresser service : serviceLoader) {
    System.out.println(service.getClass().getClassLoader());
    byte[] compress = service.compress("Hello".getBytes(StandardCharsets.UTF_8));
    System.out.println(new String(compress));
    byte[] decompress = service.decompress("Hello".getBytes(StandardCharsets.UTF_8));
    System.out.println(new String(decompress));
  }
}
输出结果
代码语言:java复制
sun.misc.Launcher$AppClassLoader@18b4aac2
compress by Gzip
decompress by Gzip
sun.misc.Launcher$AppClassLoader@18b4aac2
compress by Zip
decompress by Zip

由输出结果可以看到,不需要我们自己去实例化两个实现类,就可以直接调用。加载实现类的类加载器是 AppClassLoader

如果你还有这个接口的其他实现,你可以在别的包里实现这个接口,然后在实现类所在包的 META-INF/services 文件夹下创建 cn.ppphuang.demoserver.serviceproviders.Compresser 文件,文件内容为你的实现类的全限定类名。ServiceLoader 会去寻找所有包下的 META-INF/services/cn.ppphuang.demoserver.serviceproviders.Compresser 文件,加载并实例化文件内容中每一行的实现类。

使用场景

SPI 机制使用的非常广泛,我们以 JDBC 为例看 SPI 如何使用。

JDBC 使用 SPI 加载不同类型数据库的驱动,下面是我们常用的使用 JDBC 操作 MySql 数据库的示例代码,没有显式指定使用哪种数据库驱动,依然可以正常使用。

代码语言:java复制
public static void main(String[] args) throws SQLException, ClassNotFoundException {
  	Connection conn = null;
    try {
      conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test", "root", "root");
    } catch (SQLException e) {
      System.out.println("数据库连接失败");
    }
    Statement statement = conn.createStatement();
    ResultSet resultSet = statement.executeQuery("select * from user where id = 1");
    while (resultSet.next()) {
      System.out.println(resultSet.getString(2));
    }
}

来看 DriverManager 类的代码,静态代码块中调用 loadInitialDrivers 方法加载数据库驱动,方法里使用 ServiceLoader.load(Driver.class) 加载驱动类。

代码语言:java复制
public class DriverManager {
	static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
  }
  private static void loadInitialDrivers() {
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
      public Void run() {
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        Iterator<Driver> driversIterator = loadedDrivers.iterator();
        while(driversIterator.hasNext()) {
          driversIterator.next();
        }
        return null;
      }
    });
 	}
}

我们打开 mysql-connector-java 包的 META-INF/services 文件夹,果然有 java.sql.Driver 类的 SPI 配置文件,文件内容的第一行就是 MySQL 的连接驱动类的全限定类名。

image-20220420173608336.pngimage-20220420173608336.png

再看 com.mysql.jdbc.Driver 驱动类,静态方法中实例化了自己,并将自己注册到 DriverManager 中。

以上就是为什么我们不指定驱动类还可以正常使用的原因。

代码语言:java复制
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!");
        }
    }
}

我们总结一下 JDBC 自适应驱动的流程:

  1. 实现了 java.sql.Driver 的驱动包,按照 SPI 的约定,在 META-INF/services/java.sql.Driver 文件中指定具体的驱动类。
  2. DriverManager 利用 ServiceLoader 去扫描各个 jar 包下的 META-INF/services/java.sql.Driver 文件,加载并初始化文件内容中指定的驱动实现类。
  3. 初始化具体的实现类,就会自动向 DriverManager 注册当前实现类到 DriverManager 中的 registeredDrivers。
  4. 使用 DriverManager.getConnection 连接数据库时,getConnection 中会循环 registeredDrivers 尝试校验并连接数据库

美中不足

使用 Java SPI 能方便得解耦模块,使得接口的定义与具体业务实现分离。应用程序可以根据实际业务情况启用或替换具体组件。

但是也有一些缺点:

  • 不能按需加载。虽然 ServiceLoader 做了延迟载入,但是基本只能通过遍历全部获取,也就是接口的实现类得全部载入并实例化。如果你并不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
  • 获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。
  • 多个并发多线程使用 ServiceLoader 类的实例是不安全的。

Dubbo SPI

为了使用更加完美的 SPI 机制,优化 Java SPI 的弊端,很多厂商自己实现了一套 SPI 机制,比如 Dubbo 。

Dubbo SPI 扩展能力的特性:

  • 按需加载。Dubbo 的扩展能力不会一次性实例化所有实现,而是用哪个扩展类则实例化哪个扩展类,减少资源浪费。
  • 增加扩展类的 IOC 能力。Dubbo 的扩展能力并不仅仅只是发现扩展服务实现类,而是在此基础上更进一步,如果该扩展类的属性依赖其他对象,则 Dubbo 会自动的完成该依赖对象的注入功能。
  • 增加扩展类的 AOP 能力。Dubbo 扩展能力会自动的发现扩展类的包装类,完成包装类的构造,增强扩展类的功能。
  • 具备动态选择扩展实现的能力。Dubbo 扩展会基于参数,在运行时动态选择对应的扩展类,提高了 Dubbo 的扩展能力。
  • 可以对扩展实现进行排序。能够基于用户需求,指定扩展实现的执行顺序。
  • 提供扩展点的 Adaptive 能力。该能力可以使的一些扩展类在 consumer 端生效,一些扩展类在 provider 端生效。

Dubbo SPI 加载扩展的工作流程:

extension-load.pngextension-load.png

主要步骤为 4 个:

  • 读取并解析配置文件。
  • 缓存所有扩展实现。
  • 基于用户执行的扩展名,实例化对应的扩展实现。

案例解析

在dubbo中,下面这种获取扩展类的方法很常见,通过指定接口类 ThreadPool.class 的实现类的别名 eager 即可获取到对应的实现类。

代码语言:java复制
ThreadPool threadPool = ExtensionLoader.getExtensionLoader(ThreadPool.class).getExtension("eager")
//EagerThreadPool

相应的配置文件在 META-INF/dubbo/internal/com.alibaba.dubbo.common.threadpool.ThreadPool ,内容如下:

代码语言:java复制
fixed=com.alibaba.dubbo.common.threadpool.support.fixed.FixedThreadPool
cached=com.alibaba.dubbo.common.threadpool.support.cached.CachedThreadPool
limited=com.alibaba.dubbo.common.threadpool.support.limited.LimitedThreadPool
eager=com.alibaba.dubbo.common.threadpool.support.eager.EagerThreadPool

来看与 Java SPI 配置文件的区别,配置文件都在 META-INF 文件夹下,但是具体路径不同; Java SPI 配置文件的每一行只是实现类的全限定类名,Dubbo SPI 配置文件里全限定名前都有一个别名,可以通过别名获取到该实现类。

来看 ThreadPool 接口,该接口有一个 @SPI("fixed") 注解,注解的 value 是 fixed 。表明该接口的默认实现是别名为 fixed 的实现类,即 FixedThreadPool

代码语言:java复制
@SPI("fixed")
public interface ThreadPool {
    @Adaptive({Constants.THREADPOOL_KEY})
    Executor getExecutor(URL url);
}

@SPI 注解:

代码语言:java复制
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface SPI {
    /**
     * default extension name
     */
    String value() default "";
}

那么可以这样获取该类的默认实现类:

代码语言:java复制
ThreadPool defaultExtension = ExtensionLoader.getExtensionLoader(ThreadPool.class).getDefaultExtension();
//FixedThreadPool

源码分析

Dubbo SPI 的关键类是 ExtensionLoader 。先看类的几个重要静态属性,看完就能知道为什么上例中的配置文件为什么在 META-INF/dubbo/internal/ 中了。还有几个 ConcurrentHashMap 用于缓存数据,后续的方法中都会用到。

代码语言:java复制
public class ExtensionLoader<T> {
    private static final String DUBBO_DIRECTORY = "META-INF/dubbo/";

    private static final String DUBBO_INTERNAL_DIRECTORY = DUBBO_DIRECTORY   "internal/";
  
		private static final ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS = new ConcurrentHashMap<Class<?>, ExtensionLoader<?>>();

    private static final ConcurrentMap<Class<?>, Object> EXTENSION_INSTANCES = new ConcurrentHashMap<Class<?>, Object>();
  
    private final Holder<Map<String, Class<?>>> cachedClasses = new Holder<Map<String, Class<?>>>();
  
    private final ConcurrentMap<String, Holder<Object>> cachedInstances = new ConcurrentHashMap<String, Holder<Object>>();
}

通过 getExtensionLoader 方法获取某个接口类型的 ExtensionLoader 对象时,会判断是否是 interface ,也会通过 withExtensionAnnotation 方法判断该接口是否有 @SPI 注解,没有的话会抛出异常,表明该接口不是一个可以扩展的接口。然后从 EXTENSION_LOADERS 中获取实例,没有就实例化一个,然后返回。

代码语言:java复制
public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
    if (type == null)
      throw new IllegalArgumentException("Extension type == null");
    if (!type.isInterface()) {
      throw new IllegalArgumentException("Extension type("   type   ") is not interface!");
    }
    if (!withExtensionAnnotation(type)) {
      throw new IllegalArgumentException("Extension type("   type  
                                         ") is not extension, because WITHOUT @"   SPI.class.getSimpleName()   " Annotation!");
    }

    ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    if (loader == null) {
      EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type));
      loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    }
    return loader;
}
代码语言:java复制
private static <T> boolean withExtensionAnnotation(Class<T> type) {
  	return type.isAnnotationPresent(SPI.class);
}

有了 ExtensionLoader 实例就可以调用 getExtension 方法指定实现类的别名,来获取该实现类的实例。

如果 "true".equals(name) 就返回该接口通过 @SPI 注解指定的默认实现类。

判断 cachedInstances 中是否有该实现类的缓存数据,返回值是 Holder 对象,这个对象可以看作为一个数据承载对象,通过 holder.get() 可以获取到对象里承载的数据,这里就是接口实现类的实例化对象。如果 cachedInstances 中获取不到 Holder 对象,就会调用 createExtension 方法获取接口的具体实现类对象,放入承载对象中,然后就可以返回实现类的实例。(可以看到这里使用了常用的 double check方法)

代码语言:java复制
public T getExtension(String name) {
        if ("true".equals(name)) {
            return getDefaultExtension();
        }
        Holder<Object> holder = cachedInstances.get(name);
        if (holder == null) {
            cachedInstances.putIfAbsent(name, new Holder<Object>());
            holder = cachedInstances.get(name);
        }
        Object instance = holder.get();
        if (instance == null) {
            synchronized (holder) {
                instance = holder.get();
                if (instance == null) {
                    instance = createExtension(name);
                    holder.set(instance);
                }
            }
        }
        return (T) instance;
    }

createExtension 方法中通过 getExtensionClasses().get(name) 方法获取到别名为 name 的接口实现类 Class,然后通过 clazz.newInstance() 实例化返回。

代码语言:java复制
private T createExtension(String name) {
    Class<?> clazz = getExtensionClasses().get(name);
    try {
        T instance = (T) EXTENSION_INSTANCES.get(clazz);
        if (instance == null) {
            EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance());
            instance = (T) EXTENSION_INSTANCES.get(clazz);
        }
        return instance;
    } catch (Throwable t) {
      	throw new IllegalStateException("Extension instance(name: "   name   ", class: "  
                                      type   ")  could not be instantiated: "   t.getMessage(), t);
    }
}

那么 getExtensionClasses().get(name) 方法如何获取到别名指定的类呢?我们继续追代码会发现这样的调用链:

getExtensionClasses() -> loadExtensionClasses() -> loadDirectory() -> loadResource() -> loadClass() 。

鉴于篇幅,笔者不一一贴出代码,只拿重要的节点来描述:

在这整个调用链中会维护一个 Map<String, Class<?>> extensionClasses,key 为实现类的别名, value 为该实现类。 getExtensionClasses().get(name) 就是从这个 map 中获取 name 别名的实现类。

loadDirectory() 会找到所有包中 META-INF/dubbo/internal/ 路径下指定接口类名的文件。在上例中就是 META-INF/dubbo/internal/com.alibaba.dubbo.common.threadpool.ThreadPool

loadResource() 会解析每一个文件的内容:

代码语言:java复制
private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader, java.net.URL resourceURL) {
  ...
    while ((line = reader.readLine()) != null) {
     	int i = line.indexOf('=');
      if (i > 0) {
        name = line.substring(0, i).trim();
        line = line.substring(i   1).trim();
      }
      if (line.length() > 0) {
        loadClass(extensionClasses, resourceURL, Class.forName(line, true, classLoader), name);
      } 
    }
  ...
}

代码中可以看见读到的每一行内容按照 = 号分隔,前面是实现类的别名,后面是实现类的全限定类名。有了别名的全限定类名就可以通过 Class.forName(line, true, classLoader) 获取 Class ,然后将别名与 Class 传给 loadClass() 。

loadClass() 中会将别名与 Class 写入到上文中提到的 extensionClasses 这个 map 中,这样 getExtensionClasses().get(name) 就能方便获取了。

锦上添花

讲到这里, Dubbo SPI 的主要流程应该已经讲完了,但是 Dubbo SPI 中对 Java SPI 的增强还没有提及,比如增加扩展类的 IOC 能力;增加扩展类的 AOP 能力等。这些在 ExtensionLoader 类中都有体现,感兴趣的同学可以查看代码,相信你一定能看到这些具体的实现。

实战

如何将Dubbo SPI引入项目

了解了 Dubbo SPI 的实现原理,那怎么在我们的项目中使用 Dubbo SPI 呢?现在我们在一个现有使用 Java SPI 的项目中引入 Dubbo SPI ,通过这个实践让你更深入了解 Dubbo SPI 的原理。

这个项目是一个简单 RPC 项目,原本用来序列化、和解压缩的接口实现类都是通过 Java SPI 来加载到项目中的:

代码语言:java复制
cn.ppphuang.rpcspringstarter.common.protocol.JavaSerializeMessageProtocol
cn.ppphuang.rpcspringstarter.common.protocol.KryoMessageProtocol
cn.ppphuang.rpcspringstarter.common.protocol.ProtoBufSerializeMessageProtocol
代码语言:java复制
public static Map<String, MessageProtocol> buildSupportMessageProtocol() {
    HashMap<String, MessageProtocol> supportMessageProtocol = new HashMap<>();
    ServiceLoader<MessageProtocol> loader = ServiceLoader.load(MessageProtocol.class);
    for (MessageProtocol messageProtocol : loader) {
        MessageProtocolAno annotation = messageProtocol.getClass().getAnnotation(MessageProtocolAno.class);
        Assert.notNull(annotation, "message protocol name can not be empty!");
        supportMessageProtocol.put(annotation.value(), messageProtocol);
    }
    return supportMessageProtocol;
}

必须通过 ServiceLoader.load(MessageProtocol.class) 获取所有的接口实现类,然后放入到 map 中以供后续取用,不能指定实例化某一个实现类。因为我们序列化或者解压缩实现类的选择都是通过项目的启动配置文件来决定的,项目启动时只会选择配置中指定的这个实现类,所以加载并实例化所有的实现类就会浪费资源。

我们来用 Dubbo SPI 替换 Java SPI :

创建 @SPI 注解,这里因为我们通过配置文件决定默认实现类,所有注解没有 value 值:

代码语言:java复制
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SPI {
}

创建 Holder 对象承载类:

代码语言:java复制
public class Holder<T> {
    private volatile T value;
    public T get() {
        return value;
    }
    public void set(T value) {
        this.value = value;
    }
}

创建 ExtensionLoader 类,代码较长建议查看附录链接中的源代码:

代码语言:java复制
public class ExtensionLoader<T> {
    private static final String SERVICES_DIRECTORY = "META-INF/services/";
  	public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {}
    public T getExtension(String name) {}
  	private T createExtension(String name) {}
  	private Map<String, Class<?>> getExtensionClasses() {}
  	private Map<String, Class<?>> loadExtensionClasses() {}
  	private void loadFile(Map<String, Class<?>> extensionClasses, String dir) {}
  	private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader, URL resourceUrl) {
      final int ei = line.indexOf('=');    
      //配置文件内容格式兼容 Java SPI
      if (ei > 0) {
        String name = line.substring(0, ei).trim();
        String clazzName = line.substring(ei   1).trim();
        if (name.length() > 0 && clazzName.length() > 0) {
          Class<?> clazz = classLoader.loadClass(clazzName);
          extensionClasses.put(name, clazz);
        }
      } else {
        Class<?> clazz = classLoader.loadClass(line);
        //使用类注解中的指定别名
        SPIExtension annotation = clazz.getAnnotation(SPIExtension.class);
        String name = annotation.value();
        extensionClasses.put(name, clazz);
      }
    }
}

类中的主要流程与 Dubbo SPI 中基本类似,删减了一些不需要的增强功能,主要实现类的选择性加载。同时也加入了自己的一些修改:

  1. 配置文件路径兼容 Java SPI ,也放到 META-INF/services/ 文件夹下。
  2. 配置文件内容格式兼容 Java SPI ,通过 loadResource 方法中的改动来实现 。
    1. SPI 文件的格式为 xxxx 时,按照实现类中 @SPIExtension 注解的 value 名称作为别名。com.alibaba.dubbo.common.compiler.support.JdkCompiler。
    2. SPI 文件的格式为 xxx=xxxx 时,xxx 为别名。jdk=com.alibaba.dubbo.common.compiler.support.JdkCompiler。

有了这样的兼容处理,不需要改动配置文件就可以直接替换 Java SPI 。

@SPIExtension 注解:

代码语言:java复制
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SPIExtension {
    String value();
}
代码语言:java复制
@SPI
public interface MessageProtocol {
  //表明该接口是支持 SPI 扩展的接口
}
代码语言:java复制
@SPIExtension("kryo")
public class KryoMessageProtocol implements MessageProtocol {
  //表明该实现类的默认别名是 kryo
  //配置文件中可以使用 = 号设置新别名来覆盖该别名
}
@SPIExtension("protobuf")
public class ProtoBufSerializeMessageProtocol implements MessageProtocol {
  //表明该实现类的默认别名是 protobuf
  //配置文件中可以使用 = 号设置新别名来覆盖该别名
}

这样就可以使用 Dubbo SPI 加载 Java SPI 机制下的类,项目中的实现类按需加载,不需要像 Java SPI 那样遍历实例化的所有对象了:

代码语言:java复制
MessageProtocol protocol = ExtensionLoader.getExtensionLoader(MessageProtocol.class).getExtension("kryo");
MessageProtocol protocol = ExtensionLoader.getExtensionLoader(MessageProtocol.class).getExtension("protobuf");

整个替换代码比较简单,容易看懂,而且项目中也保留了其他接口 Java SPI 扩展的方式,可以对照项目中已经替换的 Dubbo SPI 扩展加载方式来阅读理解。

https://github.com/PPPHUANG/rpc-spring-starter

参考

https://dubbo.apache.org/zh/docs/concepts/extensibility/

https://github.com/apache/dubbo

一位后端写码师,一位黑暗料理制造者。公众号:DailyHappy

0 人点赞