聊聊SPI与API、SPI打破双亲委派的原因

环境

Java 1.8


API想必大家都很熟悉,开发人员,日常开发中干的基本都是API开发

API

全称:Application Programming Interface 应用程序接口

在这里插入图片描述

类似上图的情形,我们称之为API,规则如下:

  • 服务方提供接口规则,并实现。
  • 调用方需对服务方有依赖引用。

我们现在就基于API思路来,来为JDK设计一套数据库访问的接口,即:我们自己来设计一下JDBC的思路;

我们首先要定义一套数据库的交互接口,接着去实现它,然后开发人员在引入JDK时,就可以开发使用了。
如下图:

在这里插入图片描述

根据上面的流程图,很容易发现按照API的思路去设计会有很严重的问题:

  • JDK和数据库进行了强耦合。
    导致,如:MySQL迭代升级后,实现变更了,JDK也得跟着变;

这种问题,很明显是设计上的问题,因为数据库厂家不同,实现应该由数据库厂家自己去实现,但是数据库访问的接口该谁来定义呢?由数据库厂家定义的话,大家都各自定义一套自己的标准,这样JDK用起来非常麻烦,依然是强耦合。那就只能JDK来定义标准了。

像这种:JDK定义接口标准,数据库厂商去实现这些标准类,我们把这种设计形式称为 SPI

SPI

全称:Service Provider Interface,是一种服务发现机制

SPI机制的概念

以下概念来自 复制粘贴

SPI的全名为Service Provider Interface,主要是应用于厂商自定义组件或插件中,在java.util.ServiceLoader的文档里有比较详细的介绍。

简单的总结下java SPI机制思想:我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块xml解析模块jdbc模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制Java SPI就是提供这样的一个机制:为某个接口寻找服务实现的机制。

Java SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名文件,该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader

OK,概念看完了,是不是看的头晕,我看的也头晕,但是不要慌,且听我叨叨;

假设我们按照SPI来设计JDBC,那么流程图如下:

实际上JDK就是按照SPI的思路来设计JDBC的。

在这里插入图片描述

根据SPI的特点,我们会发现:

  • 服务方(JDK)提供接口标准
  • 实现方独立的jar包,作为接口的实现;即:实现类的提供方。此处场景就是
  • 服务方(JDK),需要有一种服务发现的能力,才能找到实现类

概念基本讲完了,很好理解,接下来我们来探讨下,服务发现机制

服务发现机制

顾名思义,服务方定义好标准接口后,利用办法去把其实现类找到。

我们日常开发中会遇到两类:

  • 一类是在项目里引入jar包
  • 另一类是在微服务的环境中的服务治理中去发现。

这里只讲引jar包的方式,对JDK来说,就是从classpath中如何加载类的问题?

这里就得讲讲JDK工具类ServiceLoader

我们看看JDK是怎么使用它的:

这段代码是DriverManager类中loadInitialDrivers()方法里一段逻辑;

//代码逻辑片段
...
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
    while(driversIterator.hasNext()) {
        driversIterator.next();
    }
} catch(Throwable t) {
// Do nothing
}
...

简单分析下源码:

public static <S> ServiceLoader<S> load(Class<S> service) {
	//从当前线程中获取 线程上下文类加载器
	//默认情况下,就是AppClassLoader,也就是系统加载器。
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

private ServiceLoader(Class<S> svc, ClassLoader cl) {
	//这里就是Driver.class
    service = Objects.requireNonNull(svc, "Service interface cannot be null");
    //线程上下文类加载器
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();
}

接着我们来看看while(driversIterator.hasNext())这段源码:

public boolean hasNext() throws ServiceConfigurationError {
    if (this.nextName != null) {
        return true;
    } else {
        if (this.configs == null) {
            try {
 //会去classpath路径中的META-INF/services/去寻找以接口标准命名的文件。
 //在JDK中JDBC实现中就是java.sql.Driver。
 //也就是说JDK提供的ServiceLoader工具类会去classpath中META-INF/services/路径下去找以java.sql.Driver命名的文件
            //然后读取文件,文件里写着的就是接口的实现类
                String var1 = "META-INF/services/" + this.service.getName();
                if (this.loader == null) {
                    this.configs = ClassLoader.getSystemResources(var1);
                } else {
                    this.configs = this.loader.getResources(var1);
                }
            } catch (IOException var2) {
                Service.fail(this.service, ": " + var2);
            }
        }

        while(this.pending == null || !this.pending.hasNext()) {
            if (!this.configs.hasMoreElements()) {
                return false;
            }

            this.pending = Service.parse(this.service, (URL)this.configs.nextElement(), this.returned);
        }

        this.nextName = (String)this.pending.next();
        return true;
    }
}

通过阅读源码我们知道:

  • JDK提供的ServiceLoader工具类会去classpath中META-INF/services/路径下去找以java.sql.Driver命名的文件。
  • 接着从文件中得到标准接口的实现类,并用线程上下文类加载器加载进JVM

我们进一步思考,为什么要使用线程上下文类加载器

为什么要设计线程上下文类加载器?

我们知道jdk从1.2开始,使用双亲委派的方式来加载类。

  1. Bootstrap classLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。

  2. ExtClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。

  3. AppClassLoader:主要负责加载应用程序的主函数类

我们开发人员编写的类,包括第三方类库,都是由AppClassLoader来进行加载的。
回到上面的SPI设计思路,MySQL提供的驱动类库,就是AppClassLoader类加载器来加载的。

既然如此为什么还要设计上下文类加载器呢?

首先我们要知道,标准接口java.sql.Driver是在核心类库里的,也就是说JDK中的java.sql.Driver是由Bootstrap classLoader(启动类加载器)进行加载的。那么它的实现类,自然也得是Bootstrap classLoader进行加载。那如果Bootstrap classLoader加载不了怎么办?(第三方类库不在核心包路径下,Bootstrap classLoader是无法加载的)

Bootstrap classLoader无法加载怎么办?

按照双亲委派机制,如果上层类加载器加载不了,那就就给下一层的类加载器。

在这里插入图片描述
按照双亲委派的机制,由系统类加载器,加载第三方类库(比如:MySQL驱动),根据规则,最终会交给启动类加载器进行加载。也就符合了SPI接口java.sql.Driver由启动类加载器加载,实现类也得是启动类加载器加载。

这么看来,线程上下文类加载器,设计的不是有点多余吗?

到底哪里理解错了?

真正的原因:
在加载第三方类库时,加载Driver类的实现类时,一开始就不是系统类加载器去加载的,而是直接使用启动类加载器进行加载:

在这里插入图片描述

如图,一上来就是启动类加载器进行加载,而启动类加载器只加载核心包的类,第三方的类库肯定加载不了。为了解决这个问题,才设计了线程上下文类加载器

ServiceLoader工具类,可以理解为就是对线程上下文类加载器进行了封装。
并且我自己理解:线程上下文类加载器,其实也就是一个壳,默认情况下,它会被赋值为系统类加载器

也就是为什么要破坏双亲委派,引入线程上下文类加载器的原因。

双亲委派的破坏

上面讲的SPI的场景,是第二次破坏双亲委派。
到目前为止,总共有四次破坏了双亲委派。

第一次,历史原因,在jdk1.2开始引入双亲委派机制,那么在此之前如 jdk1.1的时候,存在的自定义的类加载器,它们就不符合双亲委派。而实际上,我们可以继承ClassLoader类,然后重写loadClass方法,也可以打破双亲委派。(打破双亲委派,不一定是坏事)

第二次,SPI,上面已经讲了,引入了线程上下文特意打破双亲委派。

第三次,用户对程序动态性的追求导致的;即:代码热替换、模块热部署。
OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。

在这里插入图片描述

第四次,是JDK9,引入了模块化,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责哪个模块的加载器完成加载。

经过破坏后的双亲委派模型更加高效,减少了很多类加载器之间不必要的委派操作。

练习

最近人民日报,批评国内互联网巨头,不要盯着老百姓的菜篮子,要向往科技创新的星辰大海。但商人逐利,肯定不怎么听话。

假设高层要求,外卖接口以后全国统一,国家实现统一的外卖平台。现有的外卖平台:开水团和饿不死,将作为内容提供方;即:国家制定SPI接口,开水团和饿不死 实现这些接口,该怎么实现?

自己模拟的话,就需要有个思路:

  1. 模拟服务方定义SPI接口
  2. 模式提供方实现SPI的接口
  3. 利用工具类ServiceLoader,模拟服务发现。在resources目录下创建META-INF/services创建SPI接口全类限定名的文件,里面存放实现类的全类限定名。

模拟服务方定义接口

package com.ssm.boot.admin.java.spi.waimai.spi;

/**
 * SPI  点外卖
 */
public interface OrderTakeOut {

    void order();
}

模式提供方实现SPI的接口

饿不死:

package com.ssm.boot.admin.java.spi.waimai.provider;

import com.ssm.boot.admin.java.spi.waimai.spi.OrderTakeOut;

public class EbsOrder implements OrderTakeOut {
    @Override
    public void order() {
        System.out.println("饿不死下单了");
    }
}

开水团:

package com.ssm.boot.admin.java.spi.waimai.provider;

import com.ssm.boot.admin.java.spi.waimai.spi.OrderTakeOut;

public class WaterOrder implements OrderTakeOut {
    @Override
    public void order() {
        System.out.println("开水团下单了");
    }
}

模拟服务发现

文件存在:

目录显示META-INF.services这是Intellij IDEA显示问题。

在这里插入图片描述

package com.ssm.boot.admin.java.spi.waimai;

import com.ssm.boot.admin.java.spi.waimai.spi.OrderTakeOut;

import java.util.Iterator;
import java.util.ServiceLoader;

public class TakeOutTest {

    public static void main(String[] args) {

        //这里是模拟服务发现
        ServiceLoader<OrderTakeOut> load = ServiceLoader.load(OrderTakeOut.class);
        Iterator<OrderTakeOut> iterator = load.iterator();
        while (iterator.hasNext()) {
            OrderTakeOut next = iterator.next();
            next.order();
        }
    }
}

打印的结果:

开水团下单了
饿不死下单了

总结

  1. SPI 最常用的场景就是插件的开发。比如eclipse制定接口标准,然后开发人员根据这些接口标准去做具体实现,来开发插件。
  2. SPI 开发步骤:
    2.1 服务方提供SPI接口
    2.2 提供方(或者调用方)实现接口
    2.3 服务方利用服务发现机制,找到SPI接口的实现。
  3. 服务发现机制有两种:1. 引jar包的方式;2. 利用服务治理的技术来发现。

参考地址:

Java SPI思想梳理
JDBC与SPI机制
SPI与API
设计原则:小议 SPI 和 API
看评论,文章写得一般

谈谈双亲委派模型的第四次破坏-模块化

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

山鬼谣me

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值