很久之前折腾的插件框架. 下面介绍一个简易实现

这个 Demo 使用 JDK 21, 用 Java Platform Module System 和 IntelliJ IDEA Build System 构建. 如果用 IDEA 以外的工具打开, 需要自行配置模块. 不用工具的话也可以自己打命令

代码: https://github.com/Erzbir/plugin-demo

这个 Demo 的目标比较简单

  • 提供插件生命周期回调
  • 支持从 jar 加载插件
  • 插件之间隔离
  • 支持热重载

不涉及依赖解析, 版本管理, 以及插件之间的协作等

并且没有插件描述文件, 直接在构造插件的时候填入 id 等必要信息

总体设计

整体分为两个模块

实现层通过 SPI 提供一个 PluginManager 的实现. 这样做便于切换不同实现

插件通过 PluginManager 管理所有操作都基于插件 id

每个插件在加载时都会创建独立的 PluginClassLoader 用于隔离和卸载

类加载原理

在启动时先启动 JVM, 之后会由 Application ClassLoader 来加载 main 启动类

在使用前会经历三个步骤: 加载, 链接, 初始化

类的生命周期

这是开始顺序而不是进行顺序, 并不是一步一步往下的. 加载, 验证, 准备, 初始化和卸载的开始顺序是确定的, 但是解析则不确定. 在某些情况下, 它会在初始化阶段之后再开始, 这是为了支持 Java 语言的运行时绑定特性

对于 "初始化" 阶段, 有六种情况必须立即对类进行初始化. 加载, 验证, 准备需在此前开始.\

Loading 阶段, JVM 完成三件事:

  1. 通过一个类的全限定名来获取此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象, 作为方法区这个类的各种数据访问入口

第一条说获取类的字节流, 也就是获取 .class 格式数据的字节码. Java 虚拟机规范 中只说了 "通过全限定类名来获取字节流", 但并未说如何获取. 可以从想到的任意一个位置, 比如文件系统的某个目录下, 某个压缩包中, 或者从网络上

加载一个类的大致过程是, 先获取了字节流, 然后通过类加载器将其转换为 JVM 可识别的类对象

在 JVM 的角度上来说, 实际上只存在两种类加载器: Bootstrap ClassLoader (在 JVM 中) 和虚拟机外的所有继承自 java.lang.ClassLoader 的类加载器

提供给开发者的就是这个 java.lang.ClassLoader, 其中有几个 defineClass 方法, 通过 defineClass 方法可以将一个类的字节流加载到 JVM 中. 这个 defineClass 方法是一个 native 方法, 由 JVM 来执行

ClassLoader 中定义类的方法:

static native Class<?> defineClass1(ClassLoader loader, 
                                    String name, 
                                    byte[] b, 
                                    int off, 
                                    int len, 
                                    ProtectionDomain pd, 
                                    String source);

static native Class<?> defineClass2(ClassLoader loader, 
                                    String name, 
                                    java.nio.ByteBuffer b, 
                                    int off, 
                                    int len, 
                                    ProtectionDomain pd, 
                                    String source);
                                    
static native Class<?> defineClass0(ClassLoader loader,
                                    Class<?> lookup,
                                    String name,
                                    byte[] b, int off, int len,
                                    ProtectionDomain pd,
                                    boolean initialize,
                                    int flags,
                                    Object classData);

双亲委派

在开发人员的角度来说, 存在三层类加载器:

  • Bootstrap Class Loader: 加载 $JAVA_HOME/lib 目录, 或者 -Xbootclasspath 参数指定的路径
  • Platform Class Loader: 用于加载 Java 平台模块中非核心但对应用可见的类库, 比如 java.sql, java.logging 等模块的类
  • Application Class Loader: 这个也叫做系统类加载器, 负责加载用户 classpath 的类库

双亲委派就是子加载器先交给父加载器加载, 当父加载器无法加载时, 再由自己尝试加载

这样的设计减少了核心类被重复定义的风险, 也提升了类型一致性和安全性

不同加载器加载的类即使名字一样, 也不是同一个类

BootstrapClassLoader
    ↑
PlatformClassLoader
    ↑
ApplicationClassLoader(SystemClassLoader)
    ↑
User Custom ClassLoader

双亲委派模型实现:

public class CustomClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 检查有没有被这个类加载器加载过
        Class<?> c = findLoadedClass(name);

        if (c == null) {
            try {
                // 委托给父类加载器加载
                ClassLoader parent = getParent();
                if (parent != null) {
                    c = parent.loadClass(name);
                } else {
                    c = findSystemClass(name);
                }
            } catch (ClassNotFoundException ignore) {
                // 父类加载器没有加载到这个类
            }
            if (c == null) {
                // 通过自身加载
                c = findClass(name);
            }
        }
        if (c == null) {
            // 最终也没有加载到这个类, 抛错
            throw new ClassNotFoundException(name);
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

如果用于加载插件, 需要打破双亲委派, 优先从插件自己的 jar 里加载类, 而不是先交给父加载器

原因是: 如果严格遵循双亲委派, 插件里的 com.test.A 会先交给父加载器去找. 父加载器如果在宿主 classpath 上找到了同名类, 就直接返回宿主的版本, 插件自己 jar 里的类文件反而被忽略了

所以可以会这么写:

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 先查找是否被此类加载器加载过
        Class<?> loaded = findLoadedClass(name);
        if (loaded != null) {
            if (resolve) {
                resolveClass(loaded);
            }
            return loaded;
        }

        // 如果 Java 自带的系统类或是插件框架的依赖, 交给父类加载器
        if (name.startsWith(JAVA_PACKAGE_PREFIX)
                || name.startsWith(JAVAX_PACKAGE_PREFIX)
                || name.startsWith(PLUGIN_PACKAGE_PREFIX)) {
            ClassLoader parent = getParent();
            if (parent == null) {
                throw new ClassNotFoundException(name);
            }
            return parent.loadClass(name);
        }

        // 插件自身类 -> 本 ClassLoader
        Class<?> c = findClass(name);
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

插件加载原理

利用 Java 运行时可以动态加载类的特性, 就可以把字节码文件当作插件在运行时加载进来

一个 Jar 包, 一个 ZIP 包, 或是一堆 class 文件, 都可以成为一个插件. 但不管怎样, 都必须要有一个插件主类来加载插件实例以及执行插件的逻辑

为了简单方便, 自定义类加载器可以直接继承自 URLClassLoader, 只需要调用 addURLaddFile 就可以直接加载所有 class 了, 或者也可以不继承 URLClassLoader 自己 defineClass. 继承 URLClassLoader 来实现的好处是可以自动加载目标类所引入的依赖

这里需要注意一个问题. 继承自 URLClassLoader 时, 在 loadClass 里调用 findClass, 如果是一个新的类, 会尝试去 defineClass. 这时候会在 defineClass 过程中解析它引用了哪些类, 然后递归调用 loadClass. 所以在类加载时需要让非插件本身的类交给系统类加载器或父类加载器, 比如 java.lang.System 以及框架依赖等

在一个插件打包时可能包含插件框架的依赖, 这部分应该交由系统类加载器加载, 而不是由加载插件类的类加载器重复加载, 因为这些类必须是唯一的. 在不包含插件框架依赖的情况下, 如果尝试自己加载, 这个自定义类加载器肯定是加载不到这个类的. 所以不管怎样, 自定义的类加载器不能加载这些类

通过类加载器加载插件主类, 并将这个插件的所有 class 加载进来, 就可以实例化这个插件主类, 调用生命周期回调, 就可以开始执行插件的自定义逻辑

在每次加载插件的时候都使用一个新的类加载器, 这样可以把插件隔离开, 也可以在卸载插件时卸载这个插件的所有类. 从 JVM 中卸载类是 GC 的工作, 需要确保一个类的所有对象都没有强引用, 并且加载它的类加载器也没有强引用

不是自定义的类加载器加载的类无法被 JVM 卸载

加载流程大致如下:

  1. 读取 jar
  2. 找到插件主类
  3. 使用自定义 ClassLoader 加载主类
  4. 实例化插件
  5. 注册到管理器

插件约定

给插件一些约定会更方便操作和编写, 比如:

  • 生命周期
  • 实现标准插件接口的主类
  • 一个唯一 id
  • 插件主类通过 SPI 的方式, 将类路径声明在 SPI 配置文件中
  • 插件主类实例由插件自己提供, 必须是 static 的, 名称叫 INSTANCE 的字段

还可以规定实例化主类的方式, 以及版本或者依赖等额外信息

生命周期

根据需求给插件定义生命周期, 比如下面四个

  • 加载成功时触发
  • 启用时触发
  • 禁用时触发
  • 卸载时触发

编写插件时需要考虑哪些逻辑放到哪个生命周期里, 并且应该在禁用的时候把插件逻辑关掉, 在卸载的时候做清理

插件接口

定义了四个生命周期回调:

  • onLoad - 加载成功时触发
  • onEnable - 启用时触发
  • onDisable - 禁用时触发
  • onUnload - 卸载时触发

插件描述直接用 PluginDescription 通过构造器的方式在实现插件主类的时候声明 id 等必要信息

直接将插件描述填到构造器中实际上是偷了一个懒, 这样做不需要去解析文件

使用 Java 开发的插件应该继承 JavaPlugin , 未来可以有其他形式比如 KotlinPlugin

插件加载器

只实现了加载 .jar 插件形式的 PluginLoader: FatJarPluginLoader . 加载 .class 或其他的原理一样

由于插件主类声明的时候直接使用了 SPI 的方式, 所以实际上可以使用 Java 提供的 SPI Loader 来直接加载插件主类

FatJarPluginLoader 中用于加载插件中的 class 的是 PluginClassLoader , 本质上是一个 URLClassLoader , 但重写了 loadClass 方法使其打破双亲委派

在这个 PluginClassLoader 中, 将 Java 系统类插件框架本身的类 都交给了父类加载器. 如果不这么做, 由于是直接继承 URLClassLoader 实现的类加载, 其会在 defineClass 的时候递归加载引入的类 (import 导入的类). 其中有大量类都是重复的或者说是环境中自带的, 不希望这些已经被加载过的类再加载一次

这个 demo 为了简单实现插件之间的隔离, 在每次加载的时候都是为其构造一个新的类加载器, 所以加载完成后需要把加载好的插件和这次使用的类加载器使用 PluginLoadResult 一起返回

插件管理器

实现一个 PluginManager 是整个框架的核心

PluginManager 中将涉及到:

  • 插件解析
  • 插件类加载
  • 插件注册
  • 插件生命周期管理

PluginManager 接口在 demo.plugin.api 模块中, 可以通过这个接口操作插件的生命周期, 比如:

  • loadPlugin
  • enablePlugin
  • unloadPlugin

demo.plugin.core 模块通过 SPI 的方式提供了一个 JavaPluginManager 实现, 此实现负责加载 JavaPlugin

这里作为演示, 实现的 PluginManager 默认从运行目录下的 ./plugins 加载

如果需要支持多种位置, 可以抽象出一个 PluginSource 来加载某个 URI 上的插件

JavaPluginManager 内部, 会将加载完成的插件包装进 PluginWrapper 后进行注册, 并且内部对插件的操作都由 PluginWrapper 委托完成

这是一种更有扩展性和安全性的做法, 调用某插件的生命周期回调时有可能会抛错导致整个程序崩溃, 这并不是插件框架预期的结果, 所以在其内部可以通过默认以及自定义的错误处理器来处理这些错误, 并且也可以将这些错误传递给 PluginManager 或是进一步传递. 并且这个 PluginWrapper 维护了插件的状态, 实现了具体的生命周期

在这个 demo 中, 四个生命周期对应内部维护的状态机, 流转顺序为 CREATED → LOADED → ENABLED ⇄ DISABLED → UNLOADED

基本执行流程是这样: 插件管理器加载 .jar 插件 -> 插件加载器从这个 Jar 包获取插件主类实例 -> 插件管理器将这个实例包装成 PluginWrapper -> 将包装的插件和加载这个插件的类加载器注册到内部的容器中

使用示例

首先需要打包一个插件, 新建一个项目或者模块 demo.plugin.test, 添加对 demo.plugin.api 模块的 provided 依赖 (这是对于构建工具来说的, 对于模块系统来说就是 requires static)

demo.plugin.test 中实现了一个用来测试的插件 TestPlugin

实现主类之后需要添加 SPI 配置文件 com.demo.plugin.Plugin

然后可以使用 IDEA 构建 Jar 包的功能, 将 demo.plugin.test 构建打包成一个 Jar

或者使用命令:

# 编译 demo.plugin.api 模块
javac -d out/module --module-source-path api/src -m demo.plugin.api

# 编译 demo.plugin.test 模块
javac -d out/module --module-source-path plugin-test/src --module-path out/module/demo.plugin.api -m demo.plugin.test

# 打包插件, 将输出的 test-plugin.jar 放到 plugins 文件夹
cp -r plugin-test/resources/* out/module/demo.plugin.test && jar --create --file plugins/test-plugin.jar -C out/module/demo.plugin.test .

打包好插件了之后, 将插件复制到 ./plugins 文件夹, 可以在 demo.plugin.usage 模块进行测试框架

这里不要在 JUnit 中运行, 不能观察到类卸载

使用 IDEA 可以直接运行. 如果没有 IDEA, 可以敲下面命令:

# 编译 demo.plugin.api 模块
javac -d out/module --module-source-path api/src -m demo.plugin.api

# 编译 demo.plugin.core 模块
javac -d out/module --module-source-path core/src -m demo.plugin.core --module-path out/module/demo.plugin.api

# 编译 demo.plugin.usage 模块
javac -d out/module --module-source-path usage/src -m demo.plugin.usage --module-path out/module/demo.plugin.core:out/module/demo.plugin.api

# 运行 PluginManagerTest:main
java -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -p out/module/demo.plugin.usage:out/module/demo.plugin.api:out/module/demo.plugin.core -m demo.plugin.usage/com.demo.usage.PluginManagerTest

可以看到成功运行并正确执行了插件中的打印逻辑

Screenshot2025-04-26at02.44.23

如果要观察 JVM 加载以及卸载类的行为, 加上 -verbose:class 即可

java -verbose:class -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -p out/module/demo.plugin.usage:out/module/demo.plugin.api:out/module/demo.plugin.core -m demo.plugin.usage/com.demo.usage.PluginManagerTest

这里的输出会非常长, 可以看到在卸载插件后, JVM 也将类卸载了

Screenshot2025-04-26at02.52.16

局限

  • 插件描述没有提前校验
  • 没有对插件依赖进行解析
  • 插件之间缺少协作机制
  • 实例获取方式被固定为静态 INSTANCE, 灵活性有限