由类的动态加载和AOP到插件化思想

孔子曰: 与善人居,如入芝兰之室,久而不闻其香,即与之化矣。


写在前面

  web应用的升级和改造一般都需要重启应用,作为线上的应用,重新部署不免会影响用户的使用,当然现在运维技术高速发展,已经可以实现灰度等平滑过渡方式,但总的来说还是通过重新部署的方式。
  以前接触jenkins的时候,觉得这个东西真的好用啊,持续集成,节约了我们大部分时间,后面发现jenkins优秀的不止那么一点点,它的另一个功能引起了我的注意,插件管理,通过插件功能,可以实现在线升级和安装新应用。
  所以我就想能不能实现一个类似的功能,通过基于远程web端的插件下载和更新,在不重启应用的情况下装载运行,并且提供相应的插件控制台,实现插件的输出展示等?当然可以,下面我们就来一起实现。

项目设计

  废话不多说,根据设想绘制项目体系结构图:
image

  考虑到web插件的适用性,设计为远程插件仓库,通过类似于maven的方式实现下载安装,并且项目集成的插件可以随时卸载、暂停与启用,所以,相应的UML类图可绘制:
image

  基本思路我们又了,下面我们就来开发它吧。

具体开发

  1. 下载安装
      我们把插件信息配置成json格式存在远程,类似于:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "configs": [
    {
    "active": true,
    "className": "com.frank.plugin.ServerLogPlugin",
    "id": "1",
    "jarRemoteUrl": "file:D:/site/com-frank-log-plugin-0.0.1-SNAPSHOT.jar",
    "name": "参数日志打印"
    }
    ],
    "name": "插件仓库"
    }

      通过下载插件,在服务启动的情况下加载我们定义好的jar包插件,然后把类加载至jvm中,便于调用执行,这里涉及到类的动态加载,那什么是类动态加载呢?
      动态加载指的是每个编写的java拓展名类文件都存储着需要执行的程序逻辑,这些java文件经过Java编译器编译成拓展名为class的文件,class文件中保存着Java代码经转换后的虚拟机指令,当需要使用某个类时,虚拟机将会加载它的class文件,并创建对应的class对象,将class文件加载到虚拟机的内存。
    image
      我们这里借用idea生成的类图结构看出URLClassLoader中存在一个URLClassPath类.
      通过这个类就可以找到要加载的字节码流,也就是说URLClassPath类负责找到要加载的字节码,再读取成字节流,最后通过defineClass()方法创建类的Class对象。
      从URLClassLoader类的结构图可以看出其构造方法都有一个必须传递的参数URL[],该参数的元素是代表字节码文件的路径,换句话说在创建URLClassLoader对象时必须要指定这个类加载器的到那个目录下找class文件,为了更加便捷的加载类,我们可以通过反射的方式调用其protected方法addURL来加载类。所以代码可以这样写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    File jarFile = new File(getLocalJarFile(config));
    // 从远程下载plugin 文件至本地
    if (!jarFile.exists()) {
    URL url = new URL(config.getJarRemoteUrl());
    InputStream stream = url.openStream();
    jarFile.getParentFile().mkdirs();
    try {
    Files.copy(stream, jarFile.toPath());
    } catch (Exception e) {
    jarFile.deleteOnExit();
    throw new RuntimeException(e);
    }
    stream.close();
    }
    // 将本地jar 文件加载至 classLoader
    URLClassLoader loader = (URLClassLoader) getClass().getClassLoader();
    URL targetUrl = jarFile.toURI().toURL();
    boolean isLoader = false;
    for (URL url : loader.getURLs()) {
    if (url.equals(targetUrl)) {
    isLoader = true;
    break;
    }
    }
    if (!isLoader) {
    Method add = URLClassLoader.class.getDeclaredMethod("addURL", new Class[] { URL.class });
    add.setAccessible(true);
    add.invoke(loader, targetUrl);
    }
    // 初始化 Plugin Advice 实例化
    Class<?> adviceClass = loader.loadClass(config.getClassName());
    if (!Advice.class.isAssignableFrom(adviceClass)) {
    throw new RuntimeException(
    String.format("plugin 配置错误 %s非 %s的实现类 ", config.getClassName(), Advice.class.getName()));
    }
    adviceCache.put(adviceClass.getName(), (Advice) adviceClass.newInstance());
  2. 插件启用
      在下载安装阶段我们已经通过类的动态加载把插件类加载至jvm中,但是要实现对功能调用和切入,必须得使用AOP的方式。先来看看准备好的一个小插件:

    1
    2
    3
    4
    5
    6
    7
    public class ServerLogPlugin implements MethodBeforeAdvice {
    public void before(Method method, Object[] args, Object target) throws Throwable {
    String result = String.format("%s.%s() 参数:%s", method.getDeclaringClass().getName(),
    method.getName(),Arrays.toString(args));
    System.out.println(result);
    }
    }

      这里用到了AOP,什么是AOP呢?
      AOP是Spring提供的关键特性之一。AOP即面向切面编程,是OOP编程的有效补充。使用AOP技术,可以将一些系统性相关的编程工作,独立提取出来,独立实现,然后通过切面切入进系统。从而避免了在业务逻辑的代码中混入很多的系统相关的逻辑——比如权限管理,事物管理,日志记录等等。这些系统性的编程工作都可以独立编码实现,然后通过AOP技术切入进系统即可。从而达到了 将不同的关注点分离出来的效果。
      插件类实现了MethodBeforeAdvice接口,这个接口是aop的接口Advice的继承,它是一种前置增强,定义我们的插件在所切入的方法之前执行,能够达到在service方法调用之前打印其传入参数。所以我们在启用插件的时候就是去开始aop的切入,具体的实现方法入下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!-- 接入系统aop配置 -->
    <!-- 插件工厂配置 -->
    <bean id="pluginFactory" class="com.frank.plugin.DefaultPluginFactory" />
    <aop:config>
    <aop:aspect id="aspect" ref="pluginFactory">
    <aop:pointcut id="point" expression="execution(* *.*(..))" />
    <aop:before method="doBefore" pointcut-ref="point" />
    </aop:aspect>
    </aop:config>

      DefaultPluginFactory中的启用插件的方法实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    PluginConfig config = configs.get(pluginConfigId);
    Arrays.stream(applicationContext.getBeanDefinitionNames())
    .map(applicationContext::getBean)
    .filter(o -> o!=null).filter(o -> (o instanceof Advised))
    .filter(o -> findAdvice(config.getClassName(), (Advised) o) ==null)
    .forEach(o -> {
    Advice advice;
    try {
    advice = buildAdvice(config);
    ((Advised) o).addAdvice(advice);
    } catch (Exception e) {
    throw new RuntimeException("启用失败", e);
    }
    });
    try {
    config.setActive(true);
    storeConfigs();
    } catch (IOException e) {
    // TODO 需要回滚已添加的切面
    throw new RuntimeException("启用失败", e);
    }
  3. 停用、卸载
      停用和启用插件方法相反,只需移除相关插件类的切面通知即可。卸载判断是否被启用,如果启用则停用后再删除对应插件,代码比较简单,不做阐述。

最终效果

  本文实现的插件系统作为测试使用,功能模块比较简单,有很多不完善的地方,最后实现的功能入下:
image
image

写在后面

  经过本次实践,进一步加深了对类的加载和aop的理解,后面了解到,插件这种类似的功能在安卓开发上有应用,安卓中通过动态代理的方式Hook系统服务,实现各种特定的功能,当然它的做法和实现复杂的多,但是我想他也离不开动态加载等。
  最后关于这web插件的扩展,其实有很多种方式,可以做成平台,也可以在自己公司搭建通用化组件,附上设想的插件体系业务架构:
image