哒哒网络

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

把动态扩展做成基础设施: 基于 PF4J 与 Spring 子容器的插件化实践

把动态扩展做成基础设施:sundablog 基于 PF4J 与 Spring 子容器的插件化实践

本文基于 sundablog 0.1.0-alpha 当前源码整理,运行基线为 Java 8、Spring Boot 2.7.6、PF4J 3.15.0。文中的类名、配置键、插件元数据和构建命令均来自仓库现有实现。

业务系统发展到一定阶段,经常会遇到一类矛盾:主程序要保持稳定,客户或项目的差异化逻辑却在持续增加。

以 EDC 场景为例,不同项目可能使用不同的 Excel 模板、字段映射和导入校验。如果把这些差异全部写进主应用,最终得到的往往是不断膨胀的条件分支;如果为每个差异单独部署服务,运维和调用成本又可能超过业务收益。

sundablog 的选择是把“稳定平台能力”和“可替换业务实现”分开:PF4J 负责插件发现和生命周期,Spring 子容器负责插件 Bean 的依赖注入,定制 ClassLoader 负责维持共享契约的类型一致性,starter 再把这些能力收敛成可自动配置的基础设施。

这不是一个安全沙箱,也不是微服务的替代品。它解决的是可信代码在同一 JVM 内的动态扩展问题

一、先定义边界:插件化要解决什么

这个 starter 的目标可以归纳为四点:

  1. 主程序不重启或少改代码,就能安装、加载、启动、停止和卸载业务插件。
  2. 每个插件拥有独立的 Spring Bean 容器,插件 Bean 之间不直接混在主容器中。
  3. 插件可以复用主程序已经初始化的 Service、Mapper、数据源和配置环境。
  4. 主程序与插件共享的 SPI/API 必须保持同一个 JVM 类型身份,避免跨 ClassLoader 强转失败。

与之对应,starter 明确不提供以下能力:

  • 不提供进程级 CPU、内存、线程或网络隔离。
  • 不阻止插件访问主 JVM 中的 Bean、配置和其他资源。
  • 不自动提供管理后台或 HTTP 接口,Web 层的权限、审计和防重放仍由业务模块负责。
  • 不替业务模块定义 Excel、报表或 EDC 等具体扩展接口。

因此,插件必须来自受信任的研发和发布链路。对于不可信第三方代码、重计算任务或需要独立故障域的能力,应优先选择独立进程或容器。

二、整体架构:两种隔离、两座桥

sundablog 插件体系同时使用了 ClassLoader 隔离和 Spring 容器隔离。

pf4j-overall-architecture.png

这里有两座关键的桥:

  • SpringPf4jRuntime:PF4J 自己创建插件主类,无法直接使用主 Spring 容器的构造器注入。starter 通过静态 AtomicReference 保存主容器和子容器注册表,让插件基类在启动时取得它们。
  • PluginApplicationContextRegistry:主 Spring 容器不会反向发现子容器中的 Bean。插件启动成功后把子容器注册到这里,主程序再通过 PluginBeanInvoker 按插件 ID 和接口类型调用插件 Bean。

容器关系是单向的:插件子容器能向 parent 查找主程序 Bean,主容器不能天然看见插件 Bean。这也是注册表存在的原因。

三、Spring Boot 启动链路

starter 同时在 spring.factoriesAutoConfiguration.imports 中声明了 sundablogPf4jAutoConfiguration,在当前 Spring Boot 2.7.6 环境中由自动配置加载。只有 sundablog.pf4j.enabled=true 时配置生效;该值默认就是 true

启动过程可以拆成以下步骤:

pf4j-startup-sequence.png

几个实现细节值得注意:

  • plugins-root 会转为绝对、规范化路径,并在管理器创建时自动建目录;相对路径基于进程当前工作目录。
  • system-version 传给 PF4J,用于校验标准元数据中的 plugin.requires
  • 启动目录中的插件先由 PF4J 加载,再在启动前校验 sundablog.contracts。契约不兼容的插件会被卸载。
  • ApplicationRunner 捕获初始化异常并记录 error 日志,不会主动终止主应用。这是“插件失败、主业务继续启动”的 fail-open 策略。
  • 目录自动扫描不会执行在线安装请求中的 URL 白名单、最大包大小和调用方 SHA-256 校验。换言之,plugins-root 的文件投放链路本身必须可信。

核心代码:自动加载与契约校验

自动加载的关键不在于调用 loadPlugins(),而在于把“加载”“契约校验”“启动”明确拆成三个阶段。只有校验通过的插件才允许进入启动阶段:

public ApplicationRunner pf4jPluginBootstrapRunner(
        PluginManager pluginManager,
        sundablogPf4jProperties properties,
        PluginMetadataReader pluginMetadataReader,
        PluginContractValidator pluginContractValidator,
        PluginStateStore pluginStateStore) {
    return new ApplicationRunner() {
        @Override
        public void run(ApplicationArguments args) {
            try {
                // 先加载描述信息和 ClassLoader,不立即开放插件能力。
                pluginManager.loadPlugins();

                // 契约不兼容的插件会在 startPlugins() 之前被卸载。
                validateLoadedPlugins(
                        pluginManager,
                        pluginMetadataReader,
                        pluginContractValidator);

                if (properties.isAutoStartAtStartup()) {
                    pluginManager.startPlugins();
                }

                recordLoadedPluginStates(
                        pluginManager,
                        pluginMetadataReader,
                        pluginStateStore);
            } catch (Exception ex) {
                log.error("PF4J 插件初始化失败", ex);
            }
        }
    };
}

契约校验会先复制一份插件列表快照,因为循环中调用 unloadPlugin() 会修改管理器内部集合。如果直接遍历原集合,容易出现并发修改异常或漏检:

private void validateLoadedPlugins(
        PluginManager pluginManager,
        PluginMetadataReader pluginMetadataReader,
        PluginContractValidator pluginContractValidator) {
    List<PluginWrapper> plugins =
            new ArrayList<>(pluginManager.getPlugins());

    for (PluginWrapper pluginWrapper : plugins) {
        try {
            PluginMetadata metadata = pluginMetadataReader.read(
                    pluginWrapper.getPluginPath());
            pluginContractValidator.validate(metadata);
        } catch (Exception ex) {
            log.error("插件契约校验失败,插件将被卸载,pluginId={}",
                    pluginWrapper.getPluginId(), ex);
            pluginManager.unloadPlugin(pluginWrapper.getPluginId());
        }
    }
}

这种顺序保证了“JAR 能被 PF4J 识别”与“插件可以在当前业务平台运行”是两个独立结论。前者只说明插件描述和依赖关系基本有效,后者还需要经过平台契约校验。

当前 EDC 模块还保留了旧版 Pf4jBootstrap,但默认关闭。不要同时启用 sundablog.pf4j.legacy-bootstrap-enabled=true,否则旧启动器与 starter 可能重复加载或启动插件。

四、一个插件为什么需要 Spring 子容器

PF4J 原生插件对象并不是由 Spring 创建的。单纯把 PF4J 接进 Spring Boot,只能管理插件生命周期,并不能自然获得组件扫描、依赖注入和配置绑定。

SpringPf4jPlugin 在每次插件启动时创建一个新的 AnnotationConfigApplicationContext,初始化顺序如下:

  1. 将主应用 ConfigurableApplicationContext 设为 parent。
  2. 复用主应用 Environment,让插件读取相同的 profile 和配置源。
  3. 将子容器 ClassLoader 设置为当前插件的 PluginClassLoader
  4. 注册 ConfigurationPropertiesBindingPostProcessor,支持插件内的 @ConfigurationProperties
  5. 注册 getConfigurationClasses() 返回的显式配置类。
  6. 默认扫描插件主类所在包,也可以通过 getScanPackages() 覆盖。
  7. 在插件 ClassLoader 作为线程上下文 ClassLoader 的条件下执行 refresh()
  8. 刷新成功后注册子容器,再执行 afterStart()

如果刷新或 afterStart() 失败,子容器会被关闭并从注册表移除,插件启动以异常结束。只有完整启动成功的插件 Bean 才会对主程序可见。

核心代码:启动插件并发布子容器

start() 先创建尚未刷新、尚未对外可见的子容器。只有 refresh() 成功后,才保存引用并注册到运行时注册表:

@Override
public void start() {
    ensureWrapperAvailable();
    AnnotationConfigApplicationContext context = createApplicationContext();
    String pluginId = getPluginId();
    ClassLoader originalClassLoader =
            switchThreadContextClassLoader(context.getClassLoader());

    try {
        // refresh 前仍然可以补充 BeanDefinition。
        beforeRefresh(context);
        context.refresh();

        // 只有完整 refresh 的容器才能发布给主程序。
        this.applicationContext = context;
        SpringPf4jRuntime.getPluginContextRegistry()
                .register(pluginId, context);
        afterStart(context);
    } catch (Exception ex) {
        closeQuietly(context);
        applicationContext = null;
        SpringPf4jRuntime.getPluginContextRegistry()
                .unregister(pluginId);
        throw new IllegalStateException(
                "启动 Spring PF4J 插件失败: " + pluginId, ex);
    } finally {
        restoreThreadContextClassLoader(originalClassLoader);
    }
}

子容器的创建集中处理 parent、Environment、ClassLoader 和配置绑定。这里没有立即调用 refresh(),是为了给 beforeRefresh() 和显式配置类保留修改 Bean 图的窗口:

private AnnotationConfigApplicationContext createApplicationContext() {
    ConfigurableApplicationContext parent =
            SpringPf4jRuntime.getApplicationContext();
    AnnotationConfigApplicationContext context =
            new AnnotationConfigApplicationContext();

    context.setParent(parent);
    context.setEnvironment(parent.getEnvironment());
    context.setClassLoader(wrapper.getPluginClassLoader());

    ConfigurationPropertiesBindingPostProcessor.register(context);

    Class<?>[] configurationClasses = getConfigurationClasses();
    if (configurationClasses != null
            && configurationClasses.length > 0) {
        context.register(configurationClasses);
    }

    String[] scanPackages = normalizeScanPackages(getScanPackages());
    if (scanPackages.length > 0) {
        context.scan(scanPackages);
    }
    return context;
}

这段实现还有一个重要的“发布时点”:注册表中看不到正在构建的容器。主程序只会发现已完成 Bean 创建、依赖注入和初始化回调的插件,避免业务请求调用到半初始化对象。

停止过程按相反方向释放资源:

beforeStop
  -> 调用所有 PluginResourceReleaser Bean
  -> 从 PluginApplicationContextRegistry 注销
  -> 关闭 Spring 子容器
  -> 清空插件持有的 context 引用
  -> 恢复线程上下文 ClassLoader

线程池、连接、文件句柄和三方 SDK 客户端不能依赖 ClassLoader 卸载自动回收。插件 Bean 持有此类资源时,应实现 PluginResourceReleaser 做显式清理。

停止代码把清理逻辑放在 finally 中。即使插件自己的 beforeStop() 抛出异常,资源释放、注册表注销、容器关闭和 TCCL 恢复仍然会继续执行:

@Override
public void stop() {
    String pluginId = getPluginId();
    AnnotationConfigApplicationContext context = applicationContext;
    ClassLoader originalClassLoader = switchThreadContextClassLoader(
            context != null ? context.getClassLoader() : null);
    try {
        beforeStop(context);
    } finally {
        releasePluginResources(context);
        SpringPf4jRuntime.getPluginContextRegistry()
                .unregister(pluginId);
        closeQuietly(context);
        applicationContext = null;
        restoreThreadContextClassLoader(originalClassLoader);
    }
}

子容器带来了一个实用的依赖方向:插件可以注入主程序 Bean,而事务、权限、审计和数据库写入仍留在主程序服务中。推荐让插件调用主程序提供的 Facade,而不是让插件自己维护数据源和事务组件。

五、ClassLoader:插件化最容易踩坑的地方

在 JVM 中,一个类型由“类全名 + 定义它的 ClassLoader”共同标识。即使两个 jar 中的接口字节码完全相同,只要分别由主应用和插件 ClassLoader 加载,JVM 就会把它们视为两个类型,典型报错是:

ClassCastException: xxx cannot be cast to xxx

PF4J 默认整体偏 plugin-first,这适合隔离插件私有依赖,却不适合共享业务契约。sundablog 使用 ParentFirstPackagesPluginClassLoader 叠加包前缀策略,实际判定顺序为:

  1. PF4J 自带的强制 parent-first 规则优先。
  2. 命中 plugin-first-packages 时由插件加载。
  3. 否则命中 platform-first-packages 时委托主应用加载。
  4. 其余类继续遵守 PF4J 的默认插件加载链路。

上述优先级直接体现在 shouldDelegateToParent() 中:

@Override
protected boolean shouldDelegateToParent(String className) {
    // 先保留 PF4J 对 Java、PF4J API 等类型的内置委托规则。
    if (super.shouldDelegateToParent(className)) {
        return true;
    }

    // 显式 plugin-first 的包优先留在插件 ClassLoader。
    for (String packagePrefix : pluginFirstPackages) {
        if (className.startsWith(packagePrefix)) {
            return false;
        }
    }

    // 共享框架与业务契约统一交给主应用 ClassLoader。
    for (String packagePrefix : platformFirstPackages) {
        if (className.startsWith(packagePrefix)) {
            return true;
        }
    }

    // 其余类回到 PF4J 默认的插件加载链路。
    return false;
}

这里的返回值不是“类是否存在于 parent”,而是“是否应该先委托 parent”。返回 false 后,仍由 PF4J 的 PluginClassLoader 继续完成插件自身、插件依赖和 parent 的后续查找。因此不能把它理解为简单的二选一类加载器。

默认平台优先包包括 Spring、PF4J、SLF4J、Log4j/Logback 和 com.sundablog.framework.。业务 SPI 必须由主应用显式补充,例如:

sundablog:
  pf4j:
    platform-first-packages:
      - org.springframework.
      - org.pf4j.
      - org.slf4j.
      - org.apache.logging.
      - ch.qos.logback.
      - com.sundablog.framework.
      - com.sundablog.module.edc.api.
      - com.sundablog.module.edc.plugin.

单个插件还可以通过元数据追加策略。当前导出插件的配置是:

sundablog.parent-first-packages=com.sundablog.module.edc.plugin.,com.sundablog.module.edc.
sundablog.plugin-first-packages=com.sundablog.plugin.exportformdata.

需要特别谨慎的是:当前实现中 plugin-first 的判断先于可配置的 platform-first。如果两个列表出现重叠,插件优先配置会覆盖平台优先配置,PF4J 自带的强制规则除外。因此不要把 Spring、日志 API、PF4J 或共享业务接口加入 plugin-first-packages

六、契约版本不是插件版本

PF4J 标准元数据已经包含 plugin.versionplugin.requires,但它们描述的是插件版本以及主程序版本约束,无法表达“插件依赖哪一版业务 SPI”。

starter 增加了 sundablog.contracts

sundablog.contracts=edc-formdata-export:1.0.0

主程序声明支持的版本:

sundablog:
  pf4j:
    require-plugin-contracts: true
    strict-contract-version: true
    supported-contracts:
      edc-formdata-import: 1.0.0
      edc-formdata-export: 1.0.0

默认校验规则很明确:

  • 要求声明契约时,空的 sundablog.contracts 直接失败。
  • 插件声明了平台未配置的契约,直接失败。
  • strict-contract-version=true 时只接受字符串完全相等,不包含 SemVer 范围兼容。
  • 关闭严格版本后,当前默认实现只检查契约名称存在,不再比较版本。

默认验证器只关心“契约名称和版本”,完全不依赖 EDC、Excel 或报表业务:

@Override
public void validate(PluginMetadata pluginMetadata) {
    Map<String, String> pluginContracts =
            pluginMetadata.getContractVersions();

    if (pluginContracts.isEmpty()) {
        if (properties.isRequirePluginContracts()) {
            throw new IllegalArgumentException("插件未声明平台契约版本");
        }
        return;
    }

    Map<String, String> supportedContracts =
            properties.getSupportedContracts();
    for (Map.Entry<String, String> entry : pluginContracts.entrySet()) {
        String contractName = entry.getKey();
        String pluginVersion = entry.getValue();
        String platformVersion = supportedContracts.get(contractName);

        if (!StringUtils.hasText(platformVersion)) {
            throw new IllegalArgumentException(
                    "当前平台未声明支持插件契约: " + contractName);
        }
        if (properties.isStrictContractVersion()
                && !platformVersion.equals(pluginVersion)) {
            throw new IllegalArgumentException(
                    "插件契约版本不兼容: " + contractName
                            + ",插件=" + pluginVersion
                            + ",平台=" + platformVersion);
        }
    }
}

这个实现有意保持保守:它不会猜测 1.0.1 是否兼容 1.0.0。当平台需要 SemVer 或兼容矩阵时,应替换整个 PluginContractValidator,并为版本边界增加自动化契约测试。

如果需要 1.x、版本区间或兼容矩阵,应替换 PluginContractValidator,而不是在业务代码中散落版本判断。

DefaultPluginMetadataReader 会先读 META-INF/MANIFEST.MF 主属性,再读根目录 plugin.properties,后者的同名 key 覆盖前者。当前仓库中的插件统一使用 plugin.properties 声明 sundablog 扩展元数据。

七、用仓库中的导出插件走通开发闭环

下面以现有 sundablog-plugin-exportFormData 为例。这个插件暴露 ExportFormDataTemplate,主程序通过它生成 EDC 表单数据导入模板。

1. 在 API 模块定义稳定接口

当前接口位于 sundablog-module-edc-api

public interface ExportFormDataTemplate {

    String exportFormData(String projectId,
                          String databaseId,
                          String databaseVersion,
                          List<String> coreIds);
}

接口、DTO、枚举和必要注解应放在 API 模块。避免在契约中暴露 Controller 请求对象、数据库实体和插件私有解析库。

2. 插件主类继承 SpringPf4jPlugin

public class JianfanExportFormDataPlugin extends SpringPf4jPlugin {

    public JianfanExportFormDataPlugin(PluginWrapper wrapper) {
        super(wrapper);
    }
}

PluginWrapper 构造器不能省略。基类需要从 wrapper 取得插件 ID 和 ClassLoader;无 wrapper 时,start() 会拒绝创建 Spring 子容器。

3. 把扩展实现注册为插件 Bean

现有实现与插件主类位于同一个包,默认扫描即可发现:

@Component
public class JianfanExportFormDataTemplate implements ExportFormDataTemplate {

    @Resource
    private VisitTableService visitTableService;

    @Override
    public String exportFormData(String projectId,
                                 String databaseId,
                                 String databaseVersion,
                                 List<String> coreIds) {
        validateArguments(projectId, databaseId, databaseVersion);

        String buildId = AuthenticationInterceptor.threadLocalPro.get();
        if (StrUtil.isBlank(buildId)) {
            throw new IllegalStateException("当前线程缺少项目构建 ID,无法读取访视表配置");
        }

        LinkedHashMap<String, Map<String, Object>> sheets = buildSheets(
                projectId, databaseId, databaseVersion, buildId);
        if (sheets.isEmpty()) {
            throw new IllegalStateException("当前数据库版本没有可导出的表单");
        }

        Path outputFile = createOutputFile(projectId);
        writeWorkbook(outputFile, sheets);
        return outputFile.toAbsolutePath().normalize().toString();
    }
}

这是当前实现主流程的源码节选;validateArgumentsbuildSheetscreateOutputFilewriteWorkbook 等私有方法未在本文展开。实际业务类还注入了多个主程序 Mapper,并将模板输出到系统临时目录。

4. 声明插件元数据

src/main/resources/plugin.properties 的当前内容如下:

plugin.id=sundablog-plugin-exportFormData
plugin.class=com.sundablog.plugin.exportformdata.JianfanExportFormDataPlugin
plugin.version=${project.version}
plugin.provider=sundablog
plugin.description=sundablog EDC 表单模板导出测试插件
plugin.requires=0.0.0

sundablog.contracts=edc-formdata-export:1.0.0
sundablog.parent-first-packages=com.sundablog.module.edc.plugin.,com.sundablog.module.edc.
sundablog.plugin-first-packages=com.sundablog.plugin.exportformdata.

插件模块开启了 Maven resource filtering,打包时 ${project.version} 会替换成实际版本。当前产物中该值为 0.1.0-alpha

5. 配置 Maven 依赖边界

当前插件 POM 将共享依赖设为 provided

<dependency>
    <groupId>com.sundablog</groupId>
    <artifactId>sundablog-module-edc-api</artifactId>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.sundablog</groupId>
    <artifactId>sundablog-module-edc-biz</artifactId>
    <version>${revision}</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.sundablog</groupId>
    <artifactId>sundablog-spring-boot-starter-pf4j</artifactId>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.pf4j</groupId>
    <artifactId>pf4j</artifactId>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <scope>provided</scope>
</dependency>

当前两个 EDC 插件是普通的 thin jar,产物只包含插件类和元数据,共享依赖全部由主程序提供。仓库尚未配置 shade/assembly,因此新增的插件私有三方库不会因为声明 Maven compile 依赖就自动进入插件 jar;需要携带私有依赖时,应先明确采用 shaded jar、独立 lib 目录或其他 PF4J 支持的发布布局,并补充依赖冲突验证。

还要诚实面对当前实现的过渡状态:导入、导出插件为了迁移既有逻辑,直接以 provided 依赖 sundablog-module-edc-biz,并注入主程序 Mapper/Service。这条链路可以工作,但插件与 biz 内部结构耦合较深。长期更稳妥的方向是只依赖 API 模块,通过主程序 Facade 完成事务、权限和持久化。

6. 打包、投放并启动

在仓库根目录执行:

mvn -pl sundablog-plugin/sundablog-plugin-exportFormData -am -DskipTests package

产物路径:

sundablog-plugin/sundablog-plugin-exportFormData/target/
  sundablog-plugin-exportFormData-0.1.0-alpha.jar

将 jar 放入 sundablog.pf4j.plugins-root 指定目录后重启主程序,默认会自动加载并启动。也可以通过 Pf4jPluginService#installPlugin 在线安装。

7. 主程序调用插件 Bean

指定插件 ID 调用可以避免多个插件实现相同接口时产生歧义:

String filePath = pluginBeanInvoker.invoke(
        "sundablog-plugin-exportFormData",
        ExportFormDataTemplate.class,
        template -> template.exportFormData(
                projectId, databaseId, databaseVersion, coreIds));

PluginBeanInvoker 默认要求候选 Bean 唯一:找不到会给出已注册插件列表和排查提示,找到多个会要求调用方指定插件 ID 或自行通过 getBeans 选择,不会随机挑选一个实现。

调用器本身只负责发现、唯一性校验和函数调用,不理解任何 EDC 业务:

@Override
public <T, R> R invoke(
        String pluginId,
        Class<T> beanType,
        Function<T, R> invoker) {
    validateInvoker(invoker);
    return invoker.apply(getRequiredBean(pluginId, beanType));
}

private <T> T selectOnlyBean(
        String pluginId,
        Class<T> beanType,
        List<T> beans) {
    String scope = StringUtils.hasText(pluginId)
            ? "插件 " + pluginId
            : "所有已启动插件";

    if (beans == null || beans.isEmpty()) {
        throw new IllegalStateException(
                scope + " 中未找到插件 Bean: " + beanType.getName()
                        + ",已注册插件="
                        + pluginApplicationContextRegistry.getPluginIds());
    }
    if (beans.size() > 1) {
        throw new IllegalStateException(
                scope + " 中找到多个插件 Bean: " + beanType.getName()
                        + ",请指定 pluginId 或使用 getBeans 自行选择");
    }
    return beans.get(0);
}

如果业务需要广播调用,应该显式获取 getBeans(beanType) 并自行定义顺序、异常聚合和部分失败策略;如果需要按租户、项目或导入类型路由,也应该在业务模块增加调度层,而不是把规则塞进通用调用器。

八、在线安装链路与失败回滚

DefaultPf4jPluginService 用一把 ReentrantLock 串行化 list、install、start、stop、unload 和 uninstall,避免同一 JVM 内多个管理操作并发修改 PF4J 状态。

在线安装的实际链路是:

校验请求和 URL 前缀白名单
  -> 下载到 *.downloading
  -> 原子移动到最终 jar/zip 路径(文件系统不支持时降级为普通移动)
  -> 读取插件元数据
  -> 校验文件大小和 SHA-256
  -> 校验业务契约
  -> PF4J loadPlugin
  -> 按 autoStart 决定是否 startPlugin
  -> 写入 PluginStateStore

文件名只保留最后一级路径,并且只接受 .jar.zip;同名文件已存在时会追加时间戳和序号。下载、校验、加载或启动任一步失败,服务都会尽力停止、卸载并删除本次下载文件。

核心代码:串行安装与补偿回滚

安装过程用一把进程内锁保护 PF4J 状态变化,并把每个阶段的结果保存在局部变量中,为失败补偿提供依据:

@Override
public Pf4jPluginVO installPlugin(Pf4jPluginInstallRequest request) {
    pluginOperationLock.lock();
    Path downloadedPath = null;
    String pluginId = null;
    try {
        validateInstallRequest(request);
        pluginSecurityVerifier.verifySource(request);

        downloadedPath = downloadPlugin(request);
        PluginMetadata metadata =
                pluginMetadataReader.read(downloadedPath);
        PluginPackageVerification verification =
                pluginSecurityVerifier.verifyPackage(
                        request, downloadedPath, metadata);
        pluginContractValidator.validate(metadata);

        pluginId = pluginManager.loadPlugin(downloadedPath);
        if (!StringUtils.hasText(pluginId)) {
            throw new IllegalStateException("PF4J 未返回插件 ID");
        }

        if (Boolean.TRUE.equals(request.getAutoStart())) {
            PluginState state = pluginManager.startPlugin(pluginId);
            ensurePluginStarted(pluginId, state);
        }

        PluginWrapper wrapper = getRequiredPlugin(pluginId);
        saveState(wrapper, metadata, verification, null, null);
        return buildPluginVO(wrapper);
    } catch (Exception ex) {
        rollbackInstall(pluginId, downloadedPath);
        throw new IllegalStateException(
                "下载安装插件失败: " + getRootMessage(ex), ex);
    } finally {
        pluginOperationLock.unlock();
    }
}

回滚方法根据 pluginId 和下载路径判断安装进行到了哪一步。停止、卸载和文件删除都采用尽力而为策略,避免清理异常覆盖最初的安装失败原因:

private void rollbackInstall(String pluginId, Path downloadedPath) {
    if (StringUtils.hasText(pluginId)) {
        try {
            PluginWrapper wrapper = pluginManager.getPlugin(pluginId);
            if (wrapper != null
                    && PluginState.STARTED.equals(wrapper.getPluginState())) {
                pluginManager.stopPlugin(pluginId);
            }
            if (wrapper != null) {
                pluginManager.unloadPlugin(pluginId);
            }
        } catch (Exception ex) {
            log.warn("回滚 PF4J 插件安装失败,pluginId={}", pluginId, ex);
        }
    }
    deleteQuietly(downloadedPath);
}

这是一段补偿式事务,不是 ACID 事务。尤其是插件 start() 已经调用外部系统或写入业务数据时,框架无法自动回滚这些副作用。插件自己的启动逻辑应保持幂等,并避免在 start() 中执行不可逆业务操作。

一份偏生产的配置可以写成:

sundablog:
  pf4j:
    enabled: true
    plugins-root: /opt/sundablog/plugins
    system-version: 0.1.0-alpha
    auto-load-at-startup: true
    auto-start-at-startup: true

    connect-timeout-ms: 10000
    read-timeout-ms: 60000
    max-plugin-size-bytes: 52428800
    require-sha256: true
    allowed-plugin-url-prefixes:
      - https://plugins.example.com/
      - file:/opt/sundablog/plugin-repository/

    require-plugin-contracts: true
    strict-contract-version: true
    supported-contracts:
      edc-formdata-import: 1.0.0
      edc-formdata-export: 1.0.0

    platform-first-packages:
      - org.springframework.
      - org.pf4j.
      - org.slf4j.
      - org.apache.logging.
      - ch.qos.logback.
      - com.sundablog.framework.
      - com.sundablog.module.edc.api.
      - com.sundablog.module.edc.plugin.
    plugin-first-packages: []

全部配置项及默认值如下:

配置项 默认值 当前作用
enabled true 是否启用自动配置
plugins-root plugins 插件根目录
system-version 0.0.0 PF4J 校验 plugin.requires 使用的主程序版本
auto-load-at-startup true 启动后扫描插件目录
auto-start-at-startup true 扫描加载后启动插件
connect-timeout-ms 10000 在线下载连接超时,毫秒
read-timeout-ms 60000 在线下载读取超时,毫秒
max-plugin-size-bytes 0 下载完成后的包大小上限,<= 0 不限制
require-sha256 false 在线安装是否必须提供期望摘要
allowed-plugin-url-prefixes 在线下载 URL 的字符串前缀白名单
platform-first-packages Spring、PF4J、日志、sundablog framework 平台优先包前缀
plugin-first-packages 全局插件优先包前缀
supported-contracts 平台支持的业务契约及版本
require-plugin-contracts false 是否强制插件声明业务契约
strict-contract-version true 是否要求契约版本字符串完全一致

九、可替换的扩展点

自动配置广泛使用 @ConditionalOnMissingBean,业务侧可以按需替换默认实现:

扩展点 默认实现 适合的定制场景
PluginMetadataReader DefaultPluginMetadataReader 自定义发布清单或元数据来源
PluginClassLoadingPolicyResolver DefaultPluginClassLoadingPolicyResolver 按租户、插件或依赖图生成加载策略
PluginSecurityVerifier DefaultPluginSecurityVerifier 数字签名、证书链、审批状态校验
PluginContractValidator DefaultPluginContractValidator SemVer 范围、兼容矩阵
PluginStateStore InMemoryPluginStateStore 数据库存储、审计和重启后查询
PluginBeanInvoker DefaultPluginBeanInvoker 调用指标、超时、隔离、业务路由
PluginManager SpringPluginManager 多插件目录或特殊 PF4J 行为,替换风险较高
Pf4jPluginService DefaultPf4jPluginService 企业发布流程、审批、集群分发

插件基类还提供 beforeRefreshafterStartbeforeStopgetConfigurationClassesgetScanPackages 等生命周期扩展点。修改容器 Bean 图应放在 beforeRefresh,释放资源应优先使用 PluginResourceReleaser

十、生产环境必须正视的限制

1. 完整性校验不等于可信发布

SHA-256 只能证明文件与调用方提供的摘要一致,不能证明摘要是谁发布的。当前实现没有数字签名、证书链或制品仓库身份验证。更稳妥的做法是由 CI 生成不可变制品和签名,部署侧只接受受信发布系统下发的版本。

此外,URL 白名单目前使用字符串 startsWith,不是基于 URI 的 scheme、host、port 和规范化 path 校验。配置前缀时至少保留完整协议、主机和末尾 /,并在外层网关限制重定向与出网范围。

2. 包大小在下载完成后才校验

max-plugin-size-bytes 当前在文件完全落盘后检查,下载过程没有按累计字节数提前中止。恶意或错误的大文件仍可能消耗磁盘和下载时间。在线安装还持有全局插件操作锁,慢下载期间其他生命周期操作和列表查询都会等待。

3. 启动目录是一条独立信任链

启动扫描只补做契约校验,不校验来源和调用方摘要。生产环境应把 plugins-root 设为权限受控目录,通过发布流水线写入,并在进程启动前完成签名、摘要和文件权限检查。

4. 默认状态存储不是审计系统

InMemoryPluginStateStore 重启即丢失。当前在线安装失败会回滚运行时和文件,但不会持久化一条完整的失败安装审计。生产系统应替换为数据库实现,并由业务 Web 层记录操作人、请求 ID、插件版本、摘要、来源、审批单和失败原因。

5. 生命周期与业务调用尚未做协同

生命周期管理操作在单 JVM 内串行,但 PluginBeanInvoker 调用没有与 stop/unload 建立读写锁或在途请求计数。管理员停止插件时,正在执行的业务调用可能与子容器关闭并发。对长任务插件,应先摘流、等待在途调用归零,再停止和卸载。

6. 集群节点之间没有自动一致性

ReentrantLock 和内存状态都只在单进程生效。多实例部署时,每个节点的插件目录、加载状态和操作时序可能不同。需要由部署平台做节点级滚动发布,或增加数据库期望状态、分布式协调和节点对账机制。

7. 插件 Bean 不应承担核心事务边界

插件子容器能取得主容器 Bean,但主容器的 BeanPostProcessor 不会简单地“继承”到子容器。稳定做法是让插件调用主程序中已经代理好的事务 Facade,由主程序控制事务、权限、审计和幂等;不要默认插件方法上的 @Transactional 一定按主应用规则生效。

8. 停止插件不等于卸载所有引用

线程、ThreadLocal、静态集合、JDBC 驱动、定时任务和三方缓存都可能持有插件类或 ClassLoader。除了实现资源释放接口,还应在压力测试中反复执行 start/stop/unload,并通过堆转储确认旧 ClassLoader 可以被回收。

十一、建议的演进顺序

基于当前实现,后续建设不需要一次性做成复杂平台,可以按风险排序推进。

第一优先级:补齐可信发布与可观测性。

  • 增加插件签名验证、规范化 URI 校验和下载过程流式限额。
  • 让启动目录插件也经过统一的包验证流程。
  • 用数据库实现 PluginStateStore,补齐操作审计、失败记录、指标和告警。
  • 对关键插件增加 readiness 检查;如果插件是主流程强依赖,提供可配置 fail-fast,而不是始终只记日志继续启动。

第二优先级:治理生命周期并发和集群一致性。

  • 为插件调用增加租约或在途计数,形成“摘流 -> 等待 -> 停止 -> 卸载”的闭环。
  • 将下载移出 PF4J 状态锁,只在校验后进入短生命周期临界区。
  • 建立期望状态与节点实际状态对账,保证多实例版本一致。

第三优先级:收紧契约和依赖边界。

  • 把当前插件对 sundablog-module-edc-biz 的直接依赖逐步收敛到稳定 API 和 Facade。
  • 引入 SemVer 范围、API 二进制兼容检查和插件契约集成测试。
  • 检测并拒绝 parent-first/plugin-first 重叠的核心包配置。
  • 为确需私有三方库的插件建立统一的打包和冲突扫描规范。

第四优先级:按风险升级隔离级别。

当插件开始承载不可信代码、重计算、大内存任务或强 SLA 时,同 JVM 模型已经不合适。此时应保留现有业务 SPI 思路,但把执行载体迁移到独立进程、容器或远程服务。

结语

PF4J 解决“插件怎么被发现和管理”,Spring 子容器解决“插件里的业务对象怎么被组织”,定制 ClassLoader 解决“主程序和插件如何共享同一个契约类型”。sundablog starter 的价值,是把三者组成了一条可复用的工程链路。

真正决定这套架构能否长期稳定的,不是动态加载本身,而是边界治理:哪些类型必须共享、哪些依赖允许私有、谁负责事务、插件包如何被信任、停止时如何处理在途请求、集群如何保持版本一致。

把这些边界显式化之后,插件就不再是散落在主程序外部的一组 jar,而是一种可管理、可验证、可演进的业务扩展机制。

推荐一个AI聚合平台

https://codex.sundavip.com/
posted on 2026-07-28 17:08  哒哒网络  阅读(57)  评论(0)    收藏  举报