一、背景

功能模块化是实现系统能力高可扩展性的常见思路。而模块化又可分为静态模块化和动态模块化两类:

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,代码如下:

  1. public void exmaple(String[] args) {
  2. // 获取插件包文件夹下的所有jar文件的URL,后续创建的ClassLoader将在这些URL中去寻找所需类
  3. URL[] urls = getUrls(new File("/plugins/pluginA/"));
  4. // 将加载当前类的ClassLoader作为新创建ClassLoader的父ClassLoader
  5. ClassLoader classLoader = new URLClassLoader(urls, this.getClass().getClassLoader());
  6. }
  7.  
  8. private URL[] getUrls(File dir) {
  9. List<URL> results = new ArrayList<>();
  10. try {
  11. // 遍历插件文件顶层目录下的所有jar文件
  12. Files.newDirectoryStream(dir.toPath(), "*.jar")
  13. .forEach(path -> results.add(getUrl(path)));
  14. // 遍历插件文件夹/lib子目录下的所有jar文件。如果还有其他子目录,同理一起遍历
  15. Files.newDirectoryStream(Paths.get(dir.getAbsolutePath(), "lib"), "*.jar")
  16. .forEach(path -> results.add(getUrl(path)));
  17. } catch (IOException e) {
  18. throw new RuntimeException(e.getMessage(), e);
  19. }
  20. return results.toArray(new URL[0]);
  21. }
  22.  
  23. private URL getUrl(Path path) {
  24. try {
  25. return path.toUri().toURL();
  26. } catch (MalformedURLException e) {
  27. throw new RuntimeException(e.getMessage(), e);
  28. }
  29. }

上述代码中将加载当前类的ClassLoader作为插件ClassLoader的父ClassLoader,是为了保证系统工程和插件工程中同时使用到的抽象接口的构造函数的参数被同一个ClassLoader加载,否则后续反射获取插件构造函数时,可能会提示找不到指定的构造函数。具体原因下面3.3节中会详细说明。

由于创建ClassLoader有一定开销,为了提升性能,可以将创建好的的ClassLoader缓存起来,下次相同插件需要时,直接从缓存中取与之对应的ClassLoader对象即可。

3.2 加载Class

使用插件实现类的完全限定名加载插件实现类的Class对象,代码如下:

  1. Class pluginClass = classLoader.loadClass("com.a.b.c.ConcretePlugin");

3.3 反射获得构造函数,并调用构造函数

使用反射机制,获取插件实现类的构造函数,并通过调用此构造函数,实例化插件实现类对象,代码如下:

  1. // 无参数的构造函数
  2. Object pluginA = pluginClass.getConstructor().newInstance();
  3.  
  4. // 有参数的构造函数
  5. 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,还不能正常使用,需将其转换为插件抽象接口类型,这样系统工程中就可以通过调用抽象接口中的方法,引用插件实现的具体能力了。代码如下:

  1. 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驱动的。)

对于第二个意外,需要我们加载插件前,手动去替换线程上下文类加载器,同时当本线程执行完插件行为(即调用完插件抽象接口中定义的方法)后,还原上下文类加载器,以便本线程后续调用的与插件无关的代码不会受到影响。

可以单独实现一个线程上下文类加载器替换器完成线程上下文类加载器的替换和还原,代码如下:

  1. public class ThreadContextClassLoaderSwapper {
  2. private static final ThreadLocal<ClassLoader> classLoader = new ThreadLocal<>();
  3.  
  4. // 替换线程上下文类加载器会指定的类加载器,并备份当前的线程上下文类加载器
  5. public static void replace(ClassLoader newClassLoader) {
  6. classLoader.set(Thread.currentThread().getContextClassLoader());
  7. Thread.currentThread().setContextClassLoader(newClassLoader);
  8. }
  9.  
  10. // 还原线程上下文类加载器
  11. public static void restore() {
  12. if (classLoader.get() == null) {
  13. return;
  14. }
  15. Thread.currentThread().setContextClassLoader(classLoader.get());
  16. classLoader.set(null);
  17. }
  18. }

五、总结

本文介绍了一种简单易行的动态模块化实现方案:

1. 设计插件抽象接口,作为系统工程和插件工程的桥梁。

2. 使用URLClassLoader动态从外部Jar文件中查找插件实现类。

3. 使用反射机制从Class文件中查找构造函数并实例化插件实现类,将实例类型转换后,便可以直接调用插件实现类实现的抽象接口函数。

4. 为了尽可能缓解Jar Hell问题对插件架构的影响,为每个插件分配独立的类加载器pluginClassLoaderA,且在使用插件期间,保证当前线程上线文类加载器也是pluginClassLoaderA。

Java进阶知识点8:高可扩展架构的利器 - 动态模块加载核心技术(ClassLoader、反射、依赖隔离)的更多相关文章

  1. 别翻了,这篇文章绝对让你深刻理解java类的加载以及ClassLoader源码分析【JVM篇二】

    目录 1.什么是类的加载(类初始化) 2.类的生命周期 3.接口的加载过程 4.解开开篇的面试题 5.理解首次主动使用 6.类加载器 7.关于命名空间 8.JVM类加载机制 9.双亲委派模型 10.C ...

  2. Java进阶知识点: 枚举值

    Java进阶知识点1:白捡的扩展性 - 枚举值也是对象   一.背景 枚举经常被大家用来储存一组有限个数的候选常量.比如下面定义了一组常见数据库类型: public enum DatabaseType ...

  3. 使用javassist运行时动态重新加载java类及其他替换选择

    在不少的情况下,我们需要对生产中的系统进行问题排查,但是又不能重启应用,java应用不同于数据库的存储过程,至少到目前为止,还不能原生的支持随时进行编译替换,从这种角度来说,数据库比java的动态性要 ...

  4. Java进阶知识点:不要只会写synchronized - JDK十大并发编程组件总结

    一.背景 提到Java中的并发编程,首先想到的便是使用synchronized代码块,保证代码块在并发环境下有序执行,从而避免冲突.如果涉及多线程间通信,可以再在synchronized代码块中使用w ...

  5. Java进阶专题(十八) 系统缓存架构设计 (下)

    前言 上章节介绍了Redis相关知识,了解了Redis的高可用,高性能的原因.很多人认为提到缓存,就局限于Redis,其实缓存的应用不仅仅在于Redis的使用,比如还有Nginx缓存,缓存队列等等.这 ...

  6. Java进阶知识点:并发容器背后的设计理念

    一.背景 容器是Java编程中使用频率很高的组件,但Java默认提供的基本容器(ArrayList,HashMap等)均不是线程安全的.当容器和多线程并发编程相遇时,程序员又该何去何从呢? 通常有两种 ...

  7. Java进阶知识点6:并发容器背后的设计理念 - 锁分段、写时复制和弱一致性

    一.背景 容器是Java编程中使用频率很高的组件,但Java默认提供的基本容器(ArrayList,HashMap等)均不是线程安全的.当容器和多线程并发编程相遇时,程序员又该何去何从呢? 通常有两种 ...

  8. java8--类加载机制与反射(java疯狂讲义3复习笔记)

    本章重点介绍java.lang.reflect包下的接口和类 当程序使用某个类时,如果该类还没有被加载到内存中,那么系统会通过加载,连接,初始化三个步骤来对该类进行初始化. 类的加载时指将类的clas ...

  9. Android 高仿腾讯旗下app的 皮肤加载技术

    http://www.cnblogs.com/punkisnotdead/p/4968851.html 以前写的这篇文章 可以高仿出 知乎 新浪微博等 绝大多数app的换肤技术,但是遗漏了腾讯的效果, ...

随机推荐

  1. git使用基础

    一.git介绍 git是由 Linus 开发的一种“分布式版本控制”软件,而在此之前,版本控制基本上都是“集中式版本控制”,如:CVS,SVN 等.两者的区别: 1. "集中式版本控制系统& ...

  2. $ 专治各种python字符编码问题疑难杂症

    标准动作 在脚本第一行指定编码格式: # coding:utf-8 将默认的ascii字符流处理方式变为utf-8: import sys sys.getdefaultencoding() 'asci ...

  3. jmeter 分布式集群

    Jmeter压测过程中,由于测试机配置有限,CPU.内存都可能是存在瓶颈.如果使用很大的并发进行测试时,就可能会感到程序比较卡,这时候就无法继续增加压力了. 解决方法: 搭建Jmeter分布式集群,远 ...

  4. ThreadLocal管理登录信息

    通常在项目中,用户登录后,我们会将用户的信息存到session,如果想在其它地方获取session中的用户信息,我们需要先获取HttpServletRequest,再通过request.getSess ...

  5. P4949 最短距离(基环树+树链剖分)

    题目 P4949 最短距离 做法 先把非树边提出来 查询\((x,y)\)的最短距离就分类查询:树上\((x,y)\)距离,经过非树边距离 带边权查询链长,一个烂大街的套路:树链剖分,节点维护树边距离 ...

  6. Sybase:删除表中的某列

    Sybases:删除表中的某列 alter table tb1(表名) drop clo1(列名); commit;

  7. 介绍Web项目中用到的几款JQuery消息提示插件

    第一款 noty 官方网站:https://github.com/needim/noty 第二款 artDialog artDialog是一个精巧的web对话框组件,压缩后只有十多KB,并且不依赖其他 ...

  8. Linux系统基本的内存管理知识讲解

    内存是Linux内核所管理的最重要的资源之一.内存管理系统是操作系统中最为重要的部分,因为系统的物理内存总是少于系统所需要的内存数量.虚拟内存就是为了克服这个矛盾而采用的策略.系统的虚拟内存通过在各个 ...

  9. VC++6.0调试简单快捷键

    编译——F7 重新编译——Ctrl+F7 设置断点 ——F9 取消断点——F9 删除所有断点——Ctrl+Shift+F9 开始调试——F5 进行下一次调试——F5 停止调试——Shift+F5 逐过 ...

  10. OSTU二值化算法

    介绍 Ostu方法又名最大类间差方法,通过统计整个图像的直方图特性来实现全局阈值T的自动选取,其算法步骤为: 1) 先计算图像的直方图,即将图像所有的像素点按照0~255共256个bin,统计落在每个 ...