背景
第一次遇到这个问题是在考虑基于接口,如何高效寻找所有的实现类:
- 工厂模式中:需要将所有支持生产的类型注册到工厂类中,每新增一个类型要修改一次工厂类的代码(对修改无法关闭)
- 策略模式中:所有支持的策略需要注册到管理类中,每新增一种策略需要修改一次策略管理类的代码
如果涉及分模块开发时,实现类不在接口模块中,此时就是件很麻烦的一件事,不仅操作琐碎,也不符合低耦合、模块化的设计思想。Java中提供了SPI(全称Java Service Provider Interface),简单说就是Java提供的一种机制,内部定制接口规范,外部使用SPI来扩展高频替换的组件。实际上利用的是Java的一种抽象机制,规范只有一个,具体的实现由用户去选择。但是这种方式仍然属于手动配置:
SPI的实现
先来看一下SPI的实现方式,比如我们常见的数据库驱动,在我们的MySQL驱动中就有这么一个文件:
它的内容只有一行,如下:
com.mysql.cj.jdbc.Driver
在应用中获取实例的方式如下:
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
这样就可以迭代循环这个迭代器,拿到所有配置的实例。我们可以在LazyIterator这个类中看下它的原理:
// hasNext时调用
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
// 懒加载 如果configs为空,则去 META-INF/services/ + service.getName() 文件中读取
String fullName = PREFIX + service.getName();
// 如果传入的类加载器为空,那么使用默认的类加载器 加载所有ClassPath下的 META-INF/services/java.sql.Driver文件
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()) {
// 如果configs为空,此处为true,即结束循环
// 如果不为空,每次在pending.hasNext()为false时,都会迭代下一个配置文件
if (!configs.hasMoreElements()) {
return false;
}
// 解析当前configs的文件,解析其配置项
pending = parse(service, configs.nextElement());
}
// 拿到当前配置项的一个 在nextService方法中使用进行实例化
nextName = pending.next();
return true;
}
// next方法调用
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 调用Class.forName获取Class实例 中间参数代表不实例化
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
// 实例化错误 抛出Error异常
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
// 调用newInstance()进行实例化 并转换为接口的实现类实例
S p = service.cast(c.newInstance());
// 加入缓存 进行返回
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
// 当实例化异常时,抛出Error
throw new Error(); // This cannot happen
}
简单说有以下两步:
- ServiceLoader.load并不会直接加载数据,而是在转换为迭代器时进行懒加载,主要是读取ClassPath下 META-INF/services/ + service.name()文件的所有配置项,将其转换为一行一行的serviceName
- 每次循环去出一行数据,调用Class.forName(cn, false, loader)方法获取Class(第二个参数为false代表不实例化对象),最后调用clz.newInstance()方法进行实例化,并会将其存入providers缓存起来(同一个serviceLoader只会实例化一次实现类,但调用静态方法会创建新的),返回当前行的接口实例
SPI的优缺点
Java中很多框架的集成都使用到了SPI,它是一个可插拔的机制,自然优点也有很多:
- 接口与实现解耦,第三方实现与接口抽象分离
- 多个实现类时可配置指定的实现,配置文件此时为实现开关
- 配置类所有都在ClassPath下的 META-INF/services/ 文件中可以找到,清晰明了方便维护
但是它的缺点也很明显:
- 每次都需要维护一个 META-INF/services/serviceX 配置文件,而且是强制的
- ServiceLoad加载实例方法是加载所有实现类,当需要找一个特定的实现类时仍然循环读取所有文件,且是线程不安全的
Spring Boot的自动装配告诉我们,Spring Boot根据ClassPath中的核心类来自动装配。某些情况我们真的需要SPI吗?我们能不能根据接口的默认实现来自动配置呢?(不使用SPI,不写META-INF文件的方式)
当你在Idea中对某个接口点下 ctrl + alt + B 时,它会弹出一堆实现类,这些也没有通过SPI的方式注册,这是不依赖SPI机制的;尝试换个角度说,Spring Boot会根据用户的接口实现类来注入到需要注入的地方,比如JPA为每个Repository接口创建代理类,它按照约定来说也有一个而已,也不用担心多个实现类的选择,当然是耦合度越低越好了,再者,数据库驱动,我们当然会根据需要引入对应的数据库驱动Jar包,自然也不会引入多个数据库驱动,同理也只有一个,那么此时,SPI的方式真的需要吗?
抛开Spring,在我们实现策略模式(简单示例)时,我们往往会指定一个接口写很多不同的策略,但是每当需要新增一个策略时,还需要在策略的调用入口新增一个 new Strategy() 类似的代码,在分支语句中,来维护新的策略供调度程序调用。如果涉及分模块开发时,实现类不在接口模块中,此时就是件很麻烦的一件事,不仅操作琐碎,也不符合低耦合、模块化的设计思想。
基于Spring框架
基于Spring框架是比较容易的,因为对于ApplicationContext有特定的方法来获取其实现类,比如:
Map<String, ServletContext> beans = applicationContext.getBeansOfType(ServletContext.class);
它的原理是基于applicationContext容器,在程序启动时解析我们所有注册的Bean定义,并装载到容器中(实际上就是一个Map缓存),在我们需要找接口的所有实现类时,只需要在缓存中遍历获取即可。虽然它是很方便的,但是我们仍然需要将自己的Bean定义注册到容器中,并且依赖Spring框架,实际上很多项目,除了Java后端项目其依赖Spring的会很少,所以这里只是作为一种实现方式,我们可以借鉴其思想,运用到我们的设计中去。
基于文件读取所有实现类
这个想法是不错的,但是也会有很多坑。首先查询网上资料:
第一篇博客Java -- 获取指定接口的所有实现类或获取指定类的所有继承类大概思路就是:
- 根据ClassLoader获取工作目录ClassPath
- 读取所有后缀为.class文件,并加载类到list中
- 循环list判断是否属于传入接口的实现类,并排除自身
- 返回所有符合条件的类
这个思路是没有问题的,但是无法作为工程化使用,因为存在以下缺陷:
- 当程序打成jar包,发布运行时,上述的这种遍历file的操作 就失效了
- 只能扫描到当前方法的同级目录及其子目录,无法覆盖整个项目模块
- 通过ClassLoader获取当前工作目录时,使用了“../bin/”这么一个固定的目录名
(而且不同的IDE(主要是eclipse 和 idea)项目的资源目录,在这一点上是不同的) - 实际上ClassLoader已经将我们需要的类加载到我们的VM虚拟机中,并不需要重复扫描文件,太繁琐
第二篇博客:获取Java接口的所有实现类
这篇博客考虑了生产部署的场景,通过JarFile工具类进行单独处理打包的jar文件,并且不需要指定扫描的Jar文件或目录,而是通过ClassLoader找到对应接口的包路径,读取对应包文件的JarFile,获取对应的类实例。
文件扫描的通病是:需要扫描Jar中所有属性,并且调用Class.forName进行获取Class实例,这样难免会扫描到某些不归属当前类加载器的类,抛出ClassNotFoundException异常,比如在扫描WebMvcConfigurer的所有实例时,会扫描所有的class属性,从而加载一些不需要的class:
Exception in thread "main" java.lang.NoClassDefFoundError: javax/servlet/jsp/tagext/BodyTagSupport
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at java.lang.Class.forName0(Native Method)
at java.lang.Class.forName(Class.java:348)
at com.thoughtworks.springboot.mini.other.ImplementsFileClassLoader.getAllClass(ImplementsFileClassLoader.java:87)
at com.thoughtworks.springboot.mini.other.ImplementsFileClassLoader.getAllClassByInterface(ImplementsFileClassLoader.java:44)
at com.thoughtworks.springboot.mini.other.ImplementsFileClassLoader.main(ImplementsFileClassLoader.java:30)
at com.thoughtworks.springboot.mini.config.OriginStarter.main(OriginStarter.java:13)
Caused by: java.lang.ClassNotFoundException: javax.servlet.jsp.tagext.BodyTagSupport
at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
... 18 more
对于BodyTagSupport这些类并不是我们需要扫描的,也不是我们需要寻找的实现类,而是在ParamTag这个类调用Class.forName时加载了相关的类,意思就是使用到了此类:
public class ParamTag extends BodyTagSupport {
// ... 省略细节
}
所以,使用文件扫描的方式是不合适的,因为ClassPath下的所有Class,并不需要全部装载到容器类,而且也不需要全部扫描,过于繁琐。
基于字节码
这里我先来探究一下Tomcat中@HandlesTypes的实现,也是这个注解才让我有了今天的想法,它的作用是将注解指定的Class对象作为参数传递到onStartup(ServletContainerInitializer)方法中。这个注解是要留给用户扩展的,它指定的Class对象并没有要继承ServletContainerInitializer,也没有写入META-INF/services/的文件(也不可能写入)中,那么Tomcat是怎么扫描到指定的类的呢。
@HandlesTypes(InterfaceA.class)
public class TestServletContainerInitializer implements ServletContainerInitializer {
/**
* Receives notification during startup of a web application of the classes
* within the web application that matched the criteria defined via the
* {@link HandlesTypes} annotation.
*
* @param c The (possibly null) set of classes that met the specified
* criteria
* @param ctx The ServletContext of the web application in which the
* classes were discovered
* @throws ServletException If an error occurs
*/
@Override
public void onStartup(Set<Class<?>> c, ServletContext ctx) throws ServletException {
// onStartup 方法在tomcat启动成功后回调,其中参数c会包含所有@HandlesTypes这个注解value类的所有子类
System.out.println(c);
}
}
实际上Tomcat 中使用了 Byte Code Engineering Library (BCEL) 框架,这是Apache Software Foundation 的 Jakarta 项目的一部分,作用同ASM类似,是字节码操纵框架。在运行时获取了每个类的超类和实现的接口名,并且判断是否与记录的注解类名相同,若相同则保存起来,最后交给tomcat实际调用
基于这个思想,我个人想实现一个基于Java的注册框架,不依赖Tomcat和Spring容器(各位有自己的想法也可以提一些建议),在程序启动时加载所有ClassPath下的类,在用户需要的地方注入,完成策略、工厂、多模块开发等模式的依赖注入。这里是一个起源部分,我也打算静下心来安静的沉淀一下,学习Tomcat源码和BCEL字节码的技术,由于此时仍然是设计阶段,决定在此类文章下的部分时,将框架及设计思想公开。
饮水思源
Java Service Provider Interface
高级开发必须理解的Java中SPI机制
获取Java接口的所有实现类