找回密码
 立即注册
首页 业界区 业界 Spring Boot Pf4j模块化能力设计思考

Spring Boot Pf4j模块化能力设计思考

辜酗徇 3 天前
前言

上一篇文章我们浅尝辄止模块化整体概览,所谓模块化基础设施则是基于Pf4j二次封装,我们具体到底要实现哪些,本篇我们从Pf4j开始讲解,我们深挖细节。
模块化基础设施

Pf4j官方有基本介绍以及对整个框架的架构等等,我们不再浪费口舌。如下是通过AI总结Pf4j的能力所导出的时序图,基本上算是那么回事,给不了解Pf4j能力的童鞋留个基本印象
1.png

基于上述Pf4j的基本能力概括(启动、停止、热加载/卸载、销毁、扩展点),我们先抛开封装涉及Web应用的基础能力,我们再来思考一个问题:模块化框架还应具备哪些能力?以此再对照深入P4fj实现细节确认是否能扩展增强或者对底层复杂实现进行简化等等,如下我们一一抽丝剥茧且化繁为简以及直接给出若不支持如何扩展实现(注:pf4j既支持jar包也支持将jar打包成zip,我们仅以原始jar包为例讲解,zip的jar包无非多了一步将jar包解压等等,逻辑大同小异)
插件目录自定义

2.png
  1. public static final String PLUGINS_DIR_PROPERTY_NAME = "pf4j.pluginsDir";
  2. public static final String MODE_PROPERTY_NAME = "pf4j.mode";
  3. public static final String DEFAULT_PLUGINS_DIR = "plugins";
  4. public static final String DEVELOPMENT_PLUGINS_DIR = "../plugins";
  5. protected void initialize() {
  6.         if (pluginsRoots.isEmpty()) {
  7.             pluginsRoots.addAll(createPluginsRoot());
  8.         }
  9. }
  10. protected final List<Path> pluginsRoots = new ArrayList<>();
  11. protected List<Path> createPluginsRoot() {
  12.         String pluginsDir = System.getProperty(PLUGINS_DIR_PROPERTY_NAME);
  13.         if (pluginsDir != null && !pluginsDir.isEmpty()) {
  14.             return Arrays.stream(pluginsDir.split(","))
  15.                 .map(String::trim)
  16.                 .map(Paths::get)
  17.                 .collect(Collectors.toList());
  18.         }
  19.         pluginsDir = isDevelopment() ? DEVELOPMENT_PLUGINS_DIR : DEFAULT_PLUGINS_DIR;
  20.         return Collections.singletonList(Paths.get(pluginsDir));
  21.     }
复制代码
当我们实现自定义继承PluginManager时,若我们已指定插件目录路径则按照手动指定的插件目录解析并加载插件,否则从JVM系统属性获取pf4j.pluginsDir,若已指定则按照指定路径解析并加载插件,否则默认的插件目录则为plugins或者../plugins(pf4j区分测试和生产模式,也是从JVM系统属性获取MODE_PROPERTY_NAME)。
插件与插件之间的目录自定义

3.png

我们设计的具体插件有2个层级,将插件jar包去除版本号的名称作为插件目录,其目录下放插件jar包,同时还有个lib目录,该lib目录用于存放插件的依赖,此依赖既可来源于插件引入独立依赖的mvn仓库包,也可来源于与外部对接时的私有包(不存在于mvn仓库)。 很显然,pf4j不支持(一句话概括:pf4j没有插件目录层级之分,所有插件jar包统一放到插件目录下以及依赖也统一放到插件目录下的lib目录下,在此不再做进一步的源码讲解),我们重写其方法实现
4.png

如上BasePluginRepository为pf4j提供的基本实现,同时pf4j进一步提供JarPluginRepository继承BasePluginRepository,仅仅只是作为构造函数传递插件目录路径,所以我们可以自定义继JarPluginRepository,重写上述streamFiles方法即可,该方法有2个参数,第一个则是插件目录路径,第二个则是文件类型过滤(jar)
5.png

上述我们已重写最终得到的将是具体插件jar包路径,这里我们可考虑做两方面处理,一是强校验插件目录的名称和jar包忽略版本号的名称是否完全一致,二是版本冲突处理即存在不同版本的jar包时,例如可以根据版本约定俗成的alpha、rc、release版本等规则取最新的jar包,其他各凭实际情况自由发挥。
插件独立依赖包解析

上述仅仅只是取到了具体插件jar包路径,接下来通过类加载器加载jar包以及加载依赖,首先需要讨论类加载器的事情,个人认为pf4j的类加载器考虑的很周到,设计的比较灵活,但我们实际可以稍微简化下,核心思路总结起来主要也就4点:遵循“双亲委派” 基础规则,先检查类是否已加载,避免重复加载→遵循双亲委派核心思想,优先从主应用(父类加载器)加载→插件专属类优先从插件加载(可选)→类从插件加载→自定义额外类路径兜底(可选)→最终加载失败抛异常。当然实际情况下我们可能也有其他考虑,可看看pf4j类加载器源码自行做取舍,如下自定义继承
  1. public class GJPluginClassLoader extends PluginClassLoader {
  2.     private static final Logger log = LoggerFactory.getLogger(GJPluginClassLoader.class);
  3.     // 插件专属资源
  4.     private static final Set<String> PLUGIN_FIRST_RESOURCES = Set.of(
  5.             "META-INF/extensions.idx"
  6.     );
  7.   @Override
  8.     protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
  9.         // 1. 检查是否已加载
  10.         Class<?> loadedClass = findLoadedClass(name);
  11.         if (loadedClass != null) {
  12.             return loadedClass;
  13.         }
  14.         // 2. 优先从主应用(父类加载器)加载
  15.         try {
  16.             return parentClassLoader.loadClass(name);
  17.         } catch (ClassNotFoundException e) {
  18.             log.debug("Class {} not found in parent classloader, trying plugin", name);
  19.         }
  20.         // 3. 检查是否是插件专用资源(优先从插件加载)
  21.         if (isPluginOnlyResource(name)) {
  22.             try {
  23.                 Class<?> pluginClass = super.loadClass(name, resolve);
  24.                 if (pluginClass != null) {
  25.                     return pluginClass;
  26.                 }
  27.             } catch (ClassNotFoundException ignored) {
  28.             }
  29.         }
  30.         // 4. 尝试从插件中加载
  31.         try {
  32.             Class<?> pluginClass = super.loadClass(name, resolve);
  33.             if (pluginClass != null) {
  34.                 return pluginClass;
  35.             }
  36.         } catch (ClassNotFoundException e) {
  37.             log.warn("Class {} not found in plugin, trying additional paths", name);
  38.         }
  39.         // 5. 最后从额外类路径中查找
  40.         Class<?> additionalClass = findClassInAdditionalPaths(name);
  41.         if (additionalClass != null) {
  42.             return additionalClass;
  43.         }
  44.         throw new ClassNotFoundException("Class " + name + " not found in parent, plugin or additional paths");
  45.     }
复制代码
既然我们已自定义类加载器,那么何时实例化类加载器呢,就在我们继承底层DefaultPluginManager自定义的PluginManager里面重写createPluginLoader方法
  1.     @Override
  2.     protected PluginLoader createPluginLoader() {
  3.         return new GJJarPluginLoader(this);
  4.     }
复制代码
上述我们提供继承自底层的PluginClassLoader自定义类加载器,我们开始加载jar包及其依赖,此时又得需要自定义jar包加载类继承自底层的JarPluginLoader,全部代码一贴如下:
  1. public class GJJarPluginLoader extends JarPluginLoader {
  2.     private static final Logger log = LoggerFactory.getLogger(GJJarPluginLoader.class);
  3.     private static final String LIB_DIR = "lib";
  4.     public GJJarPluginLoader(PluginManager pluginManager) {
  5.         super(pluginManager);
  6.     }
  7.     @Override
  8.     public boolean isApplicable(Path pluginPath) {
  9.         return Files.exists(pluginPath) && FileUtils.isJarFile(pluginPath);
  10.     }
  11.     @Override
  12.     public ClassLoader loadPlugin(Path pluginPath, PluginDescriptor pluginDescriptor) {
  13.         GJPluginClassLoader pluginClassLoader = new GJPluginClassLoader(this.pluginManager, pluginDescriptor, this.getClass().getClassLoader());
  14.         pluginClassLoader.addFile(pluginPath.toFile());
  15.         loadDependencyJars(pluginPath, pluginClassLoader);
  16.         return pluginClassLoader;
  17.     }
  18.     void loadDependencyJars(Path pluginPath, GJPluginClassLoader pluginClassLoader) {
  19.         // 插件 JAR 所在目录
  20.         Path pluginDir = pluginPath.getParent();
  21.         if (pluginDir == null) {
  22.             log.warn("Plugin path has no parent directory: {}", pluginPath);
  23.             return;
  24.         }
  25.         Path libDir = pluginDir.resolve(LIB_DIR);
  26.         // 1. LIB_DIR不存在,说明无依赖,忽略加载独立依赖
  27.         if (!Files.exists(libDir)) {
  28.             return;
  29.         }
  30.         // 2. 从 MANIFEST.MF 读取 Class-Path 声明的依赖
  31.         List<Path> declaredPaths = getDeclaredClassPathJars(pluginPath);
  32.         if (declaredPaths.isEmpty()) {
  33.             log.debug("No Class-Path found in MANIFEST.MF");
  34.             return;
  35.         }
  36.         // 3. 提取 Class-Path 中所有指向 lib/ 下的 JAR 文件名(标准化为文件名)
  37.         Set<String> declaredJarNames = getDeclaredJarNames(declaredPaths);
  38.         if (declaredJarNames.isEmpty()) {
  39.             return;
  40.         }
  41.         Set<String> loadedJarNames = new HashSet<>();
  42.         try (Stream<Path> stream = Files.list(libDir)) {
  43.             List<Path> libJars = stream
  44.                     .filter(Files::isRegularFile)
  45.                     .filter(path -> path.getFileName().toString().endsWith(".jar"))
  46.                     .toList();
  47.             for (Path jarPath : libJars) {
  48.                 String jarName = jarPath.getFileName().toString();
  49.                 // 只加载 Class-Path 中声明的 JAR
  50.                 if (!declaredJarNames.contains(jarName)) {
  51.                     log.warn("Ignored undeclared JAR in lib/: {}", jarName);
  52.                     continue;
  53.                 }
  54.                 // 避免重复加载
  55.                 if (!loadedJarNames.add(jarName)) {
  56.                     log.warn("Duplicate JAR in lib/: {}", jarName);
  57.                     continue;
  58.                 }
  59.                 // 使用 PF4J 标准方法添加
  60.                 pluginClassLoader.addFile(jarPath.toFile());
  61.                 log.debug("Loaded dependency JAR: {}", jarName);
  62.             }
  63.         } catch (IOException e) {
  64.             log.error("Failed to scan lib directory: {}", libDir, e);
  65.         }
  66.     }
  67.     private Set<String> getDeclaredJarNames(List<Path> declaredPaths) {
  68.         Set<String> declaredJarNames = declaredPaths.stream()
  69.                 .filter(path -> {
  70.                     try {
  71.                         return path.startsWith(LIB_DIR) ||
  72.                                 path.getFileName().toString().equals(path.toString());
  73.                     } catch (Exception e) {
  74.                         return false;
  75.                     }
  76.                 })
  77.                 .map(path -> {
  78.                     // 处理相对路径(如 "lib/xxx.jar")
  79.                     return path.getFileName().toString();
  80.                 })
  81.                 .collect(Collectors.toSet());
  82.         if (declaredJarNames.isEmpty()) {
  83.             log.debug("No lib JARs declared in Class-Path");
  84.         }
  85.         return declaredJarNames;
  86.     }
  87.     private List<Path> getDeclaredClassPathJars(Path pluginJarPath) {
  88.         try (JarFile jarFile = new JarFile(pluginJarPath.toFile())) {
  89.             Manifest manifest = jarFile.getManifest();
  90.             if (manifest == null) {
  91.                 return Collections.emptyList();
  92.             }
  93.             Attributes mainAttrs = manifest.getMainAttributes();
  94.             String classPath = mainAttrs.getValue(Attributes.Name.CLASS_PATH);
  95.             if (classPath == null || classPath.trim().isEmpty()) {
  96.                 return Collections.emptyList();
  97.             }
  98.             // 按空格分割(支持多个空格)
  99.             return Arrays.stream(classPath.trim().split("\\s+"))
  100.                     .filter(s -> !s.isEmpty())
  101.                     .map(Paths::get)
  102.                     .collect(Collectors.toList());
  103.         } catch (Exception e) {
  104.             throw new RuntimeException("Failed to read Class-Path from plugin manifest: " + pluginJarPath, e);
  105.         }
  106.     }
  107. }
复制代码
上述总结起来一句话:插件包先投入类加载器,再将插件lib目录独立依赖投入类加载器,但尤其需要注意2点涉及安全方面的考虑,一是防止路径穿越,二是在利用mvn自动化构建时将lib下所有依赖写入到JAR清单文件MANIFEST.MF(并不是随随便便放个JAR包到lib目录下我们就任意去加载,当然更重要的前置条件是当插件放到平台插件目录下时肯定要做SHA256等等一致性校验)
6.png

插件与插件相互依赖解析

pf4j以逆拓扑排序解析插件依赖,插件依赖由约定的plugin.properties中的plugin.dependencies属性定义插件依赖,若依赖插件gj.plugin.demo2和gj.plugin.demo3(可选是否带上版本号),那么:plugin.dependencies=gj.plugin.demo2@1.0.0-SNAPSHOT,gj.plugin.demo2@1.0.0-SNAPSHOT。注意多个依赖用英文半角符号隔开,同时若出现循环依赖则会抛出异常,不用担心死循环问题
7.png

插件加载异常自定义策略

多个插件加载,若某个插件出现加载异常我们可能并不希望影响其他插件的正常加载,pf4j提供了插件加载异常策略,默认是会抛出异常。
8.png

若需调整插件加载异常策略,我们在继承底层DefaultPluginManager自定义的PluginManager里面重写initialize方法,在调用父类初始化方法后重写其策略即可
  1.    @Override
  2.     protected void initialize() {
  3.         super.initialize();
  4.         // 覆盖默认设置解析依赖异常不影响其他插件正常加载
  5.         this.resolveRecoveryStrategy = ResolveRecoveryStrategy.IGNORE_PLUGIN_AND_CONTINUE;
  6.         this.configurationRepository = createConfigurationRepository();
  7.     }
复制代码
总结

还没完,留个彩蛋:pf4j默认的PluginManager对插件的相关操作非线程安全,所以请注意单个插件操作的原子性。如上对pf4j二次封装模块化大致设计思路,仅供各位参考,本文暂到此为止,感谢阅读。 

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

昨天 00:54

举报

您需要登录后才可以回帖 登录 | 立即注册