ARTICLE DETAIL

资讯详情

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

Spring 面经系列 · 第 6 篇:Spring MVC 请求全流程、父子容器与零配置原理

Spring 面经系列 · 第 6 篇:Spring MVC 请求全流程、父子容器与零配置原理

Spring 面经系列 · 第 6 篇:Spring MVC 请求全流程、父子容器与零配置原理

💡一个 URL 敲下去,Spring MVC 内部其实只干了两件大事:先根据请求地址把「该谁处理」查出来,再把处理结果换成能发回浏览器的字节。听起来很简单,但面试的连环问全埋在这条链路的岔口上——返回带@ResponseBody的对象时,视图解析器去哪儿了?控制器是单例,那类里的成员变量凭什么敢直接写?GET 参数乱码为什么CharacterEncodingFilter救不了?子容器能看见父容器的 Bean,为什么把 Controller 全交给父容器就变成 404?以及最反直觉的一个:只有接口、没有实现类的 MyBatis Mapper,凭什么能被@Autowired注进 Service?

这一篇按 Web 层这条线把 10 个分 P 串起来:主流程 → 控制器的状态 → 乱码 → 拦截器与过滤器 → 父子容器 → 零配置 → Mapper 接口注入。素材仍然是语音转写,错字很多(DispatcherServlet被写成「departure civil」、CharacterEncodingFilter被写成「chart encoding field」、Servlet被写成「civil / Sol」),我按术语本来的样子还原了。凡是能对着官方文档和源码核对的,我都明确标出来;核不了的按规范留在「未核实」里。


1. 一条请求在 DispatcherServlet 里走完的路

视频里给的定位很直接:要用 Spring MVC,通常要在web.xml里声明一个 Servlet,这个 Servlet 就是DispatcherServlet;它既叫「前端控制器」,也叫「核心调度器」,因为它会根据你的请求去框架底层调度不同的 API,最终给你响应。整条主干可以压成一句横串:

浏览器请求 → Filter 链 → DispatcherServlet.doDispatch → HandlerMapping(返回执行链) → HandlerInterceptor.preHandle → HandlerAdapter.handle → Controller 方法 →(返回值 ModelAndView)→ ViewResolver 解析视图名 → View.render 渲染 → HTML 响应

拆开看每个环节各自负责什么:

环节关键类它负责什么视频里的说法源码核实情况
请求入口DispatcherServlet接住所有映射到它的请求,是唯一调度点前端控制器 / 核心调度器处理逻辑集中在doDispatch,属实
找处理器HandlerMapping按 URL、方法、参数把请求映射到处理方法从处理器映射器里按地址找getHandler()返回HandlerExecutionChain
前置拦截HandlerInterceptor在方法执行前后插入逻辑,可截断请求执行链里包含拦截器applyPreHandle返回 false 时直接 return
调用方法HandlerAdapter真正把你写的处理方法调起来「由 handler adapter 帮我们调用」ha.handle(...)返回ModelAndView
出入参转换HttpMessageConverter按媒体类型读写请求体 / 响应体AJAX 那条分支靠它官方文档确认@RequestBody用它反序列化
结果封装ModelAndView请求域数据 + 视图「不管返回什么都封装成 ModelAndView」只对走视图的分支成立,见 Q2
视图定位ViewResolver视图名加前缀后缀,定位到真实视图加/WEB-INF前缀、.jsp后缀在render里通过resolveViewName完成
渲染输出View.render把模型数据渲染进 JSP/FreeMarker/Thymeleaf 模板「解析视图上的动态数据,响应静态 HTML」view.render(mv.getModelInternal(), request, response)
Q1:为什么 HandlerMapping 返回的是「执行链」而不是直接返回方法?

因为一个请求要经过的不只是你的方法,还有一串拦截器。HandlerExecutionChain里装的是「目标 handler + 一组拦截器」,匹配到的拦截器按配置顺序全部塞进这条链,最后才是处理方法。视频里强调的这个设计,在源码里就是getHandler()的返回类型——如果只返回方法本身,拦截器就得由调用方自己再查一遍,顺序和短路逻辑(某个preHandle返回 false 就整条链路终止)会散得到处都是。

顺着这条链,还有一个细节值得记住:preHandle是在 Controller 方法之前跑的,postHandle是在方法返回之后、视图渲染之前,afterCompletion则在结果处理阶段触发。视频里只说了「依次进、依次出」,实际这三个时机是有明确差别的。

再看返回值这一句:视频里说「不管你返回 String 还是对象,底层最终都会封装成 ModelAndView」。这句话在走视图的分支上没问题,但在带@ResponseBody的分支上不准确——我对着源码看,ha.handle(...)完全可能返回 null,而后面做结果处理时是有条件的:只有mv != null且视图没被清除,才会进入渲染。也就是说,@ResponseBody场景下不是「包了一个 ModelAndView 再去解析视图」,而是压根没产生 ModelAndView。

Q2:AJAX / JSON 那条分支真的跳过视图解析了吗?

是的,跳过的不是某一步,而是后半整段。视频作者在讲完主流程后专门补了一句:如果请求的是 JSON 数据,后续的视图解析器、解析视图这些都不会有了,基本直接把数据响应回去。这在框架文档里能对上:@ResponseBody的返回值是通过HttpMessageConverter序列化到响应体的,@RequestBody则相反,是把请求体读取并反序列化成对象。

【入】请求体 JSON → HttpInputMessage → HttpMessageConverter.canRead(看 Content-Type)→ read → Java 参数 【出】Java 返回值 → canWrite → HttpMessageConverter.write → HttpOutputMessage → 响应体 JSON(视图链不再进入)

视频里给的配置说法是这样的:Spring MVC 默认支持的 JSON 实现是 Jackson,你只要把 Jackson 的依赖加上,再在 XML 里配一行annotation-driven,底层就会替你注册一个实现了HttpMessageConverter的 Jackson 转换器;如果你换成 Fastjson,就得手动把对应的转换器配进去,框架不会替你猜。这里有一个坑要提醒:具体转换器类名跟版本绑定,早年是MappingJackson2HttpMessageConverter(Jackson 2 时代),而 Spring 官方参考文档现在的示例里出现的是JacksonJsonHttpMessageConverter(Jackson 3 时代)。面试时把「read / write / canRead / canWrite 这四个动作 + 按媒体类型挑转换器」讲清楚,比背一个类名稳得多。


2. 控制器是单例:问题只出在「状态」上

视频里说这道题也可能换个问法:「Spring MVC 的控制器是不是线程安全的?」——两种问法答案同一套,因为它本质上就是一个 Bean,你没设置作用域时默认就是单例。所以这题不需要重新组织语言,只需要把「单例 Bean 的线程安全」那套答案搬到控制器上。

关键概念还是「有状态 / 无状态」:在类里声明了成员变量,就是有状态的单例对象;并发下多个线程同时读写这个字段,就有线安全问题。解决层次视频给了三条,我按代价排一下:

方案具体做法代价适用场景面试评价
消灭状态变量只声明在方法里(局部变量、方法参数)几乎为零绝大多数控制器首选,也是控制器的常态
线程隔离用ThreadLocal把值绑到当前线程需要记得清理必须跨方法传递请求内状态正解,但要补一句「用完 remove」
加锁synchronized/ 显式锁保护字段并行变串行,压吞吐确实要共享且必须互斥的状态视频原话:这是「下下策」

一句话理解:控制器本身不负责线程安全,负责线程安全的是你把状态放在哪一层。

这里视频还补了一个很实用的点:在控制器里你可以放心地直接注入HttpServletRequest、HttpSession这类 Servlet 作用域对象,不会有问题,因为框架底层早就把「当前请求」放进了 ThreadLocal,注进来的东西本身就是按线程取值的。这一条我在spring-web源码里能对上:RequestContextHolder内部就是两个ThreadLocal(一个是普通的,一个是可继承的),当前请求属性绑在上面;至于「注入时以可解析依赖的形式暴露」这一步,笔者没有逐行核到注册位置,属于合理推断。


3. 中文乱码:三个位置三把钥匙

这题视频给的开场答案很短:只针对 Spring MVC 来问的话,通常就是没配那个「编码过滤器」(转写里的 chart encoding field 就是CharacterEncodingFilter),给它设成 UTF-8 就够了。但作者紧接着把话说开了:乱码的成因很多,每一道关卡都要设成 UTF-8 才尽量不出事。这句话我完全同意——乱码不是一个 bug,是一串编码点里某一个对不上。

把「编码发生在哪」想清楚,解法自然就分开了:

乱码位置谁在做解码/编码官方默认值首选解法兜底手段最容易踩的坑
GET 的 query 参数Web 容器解析 URI 时URIEncoding默认 UTF-8(Tomcat 9 文档)前端正确 encode,容器编码统一useBodyEncodingForURI以为加了过滤器就行——过滤器跑在它后面
POST 表单体request.getParameter()请求没声明字符集时按 ISO-8859-1CharacterEncodingFilter,UTF-8方法内显式转码过滤器排在解析参数之后,设了也没用
响应体消息转换器 / 视图模板StringHttpMessageConverter默认字符集仍是 ISO-8859-1(源码核实)显式声明produces带 charset开forceEncoding连带设响应编码只设了响应头没设字符集
工程文件本身编译器 / IDE跟随 IDE 设置工具编码统一 UTF-8另存为时确认目标编码设了 IDE 但单个文件仍是 GBK
数据库读出来JDBC / 表字符集跟随库表与连接串库、表、连接串三处对齐—只改连接串不改表字符集

这里有两个「视频没说但很要命」的点,是我核对文档后补的。第一,CharacterEncodingFilter对 GET 的查询串是无效的:查询串的解码发生在容器解析 URI 阶段,Tomcat 文档里明确说,请求字符集未知时默认按 ISO-8859-1 处理,且URIEncoding不影响路径之外的这套默认;换句话说,GET 乱码要往前端 encode 和连接器编码去找,不要指望过滤器。第二,作者提到「设置一下 Tomcat 上的connector URIEncoding」,这条建议有明显的年代印记——Tomcat 官方文档里URIEncoding的默认值已经是 UTF-8,它主要影响的是老版本(早于 Tomcat 8 的年代默认是 ISO-8859-1);面试里可以说这句,但补一句「新版本默认已是 UTF-8」更稳。

至于过滤器本身,源码里的行为是:它是OncePerRequestFilter的子类,有forceRequestEncoding/forceResponseEncoding两个开关;只设编码时它只在请求没声明字符集的情况下生效,开了forceEncoding才会无条件覆盖,并且把响应也纳入默认编码(但这个响应编码通常会被视图里更完整的 Content-Type 覆盖掉)。


4. 拦截器 vs 过滤器:谁属于谁,决定谁先跑

视频作者给这题定调是「非常简单」,只要你搞懂两者的归属,区别和顺序是能自己推出来的。他总结了三点突出差别,我补成一张对照表:

对比维度HandlerInterceptor(Spring MVC)Servlet Filter(Servlet 规范)面试怎么答
依赖容器依赖 Spring MVC,不依赖 Servlet 容器依赖 Servlet 容器(Tomcat)归属决定生命周期长短
作用范围只对 DispatcherServlet 映射到的请求生效(视频里的「action 请求」)对所有进入容器的请求生效静态资源、404 页只有过滤器能拦
能否用 DI它本身是容器里的 Bean,可以注入原生 Filter 不能访问容器中的 BeanBoot 里用容器注册的 Filter 可以,作者自己补了这句
三个时机preHandle/postHandle/afterCompletion只有doFilter一个入口,靠链式调用前后夹拦截器能拿到 ModelAndView,Filter 拿不到
顺序在 DispatcherServlet 之内在 DispatcherServlet 之前谁在外面谁先跑

顺序这条最适合画成流水线,进和出的方向是对称的:

【进】请求 → Tomcat → Filter#1 → Filter#2 → Servlet(DispatcherServlet)→ Interceptor#1.preHandle → Interceptor#2.preHandle → Controller 【出】Controller → Interceptor#2.postHandle → Interceptor#1.postHandle → 视图渲染 → Interceptor#1.afterCompletion → Interceptor#2.afterCompletion → Filter#2 → Filter#1 → 响应

注意两个容易讲错的地方。第一,拦截器有多个时按配置的顺序执行,但postHandle/afterCompletion是反序退出的,视频只说「依次的出去」,方向要说清。第二,afterCompletion不只在正常路径上跑——源码里如果分发过程抛异常,会走triggerAfterCompletion补触发,所以它是「无论如何都要收尾」的地方(清理 ThreadLocal、埋点计时放这里)。


5. 父子容器:为什么要分,能不能合成一个

这组分 P 在视频里是一组连环问(阿里一面)。作者先把前提钉住了:Spring 整合 Spring MVC并不是一定要父子容器,一个容器照样能跑,「你可以去参考 Spring Boot,它就是一个容器就搞定了」。那为什么传统spring + springmvc的搭法就要分?他给了三个原因,外加一个他觉得牵强的第四个:

父容器(root WebApplicationContext,由 ContextLoaderListener 创建) ├─ 管:Service / DAO / 事务 / 数据源 / 第三方 Bean │ ↑ 子容器可见(查找沿 parent 向上) └─ 子容器(DispatcherServlet 的 WebApplicationContext) ├─ 管:Controller / 拦截器 / 视图解析器 / 消息转换器 └─ 对父容器:不可见 —— 父容器根本不知道子容器里有什么
  • 划分框架边界,有点单一职责的味道:Service、DAO 交给 Spring 管,Controller 和 Web 相关的交给 Spring MVC 管,职责清晰。
  • 规范整体架构:因为子能看到父、父看不到子,而现实中只有「Controller 依赖 Service」,从来没有人「Service 里注入 Controller」。可达性方向和依赖方向刚好一致,等于用容器结构强制了分层只能向下依赖。
  • 方便子容器切换:这是作者认为最实在的一条。早年确实有 Spring 配 Struts 2 组 SSH 的用法,Web 层不只有 Spring MVC 一个选项;把 Web 层单独抽成子容器后,要把 Spring MVC 换成 Struts 2,只需要换掉子容器的配置,父容器配置一行不用动。而 Spring Boot 从来没跟 Struts 2 搭配过,所以它压根不需要这层结构。
  • 顺带一提的「节省重复 Bean 创建」(多个子容器共用一个 Bean 就放父容器,避免同名冲突):作者自己都评价「这个原因有点牵强」,因为一个父容器挂两个子容器的场景在 Spring + Spring MVC 里很少见。这个坦率的评价我觉得比背四条原因更值得学。
Q3:把 Spring MVC 的 Bean 全交给父容器管理,行不行?

视频给的答案是肯定不行,而且给的理由非常具体:所有 Controller 都会被解析成HandlerMethod存进处理器映射器,请求进来时按 URI 匹配,匹配不上就是 404;而解析这一步拿的是当前容器的 BeanDefinition 名字,不是getBean——只有getBean那条路才会往上找父容器。所以你把 Controller 全放父容器,子容器压根不去那儿捞,注册不到处理方法,请求自然 404。作者现场翻源码时看到的方法是initHandlerMethods,还特意提醒「具体是不是这个名字我有点忘了」。

我把这段逻辑核实了一遍,方向和视频说的完全一致,而且比视频多一个开关:AbstractHandlerMethodMapping里有个getCandidateBeanNames(),实现是判断detectHandlerMethodsInAncestorContexts这个布尔位,它为 false(源码里的默认值就是 false)时走obtainApplicationContext().getBeanNamesForType(Object.class),为 true 时才换成BeanFactoryUtils.beanNamesForTypeIncludingAncestors(...)。这个位是RequestMappingHandlerMapping暴露的公开属性,打开后注释写得很清楚:to detect handler beans in ancestor contexts (typically the Spring root WebApplicationContext)。也就是说:默认不行,但配一个开关就能让子容器识别父容器里的 Controller。这一层视频没讲到,是可以拿来加分的地方。

Q4:反过来,把 Spring 的 Bean 全交给子容器管理,行不行?

视频的答案是可以:Controller 在子容器里能被解析,请求进得来;Controller 里注入 Service 也在同一个容器,注得上,所以功能上没问题。但一般不这么干,因为它把前面那三条优点(职责边界、架构约束、容器可切换)全丢了。

更要紧的是他点出的一个副作用,正好补进「AOP / 事务失效原因」那份清单里:如果你把事务或 AOP 的配置放在父容器,而 Bean 都在子容器,那么父容器做代理时扫的是自己容器里的 Bean,容器里没几个 Bean,动态代理根本不会被创建,增强自然不生效;这类配置必须跟着 Bean 待在子容器里。

配置组合映射解析Controller 注入 Service事务/AOP结论
Service 在父、Controller 在子(常规)正常正常(子可见父)配在父,作用在父容器 Bean 上,正常推荐
Controller 全放父容器拿不到 → 404——默认不行,需开祖先扫描开关
全部塞进子容器正常正常(同容器)必须一并搬到子容器,否则不生效能跑,但丢了分层约束
AOP/事务配父、Bean 在子正常正常代理对象创建不出来,增强失效经典踩坑组合

6. 零配置 Spring MVC:SPI 把 web.xml 换成了 Java Config

视频里先解释了什么叫「零配置」:一个 Spring MVC 的 Web 应用,web.xml没了,spring.xml、springmvc.xml也没了,取而代之用 Java 代码来配,也就是 Java Config。作者特别指出这条能力早在 Spring 3 之后的版本就有了,只是大家一直没用,直到 Spring Boot 流行才熟悉起来——很多人从 XML 直接跳到 Boot,不适应的缺口就在这儿。(Boot 的自动配置属于另一条线,这里只交代关系,不展开。)

省略之前要清楚每个文件原来在干什么,替换才有的放矢:

原来在哪配原作用零配置下由谁承担关键类
web.xml的ContextLoaderListener创建父容器SPI 入口实例化你的初始化类后注册监听器AbstractContextLoaderInitializer
web.xml的DispatcherServlet创建子容器、映射请求同上,改用 API 动态注册 ServletAbstractDispatcherServletInitializer
spring.xml父容器扫哪些包、排除 Controller父容器配置类getRootConfigClasses()返回的类
springmvc.xml子容器只包含 Controller、配拦截器/文件上传子容器配置类getServletConfigClasses()返回的类

原理的关键是一个字:SPI。视频里的描述是,Servlet 3.0 之后容器提供了一种 SPI 扩展方式,你在META-INF/services下放一个以接口全名命名的文件,文件里写实现类,容器启动时就会自动找到它并调用其onStartup方法——作者还类比了数据库驱动,「我们以前用的驱动方式一直也是 SPI」。这条链我核实过,在spring-web的源码与资源目录里躺得干干净净:

Servlet 容器启动 → 扫描 META-INF/services/jakarta.servlet.ServletContainerInitializer → 加载 org.springframework.web.SpringServletContainerInitializer(类上标 @HandlesTypes(WebApplicationInitializer.class)) → 容器把所有实现了该接口的【具体类】传进来 → 逐个实例化并调 onStartup(servletContext) → 你的子类 extends AbstractAnnotationConfigDispatcherServletInitializer → 父容器配置类 getRootConfigClasses() / 子容器配置类 getServletConfigClasses() → 两个容器建好

这里作者讲了一个很容易被忽略的机制:SPI 文件里维护的是 Spring 自带的那个入口类,而AbstractDispatcherServletInitializer这些是抽象类,抽象类不能被实例化,所以它当时不会起作用——只有你在应用里写一个继承它的子类,容器扫到具体类并实例化,onStartup里注册监听器和 DispatcherServlet 的代码才真正跑起来。这句话我在源码里对上了:SpringServletContainerInitializer的onStartup接收的是容器传进来的一组类型,它会过滤出WebApplicationInitializer的实现类逐个实例化调用。也正因如此,配置类不再靠 XML 文件,而是靠@Configuration类:父容器配置类里设置扫描包并排除Controller 注解,子容器配置类里只包含Controller 注解——这跟以前在 XML 里做 include/exclude 是一回事,作者现场也是这么对照着讲的。

面试时这道题的关键点点两个就够:原理是 SPI(容器启动时按META-INF/services找到入口类,再按@HandlesTypes把接口实现类交给它),落地是自己写一个继承AbstractAnnotationConfigDispatcherServletInitializer的子类,用它两个抽象方法分别交出父子容器的配置类。


7. 只有接口没有实现类的 Mapper,是怎么被容器管起来的

这是本篇的技术高点,也是视频里说「没看过源码听起来会坐飞机」的那道题。问题本身很朴素:MyBatis 的 Mapper 接口没有实现类,可你在 Service 里@Autowired一个接口就能用。答案分两层——接口能不能进容器和进来的东西怎么实例化。

先把两条底层事实摆正。第一条,MyBatis 侧:Mapper 接口的核心实现是 JDK 动态代理,这点视频说得很明确,我在 MyBatis 源码里也核实了,MapperProxyFactory创建实例时调的就是Proxy.newProxyInstance(...)。第二条,Spring 侧:默认的组件扫描不会为接口注册 BeanDefinition——扫描框架判断候选类的那个方法(转写里被识别成「Is candidate component」,真名是isCandidateComponent)要求元数据「独立且具体」,抽象类只有在带特定注解时才放行,接口直接出局。

所以整合方案必须回答三个问题,串起来是这条流水线:

MapperScannerConfigurer(一个 BeanDefinitionRegistryPostProcessor,能动态注册 BeanDefinition) → ClassPathMapperScanner(继承 Spring 内置扫描器,复用它的 class 文件解析能力) → 重写 isCandidateComponent:把「接口」放进来 → 改写 BeanDefinition 的 beanClass:从「接口」换成 MapperFactoryBean ← 视频称「偷天换日」 → MapperFactoryBean.getObject():在这里创建 JDK 动态代理,代理对象进容器 → getObjectType():告诉容器「这个 BeanDefinition 产出的类型是 UserMapper」

视频给的四步和这条流水线一一对应,我把它整理成表,并把官方叫法贴上:

关键点要解决的问题视频里的说法官方文档/源码里的名字是否核实
借扩展点注册内置扫描不认接口,得自己注册 BeanDefinition实现某个扩展接口,它会给你一个注册器MapperScannerConfigurer implements BeanDefinitionRegistryPostProcessor文档原话,已核实
复用扫描器100 个接口不可能手动注册 100 次继承 Spring 内置扫描器,重写排除接口的方法ClassPathMapperScanner extends ClassPathBeanDefinitionScanner已核实
放行接口让接口成为候选组件重写「排除接口」那个方法覆写后条件为「是接口、独立、且不是注解类型」已核实
偷天换日接口 BeanDefinition 无法实例化改掉 beanClass,改成 FactoryBeandefinition.setBeanClass(mapperFactoryBeanClass),默认MapperFactoryBean已核实
造代理真正产出可调用的对象在getObject()里自由控制实例化过程,创建 JDK 动态代理MapperFactoryBean<T> implements FactoryBean<T>已核实
Q5:getObjectType 为什么重要,它和「提前判型」是什么关系?

因为FactoryBean有两层身份:容器里真正存在的是 FactoryBean 这个 Bean,而你注入的是它getObject()生产出来的产品。问题就来了——当别人@Autowired UserMapper时,容器手里只有一个beanClass = MapperFactoryBean的 BeanDefinition,光看 BeanDefinition 根本不知道它会产出一个UserMapper。getObjectType()就是回答这个问题的:容器在不创建产品对象的前提下询问 FactoryBean「你会产出什么类型」,用这个答案完成按类型匹配。这个机制在源码里能看到确证:AbstractBeanFactory在做类型判断时,如果目标实例是 FactoryBean,就走getTypeForFactoryBean(factoryBean),而FactoryBeanRegistrySupport里这个方法的实现就是老老实实调factoryBean.getObjectType()(还顺带处理了它抛异常的情况,按契约返回 null)。

这里有一个和前面父子容器呼应的细节,很值得在面试里连起来讲:第 5 章里处理器映射器扫描候选用的是getBeanNamesForType(Object.class)——任何按类型的查找都会触发判型,也就可能触发 FactoryBean 的类型判断。MyBatis 的做法更进一步:它在改写 BeanDefinition 时,除了把beanClass换成MapperFactoryBean,还额外往 BeanDefinition 上写了一个「FactoryBean 产物类型」的属性(源码里那个常量是FACTORY_BEAN_OBJECT_TYPE),并把mapperInterface设进属性值。这一步的目的是让容器在更早、更便宜的时刻(还没实例化 FactoryBean 之前)就能知道类型,不至于为了判型被迫提前把 Bean 创建出来。为什么非做不可,涉及 Spring 内部判型与BeanPostProcessor时序,属于我的推断,视频没讲,我也没逐行核到,标在未核实里。

顺带把视频开头那句「Spring 内置扫描会把你排除掉」和这道题收个尾:Spring 不是不能管接口,而是它的默认注册路径不为接口买单;一旦有第三方通过合法扩展点把 BeanDefinition 注册进来、并把 beanClass 换成一个能产出对象的 FactoryBean,接口就和一个普通 Bean 没有任何区别了。这也是「Spring 的扩展点」这类题最标准的一个答案范式。


最后总结

这组分 P 放在一起看,其实是同一条判断链:Web 层的每一个「为什么」,都取决于「这件事归谁做」。DispatcherServlet 只做调度,映射归 HandlerMapping、调用归 HandlerAdapter、序列化归 HttpMessageConverter、渲染归 ViewResolver 和 View——搞混归属,就会答错@ResponseBody时视图解析器去哪了;乱码搞混归属,就会把过滤器当万能药,而 GET 查询串的解码根本发生在容器层;父子容器搞混归属,就解释不了「子能看到父、父看不到子」为什么会变成 404 和事务失效两个完全不同的症状;MyBatis 整合搞混归属,就会以为 Spring 天生能扫接口。

我个人更倾向于这样排这套连环问的答法:先用一句话给出主干流程(不超过 45 秒),然后主动挑一个有源码细节的点往下钻——detectHandlerMethodsInAncestorContexts这个默认关掉的开关、getObjectType()的提前判型、SPI 里「抽象类不会被实例化所以必须自己写子类」这三处,任何一处讲透,都比把七步流程背完更有说服力。视频作者反复强调「记住这几个关键点就够了」,我觉得这个取舍是对的:面试官要的是你能不能定位机制,不是你能不能默写流程。

本文覆盖分 P

分 P视频原始标题本文对应小节
P43Spring如何管理Mybaits的Mapper接口的7
P44说说你是如何解决 get 和 post 乱码问题?3
P45Spring MVC的控制器是不是单例模式,如果是,有什么问题,怎么解决?2
P46spring MVC的工作流程1
P47SpringMVC中处理AJAX请求原理?1(Q2 与消息转换器分支)
P48Spring和SpringMVC为什么需要父子容器?5
P49是否可以把SpringMVC所有Bean都交给Spring容器来管理?5(Q3)
P50是否可以把Spring的Bean都交给Spring-mvc来管理5(Q4)
P51如何实现零配置的SpringMVC?原理是什么?6
P52SpringMVC的拦截器和过滤器的区别和执行顺序4

事实说明

  • 视频里给的事实(转写明确讲到,仅代表视频表述):DispatcherServlet 叫前端控制器/核心调度器;HandlerMapping 返回含拦截器的执行链,由 HandlerAdapter 调用处理方法;返回值最终封装成 ModelAndView,视图解析器加前缀后缀定位视图再渲染成 HTML;AJAX/JSON 走HttpMessageConverter的 read/write,后续视图解析不再有;Spring MVC 默认支持 Jackson,XML 加annotation-driven即自动注册,Fastjson 要手动配转换器;控制器默认单例,有状态成员变量才有并发问题,解法是方法级变量、ThreadLocal、同步锁(锁是下下策);Servlet 作用域对象可放心注入,框架已放进 ThreadLocal;CharacterEncodingFilter设 UTF-8 是首要解法,其次 IDE 文件编码、Tomcatconnector URIEncoding、数据库编码逐关对齐;拦截器不依赖 Servlet 容器、只作用于 action 请求、可访问容器 Bean,过滤器依赖 Servlet 容器、作用于所有请求;顺序是过滤器 → Servlet → 拦截器 → Controller,再反序退出;父子容器三条原因(划边界、规范架构、便于切换 Web 框架),外加作者自认牵强的「省重复 Bean 创建」;Controller 全放父容器会 404,因为解析处理方法拿的是当前容器的 BeanDefinition 名字而非getBean;Bean 全放子容器可行,但 AOP/事务配置必须一起搬;零配置的关键字是 SPI,抽象类不能实例化所以必须自写子类,用getRootConfigClasses/getServletConfigClasses交出父子配置类;MyBatis 整合四个关键点(借BeanDefinitionRegistryPostProcessor注册、继承内置扫描器、重写排除接口的方法、偷天换日改 beanClass 为 FactoryBean 并在getObject()里造 JDK 动态代理)。
  • 官方文档 / 公开事实(本次逐条核实):DispatcherServlet.doDispatch内getHandler→applyPreHandle→getHandlerAdapter→ha.handle→applyPostHandle→processDispatchResult的调用顺序,且只有mv != null才render(Spring Framework 源码);AbstractHandlerMethodMapping.getCandidateBeanNames()在detectHandlerMethodsInAncestorContexts(默认 false)时只调当前容器getBeanNamesForType(Object.class),该开关的注释明确写着可识别祖先容器(通常是 Spring root WebApplicationContext)里的 handler Bean;@RequestBody经HttpMessageConverter反序列化、@ResponseBody经其序列化到响应体(Spring 官方参考文档,其中当前示例用的 JSON 转换器名为JacksonJsonHttpMessageConverter);CharacterEncodingFilter继承OncePerRequestFilter,具备forceRequestEncoding/forceResponseEncoding,forceEncoding时连带设为响应默认编码(Spring 源码与类注释);StringHttpMessageConverter.DEFAULT_CHARSET在源码当前主干上仍是ISO_8859_1;RequestContextHolder使用NamedThreadLocal/NamedInheritableThreadLocal持有请求属性;spring-web中存在META-INF/services/jakarta.servlet.ServletContainerInitializer,内容为org.springframework.web.SpringServletContainerInitializer,该类标注@HandlesTypes(WebApplicationInitializer.class)并负责实例化实现类后调用其onStartup;AbstractDispatcherServletInitializer.registerDispatcherServlet通过createServletApplicationContext()建子容器再servletContext.addServlet(...);Tomcat 官方文档中 HTTP Connector 的URIEncoding默认值为 UTF-8,useBodyEncodingForURI默认 false,且请求字符集未知时默认按 ISO-8859-1;MyBatis-Spring 文档原话「MapperScannerConfigureris aBeanDefinitionRegistryPostProcessor」,以及MapperFactoryBean注册 mapper、@MapperScan/<mybatis:scan>扫描、mapper 线程安全可直接注入;MyBatis 源码MapperProxyFactory使用Proxy.newProxyInstance;MyBatis-Spring 源码中ClassPathMapperScanner继承ClassPathBeanDefinitionScanner、覆写后的候选条件为「是接口 + 独立 + 非注解类型」、definition.setBeanClass(mapperFactoryBeanClass)、写入FACTORY_BEAN_OBJECT_TYPE属性、未显式指定工厂时启用按类型自动装配;MapperFactoryBean<T> extends SqlSessionDaoSupport implements FactoryBean<T>且实现getObjectType();AbstractBeanFactory判型时对 FactoryBean 走getTypeForFactoryBean,其实现调用factoryBean.getObjectType();Spring 默认扫描的候选条件为「独立且具体(或带特定注解的抽象类)」,接口被排除。
  • 笔者判断 / 未核实:「不管返回什么都封装成 ModelAndView」我判断只在走视图的分支成立,@ResponseBody场景源码里mv为 null(属对视频表述的修正,不是官方结论);「getBeanNamesForType触发判型会让 MyBatis 额外写FACTORY_BEAN_OBJECT_TYPE以避免 FactoryBean 被提前实例化」是结合源码注释「for spring-native」做的推断,具体时序未逐行核实;「请求/会话对象以可解析依赖形式注入」这一步未核到注册位置,只核实了 ThreadLocal 那一半;MyBatis-Spring 文档版本号显示为 4.1.1-SNAPSHOT(开发中快照),文中未据其推断任何 Spring 版本兼容矩阵;HandlerAdapter注解式实现的确切类名、DispatcherServlet.properties里的默认策略清单,本次抓取超时未核对;各分 P 时长与顺序取自字幕文件元信息,视频画面里的示意图内容无法从转写还原,本文所有流程图均按文字描述重绘,不代表原图。

参考资料 & 致谢

[1] Spring面经-哔哩哔哩(素材来源,共 77 个分 P,本文取自其中 P43~P52;文中一律称「视频作者」)

[2] Spring Framework 官方项目页

[3] Spring MVC 参考文档 - DispatcherServlet / @RequestBody / @ResponseBody

[4] Spring Framework 源码仓库(DispatcherServlet、AbstractHandlerMethodMapping、CharacterEncodingFilter、SpringServletContainerInitializer)

[5] MyBatis 官网

[6] MyBatis-Spring 参考文档 - Injecting Mappers / Scanning for mappers

[7] MyBatis 3 源码仓库(MapperProxyFactory)

[8] Apache Tomcat 官网

[9] Tomcat 9 - HTTP Connector 配置(URIEncoding / useBodyEncodingForURI)

[10] Jackson 项目主页

[11] Fastjson2 项目主页

[12] Apache Struts 官网

[13] Spring Boot 官方项目页(本文仅用于对照说明「单容器」,展开留给系列另一篇)

[14] Oracle Java SE 文档(动态代理)

感谢视频作者把这组 Web 层连环问拆得这么细,尤其是「处理器映射扫的是 BeanDefinition 名字而不是 getBean」这一条,是全套题里最容易被讲错的地方;也感谢我自己在核对源码时发现的两个开关(detectHandlerMethodsInAncestorContexts和 TomcatURIEncoding的默认值),它们正好说明口述内容和当前版本行为之间需要一步对照验证。

返回列表