前往小程序,Get更优阅读体验!
立即前往
发布
社区首页 >专栏 >深入解析Java SPI🌟从使用到原理的全面之旅🚀

深入解析Java SPI🌟从使用到原理的全面之旅🚀

原创
作者头像
菜菜的后端私房菜
发布2025-01-06 10:09:10
发布2025-01-06 10:09:10
2300
举报
文章被收录于专栏:菜菜的后端私房菜深入浅出Java

深入解析Java SPI🌟从使用到原理的全面之旅🚀

✨前言

在Java开发中,我们经常需要一种机制来解耦接口和其实现类,使得系统更加灵活、可扩展

传统的做法是通过硬编码或配置文件指定实现类,但这显然不够优雅且缺乏灵活性

Java SPI(Service Provider Interface)允许开发者将接口的实现从代码中分离出来,在运行时动态加载这些实现

使用SPI能够轻松将服务接口与实现分离解耦,动态加载实现服务,提高模块化,让系统更加灵活易于扩展

本文将从零开始介绍SPI,再到如何使用SPI,然后分析SPI实现原理,最后例举出SPI应用场景,文章导图如下:

导图
导图

🔍SPI简介

Java SPI (Service Provider Interface)是一种服务提供者接口机制,用于在运行时动态加载和使用服务实现

对于不熟悉的同学来说概念可能太抽象、太陌生,简单举个例子:

当我们在使用API(Application Programming Interface)时,我们需要引入三方库的依赖(jar包),在三方库的API中接口和实现都是在被调用方(三方)定义与实现的

比如我们想使用Apache的工具类API,我们需要引用其依赖再使用,其中API的接口与实现都在引用的依赖中定义与实现

而SPI却大大不同,SPI的接口可以在调用方(我们项目中)进行定义,实现类由其他三方库进行实现,在项目中使用时直接使用接口,而无需关心实现类

SPI
SPI

比如JDBC(Java Database Connectivity)中的Driver,基于接口而无需关心具体用哪个数据库的驱动(想用MySQL的就引入MySQL的依赖,想用其他数据库的就引入对应的依赖)

使用SPI不仅能够将接口与实现解耦,还符合面向对象,基于接口(抽象)而无需关心实现,松耦合、易扩展

✍️SPI使用(搭建项目)

使用SPI需要满足以下几个步骤:

  1. 定义SPI接口
  2. 三方依赖(被调用方,服务提供方)中实现SPI接口
  3. 三方依赖(被调用方,服务提供方)编写SPI配置文件(在资源目录创建**/META-INF/services**目录,其下再创建以SPI接口全限定类名的文件,文件内容为实现类的全限定类名)
  4. 调用方引入实现SPI接口的依赖,并使用ServiceLoader加载SPI接口

项目中,我们会简单定义一个数据库相关的接口,其抽象方法返回具体的数据库名,并实现两个三方依赖来实现接口,具体返回MySQL与PgSQL,最后在调用方进行加载实现类并使用

项目结构如下:

项目结构
项目结构

关于SPI的固定使用步骤也体现“约定大于配置”的原则,使用接下来搭建项目查看SPI是如何使用的:

SPI-Common项目定义SPI接口

代码语言:java
复制
   package com.caicai;
   
   /**
    * @author: 菜菜的后端私房菜
    * @create: 2025/1/5 10:24
    * @description:
    */
   public interface DatabaseInterface {
       String getDatabaseName();
   }

SPI-Provider-MySQL项目具体实现(需要依赖SPI-Common项目,因为要实现接口)

代码语言:java
复制
   package com.caicai;
   
   /**
    * @author: 菜菜的后端私房菜
    * @create: 2025/1/5 10:28
    * @description:
    */
   public class MySQLDatabase implements DatabaseInterface{
       @Override
       public String getDatabaseName() {
           return "MySQL";
       }
   }

SPI-Provider-MySQL项目在其resource目录下创建/META-INF/services/com.caicai.DatabaseInterface文件并填写com.caicai.MySQLDatabase (实现SPI接口类的全限定类名)

SPI配置文件
SPI配置文件

SPI-Provider-PgSQL项目搭建与SPI-Provider-MySQL同理,只是实现不同

代码语言:java
复制
   package com.caicai;
   
   /**
    * @author: 菜菜的后端私房菜
    * @create: 2025/1/5 10:31
    * @description:
    */
   public class PgSQLDatabase implements DatabaseInterface{
       @Override
       public String getDatabaseName() {
           return "PgSQL";
       }
   }

SPI-Invoke项目导入两个具体实现的依赖,并使用ServiceLoader加载SPI接口实现类

代码语言:java
复制
   import java.util.ServiceLoader;
   
   /**
    * @author: 菜菜的后端私房菜
    * @create: 2025/1/5 10:25
    * @description:
    */
   public class SPIDemo {
       public static void main(String[] args) {
           ServiceLoader<DatabaseInterface> serviceLoader = ServiceLoader.load(DatabaseInterface.class);
           for (DatabaseInterface databaseInterface : serviceLoader) {
               System.out.println("使用的数据库:" + databaseInterface.getDatabaseName());
           }
       }
   }
   
   /*
   结果输出:
   使用的数据库:MySQL
   使用的数据库:PgSQL
   */

📚SPI原理(源码分析)

在使用SPI的过程中,有很多约定俗成的规则,比如:

  1. 要在/META-INF/services目录下创建SPI配置文件
  2. SPI配置文件需要用SPI接口的全限定类名命名
  3. SPI配置文件的内容需要是实现类的全限定类名

那么我们在使用的过程中,能不能更改这些规则呢?SPI又是如何加载实现类的呢?

带着这些问题,我们对ServiceLoader进行源码分析:

ServiceLoader

ServiceLoader加载SPI接口时需要存储一些相关信息,如:SPI接口的Class(service)、加载实现类会用到的类加载器(loader)、已加载实现类的缓存(providers)等

ServiceLoader
ServiceLoader

从字符串PREFIX被final修饰可以看出,SPI配置文件的目录 META-INF/services/ 应该是固定不变的

ServiceLoader.load

ServiceLoader.load用于实例化ServiceLoader,但并不会加载SPI接口的具体实现类,而是采用懒加载的方式,迭代时才进行加载

从ServiceLoader.load方法进入,发现类加载使用的是当前线程的类加载器

代码语言:java
复制
public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

最终load方法会调用的ServiceLoader构造方法进行初始化,然后调用reload方法

ServiceLoader中类加载器为线程上下文类加载器(默认通常是系统/应用程序类加载器),类加载器会在后续对实现类进行加载

当SPI接口为核心类库时(java.sql.Driver以JDBC为例),本由引导类加载器进行加载的职责会交给应用程序类加载器执行

这种父类加载器委托子类加载器加载实现类的方式,打破双亲委派模型,由应用程序类加载器对JDBC驱动实现类进行加载

(不理解类加载器相关知识的同学也不用担心,感兴趣可以查看往期类加载器文章)

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

reload方法会去清空缓存,并实例化懒加载的迭代器

代码语言:java
复制
public void reload() {
    //清空缓存
    providers.clear();
    lookupIterator = new LazyIterator(service, loader);
}

可以发现在ServiceLoader.load的过程中,从未去对实现类进行加载

直到迭代ServiceLoader时,才会通过LazyIterator懒加载的方式去加载实现类

ServiceLoader迭代器实现

增强for循环是Java的语法糖,实际上会使用迭代器进行迭代

代码语言:java
复制
for (DatabaseInterface databaseInterface : serviceLoader) {
    System.out.println("使用的数据库:" + databaseInterface.getDatabaseName());
}

ServiceLoader的迭代器实现主要由knownProviders与lookupIterator来实现

knownProviders就是加载实现类缓存的迭代器,lookupIterator就是懒加载实现类迭代器

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

    };
}

代码比较简单,简单来说就是有缓存优先用缓存,否则使用懒加载迭代器进行加载实现类

迭代器hasNext(读取实现类全限定类名)

迭代器hasNext判断是否有下一个实现时,会检测是否加载过SPI配置文件(META-INF/services + SPI接口全限定类名)

如果为空说明未加载过,使用类加载器去查找SPI配置文件的URL

然后尝试去解析配置文件中的全限定类名,并将结果放入迭代器pending中

代码语言:java
复制
private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    
    //configs是SPI配置文件资源,不为空说明已经加载过
    if (configs == null) {
        try {
            //文件路径:META-INF/services  + SPI接口全限定类名
            String fullName = PREFIX + service.getName();
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);
            else
                //查找资源目录下SPI配置文件
                configs = loader.getResources(fullName);
        } catch (IOException x) {
            fail(service, "Error locating configuration files", x);
        }
    }
    
    //pending是实现类全限定类名的迭代器
    while ((pending == null) || !pending.hasNext()) {
        if (!configs.hasMoreElements()) {
            return false;
        }
        //如果实现类迭代器没有下一个值 并且 解析的SPI配置文件有内容就进行解析
        //解析就是读取每一行的全限定类名放入列表最后返回全限定类名的迭代器
        pending = parse(service, configs.nextElement());
    }
    nextName = pending.next();
    return true;
}

简单来说就是未加载全限定类名就去加载全限定类名,加载过就返回下一个全限定类名

迭代器next(加载实现类)

迭代器的next方法,最终会调用nextService通过反射先进行类加载再进行实例化最后加入缓存

代码语言:java
复制
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 {
        //实例化
        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应用的案例

🎈应用

SPI 在实际应用中最经典的例子莫过于 JDBC 驱动的加载

当我们使用 DriverManager.getConnection() 方法获取数据库连接时,实际上就是利用了 SPI 来动态加载合适的 JDBC 驱动程序

DriverManager 类初始化时会加载初始化驱动

代码语言:java
复制
static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}

loadInitialDrivers方法中就会使用ServiceLoader.load初始化,并调用迭代器加载驱动

代码语言:java
复制
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;
    }
    // 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() {
			
            //实例化ServiceLoader
            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);
        }
    }
}

以MySQL的驱动为例,它的实现类为com.mysql.cj.jdbc.Driver,在运行时动态的加载实例化实现类

MySQL驱动
MySQL驱动

🎉总结

SPI机制能够将接口与实现进行解耦,从而降低耦合性、提高模块的扩展性

使用SPI时需要遵守约定,定义SPI接口、配置SPI配置文件

SPI机制由ServiceLoader实现,

ServiceLoader类加载实现类时可能打破双亲委派模型,父类加载器的职责交给子类加载器执行

ServiceLoader迭代器优先采用缓存,没有缓存才进行懒加载SPI接口的实现类

迭代器hasNext判断是否存在下一个元素时,没缓存的情况会去加载SPI配置文件,并一行行解析文件中的全限定类名

迭代器next获取下一个元素时,没缓存的情况会通过反射根据全限定类名进行类加载,再实例化对象,最后放入缓存

SPI低耦合、高扩展的特性被应用在各种框架、中间件中,如JDBC、Tomcat..

最后(不要白嫖,一键三连求求拉~)

😁我是菜菜,热爱技术交流、分享与写作,喜欢图文并茂、通俗易懂的输出知识

📚在我的博客中,你可以找到Java技术栈的各个专栏:Java并发编程与JVM原理、Spring和MyBatis等常用框架及Tomcat服务器的源码解析,以及MySQL、Redis数据库的进阶知识,同时还提供关于消息中间件和Netty等主题的系列文章,都以通俗易懂的方式探讨这些复杂的技术点

🏆除此之外,我还是掘金优秀创作者、腾讯云年度影响力作者、华为云年度十佳博主....

👫我对技术交流、知识分享以及写作充满热情,如果你愿意,欢迎加我一起交流(vx:CaiCaiJava666),也可以持续关注我的公众号:菜菜的后端私房菜,我会分享更多技术干货,期待与更多志同道合的朋友携手并进,一同在这条充满挑战与惊喜的技术之旅中不断前行

🤝如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

📖本篇文章被收入专栏 Java,感兴趣的同学可以持续关注喔

📝本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以star持续关注喔~

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 深入解析Java SPI🌟从使用到原理的全面之旅🚀
    • ✨前言
    • 🔍SPI简介
    • ✍️SPI使用(搭建项目)
    • 📚SPI原理(源码分析)
      • ServiceLoader
      • ServiceLoader.load
      • ServiceLoader迭代器实现
      • 迭代器hasNext(读取实现类全限定类名)
      • 迭代器next(加载实现类)
    • 🎈应用
    • 🎉总结
      • 最后(不要白嫖,一键三连求求拉~)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档