本手册指导如何使用 PF4J 与 Knotra 构建具备热加载、平滑热升级、在途调用排空(Drain)与 ClassLoader 彻底回收能力的生产级插件系统。
- 共享契约下沉:接口与配置 DTO 必须定义在独立的 contract 模块中,由宿主与插件共享 ClassLoader。
- 只导出受控工厂:插件只向外暴露
ComponentFactory,禁止将插件内部的私有类或单例实例直接注册到宿主全局上下文。 - 加载不等于挂载:加载插件 Jar 仅建立工厂目录(Catalog),具体挂载时机、拓扑与配置由宿主显式控制。
- 排空后再卸载:旧版本插件卸载前必须完成在途调用排空(Drain),确保插件
PluginClassLoader能够被 JVM 垃圾回收(GC)。
推荐使用如下三模块标准结构进行工程化组织:
my-project/
├── platform-contract/ # 1. 共享契约模块(仅包含接口与通用 DTO)
│ └── src/main/java/com/example/contract/
│ ├── PaymentGateway.java
│ └── PaymentConfig.java
│
├── payment-plugin/ # 2. 插件实现模块(打包为独立 PF4J Jar)
│ ├── pom.xml
│ └── src/main/java/com/example/payment/
│ ├── PaymentPluginProvider.java
│ └── DefaultGatewayComponent.java
│
└── host-app/ # 3. 宿主应用模块(负责运行 Knotra 运行时与插件加载器)
├── pom.xml
└── src/main/java/com/example/host/
插件必须将共享契约与 Knotra 核心包设置为 provided,避免将其重复打包进插件 Jar 中引起类加载冲突:
<dependencies>
<!-- 共享契约:由宿主提供 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>platform-contract</artifactId>
<version>${contract.version}</version>
<scope>provided</scope>
</dependency>
<!-- Knotra 核心与 SPI 依赖:由宿主提供 -->
<dependency>
<groupId>io.knotra</groupId>
<artifactId>knotra-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.knotra</groupId>
<artifactId>knotra-pf4j-spi</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>共享契约必须保持纯粹与稳定:
package com.example.contract;
public interface PaymentGateway {
ChargeResult charge(ChargeRequest request);
}配置类同样作为共享契约暴露:
package com.example.contract;
public record PaymentConfig(String channel, int timeoutMillis) {
public PaymentConfig {
if (channel == null || channel.isBlank()) {
throw new IllegalArgumentException("channel 不能为空");
}
if (timeoutMillis <= 0) {
throw new IllegalArgumentException("timeoutMillis 必须大于 0");
}
}
}契约禁令:禁止在共享契约的方法签名或返回值中使用插件私有类型、私有异常或第三方非共享类库。
插件通过实现 RuntimeComponentProvider 暴露扩展工厂:
package com.example.payment;
import org.pf4j.Extension;
import io.knotra.pf4j.spi.RuntimeComponentProvider;
import io.knotra.pf4j.spi.ExportedComponentFactory;
@Extension
public final class PaymentPluginProvider implements RuntimeComponentProvider {
@Override
public Collection<ExportedComponentFactory<?>> factories() {
return List.of(
ExportedComponentFactory.of(
PaymentConfig.class,
new ConfiguredGatewayFactory())
);
}
}public final class ConfiguredGatewayFactory implements ComponentFactory<PaymentConfig> {
@Override
public String factoryId() {
return "payment.configured";
}
@Override
public PaymentConfig normalizeConfig(PaymentConfig config) {
return new PaymentConfig(config.channel().trim(), Math.min(config.timeoutMillis(), 5000));
}
@Override
public Component<PaymentConfig> create() {
return new Component<>() {
@Override
public ComponentDescriptor descriptor() {
return ComponentDescriptor.named("payment.configured");
}
@Override
public void start(ActivationContext context, PaymentConfig config) {
PaymentGateway gateway = new RealPaymentGateway(config);
// 1. 暴露契约实现
context.provide(PaymentGateway.class, gateway);
// 2. 登记销毁逻辑:在组件停止时按 LIFO 逆序自动调用
context.lifecycle().onClose("payment-gateway", gateway::close);
}
};
}
}无状态原则:
ComponentFactory必须是无状态的,不得在工厂字段中缓存某次激活的具体业务对象;业务实例必须在start(...)期间通过ActivationContext管理。
宿主在创建 Pf4jArtifactAdapter 时显式声明共享包路径:
try (KnotraRuntime runtime = KnotraRuntime.create();
Pf4jArtifactAdapter adapter = Pf4jArtifactAdapter.create(
pluginsRoot,
runtime,
Set.of("com.example.contract"))) { // 声明共享契约包
// 异步加载插件 Jar
ArtifactSnapshot artifact = adapter.loadArtifactAsync(pluginJar)
.toCompletableFuture()
.get(30, TimeUnit.SECONDS);
// 加载成功后,工厂目录可见,但尚未挂载任何组件
assertTrue(adapter.factories().find("payment.configured").isPresent());
}从工厂目录中解析目标工厂并挂载到运行时树:
ArtifactFactoryHandle.Configured<PaymentConfig> factory = adapter.factories()
.resolve("payment.configured", PaymentConfig.class)
.orElseThrow();
// 解码配置并挂载
PaymentConfig config = factory.decodeConfig(Map.of("channel", "alipay", "timeoutMillis", 3000));
ConfiguredMountHandle<PaymentConfig> handle = factory.mount(runtime.root(), "payment-main", config);
handle.requireActive(Duration.ofSeconds(10));宿主可以通过 KnotraLoader 声明期望的组件拓扑树,Loader 会自动计算 Diff 并执行平滑增删改:
ComponentFactoryResolver resolver = Pf4jFactoryResolver.of(adapter);
try (KnotraLoader loader = KnotraLoader.owned(runtime, resolver)) {
ComponentTree desired = ComponentTree.of(
ComponentEntry.configured(
"payment.main",
FactoryRef.of("payment.configured", "1.0.0"),
Map.of("channel", "wechat", "timeoutMillis", 2000)));
// 自动对比收敛
ReconcileResult result = loader.reconcileAsync(desired)
.toCompletableFuture()
.get(30, TimeUnit.SECONDS);
result.requireConverged();
}当需要升级插件至新版本时,标准执行时序如下:
sequenceDiagram
autonumber
participant Host as 宿主应用
participant Adapter as Pf4jAdapter
participant Loader as KnotraLoader
participant Old as 旧版插件实例
participant New as 新版插件实例
Host->>Adapter: 1. 加载新版插件 Jar (v2)
Host->>Loader: 2. 提交包含 v2 的新期望树
Loader->>New: 3. 启动并激活新实例 (v2)
Loader->>Old: 4. 旧实例标记 STOPPING,停止承接新请求
Note over Old: 5. 在途调用排空 (Drain Lease)
Old->>Old: 6. 确定性 LIFO 资源清理
Host->>Adapter: 7. 卸载旧插件 Artifact (v1)
Note over Adapter: 8. 旧 ClassLoader 被 JVM GC 回收
执行代码:
// 1. 加载新版本
adapter.loadArtifactAsync(newPluginJar)
.toCompletableFuture()
.get(30, TimeUnit.SECONDS);
// 2. 升级期望树
loader.reconcileAsync(desiredV2)
.toCompletableFuture()
.get(60, TimeUnit.SECONDS);
// 3. 卸载旧版本 Artifact
adapter.unloadArtifactAsync(oldArtifactId)
.toCompletableFuture()
.get(60, TimeUnit.SECONDS);为了确保插件卸载后其 PluginClassLoader 能够被 JVM GC 彻底回收,插件与宿主必须严格遵守以下七大红线:
- 禁止静态字段持有实例:插件内部不得在
static字段中持久持有业务对象、线程池或 Class 引用。 - 禁止后台线程逃逸:组件启动的后台定时任务或线程必须登记到
context.lifecycle(),在销毁时显式 shutdown。 - 禁止将私有类暴露到宿主上下文:不得向宿主注册插件私有的 Exception、Class 或 Listener。
- 禁止未清理的 ThreadLocal:若使用
ThreadLocal,在每次请求结束时必须显式调用remove()。 - 注销第三方客户端:JMX MBean、日志 Appender、注册中心客户端必须在 lifecycle 关闭钩子中注销。
- 保持契约 ClassLoader 纯净:确认共享契约是由宿主 ClassLoader 加载,禁止将其打包在插件内部。
- 使用纯数据快照:Knotra 的快照(
RuntimeSnapshot)与失败详情(FailureInfo)均为纯数据 DTO,内部不持有 Class 引用。