Spring MVC 请求流程:DispatcherServlet、HandlerMapping、Adapter、ViewResolver(093)

Spring与Spring Boot约 14 分钟阅读7,421 字符
知识库文档 94 / 251

引言:请求进来之后,到底经过了谁的手

排查 Spring MVC 的问题时,几乎所有报障都能归到"请求流程"上的某一环:接口返回 404,是没有任何 Handler 认领这个 URL;接口返回 406/415,是找不到合适的消息转换器;页面渲染报"找不到视图",是 ViewResolver 解析失败;参数怎么传都绑定不上,是参数解析器没有覆盖这种入参形式。这些问题的共同答案,都藏在 DispatcherServlet.doDispatch() 这一个方法里。这篇文章把这条主干流程完整讲清楚:请求怎么被路由到 Controller、方法怎么被找到并调用、返回值怎么变成最终的 HTTP 响应。

一、DispatcherServlet:前端控制器模式的具体实现

文章配图
文章配图

Spring MVC 遵循经典的**前端控制器(Front Controller)**模式:所有进入应用的请求先统一交给一个 Servlet(DispatcherServlet),再由它分发给具体的处理逻辑,而不是让每个 URL 对应一个独立的 Servlet。容器启动时的配置(Servlet 容器场景下通常是 <servlet-mapping> 的 url-pattern 设为 /,Spring Boot 场景下由 DispatcherServletAutoConfiguration 自动注册)决定了几乎所有请求都会先经过 DispatcherServlet。

DispatcherServlet 内部持有九个核心策略组件,初始化阶段(initStrategies())会依次加载:

组件 职责
HandlerMapping 根据请求找到应该由哪个方法处理
HandlerAdapter 用统一的方式去调用形态各异的 Handler
HandlerExceptionResolver 统一处理 Handler 执行过程中抛出的异常
ViewResolver 把逻辑视图名解析成真正的 View 对象
RequestToViewNameTranslator Handler 没有显式返回视图名时,按约定推断一个
LocaleResolver / ThemeResolver 国际化与主题相关的辅助解析
MultipartResolver 识别并解析文件上传请求
FlashMapManager 管理重定向场景下跨请求传递的临时数据

这篇文章聚焦其中最核心的三个:HandlerMapping(找谁处理)、HandlerAdapter(怎么调用)、ViewResolver(怎么渲染),它们串联起了一次请求从进入到响应的完整生命周期。

文章配图
文章配图

二、HandlerMapping:请求怎么找到对应的方法

文章配图
文章配图

启动阶段:建立 URL 到方法的映射表

容器启动时,RequestMappingHandlerMapping(最常用的 HandlerMapping 实现)会扫描所有 @Controller/@RequestMapping 标注的 Bean,把类级别路径和方法级别路径拼接后,注册进一张内部映射表(MappingRegistry),核心结构大致是:

java
// 简化表示,真实实现按 RequestMappingInfo 做更精细的匹配(HTTP方法、参数、头部条件等)
Map<RequestMappingInfo, HandlerMethod> registry;

RequestMappingInfo 不只包含 URL 路径,还包含 HTTP 方法(GET/POST 等)、请求参数条件、请求头条件、内容类型条件——这也是为什么同一个路径可以用 @GetMapping 和 @PostMapping 分别映射到两个不同方法,它们在注册表里是两条独立的记录。

文章配图
文章配图

请求到达时: 的匹配过程

java
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    for (HandlerMapping mapping : this.handlerMappings) {
        HandlerExecutionChain handler = mapping.getHandler(request);
        if (handler != null) {
            return handler;
        }
    }
    return null;
}

DispatcherServlet 会依次询问容器里所有的 HandlerMapping(不止 RequestMappingHandlerMapping,静态资源也有专门的 HandlerMapping),第一个返回非空结果的就被采用。返回的不是一个裸的方法引用,而是一个 HandlerExecutionChain——它把目标 HandlerMethod 和这次请求需要经过的一串 HandlerInterceptor 打包在一起。

排查 404 的三个常见根源

  1. 路径确实没有任何 Controller 声明——最直接的情况,检查类级 @RequestMapping 和方法级路径拼接后的最终路径。
  2. 静态资源映射抢先命中。Spring Boot 默认会给 /static、/public 等目录下的资源注册一个 SimpleUrlHandlerMapping,如果业务路径恰好和静态资源路径规则冲突,可能被错误的 HandlerMapping 提前"认领"(返回 404 或者返回静态文件本身,具体取决于文件是否存在)。
  3. HTTP 方法不匹配:路径本身注册了,但只注册了 @PostMapping,用 GET 请求访问会匹配不上 RequestMappingInfo 里的方法条件,这种情况实际会得到 405 而不是 404——这本身就是 HandlerMapping 匹配逻辑区分"路径存在但条件不满足"和"路径完全不存在"这两种情形的直接体现。

三、HandlerAdapter:用适配器模式抹平 Handler 形态的差异

为什么需要一层适配器

Spring MVC 历史上支持过多种风格的 Handler:实现 Controller 接口的类、实现 HttpRequestHandler 接口的类,以及现在最常用的、用 @RequestMapping 系列注解标注的普通方法(HandlerMethod)。这些 Handler 的方法签名完全不同,DispatcherServlet 不可能对每种形态写一段专门的调用代码,于是引入了适配器模式:HandlerAdapter 接口统一暴露一个 handle(request, response, handler) 方法,具体怎么调用目标 Handler 由各自的适配器实现类内部处理。

java
public interface HandlerAdapter {
    boolean supports(Object handler);
    ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
}

DispatcherServlet 找到 Handler 之后,同样是遍历容器里所有的 HandlerAdapter,调用 supports() 找到第一个能处理当前 Handler 类型的适配器——这跟 HandlerMapping 的"遍历 + 第一个命中"是同一套设计思路。

文章配图
文章配图

:现代 Spring MVC 的核心适配器

对于最常见的 @RequestMapping 方法,真正干活的是 RequestMappingHandlerAdapter.invokeHandlerMethod()。这里有两组关键组件:

  • **HandlerMethodArgumentResolver**:负责把 HttpServletRequest 里的各种数据(路径变量、查询参数、请求体、请求头、Cookie……)解析并绑定成目标方法的入参。每一种参数类型(@PathVariable、@RequestParam、@RequestBody、HttpServletRequest 本身等)都对应一个具体的解析器实现,方法调用前会遍历所有已注册的解析器,找到第一个 supportsParameter() 返回 true 的来处理这个参数。
  • **HandlerMethodReturnValueHandler**:方法执行完之后,返回值同样需要被"翻译"成响应——返回 String 被当作视图名,返回 ModelAndView 直接使用,标注了 @ResponseBody(或类上标了 @RestController)的返回值会被交给 HttpMessageConverter 直接序列化写入响应体,完全不走视图解析这条路。

这组"参数解析器 + 返回值处理器"的设计,本质上是把"HTTP 请求/响应"和"普通 Java 方法调用"之间的转换逻辑,拆成了一组可插拔、可扩展的策略对象——这也是为什么你可以自己实现一个 HandlerMethodArgumentResolver 来支持自定义参数注解,完全不需要改动框架源码。

文章配图
文章配图

四、:一次请求的完整调用时序

把 HandlerMapping 和 HandlerAdapter 串起来,DispatcherServlet.doDispatch() 的主干逻辑大致是:

java
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HandlerExecutionChain mappedHandler = getHandler(request);
    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

    // 拦截器链:前置处理,任意一个返回 false 就中断请求
    if (!mappedHandler.applyPreHandle(request, response)) {
        return;
    }

    ModelAndView mv = null;
    try {
        mv = ha.handle(request, response, mappedHandler.getHandler());
    }
    catch (Exception ex) {
        // 异常会被 processHandlerException 交给 HandlerExceptionResolver 链条处理
        // 处理成功则得到一个"异常对应的错误视图"ModelAndView,继续走正常渲染流程
    }

    // 拦截器链:后置处理,能拿到 ModelAndView 并可以修改
    mappedHandler.applyPostHandle(request, response, mv);

    // 渲染视图 / 如果已经通过 @ResponseBody 写完响应,这一步基本是空操作
    processDispatchResult(request, response, mappedHandler, mv, null);

    // 不管成功还是失败,最终触发拦截器的 afterCompletion
    mappedHandler.triggerAfterCompletion(request, response, null);
}

这条主干逻辑解释了拦截器(HandlerInterceptor)三个方法各自的执行时机:preHandle 在真正调用 Handler 之前、可以直接中断请求;postHandle 在 Handler 执行完之后、视图渲染之前,可以修改 ModelAndView;afterCompletion 无论成功还是异常都会执行,适合做资源清理、日志记录。这也是为什么"权限校验"通常放在 preHandle,而"记录响应耗时"通常放在 afterCompletion——这不是约定俗成的最佳实践,而是这条调用时序本身决定的。

文章配图
文章配图

五、ViewResolver:逻辑视图名怎么变成真实响应

文章配图
文章配图

返回视图名这条路径

如果 Handler 方法返回的是一个 String(视图名)或者 ModelAndView,processDispatchResult 最终会调用 resolveViewName():

java
protected View resolveViewName(String viewName, Map<String, Object> model,
        Locale locale, HttpServletRequest request) throws Exception {
    for (ViewResolver viewResolver : this.viewResolvers) {
        View view = viewResolver.resolveViewName(viewName, locale);
        if (view != null) {
            return view;
        }
    }
    return null;
}

同样是"遍历所有 ViewResolver,第一个返回非空结果的生效"。得到 View 对象之后,调用 view.render(model, request, response),由具体的视图技术(JSP、Thymeleaf、FreeMarker 等)完成实际的 HTML 渲染并写入响应流。

这条路径:完全绕开 ViewResolver

这是最容易被误解的一点:标注了 @ResponseBody(或 @RestController)的方法,返回值处理在 HandlerMethodReturnValueHandler 阶段就已经完成——对应的处理器(RequestResponseBodyMethodProcessor)直接调用 HttpMessageConverter 把返回对象序列化(比如转成 JSON)并写入响应体,invokeHandlerMethod() 返回的 ModelAndView 是 null。processDispatchResult 拿到 null 的 ModelAndView,不会做任何视图解析,直接跳过。

也就是说,@ResponseBody 和"返回视图名"这两种写法,实际上是两条完全独立的响应生成路径,只是共享了同一个 doDispatch() 主干和同一层拦截器链——理解了这一点,"为什么 REST 接口不需要配置 ViewResolver"这种问题也就有了确切答案:不是不需要配置就能生效,而是这条代码路径压根不会走到 ViewResolver 那一步。

文章配图
文章配图

404/415/406 分别对应哪个环节没找到匹配

  • 404 Not Found:HandlerMapping 没有找到任何匹配的 Handler。
  • 405 Method Not Allowed:路径匹配上了,但 HTTP 方法不在 RequestMappingInfo 允许的范围内。
  • 415 Unsupported Media Type:请求体的 Content-Type 没有任何 HttpMessageConverter 能处理(比如声明只接受 application/json 却传了 application/xml)。
  • 406 Not Acceptable:Accept 请求头指定的响应格式,没有任何 HttpMessageConverter 能生成(常见于误配了 produces 属性)。
  • 视图渲染报"找不到视图":路径正确、方法执行成功,但 ViewResolver 按约定规则(前缀+视图名+后缀)拼出的物理路径下没有对应的模板文件。

把这五种报错对照到具体环节,排查思路就不再是"猜配置哪里错了",而是先判断这次请求卡在流程图上的哪一个节点。

文章配图
文章配图

六、小结:从请求进来到响应出去的完整路径

code
请求到达 DispatcherServlet.doDispatch()
    → getHandler():遍历 HandlerMapping,找到 HandlerExecutionChain(HandlerMethod + 拦截器链)
    → getHandlerAdapter():遍历 HandlerAdapter,找到能处理这个 Handler 类型的适配器
    → mappedHandler.applyPreHandle():拦截器前置处理,可直接中断请求
    → ha.handle():
        → 参数解析:HandlerMethodArgumentResolver 把请求数据绑定成方法入参
        → 执行目标方法
        → 返回值处理:HandlerMethodReturnValueHandler
            → 返回视图名/ModelAndView:走视图解析路径
            → @ResponseBody:HttpMessageConverter 直接写响应体,ModelAndView 为 null
    → mappedHandler.applyPostHandle():拦截器后置处理
    → processDispatchResult():
        → ModelAndView 非空:resolveViewName() 遍历 ViewResolver,得到 View 后 render()
        → ModelAndView 为空:跳过视图渲染(响应已经在 handle 阶段写完)
    → mappedHandler.triggerAfterCompletion():无论成功失败都执行

这张图上的每一个节点,都能对应到一类具体的报障现象。下次遇到"接口莫名其妙不工作",先定位卡在哪个节点,比直接猜配置效率高得多。

文末答疑:面试官会怎么问

Q1:如果同一个应用里注册了两个**HandlerMapping**,一个是自定义的、专门处理某类特殊路径,另一个是 Spring MVC 默认的**RequestMappingHandlerMapping**,两者对同一个路径都能返回非空的匹配结果,最终会用哪一个?这个顺序由什么决定,你会怎么控制它?

A:DispatcherServlet 遍历 handlerMappings 列表时是按顺序取第一个非空结果,这个顺序由各个 HandlerMapping 实现的 Ordered 优先级(getOrder())决定,数值越小优先级越高、越先被询问。如果两个 HandlerMapping 都能匹配同一个路径且没有显式设置顺序,实际生效的是哪一个会依赖它们在容器里的默认注册顺序,这是一种容易踩雷的隐式行为。工程上应该给自定义的 HandlerMapping 显式实现 Ordered 接口或使用 @Order 注解,明确声明它应该在 RequestMappingHandlerMapping 之前还是之后被询问,而不是依赖框架的默认加载顺序。

Q2:**HandlerInterceptor.postHandle()**里能不能修改 Controller 已经准备好的响应数据?如果 Controller 方法标注了**@ResponseBody**,这个修改还生效吗?为什么?

A:如果是返回视图名/ModelAndView 的传统流程,postHandle 拿到的 ModelAndView 还没有被渲染,此时修改其中的 Model 数据是有效的,会体现在最终渲染出的视图里。但如果方法标注了 @ResponseBody,返回值处理阶段已经通过 HttpMessageConverter 把数据序列化并直接写入了响应流,invokeHandlerMethod() 返回的 ModelAndView 是 null——postHandle 这时候拿到的是空对象,任何修改都不会有任何效果,因为响应体已经写完了。这也是为什么如果需要在 @ResponseBody 场景下统一修改返回内容(比如包一层统一响应结构),应该用 ResponseBodyAdvice 这种在消息转换器写入之前介入的扩展点,而不是指望在拦截器的 postHandle 阶段修改。

Q3:假设一个 Spring MVC 应用同时配置了全局异常处理(**@ControllerAdvice**+**@ExceptionHandler**)和自定义的**HandlerExceptionResolver**,Controller 方法抛出异常之后,实际的处理顺序和优先级是怎样的?

A:@ExceptionHandler 的底层实现本身就是一种 HandlerExceptionResolver(ExceptionHandlerExceptionResolver),Spring MVC 在 HandlerExceptionResolverComposite 里维护了一个有序列表,默认包含 ExceptionHandlerExceptionResolver、ResponseStatusExceptionResolver、DefaultHandlerExceptionResolver 等,自定义的 HandlerExceptionResolver 也会加入这个列表,顺序同样受 Ordered/@Order 控制。异常处理链条会按顺序依次询问每个 Resolver,第一个能处理(返回非 null 的 ModelAndView)的生效,后面的不再执行。所以回答这个问题的关键点是:不存在"全局异常处理器和自定义 Resolver 谁天生优先"的说法,谁先谁后完全取决于它们在这个有序列表里的相对位置,需要显式检查或配置 @Order。

Q4:如果要在不修改任何业务 Controller 代码的前提下,给某一类接口统一增加"请求耗时超过阈值就打警告日志"的能力,你会选择在**HandlerInterceptor**、AOP 切面还是 Filter 里实现?为什么,这个选择跟本文讲的请求流程有什么关系?

A:三者都能拿到"进入"和"退出"两个时间点,但作用范围和能获取的信息不同:Filter 工作在 Servlet 容器层面,比 DispatcherServlet 更早介入,拿不到具体匹配到的是哪个 HandlerMethod,适合做非常通用、跟具体业务路由无关的处理(比如统一编码设置);HandlerInterceptor 工作在 DispatcherServlet 内部,preHandle/afterCompletion 之间正好完整覆盖了 HandlerAdapter.handle() 执行的全过程(包括参数解析和返回值处理),并且能从 HandlerMethod 上拿到方法、注解等元信息,天然适合按 Handler 粒度做统一的耗时统计或日志;AOP 切面则是绑定在具体 Bean 的方法调用上,如果目标是"所有走 MVC 请求流程的接口",用 HandlerInterceptor 更贴合语义,因为它本来就是 Spring MVC 请求流程里专门为这类横切需求设计的扩展点,不需要额外操心"这个方法有没有被 AOP 代理""这次调用是不是从外部发起的"这些本文反复出现过的坑。