Java进阶知识点8:高可扩展架构的利器 - 动态模块加载核心技术(ClassLoader、反射、依赖隔离)
一、背景
功能模块化是实现系统能力高可扩展性的常见思路。而模块化又可分为静态模块化和动态模块化两类:
1. 静态模块化:指在编译期可以通过引入新的模块扩展系统能力。比如:通过maven/gradle引入一个依赖(本质是一组jar文件)。
2. 动态模块化:指在JVM运行期可以通过引入新的模块扩展系统能力。比如:利用OSGI系统引入某个bundle(本质是一个jar文件),或者自己利用JDK提供的能力,将某个jar文件中的能力动态加载到运行时环境中。
静态模块化大家使用的比较多,也比较熟悉,所以本文重点介绍动态模块化。
当然本文的重点不在于阐述如何搭建一个OSGI系统,毕竟这些内容不是一篇文章可以表述详尽的,且这些技术不见得是任何规模的项目都适合使用的。当你了解实现动态模块化所必备的基础技术知识后,相信你也可以很快用自己的方式在自己的特定项目中实现适当程度的动态模块化功能,这是本文希望达到的效果。
二、设计自己的插件包
2.1 设计插件抽象接口
所谓插件机制,即允许系统中的某种抽象能力可以有不同的实现,且允许将这些实现存放于系统外部的插件包中,而不是固化在系统内部。
这个系统中的抽象能力,即插件所需的抽象接口。抽象接口可以是Java中的interface或abstract class,甚至是可被重写函数的普通class。抽象接口作为业务系统与插件之间的桥梁,通常存放于一个独立的common工程中,系统工程和插件工程都会依赖这个common工程。如下图所示:
业务系统使用common工程中的抽象接口,调用接口函数,完成抽象能力的执行。插件工程负责实现common工程中的抽象接口,完成抽象能力的具体实现。将抽象接口独立存放在common工程中,而不是直接存在于系统工程中,是为了避免插件工程依赖整个系统工程,这样会导致插件与系统的紧耦合风险。毕竟作为一款插件的实现方而言,不需要知道系统工程中的内容,这样也不会存在因系统工程的改动而破坏插件工程逻辑的可能性。
2.2 创建插件工程
创建插件工程,让此插件工程依赖common工程。
同时插件工程中还可以按需添加实现本工程所需的其他第三方依赖。
2.3 实现插件功能
在插件工程中新建插件实现类,该类负责实现插件抽象接口。
记住实现类的完全限定名(例如:com.a.b.c.ConcretePlugin),后续实例化插件包中的类对象时,需要此完全限定名。
2.4 构建插件工程,输出插件包
使用构建工具Gradle/Maven等构建插件工程,构建成功后得到的jar输出目录(比如Gradle默认的build/libs目录)中的所有内容(即所有jar包,包括第三方依赖的jar包),即为插件包所需内容。
为插件包建立一个单独的文件夹,将插件工程输出的所有jar文件拷贝至此文件夹下,该文件夹即为插件包文件夹。 插件包文件夹中的jar文件可以全部存在文件夹顶层目录下,也可以在插件包文件夹下新建子文件夹分门别类存放(比如第三方依赖的jar文件单独存在于lib子目录下),均不影响后续对插件内容的加载。
为了传输和储存方便,插件包文件夹可以压缩打包成一个独立的文件,使用时再解压即可。
三、实例化插件包中的类对象
3.1 创建并缓存ClassLoader
当系统工程中需要动态引用某个插件的能力时,需首先为每个插件创建独立的ClassLoader(原因参见下节内容《隔离不同插件包中的依赖冲突》)。
ClassLoader可以使用URLClassLoader,代码如下:
public void exmaple(String[] args) { // 获取插件包文件夹下的所有jar文件的URL,后续创建的ClassLoader将在这些URL中去寻找所需类 URL[] urls = getUrls(new File("/plugins/pluginA/")); // 将加载当前类的ClassLoader作为新创建ClassLoader的父ClassLoader ClassLoader classLoader = new URLClassLoader(urls, this.getClass().getClassLoader()); } private URL[] getUrls(File dir) { List<URL> results = new ArrayList<>(); try { // 遍历插件文件顶层目录下的所有jar文件 Files.newDirectoryStream(dir.toPath(), "*.jar") .forEach(path -> results.add(getUrl(path))); // 遍历插件文件夹/lib子目录下的所有jar文件。如果还有其他子目录,同理一起遍历 Files.newDirectoryStream(Paths.get(dir.getAbsolutePath(), "lib"), "*.jar") .forEach(path -> results.add(getUrl(path))); } catch (IOException e) { throw new RuntimeException(e.getMessage(), e); } return results.toArray(new URL[0]); } private URL getUrl(Path path) { try { return path.toUri().toURL(); } catch (MalformedURLException e) { throw new RuntimeException(e.getMessage(), e); } }
上述代码中将加载当前类的ClassLoader作为插件ClassLoader的父ClassLoader,是为了保证系统工程和插件工程中同时使用到的抽象接口的构造函数的参数被同一个ClassLoader加载,否则后续反射获取插件构造函数时,可能会提示找不到指定的构造函数。具体原因下面3.3节中会详细说明。
由于创建ClassLoader有一定开销,为了提升性能,可以将创建好的的ClassLoader缓存起来,下次相同插件需要时,直接从缓存中取与之对应的ClassLoader对象即可。
3.2 加载Class
使用插件实现类的完全限定名加载插件实现类的Class对象,代码如下:
Class pluginClass = classLoader.loadClass("com.a.b.c.ConcretePlugin");
3.3 反射获得构造函数,并调用构造函数
使用反射机制,获取插件实现类的构造函数,并通过调用此构造函数,实例化插件实现类对象,代码如下:
// 无参数的构造函数 Object pluginA = pluginClass.getConstructor().newInstance(); // 有参数的构造函数 Object pluginB = pluginClass.getConstructor(ParamA.class, ParamB.class).newInstance(new ParamA(), new ParamB());
从上述代码可以看出,如果构造函数带参数,那么插件工程中需先准备好所需参数,包括参数类型的class对象和参数对象本身,这些都会导致插件构造参数此时首先被系统工程中的加载插件代码类的类加载器(假设为classLoaderA)加载了。如果classLoaderA又不是插件类加载器(假设为pluginClassLoader)的父加载器,意味着pluginClassLoader加载得到的pluginClass中,插件构造参数很可能就不是classLoaderA加载的了。JVM中,只有被同一个类加载器加载的相同完全限定名的类,才会真正被认为是相同的类,所以此时很可能出现JVM认为插件类中的构造参数与上述代码中反射所查找的构造参数不一致,从而抛出无法找到构造参数的异常。所以我们在3.1中才会为URLClassLoader显示设置父类加载器。
上述说明涉及类加载器的双亲委派机制,网上优秀的介绍文章很多,本文不再赘述。
3.4 将构造得到的Object转换为插件抽象接口类型
上一个得到的插件类实例类型为Object,还不能正常使用,需将其转换为插件抽象接口类型,这样系统工程中就可以通过调用抽象接口中的方法,引用插件实现的具体能力了。代码如下:
ConcretePlugin plugin = (ConcretePlugin) pluginObject;
四、隔离不同插件包中的依赖冲突
4.1 “Jar Hell”问题对插件架构的影响
Jar Hell问题引起的原因是当某个ClassLoader的Jar搜索路径中的两个Jar包里存在相同完全限定名的类时,ClassLoader只会从其中一个Jar包中加载该类。而不同人编写的Jar,类的完全限定名是可能重复的,即便是同一个人编写的Jar,其不同版本的实现也使用的是相同的完全限定名。当这些完全限定名相同,但实现不同的Class所在的Jar包被作为第三方依赖同时引入到某个类加载器的Jar搜索路径下时(比如AppClassLoader的搜索路径为ClassPath),依赖冲突就产生了,而且难以解决。
在插件架构中,Jar Hell问题出现的概率可能更高,原因有如下几点:
1. 因为基于相同插件抽象接口实现的不同插件类,其业务功能本来就有一定的相似性,不同人为各自插件类取的类名冲突的可能性较大。
2. 因为不同插件功能的相似性,他们可能存在相同依赖的可能性更大,而不同插件的开发人员可能选用的依赖版本并不相同,不同版本的依赖实现完全不同甚至互相不兼容。比如不同插件负责从不同数据库中读取数据,而HBase0.9.4.x与HBase1.1.x的驱动实现不同,但驱动类的完全限定名相同,所以HBase0.9.4.x的读取插件和HBase1.1.x的读取插件所依赖的Jar包存在依赖冲突。
4.2 “Jar Hell”问题的解决思路
解决Jar Hell问题的核心思想就是,为不同的插件创建独立的ClassLoader,从根本上杜绝各插件引入的可能冲突的Jar包在同一个ClassLoader的Jar搜索路径下。
这样做后,似乎每个插件下的所有类都会被其独享的ClassLoader加载,但是这里存在两个意外,一个来自于双亲委派机制,一个来自于线程上下文类加载器。
由于Java的ClassLoader默认采用双亲委派机制,即自己加载某个Class时,优先让自己的父加载器去加载,如果父加载器无法加载,再尝试自己加载。所以,虽然每个插件都有自己的ClassLoader,但是它们存在相同的父ClassLoader(即3.1中设置的父ClassLoader),而这个父ClassLoader将负责搜索并加载系统工程引入的依赖Jar,也就是说系统工程所引入的Jar包,可能与插件包引入的Jar包存在冲突的可能。
对于第一个意外,本文采用了一种很简单的解决思路,即规范插件包的实现,插件包中尽量不要引入可能与系统工程依赖的Jar包存在版本冲突的Jar包,毕竟所有插件的实现方只需与一个系统工程兼容即可,插件与插件之间不用去关注其他插件引入了哪些Jar包,后者才是最麻烦的事情。
一般情况下,一个类所引用的其他类将默认用本类的类加载器加载,所以通常情况下,当我们用pluginClassLoader去loadClass后,随着抽象接口的实例化和方法调用,实现此抽象接口的所有其他辅助类或第三方依赖类都会用依次按需被pluginClassLoader加载。但是,在某些特定情况下,Java会使用线程上下文类加载器去加载所需的Class,而此时线程上下文类加载器并不是pluginClassLoader。(关于使用线程上下文加载器的一个典型例子:java.sql包下的JDBC相关代码,会使用线程上下文类加载器去加载实际的JDBC驱动中的代码,因为java.sql属于Java核心库内容,里面的类被引导类加载器加载,但是引导类加载器的Jar搜索路径也仅限于Java核心库,所以引导类加载器是无法加载存放在ClassPath下的各个厂商实现的JDBC驱动的。)
对于第二个意外,需要我们加载插件前,手动去替换线程上下文类加载器,同时当本线程执行完插件行为(即调用完插件抽象接口中定义的方法)后,还原上下文类加载器,以便本线程后续调用的与插件无关的代码不会受到影响。
可以单独实现一个线程上下文类加载器替换器完成线程上下文类加载器的替换和还原,代码如下:
public class ThreadContextClassLoaderSwapper { private static final ThreadLocal<ClassLoader> classLoader = new ThreadLocal<>(); // 替换线程上下文类加载器会指定的类加载器,并备份当前的线程上下文类加载器 public static void replace(ClassLoader newClassLoader) { classLoader.set(Thread.currentThread().getContextClassLoader()); Thread.currentThread().setContextClassLoader(newClassLoader); } // 还原线程上下文类加载器 public static void restore() { if (classLoader.get() == null) { return; } Thread.currentThread().setContextClassLoader(classLoader.get()); classLoader.set(null); } }
五、总结
本文介绍了一种简单易行的动态模块化实现方案:
1. 设计插件抽象接口,作为系统工程和插件工程的桥梁。
2. 使用URLClassLoader动态从外部Jar文件中查找插件实现类。
3. 使用反射机制从Class文件中查找构造函数并实例化插件实现类,将实例类型转换后,便可以直接调用插件实现类实现的抽象接口函数。
4. 为了尽可能缓解Jar Hell问题对插件架构的影响,为每个插件分配独立的类加载器pluginClassLoaderA,且在使用插件期间,保证当前线程上线文类加载器也是pluginClassLoaderA。