侧边栏壁纸
博主头像
孔子说JAVA博主等级

成功只是一只沦落在鸡窝里的鹰,成功永远属于自信且有毅力的人!

  • 累计撰写 352 篇文章
  • 累计创建 135 个标签
  • 累计收到 10 条评论

目 录CONTENT

文章目录

Java SPI机制的原理及运用

孔子说JAVA
2022-10-30 / 0 评论 / 0 点赞 / 59 阅读 / 13,607 字 / 正在检测是否收录...
广告 广告

SPI机制(Service Provider Interface)源自服务提供者框架(Service Provider Framework),是一种将服务接口与服务实现分离以达到解耦的机制,大大提升了程序的可扩展性。引入服务提供者就是引入了spi接口的实现者,通过本地的注册发现获取到具体的实现类,达到轻松可插拔的效果。

11121

1、SPI 简介

1.1 SPI 介绍

SPI(Service Provider Interface),字面意思为服务提供者接口,是JDK内置的一种服务提供发现机制,是JDK提供给“服务提供厂商”或者“插件开发者”使用的接口,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,比如java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL和Oracle都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现。Java 中 SPI 机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是解耦。

  • 在面向对象的设计中,模块之间我们一般会采取面向接口编程的方式,而在实际编程过程过程中,API的实现是封装在jar中,当我们想要换一种实现方法时,还要生成新的jar替换以前的实现类。而通过jdk的SPI机制就可以实现,首先不需要修改原来作为接口的jar的情况下,将原来实现的那个jar替换为另外一种实现的jar即可。

在系统的各个模块中,往往有不同的实现方案,例如日志模块的方案、xml解析的方案等,为了在装载模块的时候不具体指明实现类,我们需要一种服务发现机制,java spi就提供这样一种机制。有点类似于IoC的思想,将服务装配的控制权移到程序之外,在模块化设计时尤其重要。Java SPI机制在很多大型中间件(如Dubbo)中均有采用,属于高级Java开发的进阶必备知识点,务必要求掌握。

1.2 API与SPI区别

在面向对象的程序设计中,模块之间交互采用接口编程,通常情况下调用方不需要知道被调用方的内部实现细节,因为一旦涉及到了具体实现,如果需要换一种实现就需要修改代码,这违反了程序设计的"开闭原则"。所以我们一般有两种选择:一种是使用API(Application Programming Interface),另一种是SPI(Service Provider Interface),API通常被应用程序开发人员使用,而SPI通常被框架扩展人员使用。

  • API:由实现方制定接口标准并完成对接口的不同实现,这种模式服务接口从概念上更接近于实现方;
  • SPI:由调用方制定接口标准,实现方来针对接口提供不同的实现;从前半句话我们来看,SPI其实就是"为接口查找实现"的一种服务发现机制;这种模式,服务接口组织上位于调用方所在的包中,实现位于独立的包中。

image-1666915911612

1.3 SPI 规范

要使用 Java SPI,需要遵循下面的几点约定:

  • 在服务调用方(主程序)中定义服务的通用接口。
  • 服务提供者(可插拔的jar包)提供接口的具体实现,并在jar包的META-INF/services/目录中创建一个以“接口全限定名”为命名的文件。文件内容为该接口的具体实现类的"全限定名"。
  • 服务提供者jar 包(接口实现类所在jar包)放在主程序的 classpath 中。
  • 在服务调用方(主程序)通过 java.util.ServiceLoder 动态装载实现模块,扫描 META-INF/services 目录下的配置文件找到实现类的全限定名,把类加载到 JVM。

注:SPI的实现类必须携带一个不带参数的构造方法;

1.4 SPI 原理

应用启动的时候,扫描classpath下面的所有jar包,将jar包下的/META-INF/services/目录下的文件加载到内存中,进行一系列的解析(文件的名称是spi接口的全路径名称,文件内容应该是spi接口实现类的全路径名,可以用多个实现类,在文件中换行保存),之后判断当前类和当前接口是否是同一类型?结果为true,则通过反射生成指定类的实例对象,保存到一个map集合中,可以通过遍历或者迭代的方式拿出来使用。

1.5 使用场景

SPI 适用于:调用者根据实际使用需要,启用、扩展、或者替换框架的实现策略,实现可插拔式的插件化效果。比较常见的例子:

  • 数据库驱动加载接口实现类的加载(JDBC加载不同类型数据库的驱动)

  • 日志门面接口实现类加载(SLF4J加载不同提供商的日志实现类)

  • Spring(Spring中大量使用了SPI,比如:对servlet3.0规范对ServletContainerInitializer的实现、自动类型转换Type Conversion SPI(Converter SPI、Formatter SPI)等)

  • Dubbo(Dubbo中也大量使用SPI的方式实现框架的扩展, 不过它对Java提供的原生SPI做了封装,允许用户扩展实现Filter接口)

2、SPI的使用

2.1 简单示例

简单示例中接口和实现类都在一个项目中,主要目的是帮助大家先简单了解下spi的加载过程。

  1. 定义SPI接口
public interface SpiInterface {
    void test(String keyword);
}
  1. SPI接口实现
public class SpiTest1  implements SpiInterface{
    @Override
    public void test(String keyword) {
        System.out.println("this is " + keyword);
    }
}
  1. SPI接口声明

在项目的classpath下新建META-INF/services目录,在该目录底下一个以接口全路径命名的文件,文件内写入SPI接口的实现类。

image-1666916604434

  1. SPI加载

在main方法中进行spi的加载。

public static void main(String[] args) {
    ServiceLoader<SpiInterface> load = ServiceLoader.load(SpiInterface.class);
    Iterator<SpiInterface> iterator = load.iterator();   
    while (iterator.hasNext()) {
       SpiInterface next = iterator.next();
       next.test("SPI demo");
    }
}

Main方法中打印出了this is SPI demo,,可见SpiInterface最终加载了SpiTest1,如果SpiInterface有多个实现类,只需要在com.demo.spi.SpiInterface文件中写入全部实现类的全路径即可。

2.2 简单日志框架SPI实现

2.2.1 接口项目

  1. 创建一个maven项目命名为spi-interface,定义一个SPI对外服务接口,用来后续提供给调用者使用;
package cn.com.wwh;
/**
 * 服务提供者接口
 *
 * @version:1.0
 */
public interface Logger {
    
    /**
     * info日志
     * @param msg
     */
    public void info(String msg);
    
    /**
     * debug日志
     * @param msg
     */
    public void debug(String msg);
}
  1. SPI的核心功能实现,为服务的调用者提供特定的功能
package cn.com.wwh;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.ServiceLoader;

/**
 * 为服务的调用者提供特定的功能,是SPI的核心功能
 */
public class LoggerService {

    private static final LoggerService INSTANCE = new LoggerService();

    private final Logger logger;                          

    private final List<Logger> loggers = new ArrayList<>();

    private LoggerService() {
        // ServiceLoader是实现SPI的核心类
        ServiceLoader<Logger> sl = ServiceLoader.load(Logger.class);
        Iterator<Logger> it = sl.iterator();
        while (it.hasNext()) {
            loggers.add(it.next());
        }

        if (!loggers.isEmpty()) {
            // 多个实例取第一个实现
            logger = loggers.get(0);
        } else {
            logger = null;
        }
    }

    /**
     * 获取日志类实例
     * @return
     */
    public static LoggerService getLoggerService() {
        return INSTANCE;
    }

    /**
     * info日志
     * @param msg
     */
    public void info(String msg) {
        if (logger == null) {
            System.err.println("在info方法中没有找到Logger的实现类...");
        } else {
            logger.info(msg);
        }
    }

    /**
     * debug日志
     * @param msg
     */
    public void debug(String msg) {
        if (logger == null) {
            System.err.println("在debug方法中没有找到Logger的实现类...");
        } else {
            logger.info(msg);
        }
    }
}

将上面这个这个接口项目打成spi-interface.jar包。

2.2.2 服务实现方项目

  1. SPI接口实现类

新建一个maven项目并导入第一步中打出来的spi-interface.jar包,这个项目用来提供服务的实现,定义一个类,实现接口项目中定义的cn.com.wwh.Logger接口,示例代码如下:

package cn.com.wwh;

import cn.com.pep.Logger;

/**
 * 服务接口的实现类
 */
public class Logback implements Logger {

    @Override
    public void debug(String msg) {
        System.err.println("调用Logback的debug方法,输出的日志为:" + msg);
    }

    @Override
    public void info(String msg) {
        System.err.println("调用Logback的info方法,输出的日志为:" + msg);
    }

}
  1. SPI接口声明

在当前项目的classpath路径下建立 META-INF/services/ 文件夹,并且新建一个名称为cn.com.wwh.Logger的文件,文件内容为cn.com.wwh.Logback,这一步是关键。

image-1666919748151

然后将这个项目打包成spi-provider.jar包,供给服务调用方使用。

2.2.3 服务调用方项目

新建一个maven项目,命名为spi-test,导入前面两个步骤打的 spi-interface.jar 和 spi-provider.jar 这两个jar包,并编写测试代码,示例如下:

package cn.com.wwh;

import cn.com.pep.LoggerService;

/**
 * spi测试类
 */
public class SpiTest {
    
    public static void main(String[] args) {
        LoggerService logger = LoggerService.getLoggerService();
        logger.info("我是中国人");
        logger.debug("白菜多少钱一斤");
    }
}

运行该测试方法可以看到控制台出现了我们想要的结果。

2.3 插件实现示例

2.3.1 主程序代码

主程序需要提供一个插件服务的接口,在插件的项目中实现这个接口来让主程序能够加载到插件。

/**
 * 插件服务接口,插件项目需要提供实现这个接口的类,才能被主程序加载
 */
public interface IPluginService {

    /**
     * 插件功能主入口方法
     */
    void service();

    /**
     * 插件名成,通常用于展示在界面
     *
     * @return 插件名称
     */
    String name();

    /**
     * 表示当前插件的版本
     *
     * @return 插件版本号
     */
    String version();

}

插件加载类,通过指定插件路径,从插件路径下读取所有插件的 jar,利用 java 的 SPI机制,从这些 jar 中读取 IPluginService 的实现类

/**
 * 插件加载器,用于从插件目录加载所有插件的 IPluginService 实现类
 */
public class PluginLoader {
    /**
     * 插件加载的相对路径:这里表示所有的插件jar都放在主程序jar同级目录的 {@code PLUGIN_PATH} 文件夹下
     */
    public static final String PLUGIN_PATH = "plugins";

    public static List<IPluginService> loadPlugins() throws MalformedURLException {
        List<IPluginService> plugins = new ArrayList<>();

        File parentDir = new File(PLUGIN_PATH);
        File[] files = parentDir.listFiles();
        if (null == files) {
            return Collections.emptyList();
        }
        
        // 从目录下筛选出所有jar文件
        List<File> jarFiles = Arrays.stream(files)
                .filter(file -> file.getName().endsWith(".jar"))
                .collect(Collectors.toList());

        URL[] urls = new URL[jarFiles.size()];
        for (int i = 0; i < jarFiles.size(); i++) {
            // 加上 "file:" 前缀表示本地文件
            urls[i] = new URL("file:" + jarFiles.get(i).getAbsolutePath());
        }
        
        URLClassLoader urlClassLoader = new URLClassLoader(urls);
        // 使用 ServiceLoader 以SPI的方式加载插件包中的 IPluginService 实现类
        ServiceLoader<IPluginService> serviceLoader = ServiceLoader.load(IPluginService.class, urlClassLoader);
        for (IPluginService iPluginService : serviceLoader) {
            plugins.add(iPluginService);
        }
        return plugins;
    }
}

主程序的 main 方法,用于打印查看插件的加载结果,以及插件方法的调用结果。

public class Main {

    public static void main(String[] args) throws MalformedURLException {

        System.out.println("开始加载插件");
        List<IPluginService> services = PluginLoader.loadPlugins();
        System.out.println(services.size() + "个插件加载成功\n");

        for (int i = 0; i < services.size(); i++) {
            IPluginService service = services.get(i);
            System.out.println("===插件" + i + "===");
            System.out.println("插件名:" + service.name());
            System.out.println("版本号:" + service.version());
            System.out.println("插件服务启动:");
            service.service();
        }

    }
}

编写好以上代码之后,将主程序打成 jar 包。

2.3.2 插件代码

下面用两个插件项目来实现插件,分别叫 「插件一」 和 「插件二」,插件项目不需要定义 main 方法,但需要定义 IPluginService 的实现类,插件的功能是由主程序加载之后,调用 service 方法触发的。

在插件项目中,把打包好的主程序 jar 作为依赖引入,因为 IPluginService 是在主程序中定义的。

  1. 插件一代码

image-1666921113109

/**
 * 插件一的 IPluginService 实现类,也是插件的主类
 */
public class Plugin1Service implements IPluginService {
    @Override
    public void service() {
        // 这里可以做插件需要做的任何事情,这里仅用一句打印表示插件的功能被调用
        System.out.println(name() + "功能调用");
    }

    @Override
    public String name() {
        return "插件一";
    }

    @Override
    public String version() {
        return "1.2.5";
    }
}

在插件一项目下创建资源文件目录 resources(打包后目录中的文件会在jar包内的根目录), resources 目录下创建目录:META-INF/services,目录下新建文件,文件名为主程序中 IPluginService 的全类名,文件内容为当前插件项目中,实现了 IPluginService 接口的类的全类名。

image-1666921197295

  1. 插件二代码
/**
 * 插件二的 IPluginService 实现类,也是插件的主类
 */
public class Plugin2Service implements IPluginService {
    @Override
    public void service() {
        // 这里可以做插件需要做的任何事情,这里仅用一句打印表示插件的功能被调用
        System.out.println(name() + "功能调用");
    }

    @Override
    public String name() {
        return "插件二";
    }

    @Override
    public String version() {
        return "2.2.5";
    }
}

在插件二项目下创建资源文件目录 resources(打包后目录中的文件会在 jar 包内的根目录), resources 目录下创建目录:META-INF/services,目录下新建文件,文件名为主程序中 IPluginService 的全类名,文件内容为当前插件项目中,实现了 IPluginService 接口的类的全类名。

image-1666921257192

  1. 打包和运行

将主程序和两个插件项目打成 jar 包,插件的jar放在和主程序jar同级目录的 plugins 文件夹下:

JAVA插件DEMO
│ 
│  MainApp.jar
│
└─plugins
        Plugin1.jar
        Plugin2.jar

使用 cmd 启动主程序,输出结果如下:

11122

3、SPI源码分析

在上述程序中,我们并没有去创建Spi实现类的实例,那SPI机制是何时创建并加载到内存当中的呢?SPI实现都是通过ServiceLoader类实现的,看下具体代码。

3.1 变量

ServiceLoader类是定义在java.util包下的,使用final定义禁止子类继承和修改,实现了Iterable接口,使得可以通过迭代或者遍历的方式获取SPI接口的不同实现。

public final class ServiceLoader<S> implements Iterable<S>{

    //查找文件位置的前缀
    private static final String PREFIX = "META-INF/services/";

    // 需要加载的接口
    private final Class<S> service;

    // 类加载器
    private final ClassLoader loader;

    // 权限控制
    private final AccessControlContext acc;

    // 缓存
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

    // 懒加载迭代器
    private LazyIterator lookupIterator;
    
    public void reload() {
        providers.clear();
        lookupIterator = new LazyIterator(service, loader);
    }

3.2 方法

从上面的我们所举的例子中,我们知道SPI的入口是 ServiceLoader.load(Class<S> service) 方法。

image-1666918383959

从上图可以看到当我们调用ServiceLoader.load()时,就是使用指定的类型和当前线程绑定的classLoader实例化了一个LazyIterator对象赋值给lookupIterator这个引用,并且清除了原来providers列表中缓存的服务的实现。接下来我们调用了ServiceLoader实例的iterator()方法获取了一个迭代器。

public Iterator<S> iterator() {
        //通过匿名内部类方式提供了一个迭代器
        return new Iterator<S>() {
            //获取缓存的服务实现者的迭代器
            Iterator<Map.Entry<String, S>> knownProviders = providers.entrySet().iterator();

            //判断迭代器中是否还有元素
            public boolean hasNext() {
                //缓存的服务实现者的迭代器中已经没有元素了
                if (knownProviders.hasNext())
                    return true;
                return lookupIterator.hasNext();//判断延迟加载的迭代器中是否还有元素
            }

            //获取迭代其中的下一个元素
            public S next() {
                if (knownProviders.hasNext())
                    return knownProviders.next().getValue();
                return lookupIterator.next();//获取延迟加载的迭代器中的下一个元素
            }

            public void remove() {
                throw new UnsupportedOperationException();
            }
        };
}

我们接着调用上步获取的迭代器it的hasNext()方法,因为我们在ServiceLoader.load()过程中其实是清除了providers列表中的缓存服务实现的,所以其实调用的是lookupIterator.hasNext()方法,如下:

public boolean hasNext() {
        if (nextName != null) {//存在下一个元素
            return true;
        }
        if (configs == null) {//配置文件为空
            try {
                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;
            }
            pending = parse(service, configs.nextElement());//配置文件内容解析
        }
        nextName = pending.next();//获取服务实现类的全路径名
        return true;
    }

假如上部判断为true,紧接着我们又调用了迭代器it的next()方式,同理也调用的是lookupIterator.next()方法,源码如下:

public S next() {
        if (!hasNext()) {
            throw new NoSuchElementException();
        }
        String cn = nextName;//文件中保存的服务接口实现类的全路径名
        nextName = null;
        Class<?> c = null;
        try {
            //获取全限定名的Class对象
            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 {
            //通过反射生成服务接口的实现类,并判断这个实例是否是接口的实现
            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
    }

通过分析源码,我们了解到SPI是通过类加载+反射的机制来实例化对象的。SPI实现的主要流程是:扫描classpath路径下的所有jar包下的/META-INF/services/目录(即我们需要将服务接口的具体实现类暴露在这个目录下,之前我们提到需要在实现类的classpath下面建立一个/META-INF/services/文件夹就是这个原因),找到对应的文件,读取这个文件名找到对应的SPI接口,然后通过InputStream流将文件内容读出来,获取到实现类的全路径名,并得到这个全路径名所表示的Class对象,判断其与服务接口是否是同一类型,然后通过反射生成服务接口的实现,并保存在providers列表中,供给后续的使用。

4、SPI应用案例

4.1 加载jdbc驱动

java.sql.Driver的spi实现,有mysql驱动、oracle驱动等。以mysql为例,实现类是com.mysql.cj.jdbc.Driver,在我们引入的mysql-connector-java-8.0.11.jar中,我们可以看到有一个META-INF/services目录,目录下有一个文件名为java.sql.Driver的文件,其中的内容是com.mysql.cj.jdbc.Driver,这就是Driver接口的mysql实现类。

image-1666917162997

在DriverManager类中,通过SPI加载的关键代码如下:

/**
 * Load the initial JDBC drivers by checking the System property
 * jdbc.properties and then use the {@code ServiceLoader} mechanism
 */
static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}


private static void loadInitialDrivers() {
        String drivers;
        try {
            drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
                public String run() {
                    return System.getProperty("jdbc.drivers");
                }
            });
        } catch (Exception ex) {
            drivers = null;
        }
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {
                //通过serviceLoad加载驱动接口
                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);
            }
        }
    }

4.2 Slf4j

Slf4j是一个典型的门面接口,之前我们使用log4j作为日记记录框架时,需要同时引入slf4j和log4j的依赖。当我们想使用logback时,只需要把log4j的jar包替换为logback的jar包就可以了。这里就是利用了SPI的机制。

image-1666917523975

在log4j-to-slf4j.jar中我们可以看到前面提到的服务提供方的SPI接口声明:

image-1666917988434

这样我们只需要将log4j-to-slf4j.jar引入classpath,slf4j就能够获取到org.apache.logging.slf4j.SLF4JProvider作为实现类。

5、SPI总结

5.1 优点

  1. 使用Java SPI机制的优势是实现解耦,使得第三方服务模块的装配控制的逻辑与调用者的业务代码分离,而不是耦合在一起。应用程序可以根据实际业务情况启用框架扩展或替换框架组件。

  2. 相比使用提供接口jar包,供第三方服务模块实现接口的方式,SPI的方式使得源框架不必关心接口的实现类的路径,可以不用通过下面的方式获取接口实现类:

    • 代码硬编码 import 导入实现类
    • 指定类全路径反射获取:例如在JDBC4.0之前,JDBC中获取数据库驱动类需要通过Class.forName(“com.mysql.jdbc.Driver”),类似语句先动态加载数据库相关的驱动,然后再进行获取连接等的操作
    • 第三方服务模块把接口实现类实例注册到指定地方,源框架从该处访问实例
  3. 通过SPI的方式,第三方服务模块实现接口后,在第三方的项目代码的META-INF/services目录下的配置文件指定实现类的全路径名,源码框架即可找到实现类。

5.2 缺点

  1. SPI这种设计方式为我们的应用扩展提供了极大的便利,但是它的短板也是显而易见的,Java SPI 在查找扩展实现类的时候遍历 SPI 的配置文件并且将实现类全部实例化,假设一个实现类初始化过程比较消耗资源且耗时,但是你的代码里面又用不上它,这就产生了资源的浪费。所以说 Java SPI 无法按需加载实现类。

  2. 通过SPI,我们也只能实例化一些简单的对象,那种有依赖关系的对象,通过JAVA原生的SPI是实现不了的。

  3. 多个并发多线程使用ServiceLoader类的实例是不安全的。

0

评论区