一佳技术

easy-plugin 插件化组件设计

本文档描述 easy-plugin 组件的设计与实现。该组件基于 PF4J (Plugin Framework for Java) 进行 Fat-Jar 场景的二次封装,解决 PF4J 原生假设”插件 = 外部独立 jar”而 Spring Boot Fat-Jar “所有插件打进单一可执行 jar”的矛盾。核心改造了 PF4J 的插件发现、加载、描述符解析三大环节,使 Fat-Jar 内部也能运行完整的 ExtensionPoint → Extension → ExecutionUtil 链路。


一、背景与问题

1.1 PF4J 原生的假设不适合 Fat-Jar

PF4J 原生假设Fat-Jar 实际情况矛盾
插件是外部独立 jar 文件所有插件打进单一可执行 jar插件发现机制(扫描 jar 目录)失效
插件有独立 ClassLoader插件类被 Spring Boot 主 ClassLoader 加载类加载机制失效
插件描述符从外部 jar 读取描述符 (plugin.properties) 在 classpath 资源中描述符加载路径失效

1.2 核心改造点

easy-plugin 重写了 PF4J PluginManager 的三大工厂方法:

PF4J 工厂原生实现Fat-Jar 重写
createPluginLoader()按插件路径加载独立 ClassLoaderFarJarPluginLoader → 从主 ClassLoader 取
createPluginRepository()扫描外部 jar 目录FatJarPluginRepository → 扫描 classpath 所有 plugin.properties
createPluginDescriptorFinder()从外部 jar 文件读描述符FatJarPropertiesPluginDescriptorFinder → 从 classpath 资源读

1.3 ExecutionUtil 不是 PF4J 的一部分

PF4J 原生只负责插件生命周期和扩展注册,不提供「按节点路径 + Pattern 匹配查找扩展」的高阶函数。easy-plugin 在 PF4J 之上增加了:

  • Pattern 枚举(ACCURATE / CHILDREN / DESCENDANTS / WILDCARD)
  • PluggableUtil 旁路缓存 + 节点路径过滤
  • ExecutionUtil 批量执行 / 首个匹配执行

二、组件定位与边界

定位:Spring Boot Fat-Jar 场景下的 PF4J 插件化增强框架。提供插件发现、扩展点注册、节点路径匹配、执行工具链。

边界:

做不做
PF4J Fat-Jar 三大工厂重写不实现具体业务 ExtensionPoint
ExecutionUtil 高阶执行函数不提供远程插件加载(只支持本地 Fat-Jar)
Pattern 节点路径匹配机制不做插件热部署(Spring Boot Fat-Jar 本身不支持)
PluggableUtil 扩展旁路缓存不替代 PF4J 原生 ExtensionFinder,只是旁路
SpringPf4jConfiguration 自动配置不自动扫描 Extension 实现(依赖 PF4J META-INF/extensions.idx)

约束:

  • PF4J 使用官方版本 pf4j-spring:0.8.0 + 传递的 pf4j:3.10.0;原 ZTO 内部定制版的改动,以同 FQCN 源码 shadow(org/pf4j/... 顶层包,非 io/matrix/extension)形式内置在 easy-plugin 中,运行时 classloader 优先加载本组件类,从而不再依赖任何定制 jar(详见 五、包结构与核心类 的 shadow 覆盖层)
  • PluggableUtil.extensionMap 是静态全局缓存,插件 STARTED 时填充、STOPPED 时清除
  • 类名拼写不一致:FarJarPluginLoader/FarJarPluginManager(Far)vs FatJarPath/FatJarPluginRepository(Fat)—— 历史遗留,不影响功能

三、总体架构

flowchart TD
    subgraph "插件发现 (FatJarPluginRepository)"
        A1["PathMatchingResourcePatternResolver<br/>扫描 classpath:plugin.properties"]
        A2["为每个 properties 创建<br/>FatJarPath(delegate, descriptor, pluginClass)"]
        A1 --> A2
    end

    subgraph "插件加载 (FarJarPluginLoader)"
        B1["判断 path instanceof FatJarPath"]
        B2["FatJarPath.getPluginClass().getClassLoader()<br/>直接用 Spring Boot 主 ClassLoader"]
        B1 --> B2
    end

    subgraph "插件描述符解析 (FatJarPropertiesPluginDescriptorFinder)"
        C1["FatJarPath 场景:直接返回<br/>已携带的 PluginDescriptor"]
        C2["非 FatJarPath:从 pluginClass<br/>的 ClassLoader 读取 plugin.properties"]
    end

    subgraph "PF4J 插件生命周期"
        D1["插件 STARTED"]
        D2["FatJarPluginStateListener<br/>→ PluggableUtil.cache4Plugin()"]
        D3["插件 STOPPED/DISABLED"]
        D4["→ PluggableUtil.evict4Plugin()"]
        D1 --> D2
        D3 --> D4
    end

    subgraph "扩展缓存与路由 (PluggableUtil)"
        E1["extensionMap<br/>ConcurrentMap<Class<?>, List<Wrapper>>"]
        E2["getExtension() → 默认 Pattern.DEFAULT"]
        E3["getExtensions() → 默认 Pattern.DESCENDANTS"]
        E4["Wrapper 内含 pluginId + ExtensionWrapper + 按 ordinal 排序"]
    end

    subgraph "高阶执行工具 (ExecutionUtil)"
        F1["execute(clazz, Consumer, nodes)<br/>遍历全部匹配扩展"]
        F2["executeFirst(clazz, Function, nodes)<br/>取首个匹配扩展"]
    end

    subgraph "Pattern 匹配"
        G1["DEFAULT / ACCURATE<br/>精准匹配:长度相同 + 每个元素相等"]
        G2["CHILDREN<br/>调用方 nodes+1 == 扩展 nodes"]
        G3["DESCENDANTS<br/>调用方 nodes 前缀匹配扩展 nodes"]
        G4["WILDCARD<br/>永远返回 true"]
    end

    A2 --> B2
    A2 --> C1
    C2 --> B2
    D2 --> E1
    D4 --> E1
    F1 --> E3
    F2 --> E2
    E2 --> G1 & G2 & G3 & G4
    E3 --> G1 & G2 & G3 & G4

PluggableUtil 缓存结构:

flowchart TD
    subgraph "extensionMap"
        K1["key = ExtensionPoint 接口<br/>(如 Connector.class)"]
        K2["key = ExtensionPoint 父类<br/>(如果是 abstract class)"]
    end
    subgraph "Wrapper 列表(按 ordinal 升序)"
        W1["Wrapper#1<br/>pluginId + ExtensionWrapper"]
        W2["Wrapper#2<br/>pluginId + ExtensionWrapper"]
        W3["..."]
    end
    K1 --> W1
    K1 --> W2
    K1 --> W3
    K2 --> W1
    K2 --> W2

缓存触发时机:插件 STARTED 时从 FarJarPluginManager.getExtensionWrappers(pluginId) 拉取,按 ExtensionPoint 的所有父类和实现接口(递归)归类。所以同一个 Extension 可能出现在多个 key 下(如果它实现了多个 ExtensionPoint 接口)。


四、核心 API 草图

ExecutionUtil

方法返回值说明
execute(clazz, Consumer<T>, String... nodes)int遍历所有匹配扩展,消费执行,返回执行次数
execute(clazz, Function<T,R>, String... nodes)List<R>遍历匹配扩展,收集非空返回值
executeFirst(clazz, Consumer<T>, String... nodes)boolean取首个匹配扩展执行
executeFirst(clazz, Function<T,R>, String... nodes)Optional<R>取首个匹配扩展执行并返回结果

所有方法都有带 Pattern 参数的重载版本(显式指定匹配策略)。

Pattern 枚举

public enum Pattern {
    DEFAULT,     // 同 ACCURATE
    ACCURATE,    // 精准匹配:调用方与扩展 nodes 长度相同、每个元素相等(支持 "*" 通配符)
    CHILDREN,    // 扩展 nodes 长度 == 调用方 + 1,前缀相同
    DESCENDANTS, // 扩展 nodes 长度 > 调用方,前缀相同(默认策略)
    WILDCARD;    // 永远返回 true
}

PluggableUtil

// 静态方法
Optional<T> getExtension(Class<T> clazz, Pattern pattern, String... nodes);
List<T> getExtensions(Class<T> clazz, Pattern pattern, String... nodes);
// 生命周期回调
void cache4Plugin(PluginWrapper wrapper);    // STARTED 时
void evict4Plugin(PluginWrapper wrapper);     // STOPPED/DISABLED 时

FatJarPluginManager

// 只暴露一个额外方法
List<ExtensionWrapper> getExtensionWrappers(String pluginId);
// 底层调用 extensionFinder.find(pluginId),供 PluggableUtil 缓存使用

五、包结构与核心类

easy-plugin/
└── io/matrix/extension/
    ├── ExecutionUtil.java                        # 高阶执行工具(抽象静态类)
    ├── Pattern.java                              # 节点路径匹配枚举
    ├── PluggableUtil.java                        # 扩展缓存 + 生命周期桥接
    ├── autoconfigure/
    │   └── SpringPf4jConfiguration.java          # Spring Boot 自动配置(注册 FarJarPluginManager)
    └── pf4j/                                     # PF4J Fat-Jar 重写层
        ├── FarJarPluginLoader.java               # 插件类加载(Far=Fat 拼写)
        ├── FarJarPluginManager.java              # PF4J PluginManager 三大工厂重写
        ├── FatJarPath.java                       # 路径装饰器(携带 PluginDescriptor + pluginClass)
        ├── FatJarPluginRepository.java           # 插件发现(classpath 扫描 plugin.properties)
        ├── FatJarPluginStateListener.java        # 插件生命周期 → PluggableUtil 缓存桥接
        └── FatJarPropertiesPluginDescriptorFinder.java  # 插件描述符解析
类职责文件
ExecutionUtil批量执行 / 首个匹配执行高阶函数ExecutionUtil.java
Pattern节点路径匹配策略枚举(5 值 + 通配符 *)Pattern.java
PluggableUtil扩展旁路缓存 + 生命周期回调;维护 extensionMap 静态全局缓存PluggableUtil.java
SpringPf4jConfiguration自动配置入口:注册 FarJarPluginManager + 挂 FatJarPluginStateListenerSpringPf4jConfiguration.java
FarJarPluginManagerPF4J SpringPluginManager 子类;重写三大工厂(Loader/Repository/DescriptorFinder)FarJarPluginManager.java
FatJarPluginRepository插件发现:扫描 classpath 所有 plugin.properties → 生成 FatJarPath 列表FatJarPluginRepository.java
FatJarPluginLoader插件类加载:FatJarPath 场景下直接用 pluginClass.getClassLoader()FarJarPluginLoader.java
FatJarPropertiesPluginDescriptorFinder插件描述符解析:FatJarPath 场景下直接返回已携带的 PluginDescriptorFatJarPropertiesPluginDescriptorFinder.java
FatJarPluginStateListener插件状态变化回调:STARTED → cache4Plugin,STOPPED → evict4PluginFatJarPluginStateListener.java
FatJarPathPF4J Path 装饰器:包装 delegate + PluginDescriptor + pluginClassFatJarPath.java

Shadow 覆盖层(org/pf4j,非 io/matrix 包)

以下类与官方 PF4J 同包同名(FQCN 相同),源码直接放于 easy-plugin/src/main/java/org/pf4j/...。因 JVM 双亲委派 + 类路径顺序「先加载者胜出」,运行时 easy-plugin 的类优先于官方 jar 中的同名类,从而把原 ZTO 定制版的逻辑内置,彻底摆脱定制 jar:

类相对官方改动
Extension.java新增 nodes() / supplements() 注解属性,支撑场景节点路由
ExtensionDescriptor.java新增 nodes 字段 + (int, Class, String[]) 三参构造;是官方 3.8.0(public 字段 ordinal/extensionClass)的超集,故二进制兼容
AbstractExtensionFinder.javacreateExtensionWrappers 依据 supplements() 为补充节点生成多个 ExtensionWrapper
SpringPluginManager.java@PostConstruct init() → InitializingBean.afterPropertiesSet(),避免依赖 javax.annotation
FileUtils.javaisZipFile 不再校验 Files.isRegularFile

shadow 铁律:只能做加法补丁(新增字段/方法/注解),不得覆盖或删改官方已有成员——否则官方其它未 shadow 的类按编译期契约调用会触发 NoSuchMethodError/NoSuchFieldError。官方 ExtensionDescriptor 恰好用 public 字段而非 getter,是本方案能干净扩展的关键前提。 现状:pf4j-spring / pf4j 均已回退官方(0.8.0 / 3.8.0),「pf4j-spring 版本非官方」的历史问题已移除。

依赖:

依赖scope版本说明
pf4j-springcompile0.8.0PF4J 官方 Spring 集成
pf4j(pf4j-spring 传递)compile3.8.0PF4J 官方核心;nodes/supplements 逻辑由源码 shadow 提供
spring-boot-autoconfigureprovided父 POM自动配置注解

六、关键设计决策

决策内容理由
插件发现用 classpath 扫描FatJarPluginRepository 扫描 classpath*:plugin.propertiesFat-Jar 无外部 jar,所有插件类/资源都在主 jar 内
类加载用主 ClassLoaderFarJarPluginLoader 不创建独立 ClassLoaderSpring Boot Fat-Jar 机制下所有类已被主 ClassLoader 加载
PluggableUtil 旁路缓存不走 PF4J 原生 ExtensionFinder,插件 STARTED 时主动拉取原生 ExtensionFinder 没有 ordinal 排序和节点路径过滤
extensionMap 静态全局ConcurrentMap<Class<?>, List<Wrapper>>,按 ExtensionPoint 父类/接口递归归类同一个 Extension 实现多个 ExtensionPoint 时可被多处查到
缓存生命周期绑定插件状态FatJarPluginStateListener 回调 cache4Plugin / evict4Plugin插件禁用时扩展自动从缓存清除
Pattern 默认策略差异getExtensions 默认 DESCENDANTS(向上找所有祖先扩展),getExtension 默认 DEFAULT批量执行通常要全链路,首个匹配要精准
nodes 用通配符 *扩展声明的 nodes 中出现 * 时该位置任意匹配方便声明通用扩展

七、已知问题与待改进

问题严重度说明
Far/Fat 拼写不一致低FarJarPluginLoader/FarJarPluginManager vs FatJarPath/FatJarPluginRepository,历史遗留
shadow 依赖 classpath / fat-jar 排序中org/pf4j shadow 类靠类路径顺序覆盖官方 jar;fat-jar 需保证 easy-plugin.jar 在 BOOT-INF/lib 中排在 pf4j 之前(一般 direct dep 在前,建议实包冒烟验证)
Extension 实现必须显式 implements 接口高PF4J 反射检查直接接口,只靠父类继承会找不到扩展——USAGE.md 已警告
必须有无参构造函数高PF4J 反射实例化,无参构造是硬约束
没有插件热部署低Fat-Jar 模式本身不支持热替换,需要重启应用
META-INF/extensions.idx 自动生成低PF4J 编译时注解处理器生成;如果没有则扩展不被发现