Spring MVC 请求处理全链路:从 URL、Header、Body 到 Validation、Filter、Security 与异常处理

Spring MVC 请求处理全链路:从 URL、Header、Body 到 Validation、Filter、Security 与异常处理

Spring Web 中很多看起来毫不相关的问题,实际上都可以归结为同一个问题:一个 HTTP 请求进入应用后,究竟由谁在什么阶段处理它?

例如:

  • @PathVariable 为什么接不住带 / 的值?
  • @RequestParam String name 为什么本地正常、换到生产环境却突然报错?
  • 同名请求参数为什么会变成逗号拼接的字符串?
  • @RequestHeader Map 为什么会丢失重复 Header?
  • 明明手工设置了 Content-Type,为什么响应最终还是 text/plain
  • @RequestBody 为什么会出现 No converter found
  • 为什么 Filter 里读取了一次 Body,Controller 就再也读不到?
  • Bean Validation 注解明明写了,为什么校验完全没有发生?
  • @WebFilter 为什么能执行,却不能按普通 Bean 注入?
  • 为什么多调用一次 FilterChain#doFilter(),Controller 会执行两次?
  • @ControllerAdvice 为什么接不住 Filter 抛出的异常?
  • Spring Security 的 ROLE_ 到底什么时候需要自己加?

这些现象背后并不是十几套互不相干的机制,而是一条完整的请求处理链。

本文从这个全链路模型出发,把 URL、Header、Body、参数校验、Filter、Spring Security 和异常处理串成一套知识体系。理解这套模型后,遇到问题时就不需要记忆大量“结论”,而是可以顺着调用链找到真正负责处理当前问题的组件。

版本说明:文中的底层源码主线来自 Spring Framework 5.x / Spring Boot 2.x 时代的经典实现,因此会看到 AntPathMatcherWebSecurityConfigurerAdapterjavax.servletjavax.validation 等 API。文章会保留这些机制背后的知识价值,同时单独标注现代 Spring Framework 6/7、Spring Boot 3/4、Spring Security 6/7 中已经变化的地方。

一、先建立全局模型:一次请求到底经历了什么

在 Servlet 技术栈中,一个请求通常会经历下面的路径:

flowchart TD
    A[HTTP Request] --> B[Servlet Container<br/>Tomcat / Jetty]
    B --> C[Servlet FilterChain]
    C --> D[Spring Security FilterChain<br/>如果启用 Security]
    D --> E[DispatcherServlet]
    C --> E
    E --> F[HandlerMapping<br/>寻找 Controller 方法]
    F --> G[HandlerMethodArgumentResolver<br/>解析 Controller 参数]
    G --> H[HttpMessageConverter / DataBinder<br/>Body 转换与类型转换]
    H --> I[Validation<br/>参数校验]
    I --> J[Controller Method]
    J --> K[ReturnValueHandler]
    K --> L[Content Negotiation<br/>媒体类型协商]
    L --> M[HttpMessageConverter<br/>序列化响应]
    M --> N[HTTP Response]

    E -.异常.-> X[HandlerExceptionResolver]
    X --> Y[@ExceptionHandler / @ControllerAdvice]

这张图里最重要的不是每个类名,而是边界

  1. Filter 在 DispatcherServlet 之前。
  2. Spring MVC 的参数解析、Body 转换、Validation 和 @ControllerAdvice 都发生在 DispatcherServlet 体系内部。
  3. Spring Security 本质上也是建立在 Filter 机制之上。
  4. URL 匹配和 Controller 参数解析是两个阶段。
  5. 请求 Body 和响应 Body 都依赖 HttpMessageConverter,但方向相反。
  6. Content-TypeAccept 共同参与内容协商,不能只看 Controller 里手工写了什么 Header。

后面所有问题,都可以映射回这条链路。


二、URL 与请求参数:先匹配 Handler,再解析参数

2.1 @PathVariable 并不是“任意字符串占位符”

一个典型 Controller:

1
2
3
4
5
6
7
8
@RestController
public class HelloController {

@GetMapping("/hi/{name}")
public String hello(@PathVariable("name") String name) {
return name;
}
}

请求:

1
GET /hi/xiaoming

可以正常得到:

1
xiaoming

但如果请求是:

1
GET /hi/xiao/ming

不要把它理解为:

1
name = "xiao/ming"

因为在 URI Path 语义中,/路径段分隔符。Spring 在调用 Controller 之前,必须先完成 Handler 匹配:

1
2
Pattern: /hi/{name}
Path: /hi/xiao/ming

这两个路径结构并不相同,因此通常在 HandlerMapping 阶段就匹配失败,Controller 甚至没有机会执行。

旧版 Spring MVC 的匹配过程

经典的 AbstractHandlerMethodMapping#lookupHandlerMethod 可以概括为:

flowchart TD
    A[得到 lookupPath] --> B{是否存在直接匹配?}
    B -- 是 --> C[加入候选 Match]
    B -- 否 --> D[遍历全部 Mapping]
    D --> E[RequestMappingInfo#getMatchingCondition]
    E --> F{Method/Params/Headers/Consumes/Produces/Path 是否都匹配?}
    F -- 否 --> G[丢弃候选]
    F -- 是 --> C
    C --> H[按匹配精度排序]
    H --> I[选出 bestMatch]
    I --> J[HandlerMethod]

在 Spring 5.x 的经典实现里,路径匹配常见地落到 AntPathMatcher。所以:

1
/hi/{name}

只会把一个路径段作为 {name},不会把任意多个 / 吞进去。

2.2 如果变量本身确实需要包含 / 怎么办

有三种更合理的设计思路。

方案一:把“路径”当查询参数

如果值本身就是一段路径、URL、对象 Key 等,通常不应该强行塞进单个 Path Variable:

1
2
3
4
@GetMapping("/hi")
public String hi(@RequestParam("name") String name) {
return name;
}

请求:

1
GET /hi?name=xiao%2Fming

这种 API 语义通常更清晰。

方案二:使用通配路径并显式提取

旧版基于 AntPathMatcher 的写法可以使用:

1
2
3
4
5
6
7
8
9
10
private final AntPathMatcher pathMatcher = new AntPathMatcher();

@GetMapping("/hi/**")
public String hi(HttpServletRequest request) {
String path = (String) request.getAttribute(
HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
String pattern = (String) request.getAttribute(
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
return pathMatcher.extractPathWithinPattern(pattern, path);
}

这比直接:

1
request.getRequestURI().split("/hi/")[1]

稳健得多,因为字符串 split 会把“业务数据”和“路由结构”混在一起处理,一旦数据内部再次出现 /hi/,就会出现意外结果。

方案三:重新设计资源标识

如果一个资源标识天然包含大量 /?# 等 URI 保留字符,通常值得考虑:

  • URL Encode;
  • Base64URL 编码;
  • UUID / ID 替代原始路径;
  • Query Parameter;
  • Request Body。

一个好的 REST API 不应该要求路由器“猜”某个 / 到底是结构还是数据。

2.3 尾部 /:不要依赖“系统会帮我兼容”

旧版 Spring MVC 中常见:

1
2
/hi/xiaoming
/hi/xiaoming/

都可能匹配同一个 Handler。这来自可选 trailing slash 的历史行为。

旧实现可以理解为:

1
2
3
4
5
6
7
8
9
10
if (pathMatcher.match(pattern, lookupPath)) {
return pattern;
}

if (useTrailingSlashMatch) {
if (!pattern.endsWith("/")
&& pathMatcher.match(pattern + "/", lookupPath)) {
return pattern + "/";
}
}

版本提示:Spring 6 以后

现代 Spring MVC 默认使用 PathPatternParser,透明的 trailing-slash 自动匹配已经不再推荐依赖。更稳妥的做法是:

  • 明确规范 URL;
  • 在网关 / 代理层做规范化重定向;
  • 或显式定义需要兼容的路径。

因此,不要把“多一个 / 也能访问”当成稳定 API 契约。


2.4 @RequestParam 不写参数名,为什么有时会失效

下面两种代码看起来都合理:

1
2
3
4
@GetMapping("/hi1")
public String hi1(@RequestParam("name") String name) {
return name;
}
1
2
3
4
@GetMapping("/hi2")
public String hi2(@RequestParam String name) {
return name;
}

第二种写法依赖一个隐含条件:运行时必须能够获取 Java 方法参数名 name

Spring 在解析 @RequestParam 时,如果注解中没有显式指定名称,就需要执行类似逻辑:

1
2
3
4
5
6
7
8
String name = info.name;
if (name.isEmpty()) {
name = parameter.getParameterName();
if (name == null) {
throw new IllegalArgumentException(
"Name for argument type ... not available");
}
}

所以真正的问题变成了:

JVM 运行时还能不能知道源码里的参数叫 name

Java class 文件里的参数名从哪里来

历史上常见两个来源:

  • LocalVariableTable:通常与 debug 信息相关;
  • MethodParameters:由 Java -parameters 编译选项产生。

可以用:

1
javap -verbose HelloController.class

检查 class 文件是否保留了参数信息。

Maven 编译配置

现代项目如果依赖参数名反射,应显式开启:

1
2
3
4
5
6
7
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>

版本提示:Spring 6.1 后这件事更重要

Spring Framework 6.1 移除了 LocalVariableTableParameterNameDiscoverer,不再尝试通过解析字节码里的局部变量表兜底推断参数名。依赖方法参数名的场景,应使用标准 -parameters 编译标志。

因此,在公共 API Controller 中,一个非常朴素但稳定的工程实践仍然成立:

1
2
3
@RequestParam("name") String name
@PathVariable("id") Long id
@RequestHeader("X-Request-Id") String requestId

显式契约通常比隐式推断更适合生产代码。


2.5 请求参数默认是 required 的

代码:

1
2
3
4
5
6
@GetMapping("/student")
public String student(
@RequestParam("name") String name,
@RequestParam("address") String address) {
return name + ":" + address;
}

请求:

1
GET /student?name=xiaoming

很多开发者会直觉认为:

1
address = null

@RequestParam 默认:

1
required = true

因此缺少 address 时,Spring 会把它当成客户端请求不完整,通常返回 400。

其核心判断可以概括为:

1
2
3
4
5
6
7
if (arg == null) {
if (defaultValue != null) {
arg = defaultValue;
} else if (required && !parameter.isOptional()) {
handleMissingValue(...);
}
}

四种表达“可选”的方式

1. 默认值

1
2
3
4
@RequestParam(
value = "address",
defaultValue = "unknown"
) String address

2. required = false

1
2
@RequestParam(value = "address", required = false)
String address

3. Optional<T>

1
@RequestParam("address") Optional<String> address

4. Nullable 语义

不同版本、不同 nullability 注解支持细节会变化。工程上如果接口契约强调“可以不传”,推荐优先使用:

1
required = false

或:

1
Optional<T>

让 API 意图直接可读。


2.6 同名 Query 参数为什么会变成逗号拼接

请求:

1
GET /hi?name=xiaoming&name=hanmeimei

Controller:

1
2
3
4
@GetMapping("/hi")
public String hi(@RequestParam("name") String name) {
return name;
}

一个容易忽略的事实是:HTTP Query 参数天然可以是多值的。

Servlet 层获取时,本质更接近:

1
String[] values = request.getParameterValues("name");

得到:

1
["xiaoming", "hanmeimei"]

当 Controller 参数类型写成单个 String 时,Spring 还需要做一次:

1
String[] -> String

经典实现会通过 ConversionService 把多个值用 , 拼接:

1
xiaoming,hanmeimei

如果业务语义本来就是多值,应该直接声明:

1
2
3
4
@GetMapping("/hi")
public String hi(@RequestParam("name") String[] names) {
return Arrays.toString(names);
}

或者:

1
@RequestParam("name") List<String> names

这比依赖隐式的数组转字符串规则更清楚。


2.7 URL 参数最终为什么能自动变成 intDate 等类型

HTTP Query 参数和 Path Variable 从协议层进入 Spring 时,本质通常仍然是字符串:

1
2
3
"18"
"2026-08-12"
"true"

但 Controller 可以声明:

1
2
3
4
@GetMapping("/user")
public String user(@RequestParam("age") int age) {
return String.valueOf(age);
}

这是因为参数解析结束后还存在类型转换阶段

flowchart LR
    A[HTTP 原始值<br/>String / String[]] --> B[ArgumentResolver]
    B --> C[WebDataBinder]
    C --> D[ConversionService]
    D --> E[Converter / ConverterFactory / Formatter]
    E --> F[Controller 参数类型]

例如:

1
String -> Integer

可以找到数字转换器。

但日期转换没有“看到像日期的字符串就一定能猜对格式”这么简单。

不要依赖 java.util.Date(String) 的历史行为

下面这种接口:

1
2
3
4
@GetMapping("/time")
public String time(@RequestParam("date") Date date) {
return date.toString();
}

面对:

1
2021-05-01 20:26:53

并不保证能成功,因为普通对象转换器可能最终寻找构造器或工厂方法,而 Date 的历史字符串构造逻辑并不支持任意自定义格式。

更明确的写法是:

1
2
3
4
5
6
7
@GetMapping("/time")
public String time(
@RequestParam("date")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
Date date) {
return date.toString();
}

现代项目更推荐 java.time

例如:

1
2
3
4
5
6
7
@GetMapping("/time")
public String time(
@RequestParam("date")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime date) {
return date.toString();
}

如果接口需要表达时区,应考虑:

  • Instant
  • OffsetDateTime
  • ZonedDateTime

不要用一个没有时区语义的 Date / LocalDateTime 去承载跨时区协议含义。

@DateTimeFormat 与 JSON Body 不是一回事

@DateTimeFormat 主要参与 Spring 的字段格式化/绑定,例如 Query Parameter、Path Variable、Form 数据。

JSON Body 的序列化和反序列化通常交给 Jackson,因此日期格式应通过:

1
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

或者统一配置 ObjectMapper

这正体现了本文最重要的主线:URL 参数转换和 Body JSON 反序列化属于两条不同的处理链。


三、Header:协议上大小写不敏感,不代表所有 Map API 都不敏感

3.1 一个 Header 可以有多个值

HTTP Header 可能以两种形式表达多值:

1
X-Tag: a,b

或:

1
2
X-Tag: a
X-Tag: b

如果 Controller 写成:

1
2
3
4
5
@GetMapping("/headers")
public Map<String, String> headers(
@RequestHeader Map<String, String> headers) {
return headers;
}

普通 Map<String, String> 天然只能表达:

1
key -> single value

无法完整表达:

1
key -> [value1, value2, ...]

旧版 RequestHeaderMapMethodArgumentResolver 的核心区别也很直接:

1
2
3
4
5
6
7
if (MultiValueMap.class.isAssignableFrom(paramType)) {
String[] values = webRequest.getHeaderValues(headerName);
// 逐个 add
} else {
String value = webRequest.getHeader(headerName);
// 只 put 一个
}

因此,如果需要完整 Header 集合,应使用:

1
@RequestHeader MultiValueMap<String, String> headers

更推荐:

1
@RequestHeader HttpHeaders headers

因为 HttpHeaders 不只是一个多值 Map,还提供了大量 HTTP 语义化方法,例如:

1
2
3
headers.getContentType();
headers.getAccept();
headers.getFirst("X-Request-Id");

3.2 Header 名称大小写不敏感,但普通 Map#get() 仍然大小写敏感

协议层:

1
2
3
MyHeader
myheader
MYHEADER

应视为同一个 Header 名称。

所以:

1
@RequestHeader("MyHeader") String value

通常可以接收到请求里的:

1
myheader: value

因为底层 Servlet 容器查找 Header 时会做大小写不敏感匹配。

但下面这个代码就不一样:

1
2
3
4
@GetMapping("/headers")
public String headers(@RequestHeader Map<String, String> headers) {
return headers.get("MyHeader");
}

如果 Spring 最终把容器枚举出来的原始名称:

1
myheader

作为普通 LinkedHashMap 的 key,后续:

1
headers.get("MyHeader")

当然可能得到 null,因为 LinkedHashMap 是大小写敏感的。

为什么 HttpHeaders 更安全

经典实现的 HttpHeaders 使用 case-insensitive 的键结构,因此更符合 HTTP Header 的协议语义。

所以工程上不要为了“抽象成 Map”而丢失协议语义:

1
@RequestHeader HttpHeaders headers

通常比:

1
@RequestHeader Map<String, String> headers

更合适。


四、Content-Type 不是随手 addHeader 就能决定的

下面的代码很容易让人产生错觉:

1
2
3
4
5
@GetMapping("/hello")
public String hello(HttpServletResponse response) {
response.addHeader("Content-Type", "application/json");
return "ok";
}

开发者可能认为:

1
我已经设置 Content-Type=application/json

因此响应一定是:

1
Content-Type: application/json

实际上,最终响应媒体类型还会受到:

  • Servlet 容器实现;
  • Accept 请求头;
  • @RequestMapping(produces=...)
  • Controller 返回值类型;
  • 已注册 HttpMessageConverter
  • Converter 支持的 MediaType;
  • Spring 内容协商顺序;

等因素影响。

4.1 Tomcat 中 Content-Type 是特殊 Header

经典 Tomcat 实现里,Content-Type 不一定像普通 Header 一样直接塞进 Header 集合,而可能被识别为响应对象的专门属性:

1
2
3
4
if (name.equalsIgnoreCase("Content-Type")) {
setContentType(value);
return true;
}

随后 Spring MVC 在处理 Controller 返回值时,会进入:

1
2
RequestResponseBodyMethodProcessor
-> writeWithMessageConverters

并重新决定最终 MediaType

如果 Spring 读取不到“已确定的 Content-Type”,就会继续内容协商。

4.2 内容协商的核心不是“谁最后 setHeader”

可以抽象成:

flowchart TD
    A[Controller 返回值] --> B[读取响应已预设 Content-Type]
    B --> C{是否 concrete?}
    C -- 是 --> D[直接使用该 MediaType]
    C -- 否 --> E[读取请求 Accept]
    E --> F[计算返回值可产生的 MediaType]
    F --> G[求兼容集合]
    G --> H[选择最合适 MediaType]
    H --> I[选择 HttpMessageConverter]
    I --> J[写入 Body 与最终 Header]

对于一个 String 返回值:

1
return "ok";

常见候选是 StringHttpMessageConverter,因此最终很可能得到:

1
Content-Type: text/plain;charset=UTF-8

4.3 更正确的控制方式

使用 produces

1
2
3
4
5
6
7
@GetMapping(
value = "/hello",
produces = MediaType.APPLICATION_JSON_VALUE
)
public String hello() {
return "{\"result\":\"ok\"}";
}

但更推荐直接返回对象:

1
2
3
4
5
6
7
8
9
record Result(String result) {}

@GetMapping(
value = "/hello",
produces = MediaType.APPLICATION_JSON_VALUE
)
public Result hello() {
return new Result("ok");
}

这才真正让“Java 对象 -> JSON”这条链路成立。

让客户端通过 Accept 表达期望

1
2
GET /hello
Accept: application/json

服务器会将客户端可接受类型和自身可生产类型进行匹配。

4.4 Tomcat 与 Jetty 行为可能不同

同一个:

1
HttpServletResponse#addHeader(...)

在 Tomcat 和 Jetty 中最终执行的是不同容器实现。

这意味着一些“测试出来的框架结论”其实是:

1
Spring + 某个 Servlet 容器 + 某个版本

共同作用的结果。

排查 Header、编码、连接、Servlet 行为时,不能忘记容器也是调用链的一部分。


五、Body:真正的核心是 HttpMessageConverter

URL 和 Header 主要解决字符串值的获取与绑定,而 Body 经常涉及:

1
2
3
4
5
6
JSON <-> Java Object
XML <-> Java Object
byte[]
Resource
Form
String

Spring MVC 并不自己实现所有 JSON/XML 编解码细节,而是通过 HttpMessageConverter 适配 Jackson、Gson 等库。

5.1 No converter found for return value of type 到底是什么意思

例如:

1
2
3
4
5
6
7
@Data
@AllArgsConstructor
@NoArgsConstructor
class Student {
private String name;
private Integer age;
}
1
2
3
4
5
6
7
8
@RestController
public class StudentController {

@GetMapping("/student")
public Student student() {
return new Student("xiaoming", 12);
}
}

如果是一个纯 Spring MVC 项目,只依赖:

1
2
3
4
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>

但 classpath 中没有任何可用 JSON Converter,就可能出现:

1
No converter found for return value of type ...

这句话真正的含义不是:

1
Spring 不支持这个 Java 类

而是:

1
当前注册的 HttpMessageConverter 中,没有谁能把这个返回值写成可接受的 HTTP MediaType。

5.2 writeWithMessageConverters 的核心决策

可以概括为:

1
2
3
4
5
1. 客户端接受什么?        -> Accept
2. 服务端能产生什么? -> produces + Converter 能力
3. 两者交集是什么? -> compatible media types
4. 哪个 Converter 能写? -> canWrite(...)
5. 用它真正序列化 Body

伪代码:

1
2
3
4
5
6
7
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
List<MediaType> producibleTypes = getProducibleMediaTypes(...);

if (body != null && producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
"No converter found for return value of type: " + valueType);
}

5.3 为什么 Spring Boot 通常“自动就有 JSON”

spring-boot-starter-web 会引入 Web 与 JSON 相关依赖,并通过自动配置根据 classpath 判断是否创建对应 Converter。

这种设计模式值得记住:

1
依赖存在 -> 条件成立 -> 自动配置生效 -> Converter 被注册

Spring 生态里大量自动配置都遵循同样思路:

1
2
3
@ConditionalOnClass(...)
@ConditionalOnMissingBean(...)
@ConditionalOnProperty(...)

所以遇到“之前能序列化,现在突然不能”时,除了看 Controller,更要检查:

1
mvn dependency:tree

或 Gradle 依赖树。


5.4 新增一个 JSON 库,接口返回结果竟然变了

假设一开始应用实际使用 Gson,返回:

1
2
3
{
"name": "xiaoming"
}

其中:

1
age = null

没有被序列化。

后来项目间接引入 Jackson,Controller 一行没改,却得到:

1
2
3
4
{
"name": "xiaoming",
"age": null
}

这类问题的本质是:

依赖变化改变了默认 HttpMessageConverter,而不同 JSON 库的默认策略不完全相同。

Spring MVC 在发现多个实现时存在优先选择逻辑,因此“增加一个依赖”有时等价于“改变 Web API 行为”。

不要把序列化契约交给默认值

如果 API 明确要求不输出 null,应该显式定义,例如 Jackson:

1
2
3
4
5
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Student {
private String name;
private Integer age;
}

或者全局配置 ObjectMapper

对外 API 的 JSON 形态是契约,应该被测试固定,而不是依赖“当前刚好加载的是哪个 JSON 库”。

建议至少建立:

  • Controller/MockMvc 响应快照测试;
  • JSON Schema / OpenAPI Contract;
  • 关键字段 null/缺省语义测试。

5.5 Request Body 是流:读一次就少一次

这是 Spring Web 中极其重要的一条基础规律。

错误示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BodyLogFilter implements Filter {

@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {

String body = new String(
request.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);

System.out.println(body);

chain.doFilter(request, response);
}
}

Filter 已经把:

1
request.getInputStream()

读到底了。

Controller 再进入:

1
2
3
4
@PostMapping("/student")
public Student save(@RequestBody Student student) {
return student;
}

RequestResponseBodyMethodProcessor 检测请求流时可能发现已经没有数据,于是得到:

1
Required request body is missing

为什么 Spring 自己探测 Body 不会把第一个字节吃掉

内部通常会使用具备回退能力的流包装,例如读取一个字节判断是否为空,再通过 unread 放回去。

关键思想不是某个具体类,而是:

读取流之前必须明确所有权:你是消费它,还是仅观察它?

5.6 记录 Body 的更合理方式

方案一:RequestBodyAdvice

如果你的目标是记录已经反序列化成功的 @RequestBody 对象,可以使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RestControllerAdvice
public class RequestLogAdvice extends RequestBodyAdviceAdapter {

@Override
public boolean supports(
MethodParameter methodParameter,
Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}

@Override
public Object afterBodyRead(
Object body,
HttpInputMessage inputMessage,
MethodParameter parameter,
Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {

// 这里拿到的已经是解析后的对象
System.out.println(body);
return body;
}
}

这避免了 Filter 提前消费原始 InputStream。

方案二:缓存型 Request Wrapper

如果确实要记录原始 Body,可以使用缓存请求包装器,但要注意使用时机:

1
先包装请求 -> 让后续链正常读取 -> 再从缓存读取日志内容

不要只是换成一个 Wrapper,然后仍然在 chain.doFilter() 之前把输入流读到底。

安全提醒

生产日志不要无脑记录所有 Body。必须考虑:

  • Password;
  • Token;
  • Cookie;
  • 身份证/手机号;
  • 银行卡;
  • 医疗信息;
  • 超大文件上传;
  • 二进制内容。

日志 Filter 往往比业务代码更容易造成数据泄露。


六、Validation:有约束注解,不等于校验一定会执行

Bean Validation 的最大误区是:

1
字段上写了 @Size / @NotNull

并不自动等于:

1
任何地方创建这个对象都会被校验

约束定义触发校验是两件事。

6.1 @RequestBody 对象为什么没有被校验

DTO:

1
2
3
4
5
public class StudentRequest {

@Size(max = 10)
private String name;
}

Controller:

1
2
3
@PostMapping("/students")
public void add(@RequestBody StudentRequest request) {
}

仅仅字段存在 @Size,不一定会触发 Spring MVC 的 Bean Validation。

经典 RequestResponseBodyMethodProcessor 在解析 Body 后,会进入类似:

1
validateIfApplicable(binder, parameter);

它会检查 Controller 方法参数本身是否有:

1
@Valid

或:

1
@Validated

因此推荐:

1
2
3
4
@PostMapping("/students")
public void add(
@Valid @RequestBody StudentRequest request) {
}

现代 Spring Boot 3+ 使用:

1
import jakarta.validation.Valid;

而不是旧资料中的:

1
javax.validation.Valid;

6.2 @Valid@Validated 怎么区分

可以从两个维度理解。

@Valid

来自 Jakarta Bean Validation:

1
jakarta.validation.Valid

核心价值包括:

  • 触发 Bean Validation;
  • 标记级联校验。

@Validated

来自 Spring:

1
org.springframework.validation.annotation.Validated

在常规对象参数校验之外,常用于:

  • 指定 Validation Groups;
  • Spring 的方法级校验语义。

对于普通 @RequestBody DTO:

1
@Valid @RequestBody XxxRequest

通常已经足够清晰。

需要分组时再使用:

1
@Validated(CreateGroup.class)

6.3 嵌套对象为什么不会自动递归校验

1
2
3
4
5
6
7
public class StudentRequest {

@NotBlank
private String name;

private PhoneRequest phone;
}
1
2
3
4
5
public class PhoneRequest {

@Size(max = 20)
private String number;
}

即使外层:

1
@Valid @RequestBody StudentRequest request

也不意味着:

1
Student.phone.number

必然被递归校验。

级联校验需要在嵌套字段上显式:

1
2
@Valid
private PhoneRequest phone;

完整示例:

1
2
3
4
5
6
7
8
9
public class StudentRequest {

@NotBlank
private String name;

@Valid
@NotNull
private PhoneRequest phone;
}

这背后的元数据语义是:

1
phone.cascading = true

产品代码中 DTO 很容易嵌套 3~5 层,因此每一个对象边界都要问:

这里需要级联校验吗?


6.4 @Size(min = 1) 为什么挡不住 null

例如:

1
2
@Size(min = 1, max = 10)
private String name;

很多人会以为:

1
最小长度 1

等于:

1
不能为 null

实际上 Bean Validation 的许多约束遵循一个很重要的组合原则:

某个约束只负责自己的维度,null 是否允许由专门的 nullability 约束负责。

经典 SizeValidatorForCharSequence 逻辑类似:

1
2
3
4
5
6
7
public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
int length = value.length();
return length >= min && length <= max;
}

因此真正表达“必须存在,且长度 1~10”应写成:

1
2
3
@NotNull
@Size(min = 1, max = 10)
private String name;

字符串业务里更常见:

1
2
3
@NotBlank
@Size(max = 10)
private String name;

一个容易被口语化表述误导的点

@SizeCharSequence 约束的是字符序列长度,不是数据库字节长度,也不是 UTF-8 编码后的字节数。

如果数据库字段受“字节长度”限制,例如某些字符集下:

1
10 个汉字 != 10 bytes

就需要额外业务约束,而不能把 @Size(max = 10) 当作“最多 10 字节”。


6.5 Path Variable 上的约束属于另一条校验路径

例如:

1
2
3
4
5
6
7
@DeleteMapping("/students/{id}")
public void delete(
@PathVariable("id")
@Min(1)
@Max(10000)
Long id) {
}

id 的解析器是 Path Variable 对应的 HandlerMethodArgumentResolver,它本身的主要职责是:

1
从 URI 模板变量中把 id 取出来并做类型转换

方法参数约束的校验则属于方法校验语义,并不是每一个 ArgumentResolver 内部都自己写一套 Validation。

版本提示:Spring Framework 6.1+

Spring MVC 增强了内建的 method validation 支持。现代项目要区分两类错误:

  • @RequestBody 对象做 Bean Validation,常见 MethodArgumentNotValidException
  • 对 Handler 方法参数/返回值做方法级约束,可能产生 HandlerMethodValidationException

因此统一异常处理时,不要只捕获一种 Validation 异常就认为覆盖完整。


七、Filter:理解责任链,才能理解“执行 0 次、1 次、2 次”的后果

Filter 是 Servlet 标准,不属于 Spring MVC Controller 体系。

典型用途:

  • 访问日志;
  • TraceId;
  • CORS;
  • 认证入口;
  • 请求包装;
  • 压缩;
  • 监控;
  • 安全审计。

7.1 FilterChain 的本质是责任链模式

Tomcat 中的经典核心类是:

1
ApplicationFilterChain

可以抽象成:

sequenceDiagram
    participant C as Container
    participant FC as FilterChain
    participant F1 as Filter1
    participant F2 as Filter2
    participant S as Servlet
    participant D as DispatcherServlet

    C->>FC: doFilter(request,response)
    FC->>F1: doFilter(..., chain)
    F1->>FC: chain.doFilter(...)
    FC->>F2: doFilter(..., chain)
    F2->>FC: chain.doFilter(...)
    FC->>S: servlet.service(...)
    S->>D: DispatcherServlet.service(...)

经典实现里会维护:

1
2
3
filters[]  所有 Filter
n Filter 总数
pos 当前执行到哪个 Filter

伪代码:

1
2
3
4
5
6
7
if (pos < n) {
Filter filter = filters[pos++];
filter.doFilter(request, response, this);
return;
}

servlet.service(request, response);

理解这几行代码后,Filter 中最常见的两个事故都很好解释。


7.2 chain.doFilter() 调两次:业务可能执行两次

错误代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class DemoFilter implements Filter {

@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {

try {
doSomething();
} catch (Exception ex) {
chain.doFilter(request, response);
}

chain.doFilter(request, response);
}
}

发生异常时:

1
2
3
4
5
6
7
8
9
10
第一次 chain.doFilter()
-> 后续 Filter
-> Servlet
-> Controller

catch 结束

第二次 chain.doFilter()
-> 再沿当前链继续
-> 可能再次到 Servlet / Controller

于是出现极其危险的现象:

  • 查询执行两次,看起来只是日志重复;
  • 下单执行两次,可能生成重复订单;
  • 转账执行两次,直接变生产事故。

因此 Filter 必须保证控制流清晰:

1
2
3
4
5
6
7
8
9
@Override
public void doFilter(...) {
try {
before();
chain.doFilter(request, response);
} finally {
after();
}
}

如果某个异常分支已经继续链路,必须:

1
return;

避免再次执行。


7.3 一次都不调用:后面的业务全部被截断

1
2
3
4
5
6
7
8
9
10
11
@Component
public class DemoFilter implements Filter {

@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) {
System.out.println("do some logic");
}
}

很多人会误以为:

1
只是后续 Filter 不执行,Controller 还会执行

实际上不会。

因为当前 Filter 返回后,ApplicationFilterChain#internalDoFilter() 就结束了,永远到不了:

1
servlet.service(request, response);

这就是 Filter 为什么既能做“前置处理”,又能做“拦截”:

1
2
3
4
5
6
if (!allowed) {
response.sendError(403);
return;
}

chain.doFilter(request, response);

调用链是否继续,是 Filter 自己决定的。


八、Filter 的三种注册方式不要混着用

8.1 @WebFilter 是 Servlet 规范,不是 Spring @Component

1
2
3
@WebFilter(urlPatterns = "/*")
public class TimeCostFilter implements Filter {
}

这里的 @WebFilter 来自 Servlet 规范。

在 Spring Boot 中通常需要配合:

1
2
3
4
@ServletComponentScan
@SpringBootApplication
public class Application {
}

Spring Boot 扫描到 Servlet 组件后,会用自己的注册结构把它注册到 Web Server。

经典实现中会构建 FilterRegistrationBean,而原始 Filter 定义可能作为内部 Bean 存在。

所以不要理所当然认为:

1
Filter 能执行 == 这个 Filter 一定是一个普通 Spring Bean

这两个概念不同。

8.2 @Component:让 Filter 成为普通 Spring Bean

如果 Filter 没有特殊 Servlet 注册需求,通常更简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@Order(1)
public class TimeCostFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {

long start = System.nanoTime();
try {
filterChain.doFilter(request, response);
} finally {
long cost = System.nanoTime() - start;
// record metric
}
}
}

Spring Boot 能识别 Filter 类型 Bean,并完成 Web 注册。

8.3 FilterRegistrationBean:需要精确控制时使用

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class FilterConfiguration {

@Bean
public FilterRegistrationBean<TimeCostFilter> timeCostFilter() {
FilterRegistrationBean<TimeCostFilter> registration =
new FilterRegistrationBean<>();

registration.setFilter(new TimeCostFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}
}

它适合需要明确控制:

  • URL Pattern;
  • Order;
  • DispatcherType;
  • Async Supported;
  • Filter Name;
  • Servlet Name;

的场景。


8.4 为什么 @WebFilter + @Order 在一些版本中不按预期工作

经典 Spring Boot 实现中:

1
2
3
@WebFilter
-> 扫描 Servlet 组件
-> 动态构建 FilterRegistrationBean BeanDefinition

而这个转换过程未必会把 Spring 的:

1
@Order

同步写入 FilterRegistrationBean.order

过滤器最终顺序却取决于 RegistrationBean 的 order,因此就可能出现:

1
2
类上写了 @Order
实际 Servlet Filter 顺序没变化

这也是为什么,排序是强需求时,显式 FilterRegistrationBean#setOrder() 往往更可控。

不同 Spring Boot 版本对此细节曾有变化,所以不要只凭一个老版本结论判断当前项目;最终应以注册后的 Filter 顺序为准进行集成测试。


8.5 @WebFilter + @Component 可能把同一个 Filter 注册两遍

这是一种更隐蔽的错误:

1
2
3
4
5
@WebFilter
@Component
@Order(1)
public class TimeCostFilter implements Filter {
}

它可能同时走两条路径:

1
2
3
@WebFilter
-> Servlet Component Scan
-> FilterRegistrationBean A

以及:

1
2
3
4
@Component
-> Spring Bean
-> Spring Boot 识别 Filter Bean
-> FilterRegistrationBean B

结果:

1
一个请求,同一个 Filter 逻辑执行两次

所以不要通过“两个注解都加上,总有一个能生效”的方式解决注册问题。

工程建议

从下面三种方案中选一种

1
2
3
A. @Component + OncePerRequestFilter
B. @Bean FilterRegistrationBean
C. @WebFilter + @ServletComponentScan

Spring Boot 业务项目通常优先 A;需要精确 Servlet 注册控制时优先 B。


九、Spring Boot 是怎么把 Filter 注册进 Tomcat 的

理解启动链对排查“Filter 为什么没注册 / 顺序不对 / 为什么启动时就实例化”非常有帮助。

可以粗略画成:

flowchart TD
    A[SpringApplication.run] --> B[ServletWebServerApplicationContext]
    B --> C[onRefresh]
    C --> D[createWebServer]
    D --> E[ServletWebServerFactory]
    E --> F[启动 Tomcat / Jetty]
    F --> G[回调 selfInitialize]
    G --> H[getServletContextInitializerBeans]
    H --> I[ServletRegistrationBean]
    H --> J[FilterRegistrationBean]
    H --> K[ServletListenerRegistrationBean]
    J --> L[onStartup ServletContext]
    L --> M[容器 Filter Mapping]

经典核心代码可以概括为:

1
2
3
4
5
6
7
8
9
10
11
private void selfInitialize(ServletContext servletContext)
throws ServletException {

prepareWebApplicationContext(servletContext);
registerApplicationScope(servletContext);

for (ServletContextInitializer initializer
: getServletContextInitializerBeans()) {
initializer.onStartup(servletContext);
}
}

这解释了两个重要事实:

  1. Spring Bean 生命周期与 Servlet 容器组件注册生命周期是相互衔接但不完全相同的;
  2. FilterRegistrationBean 是 Spring Boot 与 Servlet 容器之间很关键的桥梁。

十、Spring Security:本质仍然是 Filter Chain

Spring Security 功能很多,但理解 Servlet 版本的入口只需要抓住一点:

它通过一条安全 Filter Chain 包在 Spring MVC 之前。

典型结构:

flowchart LR
    A[Request] --> B[DelegatingFilterProxy]
    B --> C[FilterChainProxy]
    C --> D[SecurityFilterChain]
    D --> E[Authentication Filter]
    E --> F[Authorization Filter]
    F --> G[DispatcherServlet]

这也是为什么:

  • 登录页面可以由 Filter 生成;
  • 未认证访问可以在进入 Controller 之前被拦截;
  • Security 的异常处理与普通 @ControllerAdvice 有不同边界。

10.1 PasswordEncoder 不是“可有可无的工具类”

认证系统不能把用户输入密码和数据库明文直接:

1
rawPassword.equals(storedPassword)

因此需要 PasswordEncoder

1
2
3
4
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}

它解决两个问题:

1
2
注册/改密: raw -> encoded
登录校验: raw + encoded -> matches?

10.2 DelegatingPasswordEncoder{id} 格式

Spring Security 经典存储格式:

1
{id}encodedPassword

例如:

1
{bcrypt}$2a$...

DelegatingPasswordEncoder 会先提取:

1
id = bcrypt

再选择对应 Encoder。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
String id = extractId(encodedPassword);
PasswordEncoder delegate = idToPasswordEncoder.get(id);

if (delegate == null) {
return defaultPasswordEncoderForMatches
.matches(rawPassword, encodedPassword);
}

return delegate.matches(
rawPassword,
extractEncodedPassword(encodedPassword));

如果存储的是:

1
pass

没有 {id},就可能出现经典错误:

1
There is no PasswordEncoder mapped for the id "null"

不要把 {noop} 当生产方案

测试里可以看到:

1
{noop}pass

用于表达明文匹配,但生产密码存储不应该使用 NoOp,也不应使用 MD5/SHA-1 这类快速摘要算法作为密码哈希方案。

现代 Spring Security 的默认推荐仍是使用自适应单向密码哈希,并通过 DelegatingPasswordEncoder 保留未来算法升级能力。


10.3 ROLE_ADMINADMIN 为什么不是一回事

Spring Security 中:

1
roles("ADMIN")

不是简单地保存:

1
ADMIN

经典 UserBuilder#roles 会自动加前缀:

1
ROLE_ADMIN

而如果你直接写:

1
new SimpleGrantedAuthority("ADMIN")

得到的就是:

1
ADMIN

此时如果授权规则是:

1
hasRole("ADMIN")

内部通常会按默认 role prefix 转成:

1
ROLE_ADMIN

再去 authorities 中查。

如果用户实际只有:

1
ADMIN

匹配自然失败。

建议统一团队语义

二选一:

1
2
Role 语义:      ROLE_ADMIN + hasRole("ADMIN")
Authority 语义: order:read + hasAuthority("order:read")

不要在项目里混合:

1
2
3
4
ADMIN
ROLE_ADMIN
admin
ROLE_admin

然后靠每个开发者记忆哪个 API 会自动补前缀。


10.4 旧版 Security 配置与现代写法

旧资料中常见:

1
2
3
4
5
6
7
8
9
10
public class SecurityConfig
extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**")
.hasRole("ADMIN");
}
}

现代 Spring Security 已经采用组件式配置,不再推荐/使用 WebSecurityConfigurerAdapter

典型写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {

http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());

return http.build();
}

@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories
.createDelegatingPasswordEncoder();
}
}

这也是阅读老 Spring Security 资料时最需要做的“机制与 API 分离”:

1
2
机制仍然有价值:FilterChain、Authentication、Authority、PasswordEncoder
旧 DSL/API 需要迁移:WebSecurityConfigurerAdapter、antMatchers 等

10.5 默认登录页为什么“凭空出现”

未认证用户访问受保护接口时,典型流程是:

sequenceDiagram
    participant U as Browser
    participant S as Security Filters
    participant E as ExceptionTranslationFilter
    participant A as AuthenticationEntryPoint
    participant L as DefaultLoginPageGeneratingFilter

    U->>S: GET /admin
    S->>E: authentication required
    E->>A: commence(...)
    A-->>U: 302 Location: /login
    U->>L: GET /login
    L-->>U: HTML login page

这再次说明:

Spring Security 很多看起来像 Controller 的行为,其实根本没有进入 Controller。


十一、异常处理:@ControllerAdvice 有明确的作用边界

11.1 @ControllerAdvice 为什么接不住 Filter 异常

Filter:

1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class PermissionFilter implements Filter {

@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {

throw new NotAllowedException();
}
}

Advice:

1
2
3
4
5
6
7
8
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(NotAllowedException.class)
public Map<String, Object> handle() {
return Map.of("code", 403);
}
}

很多人期待:

1
Filter 抛异常 -> @ExceptionHandler 捕获

但调用链实际上是:

1
2
3
4
Filter
-> DispatcherServlet
-> Controller
-> HandlerExceptionResolver

@ControllerAdvice 背后的 ExceptionHandlerExceptionResolver 属于 DispatcherServlet 的异常解析体系。

如果异常在:

1
进入 DispatcherServlet 之前

就不会自然进入这套处理逻辑。

11.2 DispatcherServlet 内部异常如何被统一处理

经典 doDispatch() 可以抽象为:

1
2
3
4
5
6
7
8
9
10
Exception dispatchException = null;

try {
// find handler
// invoke handler
} catch (Exception ex) {
dispatchException = ex;
}

processDispatchResult(..., dispatchException);

然后:

1
2
3
4
5
6
processDispatchResult
-> processHandlerException
-> 遍历 HandlerExceptionResolver
-> ExceptionHandlerExceptionResolver
-> @ExceptionHandler
-> @ControllerAdvice

ExceptionHandlerExceptionResolver 初始化时会扫描 @ControllerAdvice,缓存其中的异常映射。

所以:

1
ControllerAdvice 是 MVC 异常体系,不是 JVM 全局异常捕获器。

11.3 Filter 中的异常应该怎么处理

方案一:Filter 自己转换成 HTTP 响应

认证/鉴权场景很常见:

1
2
3
4
5
6
if (!allowed) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":403}");
return;
}

对于 Spring Security,更应该使用其专门的:

  • AuthenticationEntryPoint
  • AccessDeniedHandler

而不是绕到 MVC Advice。

方案二:显式委托 HandlerExceptionResolver

如果确实希望 Filter 异常和 MVC 使用同一套异常格式,可以注入:

1
2
@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver resolver;

然后:

1
2
3
4
5
6
7
8
9
try {
filterChain.doFilter(request, response);
} catch (Exception ex) {
resolver.resolveException(
request,
response,
null,
ex);
}

这是一种显式“跨边界”方案:

1
Filter 主动把异常交给 MVC Resolver

而不是 MVC 自己天然能捕获。


十二、404 是一种非常特殊的“异常”

很多 REST API 希望统一:

1
2
3
4
{
"code": 404,
"message": "not found"
}

于是写:

1
2
3
4
5
6
7
8
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(NoHandlerFoundException.class)
public Object notFound() {
return Map.of("code", 404);
}
}

但旧版 Spring Boot 项目中经常发现完全不生效。

12.1 NoHandlerFoundException 的前提是“真的没有 Handler”

DispatcherServlet 大致逻辑:

1
2
3
4
5
6
mappedHandler = getHandler(request);

if (mappedHandler == null) {
noHandlerFound(request, response);
return;
}

只有:

1
mappedHandler == null

才有机会走 no-handler 逻辑。

12.2 静态资源 Handler 会吃掉 /**

Spring Boot 默认静态资源处理历史上会注册:

1
2
/webjars/**
/**

因此一个不存在的 API:

1
/this-api-does-not-exist

仍然可能先匹配到 Resource Handler。

于是:

1
mappedHandler != null

自然不会触发 NoHandlerFoundException

旧版方案常见:

1
2
spring.resources.add-mappings=false
spring.mvc.throw-exception-if-no-handler-found=true

现代 Spring Boot 配置名

当前属性已经是:

1
spring.web.resources.add-mappings=false

而现代 Spring MVC 的静态资源 Handler 在找不到资源时还可能抛出:

1
NoResourceFoundException

因此现在统一 404 时,应同时理解:

1
没有 Controller Handler

和:

1
匹配到了 ResourceHttpRequestHandler,但资源不存在

是两种不同路径。

不要照抄旧版本“打开一个配置就一定进入 NoHandlerFoundException”的结论。


12.3 现代 REST 异常响应可以考虑 ProblemDetail

现代 Spring Framework 提供基于 RFC 9457 的:

1
ProblemDetail

例如:

1
2
3
4
5
6
7
8
9
10
11
@RestControllerAdvice
public class ApiExceptionHandler {

@ExceptionHandler(BusinessException.class)
public ProblemDetail handle(BusinessException ex) {
ProblemDetail detail = ProblemDetail.forStatus(400);
detail.setTitle("Business Error");
detail.setDetail(ex.getMessage());
return detail;
}
}

这样可以避免每个项目重新发明完全不同的错误 JSON 协议。

当然,如果公司已经有统一的:

1
2
3
4
5
{
"code": "...",
"message": "...",
"requestId": "..."
}

也可以继续使用自己的契约,关键是全链路一致,而不是“所有错误都强行挤进 @ControllerAdvice”。


十三、把所有机制串起来:Controller 方法到底是怎么被调用的

到这里可以把 Spring MVC Controller 的内部主链压缩成下面这个模型:

sequenceDiagram
    participant U as Client
    participant T as Tomcat
    participant F as FilterChain
    participant D as DispatcherServlet
    participant HM as HandlerMapping
    participant HA as HandlerAdapter
    participant AR as ArgumentResolvers
    participant MC as MessageConverters
    participant V as Validator
    participant C as Controller
    participant ER as ExceptionResolvers

    U->>T: HTTP Request
    T->>F: doFilter
    F->>D: servlet.service
    D->>HM: getHandler
    HM-->>D: HandlerMethod
    D->>HA: handle
    HA->>AR: resolveArguments
    AR->>MC: read/convert if needed
    AR->>V: validate if applicable
    AR-->>HA: Object[] args
    HA->>C: invoke(args)
    C-->>HA: returnValue
    HA->>MC: write response
    MC-->>U: HTTP Response

    C--xD: Exception
    D->>ER: resolveException

理解这张图后,可以快速判断问题属于哪个层级:

现象 优先检查组件
404,Controller 完全没进 HandlerMapping / Path Matching
400,提示缺参数 ArgumentResolver / required
参数名找不到 编译参数名 / -parameters / 注解 name
字符串无法转 int/date ConversionService / Formatter
JSON 无法解析 HttpMessageConverter / Content-Type
返回对象无法输出 HttpMessageConverter / Accept / produces
Validation 不执行 @Valid / @Validated / Validator
嵌套对象没校验 字段上的 @Valid
Controller 执行两遍 FilterChain / 重复 Filter 注册
Controller 完全不执行但 200 Filter 没继续 chain
Advice 接不住异常 异常发生在 DispatcherServlet 之外
Security 403 Authentication / Authority / ROLE_ / Matcher

十四、源码排查方法:不要一上来就全局搜异常字符串

这些案例真正值得学习的不只是答案,而是一套定位 Spring 问题的方法。

14.1 从注解反推 ArgumentResolver

例如看到:

1
@RequestParam

就应该想到:

1
RequestParamMethodArgumentResolver

看到:

1
@PathVariable

想到:

1
PathVariableMethodArgumentResolver

看到:

1
@RequestHeader Map

想到:

1
RequestHeaderMapMethodArgumentResolver

看到:

1
@RequestBody

想到:

1
RequestResponseBodyMethodProcessor

Spring MVC 的参数解析本质上是策略集合:

1
2
3
4
5
for (HandlerMethodArgumentResolver resolver : resolvers) {
if (resolver.supportsParameter(parameter)) {
return resolver.resolveArgument(...);
}
}

所以排查第一步经常不是:

1
为什么 Spring 这么奇怪?

而是:

1
当前参数到底由哪个 Resolver 负责?

14.2 从返回值反推 ReturnValueHandler 和 Converter

如果问题发生在响应:

1
2
3
Content-Type 不对
JSON 结构变了
No converter found

优先关注:

1
2
3
RequestResponseBodyMethodProcessor
AbstractMessageConverterMethodProcessor
HttpMessageConverter

然后看三个输入:

1
2
3
Java value type
selected MediaType
converter list + order

14.3 从运行环境反推“谁真正实现了接口”

例如代码只看到:

1
HttpServletResponse response

但运行时可能是:

1
2
Tomcat: org.apache.catalina.connector.Response
Jetty: org.eclipse.jetty.server.Response

接口相同,不代表底层边界行为完全相同。

14.4 从“本地能跑,生产失败”反推构建产物

典型工具:

1
javap -verbose XxxController.class

检查:

  • MethodParameters;
  • RuntimeVisibleAnnotations;
  • 泛型签名;
  • 编译参数;

同时比较:

1
2
mvn help:effective-pom
mvn dependency:tree

很多“框架玄学”最后是:

1
2
IDE 编译参数 != CI 编译参数
本地依赖树 != 生产依赖树

14.5 先找生命周期边界,再找异常处理器

看到异常时先问:

1
2
3
4
5
6
异常发生在 Filter 前?
Filter 中?
DispatcherServlet 中?
Controller 中?
HttpMessageConverter 中?
返回值写出阶段?

然后再决定:

  • Filter 自己处理;
  • Security Handler;
  • HandlerExceptionResolver
  • @ControllerAdvice
  • Container Error Page;

而不是看到异常就统一加一个:

1
@ExceptionHandler(Exception.class)

十五、面向生产环境的实践建议

15.1 URL 与参数

  • Path Variable 只承载明确的路径段标识,不要让一个变量偷偷包含任意 /
  • Controller 公共接口参数名优先显式写出,不依赖反射推断。
  • 依赖方法参数名的项目统一开启 Java -parameters
  • @RequestParam 明确 required/default/Optional 语义。
  • 多值参数直接使用 List<T> / 数组,不依赖逗号拼接副作用。
  • 日期 Query 参数优先 java.time + 明确 ISO / pattern。

15.2 Header 与媒体类型

  • 需要完整 Header 时使用 HttpHeaders
  • 不要把协议上“大小写不敏感”错误推导成“任何 Map API 都不敏感”。
  • Content-Type 通过返回对象、produces 和 Converter 契约决定,不要靠手工 addHeader 硬拗。
  • 对外接口明确约定 Content-TypeAccept

15.3 Body 与 JSON

  • HttpMessageConverter 当成 Web API 契约的一部分。
  • JSON 库变化必须进入回归测试范围。
  • 不要在 Filter 里提前消费 Request Body。
  • 记录 Body 时做长度限制、Content-Type 白名单和敏感字段脱敏。
  • 对外 JSON 的 null、日期、枚举、未知字段策略都应显式配置。

15.4 Validation

  • @RequestBody DTO 显式 @Valid / @Validated
  • 每一层嵌套 DTO 都检查是否需要 @Valid
  • @Size@Pattern 等不要误解为 @NotNull
  • 字符串通常使用 @NotBlank;集合通常根据业务组合 @NotEmpty + @Size
  • Spring 6.1+ 统一考虑对象校验和方法校验两套异常类型。

15.5 Filter

  • 普通 Spring Boot Filter 优先 OncePerRequestFilter
  • chain.doFilter() 的调用路径必须能一眼证明“最多一次”。
  • 拦截请求时显式写响应并 return
  • 不混用 @WebFilter@Component
  • 强排序需求用 FilterRegistrationBean#setOrder() 并做集成测试。

15.6 Security

  • 密码必须使用合适的 PasswordEncoder,禁止生产 NoOp/MD5。
  • 团队统一 Role 与 Authority 命名规范。
  • 新项目使用 SecurityFilterChain Bean 风格配置。
  • 认证失败和鉴权失败用 Spring Security 自己的异常处理扩展点。
  • 不要把 Security Filter 异常指望交给 MVC @ControllerAdvice 自动处理。

15.7 Exception

  • 先定义统一错误契约,再决定各层如何落到这个契约。
  • MVC 异常用 @RestControllerAdvice
  • Filter/Security 异常在对应层处理,必要时显式委托 HandlerExceptionResolver
  • 404 同时关注 Controller Handler 与 Static Resource Handler。
  • 新项目可评估 ProblemDetail / RFC 9457。

十六、从旧版 Spring 迁移到现代 Spring 时最容易踩的版本差异

主题 旧资料常见写法 现代实践
Servlet API javax.servlet.* Spring 6+/Boot 3+ 使用 jakarta.servlet.*
Bean Validation javax.validation.* jakarta.validation.*
路径匹配 AntPathMatcher 为主 Spring MVC 6+ 默认 PathPatternParser
尾部 / 常见自动兼容 不应再依赖透明 trailing slash 匹配
参数名发现 debug LocalVariableTable 可兜底 Spring 6.1+ 应使用 -parameters
Security 配置 WebSecurityConfigurerAdapter SecurityFilterChain Bean
URL 授权 DSL antMatchers(...) requestMatchers(...)
404 静态资源配置 spring.resources.add-mappings spring.web.resources.add-mappings
静态资源不存在 关注 NoHandlerFoundException 还要关注 NoResourceFoundException
MVC 校验 重点关注 MethodArgumentNotValidException Spring 6.1+ 还应关注 HandlerMethodValidationException
REST 错误模型 自定义 JSON 为主 可使用 ProblemDetail / RFC 9457
Filter 基类 直接 Filter Spring Filter 场景常优先 OncePerRequestFilter

注意,这些是版本演进方向,不意味着所有老项目都要为了“新”而立刻改写。真正需要迁移的是:

1
2
3
4
已经进入不受支持版本
安全修复无法获得
升级 JDK / Servlet / Jakarta 生态时被依赖阻塞
新老 API 混用导致维护成本持续增加

迁移时最重要的是用集成测试固定:

  • URL 匹配;
  • Security Matcher;
  • JSON 协议;
  • Validation 返回;
  • 404;
  • Filter 顺序;

这些外部行为。


十七、总结:真正需要记住的是“谁在处理当前请求”

Spring Web 的复杂感,很多时候来自“同一个 HTTP 请求被很多层依次加工”。但层次一旦分清,问题就会变得非常有规律。

可以把整篇文章压缩成七句话:

  1. URL 先决定 Handler 能不能匹配,参数值是否能绑定是下一阶段的问题。
  2. Controller 参数由不同 HandlerMethodArgumentResolver 负责,解析完还可能继续经过 ConversionService、Formatter 和 Validation。
  3. Body 的核心是 HttpMessageConverter,JSON 库和 Converter 顺序都是 API 行为的一部分。
  4. Filter 是 Servlet 责任链,chain.doFilter() 既是继续链路的开关,也是最容易制造重复执行事故的地方。
  5. Spring Security 建立在 Filter Chain 上,认证、授权、登录页、PasswordEncoder 和角色前缀都应该放在这条链上理解。
  6. @ControllerAdvice 属于 DispatcherServlet 的异常处理体系,不能越过生命周期边界自动捕获所有异常。
  7. 版本升级时不要机械背源码类名,要保留“阶段、职责、扩展点、数据形态”这四个稳定概念。

以后再遇到 Spring Web 的奇怪现象,可以先画出这条最小链路:

1
2
3
4
5
6
7
8
9
10
11
12
Container
-> Filter
-> Security
-> DispatcherServlet
-> HandlerMapping
-> ArgumentResolver
-> Converter / Binder
-> Validation
-> Controller
-> ReturnValueHandler
-> HttpMessageConverter
-> Response

然后问一句:

“当前异常发生在哪一层?这一层真正负责处理它的组件是谁?”

大多数所谓的 Spring Web “玄学”,到这里就已经不玄了。


参考资料与版本延伸


Spring MVC 请求处理全链路:从 URL、Header、Body 到 Validation、Filter、Security 与异常处理
https://allendericdalexander.github.io/2026/08/12/geeker/144/4spring-mvc-request-pipeline/
作者
AtLuoFu
发布于
2026年8月12日
许可协议