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,最终给你响应。整条主干可以压成一句横串:
text
浏览器请求 → 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 则相反,是把请求体读取并反序列化成对象。
text
【入】请求体 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-1 | CharacterEncodingFilter,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 不能访问容器中的 Bean | Boot 里用容器注册的 Filter 可以,作者自己补了这句 |
| 三个时机 | preHandle / postHandle / afterCompletion |
只有 doFilter 一个入口,靠链式调用前后夹 |
拦截器能拿到 ModelAndView,Filter 拿不到 |
| 顺序 | 在 DispatcherServlet 之内 | 在 DispatcherServlet 之前 | 谁在外面谁先跑 |
顺序这条最适合画成流水线,进和出的方向是对称的:
text
【进】请求 → 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 的搭法就要分?他给了三个原因,外加一个他觉得牵强的第四个:
text
父容器(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 动态注册 Servlet | AbstractDispatcherServletInitializer |
spring.xml |
父容器扫哪些包、排除 Controller | 父容器配置类 | getRootConfigClasses() 返回的类 |
springmvc.xml |
子容器只包含 Controller、配拦截器/文件上传 | 子容器配置类 | getServletConfigClasses() 返回的类 |
原理的关键是一个字:SPI。视频里的描述是,Servlet 3.0 之后容器提供了一种 SPI 扩展方式,你在 META-INF/services 下放一个以接口全名命名的文件,文件里写实现类,容器启动时就会自动找到它并调用其 onStartup 方法------作者还类比了数据库驱动,「我们以前用的驱动方式一直也是 SPI」。这条链我核实过,在 spring-web 的源码与资源目录里躺得干干净净:
text
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)要求元数据「独立且具体」,抽象类只有在带特定注解时才放行,接口直接出局。
所以整合方案必须回答三个问题,串起来是这条流水线:
text
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,改成 FactoryBean | definition.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 | 视频原始标题 | 本文对应小节 |
|---|---|---|
| P43 | Spring如何管理Mybaits的Mapper接口的 | 7 |
| P44 | 说说你是如何解决 get 和 post 乱码问题? | 3 |
| P45 | Spring MVC的控制器是不是单例模式,如果是,有什么问题,怎么解决? | 2 |
| P46 | spring MVC的工作流程 | 1 |
| P47 | SpringMVC中处理AJAX请求原理? | 1(Q2 与消息转换器分支) |
| P48 | Spring和SpringMVC为什么需要父子容器? | 5 |
| P49 | 是否可以把SpringMVC所有Bean都交给Spring容器来管理? | 5(Q3) |
| P50 | 是否可以把Spring的Bean都交给Spring-mvc来管理 | 5(Q4) |
| P51 | 如何实现零配置的SpringMVC?原理是什么? | 6 |
| P52 | SpringMVC的拦截器和过滤器的区别和执行顺序 | 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;文中一律称「视频作者」)
3 Spring MVC 参考文档 - DispatcherServlet / @RequestBody / @ResponseBody
6 MyBatis-Spring 参考文档 - Injecting Mappers / Scanning for mappers
7 MyBatis 3 源码仓库(MapperProxyFactory)
9 Tomcat 9 - HTTP Connector 配置(URIEncoding / useBodyEncodingForURI)
10 Jackson 项目主页
13 Spring Boot 官方项目页(本文仅用于对照说明「单容器」,展开留给系列另一篇)
感谢视频作者把这组 Web 层连环问拆得这么细,尤其是「处理器映射扫的是 BeanDefinition 名字而不是 getBean」这一条,是全套题里最容易被讲错的地方;也感谢我自己在核对源码时发现的两个开关(detectHandlerMethodsInAncestorContexts 和 Tomcat URIEncoding 的默认值),它们正好说明口述内容和当前版本行为之间需要一步对照验证。