Grtsinry43的前端札记 | 大三技术成长实录 & 学习笔记 | 「岁月漫长,值得等待」
文章
技术学习

使用 pf4j-spring 实现插件注入和 api 接口动态注册 | 插件系统构建(上)

2025年1月25日 12 分钟阅读 浏览 0 喜欢 0 评论 0

哪个男孩不想拥有一个自己的插件系统?(x)话说回来,这个我已经计划好久了,不过一直在学其他的东西,刚开始想的是微服务+注册中心,不过对个人来说写这个实在太消耗精力了。在群里大佬的推荐下我感觉可以用 pf4j 试一试,不过毕竟是新玩意儿,加上…它文档是真的抽象,更不必说中文互联网从头翻到尾都是依托答辩,所以最后的效果是经过了大半天的研究,断点,看源码,然后尝试归纳出来的,这东西本来以为前端注册和加载组件是难点,结果是熟悉框架(说实话用 Spring 和反射自己实现都没这么痛苦)…不过既然已经弄出来了,那这篇文章可能是你能找到的最详细的一步一步手把手整合框架的教程了。

(虽然我感觉是因为我太菜了 😭 搞这个才这么麻烦,大佬轻喷,有更好的想法恳请赐教)

[!NOTE] 长文及分篇提示

这篇文章是整个插件系统构建环节的第一步(插件实例注入,Restful API 管理,动态加载远程组件),全文较长因此分开书写,再加上本来后两部分还在 dev 分支,还没有深入测试

了解 pf4j

pf4j,也就是 Plugin Framework for Java,它足够轻量,足够具有拓展性,官方的介绍是:

PF4J is an open source (Apache license) lightweight (around 100 KB) plugin framework for java, with minimal dependencies (only slf4j-api & java-semver) and very extensible (see PluginDescriptorFinder and ExtensionFinder).

官方文档也是很好心的给出了 4 个扩展和非常简洁的一段使用方法,大意就是确定一个接口(抽象类)作为扩展接口,然后将扩展类打上 @Extension 注解

No XML, only Java.

You can mark any interface or abstract class as an extension point (with marker interface ExtensionPoint) and you specified that an class is an extension with @Extension annotation.

集成框架

在 pf4j-spring 的 github 仓库提供了一个示例(How to use),我们沿着这个思路分析其用法,于是尝试出了注册方法,那咱们就开始吧:先把 github 仓库贴上:pf4j-spring

安装依赖

首先到 Maven 中央仓库找到最新版本:pf4j-spring/0.9.0

复制到 pom.xml 即可:

java
<dependency>
    <groupId>org.pf4j</groupId>
    <artifactId>pf4j-spring</artifactId>
    <version>0.9.0</version>
    <scope>provided</scope>
</dependency>

我们首先为其创建配置文件:

java
package com.grtsinry43.grtblog.config;

import org.pf4j.spring.SpringPluginManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author grtsinry43
 * @date 2025/1/25 12:39
 * @description 热爱可抵岁月漫长
 */
@Configuration
public class PluginConfig {
    @Bean
    public SpringPluginManager pluginManager() {
        // 这里使用 SpringPluginManager
        return new SpringPluginManager();
    }
}

这里的 SpringPluginManager,稍微追一下源码可以发现,其首先继承了默认管理接口(因为这本身也是一个扩展),提供了应用程序上下文及其 get set,提供一个 @PostConstruct 方法,初始化后会调用加载和运行插件,通过 BeanFactory 将应用上下文中的类信息实例化并注入,以实现插件的效果。

看一下源码

[!WARNING] 提示

这里与 DefaultPluginManager 不一样的,其提供了初始化行为,无需我们手动执行 runner

配置内容

我们不难在 DefaultPluginManager 中发现,可以在 application.yml 中指定插件目录

yml
pf4j:
  pluginsConfigDir: plugins

我们也可以同时开一下调试级别日志方便我们进行排查:

yml
logging:
  level:
    org:
      pf4j: DEBUG

创建扩展接口

按理来说,我们创建一个统一扩展接口,插件继承接口实现自定义方法,再提供主类集成 pf4j 的接口实现生命周期方法就可以圆满解决了,不过当你想在新项目中引用原来的扩展接口的时候,如何引用就成了问题…

先不着急,我们可以先写一下扩展接口,比如我命名为 BlogPlugin

java
package com.grtblog;

import org.pf4j.ExtensionPoint;
import org.springframework.http.ResponseEntity;

/**
 * @author grtsinry43
 * @date 2025/1/25 12:38
 * @description 热爱可抵岁月漫长
 */
public interface BlogPlugin extends ExtensionPoint {
    /**
     * 用于插件的初始化(加载插件)
     */
    void apply();
    /**
     * 返回插件的 REST API 路径
     */
    String getEndpoint();

    /**
     * 处理请求逻辑
     */
    ResponseEntity<?> handleRequest();
}

问题来了,

如果你选择复制粘贴,那么 jvm 会说:我看这两个接口也不一样啊,这加载的类不是一模一样也可以说毫不相关了()

如果你选择创建一个共享包,那么就是以下的步骤:

创建一个新的 maven 项目,src/main/java/包名 还是方才相同的内容,然后去安装好依赖:

xml
<dependencies>
        <dependency>
            <groupId>org.pf4j</groupId>
            <artifactId>pf4j</artifactId>
            <version>3.13.0</version>
        </dependency>
        <dependency>
            <groupId>org.pf4j</groupId>
            <artifactId>pf4j-spring</artifactId>
            <version>0.9.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>3.4.0</version>
        </dependency>
    </dependencies>

共享包的优雅解决

插件系统肯定要人人开发,方便贡献嘛,因此我们最好还是打包并开放,而 Github Packages 便成为了不错的选择:

还是先看文档捏:使用 Maven 发布 Java 包

首先在你的 pom.xml 中添加发布包相关信息:

xml
<distributionManagement>
    <repository>
         <id>github</id>
         <name>GitHub Packages</name>
         <url>https://maven.pkg.github.com/USER/REPO</url>
    </repository>
</distributionManagement>

发布包的全过程都是由 Github Actions 实现的,我们可以创建 .github/workflows/deploy.yml(deploy 就是个名字,什么都可以)

yml
name: Publish to GitHub Packages

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up JDK 17
        uses: actions/setup-java@v2
        with:
          java-version: '17'
          distribution: 'adopt'

      - name: Build with Maven
        run: mvn clean install

      - name: Publish to GitHub Packages
        run: mvn --batch-mode deploy
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

[!NOTE] 提示

其中的 permissions 十分重要,要赋予当前 token packages 的写入权限,这样我们才能发布包

add commit push 一气呵成,不出意外的话,你的包就发布成功了

发布之后看到

如果这个包是私有读的,我们需要配置好自己的 token 以获得权限,在你的家目录 .m2/settings.xml(没有就自己创建)

xml
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
    <servers>
        <server>
            <id>github</id>
            <username>name</username>
            <password>your_token</password>
        </server>
    </servers>
</settings>

于是我们在项目中安装,我们就可以继续啦。

创建插件

我们顺利到了创建插件这一步,还是创建新的 maven 项目,安装需要的依赖(与上一个几乎相同)

首先我们创建方法实现类:我们用一个网易云请求举例(其实根本没请求,就起了个名字)

java
package com.qwerty;

import com.grtblog.BlogPlugin;
import org.pf4j.Extension;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;

/**
 * @author grtsinry43
 * @date 2025/1/25 13:20
 * @description 热爱可抵岁月漫长
 */
@Extension
public class MyNetEasePlugin implements BlogPlugin {

    @Override
    public void apply() {
        System.out.println("MyNetEasePlugin applied!");
    }

    @Override
    public String getEndpoint() {
        return "/netease/playlist";
    }

    @Override
    public ResponseEntity<?> handleRequest() {
        String playlistUrl = "https://****"; 
        RestTemplate restTemplate = new RestTemplate();
        String response = restTemplate.getForObject(playlistUrl, String.class);
        return ResponseEntity.ok(response);
    }
}

然后再提供插件主类以实现生命周期方法和注册应用上下文

java
package com.qwerty;

import org.pf4j.PluginWrapper;
import org.pf4j.spring.SpringPlugin;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

/**
 * @author grtsinry43
 * @date 2025/1/25 13:20
 * @description 热爱可抵岁月漫长
 */
public class NetEasePluginMain extends SpringPlugin {

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

    @Override
    protected ApplicationContext createApplicationContext() {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
        context.scan("com.qwerty");
        context.refresh();
        return context;
    }

    @Override
    public void start() {
        System.out.println("NetEasePluginMain started!");
    }

    @Override
    public void stop() {
        System.out.println("NetEasePluginMain stopped!");
    }
}

这里的创建上下文挺重要的,我试了好久,直接包注册比较稳妥,当然按照官方方法也没有问题。

下面,为了标识这个包的信息,我们创建 resources/plugin.properties

properties
plugin.id=sample-plugin
plugin.version=1.0.0
plugin.provider=YourName
plugin.description=A sample plugin for testing PF4J
plugin.class=com.qwerty.NetEasePluginMain

这里提供的要是继承了 SpringPlugin 的主类而不是接口实现类。

由于其要求 properties 文件在 jar 包根目录,我们简单配置一下即可

xml
<build>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <includes>
                    <include>plugin.properties</include>
                </includes>
            </resource>
        </resources>
    </build>

于是便可以大笔一挥,打包咯:

shell
mvn clean package

扩展接口

其实到这步,通过 debug 日志你就可以发现插件已经通过豆工厂(bushi)成功实例化了,不过还记得我们之前提供的 getEndPoint 方法吗,我们为了让其调用更方便,于是便将其动态注册到 SpringMVC 端点上

由于我们根据应用上下文来注册插件,同样地,我们也可以在 ContextRefreshedEvent 触发时动态修改当前的路由,于是我们可以创建 PluginRouteRegistrar,我们分析一下它要完成什么:

首先要有之前依赖注入的插件管理示例,要有应用上下文获取,触发时的处理,我们让其实现 ApplicationContextAware, ApplicationListener<ContextRefreshedEvent>,于是就有了:

java
package com.grtsinry43.grtblog.runner;

import com.grtblog.BlogPlugin;
import org.pf4j.spring.SpringPluginManager;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.Set;

/**
 * @author grtsinry43
 * @date 2025/1/25 12:57
 * @description 动态注册插件的 API
 */
@Component
public class PluginRouteRegistrar implements ApplicationContextAware, ApplicationListener<ContextRefreshedEvent> {

    private final SpringPluginManager pluginManager;
    private final RequestMappingHandlerMapping requestMappingHandlerMapping;
    private ApplicationContext applicationContext;
    private final Set<String> registeredEndpoints = new HashSet<>();

    @Autowired
    public PluginRouteRegistrar(SpringPluginManager pluginManager, RequestMappingHandlerMapping requestMappingHandlerMapping) {
        this.pluginManager = pluginManager;
        this.requestMappingHandlerMapping = requestMappingHandlerMapping;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        pluginManager.getExtensions(BlogPlugin.class).forEach(extension -> {
            try {
                String endpoint = "/plugins" + extension.getEndpoint();
                if (!registeredEndpoints.contains(endpoint)) { // Check if already registered
                    System.out.println("Registering endpoint: " + endpoint);
                    Method handleRequestMethod = extension.getClass().getMethod("handleRequest");
                    requestMappingHandlerMapping.registerMapping(
                            RequestMappingInfo.paths(endpoint).methods(RequestMethod.GET).build(),
                            extension,
                            handleRequestMethod
                    );
                    registeredEndpoints.add(endpoint); // Add to registered set
                } else {
                    System.out.println("Endpoint " + endpoint + " already registered. Skipping.");
                }
            } catch (NoSuchMethodException e) {
                System.err.println("Method handleRequest not found for extension: " + extension.getClass().getName());
                e.printStackTrace(); // Important for debugging
            } catch (Exception e) { // Catch other potential exceptions during mapping
                System.err.println("Error registering endpoint: " + e.getMessage());
                e.printStackTrace();
            }
        });

    }
}

为了避免重复注入,我们维护一个 Set,注册成功即填入。

我们重写 onApplicationEvent 方法,通过 getExtensions() 获取我们之前打注解的实现类并实例化,于是我们便可以拿到他的方法。

对于注册,我们可以用 SpringMVC 提供的 RequestMappingHandlerMapping,将端点与其方法绑定,实现了动态加载路由

总结

大体的思路就是共享类,各个插件公共实现方法,由注解找到其对应类并实例化,调用 get 方法拿到端点和方法,注册到 Spring。

但是到这里充其量只是提供了一个思路,接下来我们要实现上传,动态加载和卸载,以及前端远程加载打包的 js,最终实现想要的效果,路漫漫其修远兮,前后端还有很长的路…

分享此文
评论区在赶来的路上...