来聊聊Java的SPI机制吧

本文深入探讨Java中的SPI机制,解析其工作原理、优缺点及在数据库连接中的应用,揭示如何利用SPI实现模块间的解耦。

今天来聊聊Java中一个机制——SPI。

“你是不是因为键盘上的A和S相邻而打错字了?”

并不是,SPI和API有关系,但确实是两种不一样的东西。

What & Why

SPI(Service Provider Interface),是JDK内置的一种服务提供发现机制,Java的SPI机制可以为某个接口寻找服务实现,这一机制的主要思想是将装配的控制权移到程序之外,核心目的是解耦

这是对SPI的很抽象的定义,光看这一句应该看不懂,以下慢慢分析。

在OOP设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候不用在程序里动态指明,这就需要一种服务发现机制——SPI。它有点IOC的味道,将装配的控制权移到了程序之外,起到了解耦的作用。

What 2.0

只看完上一节的定义,我们对SPI的概念应该还是很模糊的;这里从API和SPI的区别再分析。

先来看一张图:

img

在面向接口编程时,调用方和实现方靠接口联系,那接口放在哪边呢?

  • 接口放在实现方
  • 接口放在调用方

当我们把接口放在实现方的时候,这很好理解,我们作为实现方编写接口及其实现类、作为调用方引入实现方的依赖,然后就可以调用实现方实现好的方法。这种就是API。当是这种情况时,方法的接口在逻辑上更接近于实现方、在组织上属于实现方,方法的实现也位于实现方,调用方从头到尾有“被迫接受”的感觉。

//==================================实现方==================================
public interface UserService {
    void getRights();
}

public class NormalUserServiceImpl implements UserService {
    public void getRights() {
        System.out.println("普通用户无特权");
    }
}

public class VipUserServiceImpl implements UserService {
    public void getRights() {
        System.out.println("VIP用户拥有特权");
    }
}

//==================================调用方==================================
public class UserController {
    public static void main(String[] args) {
        //显式地实例化一个VipUserServiceImpl对象
        UserService userService = new VipUserServiceImpl();
        
        //当业务发生变化时,可能要改代码
        //UserService userService = new NormalUserServiceImpl();
        
        //真正调用方法的地方
        userService.getRights();
    }
}

我们一直以来都使用这种方式调用,认为这种方式理所当然,然而这么做代码间耦合度很高,调用方要依赖于实现方,根据实现方的具体实现做不同的定义。

面对这种情况,调用方表示不想忍了,想想我们在不想写硬编码、不想用new来获取对象的时候是怎么做的?我们引入IOC思想,将对象的创建和管理权外包给Spring框架,那么能不能有一种机制,不要让调用方自己去创建接口的具体实现类实例对象,而是让实现方坦白自己有怎样的实现,再去告知调用方?

这种机制就是SPI机制。从jdk6开始,Java引进一个新的特性:ServiceLoader,它主要是用来装载一系列的service provider,而且ServiceLoader可以通过service provider的配置文件来装载指定的service provider。

How

具体实现步骤:
1. 服务的提供者提供接口的实现
2. 服务的提供者在其jar包的 META-INF/services/ 目录里创建一个“约定”文件,文件名为服务接口全限定名,文件内容为该服务接口的具体实现类。

这样一来,当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到接口的具体实现类名,进而装载实例化,完成模块的注入。

Check

接下来实操一下:

实现方代码同上,调用方有所区别:

public class UserController {
    public static void main(String[] args) {
        ServiceLoader<UserService> serviceLoader = ServiceLoader.load(UserService.class);
        for (UserService service : serviceLoader) {
            System.out.println(service.getClass());	//为了方便观察,在此输出service的实现类
            service.getRights();	//调用
        }
    }
}

输出如图:

可以看到我们在调用方并没有显式地定义某一种具体实现类对象,而是通过java.util.ServiceLoader.load(UserService.class)来获取UserService类的所有实现类,实现类想要提供哪些具体实现,在约定文件中配置就好,不需要更改我们的业务代码,这就使调用方与实现方实现了解耦。

原理

SPI机制的原理是什么?如何做到的呢?我们可以到java.util.ServiceLoader中一探究竟:

我看源码的习惯之首——先看类维护的成员变量

public final class ServiceLoader<S> implements Iterable<S>{
    private static final String PREFIX = "META-INF/services/";

    // 表示正在加载的服务的类或接口
    private final Class<S> service;

    // 用于查找,加载和实例化提供程序的类加载器
    private final ClassLoader loader;

    // 创建ServiceLoader时采取的访问控制上下文
    private final AccessControlContext acc;

    // 缓存已经被实例化的服务提供者(按实例顺序)
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

    // 当前的懒查询迭代器
    private LazyIterator lookupIterator;
    
    //...
}

第二,看关注的方法。我们是通过ServiceLoader.load(UserService.class)获取所有目标接口的实现类的,所以进到load(Class)方法中看看:

public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader){
    //3. 返回ServiceLoader类实例对象
    return new ServiceLoader<>(service, loader);
}

public static <S> ServiceLoader<S> load(Class<S> service) {
    //1. 先获取当前类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    //2.调用重载方法load(Class, ClassLoader)
    return ServiceLoader.load(service, cl);
}

接下来就是追代码了:

private class LazyIterator implements Iterator<S>{
    Class<S> service;
    ClassLoader loader;
    //...

    //7. 初始化懒查询迭代器
    private LazyIterator(Class<S> service, ClassLoader loader) {
        this.service = service;
        this.loader = loader;
    }
    //...
}

/**
 * 在这里会清除providers中缓存的所有provider,然后再重新加载所有的provider。
 * 调用此方法后,在后续调用iterator方法懒查询并从头实例化provider。
 * 此方法适用于在Java虚拟机正在运行时安装了新的provider的情况。
 */
public void reload() {
    //5. 清除providers
    providers.clear();
    //6. 懒查询迭代器
    lookupIterator = new LazyIterator(service, loader);
}

private ServiceLoader(Class<S> svc, ClassLoader cl) {
    //1. 判断传来的Class是否为空,不为空则赋到类维护的变量service中
    service = Objects.requireNonNull(svc, "Service interface cannot be null");
    //2. 判断传来的ClassLoader是否为空,不为空则赋到类维护的变量loader中
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    //3. 暂时不关心变量acc
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    //4. 进到reload方法
    reload();
}

重点就在懒查询迭代器LazyIterator中,既然是迭代器,那么我们主要关注它的类似nexthasNext的方法(为了使源码易于阅读,我在此去掉其异常处理部分):

/**
 * 3. 好的镜头看过来了,说明还有下一个元素,这个方法的作用是获取下一个元素的元素名
 */
private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs == null) {
        //4. 在此获取配置的约定文件路径,通过最开始定义的PREFIX(META-INF/services/)和服务接口名service.getName()获取约定文件的全路径名fullName
        String fullName = PREFIX + service.getName();
        if (loader == null)
            configs = ClassLoader.getSystemResources(fullName);
        else
            configs = loader.getResources(fullName);
    }
    while ((pending == null) || !pending.hasNext()) {
        if (!configs.hasMoreElements()) {
            return false;
        }
        //5. 解析获取到约定文件内配置好的接口实现类名
        pending = parse(service, configs.nextElement());
    }
    //6. 维护到nextName变量中,最后返回true表示还有下一个元素,允许通过next方法获取下一个元素了
    nextName = pending.next();
    return true;
}

/**
 * 8. 真正实例化接口的实现类对象并缓存起来的方法
 */
private S nextService() {
    if (!hasNextService())
        throw new NoSuchElementException();
    //9. 先获取到下一个元素的名称nextName到cn,然后清空nextName便于下一次判断
    String cn = nextName;
    nextName = null;
    //10. 重点在此!通过Class.forName获取类的Class对象
    Class<?> c = Class.forName(cn, false, loader);
    if (!service.isAssignableFrom(c)) {
        fail(service,
             "Provider " + cn  + " not a subtype");
    }
    //11. 再去实例化一个该类对象p
    S p = service.cast(c.newInstance());
    //12. 以“类的全限定名, 类实例对象”的映射格式存到providers中维护起来,返回实例对象
    providers.put(cn, p);
    return p;
    throw new Error();          // This cannot happen
}

/**
 * 1. 使用迭代器遍历,我们会先使用hasNext判断遍历集合中是否还有下一个元素
 */
public boolean hasNext() {
    //2. 如果还有下一个元素,镜头给到hasNextService方法
    if (acc == null) {
        return hasNextService();
    } else {
        PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
            public Boolean run() { return hasNextService(); }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

/**
 * 7. 经过hasNextService判断存在下一个元素,就通过此方法获取下一个元素,本方法逻辑很简单,无论如何都会调用nextService方法
 */
public S next() {
    if (acc == null) {
        return nextService();
    } else {
        PrivilegedAction<S> action = new PrivilegedAction<S>() {
            public S run() { return nextService(); }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

通过看源码,我们就知道了

  1. 为什么约定文件要放在META-INF/services/
  2. 为什么约定文件名要是接口全限定名
  3. 为什么约定文件中要写上接口的实现类全限定名

也明白了接口的调用方能够在不显式实例化接口的实现类对象的前提下就可以调用其方法的原理。

应用

那么在什么地方有运用到SPI机制吗?是的,而且我们还很熟悉。

在初学数据库连接的时候,我们应该都是从jdbc开始的,用代码连接数据库有四要素:Driver、Url、username、password,其中的Driver根据不同数据库会有不用的实现,比如我们在使用MySQL数据库时,会通过Class.forName("com.mysql.cj.jdbc.Driver")将驱动类载入JVM。当我们不手写硬编码连接数据库后选择引入第三方包,在使用Java连接MySQL数据库的时候就需要引入mysql-connector-java包,然后通过以下代码来看看DriverManager帮我们管理的Driver

Enumeration<Driver> drivers = DriverManager.getDrivers();
if (drivers.hasMoreElements()) {
    Driver driver = drivers.nextElement();
    System.out.println(driver.getClass());
}

可以看到其输出

这时可以思考了,我们并没有Class.forName("com.mysql.cj.jdbc.Driver")来将驱动加载进虚拟机,那么这个依赖是怎么实现驱动加载的?答案就是应用了SPI机制。

也就是说,只要遵循SPI机制的规则,就可以这样玩耍,当然也有不同的异类,Oracle就是其一,所以如果我们要连接Oracle数据库,还是得使用Class.forName加载驱动。

不足

SPI机制看起来很爽,那有没有膈应人的地方?那还是有的,可能读者在上面的示例中也看出了Java的SPI存在的不足:

  1. 不能按需加载。调用方需要遍历目标接口的所有实现并实例化,然后在循环中才能找到实际需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这里就存在资源浪费。
  2. 获取某个实现类的方式不够灵活。调用方只能通过迭代器Iterator的形式获取,不能根据某个参数来获取对应的实现类,这又是个性能问题。

除开上述之外,还有一点没有做示例的不足之处:多个并发多线程使用ServiceLoader类的实例是不安全的。即ServiceLoader.load获取的ServiceLoader对象是非线程安全的。

总结

综合全篇,我们知道SPI机制是有可取之处的,这一机制/思想也有落地的实现,但同时也存在不足。为了规避它的不足,我们在SPI机制选择时,可以考虑使用dubbo实现的SPI机制。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值