Spring MVC 请求执行链与扩展点全过程
Spring MVC 是建立在 Servlet 规范之上的 Web MVC 框架。它不是“收到 URL 后反射调用 Controller”这么简单,而是把一次请求拆成:Servlet 容器接收、前端控制器调度、请求映射、拦截器、参数解析、数据绑定、校验、业务调用、返回值处理、内容协商、消息转换、异常解析和响应提交等多个可扩展阶段。
理解 Spring MVC 的关键不是背八步流程,而是回答三个问题:
- 当前请求现在处于哪一层;
- 这一层由哪个组件负责;
- 出错后由谁转换、是否还能修改响应。
学习目标
学完本页,你应当能够:
- 从 TCP 请求进入 Servlet 容器开始,讲到 JSON 字节写回客户端;
- 解释
DispatcherServlet初始化和doDispatch主流程; - 说清
HandlerMapping、HandlerAdapter为什么必须分开; - 解释
@RequestMapping如何注册、匹配和处理冲突; - 解释参数解析、数据绑定、类型转换、校验之间的边界;
- 说清
@RequestParam、@PathVariable、@ModelAttribute、@RequestBody的数据来源; - 解释
Content-Type、Accept、HttpMessageConverter、415 和 406 的关系; - 解释返回值处理器、异常解析器、视图渲染和响应提交;
- 解释 Filter、Interceptor、ControllerAdvice、AOP 的适用边界;
- 理解 Servlet 异步、
Callable、DeferredResult的线程和重派发过程; - 运行 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 只描述“这个请求映射到哪个业务入口、需要什么输入、返回什么结果”,通用机制由可替换组件完成。
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.xml 或 WebApplicationInitializer 注册 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 属性 |
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}:
@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()。稳定主线如下:
- 检查并解析 multipart 请求;
- 依次询问 HandlerMapping,得到
HandlerExecutionChain; - 找不到 Handler 时执行 noHandlerFound 或返回 404;
- 根据 Handler 类型找到支持它的 HandlerAdapter;
- 执行拦截器
preHandle; - 由 HandlerAdapter 调用 Handler;
- 获得
ModelAndView,或响应已被返回值处理器直接写出; - 执行拦截器
postHandle; - 处理异常或渲染视图;
- 执行
afterCompletion; - 清理 multipart 临时资源;
- 异步请求则进入另一套异步完成和重派发流程。
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:
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。
调用时逐个处理方法参数:
- 根据参数类型和注解询问 resolver 的
supportsParameter; - 找到第一个支持的 resolver;
- resolver 从 request、session、path variables、body 或容器中取原始值;
- 必要时执行转换、绑定和校验;
- 所有参数准备完成后通过反射调用目标方法;
- 取得返回值和返回类型描述;
- 找到支持的 return value handler;
- handler 更新 ModelAndViewContainer、启动异步或直接写响应。
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["写响应、生成视图或启动异步"]九、参数解析器到底解析什么
| 参数写法 | 常见数据来源 | 主要处理思路 |
|---|---|---|
@RequestParam | query、表单参数 | 取字符串后类型转换 |
@PathVariable | URL模板变量 | 从匹配结果属性读取并转换 |
@RequestHeader | HTTP Header | 读取Header并转换 |
@CookieValue | Cookie | 查找Cookie并转换 |
@RequestBody | HTTP body | HttpMessageConverter反序列化 |
@ModelAttribute | query/form/path等属性 | 创建对象并逐属性绑定 |
HttpServletRequest | Servlet对象 | 直接注入当前请求 |
Principal | 认证主体 | 从请求安全上下文读取 |
MultipartFile | multipart part | MultipartResolver解析后提供 |
@RequestAttribute | request attribute | Filter/Interceptor等预先写入 |
9.1 @RequestParam 不是读取 JSON
请求:
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 等组件负责:
"1001" -> Long
"2026-07-18" -> LocalDate
"PAID" -> OrderStatus转换失败会形成 typeMismatch 等错误,可能最终表现为 400。
10.2 数据绑定
WebDataBinder 把多个属性写入目标对象,例如:
customerName=张三
address.city=上海
items[0].productId=1001绑定不仅是转换,还包含允许字段、禁止字段、嵌套路径、字段错误收集等。对后台管理更新接口直接绑定持久化实体可能产生 mass assignment:攻击者提交 role=ADMIN、status=PAID 等本不允许修改的字段。商业项目应使用专用请求 DTO,并限制可写属性。
10.3 Bean Validation
@Valid 或 @Validated 触发 Validator,对已经构造和绑定的对象执行约束:
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,由异常处理器统一转换。团队应统一错误码、字段路径和国际化策略,不要有的接口返回字符串、有的接口直接暴露框架异常。
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;
- 调整自动增长集合上限等绑定行为。
@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。读取流程大致是:
- 检查请求媒体类型;
- 遍历 Converter;
- 找到既支持目标 Java 类型又支持媒体类型的 Converter;
- 从 ServletInputStream 读取字节;
- 反序列化为对象;
- 执行 RequestBodyAdvice;
- 必要时校验;
- 把对象作为 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。
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 渲染。
Controller返回 "order/detail"
↓
ViewResolver查找模板
↓
得到View对象
↓
View读取Model
↓
渲染HTML并写入response找不到模板、循环视图路径、模板表达式错误和响应提前提交都可能在 Controller 已正常返回后才失败。排查 500 时不能只看 Controller。
十六、拦截器完整顺序
假设注册 I1、I2:
I1.preHandle
I2.preHandle
Controller
I2.postHandle
I1.postHandle
渲染或写响应
I2.afterCompletion
I1.afterCompletionpreHandle按注册顺序进入;postHandle通常逆序,只在 Handler 正常执行后调用;afterCompletion逆序,用于清理,并可看到异常;- 某个
preHandle返回 false 时,后续拦截器和 Controller 不执行,已成功进入的拦截器执行完成回调; - 对
@ResponseBody,响应可能在返回值处理阶段已写出,postHandle不适合再修改 body; - 异步请求会调用
afterConcurrentHandlingStarted,完成后还可能重新派发。
拦截器 singleton 中不能保存“当前用户”到实例字段。请求信息应放方法局部变量、request attribute、受控 ThreadLocal,并确保异步和异常路径清理。
十七、Filter、Interceptor、AOP 怎样选择
| 机制 | 所在层 | 能覆盖什么 | 常见用途 |
|---|---|---|---|
| Filter | Servlet容器链 | 静态资源、Servlet、MVC前后 | 编码、CORS、请求包装、安全过滤 |
| Interceptor | Spring MVC Handler链 | 被HandlerMapping找到的Handler | 用户上下文、接口日志、Handler级鉴权 |
| ControllerAdvice | MVC参数/返回/异常体系 | Controller相关绑定和异常 | 统一异常、全局Binder、ResponseBodyAdvice |
| AOP | Spring Bean方法调用 | 匹配切点的Bean方法 | 事务、审计、重试、业务权限 |
Filter 能在 Spring MVC 之前读取原始请求,但不知道最终 HandlerMethod;Interceptor 能看到 HandlerMethod,却不覆盖未进入 MVC 的请求;AOP 不应替代 HTTP 层的媒体类型、状态码和响应提交处理。
十八、统一异常处理完整链路
Controller、参数解析、返回值处理或拦截器发生异常时,DispatcherServlet 会依次询问 HandlerExceptionResolver。常见组合包括:
ExceptionHandlerExceptionResolver:处理 Controller 或@ControllerAdvice的@ExceptionHandler;ResponseStatusExceptionResolver:处理@ResponseStatus和相关状态语义;DefaultHandlerExceptionResolver:把部分框架异常映射为标准 HTTP 状态。
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 --> J18.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 后:
- MVC 启动 AsyncContext;
- 原 Servlet 请求线程释放回连接器线程池;
- Callable 交给 MVC 配置的异步 TaskExecutor;
- 执行完成后保存结果;
- Servlet 容器对同一请求执行异步 dispatch;
- DispatcherServlet 再次进入,将保存结果交给返回值处理器;
- 序列化并完成响应。
19.2 DeferredResult<T>
DeferredResult 不要求 MVC 主动执行某个 Callable。Controller 先返回占位结果,MQ 回调、业务线程或事件监听器稍后调用 setResult / setErrorResult,随后触发异步重派发。
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
<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 启动类
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
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
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 统一异常处理
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 测试
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());
}
}执行:
mvn test
mvn spring-boot:run手工请求:
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}'二十二、商业订单接口完整链路
以创建订单为例,完整生产链路应考虑:
- 网关校验请求大小、TLS、路由和基础限流;
- Filter 设置 traceId、字符编码、安全 Header,并包装必要的可重复读请求;
- Spring Security FilterChain 完成认证和粗粒度授权;
- DispatcherServlet 查找创建订单 HandlerMethod;
- Interceptor 校验租户、接口权限和幂等上下文;
- MessageConverter 把 JSON 反序列化为请求 DTO;
- Bean Validation 校验字段格式;
- Controller 只做参数接收、响应转换,调用 Service;
- Service 在事务中校验库存、价格快照并落库;
- 返回值处理器选择 JSON Converter;
- ResponseBodyAdvice 可增加统一协议字段,但不能吞掉真实 HTTP 状态;
- 异常解析器把已知业务异常映射为稳定错误码;
- Filter 在 finally 中记录状态、耗时和响应大小并清理上下文。
不这样设计会怎样
- Controller 直接操作数据库,事务边界和复用混乱;
- 直接绑定 OrderEntity,攻击者可修改状态和金额;
- 在 Interceptor 实例字段保存用户,出现串用户;
- 读取完整敏感 body 打日志,造成数据泄露;
- 所有异常返回 200,监控和客户端无法判断失败;
- 没有请求大小限制,大 JSON 或 multipart 打满内存和磁盘;
- 在响应写出后才做关键校验,失败时无法改成错误状态。
二十三、核心扩展点地图
| 需求 | 推荐扩展点 | 不建议做法 |
|---|---|---|
| 自定义参数来源 | HandlerMethodArgumentResolver | 每个Controller重复解析request |
| 自定义返回类型 | HandlerMethodReturnValueHandler | Controller手工写response |
| 类型转换 | Converter / Formatter | 各接口手工parse |
| 局部绑定限制 | @InitBinder | 直接绑定持久化实体 |
| 全局异常 | @ControllerAdvice | 每个Controller catch Exception |
| 请求体前后加工 | RequestBodyAdvice / ResponseBodyAdvice | 在AOP里修改Servlet流 |
| 自定义Handler | HandlerMapping + HandlerAdapter | 修改DispatcherServlet源码 |
| 跨Servlet请求包装 | Filter | Interceptor读取原始body |
| Handler级上下文 | HandlerInterceptor | Filter猜测Controller方法 |
| JSON规则 | Jackson配置和Module | Controller拼JSON字符串 |
扩展时要明确线程安全、顺序、作用范围、异常行为和版本兼容。自定义 resolver 通常是 singleton,不能把当前请求数据保存到成员变量。
二十四、常见错误与错误发生阶段
| 现象 | 常见阶段 | 典型原因 |
|---|---|---|
| 404 | HandlerMapping之前/之中 | 网关路径、context path、Servlet映射、无Handler |
| 405 | 映射条件 | 路径存在但HTTP方法不支持 |
| 400类型转换 | 参数解析/绑定 | Long、日期、枚举转换失败 |
| 400校验失败 | Validator | NotNull、Min等约束失败 |
| 400 JSON错误 | MessageConverter读取 | JSON语法、字段类型、日期格式错误 |
| 415 | Converter读取前 | Content-Type或consumes不支持 |
| 406 | Converter写出前 | Accept、produces和Converter无交集 |
| 500 Controller前 | ArgumentResolver/Binder | 自定义解析器或绑定器异常 |
| 500 Controller后 | 返回值/视图/序列化 | getter异常、循环引用、模板错误 |
| 200但body不完整 | 响应写出后 | 流式响应中途异常、连接断开 |
二十五、生产排查 Runbook
25.1 404:先确定请求有没有到应用
- 查看网关 access log 的原始路径和重写后路径;
- 查看应用 access log 是否出现;
- 确认 host、端口、context path、Servlet path;
- 从 Actuator mappings 或启动日志确认实际 HandlerMapping;
- 比较 HTTP 方法、尾斜杠、URL 编码和路径匹配策略;
- 检查静态资源、默认 Servlet 和 Controller 映射顺序;
- 检查安全组件是否故意隐藏资源而返回 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 MVC | Spring WebFlux |
|---|---|---|
| 基础模型 | Servlet | Reactive 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.servlet、javax.validation |
| Boot 2.7 | 支持JDK 8,适合本文可运行Demo |
| Spring 6 / Boot 3 | Java 17基线,迁移到 jakarta.servlet、jakarta.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与Listener全过程
- Spring IoC容器与依赖注入
- Spring AOP代理与拦截器链
- Spring事务执行链
- Spring Security过滤器链
- Spring Boot内嵌Tomcat与启动流程
- Spring面试知识点
掌握验收:
- 能从 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。
