Java SPI机制的理解与应用

背景

一位前辈在一次技术分享中指出咱们目前的包管理不规范,模块间职责有重叠,理解成本高不易维护,提出在开发过程当中应当明确按照职责将服务划分到对应的模块中。java

好比咱们把全部服务都放在service层,但其实服务也是分为基础服务和业务逻辑服务的,或许把相似业务数据查询组装服务放在service层,把具体业务逻辑服务统一放在business层会更好,更利于基础服务的复用。mysql

但当服务拆离到不一样模块进行复用时,可能在开发过程当中出现服务依赖的问题,这部分依赖问题的解耦能够用到Java的SPI机制。显然,我历来没有据说过SPI是什么,也不明白这有什么好处。sql

SPI是什么

翻遍各类网上资料,来来回回都是车轱辘话,互相抄来抄去讲得并不通俗易懂,这里就用我本身的理解来解释。shell

SPI(Service Provider Interface),大意是“服务提供者接口”,是指在服务使用方角度提出的“接口要求”,是对“服务提供方”提出的约定,简单说就是:“我须要这样的服务,如今大家来知足”。编程

API(Application Programming Interface)与之相对,是站在服务提供方角度提供的无需了解底层细节的操做入口,即“我有这样的服务能够给你使用”。微信

SPI与API的出发点大相径庭,但做用与目的是相同的,即面向接口编程,也就是解耦。同时SPI使用的是一种“插件思惟”,即服务提供者负责全部的使用维护,当替换服务提供方时不要说调用方不修改代码,连配置文件都不须要修改(不过可能要修改依赖的jar)。并发

模块化插件框架

为何要用SPI

  • 在某些状况下,咱们没法预知将会使用哪个服务,好比无比经典的JDBC驱动、日志输出;
  • 某些状况下,服务提供方发生变化时服务调用方修改/维护代码或配置的成本很是高,如Dubbo、Motan、Spring等框架实现扩展。

举个例子,隔壁部门以为咱们的一个现有服务很棒,但愿咱们在其专用环境部署一份,同时但愿之后的全部迭代可以给他们也更新。可是使用的自研中间件咱们使用的内网版本他们使用公网版本,支付上咱们对接支付宝他们对接微信......在业务逻辑不变但切换基础服务时应该如何维护使成本最小?ide

方案 优势 缺点
维护两套代码 逻辑一致 实现简单但维护成本高
同一套代码,在业务逻辑中区分环境 维护成本低,统一管理 逻辑复杂,须要硬编码,当再出现新环境时还得折腾
SPI“插件”方式 维护成本低,无需针对实现方硬编码,更多新环境或服务提供方变化时修改简单且不影响原有逻辑 理解成本提升

这也许就是一些框架在发展过程当中经历过的阶段,能够发现使用“插件”能更好知足这个需求。模块化

SPI原理

试想一下,若是要实现这样的解耦方式,理想状况下应该如何作?不外乎就是如下几点:

  1. 服务调用方定义接口,并在主干服务中设置接入点
  2. 服务提供方实现接口,并按照约定将实现类放在调用方可达的位置
  3. 调用方基于约定找到对应位置,将对应接口的实现类加载到内存并链接至接入点
  4. 后续服务提供方发生变动/替换时,只要仍然保持按照约定将新的提供方实现类替换到对应位置便可,调用方无需任何修改

这是一种与IOC相同的思路,将装配控制权转移至程序外,由配置决定,切换成本低。

java.util.ServiceLoader提供的SPI加载方式

这个类很是简单,是原生支持的SPI加载方式,实际代码量也就200行左右。

关键点:

  1. 关键方法签名:public static <S> ServiceLoader<S> load(Class<S> service)
    • 实现了前文中的第1点,即提供接入点设置
    • 在服务的接入中,形如ServiceLoader<SomeService> loader = ServiceLoader.load(SomeService.class);可设置接入对应的接口
  2. 常量:private static final String PREFIX = "META-INF/services/";
    • 约定了上述第2点中指定的位置,基于约定的配置读取会从这里查找,固然这是指服务提供方提供的jar中的META-INF/services/目录
  3. 服务提供方的实现类在jar中,而只要在提供方定义好实现类与调用方接口之间的关系便可知足调用方的加载需求
    • 实现了上述第4点中的,只须要提供方按照约定提供实现类及实现关系,能够作到提供方替换时调用方无需任何修改
    • 在对应位置META-INF/services/下,文件名应为接口全限定名,内容每行为一个实现类全限定名
  4. 类签名:public final class ServiceLoader<S> implements Iterable<S>
    • ServiceLoader实现了Iterable接口,由于实现类与接口之间是多对一关系,服务提供方是有可能对一个接口提供多种实现的,所以加载时也能够加载多个实现类
    • 迭代器签名:private class LazyIterator implements Iterator<S>,实现了懒加载迭代,即迭代到对应的类才加载对应的类
  5. 迭代器中的方法:private boolean hasNextService()private S nextService()
    • 分别对应了迭代器中的hasNext()方法和next()方法
    • 实现了前文中第3点,即从约定位置读取实现类的全限定名称,并从jar中加载对应的类
    • 使用Class.forName加载类,使用newInstance初始化实例,cast进行强制类型转换最终获得实例,所以实现类必须提供无参构造方法

怎样使用SPI

清楚原理后,使用方式就很好理解。

step.1 调用方定义接口

package com.xxx;

public interface IHelloWorld {
    void sayHello();
}
复制代码

step.? 使用API方式实现接口

非必选,对照看一下非SPI的方式。

package com.xxx;

public class HelloWorldApi implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello API!");
    }
}
复制代码

step.2 调用方在业务代码中使用ServiceLoader

package com.xxx;

import java.util.ServiceLoader;

public class Main {
    public static void main(String[] args) {
        // 使用API
        IHelloWorld helloWorldApi = new HelloWorldApi();
        helloWorldApi.sayHello();

        // 使用SPI
        ServiceLoader<IHelloWorld> loader = ServiceLoader.load(IHelloWorld.class);
        for (IHelloWorld helloWorldSpi : loader) {
            helloWorldSpi.sayHello();
        }
    }
}
复制代码

主要区别在于SPI方式并不须要知道实现类是谁,彻底面向接口使用,相似RPC调用的状况;而API要求在业务方代码/配置中指明实现类。

step.3 提供方实现接口

这里提供两个实现类。

package com.xxx;

public class HelloWorldSpi1 implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello SPI 1!");
    }
}
复制代码
package com.xxx;

public class HelloWorldSpi2 implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello SPI 2!");
    }
}
复制代码

能够看出,实现方式与API方式彻底一致。

step.4 提供方提供配置

文件位于/resources/META-INF/services,文件名为com.xxx.IHelloWorld即接口全限定名称。

/resources/META-INF/services/com.xxx.IHelloWorld的内容为两个实现类的全限定名称:

com.xxx.HelloWorldSpi1
com.xxx.HelloWorldSpi2
复制代码

ps. 一般调用方与提供方不在同一个jar中

输出结果

Hello API!
Hello SPI 1!
Hello SPI 2!
复制代码

具体应用方式

参考咱们经常使用的JDBC,咱们在同一套代码中可能须要利用相同接口但不一样实现的状况下,能够在代码中利用SPI接入面向接口编程,在业务中不考虑具体的底层实现。

具体的底层实现能够分离出来,将每组实现和SPI配置文件打包成不一样的jar,在具体使用时根据须要使用不一样的jar便可。

具体实现可随时替换,不修改业务代码或配置

mysql-connector-java:5.1.47包的META-INF/services/目录下有个java.sql.Driver文件,内容为:

com.mysql.jdbc.Driver
com.mysql.fabric.jdbc.FabricMySQLDriver
复制代码

这是JDBC 4.0以后使用SPI机制直接获取实现,避免以前使用Class.forName("com.mysql.jdbc.Driver")方式加载MySQL驱动时的硬编码。详情可见java.sql.DriverManager类中的静态代码块:

static {
    loadInitialDrivers();	// 这里使用ServiceLoader获取具体的Driver接口实现
    println("JDBC DriverManager initialized");
}
复制代码

原生SPI的缺点

  1. 只能根据提供方的配置来获取实现类,当提供方提供多个实现时没法直接指定具体使用哪个实现。固然,这正是这个解耦机制上必需要作的牺牲,不然就破坏了“不修改代码”的初衷。可是这一点能够在自定义扩展时优化
  2. 非单例,每次load都会建立新的实例,建议自行优化,注意并发问题

参考资料

理解的Java中SPI机制 - 掘金

本文搬自个人博客,欢迎参观!

image
相关文章
相关标签/搜索