乐趣区

关于java:如何在项目中引入SPI

开闭准则是面向对象程序设计的终极目标,它使软件实体领有肯定的适应性和灵活性的同时具备稳定性和延续性。当利用的需要扭转时,在不批改软件实体的源代码或者二进制代码的前提下,能够扩大模块的性能,使其满足新的需要。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,入参出参都是字节数组:

package cn.ppphuang.demoserver.serviceproviders;

public interface Compresser {byte[] compress(byte[] bytes);
    byte[] decompress(byte[] bytes);
}

服务实现

有两个实现类,假如一个应用 Gzip 算法来实现:

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 算法来实现:

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 文件,文件名是压缩服务接口类的全限定类名,两行内容别离是刚刚两个接口实现类的全限定类名,如下:

cn.ppphuang.demoserver.serviceproviders.GzipCompresser
cn.ppphuang.demoserver.serviceproviders.ZipCompresser

通过 ServiceLoader 加载服务

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

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));
  }
}

输入后果

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 数据库的示例代码,没有显式指定应用哪种数据库驱动,仍然能够失常应用。

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) 加载驱动类。

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 的连贯驱动类的全限定类名。

再看 com.mysql.jdbc.Driver 驱动类,静态方法中实例化了本人,并将本人注册到 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!");
        }
    }
}

咱们总结一下 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 加载扩大的工作流程:

次要步骤为 4 个:

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

案例解析

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

ThreadPool threadPool = ExtensionLoader.getExtensionLoader(ThreadPool.class).getExtension("eager")
//EagerThreadPool

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

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

@SPI("fixed")
public interface ThreadPool {@Adaptive({Constants.THREADPOOL_KEY})
    Executor getExecutor(URL url);
}

@SPI 注解:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface SPI {
    /**
     * default extension name
     */
    String value() default "";}

那么能够这样获取该类的默认实现类:

ThreadPool defaultExtension = ExtensionLoader.getExtensionLoader(ThreadPool.class).getDefaultExtension();
//FixedThreadPool

源码剖析

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

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 中获取实例,没有就实例化一个,而后返回。

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;
}
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 办法)

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() 实例化返回。

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() 会解析每一个文件的内容:

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 来加载到我的项目中的:

cn.ppphuang.rpcspringstarter.common.protocol.JavaSerializeMessageProtocol
cn.ppphuang.rpcspringstarter.common.protocol.KryoMessageProtocol
cn.ppphuang.rpcspringstarter.common.protocol.ProtoBufSerializeMessageProtocol
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 值:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SPI {}

创立 Holder 对象承载类:

public class Holder<T> {
    private volatile T value;
    public T get() {return value;}
    public void set(T value) {this.value = value;}
}

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

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 注解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SPIExtension {String value();
}
@SPI
public interface MessageProtocol {// 表明该接口是反对 SPI 扩大的接口}
@SPIExtension("kryo")
public class KryoMessageProtocol implements MessageProtocol {
  // 表明该实现类的默认别名是 kryo
  // 配置文件中能够应用 = 号设置新别名来笼罩该别名
}
@SPIExtension("protobuf")
public class ProtoBufSerializeMessageProtocol implements MessageProtocol {
  // 表明该实现类的默认别名是 protobuf
  // 配置文件中能够应用 = 号设置新别名来笼罩该别名
}

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

MessageProtocol protocol = ExtensionLoader.getExtensionLoader(MessageProtocol.class).getExtension("kryo");
MessageProtocol protocol = ExtensionLoader.getExtensionLoader(MessageProtocol.class).getExtension("protobuf");

整个替换代码比较简单,容易看懂,而且我的项目中也保留了其余接口 Java SPI 扩大的形式,能够对照我的项目中曾经替换的 Dubbo SPI 扩大加载形式来浏览了解。

https://github.com/PPPHUANG/r…

参考

https://dubbo.apache.org/zh/d…

https://github.com/apache/dubbo

一位后端写码师,一位光明操持制造者。公众号:DailyHappy

退出移动版