Java SPI机制详解20241005

面向对象设计鼓励模块间基于接口而非具体实现编程,以降低模块间的耦合,遵循依赖倒置原则,并支持开闭原则(对扩展开放,对修改封闭)。

然而,直接依赖具体实现会导致在替换实现时需要修改代码,违背了开闭原则。

为了解决这个问题,SPI应运而生,它提供了一种服务发现机制,允许在程序外部动态指定具体实现。

这与控制反转(IoC)的思想相似,将组件装配的控制权移交给了程序之外。

SPI机制也解决了Java类加载体系中双亲委派带来的限制。

双亲委派模型虽然保证了核心库的安全性和一致性,但也限制了核心库或扩展库加载应用程序类路径上的类(通过由第三方实现)。

SPI允许核心或扩展库定义服务接口,第三方开发者提供并部署实现,SPI服务加载机制则在运行时动态发现并加载这些实现。

例如,JDBC4.0及之后版本利用SPI自动发现和加载数据库驱动,开发者只需将驱动JAR包放置在类路径下即可,无需使用Class.forName()显示加载驱动类。

SPI介绍

何谓SPI?

SPI即Service Provider Interface,字面意思就是:服务提供者的接口,我的理解是:专门提供给服务提供者或者扩展框架功能的开发中去使用的一个接口。

SPI将服务接口和具体的服务实现Felicia开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性,可维护性。

修改或者替换服务实现并不需要修改调用方。

很多框架都使用了Java的SPI机制,比如:Spring框架,数据库加载驱动,日志接口,以及Dubbo的扩展实现等等。

SPI和API有什么区别?

那SPI和API有啥区别?

说到SPI就不得不说一下API(Application Programming Interface)了,从广义上来说它们都属于接口,而且很容易混淆。

下面先用一张图说明一下:

SPI VS API

一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个:接口

当实现方提供了接口和实现,我们可以通过实现方的接口从而拥有实现方给我们提供的能力,这就是API.

这种情况下,接口和实现都是放在实现方的包中。

调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。

但接口存在于调用方这边时,这就是SPI.

由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。

举个通俗的例子:公司H是一家科技公司,新设计了一款芯片,然后现在需要量产了,而在市面上有好几家芯片制造业公司,这个时候,只要H公司指定了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就是按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。

实战演示

SLF4J(Simple Logging Facade for Java)是Java的日志门面(接口),其具体实现有几种,比如:Logback,Log4j2等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的只需要在Maven依赖里面修改一些pom依赖就好了。

这就是依赖SPI机制实现的,那我们接下来就实现一个简易版本的日志框架。

Service Provider Interface

新建一个Java项目sevice-provide-interface目录结果如下:(注意直接新建Java项目就好了,不用新建Maven项目,Maven项目会涉及到一些编译配置,如果有私服的话直接deploy会比较方便,但是没有的话,在过程中可能会遇到一些奇怪的问题。)

│  service-provider-interface.iml
│
├─.idea
│  │  .gitignore
│  │  misc.xml
│  │  modules.xml
│  └─ workspace.xml
│
└─src
    └─edu
        └─jiangxuan
            └─up
                └─spi
                        Logger.java
                        LoggerService.java
                        Main.class

新建Logger接口,这个就是SPI,服务提供者接口,后面方服务提供者就要针对这个接口进行实现。

package edu.jiangxuan.up.spi;

public interface Logger {
    void info(String msg);
    void debug(String msg);    
}

接下来就是LoggerService列,这个主要是为服务使用者(调用方)提供特定功能的。

这个类也是时间Java SPI机制的关键所在,如果存在疑惑的话可以先往后面继续看。

package edu.jiangxuan.up.spi;

import java.util.ArrayList;
import java.util.List;
import java.utile.ServiceLoader;

public class LoggerService {
    private statice final LoggerService SERVICE = new LoggerService();
    
    private final Logger logger;
    
    private final List<Logger> loggerList;
    
    private LoggerService() {
        ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);
        List<Logger> list = new ArrayList<>();
        for (Logger log : loader) {
            list.add(log);
        }
        // LoggerList是所有 ServiceProvider
        loggerList = list;
        if (!list.isEmpty()) {
            logger = list.get(0);
        } else {
            logger = null;
        }
    }
    
    public static LoggerService getService() {
        return SERVICE;
    }

    public void info(String msg) {
        if (logger == null) {
            System.out.println("info 中没有发现 Logger 服务提供者");
        } else {
            logger.info(msg);
        }
    }
    
    public void debug(String msg) {
        if (loggerList.isEmpty()) {
            System.out.println("debug 中没有发现 Logger 服务提供者");
        }
        loggerList.forEach(log -> log.debug(msg));
    }
}

新建Main类(服务使用者,调用方),启动程序查看结果。

package org.spi.service;

public class Main {
    public static void main(String[] args) {
        LoggerService service = LoggerService.getService();

        service.info("Hello SPI");
        service.debug("Hello SPI");
    }
}

程序结果

info中没有发现Logger服务提供者

debug中没有发现Logger服务提供者

此时我们只是空有接口,并没有为Logger接口提供任何的实现,所以输出结果中没有按照以前打印相应的结果。

你可以使用命令或直接使用IDEA将整个程序直接打包成jar包。

新建项目service-provide目录结构如下:

│  service-provider.iml
│
├─.idea
│  │  .gitignore
│  │  misc.xml
│  │  modules.xml
│  └─ workspace.xml
│
├─lib
│      service-provider-interface.jar
|
└─src
    ├─edu
    │  └─jiangxuan
    │      └─up
    │          └─spi
    │              └─service
    │                      Logback.java
    │
    └─META-INF
        └─services
                edu.jiangxuan.up.spi.Logger

新建Logback类

package edu.jiangxuan.up.spi.service;

import edu.jiangxun.up.spi.Logger;

public class Logback implements Logger {
    @Override
    public void info(String s) {
        System.out.println("Logback info 打印日志: " + s);
    }

    @Override
    public void debug(String s) {
        System.out.println("Logback debug 打印日志: " + s);
    }
}

将service-provider-interface的jar导入项目中

新建lib目录,然后将jar包拷贝过来,再添加到项目中

再点击OK.

接下来就可以在项目中导入jar包里面的一些类和方法了,就像JDK工具类导包一样的。

实现Logger接口,在src目录下新建META-INF/services文件夹,然后新建文件edu.jiangxuan.up.spi.Logger(SPI的全类名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback(Logback的全类名,即SPI的实现类的包名 + 类名)。

这是JDK SPI机制ServiceLoader约定好的标准。

这里先大概解释一下:Java中的SPI机制就是在每次加载类的时候会先去找到class相对目录下的META-INF文件夹下的services文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个list列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。

所以Hi提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。

接下来同样将service-provider项目打包成jar包,这个jar包就是服务提供方的实现。

通常我们导入maven的pom依赖有点类似这种,只不过我们现在没有将这个jar包发布到maven公共仓库中,所以在需要使用的地方只能手动添加到项目中。

效果展示

为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目:java-spi-test

然后先导入Logger的接口jar包,再导入具体的实现类的jar包

新建Main方法测试:

package edu.jiangxuan.up.service;

import edu.jiangxuan.up.spi.LoggerService;

public class TestJavaSPI {
    public static void main(String[] args) {
        LoggerService loggerService = LoggerService.getService();
        loggerService.info("你好");
        loggerService.debug("测试Java SPI 机制");
    }
}

运行结果如下:

Logback info 打印日志:你好

Logback debug打印日志:测试Java SPI机制

说明导入jar包中的实现类生效了。

如果我们不导入具体的实现类的jar包,那么此时测下运行的结果就会是:

info中没有发现Logger服务提供者

debug中没有发现Logger服务提供者

通过使用SPI机制,可以看出服务(LoggerService)和服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改service-provider项目中针对Logger接口1具体实现就可以了,只需要换一个jar包即可,也可以有在一个项目里面有多个实现,这不就是SLF4J原理吗?

如果某一天需求变更了,此时需要将日志输出到消息队列,或者做一些别的操作,这个时候完全不需要更改Logback的实现,只需要新增一个服务实现(service-provider)可以通过在本项目里面新增实现也可以从外部引入性的服务实现jar包。

我们可以在服务(LoggerService)中选择一个具体的服务实现(service-provider)来完成我们需要的操作。

那么接下来我们具体来说说Java SPI工作的重点原理————ServiceLoader。

ServiceLoader

ServiceLoader具体实现

想要使用Java的SPI机制是需要依赖ServiceLoader来实现的,那么我们接下来看看ServiceLoader具体是怎么做的:

ServiceLoader是JDK提供的一个工具类,位与package java.util;包下。

A facility to load implementations of a service

这是JDK官方给的注释:一种加载服务实现的工具。

再往下看,我们发现这个类是一个final类型的,所以是不可被继承修改,同时它实现lterable接口。

之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应法服务实现。

public final class ServiceLoader<S> implements Inerable<S>{xxx.....}

可以看到一个熟悉的常量定义:

private static final String PREFIX = "META-INF/services/";

下面是load方法:可以发现load方法支持两种重载后的入参:

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader c1 = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service,c1);
}

public static <S> ServiceLoader<S> load(Class<S> service,ClassLoader loader) {
    return new ServiceLoader<>(service,loader);
}

private ServiceLoader(Class<S> svc,ClassLoader c1) {
    service = Object.requireNonNull(svc,"Service interface cannot be null");
    loader = (c1 == null) ? ClassLoader.getSystemClassLoader():c1;
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();
}

public void reload() {
    providers.clear();
    lookupIterator = new LazyIterator(service,loader);
}

其解决第三方类加载的机制其实就蕴含在 ClassLoader c1 = Thread.currentThread().getContextClassLoader();中,c1就是线程上下文类加载器(Thread Context ClassLoader)。

这是每个线程持有的类加载器,JDK的设计允许应用程序或容器(如Web应用服务器)设置这个类加载器,以便核心类库能够通过它来加载应用程序类。

线程上下文类加载器默认情况是应用程序类加载器(Application ClassLoader),它负责加载classpath上的类。

当核心库需要加载应用程序提供的类时,它可以使用线程上下文类加载器来完成。

这样,即使是由引导类加载器加载的核心库代码,也能够加载并使用由应用程序类加载器加载的类

根据代码调用顺序,在reload()方法中是通过一个内部类LazyIterator1实现的。

先继续往下面看。

ServiceLoader实现了Iterable接口的方法后,具有了迭代的能力,在这个iterator方法被调用时,首先会在ServiceLoader的Provider缓存中进行查找,如果缓存中没有命中那么则在LazyIterator中进行查找。

public Iterator<S> iterator() {
    return new Iterator<S>() {
        
        Iterator<Map.Entry<String,S>> knownProviders = providers.entrySet().iterator();
    
        public boolean hasNext() {
            if (knownProviders.hasNext())
                return true;
            return lookupIterator.hasNext();//调用LazyIterator
        }

        public S next() {
            if(knownProviders.hasNext())
                return knownProviders.next().getValue();
            return lookupIterator.next();// 调用LazyIterator
        }

        public void remove() {
            throw new UnsupportedOperationException()l
        }
    };
}

在调用LazyIterator时,具体实现如下:

public boolean hasNext() {
    if (acc == null) {
        return hasNextService();
    } else {
        PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
            public Boolean run() {
                return hasNextService();
            }
        };
        return AccessController.doPrivileged(action,acc);
    }
}

private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs == null) {
        try {
            //通过PREFIX(META-INF/services/)和类名 获取对应的配置文件,得到具体的实现类
            String fullName = PREFIX + service.getName();
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);
            else
                configs = loader.getResources(fullName);
        } catch (IOException x) {
            fail(service, "Error locating configuration files",x);
        }
    }
    while ((pending == null || !pending.hasNext()) {
        if (!configs.hasMoreElements()) {
            return false;
        }
        pending = parse(service, configs.nextElement());
    }
    nextName = pending.next();
    return true;
}

public S next() {
    if (acc == null) {
        return nextService();
    } else {
        PrivilegedAction<S> action =new PrivilegedAction<S>() {
            public S run(){
                reuturn nextService();
            }
        };
        return AccessController.doPrivileged(action,acc);
    }        
}

private S nextService() {
    if (! hasNextService())
        throw new NoSuchElementException();
    String cn = nextName;
    nextName = null;
    Class<?> c = null;
    try {
        c = Class.forName(cn,false,loader);
    } catch (ClassNotFoundException x) {
        fail(service,"Provider " + cn + " not found");
    }
    if (!service.isAssignableFrom(c)) {
        fail(service,"Provider " + cn + "not a subtype");
    }
    try {
        S p = service.cast(c.newInstance());
        providers.put(cn,p);
        return p;
    } catch (Throwable x) {
        fail(service,"Provider " + cn + " could not be instantiated",x);
    }
    throw new Error();    //This cannot happen    
}

可能很多人看这个会觉得有点复杂,没关系,我这边实现了一个简单的ServiceLoader的小模型,流程和原理都是保持一致的,可以先从自己实现一个简易版本的开始学:

自己实现一个ServiceLoader

我先把代码贴出来:

package edu.jiangxuan.up.service;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Constructot;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;

public class MyServiceLoad<S> {
    
    // 对应的接口 Class 模版
    private final Class<S> service;

    // 对应实现类的 可以有多个,用List进行封装
    private final List<S> providers = new ArrayList<>();

    // 类加载器
    private final ClassLoader classLoader;

    // 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程
    public static<S> MyServiceLoader<S> load(Class<S> service) {
        return new MyServiceLoader<>(service);
    }

    // 构造方法私有化
    private MyServiceLoader(Class<S> service) {
        this.service =service;
        this.classLoader = Thread.currentThread().getContextClassLoader();
        doLoad();
    }
    
    // 关键方法,加载具体实现类的逻辑
    private void doLoad() {
        try {
            //读取所有 jar包里面META-INF/services包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名
            Enumeration<URL> urls = classLoader.getResource("META-INF/services/" + service.getName());
            //挨个遍历取到的文件
            while(urls.hasMoreElements()) {
                //取出当前的文件
                URL url = urls.nextElement();
                System.out.println("File = " + url.getPath());
                // 建立链接
                URLConnection urlConnection = url.openConnection();
                urlConnection.setUseCaches(false);
                // 获取文件输入流
                InputStream inputStream = urlConnection.getInputStream();
                // 从文件输入流获取缓存
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
                // 从文件内容里面得到实现类的全类名
                String className = bufferedReader.readLine();

                while (className != null) {
                    // 通过反射拿到实现类的实例
                    Class<?> clazz = Class.forName(className,false,classLoader);
                    // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一直多态,接口跟实现类,父类和子类等等这种关系。)则构造实例
                    if (service.isAssignableFrom(clazz)) {
                        Constructor<? extends S> consytuctor = (Constructor<? extends S) clazz.getConstructor();
                        S instance = constructor.newInstance();
                        // 把当前构造的实例对象添加到 Provider的列表里面
                        providers.add(instance);
                    }
                    //继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。
                    className = bufferedReader.readLine();
                }
            }
        } catch (Exception e) {
            System.out.println("读取文件异常。。。");
        }                
    }

    //返回spi接口对应的具体实现类列表
    public List<S> getProviders() {
        return providers;
    }
}

关键信息基本已经通过代码注释描述出来了,

主要的流程就是:

1.通过URL工具类从jar包的/META-INF/services目录下面找到对应的文件,

2.读取这个文件的名称找到对应spi接口

3.通过InputStream流将文件里面的具体实现类的全类名读取出来

4.根据获取到的全类名,先判断跟spi接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象

5.将构造出来的实例对象添加到Providers的列表中。

总结

其实不难发现,SPI机制的具体实现本质上还是通过反射完成的。

即:我们按照规定将要暴露对外使用的具体实现类在META-INF/services/文件下声明,

另外,SPI机制在很多框架中都有应用:Spring框架的基本原理也是类似的方法。

还有Dubbo框架提供同样的SPI扩展机制,只不过Dubbo和spring框架中的SPI机制具体实现方式跟咱们几天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对JDK中SPI机制的学习,能够一通百通,加深对其他高深框架的理解。

通过SPI机制能够大大地提高接口设计的灵活性,但是SPI机制也存在一些缺点,比如:

1.遍历加载所有的实现类,这样效率还是相对较低的;

2.当多个ServiceLoader同时load时,会有并发问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值