一文理解Java中的SPI机制

全菜工程师小辉

共 3818字,需浏览 8分钟

 · 2021-06-07

SPI机制简介

服务提供者接口(Service Provider Interface,简写为SPI)是JDK内置的一种服务提供发现机制。可以用来加载框架扩展和替换组件,主要是被框架的开发人员使用。在java.util.ServiceLoader的文档里有比较详细的介绍。

系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块的方案、xml解析模块、jdbc模块的方案等。面向对象的设计推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则:如果需要替换组建的一种实现,就需要修改框架的代码。SPI机制正是解决这个问题。

Java中SPI机制主要思想是将装配的控制权移到程序之外,是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,有点类似Spring的IOC机制。在模块化设计中这个机制尤其重要,其核心思想就是解耦。

SPI的接口是Java核心库的一部分,是由引导类加载器(Bootstrap Classloader)来加载的。SPI的实现类是由系统类加载器(System ClassLoader)来加载的。

引导类加载器在加载时是无法找到SPI的实现类的,因为双亲委派模型中规定,引导类加载器BootstrapClassloader无法委派系统类加载器AppClassLoader来加载。该如何解决此问题?

线程上下文类加载由此诞生,它的出现也破坏了类加载器的双亲委派模型,使得程序可以进行逆向类加载。有关这部分知识在最后补充说明。

应用场景

Java提供了很多SPI,允许第三方为这些接口提供实现。

常见的SPI使用场景:

  1. JDBC加载不同类型的数据库驱动。

  2. 日志门面接口实现类加载,SLF4J加载不同提供商的日志实现类。

  3. Spring中大量使用了SPI。可以在spring.factories中加上我们自定义的自动配置类,事件监听器或初始化器等。
    3.1 对servlet3.0规范。
    3.2 对ServletContainerInitializer的实现。

  4. Dubbo里面有很多个组件,每个组件在框架中都是以接口的形成抽象出来。具体的实现又分很多种,在程序执行时根据用户的配置来按需取接口的实现。如果Dubbo的某个内置实现不符合业务需求,那么只需要利用其SPI机制将新的业务实现替换掉Dubbo的实现即可。

这些SPI的接口是由Java核心库来提供,而SPI的实现则是作为Java应用所依赖的jar包被包含进类路径(CLASSPATH)中。例如:JDBC的实现mysql就是通过Maven被依赖进来。

SPI具体约定

Java SPI的具体约定:当服务的提供者,提供了服务接口的某种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能实现服务接口与实现的解耦。

Java SPI机制的缺点

  1. 不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。

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

  3. 扩展如果依赖其他的扩展,做不到自动注入和装配。

  4. 不提供类似于Spring的IOC和AOP功能。

  5. 扩展很难和其他的框架集成,比如扩展里面依赖了一个Spring bean,原生的Java SPI不支持。

针对以上的不足点,在生产环境的SPI机制选择时,可以考虑使用dubbo实现的SPI机制。感兴趣的同学可以自行查看,或等博客的后续更新。

SPI实例

下面用一个简单的代码实例,演示SPI的使用方法。

  1. 代码编写

定义需要的接口,然后编码接口的实现类。

  1. 增加配置文件

在项目的\src\main\resources\下创建\META-INF\services目录,并增加一个配置文件,这个文件必须以接口的全限定类名保持一致,例如:com.xiaohui.spi.HelloService。然后在配置文件中写入具体实现类的全限定类名,如有多个则换行写入。

  1. 使用JDK来载入

使用JDK提供的ServiceLoader.load()来加载配置文件中的描述信息,完成类加载操作。

补充说明SPI加载

有关双亲委派的讲解,请查看博客《Java类加载及对象创建过程详解

为什么需要破坏双亲委派?

在某些情况下父类加载器需要委托子类加载器去加载class文件。受到双亲委派加载范围的限制,父类加载器无法加载到需要的文件。

如何破坏双亲委派?

双亲委派模型并不是一个强制性的约束模型,而是java设计者推荐给开发者的类加载器实现方式,在java项目中大部分的类加载器都遵循这个模型,但也有例外,到目前为止,双亲委派模型主要出现过三次较大规模的“被破坏”情况。

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2发布之前。由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在 的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。

双亲委派模型的第二次“被破坏”是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢。

为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。使用这个线程上下文类加载器去加载所需要的代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

双亲委派模型的第三次“被破坏”是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。

破坏双亲委派的举例

以tomcat为例,讲解如何破坏双亲委派,属于上述讲解的第二次破坏。

如果有10个Web应用程序都用到了spring的话,可以把Spring的jar包放到common或shared目录下让这些程序共享。Spring的作用是管理每个web应用程序的bean,getBean时自然要能访问到应用程序的类,而用户的程序是放在/WebApp/WEB-INF目录中的(由WebAppClassLoader加载),那么在CommonClassLoader或SharedClassLoader中的Spring容器如何去加载并不在其加载范围的用户程序(/WebApp/WEB-INF/)中的Class呢?

Spring统统使用线程上下文加载器(ContextClassLoade)来加载类,无需理会被放在哪里。ContextClassLoader默认存放了WebAppClassLoader的引用,由于它是在运行时被放在了线程中,所以不管当前程序处于何处(BootstrapClassLoader或是ExtClassLoader等),在任何需要的时候都可以用Thread.currentThread().getContextClassLoader()取出应用程序类加载器来完成需要的操作。

参考:

  1. 《深入理解java虚拟机》


浏览 25
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报