• Java中的SPI原理浅谈


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

    在进入下面学习之前,我们先来再加深一下API和SPI这两个的印象:

    API:由实现方制定接口标准并完成对接口的不同实现,这种模式服务接口从概念上更接近于实现方;

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

    API和SPI简略图示:

    看完上面的简单图示,相信大家对API和SPI的区别有了一个大致的了解,现在我们使用SPI机制来实现我们一个简单的日志框架:

    第一步,创建一个maven项目命名为spi-interface,定义一个SPI对外服务接口,用来后续提供给调用者使用;

    package cn.com.wwh;
    /**
     * 
     * @FileName Logger.java
     * @version:1.0
     * @Description: 服务提供者接口
     * @author: wwh
     * @date: 2022年9月19日 上午10:31:53
     */
    public interface Logger {
    
        /**
         * 
         * @Description:(功能描述)
         * @param msg
         */
        public void info(String msg);
    
        /**
         * 
         * @Description:(功能描述)
         * @param msg
         */
        public void debug(String msg);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    package cn.com.wwh;
    
    import java.util.ArrayList;
    import java.util.Iterator;
    import java.util.List;
    import java.util.ServiceLoader;
    
    /**
     * 
     * @FileName LoggerService.java
     * @version:1.0
     * @Description: 为服务的调用者提供特定的功能,是SPI的核心功能
     * @author: wwh
     * @date: 2022年9月19日 上午10:33:30
     */
    public class LoggerService {
    
        private static final LoggerService INSTANCE = new LoggerService();
    
        private final Logger logger;                          
    
        private final List loggers = new ArrayList<>();
    
        private LoggerService() {
         //ServiceLoader是实现SPI的核心类
            ServiceLoader sl = ServiceLoader.load(Logger.class);
            Iterator it = sl.iterator();
            while (it.hasNext()) {
                loggers.add(it.next());
            }
    
            if (!loggers.isEmpty()) {
                logger = loggers.get(0);
            } else {
                logger = null;
            }
        }
    
        /**
         * @Description:(功能描述)
         * @return
         */
        public static LoggerService getLoggerService() {
            return INSTANCE;
        }
    
        /**
         * 
         * @Description:(功能描述)
         * @param msg
         */
        public void info(String msg) {
            if (logger == null) {
                System.err.println("在info方法中没有找到Logger的实现类...");
            } else {
                logger.info(msg);
            }
        }
    
        /**
         * 
         * @Description:(功能描述)
         * @param msg
         */
        public void debug(String msg) {
            if (logger == null) {
                System.err.println("在debug方法中没有找到Logger的实现类...");
            } else {
                logger.info(msg);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72

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

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

    package cn.com.wwh;
    
    import cn.com.pep.Logger;
    
    /**
     * 
     * @FileName Logback.java
     * @version:1.0
     * @Description: 服务接口的实现类
     * @author: wwh
     * @date: 2022年9月19日 上午10:50:31
     */
    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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    同时在当前项目的classpath路径下建立META-INF/services/文件夹(至于为什么这么建立目录,我们一会儿再解释),并且新建一个名称为cn.com.wwh.Logger内容为cn.com.wwh.Logback的文件,这一步是关键(具体作用后面再详细说明),然后将上面第二步这个这个项目打成spi-provider.jar包,供给之后使用,我目前使用的开发工具是Eclipse,目录结构如下图所示:

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

    package cn.com.wwh;
    
    import cn.com.pep.LoggerService;
    
    /**
     * 
     * @FileName SpiTest.java
     * @version:1.0
     * @Description: 
     * @author: wwh
     * @date: 2022年9月19日 上午10:56:31
     */
    public class SpiTest {
    
        public static void main(String[] args) {
            LoggerService logger = LoggerService.getLoggerService();
            logger.info("我是中国人");
            logger.debug("白菜多少钱一斤");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    有了SPI我们可以将服务和服务提供者轻松地解耦,假如将来的某一天我们需要将日志保存到数据库,或者通过网络发送,我们直接只需要替换针对服务接口的实现类即可,别的地方都不用修改,这更符合程序设计中的“开闭原则”。

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

    SPI实质就是一个加载服务实现的工具 ,核心类是ServiceLoader,其实了解了SPI的原理,我们再接着探究JDK中的源码就没有那么费力了,下面我们开始源码分析吧。

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

    从上面的我们所举的例子中,我们知道SPI的入口是ServiceLoader.load(Class service)方法,我们来看看它都干了什么?

    上面的这 4步总的来说,就是使用指定的类型和当前线程绑定的classLoader实例化了一个LazyIterator对象赋值给lookupIterator这个引用,并且清除了原来providers列表中缓存的服务的实现。接下来我们调用了ServiceLoader实例的iterator()方法获取了一个迭代器,代码如下:

     1 public Iterator iterator() {
     2         //通过匿名内部类方式提供了一个迭代器
     3         return new Iterator() {
     4             //获取缓存的服务实现者的迭代器
     5             Iterator> knownProviders = providers.entrySet().iterator();
     6             
     7             //判断迭代器中是否还有元素
     8             public boolean hasNext() {
     9                 //缓存的服务实现者的迭代器中已经没有元素了
    10                 if (knownProviders.hasNext())
    11                     return true;
    12                 return lookupIterator.hasNext();//判断延迟加载的迭代器中是否还有元素
    13             }
    14 
    15             //获取迭代其中的下一个元素
    16             public S next() {
    17                 if (knownProviders.hasNext())
    18                     return knownProviders.next().getValue();
    19                 return lookupIterator.next();//获取延迟加载的迭代器中的下一个元素
    20             }
    21 
    22             public void remove() {
    23                 throw new UnsupportedOperationException();
    24             }
    25         };
    26    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

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

     1 public boolean hasNext() {
     2         if (nextName != null) {//存在下一个元素
     3             return true;
     4         }
     5         if (configs == null) {//配置文件为空
     6             try {
     7                 String fullName = PREFIX + service.getName();//获取配置文件路径
     8                 if (loader == null)
     9                     configs = ClassLoader.getSystemResources(fullName);
    10                 else
    11                     configs = loader.getResources(fullName);//加载配置文件
    12             } catch (IOException x) {
    13                 fail(service, "Error locating configuration files", x);
    14             }
    15         }
    16         //遍历配置文件内容
    17         while ((pending == null) || !pending.hasNext()) {
    18             if (!configs.hasMoreElements()) {
    19                 return false;
    20             }
    21             pending = parse(service, configs.nextElement());//配置文件内容解析
    22         }
    23         nextName = pending.next();//获取服务实现类的全路径名
    24         return true;
    25     }
    26 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

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

     1 public S next() {
     2         if (!hasNext()) {
     3             throw new NoSuchElementException();
     4         }
     5         String cn = nextName;//文件中保存的服务接口实现类的全路径名
     6         nextName = null;
     7         Class c = null;
     8         try {
     9             //获取全限定名的Class对象
    10             c = Class.forName(cn, false, loader);
    11         } catch (ClassNotFoundException x) {
    12             fail(service, "Provider " + cn + " not found");
    13         }
    14             //判断实现类和服务接口是否是同一类型
    15         if (!service.isAssignableFrom(c)) {
    16             fail(service, "Provider " + cn + " not a subtype");
    17         }
    18         try {
    19             //通过反射生成服务接口的实现类,并判断这个实例是否是接口的实现
    20             S p = service.cast(c.newInstance());
    21             //将服务接口的实现缓存起来,并返回
    22             providers.put(cn, p);
    23             return p;
    24         } catch (Throwable x) {
    25             fail(service, "Provider " + cn + " could not be instantiated", x);
    26         }
    27         throw new Error(); // This cannot happen
    28     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

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

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

    另外,SPI 机制在很多框架中都有应用:slf4j日志框架、Spring 框架的基本原理也是类似的反射。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别( **Dubbo可以实现按需加载实现类 **),不过整体的原理都是一致的,我们今天先对SPI有个简单的了解,相信有了今天的基础理解剩下的那几个也不是什么难事。

  • 相关阅读:
    Vue+Echarts+API 实现折线图对农地温湿度采集功能
    关于大型语言模型的争论和局限
    深入浅出零钱兑换问题——背包问题的套壳
    20.支持向量机—数学原理知识
    揭秘制造业精益生产和智慧供应链管理背后的秘密武器!
    vs2022编译Qt5.12.9静态库
    5.4 【MySQL】页目录
    Java 21 新特性:switch的模式匹配
    uni-app实现web-view和App之间的互相通信
    窗口延时、侧输出流数据处理
  • 原文地址:https://blog.csdn.net/Huangjiazhen711/article/details/126950012