# 扩展服务

本项目是一个接入 PF4J 框架的插件模板,任何人都可以基于它进行快速扩展

https://pf4j.org/

https://github.com/pf4j/pf4j

# 一、概述

# 1.1 扩展服务原理

扩展服务以插件形式承载到框架服务器中,以插件自己独立的classLoader加载到框架的JVM进程中,避免了Java类冲突,保证各个插件与框架之间的类不会相互污染,同时也保障了各个子模块的安全。

独立的classLoader非常重要,因为java生态圈的三方依赖非常多,依赖的版本冲突问题非常常见。各个插件、框架,彼此之间存在公共依赖的情况时常发生,通过独立的classLoader解决了插件、框架的兼容问题。

# 1.2 适用场景

扩展服务适用场景:中小型Web应用程序

不适用场景:不适合大型应用构建(大型应用使用独立服务或微服务构建),因为其能力受制于插件机制,不支持SpringBoot等大型框架。

# 1.3 插件与框架关系

框架、各个插件各自拥有独立的spring IOC容器和classloader。

插件中的所有extension均会注册到框架的IOC容器中,但插件中的普通bean并不会注册到框架IOC容器中。

插件与插件、插件与框架之间的交互,可通过FrameworkContextCapable接口(plugin、extension实现均可)获取框架的IOC容器,在通过框架IOC容器获取任何extension(不论哪个插件中定义的),或框架的普通bean。

# 1.4 插件的classloader

参考 pf4j classloader (opens new window) 类加载的次序,默认是框架优先, PluginClassLoader 使用如下次序依次尝试加载类:

  • 如果类名是 java.开头,则使用java system类加载器
  • 如果类名是org.pf4j.开头,则使用框架的类加载器
  • 尝试使用当前的插件类加载器
  • 尝试使用当前插件dependencies的其他插件类加载器加载
  • 尝试使用框架的类加载器

# 二、环境搭建和开发

# 2.1 项目结构

mapgis-boot-extension
├── mapgis-plugins                 # 插件目录,设置-Dpf4j.pluginsDir参数指向的目录
│   ├── demo-plugin                # 单体示例插件
│   ├── demo-whth-deps-plugin      # 单体带本地依赖的示例插件,依赖为demo-plugin-lib
├── mapgis-plugins-deps            # 插件依赖项
│   ├── demo-plugin-lib            # demo-whth-deps-plugin插件的依赖

# 2.2 安装依赖

插件中依赖如果已经存在于框架中,将其设置为<scope>provided</scope>,因为插件可以共享框架中已存在的依赖,如果将重复的依赖加入到插件,只会增加插件的大小。

注意如果插件中某个依赖与框架依赖同时存在,在版本不一致,这时需要在插件添加其依赖,保证插件正常运行,如果插件和框架同时存在某个依赖,插件依赖优先,类似tomcat中的webapp,这样保证不会出现类的冲突。

插件自身的依赖包括org.pf4j:pf4j、org.pf4j:pf4j-spring、org.springframework:spring-webmvc、io.swagger.core.v3:swagger-annotations、jakarta.servlet:jakarta.servlet-api、org.slf4j:slf4j-api,在插件中必须设置<scope>provided</scope>,否则插件无法加载。

# 2.2.1 依赖声明

插件必须有如下依赖:

 <dependencies>
     <!-- pf4j -->
     <dependency>
         <groupId>org.pf4j</groupId>
         <artifactId>pf4j</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
     </dependency>
     <dependency>
         <groupId>org.pf4j</groupId>
         <artifactId>pf4j-spring</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
     </dependency>
     
     <!-- api -->
     <dependency>
         <groupId>com.zondy.mapgis.psmap</groupId>
         <artifactId>mapgis-psmap-api</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
     </dependency>
     
     <!-- Spring Web -->
     <dependency>
         <groupId>org.springframework</groupId>
         <artifactId>spring-webmvc</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
	 </dependency>
     
     <!-- swagger annotations -->
     <dependency>
         <groupId>io.swagger.core.v3</groupId>
         <artifactId>swagger-annotations</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
     </dependency>

     <!--java servelet标准-->
     <dependency>
         <groupId>jakarta.servlet</groupId>
         <artifactId>jakarta.servlet-api</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
     </dependency>

     <!-- slf4j-api -->
     <dependency>
         <groupId>org.slf4j</groupId>
         <artifactId>slf4j-api</artifactId>
         <!-- 必须与框架依赖版本保持一致 -->
         <version>x.x.x</version>
         <scope>provided</scope>
     </dependency>
  </dependencies>

# 2.2.2 本地依赖安装

使用压缩工具打开产品包lib目录下的mapgis-server-x.x.x.x.jar,通过解压获取依赖jar,并手动安装依赖

比如:安装mapgis-psmap-api-x.x.x.x.jar

注意修改groupId、artifactId、version与插件依赖的一致

@REM 注意修改${dir}、version参数值和jar包文件名
mvn install:install-file -DgroupId=com.zondy.mapgis.psmap -DartifactId=mapgis-psmap-api -Dversion=x.x.x.x -Dpackaging=jar -Dfile=${dir}/mapgis-psmap-api-x.x.x.x.jar

# 2.3 插件开发

插件模板提供了2种典型的插件开发场景,不带本地依赖的插件和带本地依赖的插件,并编写好了插件类、扩展服务实现类,支持Swagger3 API文档,您可以根据自己的需要直接对其进行修改或基于它新增单独的插件进行开发

# 三、插件运行调试

# 3.1 编译插件

开发模式下,框架直接找maven编译好的class文件,而不是jar包或zip包。

这种模式下,插件元数据信息由plugin.properties 定义。

可通过插件右键Build Modulemvn clean compile来进行编译

# 3.1.2 带依赖的插件编译

插件的classloader默认只会找编译后target/classes文件夹,但插件还依赖了除框架(provided)之外的其他依赖,则需要使用

插件dependency用来将插件的依赖拷贝到生成目录中target/lib文件夹下

<!-- 如果插件存在本地(非框架提供)依赖项,比如项目依赖plugin-common-lib,调试状态下,只能通过package将依赖项拷贝到target/lib才能加入到插件类加载器中 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>copy</id>
            <phase>package</phase>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <includeScope>runtime</includeScope>
                <outputDirectory>
                    ${project.build.directory}/lib
                </outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

我们可以在整个项目上执行mvn clean package,会自动将依赖放置到对应插件的target/lib目录下,或对插件依赖单独进行mvn clean package,然后将其手动拷贝到被依赖插件的target/lib文件夹下,还可以采用对带有依赖的插件进行多模块打包mvn package -pl com.zondy.mapgis:demo-plugin -am同样可以实现target/lib的自动放置。

# 3.2 加载插件

插件的插件通过框架来完成,所以需要给框架应用启动项添加参数

# 3.2.1 有框架源码

直接给框架应用模块启动项添加JVM参数-Dpf4j.mode=development开启调试模式,添加JVM参数-Dpf4j.pluginsDir=${dir}设置插件目录,添加JVM参数-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005进行调试监听

这里的dir指向的是插件所在的目录,对模板项目来说就是mapgis-plugins所在的目录

比如:

-Dpf4j.mode=development -Dpf4j.pluginsDir=D:\mapgis-boot-extension\mapgis-plugins -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

注意:要调试插件的话,框架需要以Run模式启动,不能以Debug模式启动,因为Debug模式启动时,框架会自己进行附加调试,导致插件项目无法进行附加调试

# 3.2.2 无框架源码

需要在产品包bin/startup-server.bat中添加相关启动参数

set JAVA_OPTS=-Xms256m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -DCONSOLE_CHARSET=GBK -DCONSOLE_WITH_JANSI=true -Dspring.output.ansi.enabled=ALWAYS -Dxxx.home=.

@REM 追加下面一行,注意修改%plugins_dir%的值
set JAVA_OPTS=%JAVA_OPTS% -Dpf4j.mode=development -Dpf4j.pluginsDir=D:\mapgis-boot-extension\mapgis-plugins -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

这里的-Dpf4j.pluginsDir=指向的是插件所在的目录,对模板项目来说就是mapgis-plugins所在的目录

# 3.3 插件开启远程调试

通过设置插件项目的Configurations可开启JVM远程调试

  1. Edit Configurations或通过 Shift+Alt+F10 快捷键打开

  2. Add New Configuration:Remote JVM Debug

  3. Command line arguments for remote JVM:

    -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
    

    注意:此处的端口与框架启动时监听调试的端口保持一致

  4. 点击Debug,会提示Connected to the target VM, address: 'localhost:5005', transport: 'socket'

  5. 添加断点进行调试

    比如:给demo-plugin的TestController中的getRes方法内添加断点,访问http://localhost:8015/psmap/rest/services/demo-plugin/ExtensionServer/test查看是否进入代码

# 四、插件打包和部署

# 4.1 打包为jar (one-jar)

执行mvn package可进行插件打包,可对整个项目进行打包或对带有依赖的插件进行多模块打包。

插件模板内置了打包插件,推荐插件打包使用maven-assembly-plugin,打包为one-jar,如下:

  <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-assembly-plugin</artifactId>
      <version>3.1.0</version>
      <configuration>
          <descriptorRefs>
              <descriptorRef>jar-with-dependencies</descriptorRef>
          </descriptorRefs>
          <finalName>${project.artifactId}-plugin</finalName>
          <appendAssemblyId>false</appendAssemblyId>
          <attach>false</attach>
          <archive>
              <manifest>
                  <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                  <addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
              </manifest>
              <manifestEntries>
                  <Plugin-Id>${plugin.id}</Plugin-Id>
                  <Plugin-Version>${plugin.version}</Plugin-Version>
                  <Plugin-Provider>${plugin.provider}</Plugin-Provider>
                  <Plugin-Class>${plugin.class}</Plugin-Class>
                  <Plugin-Dependencies>${plugin.dependencies}</Plugin-Dependencies>
              </manifestEntries>
          </archive>
      </configuration>
      <executions>
          <execution>
              <id>make-assembly</id>
              <phase>package</phase>
              <goals>
                  <goal>single</goal>
              </goals>
          </execution>
      </executions>
  </plugin>

# 4.2 打包为zip

插件同时支持打包为zip格式,显著的差别是将插件的依赖jar原封不动的放到lib文件夹下,zip格式插件包在运行时会自动解压到同级目录,内部包括classes文件夹和lib文件夹。

插件模板对于带有依赖的插件提供了plugin-zip-package配置选项,可勾选后将插件打包为zip形式,详细配置如下:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <configuration>
                <archive>
                    <manifestEntries>
                        <Plugin-Id>${plugin.id}</Plugin-Id>
                        <Plugin-Class>${plugin.class}</Plugin-Class>
                        <Plugin-Version>${plugin.version}</Plugin-Version>
                        <Plugin-Provider>${plugin.provider}</Plugin-Provider>
                        <Plugin-Dependencies>${plugin.dependencies}</Plugin-Dependencies>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-antrun-plugin</artifactId>
            <executions>
                <execution>
                    <id>unzip jar file</id>
                    <phase>package</phase>
                    <configuration>
                        <target>
                            <unzip src="target/${project.artifactId}-${project.version}.${project.packaging}"
                                    dest="target/plugin-classes"/>
                        </target>
                    </configuration>
                    <goals>
                        <goal>run</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <artifactId>maven-assembly-plugin</artifactId>
            <configuration>
                <descriptors>
                    <descriptor>
                        src/main/assembly/assembly.xml
                    </descriptor>
                </descriptors>
                <appendAssemblyId>false</appendAssemblyId>
            </configuration>
            <executions>
                <execution>
                    <id>make-assembly</id>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <artifactId>maven-deploy-plugin</artifactId>
            <configuration>
                <skip>true</skip>
            </configuration>
        </plugin>
    </plugins>
</build>

同时在src/main/assembly中添加有assembly.xml,内容如下:

<assembly>
    <id>plugin</id>
    <formats>
        <format>zip</format>
    </formats>
    <includeBaseDirectory>false</includeBaseDirectory>
    <dependencySets>
        <dependencySet>
            <useProjectArtifact>false</useProjectArtifact>
            <scope>runtime</scope>
            <outputDirectory>lib</outputDirectory>
            <includes>
                <include>*:jar:*</include>
            </includes>
        </dependencySet>
    </dependencySets>
    <!--
    <fileSets>
        <fileSet>
            <directory>target/classes</directory>
            <outputDirectory>classes</outputDirectory>
        </fileSet>
    </fileSets>
    -->
    <fileSets>
        <fileSet>
            <directory>target/plugin-classes</directory>
            <outputDirectory>classes</outputDirectory>
        </fileSet>
    </fileSets>
</assembly>

# 4.3 插件部署

打包后的结果如下:

mapgis-boot-extension
├── mapgis-plugins                                # 插件目录,设置-Dpf4j.pluginsDir参数指向的目录
│   ├── demo-plugin                               # 单体示例插件
│       ├── target                            
│           ├── demo-plugin-plugin.jar            # xyz-plugin.jar为插件模块
│   ├── demo-whth-deps-plugin                     # 单体带本地依赖的示例插件,依赖为demo-plugin-lib
│       ├── target                            
│           ├── demo-whth-deps-plugin-plugin.jar  # xyz-plugin.jar为插件模块
│           ├── demo-whth-deps-plugin-plugin.zip  # xyz-plugin.zip为zip格式的插件模块
├── mapgis-plugins-deps                           # 插件依赖项
│   ├── demo-plugin-lib                           # demo-whth-deps-plugin插件的依赖
│       ├── target                            
│           ├── demo-plugin-lib-x.x.x.x.jar       # 插件依赖jar

插件target目录下的xyz-plugin.jar或xyz-plugin.zip才是真正可以部署的插件模块,将其放到框架生成的产品包下的的plugins目录内即可

├── bin                                           # 可执行脚本
├── config                                        # 配置
├── lib                                           # 库
├── support                                       # 支持程序
├── plugins                                       # 插件目录
│   ├── demo-plugin-plugin.jar                    # 单体示例插件
│   ├── demo-whth-deps-plugin-plugin.jar          # 单体带本地依赖的示例插件(或zip格式)

# 五、部署后调试

产品部署后同样可以进行调试,需要在产品包bin/startup-server.bat中添加相关启动参数

set JAVA_OPTS=-Xms256m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -DCONSOLE_CHARSET=GBK -DCONSOLE_WITH_JANSI=true -Dspring.output.ansi.enabled=ALWAYS -Dxxx.home=.

@REM 追加下面一行,注意修改%plugins_dir%的值
set JAVA_OPTS=%JAVA_OPTS% -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

启动框架后,再对插件开启远程调试就可以进行调试了。

此处的参数与前面无框架源码设置的参数不同至于在于,是否添加了-Dpf4j.mode=development,这里调试依赖的jar或zip,前面依赖的class文件(不需要打包)

# 六、新增插件

# 6.1 新增插件模块

在mapgis-plugins模块内新增插件模块,模块名可为与下一步的插件id保持一致

# 6.2 定义插件属性

插件属性用来标识插件,为同时支持开发模式和运行模式,需要同时在pom.xml和plugin.properties中指定插件的属性

demo-plugin                        # 插件
├── src                            # 插件源码
├── pom.xml                        # Maven配置文件,用于运行模式下框架加载插件
├── plugin.properties              # 属性配置文件,用于开发模式下框架加载插件

插件id必须保证唯一性,class为Plugin实现类,如果是扩展的SpringMvc REST服务,服务承载到框架中时将添加/psmap/rest/services/{plugin-id}/ExtensionServer

pom.xml示例:

 <properties>
     <!-- Override below properties in each plugin's pom.xml -->
     <!-- 插件实现中必须包括如下配置项-->
     <plugin.id>demo-plugin</plugin.id>
     <plugin.class>com.zondy.mapgis.plugins.demo.DemoPlugin</plugin.class>
     <plugin.version>0.0.1</plugin.version>
     <plugin.provider>MapGIS</plugin.provider>
     <plugin.dependencies/>
 </properties>

plugin.properties示例:

 plugin.id=demo-plugin
 plugin.class=com.zondy.mapgis.plugins.demo.DemoPlugin
 plugin.version=0.0.1
 plugin.provider=MapGIS
 plugin.dependencies=

如果多个插件之间有依赖关系,这里通过plugin.dependencies属性声明插件的依赖项,具体依赖声明写法参考pf4j插件 (opens new window)

# 6.3 添加依赖

在pom.xml中添加必要的依赖

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <scope>provided</scope>
    </dependency>
    
    <dependency>
        <groupId>io.swagger.core.v3</groupId>
        <artifactId>swagger-annotations</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

# 6.4 编写插件类

创建plugin类,即前面提到的plugin.class,示例如下:

@Slf4j
public class DemoPlugin extends SpringPlugin implements FrameworkContextCapable {

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

    @Override
    public void start() {
        log.info("DemoPlugin.start,mode:{}", wrapper.getRuntimeMode());
    }

    @Override
    public void stop() {
        log.info("DemoPlugin.stop()");
    }

    @Override
    protected ApplicationContext createApplicationContext() {
        //这里创建插件自身的IOC容器
        AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
        applicationContext.setClassLoader(getWrapper().getPluginClassLoader());
        applicationContext.register(SpringConfiguration.class);
        applicationContext.refresh();
        return applicationContext;
    }

    @Override
    public void setFrameworkApplicationContext(ApplicationContext applicationContext) {
        //这里获取框架的IOC容器
        log.info("framework applicationContext:" + applicationContext.getApplicationName());
    }
}

上述代码,声明了一个SpringPlugin,并创建了一个插件自身的IOC容器,注意如果插件内部不需要spring IOC,则实现Plugin接口即可。

这里可选择实现FrameworkContextCapable接口来获取框架的IOC容器。

创建SpringMvc Controller扩展服务实现类,添加spring的REST服务注解@RestController或@Controller,同时必须添加注解@Extension和实现Rest扩展服务接口ControllerExtension,可选择实现FrameworkContextCapable接口来获取框架的IOC容器,示例如下:

@Tag(name = "demo-plugin扩展服务")
@RestController
@Extension
@RequestMapping("test")
public class TestController implements ControllerExtension, FrameworkContextCapable {

    private ApplicationContext applicationContext;
    @Autowired
    private MessageProvider messageProvider;

    @Override
    public void setFrameworkApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Operation(summary = "获取结果信息")    
    @RequestMapping(value = "/res", method = {RequestMethod.GET})
    public ResObj getRes() {
        String msg = messageProvider.getMessage();
        ResObj resObj = new ResObj();
        resObj.setMessage(msg);
        return resObj;
    }
}

其中注解@Tag,@Operation为OpenApi3注解,用于生成swagger文档

上述示例REST服务的访问基地址为http://localhost:8015/psmap/rest/services/demo-plugin/ExtensionServer

其中demo-plugin为插件id,对应上文plugin.properties或pom.xml文件中plugin.id的属性值

xxx为扩展服务添加的REST服务前缀,接口ControllerExtension默认实现了服务自述信息方法

public interface ControllerExtension extends ExtensionPoint {
    /**
     * 当前REST扩展服务的自述信息
     * 开发者可根据实际需要,实现该方法,将扩展服务的详细自述信息通过该接口提供给使用者
     *
     * @return 返回text的信息
     */
    @GetMapping("")
    default ResponseEntity<?> getServiceInfo() {
        return ResponseEntity.ok("这是一个基于SpringMVC的REST扩展服务:" + this.getClass().getName());
    }
}

当请求服务基地址http://localhost:8015/psmap/rest/services/demo-plugin/ExtensionServer,将返回如下信息:

这是一个基于SpringMVC的REST扩展服务:com.zondy.mapgis.psmap.plugins.demo.DemoPlugin

开发者也可实现接口,实现自定义的服务自述信息接口,示例如下:

@Override
@Operation(summary = "服务信息")
public ResponseEntity<?> getServiceInfo() {
    return ResponseEntity.ok("这是插件demo-plugin提供的REST扩展服务");
}

当进入http://localhost:8015/webjars/swagger-ui/index.html,可查看所有扩展服务的REST接口文档

# 6.5 插件调试、打包和部署

可参考前面的内容

# 七、常见问题

# 7.1 Package Sealing导致的依赖问题

https://docs.oracle.com/javase/tutorial/deployment/jar/sealman.html

Java JAR 提供了 Seal Package 选项。如果你使用了该选项,意味着任何程序都应该从 jar 里面加载所有类。如果从其它 Jar 中加载该包下面的类将会引发java.lang.SecurityException

部分三方库,比如org.hsqldb:hsqldb,就配置了sealed:true。因此就不能出现框架跟插件均包括该库的情况,该库目前已经存在框架中,则插件必须手动排除。

# 7.2 SpringPlugin需要注意的依赖问题

插件类型为SpringPlugin,这时插件中的Spring IOC是由框架提供支撑的,这时插件的Classloader是不能重复出现pf4j-spring相关的(spring-context、spring-core)依赖的,否则插件中的IOC容器会报错。

# 7.3 插件更新后没有生效

需要排查插件添加、删除后是否有重启服务,因为插件的加载是在框架启动时进行的,所以当插件进行了添加和删除后,必须重启框架服务