Spring Boot运行时动态注册卸载Bean、Rest Controller及模块热加载方案
实现结论
你的需求完全可以基于Spring原生能力实现,不需要修改Spring核心源码,核心设计原则如下:
- 必须为每个动态加载的模块创建独立的子ApplicationContext,将主应用的ApplicationContext设为子上下文的父容器:既可以让模块内的Bean正常注入主应用暴露的公共组件,也能实现模块间Bean隔离,卸载时不会污染主容器
- 不要尝试把模块内的Bean直接注册到主ApplicationContext:后续卸载清理极易出现引用残留、内存泄漏、类冲突问题
- 模块加载时自动扫描内部组件注册到专属子上下文,同步将Web层接口注册到主应用的请求映射体系;模块卸载时直接关闭子上下文、清理请求映射、释放模块专属ClassLoader,即可彻底移除所有模块相关的功能
- 你提到的PF4J和Spring集成的实现逻辑也是上述父子上下文的思路,你自研框架完全可以参考这套逻辑落地
具体落地步骤
模块加载流程
- 按你现有逻辑为模块创建独立URLClassLoader,将主应用的ClassLoader设为该类加载器的父级,保证Spring、公共工具类等通用依赖由主加载器加载,避免重复类定义导致的类型转换异常
- 基于模块ClassLoader创建AnnotationConfigApplicationContext实例,绑定父上下文为Spring Boot主应用上下文
- 配置子上下文的扫描路径,自动扫描模块内标注了
@Component、@Service、@RestController等注解的类,完成依赖注入 - 子上下文刷新完成后,提取所有Controller实例,注册到主应用的RequestMappingHandlerMapping中,模块提供的接口即可正常对外访问
模块卸载流程
- 遍历当前模块持有的所有Controller实例,从主应用的RequestMappingHandlerMapping中移除对应的请求映射,保证接口无法再被访问
- 调用模块对应子上下文的
close()方法,触发所有Bean的销毁回调,清空子上下文持有的所有Bean引用 - 关闭模块专属ClassLoader,释放模块所有类的资源引用
- 从你自研的模块管理容器中移除该模块的所有记录,避免残留引用导致GC无法回收
核心代码示例
import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.stereotype.Controller; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import java.io.File; import java.net.URL; import java.net.URLClassLoader; import java.util.Map; import java.util.Set; import java.util.HashSet; public class PluginManager { private final ApplicationContext mainContext; private final RequestMappingHandlerMapping handlerMapping; public PluginManager(ApplicationContext mainContext) { this.mainContext = mainContext; this.handlerMapping = mainContext.getBean(RequestMappingHandlerMapping.class); } // 加载模块 public PluginInstance loadModule(File moduleJar) throws Exception { // 创建模块专属类加载器 URLClassLoader moduleClassLoader = new URLClassLoader( new URL[]{moduleJar.toURI().toURL()}, mainContext.getClassLoader() ); // 创建模块专属子Spring上下文 AnnotationConfigApplicationContext moduleContext = new AnnotationConfigApplicationContext(); moduleContext.setClassLoader(moduleClassLoader); moduleContext.setParent(mainContext); // 按约定扫描模块包路径,可根据模块自身配置调整 moduleContext.scan("com.plugin.module.*"); moduleContext.refresh(); // 注册模块内所有Controller到主应用请求映射 Set<String> controllerBeanNames = new HashSet<>(); Map<String, Object> controllerBeans = moduleContext.getBeansWithAnnotation(Controller.class); for (Map.Entry<String, Object> entry : controllerBeans.entrySet()) { controllerBeanNames.add(entry.getKey()); handlerMapping.registerHandler(entry.getValue()); } return new PluginInstance(moduleClassLoader, moduleContext, controllerBeanNames); } // 卸载模块 public void unloadModule(PluginInstance pluginInstance) throws Exception { // 移除所有接口映射 for (String beanName : pluginInstance.getControllerBeanNames()) { Object controller = pluginInstance.getContext().getBean(beanName); handlerMapping.unregisterHandler(controller); } // 关闭模块上下文,销毁所有Bean pluginInstance.getContext().close(); // 关闭类加载器,释放类资源 pluginInstance.getClassLoader().close(); } // 模块实例持有类 public static class PluginInstance { private final URLClassLoader classLoader; private final AnnotationConfigApplicationContext context; private final Set<String> controllerBeanNames; public PluginInstance(URLClassLoader classLoader, AnnotationConfigApplicationContext context, Set<String> controllerBeanNames) { this.classLoader = classLoader; this.context = context; this.controllerBeanNames = controllerBeanNames; } public URLClassLoader getClassLoader() { return classLoader; } public AnnotationConfigApplicationContext getContext() { return context; } public Set<String> getControllerBeanNames() { return controllerBeanNames; } } }
落地注意事项
- 每个模块的包名需要做独立前缀隔离,不要和主应用、其他模块的包路径重叠,避免组件扫描冲突
- 模块JAR打包时不要包含Spring、通用工具包等主应用已经引入的依赖,避免类加载重复导致的异常
- 如果模块需要用到AOP、事务、配置绑定等Spring能力,只需要在创建子上下文时注册对应的处理器即可,用法和普通Spring上下文完全一致
- 卸载模块时需要确保主应用、其他模块没有持有该模块内类、Bean的引用,否则会出现ClassLoader无法回收的内存泄漏问题
内容的提问来源于stack exchange,提问作者Dominic
相关产品推荐
相关产品推荐

