把动态扩展做成基础设施: 基于 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 的目标可以归纳为四点:
- 主程序不重启或少改代码,就能安装、加载、启动、停止和卸载业务插件。
- 每个插件拥有独立的 Spring Bean 容器,插件 Bean 之间不直接混在主容器中。
- 插件可以复用主程序已经初始化的 Service、Mapper、数据源和配置环境。
- 主程序与插件共享的 SPI/API 必须保持同一个 JVM 类型身份,避免跨 ClassLoader 强转失败。
与之对应,starter 明确不提供以下能力:
- 不提供进程级 CPU、内存、线程或网络隔离。
- 不阻止插件访问主 JVM 中的 Bean、配置和其他资源。
- 不自动提供管理后台或 HTTP 接口,Web 层的权限、审计和防重放仍由业务模块负责。
- 不替业务模块定义 Excel、报表或 EDC 等具体扩展接口。
因此,插件必须来自受信任的研发和发布链路。对于不可信第三方代码、重计算任务或需要独立故障域的能力,应优先选择独立进程或容器。
二、整体架构:两种隔离、两座桥
sundablog 插件体系同时使用了 ClassLoader 隔离和 Spring 容器隔离。

这里有两座关键的桥:
SpringPf4jRuntime:PF4J 自己创建插件主类,无法直接使用主 Spring 容器的构造器注入。starter 通过静态AtomicReference保存主容器和子容器注册表,让插件基类在启动时取得它们。PluginApplicationContextRegistry:主 Spring 容器不会反向发现子容器中的 Bean。插件启动成功后把子容器注册到这里,主程序再通过PluginBeanInvoker按插件 ID 和接口类型调用插件 Bean。
容器关系是单向的:插件子容器能向 parent 查找主程序 Bean,主容器不能天然看见插件 Bean。这也是注册表存在的原因。
三、Spring Boot 启动链路
starter 同时在 spring.factories 和 AutoConfiguration.imports 中声明了 sundablogPf4jAutoConfiguration,在当前 Spring Boot 2.7.6 环境中由自动配置加载。只有 sundablog.pf4j.enabled=true 时配置生效;该值默认就是 true。
启动过程可以拆成以下步骤:

几个实现细节值得注意:
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,初始化顺序如下:
- 将主应用
ConfigurableApplicationContext设为 parent。 - 复用主应用
Environment,让插件读取相同的 profile 和配置源。 - 将子容器 ClassLoader 设置为当前插件的
PluginClassLoader。 - 注册
ConfigurationPropertiesBindingPostProcessor,支持插件内的@ConfigurationProperties。 - 注册
getConfigurationClasses()返回的显式配置类。 - 默认扫描插件主类所在包,也可以通过
getScanPackages()覆盖。 - 在插件 ClassLoader 作为线程上下文 ClassLoader 的条件下执行
refresh()。 - 刷新成功后注册子容器,再执行
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 叠加包前缀策略,实际判定顺序为:
- PF4J 自带的强制 parent-first 规则优先。
- 命中
plugin-first-packages时由插件加载。 - 否则命中
platform-first-packages时委托主应用加载。 - 其余类继续遵守 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.version 和 plugin.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();
}
}
这是当前实现主流程的源码节选;validateArguments、buildSheets、createOutputFile 和 writeWorkbook 等私有方法未在本文展开。实际业务类还注入了多个主程序 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 |
企业发布流程、审批、集群分发 |
插件基类还提供 beforeRefresh、afterStart、beforeStop、getConfigurationClasses 和 getScanPackages 等生命周期扩展点。修改容器 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/
浙公网安备 33010602011771号