Spring Boot Pf4j模块化开发设计方案

前言

上一篇文章还是2年前,一是工作太忙,二是人也变得懒散,好多新东西仅止于脑海里面的印象,未能深入,不成体系,最近主要花了些时间实现Java版本的模块化,同时也要重点兼顾小伙伴们从.NET Core移植模块的成本,所以需要全盘考虑的东西会更加实际,好在有些Java底子加上AI的出现,实现的过程相对会容易一些,最近对AGI提起兴趣,接下来应该会重点学习这方面的应用开发再来和大家分享,好了,话不多说,接下来的系列文章会讲讲Java版本的模块化,和大家一起探讨探讨,或许有更好的一些建议,我能学习到更多。

Spring Pf4j实现效果

我们选择【https://github.com/pf4j/pf4j】作为Java模块化的基础设施,虽然官方作者提供了pf4j-spring的版本基础使用,但能力太弱(主要作者对spring boot好像不是非常熟悉,并没有任何贬低意思,在相关issue作者也做出了表明),尤其是我们还要考虑.NET Core模块的移植,所以不能完全开箱即用,所以我对其进行二次封装。二次封装为Spring版本,注意这里我说的是封装为Spring,而不是SpringBoot,因为SpringBoot是Web应用,而Spring提供了SpringBoot的基础能力,所以我们只需要引入Spring基础包即可,万万不可将SpringBoot全家桶引入到模块化基础设施,这点考虑非常重要。最终插件只需要继承封装的插件类即可

插件开发者可重写beforeApplicationContextRefresh和afterApplicationContextReady,熟悉.NET Core开发的伙伴们应该能猜到等同于ConfigureServices和Configure方法,在before方法里可自定义手动注册相关bean(当然常见的component和bean等注解会自动注册),而after则是上下文刷新完成后可做业务上的初始化工作

Spring Pf4j上下文

每个插件有独立的上下文,所以在启动插件时需创建插件上下文,完成创建插件上下文分为4个步骤,一是初始化上下文,二是提供上述抽象开发者可重写的手动注册,三是刷新插件上下文,四是上述插件利用上下文进行相关业务初始化操作

复制代码
private ApplicationContext createApplicationContext() {

        long startTs = System.currentTimeMillis();

        // Step 1: Pre-create application context
        log.info("Initializing base context for plugin '{}'", pluginId);
        long preCreateStart = System.currentTimeMillis();
        AnnotationConfigApplicationContext annotationContext = preCreateApplicationContext();
        log.info("Initialized base context for plugin '{}' in {} ms",
                pluginId, System.currentTimeMillis() - preCreateStart);

        // Step 2: Customize context before refresh
        log.info("Customizing context configuration for plugin '{}'", pluginId);
        long handleStart = System.currentTimeMillis();
        AnnotationConfigApplicationContext context = beforeApplicationContextRefresh(annotationContext);
        log.info("Customized context configuration for plugin '{}' in {} ms",
                pluginId, System.currentTimeMillis() - handleStart);

        if (context == null) {
            context = annotationContext;
        }

        // Step 3: Refresh the context (load beans, etc.)
        log.info("Refreshing Spring context for plugin '{}'", pluginId);
        long postCreateStart = System.currentTimeMillis();
        postCreateApplicationContext(context);
        log.info("Refreshed Spring context for plugin '{}' in {} ms",
                pluginId, System.currentTimeMillis() - postCreateStart);

        // Step 4: Post-refresh custom logic
        log.info("Executing post-refresh logic for plugin '{}'", pluginId);
        long customStart = System.currentTimeMillis();
        afterApplicationContextReady(context);
        log.info("Completed post-refresh logic for plugin '{}' in {} ms",
                pluginId, System.currentTimeMillis() - customStart);

        // Total time
        log.info("Plugin '{}' context fully initialized in {} ms",
                pluginId, System.currentTimeMillis() - startTs);

        return context;
    }

整个步骤最重要的属于初始化插件的上下文,这里贴一下伪代码

Spring控制器动态注册

控制器的动态注册必然是等插件上下文刷新完成后去通过插件上下文获取控制器bean,同时基于控制器的请求处理映射为RequestMappingHandlerMapping,所以我们需要实现自定义的请求处理映射,这里我们暂时只需考虑控制器及其方法的动态注册

复制代码
public class GJPluginRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    private static final Logger log = LoggerFactory.getLogger(GJPluginRequestMappingHandlerMapping.class);

    @Override
    public void detectHandlerMethods(@NotNull Object controller) {
        super.detectHandlerMethods(controller);
    }
}

我们将上述自定义请求映射处理作为bean注册到主应用,然后在插件上下文创建完成后,获取注册到主应用的自定义请求处理映射,传入插件,伪代码如下:

复制代码
GJPluginLifecycle registerController() {
        GJPluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping = plugin.getMainApplicationContext()
        .getBean("pluginRequestMappingHandlerMapping", GJPluginRequestMappingHandlerMapping.class);
        pluginRequestMappingHandlerMapping.registerControllers(plugin);
        return this;
    }

插件上下文获取控制器bean,并将插件控制器bean注册到主应用上下文以及控制器方法注册到自定义的请求处理映射中

复制代码
public Set<Object> getControllerBeans(GJPlugin springBootPlugin) {
        ApplicationContext applicationContext = springBootPlugin.getApplicationContext();
        Set<Object> beans = new LinkedHashSet<>();

        Map<String, Object> controllerBeans = applicationContext.getBeansWithAnnotation(Controller.class);
        Map<String, Object> restControllerBeans = applicationContext.getBeansWithAnnotation(RestController.class);

        beans.addAll(controllerBeans.values());
        beans.addAll(restControllerBeans.values());

        if (log.isTraceEnabled()) {
            List<String> names = beans.stream()
                    .map(b -> b.getClass().getSimpleName())
                    .collect(Collectors.toList());
            log.debug("Scanned {} controller beans: {}", beans.size(), names);
        }

        return beans;
    }

我们再来遍历插件中所有控制器列表,进行动态注册即可

SpringDoc-OpenApi

上述为整个模块化或者插件化的设计方案,我们首先需要实现的第一个则是Swagger,将所有插件接口列表能够在主应用启动完成后在swagger页面里呈现出来,但我们插件控制器为动态注册,那么这里如何设计呢,我们一步步来。首先是在主应用引入openapi的包

复制代码
 <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
 </dependency>

上述只是主应用定义的控制器已被呈现,但要使得动态注册的插件控制器在主应用启动后也能在swagger中呈现出来,我们还需要完成3个步骤,一是在插件基础设施中引入openapi,插件化基础设施尽可能轻量,无需引入springdoc-openapi-starter-webmvc-ui,建议引入springdoc-openapi-starter-common包即可,如此插件只需对控制器等等打上标签,其他应该都用不到。二是插件注册时需要构建插件控制器的GroupedOpenApi(即每个插件对应一个GroupedOpenApi),并将其注册到主应用上下文,三是主应用需要支持动态注册多GroupedOpenApi。我们重点关注步骤2和步骤3,在主应用yml配置文件中对spring-doc的相关配置过于简单此处忽略不讲,为了实现多模块的动态注册,需要使用springdoc-OpenApi的多GroupedOpenApi延迟注册,如下为通用方案

复制代码
@Configuration
public class SpringDocOpenApiCfg {

    @Bean
    MultipleOpenApiWebMvcResource multipleOpenApiResource(List<GroupedOpenApi> groupedOpenApis,
                                                          ObjectFactory<OpenAPIService> defaultOpenAPIBuilder,
                                                          AbstractRequestService requestBuilder,
                                                          GenericResponseService responseBuilder,
                                                          OperationService operationParser,
                                                          SpringDocConfigProperties springDocConfigProperties,
                                                          SpringDocProviders springDocProviders,
                                                          SpringDocCustomizers springDocCustomizers) {
        return new MultipleOpenApiWebMvcResource(groupedOpenApis,
                defaultOpenAPIBuilder, requestBuilder,
                responseBuilder, operationParser,
                springDocConfigProperties,
                springDocProviders,
                springDocCustomizers);
    }
}

我们封装插件的注册GroupedOpenApi逻辑,如下:

复制代码
public class GJPluginOpenApiInfo {
    /**
     * 获取插件Swagger分组名称(插件ID即为组名)
     */
    public String getGroupName;

    public String getGroupName() {
        return getGroupName;
    }

    public void setGroupName(String getGroupName) {
        this.getGroupName = getGroupName;
    }

    /**
     * 获取插件Controller所在包
     */
    private List<String> getControllerPackages;

    public void setControllerPackages(List<String> getControllerPackages) {
        this.getControllerPackages = getControllerPackages;
    }

    public List<String> getControllerPackages() {
        return getControllerPackages;
    }
}
复制代码
public class GJPluginOpenApiConfig {
    public static final String PLUGIN_SWAGGER_BEAN_PREFIX = "pluginGroupedOpenApi-";

    public static void registerPluginOpenApiBeans(GJPlugin springBootPlugin, GJPluginOpenApiInfo pluginSwaggerInfo) {
        String groupName = pluginSwaggerInfo.getGroupName();
        groupName = groupName.trim().toLowerCase();
        if (groupName.trim().isEmpty()) {
            return;
        }
        String beanName = PLUGIN_SWAGGER_BEAN_PREFIX + groupName;
        String finalGroupName = groupName;
        GroupedOpenApi groupedOpenApi = GroupedOpenApi.builder()
                .group(finalGroupName.trim())
                .displayName(finalGroupName.trim())
                .packagesToScan(pluginSwaggerInfo.getControllerPackages().toArray(new String[0]))
                .build();
        springBootPlugin.registerBeanToMainContext(beanName, groupedOpenApi);
    }
}

在上述我们遍历控制器列表动态注册控制器时,此时调用上述封装注册插件的GroupedOpenApi,代码如下:

我们搞一个Demo插件控制器,看能不能在swagger界面中呈现出来

此时我们发现插件GroupedOpenApi有了,但插件接口列表没有呈现,同时主应用的接口列表悄无声息已无,于是乎开始自定义OpenApiResource调试等等系列操作,底层最后在构建计算接口列表等等时有一个方法引起重要关注

上述严格判断插件控制器方法的bean到底是不是属于对应的控制器,于是我们回过头去看我们动态注册控制器的bean和将控制器的方法注册到请求处理映射的逻辑,如下爱再重点标识一下,以免小伙伴们忘记了

未曾注意到这一细节,我们发现了问题,注册控制器到主应用上下文的bean用的控制器名称,而将控制器方法的注册传入的是控制器对象而不是简单的控制器名称,所以获取到的方法控制器bean则是控制器的hash值,而控制器的bean实际是字符串,所以传入方法的控制器也修改为控制器的名称

总结

如上基于pf4j二次封装的整个设计思路,其中还涉及一些细节并未详细展开,细节主要是对pf4j底层实现的深入了解,然后在封装以及安全等等上做出了进一步的打磨,若有需要了解的小伙伴们,可在评论留言,我们可一起碰撞碰撞思路,本文暂到此为止,感谢阅读。