Skip to content

Spring MVC 请求执行链与扩展点全过程

Spring MVC 是建立在 Servlet 规范之上的 Web MVC 框架。它不是“收到 URL 后反射调用 Controller”这么简单,而是把一次请求拆成:Servlet 容器接收、前端控制器调度、请求映射、拦截器、参数解析、数据绑定、校验、业务调用、返回值处理、内容协商、消息转换、异常解析和响应提交等多个可扩展阶段。

理解 Spring MVC 的关键不是背八步流程,而是回答三个问题:

  1. 当前请求现在处于哪一层;
  2. 这一层由哪个组件负责;
  3. 出错后由谁转换、是否还能修改响应。

学习目标

学完本页,你应当能够:

  • 从 TCP 请求进入 Servlet 容器开始,讲到 JSON 字节写回客户端;
  • 解释 DispatcherServlet 初始化和 doDispatch 主流程;
  • 说清 HandlerMappingHandlerAdapter 为什么必须分开;
  • 解释 @RequestMapping 如何注册、匹配和处理冲突;
  • 解释参数解析、数据绑定、类型转换、校验之间的边界;
  • 说清 @RequestParam@PathVariable@ModelAttribute@RequestBody 的数据来源;
  • 解释 Content-TypeAcceptHttpMessageConverter、415 和 406 的关系;
  • 解释返回值处理器、异常解析器、视图渲染和响应提交;
  • 解释 Filter、Interceptor、ControllerAdvice、AOP 的适用边界;
  • 理解 Servlet 异步、CallableDeferredResult 的线程和重派发过程;
  • 运行 JDK 8 + Spring Boot 2.7 Demo;
  • 根据 404、400、415、406、500、首字节慢、响应已提交等现象完成生产排查。

一、Spring MVC 解决了什么问题

直接编写 Servlet 时,每个接口都可能重复完成:

  • 解析 URI、query string、header 和 body;
  • 把字符串转成 Long、日期、枚举或业务对象;
  • 校验参数并组织错误响应;
  • 查找要执行的方法;
  • 读取和写入 JSON;
  • 管理字符编码和媒体类型;
  • 统一记录日志、鉴权和异常;
  • 选择视图并渲染 HTML;
  • 处理文件上传和异步请求。

Spring MVC 把稳定流程抽成框架,把差异点放入策略接口。Controller 只描述“这个请求映射到哪个业务入口、需要什么输入、返回什么结果”,通用机制由可替换组件完成。

mermaid
flowchart TD
    A["HTTP请求"] --> B["Servlet容器解析协议"]
    B --> C["Filter链"]
    C --> D["DispatcherServlet统一调度"]
    D --> E["映射、参数、校验"]
    E --> F["Controller调用Service"]
    F --> G["返回值或异常处理"]
    G --> H["对象转换为JSON或视图"]
    H --> I["Servlet容器写回响应"]

二、先分清 Servlet 容器与 Spring 容器

Tomcat、Jetty、Undertow 等 Servlet 容器负责网络连接、HTTP 解析、Servlet 生命周期、Filter 链、请求线程和响应输出。Spring 容器负责 BeanDefinition、依赖注入、生命周期、AOP 等。DispatcherServlet 同时处在两者交界处:

  • 对 Servlet 容器,它是一个 Servlet;
  • 对 Spring,它持有或关联一个 WebApplicationContext,从中取得 MVC 策略组件和 Controller Bean。

传统 WAR 应用常见两层上下文:

  • root context:Service、Repository、数据源等共享 Bean;
  • DispatcherServlet child context:Controller、HandlerMapping、ViewResolver 等 Web Bean。

子容器通常可以向父容器查找 Bean,父容器不能反向看到子容器。Spring Boot 常把配置整合得更自然,但理解父子容器仍有助于排查“Service 能注入,Controller 找不到”或“Bean 重复注册”等问题。

三、应用启动时发生什么

3.1 Servlet 注册

传统部署通过 web.xmlWebApplicationInitializer 注册 DispatcherServlet。Spring Boot 通过自动配置创建并注册它,默认映射通常是 /。映射 / 不表示它会绕过静态资源处理器,最终仍由 HandlerMapping 判断请求交给 Controller、资源处理器还是其他 Handler。

3.2 初始化 WebApplicationContext

Servlet 容器调用 DispatcherServlet.init() 后,父类 FrameworkServlet 初始化或查找 WebApplicationContext,refresh 容器,并在上下文刷新完成后初始化 MVC 策略。

3.3 初始化九类常用策略

Spring 5 的 DispatcherServlet.initStrategies() 会准备下列组件,实际是否存在、是否多个以及默认实现取决于配置和版本:

策略作用
MultipartResolver识别和解析 multipart 文件上传
LocaleResolver解析地区和语言
ThemeResolver传统主题解析,现代 REST 项目较少使用
HandlerMapping查找请求对应的 Handler
HandlerAdapter以统一方式执行不同 Handler
HandlerExceptionResolver把异常转换为 ModelAndView 或响应
RequestToViewNameTranslator无显式视图名时推导默认视图名
ViewResolver把逻辑视图名解析为 View
FlashMapManager管理重定向时的一次性 Flash 属性
mermaid
flowchart TD
    A["Servlet容器创建DispatcherServlet"] --> B["FrameworkServlet初始化WebApplicationContext"]
    B --> C["刷新Spring容器"]
    C --> D["发现Controller和MVC基础设施"]
    D --> E["initStrategies读取策略Bean"]
    E --> F["初始化HandlerMapping"]
    F --> G["扫描并注册HandlerMethod"]
    G --> H["应用进入可接收请求状态"]

3.4 为什么启动阶段要扫描 Controller

RequestMappingHandlerMapping 在初始化时查找 Handler Bean,读取类和方法上的 @RequestMapping@GetMapping 等注解,将映射条件组织成 RequestMappingInfo,再注册到内部 MappingRegistry。

启动期注册的好处是:

  • 重复映射可以尽早失败;
  • 请求到来时无需重新扫描全部 Controller;
  • 路径、HTTP 方法、参数、Header、Consumes、Produces 等条件可预先整理;
  • Actuator 等工具可以展示映射清单。

四、@RequestMapping 怎样变成可匹配映射

以下方法不只是注册一个字符串 /orders/{id}

java
@GetMapping(value = "/orders/{id}", produces = "application/json")
public OrderResponse detail(@PathVariable Long id) {
    return orderService.detail(id);
}

框架会形成多个条件的组合:

  • 路径条件:/orders/{id}
  • HTTP 方法:GET;
  • params 条件;
  • headers 条件;
  • consumes 条件;
  • produces 条件;
  • 自定义 RequestCondition。

类级映射与方法级映射会合并。两个方法如果最终条件无法区分,启动时可能抛出 ambiguous mapping;如果都能匹配请求,运行期会比较具体程度,仍无法唯一确定时也会报歧义。

4.1 路径匹配版本差异

Spring 5.3 同时存在传统 AntPathMatcher 和新的 PathPatternParser 路线;Spring 6 体系更偏向 PathPattern。二者在尾斜杠、编码字符、**、路径参数和性能上存在差异。升级时不能只看 Controller 代码不变,应回归:

  • /orders/orders/
  • 编码后的 /、分号内容和特殊字符;
  • /** 与多段变量;
  • 网关重写后的 context path;
  • 静态资源与 Controller 冲突。

五、一次请求的 doDispatch 主流程

请求经过 Servlet service()FrameworkServlet.processRequest() 后进入 DispatcherServlet.doService(),随后进入核心 doDispatch()。稳定主线如下:

  1. 检查并解析 multipart 请求;
  2. 依次询问 HandlerMapping,得到 HandlerExecutionChain
  3. 找不到 Handler 时执行 noHandlerFound 或返回 404;
  4. 根据 Handler 类型找到支持它的 HandlerAdapter;
  5. 执行拦截器 preHandle
  6. 由 HandlerAdapter 调用 Handler;
  7. 获得 ModelAndView,或响应已被返回值处理器直接写出;
  8. 执行拦截器 postHandle
  9. 处理异常或渲染视图;
  10. 执行 afterCompletion
  11. 清理 multipart 临时资源;
  12. 异步请求则进入另一套异步完成和重派发流程。
mermaid
flowchart TD
    A["DispatcherServlet.doDispatch"] --> B["checkMultipart"]
    B --> C["HandlerMapping.getHandler"]
    C --> D{"找到Handler?"}
    D -- "否" --> E["404处理"]
    D -- "是" --> F["getHandlerAdapter"]
    F --> G["Interceptor.preHandle"]
    G --> H{"是否放行?"}
    H -- "否" --> I["倒序afterCompletion并结束"]
    H -- "是" --> J["HandlerAdapter.handle"]
    J --> K["参数解析并调用Controller"]
    K --> L["返回值处理或异常解析"]
    L --> M["渲染视图或写响应体"]
    M --> N["afterCompletion与资源清理"]

注意:很多“Spring MVC 九步图”把参数解析画在 HandlerAdapter 之前。严格来说,RequestMappingHandlerAdapter 执行 HandlerMethod 时才组织参数解析器、数据绑定器和返回值处理器。

六、HandlerMapping 为什么只负责“找”

HandlerMapping 接收请求并返回 HandlerExecutionChain,后者包含:

  • 真正的 Handler,例如 HandlerMethod
  • 与当前路径匹配的 HandlerInterceptor 列表。

常见映射包括:

  • RequestMappingHandlerMapping:注解 Controller 方法;
  • 资源 HandlerMapping:静态资源;
  • BeanNameUrlHandlerMapping:按 Bean 名映射的传统方式;
  • 框架或业务自定义 HandlerMapping。

多个 HandlerMapping 有顺序。某个映射返回结果后,DispatcherServlet 使用该结果,不会把多个 Controller 合并执行。404 排查不能只搜 @GetMapping,还应看 context path、Servlet mapping、网关改写、路径匹配策略和映射顺序。

七、HandlerAdapter 为什么不能省略

Handler 可能是注解方法、传统 Controller、HttpRequestHandler 或自定义对象。DispatcherServlet 不应写大量 instanceof 并了解每种调用细节,于是引入 HandlerAdapter:

text
DispatcherServlet只认识:
    哪个Adapter支持当前Handler?
    请Adapter执行并返回统一结果。

注解 Controller 常由 RequestMappingHandlerAdapter 处理。其核心工作不仅是反射调用,还包括:

  • 初始化 @ControllerAdvice 中的 binder、model 和异常方法;
  • 选择 HandlerMethodArgumentResolver;
  • 创建 WebDataBinder;
  • 初始化 Model;
  • 执行 @InitBinder@ModelAttribute 方法;
  • 调用 Controller;
  • 选择 HandlerMethodReturnValueHandler;
  • 启动异步处理。

HandlerMapping 与 HandlerAdapter 分开体现了“定位对象”和“执行对象”职责分离,也让自定义协议 Handler 可以接入同一 DispatcherServlet。

八、Controller 方法如何被执行

RequestMappingHandlerAdapter.invokeHandlerMethod() 会把 HandlerMethod 包装为可调用对象,并准备:

  • argument resolvers;
  • return value handlers;
  • WebDataBinderFactory;
  • ModelFactory;
  • ModelAndViewContainer;
  • AsyncWebRequest 和 WebAsyncManager。

调用时逐个处理方法参数:

  1. 根据参数类型和注解询问 resolver 的 supportsParameter
  2. 找到第一个支持的 resolver;
  3. resolver 从 request、session、path variables、body 或容器中取原始值;
  4. 必要时执行转换、绑定和校验;
  5. 所有参数准备完成后通过反射调用目标方法;
  6. 取得返回值和返回类型描述;
  7. 找到支持的 return value handler;
  8. handler 更新 ModelAndViewContainer、启动异步或直接写响应。
mermaid
flowchart TD
    A["RequestMappingHandlerAdapter"] --> B["创建ServletInvocableHandlerMethod"]
    B --> C["遍历Controller参数"]
    C --> D["选择ArgumentResolver"]
    D --> E["读取请求数据"]
    E --> F["转换、绑定、校验"]
    F --> G{"全部参数成功?"}
    G -- "否" --> H["抛出绑定或消息读取异常"]
    G -- "是" --> I["反射调用Controller方法"]
    I --> J["选择ReturnValueHandler"]
    J --> K["写响应、生成视图或启动异步"]

九、参数解析器到底解析什么

参数写法常见数据来源主要处理思路
@RequestParamquery、表单参数取字符串后类型转换
@PathVariableURL模板变量从匹配结果属性读取并转换
@RequestHeaderHTTP Header读取Header并转换
@CookieValueCookie查找Cookie并转换
@RequestBodyHTTP bodyHttpMessageConverter反序列化
@ModelAttributequery/form/path等属性创建对象并逐属性绑定
HttpServletRequestServlet对象直接注入当前请求
Principal认证主体从请求安全上下文读取
MultipartFilemultipart partMultipartResolver解析后提供
@RequestAttributerequest attributeFilter/Interceptor等预先写入

9.1 @RequestParam 不是读取 JSON

请求:

http
POST /orders?channel=APP
Content-Type: application/json

{"productId":1001,"quantity":2}

@RequestParam String channel 从 query 读取;@RequestBody CreateOrderRequest body 从 body 读取。二者数据源不同。

9.2 请求体通常只能消费一次

Servlet input stream 是字节流。若 Filter 为记录日志先把 body 全部读完,又没有使用可重复读取的 wrapper 缓存,后续 @RequestBody 就可能读到空流。记录请求体时还必须限制大小并脱敏密码、token、身份证、密钥和医疗敏感信息。

9.3 参数名从哪里来

@RequestParam("channel")@PathVariable("id") 显式名称最稳定。依赖 Java 反射参数名时通常需要编译参数 -parameters;不同 JDK、编译插件和混淆流程可能造成差异。公共接口建议显式写名称,不把构建元数据当隐含契约。

十、数据绑定、类型转换和校验不是一回事

10.1 类型转换

HTTP 参数通常先表现为字符串。ConversionService 和 Formatter 等组件负责:

text
"1001" -> Long
"2026-07-18" -> LocalDate
"PAID" -> OrderStatus

转换失败会形成 typeMismatch 等错误,可能最终表现为 400。

10.2 数据绑定

WebDataBinder 把多个属性写入目标对象,例如:

text
customerName=张三
address.city=上海
items[0].productId=1001

绑定不仅是转换,还包含允许字段、禁止字段、嵌套路径、字段错误收集等。对后台管理更新接口直接绑定持久化实体可能产生 mass assignment:攻击者提交 role=ADMINstatus=PAID 等本不允许修改的字段。商业项目应使用专用请求 DTO,并限制可写属性。

10.3 Bean Validation

@Valid@Validated 触发 Validator,对已经构造和绑定的对象执行约束:

java
public class CreateOrderRequest {
    @NotNull
    private Long productId;

    @Min(1)
    @Max(100)
    private Integer quantity;
}

类型转换失败与约束不满足不是同一种错误:quantity=abc 连 Integer 都无法得到;quantity=0 转换成功,但违反 @Min(1)

10.4 BindingResult 的位置

在传统 ModelAttribute 场景中,BindingResult 必须紧跟对应绑定参数,才能由 Controller 自行处理错误。@RequestBody @Valid 常在校验失败时抛 MethodArgumentNotValidException,由异常处理器统一转换。团队应统一错误码、字段路径和国际化策略,不要有的接口返回字符串、有的接口直接暴露框架异常。

mermaid
flowchart TD
    A["读取原始请求值"] --> B["创建目标对象"]
    B --> C["ConversionService转换类型"]
    C --> D{"转换成功?"}
    D -- "否" --> E["记录typeMismatch或抛400异常"]
    D -- "是" --> F["WebDataBinder写入属性"]
    F --> G["Validator执行约束"]
    G --> H{"约束通过?"}
    H -- "否" --> I["BindingResult或校验异常"]
    H -- "是" --> J["形成Controller实参"]

十一、@InitBinder 和全局绑定扩展

Controller 或 @ControllerAdvice 可以声明 @InitBinder,用于:

  • 注册局部 Formatter/PropertyEditor;
  • 设置允许或禁止绑定字段;
  • 设置 Validator;
  • 调整自动增长集合上限等绑定行为。
java
@InitBinder("createUserRequest")
public void initUserBinder(WebDataBinder binder) {
    binder.setAllowedFields("name", "email", "departmentId");
}

全局 Converter 适合统一类型语义;局部 InitBinder 适合某类表单的特殊绑定规则。不要用 PropertyEditor 保存请求可变状态,因为许多 MVC 基础组件是 singleton,需要考虑线程安全。

十二、@RequestBody 与消息转换器

@RequestBody 常由 RequestResponseBodyMethodProcessor 处理。它不是固定调用 Jackson,而是根据:

  • 方法参数类型;
  • 请求 Content-Type
  • 已注册 Converter 的 canRead

选择合适的 HttpMessageConverter

JSON 常由 Jackson Converter 读取;String、byte array、表单和资源有其他 Converter。读取流程大致是:

  1. 检查请求媒体类型;
  2. 遍历 Converter;
  3. 找到既支持目标 Java 类型又支持媒体类型的 Converter;
  4. 从 ServletInputStream 读取字节;
  5. 反序列化为对象;
  6. 执行 RequestBodyAdvice;
  7. 必要时校验;
  8. 把对象作为 Controller 参数。

12.1 415 Unsupported Media Type

常见原因:

  • 客户端发送 JSON,却没有 Content-Type: application/json
  • Controller 的 consumes 不接受该类型;
  • 没有 Converter 能读取目标类型;
  • 媒体类型带错误字符或网关改写 Header;
  • 实际 body 是 form,却按 JSON DTO 接收。

12.2 JSON 语法错误和字段类型错误

非法 JSON、枚举值不合法、日期格式错误、数字溢出等通常在 Controller 调用前就由消息读取阶段失败。Controller 日志没有出现,不代表请求没进入 Spring MVC;应看 HttpMessageNotReadableException 的最深 Jackson cause,同时避免把完整敏感 body 写入日志。

十三、Controller 返回值怎样变成响应

Controller 方法执行后,Spring 不是统一执行 toString(),而是选择 HandlerMethodReturnValueHandler:

返回形式常见处理
ModelAndView同时提供模型与视图
String在普通 @Controller 中可作为视图名
@ResponseBody T消息转换器写响应体
ResponseEntity<T>同时控制状态、Header和Body
void可能已直接写响应或推导视图
Callable<T>Servlet异步任务
DeferredResult<T>外部线程稍后设置结果
StreamingResponseBody流式写出响应

@RestController 等价于类上组合 @Controller@ResponseBody,所以方法返回 String 通常是响应正文,不是视图名。

十四、内容协商与 406

写响应时常综合:

  • 客户端 Accept
  • Controller produces
  • 返回值 Java 类型;
  • Converter 的 canWrite
  • 服务端可生产媒体类型。

如果客户端只接受 application/xml,服务端只有 JSON Converter,且没有能写 XML 的 Converter,可能返回 406 Not Acceptable。

mermaid
flowchart TD
    A["Controller产生返回对象"] --> B["ReturnValueHandler识别ResponseBody"]
    B --> C["读取Accept与produces"]
    C --> D["计算可接受媒体类型交集"]
    D --> E{"存在兼容类型?"}
    E -- "否" --> F["406 Not Acceptable"]
    E -- "是" --> G["选择可写HttpMessageConverter"]
    G --> H["序列化Java对象"]
    H --> I["设置Content-Type并写响应体"]
    I --> J["Servlet容器提交响应"]

14.1 什么时候响应算 committed

当状态行和 Header 已经发送,或响应缓冲区被刷新后,响应可能进入 committed 状态。此后再修改状态码、Header 或切换为统一 JSON 错误通常已经来不及。下载、流式响应和大响应尤其容易出现:业务后半段抛异常,但客户端已收到 200 和部分字节。

所以流式接口必须在写出前完成可提前完成的鉴权、参数校验和资源检查;写出后失败要使用协议内错误、连接中断和可观测指标,而不能期待全局异常处理器重新生成标准 JSON。

十五、视图渲染链路

非 REST Controller 可以返回逻辑视图名。DispatcherServlet 使用 ViewResolver 把名称解析为 View,再将 Model 数据交给模板引擎或 JSP 渲染。

text
Controller返回 "order/detail"

ViewResolver查找模板

得到View对象

View读取Model

渲染HTML并写入response

找不到模板、循环视图路径、模板表达式错误和响应提前提交都可能在 Controller 已正常返回后才失败。排查 500 时不能只看 Controller。

十六、拦截器完整顺序

假设注册 I1、I2:

text
I1.preHandle
I2.preHandle
Controller
I2.postHandle
I1.postHandle
渲染或写响应
I2.afterCompletion
I1.afterCompletion
  • preHandle 按注册顺序进入;
  • postHandle 通常逆序,只在 Handler 正常执行后调用;
  • afterCompletion 逆序,用于清理,并可看到异常;
  • 某个 preHandle 返回 false 时,后续拦截器和 Controller 不执行,已成功进入的拦截器执行完成回调;
  • @ResponseBody,响应可能在返回值处理阶段已写出,postHandle 不适合再修改 body;
  • 异步请求会调用 afterConcurrentHandlingStarted,完成后还可能重新派发。

拦截器 singleton 中不能保存“当前用户”到实例字段。请求信息应放方法局部变量、request attribute、受控 ThreadLocal,并确保异步和异常路径清理。

十七、Filter、Interceptor、AOP 怎样选择

机制所在层能覆盖什么常见用途
FilterServlet容器链静态资源、Servlet、MVC前后编码、CORS、请求包装、安全过滤
InterceptorSpring MVC Handler链被HandlerMapping找到的Handler用户上下文、接口日志、Handler级鉴权
ControllerAdviceMVC参数/返回/异常体系Controller相关绑定和异常统一异常、全局Binder、ResponseBodyAdvice
AOPSpring Bean方法调用匹配切点的Bean方法事务、审计、重试、业务权限

Filter 能在 Spring MVC 之前读取原始请求,但不知道最终 HandlerMethod;Interceptor 能看到 HandlerMethod,却不覆盖未进入 MVC 的请求;AOP 不应替代 HTTP 层的媒体类型、状态码和响应提交处理。

十八、统一异常处理完整链路

Controller、参数解析、返回值处理或拦截器发生异常时,DispatcherServlet 会依次询问 HandlerExceptionResolver。常见组合包括:

  • ExceptionHandlerExceptionResolver:处理 Controller 或 @ControllerAdvice@ExceptionHandler
  • ResponseStatusExceptionResolver:处理 @ResponseStatus 和相关状态语义;
  • DefaultHandlerExceptionResolver:把部分框架异常映射为标准 HTTP 状态。
mermaid
flowchart TD
    A["MVC执行阶段抛出异常"] --> B["HandlerExceptionResolverComposite"]
    B --> C["ExceptionHandlerExceptionResolver"]
    C --> D{"找到匹配方法?"}
    D -- "是" --> E["调用ExceptionHandler并处理返回值"]
    D -- "否" --> F["询问下一个Resolver"]
    F --> G{"异常被解析?"}
    G -- "是" --> H["生成响应或ModelAndView"]
    G -- "否" --> I["继续抛给Servlet容器"]
    E --> J["检查响应是否已提交"]
    H --> J

18.1 异常匹配不是只看异常名称

框架会考虑当前 Controller、可用 Advice、Advice 顺序、异常类型和 cause 链。多个 Advice 同时能处理时应通过职责和顺序明确边界,例如:

  • 参数与协议异常;
  • 业务可预期异常;
  • 权限异常;
  • 系统未知异常。

未知异常不能把堆栈、SQL、服务器路径或敏感数据返回客户端;日志中要保留 traceId 和完整 cause,响应只返回稳定错误码和安全消息。

18.2 为什么 catch Exception 返回 200 不合理

把所有错误包装成 HTTP 200 会让网关、监控、客户端重试和 SLO 无法区分成功失败。业务错误码可以保留,但 HTTP 状态仍应表达协议层结果。是否使用 4xx/5xx 要形成团队契约,不能每个 Controller 自己决定。

十九、Servlet 异步请求全过程

同步请求中,Tomcat 请求线程一直占用到 Controller、Service、序列化和响应完成。对于等待外部事件的长请求,可使用 Servlet 3 异步能力。

19.1 Callable<T>

Controller 返回 Callable 后:

  1. MVC 启动 AsyncContext;
  2. 原 Servlet 请求线程释放回连接器线程池;
  3. Callable 交给 MVC 配置的异步 TaskExecutor;
  4. 执行完成后保存结果;
  5. Servlet 容器对同一请求执行异步 dispatch;
  6. DispatcherServlet 再次进入,将保存结果交给返回值处理器;
  7. 序列化并完成响应。

19.2 DeferredResult<T>

DeferredResult 不要求 MVC 主动执行某个 Callable。Controller 先返回占位结果,MQ 回调、业务线程或事件监听器稍后调用 setResult / setErrorResult,随后触发异步重派发。

mermaid
flowchart TD
    A["请求线程进入Controller"] --> B["返回Callable或DeferredResult"]
    B --> C["startAsync保持响应打开"]
    C --> D["释放原Servlet请求线程"]
    D --> E["业务线程产生结果"]
    E --> F["保存异步结果"]
    F --> G["Servlet容器async dispatch"]
    G --> H["DispatcherServlet再次处理"]
    H --> I["返回值处理与消息转换"]
    I --> J["完成HTTP响应"]

19.3 异步不等于非阻塞

如果 Callable 在线程池中执行阻塞数据库查询或 HTTP 调用,只是把阻塞从 Tomcat 线程转移到另一个线程池。仍需配置:

  • 有界线程池和队列;
  • 请求和异步任务超时;
  • 超时回调与错误结果;
  • MDC、SecurityContext、租户上下文传播;
  • 客户端断开检测;
  • 幂等和取消语义;
  • 线程池拒绝和监控。

真正端到端非阻塞应评估 Spring WebFlux 与响应式驱动,但不能在 JDBC 等阻塞依赖仍占主导时仅替换 Controller 返回类型。

二十、文件上传流程

multipart 请求进入 doDispatch 前会由 MultipartResolver 判断和包装。文件内容可能进入内存或临时磁盘,取决于实现和阈值。生产必须限制:

  • 单文件大小;
  • 总请求大小;
  • 文件数量;
  • 表单字段数量;
  • 临时目录容量;
  • 文件名和路径穿越;
  • MIME 与真实文件签名;
  • 病毒扫描;
  • 上传超时;
  • 失败后的临时文件清理。

不要相信客户端文件名和 Content-Type,不要直接把原文件名拼接到服务器路径。大文件应评估对象存储直传、分片上传和异步扫描,避免长时间占用应用服务器连接与磁盘。

二十一、完整 JDK 8 可运行 Demo

Demo 使用 Spring Boot 2.7.18,它支持 JDK 8;Spring Boot 3 / Spring 6 要求 Java 17,并把 javax.* 迁移为 jakarta.*

21.1 pom.xml

xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/>
    </parent>
    <groupId>demo</groupId>
    <artifactId>spring-mvc-demo</artifactId>
    <version>1.0.0</version>
    <properties>
        <java.version>8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

21.2 启动类

java
package demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MvcDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(MvcDemoApplication.class, args);
    }
}

21.3 请求 DTO

java
package demo;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

public class CreateOrderRequest {
    @NotNull
    private Long productId;

    @NotNull
    @Min(1)
    @Max(100)
    private Integer quantity;

    public CreateOrderRequest() {
    }

    public Long getProductId() {
        return productId;
    }

    public void setProductId(Long productId) {
        this.productId = productId;
    }

    public Integer getQuantity() {
        return quantity;
    }

    public void setQuantity(Integer quantity) {
        this.quantity = quantity;
    }
}

这里使用普通 POJO,而不是 record,因此可在 JDK 8 编译。无参构造器和 Setter 便于 Jackson 创建并填充对象。

21.4 Controller

java
package demo;

import java.util.LinkedHashMap;
import java.util.Map;
import javax.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {
    @GetMapping("/{id}")
    public Map<String, Object> detail(
            @PathVariable("id") Long id,
            @RequestParam(value = "verbose", defaultValue = "false")
            boolean verbose) {
        if (id.longValue() <= 0L) {
            throw new IllegalArgumentException("id必须大于0");
        }
        Map<String, Object> result = new LinkedHashMap<String, Object>();
        result.put("id", id);
        result.put("status", "PAID");
        result.put("verbose", Boolean.valueOf(verbose));
        return result;
    }

    @PostMapping
    public ResponseEntity<Map<String, Object>> create(
            @Valid @RequestBody CreateOrderRequest request) {
        Map<String, Object> result = new LinkedHashMap<String, Object>();
        result.put("orderId", Long.valueOf(10001L));
        result.put("productId", request.getProductId());
        result.put("quantity", request.getQuantity());
        return new ResponseEntity<Map<String, Object>>(
                result, HttpStatus.CREATED);
    }
}

21.5 统一异常处理

java
package demo;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException exception) {
        Map<String, Object> body = new LinkedHashMap<String, Object>();
        body.put("code", "INVALID_ARGUMENT");
        body.put("field", exception.getBindingResult()
                .getFieldErrors().get(0).getField());
        body.put("message", exception.getBindingResult()
                .getFieldErrors().get(0).getDefaultMessage());
        return new ResponseEntity<Map<String, Object>>(
                body, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, Object>> handleBusiness(
            IllegalArgumentException exception) {
        Map<String, Object> body = new LinkedHashMap<String, Object>();
        body.put("code", "INVALID_ORDER_ID");
        body.put("message", exception.getMessage());
        return new ResponseEntity<Map<String, Object>>(
                body, HttpStatus.BAD_REQUEST);
    }
}

21.6 MockMvc 测试

java
package demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldReadPathAndQuery() throws Exception {
        mockMvc.perform(get("/orders/7").param("verbose", "true"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(7))
                .andExpect(jsonPath("$.verbose").value(true));
    }

    @Test
    void shouldValidateJsonBody() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productId\":1001,\"quantity\":0}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_ARGUMENT"))
                .andExpect(jsonPath("$.field").value("quantity"));
    }

    @Test
    void shouldRejectWrongContentType() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.TEXT_PLAIN)
                .content("not-json"))
                .andExpect(status().isUnsupportedMediaType());
    }
}

执行:

bash
mvn test
mvn spring-boot:run

手工请求:

bash
curl "http://127.0.0.1:8080/orders/7?verbose=true"

curl -i -X POST "http://127.0.0.1:8080/orders" \
  -H "Content-Type: application/json" \
  -d '{"productId":1001,"quantity":2}'

二十二、商业订单接口完整链路

以创建订单为例,完整生产链路应考虑:

  1. 网关校验请求大小、TLS、路由和基础限流;
  2. Filter 设置 traceId、字符编码、安全 Header,并包装必要的可重复读请求;
  3. Spring Security FilterChain 完成认证和粗粒度授权;
  4. DispatcherServlet 查找创建订单 HandlerMethod;
  5. Interceptor 校验租户、接口权限和幂等上下文;
  6. MessageConverter 把 JSON 反序列化为请求 DTO;
  7. Bean Validation 校验字段格式;
  8. Controller 只做参数接收、响应转换,调用 Service;
  9. Service 在事务中校验库存、价格快照并落库;
  10. 返回值处理器选择 JSON Converter;
  11. ResponseBodyAdvice 可增加统一协议字段,但不能吞掉真实 HTTP 状态;
  12. 异常解析器把已知业务异常映射为稳定错误码;
  13. Filter 在 finally 中记录状态、耗时和响应大小并清理上下文。

不这样设计会怎样

  • Controller 直接操作数据库,事务边界和复用混乱;
  • 直接绑定 OrderEntity,攻击者可修改状态和金额;
  • 在 Interceptor 实例字段保存用户,出现串用户;
  • 读取完整敏感 body 打日志,造成数据泄露;
  • 所有异常返回 200,监控和客户端无法判断失败;
  • 没有请求大小限制,大 JSON 或 multipart 打满内存和磁盘;
  • 在响应写出后才做关键校验,失败时无法改成错误状态。

二十三、核心扩展点地图

需求推荐扩展点不建议做法
自定义参数来源HandlerMethodArgumentResolver每个Controller重复解析request
自定义返回类型HandlerMethodReturnValueHandlerController手工写response
类型转换Converter / Formatter各接口手工parse
局部绑定限制@InitBinder直接绑定持久化实体
全局异常@ControllerAdvice每个Controller catch Exception
请求体前后加工RequestBodyAdvice / ResponseBodyAdvice在AOP里修改Servlet流
自定义HandlerHandlerMapping + HandlerAdapter修改DispatcherServlet源码
跨Servlet请求包装FilterInterceptor读取原始body
Handler级上下文HandlerInterceptorFilter猜测Controller方法
JSON规则Jackson配置和ModuleController拼JSON字符串

扩展时要明确线程安全、顺序、作用范围、异常行为和版本兼容。自定义 resolver 通常是 singleton,不能把当前请求数据保存到成员变量。

二十四、常见错误与错误发生阶段

现象常见阶段典型原因
404HandlerMapping之前/之中网关路径、context path、Servlet映射、无Handler
405映射条件路径存在但HTTP方法不支持
400类型转换参数解析/绑定Long、日期、枚举转换失败
400校验失败ValidatorNotNull、Min等约束失败
400 JSON错误MessageConverter读取JSON语法、字段类型、日期格式错误
415Converter读取前Content-Type或consumes不支持
406Converter写出前Accept、produces和Converter无交集
500 Controller前ArgumentResolver/Binder自定义解析器或绑定器异常
500 Controller后返回值/视图/序列化getter异常、循环引用、模板错误
200但body不完整响应写出后流式响应中途异常、连接断开

二十五、生产排查 Runbook

25.1 404:先确定请求有没有到应用

  1. 查看网关 access log 的原始路径和重写后路径;
  2. 查看应用 access log 是否出现;
  3. 确认 host、端口、context path、Servlet path;
  4. 从 Actuator mappings 或启动日志确认实际 HandlerMapping;
  5. 比较 HTTP 方法、尾斜杠、URL 编码和路径匹配策略;
  6. 检查静态资源、默认 Servlet 和 Controller 映射顺序;
  7. 检查安全组件是否故意隐藏资源而返回 404。

25.2 400:区分转换、JSON读取和校验

查看异常类型与最深 cause:

  • MethodArgumentTypeMismatchException:query/path/header 类型转换;
  • HttpMessageNotReadableException:请求体读取或反序列化;
  • MethodArgumentNotValidException:RequestBody Bean Validation;
  • BindException:ModelAttribute 等绑定校验;
  • MissingServletRequestParameterException:缺少必填参数。

只记录字段名、错误码和安全摘要,不记录密码、token、密钥或完整医疗数据。

25.3 415:检查实际请求而不是接口文档

抓取客户端真正发送的 Header 和 body,确认代理是否改写 Content-Type,Controller 是否声明 consumes,服务端是否注册对应 Converter。curl 的 -d 默认类型可能不是 JSON,必须显式设置 Header。

25.4 406:检查 Accept 与可写类型

确认客户端 Accept、Controller produces、最终返回值运行时类型和 Converter 清单。返回代理对象、流对象或第三方类型时,声明类型可写不代表运行时序列化一定成功。

25.5 Controller 没打印日志但返回 500

问题可能发生在 Controller 之前的 resolver、binder、validation、interceptor,也可能发生在 Controller 返回后的 serialization、ResponseBodyAdvice、view rendering。通过 traceId、MVC debug 日志和异常栈判断阶段,不要据此断言“Controller 没被调用所以一定是网关”。

25.6 接口很慢

拆分耗时:

  • Filter 和安全链;
  • HandlerMapping;
  • 参数读取与大 JSON 反序列化;
  • Controller/Service;
  • 数据库和远程调用;
  • JSON 序列化;
  • 响应在容器缓冲区或网络写出;
  • 异步线程池排队。

使用链路追踪、Java Flight Recorder、线程栈、GC、连接池和线程池指标。不要只在 Controller 入口出口算时间,因为它看不到前置安全链和后置序列化/网络耗时。

25.7 getOutputStream() has already been called

检查是否同时使用 writer 与 output stream、Filter/Advice 是否重复写响应、异常处理器是否在下载响应已经提交后再写 JSON、转发和 include 是否混用。先判断 response.isCommitted(),再决定是否还能生成标准错误响应。

25.8 异步请求超时或上下文丢失

检查 MVC async timeout、业务 Future timeout、TaskExecutor 队列、拒绝策略、客户端超时和网关超时;确认 MDC、SecurityContext、租户和 trace context 是否通过 TaskDecorator 或显式包装传播并在 finally 清理。

二十六、Spring MVC 与 WebFlux 的边界

维度Spring MVCSpring WebFlux
基础模型ServletReactive Streams
常见线程一个请求在同步阶段占用请求线程少量事件循环处理非阻塞事件
Controller返回普通对象、Callable、DeferredResult等Mono、Flux等Publisher
生态适配JDBC和传统阻塞库成熟需要端到端非阻塞驱动
适用场景大多数CRUD和阻塞业务系统高并发I/O等待且链路可响应式化

WebFlux 不会让阻塞 JDBC 自动变成非阻塞;MVC 使用异步返回也不会自动获得响应式背压。选择应基于依赖栈、团队能力、线程模型和压测证据,而不是“新技术一定更快”。

二十七、版本区别

版本线关键点
JDK 7 + Spring 4早期可使用传统Spring MVC;示例不能使用lambda、record等新语法
JDK 8 + Spring 5.3大量存量商业项目基线,使用 javax.servletjavax.validation
Boot 2.7支持JDK 8,适合本文可运行Demo
Spring 6 / Boot 3Java 17基线,迁移到 jakarta.servletjakarta.validation
Spring 5.3到6路径匹配、弃用API、Servlet版本和默认策略需回归

迁移不能只替换 import。Servlet 容器、Validation Provider、Security、Jackson、自定义 Filter/Resolver/Advice 和测试框架必须一起升级验证。

二十八、面试标准回答

Spring MVC 一次请求完整流程

请求先由 Servlet 容器解析并经过 Filter 链,进入 DispatcherServlet。DispatcherServlet 用 HandlerMapping 找到 HandlerExecutionChain,再用 HandlerAdapter 执行 Handler;RequestMappingHandlerAdapter 通过参数解析器、DataBinder、Converter和Validator准备实参,调用 Controller 后由返回值处理器选择视图、异步处理或 HttpMessageConverter 写响应。异常交给 HandlerExceptionResolver,最后执行拦截器完成回调并清理资源。

HandlerMapping 和 HandlerAdapter 为什么分开

HandlerMapping 只负责根据请求定位 Handler 和拦截器;HandlerAdapter 负责判断是否支持该 Handler,并按其具体模型执行。分开后 DispatcherServlet 不需要了解注解方法、传统 Controller 或自定义 Handler 的调用细节,符合职责分离和适配器模式。

@RequestBody 的原理

参数解析器识别 @RequestBody,根据目标类型和请求 Content-Type 遍历 HttpMessageConverter,选择 canRead 的转换器,从 ServletInputStream 反序列化对象,再执行 BodyAdvice 和校验。没有兼容媒体类型通常是 415,JSON格式或字段类型错误通常包装为 HttpMessageNotReadableException。

@ResponseBody 怎样写成 JSON

返回值处理器识别 ResponseBody 语义,结合客户端 Accept、方法 produces、返回类型和 Converter 的 canWrite 计算媒体类型,常由 Jackson Converter 序列化对象,设置 Content-Type 并写入 response。没有可接受交集可能是 406;序列化异常发生在 Controller 已返回之后。

Filter、Interceptor 和 AOP 区别

Filter 位于 Servlet 链,可覆盖进入 MVC 前后的请求;Interceptor 位于 Handler 执行链,能看到 HandlerMethod;AOP 拦截 Spring Bean 方法,适合事务和业务横切逻辑。三者覆盖范围和可获得上下文不同,不能互相简单替代。

Spring MVC 异步是否非阻塞

Callable 或 DeferredResult 会启动 Servlet 异步,释放原请求线程,结果完成后再异步 dispatch;但 Callable 中的阻塞 I/O 仍占用异步线程,只是换了线程池。是否端到端非阻塞取决于数据库、HTTP客户端和整个调用链。

二十九、关联知识与掌握验收

掌握验收:

  • 能从 Servlet 容器、Filter、DispatcherServlet 一直讲到响应写回;
  • 能画出 doDispatch 主流程并标出异常和异步分支;
  • 能解释 HandlerMapping 与 HandlerAdapter 分开的原因;
  • 能说出 RequestMapping 注册条件和路径匹配版本差异;
  • 能解释参数解析、转换、绑定、校验的顺序和异常类型;
  • 能根据 Content-Type、Accept、Converter 区分 415 与 406;
  • 能解释 Controller 返回后为什么仍可能序列化失败;
  • 能说明响应 committed 后为什么无法重新返回统一 JSON;
  • 能比较 Filter、Interceptor、Advice、AOP;
  • 能解释 Callable 和 DeferredResult 的线程与重派发;
  • 能运行 JDK 8 Demo 和 MockMvc 测试;
  • 能根据状态码和调用阶段执行生产 Runbook。