Java SPI 机制详解
在面向对象的设计原则中,一般推荐模块之间基于接口编程,通常情况下调用方模块是不会感知到被调用方模块的内部具体实现。一旦代码里面涉及具体实现类,就违反了开闭原则。如果需要替换一种实现,就需要修改代码。
为了实现在模块装配的时候不用在程序里面动态指明,这就需要一种服务发现机制。Java SPI 就是提供了这样一个机制:为某个接口寻找服务实现的机制。这有点类似 IoC 的思想,将装配的控制权移交到了程序之外。
SPI 介绍
何谓 SPI?
SPI 即 Service Provider Interface,字面意思就是:“服务提供者的接口”,即专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
SPI 和 API 有什么区别?
说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
一般模块之间都是通过通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API,这种接口和实现都是放在实现方的。
当接口存在于调用方这边时,就是 SPI,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
实战演示
SLF4J(Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。
这就是通过 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。
Service Provider Interface
新建一个 Java 项目service-provider-interface
目录结构如下:(注意直接新建 Java 项目就好了,不用新建 Maven 项目,Maven 项目会涉及到一些编译配置,在过程中可能会遇到一些不想关的问题,所以为集中注意力于 SPI 机制,直接新建 Java 项目)
│ 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.util.ServiceLoader; public class LoggerService { private static 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<>(); // 注意 loader 是可迭代的,其中的元素即加载的实现类对象 for (Logger log : loader) { list.add(log); } // LoggerList 是所有 ServiceProvider loggerList = list; if (!list.isEmpty()) { // Logger 只取一个 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 Provider
接下来新建一个项目用来实现Logger
接口
新建项目service-provider
目录结构如下:
│ 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.jiangxuan.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 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。
所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。
接下来同样将service-provider
项目打包成 jar 包,这个 jar 包就是服务提供方的实现。
效果展示
为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目: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
接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?
如果某一天需求变更了,此时需要将日志输出到消息队列,这个时候只需要新增一个服务实现(可以在本项目里面新增实现也可以从外部引入新的服务实现 jar 包)并去掉原有的Logback
实现即可。
那么接下来我们具体来说说 Java SPI 工作的重点原理—— ServiceLoader 。
ServiceLoader
ServiceLoader 具体实现
想要使用 Java 的 SPI 机制是需要依赖ServiceLoader
来实现的,那么我们接下来看看ServiceLoader
具体是怎么做的。
ServiceLoader
是 JDK 提供的一个工具类,位于package java.util;
包下。
A facility to load implementations of a service.
这是 JDK 官方给的注释:一种加载服务实现的工具。
再往下看,我们发现这个类是一个final
类型的,所以是不可被继承修改,同时它实现了Iterable
接口。之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应的服务实现。
public final class ServiceLoader<S> implements Iterable<S>{ xxx...}
可以看到一个熟悉的常量定义:
private static final String PREFIX = "META-INF/services/";
同时注意到构造器设置为了 private,两个重载的load
方法作为静态工厂方法返回 ServiceLoader 实例:
public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) { return new ServiceLoader<>(service, loader); } private ServiceLoader(Class<S> svc, ClassLoader cl) { service = Objects.requireNonNull(svc, "Service interface cannot be null"); loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; reload(); } public void reload() { providers.clear(); lookupIterator = new LazyIterator(service, loader); }
且load
方法最终会调用到reload()
方法,reload()
是通过一个内部类LazyIterator
实现的,providers.clear();
中的 providers 是一个 Map,被用作缓存。先继续往下面看。
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(); } }; }
LazyIterator
常见方法具体实现如下,其中的acc
包括PrivilegedAction
、AccessController
和 Java 的安全机制相关,不影响方法逻辑,比如 hasNext 本质是调用 hasNextService 检查是否有下一个实现类,不过是根据acc == null
是否为真的分不同方式调用:
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() { return 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.Constructor; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; public class MyServiceLoader<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.getResources("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> constructor = (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; } }
关键信息基本已经通过代码注释描述出来了,
主要的流程就是:
- 通过 URL 工具类从 jar 包的
/META-INF/services
目录下面找到对应的文件, - 读取这个文件的名称找到对应的 SPI 接口,
- 通过
InputStream
流将文件里面的具体实现类的全类名读取出来, - 根据获取到的全类名,先判断跟 SPI 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象,
- 将构造出来的实例对象添加到
Providers
的列表中。
总结
其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在META-INF/services/
文件下声明。
另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 Spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框的理解。
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
- 遍历加载所有的实现类,这样效率还是相对较低的;
- 当多个
ServiceLoader
同时load
时,会有并发问题。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】博客园携手 AI 驱动开发工具商 Chat2DB 推出联合终身会员
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 2024年终总结:5000 Star,10w 下载量,这是我交出的开源答卷
· 一个适用于 .NET 的开源整洁架构项目模板
· AI Editor 真的被惊到了
· API 风格选对了,文档写好了,项目就成功了一半!
· 【开源】C#上位机必备高效数据转换助手