ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring与SpringMVC父子容器:Bean查找规则、经典踩坑与Boot演进

Spring与SpringMVC父子容器:Bean查找规则、经典踩坑与Boot演进 “Spring和SpringMVC为什么需要父子容器”这个问题杀伤力在于背过答案的人都能讲出“父容器放Service子容器放Controller”但你再往下问“这个结构是怎么搭起来的”“Bean在父子容器之间怎么查找”“为什么有人在这个结构里把事务配失效了”很多人就开始含糊了。我面过不少候选人十个里有九个能说到这个分层真正能把话聊透的一只手数得过来。这篇文章就从这个“看着简单”的问题出发把Servlet时代背景、Bean可见性规则、双容器配置方式、经典踩坑和Spring Boot里的变化一并理清。如果你是刚学Spring放心往下看我会从最常见的两个报错场景入手如果你已经工作几年建议重点看第5节和第6节那部分内容普通文档里写不到。1. 先从两个报错场景说起Bean找不到和事务不生效不是配置偶然1.1 Controller注入Service居然报NoSuchBeanDefinitionExceptionSSM项目里最经典的一幕Controller里写了Autowired注入一个UserService启动后在Tomcat控制台看到NoSuchBeanDefinitionException: No qualifying bean of type com.example.service.UserService。新手第一反应是检查Service类上有没有Service注解检查context:component-scan的base-package是不是漏了。结果发现注解有、包路径也包含service包还是报错。折腾半天后发现原来是两个配置文件“各管了一段”——applicationContext.xml扫了service包spring-mvc.xml只扫了controller包按理说Controller在子容器子容器会沿着parent引用去父容器找Service应该能注入才对。但如果ContextLoaderListener没有配置或者父容器的扫描路径里根本没包含service包那么这个Service就真的不存在于容器体系中Controller再聪明也拿不到。这个例子看着基础实际上把父子容器的核心价值问出来了Spring MVC中Controller之所以能拿到父容器里的Service靠的就是“子容器向上查找”这个机制。如果没有父子关系Controller只能在自己所在的ApplicationContext中找BeanSSM这种分层架构根本没法定下来。1.2 事务注解在Controller调用链中静默失效比Bean找不到更隐蔽的是Transactional不生效。一个Service方法上标了Transactional数据库操作抛了异常数据居然没回滚日志里也看不到事务拦截的痕迹。排查时发现一个典型配置错误spring-mvc.xml里加了aop:aspectj-autoproxy/applicationContext.xml里没有配置任何事务驱动。Service由父容器创建切面在子容器里配置。Transactional靠的是BeanPostProcessor在Bean初始化阶段生成代理而BeanPostProcessor只对当前容器创建的Bean生效。父容器创建的Service不会被子容器里配置的AOP逻辑增强结果就是事务静默失效异常被吞掉数据库数据留下脏数据。这种问题在SSM时代可以说一踩一个准因为它不报错、不崩溃只会在用户下单时少扣一次库存。等你在几百个Controller里定位一圈时间成本早就爆炸了。这个案例说明一个关键点容器层级不是随便拆着玩的Bean在哪个容器里注册就被哪个容器的机制处理。父子容器不是简单的“存放位置不同”它决定了一个Bean能不能被代理、被谁代理、被哪些切面拦截。2. 父子容器的诞生逻辑Servlet容器、Listener与DispatcherServlet的三角关系2.1 两个“容器”根本不是同一个东西要弄懂父子容器先得把两个“容器”的概念分开。Tomcat是Servlet容器它管理的是Servlet、Filter、Listener这些Web组件的生命周期Spring是IoC容器它管理的是Service、Dao、Controller这些业务对象的生命周期和依赖关系。Spring MVC想要在Tomcat里跑起来就得在Web应用启动时创建Spring IoC容器并把DispatcherServlet注册到Tomcat里。但这里有个问题Tomcat启动Web应用时整个应用还处于初始化阶段Spring容器由谁来创建创建完以后放在哪里Filter、Listener这类不经过DispatcherServlet的组件又怎么访问Spring容器里的Bean这些问题的答案就是父子容器结构的骨架。2.2 ContextLoaderListener为什么天然适合做父容器在传统web.xml部署方式里我们会配置一个ContextLoaderListenerlistener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-paramContextLoaderListener实现了ServletContextListener。Web应用启动时Tomcat会回调它的contextInitialized方法在这里创建一个根上下文Root WebApplicationContext并把配置文件解析、Bean扫描、Bean定义注册、单例Bean创建这些完整流程走完。这个根上下文会被挂到ServletContext的ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE属性上供整个应用全局访问。根上下文里放什么数据源、事务管理器、Service、Dao也就是所有业务层和持久层的Bean。对任何一个Web应用来说这些Bean理应是全局共享的。Filter要做权限校验需要调UserServiceScheduled定时任务线程需要调业务方法甚至其他第三方框架集成也可能需要拿到Service引用。它们都不经过DispatcherServlet唯一能访问Spring容器的地方就是这个根上下文。没有ContextLoaderListener这些组件就只能手动从Spring容器里拿Bean或者干脆自己new一个容器结果就是一堆重复的对象。2.3 DispatcherServlet为什么还要单独搞一个子容器再看DispatcherServlet的配置servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingDispatcherServlet在init时同样会创建一个WebApplicationContext专门放HandlerMapping、HandlerAdapter、ViewResolver、Controller这些Web层组件。问题来了为什么不把Controller直接丢进父容器所有Bean放一起多省事原因有两个。第一一个Web应用可以部署多个DispatcherServlet比如一个管前台页面请求一个管后台接口请求它们的URL映射不一样。如果所有Web组件堆在一个容器里两个Servlet之间的Bean会互相污染A的Controller可能被B的HandlerMapping处理到。拆成每个DispatcherServlet一个子容器配置和组件就隔离了互不干扰。第二Servlet规范里Servlet实例是Tomcat管理的它只是“在某个时机被回调”。DispatcherServlet需要自己拉起一套Spring MVC环境这套环境和全局业务容器不该混在一起。父容器提供公共底座子容器是可以插拔的Web模块这样的边界更清晰。2.4 官方其实没有强制要求“必须父子”回到标题的问题为什么需要父子容器往根上说这不是Spring拍脑袋设计出来的而是Servlet规范里Web应用启动方式倒逼出来的产物。Spring本身并不强制容器分父子ApplicationContext完全可以只有一层。但在传统WAR包部署模式下ContextLoaderListener在ServletContext阶段启动DispatcherServlet在Servlet阶段启动两者的生命周期起点不同职责也不同。为了让多个Servlet共享同一套Service层配置同时保留各自独立的Web配置Spring MVC就采用了“当前上下文向上关联根上下文”的分层结构。这段历史决定了父子容器是一个为了适配环境而生的结构不是绝对的架构设计真理。这也是为什么后来Spring Boot敢把它简化掉——下面第6节会专门展开。3. Bean查找的可见性规则子容器能拿父容器的东西反过来不行3.1 getBean在父子容器间的委托逻辑父子容器之间的关系在Spring里由HierarchicalBeanFactory接口定义。子容器执行getBean(userService)时不是直接去父容器拿而是走一套固定顺序先在自己的BeanDefinitionMap里查有没有名为“userService”的Bean定义有则从自己的单例池里返回没有就在自己容器里创建没有则检查parentBeanFactory是否为nullparent不为null委托父容器执行同样的getBean流程父容器也没有才抛出NoSuchBeanDefinitionException。所以你会发现一个有意思的现象从子容器里getBean(service)拿到的是父容器的那个单例实例但子容器自己在单例池里不会缓存这个实例。因为Bean不是由子容器创建的子容器只是“借用”了父容器里的对象。每次从子容器出发向上查找最终都会落到父容器的单例池里。这个机制用一个生活化类比就是孩子找东西先翻自己书包找不到就问家长家长给了就用但这个东西不会因为孩子问了一次就变成孩子书包里的东西。东西最后还是家长的。3.2 父容器为什么看不到子容器的Bean容器可见性最核心的规则是子容器可以看到父容器的Bean父容器看不到子容器的Bean。这不是Spring偷懒而是有意为之。因为委托查找方向是单向的——getBean是从当前容器向上找parent而不是从parent向下找child。父容器在创建UserService时如果想注入一个OrderController它找遍自己所有BeanDefinition都没有因为OrderController注册在子容器里父容器根本不知道它的存在。这条规则在SSM架构里反而成了一个意外的好处业务层代码天然拿不到Web层组件。Controller可以调用Service完成业务逻辑Service想反向拿到Controller去操作视图层不行容器层级不允许。这相当于从机制层面逼着你分层而不是靠代码规范、靠人review。我应该强调的是对于老项目这确实是一种解耦约束但对于Spring Boot单容器项目这个约束已经不存在了一旦项目演进到Boot需要靠架构习惯来继续维持分层意识。3.3 父子容器里同名Bean的“遮蔽”现象还有一种隐蔽情况父容器和子容器都定义了相同名称或相同类型的Bean。比如applicationContext.xml里扫了com.example.servicespring-mvc.xml里也扫了com.example.service结果UserService被注册了两份一个在父容器、一个在子容器。此时从Controller注入UserService子容器会优先返回自己创建的那个实例父容器里那个“同款Bean”被彻底遮蔽永远走不到。但注意父容器里的实例并不会消失它依然存在于父容器的单例池中只是子容器侧不引用它。这带来的最现实后果是同一个类在应用里可能同时存在两个实例。一个用了自己的状态一个用了另一份状态。如果这两个实例都持有某个缓存集合、某个计数变量状态就会彼此不同步而且极难排查。这个问题在第5节还会继续展开这里先留个印象。4. 双容器的标准搭法从web.xml到Java Config4.1 web.xml是最直观的“双容器说明书”我建议所有学Spring MVC的人至少在代码里看一遍传统web.xml的完整配置因为它把父子容器的形态暴露得最清楚。前面已经列过ContextLoaderListener和DispatcherServlet两段配置这里把它们组合到一起看context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping注意两个容易被忽略的细节父容器的配置文件路径通过context-param指定参数名是contextConfigLocation这是ContextLoaderListener读取的子容器的配置文件路径通过init-param指定参数名同样叫contextConfigLocation但作用域是dispatcher这个Servlet自己由DispatcherServlet读取。两个参数同名不同命分别服务两个容器。如果只在context-param里配置了配置文件DispatcherServlet不指定自己的配置文件它会去默认位置找/WEB-INF/dispatcher-servlet.xml找不到时子容器里就是空的Web配置。另外load-on-startup1/load-on-startup必须配否则DispatcherServlet会延迟到第一个请求到达时才初始化子容器也跟着延迟创建启动期可能不会暴露问题但第一个请求会非常慢而且容易在运行期突然发现容器还没初始化完。4.2 Java ConfigAbstractAnnotationConfigDispatcherServletInitializer到了Servlet 3.0时代可以用Java配置替代web.xml。Spring MVC提供AbstractAnnotationConfigDispatcherServletInitializer子类只需要实现三个方法public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { Override protected Class?[] getRootConfigClasses() { return new Class?[] { RootConfig.class }; } Override protected Class?[] getServletConfigClasses() { return new Class?[] { WebConfig.class }; } Override protected String[] getServletMappings() { return new String[] { / }; } }看名字就能对应上getRootConfigClasses提供的是根上下文父容器的配置类getServletConfigClasses提供的是DispatcherServlet上下文子容器的配置类。这套API本质上仍然是父子容器结构只是把web.xml的表达方式换成了类型安全的Java类。在我实际经验里用Java Config的人还容易犯一个错RootConfig里的ComponentScan把Controller的包也扫了WebConfig里的ComponentScan又把Controller的包扫了一遍。两个容器各创建一份Controller只是请求处理时真正用的是子容器里那个。你看着代码没什么问题但应用启动后内存里多了一堆无用对象排查内存占用时还挺难发现的。4.3 两个配置文件的职责划分配置完以后一定要在纸上把两个文件的职责画清楚容器加载入口典型配置内容父容器Root WebApplicationContextContextLoaderListener数据源、事务管理器、Service、Dao、全局AOP、定时任务子容器DispatcherServlet WebApplicationContextDispatcherServletController、HandlerMapping、HandlerAdapter、ViewResolver、消息转换器记住一个原则和请求处理链路相关的组件放子容器和全局业务能力相关的组件放父容器。Controller是请求入口放子容器事务是业务能力放父容器切面横跨业务能力也放父容器。至于Configuration类本身如果同一个配置类同时被两个容器的扫描路径命中它会被两个容器各加载一遍这不算错但要注意配置类里定义的Bean如果之间互相依赖可能会产生意想不到的双实例。所以我自己处理老项目时会把根配置和Web配置拆成两个独立的配置类避免同一个类被两个容器同时加载。5. 双容器最容易踩的坑重复扫描、代理失效、手动new容器5.1 扫描范围重叠同一个Bean被创建两遍先看一段非常容易出现在SSM项目里的配置applicationContext.xml里context:component-scan base-packagecom.example /spring-mvc.xml里context:component-scan base-packagecom.example /如果两处都配置了相同的扫描包结果不是“Spring自动去重”而是在父容器和子容器中分别扫描、分别注册。com.example包下所有带Component、Service、Repository、Controller标记的类会在两个容器里各出现一次。同一个类被创建了两个独立实例。为什么不会报错因为Spring容器的单例作用域只保证“在一个容器内”单例不跨容器。两个容器各有各的单例池天然允许相同类型、相同名称的Bean各自存在。后果我在第3节提过状态同步问题、内存浪费、AOP代理不统一。更麻烦的是当你给某个Service加缓存注解你会看到缓存有时候生效有时候不生效——因为请求链路里Controller注入的是子容器里的Service而定时任务线程拿到的可能是父容器里的Service两份对象各用各的缓存行为自然不一致。5.2 AOP代理只在创建它的容器里生效这是我一直想重点强调的坑。Spring的AOP代理是通过BeanPostProcessor在Bean创建过程中织入的而BeanPostProcessor只作用于它所在容器管理的Bean。举个例子如果事务切面和aop:aspectj-autoproxy/配置在spring-mvc.xml子容器而Service由父容器创建那么Service在父容器中初始化时子容器里的AOP处理器根本不在场Service不会被代理事务注解也就不会被解析。反过来也一样如果你把Controller注册到父容器把AOP配置放到子容器Controller同样不会被增强。这个坑的修复方式很简单事务、AOP、缓存这些全局能力必须放在和业务Bean同一个容器里。既然Service在父容器那EnableTransactionManagement、tx:annotation-driven/就应该在父容器配置。Controller特有的切面比如日志打印可以放在子容器它只针对Controller生效。如果你不是一眼能看出“切面在哪个容器生效”有一个调试技巧在目标方法里打印this.getClass()。被代理的对象类名会包含cglib或jdk.proxy字样没被代理的类名就是原始类名。这个技巧在排查父子容器导致的AOP失效时非常有效。5.3 手动new ApplicationContext的连环问题老项目中还有一种“野路子”写法由于某些Filter或线程池拿不到Spring容器里的Bean程序员就直接在代码里写ClassPathXmlApplicationContext context new ClassPathXmlApplicationContext(applicationContext.xml); UserService userService context.getBean(UserService.class);这段代码跑起来通常不会立刻报错所以它的隐患特别隐蔽。它会把applicationContext.xml里的所有Bean重新加载一遍创建一个全新的容器。这个新容器里的单例对象和原有的容器对象完全没有关系Service被实例化成两套——一套给Spring容器用一套给这段代码用。两套对象的数据库连接、缓存、线程状态都不一样运行一段时间后就会出现“接口调用正常但定时任务操作的数据和接口看到的数据不一致”这种诡异问题。正确做法是从ServletContext中拿根上下文WebApplicationContext context WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext); UserService userService context.getBean(UserService.class);WebApplicationContextUtils会从ServletContext的属性中读取ContextLoaderListener放进去的根上下文拿到的是同一个容器、同一套单例对象不会重复创建。5.4 面对“找不到Bean”我建议按这个顺序排查如果你在SSM项目里遇到Bean相关异常别急着改代码先按下面顺序走一遍查启动日志确认Root WebApplicationContext initialization completed和Initializing Spring DispatcherServlet两行日志都出现了且顺序是先根后DispatcherServlet确认发生异常的这个Bean应该属于父容器还是子容器然后检查对应配置文件的扫描路径在Controller中注入ApplicationContext调用context.getParent()如果不为null说明这个Controller确实运行在子容器中若parent就是null说明你的项目压根没有父子结构用context.getBeanDefinitionNames()把当前容器内已有的Bean名打出来过滤出目标类型看它到底注册在哪一侧最后再检查是否存在两个容器都扫描同一包的情况如果存在立即拆分配置。这套排查路径我用了很多年基本能覆盖90%以上的父子容器异常场景。比在IDE里盲目打断点快得多。6. Spring Boot为什么把父子容器收编成单容器6.1 Spring Boot的默认世界一个ApplicationContext包含所有Bean有人统计过从SSM迁到Spring Boot以后和父子容器相关的报错几乎绝迹。原因很简单Spring Boot默认不再使用ContextLoaderListenerDispatcherServlet这种两套上下文装配方式。在Spring Boot的Web项目中SpringApplication.run()创建的是一个ServletWebServerApplicationContext。数据源、事务管理器、Service、Dao、Controller、HandlerMapping全都注册在这一个ApplicationContext里。业务Bean和Web组件不再被拆成两拨整个应用只有一个根容器。我之前写Spring Boot时还专门验证过在RestController里注入ApplicationContext调用getParent()返回的是null。这在传统SSM项目的Controller里几乎不可能出现——传统结构下Controller里的ApplicationContext一定有parent。所以面试时听到“Spring Boot是单容器、没有父子结构”这种说法在业务主线上是成立的。严格抠源码的话内嵌Tomcat启动DispatcherServlet时它仍然可能创建一个极其空壳的子上下文但这个子上下文里几乎没有用户自己定义的Bean所有实际Bean都在根容器里。对使用者来说它已经不具备传统父子容器的特征了。6.2 什么时候还值得保留父子容器虽然Spring Boot默认不收这套但在某些场景下双容器结构依然有价值传统WAR包部署的老项目已经在用web.xmlContextLoaderListener没必要强行改成单容器需要多个DispatcherServlet每个Servlet想拥有不同的Web层配置比如一个面向API、一个面向后台管理且两者需要共享同一套Service团队希望靠容器机制强制“业务层不反向依赖Web层”把架构约束内建在容器层级中。如果你是新建Spring Boot项目我不建议为了“还原历史”刻意模仿父子容器。Boot的单容器设计已经证明绝大多数项目根本不需要那个层级保留反而不利于排查。6.3 老项目改造时最值得做的三件事如果你要从SSM迁到Spring Boot或者只是维护一个还在用双容器的老项目我按实际经验给出三个优先级很高的建议统一扫描入口。在传统结构中父容器只扫Service、Dao等业务组件子容器只扫Controller。在Spring Boot中这两种Bean统一由主启动类所在包扫描。如果老代码里有多个ComponentScan合并时一定要检查有没有重复包路径避免把Controller和Service扫进同一个BeanDefinition集合导致定义覆盖。事务配置跟着容器走。确认EnableTransactionManagement和业务Bean都在同一个容器。不要在业务代码里手动获取ApplicationContext读取Web层对象。原本靠容器层次提供的隔离一旦迁到单容器就失效了跨层依赖一定要靠代码规范补上比如Service接口里就禁止出现HttpServletRequest、HttpServletResponse这类Web参数类型。我在实际项目中见过太多人迁Boot之后把Controller里塞满HttpServletRequest再传给Service做逻辑处理。这在传统双容器结构下父容器看不见子容器Service类里的Web依赖不会被正常注入反倒早期会暴露问题到了Boot单容器时代Web类型在任意层都能拿到隐患反而被“藏”起来了代码很快腐烂成一层到底的“面条代码”。所以我的态度一直很明确容器结构可以简化但分层意识不能简化。想法上这是比Spring容器本身更值得维护的工程原则。最后再分享一个我个人的判断标准如果你在任何Spring项目里看到WebApplicationContextUtils和getParent这两个API同时出现基本可以确定这是一个双容器结构的项目。这时候所有排查思路都应该先问一句“这个Bean在哪个容器里”而不是急着看注解有没有写对。搞懂父子容器很多时候不是为了理解一个历史遗留设计而是为了在SSM遗址、老系统改造和面试现场都能一眼看出问题到底出在哪一层。
返回列表