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 时代的经典实现,因此会看到
AntPathMatcher、WebSecurityConfigurerAdapter、javax.servlet、javax.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]
这张图里最重要的不是每个类名,而是边界:
- Filter 在
DispatcherServlet之前。 - Spring MVC 的参数解析、Body 转换、Validation 和
@ControllerAdvice都发生在DispatcherServlet体系内部。 - Spring Security 本质上也是建立在 Filter 机制之上。
- URL 匹配和 Controller 参数解析是两个阶段。
- 请求 Body 和响应 Body 都依赖
HttpMessageConverter,但方向相反。 Content-Type和Accept共同参与内容协商,不能只看 Controller 里手工写了什么 Header。
后面所有问题,都可以映射回这条链路。
二、URL 与请求参数:先匹配 Handler,再解析参数
2.1 @PathVariable 并不是“任意字符串占位符”
一个典型 Controller:
1 | |
请求:
1 | |
可以正常得到:
1 | |
但如果请求是:
1 | |
不要把它理解为:
1 | |
因为在 URI Path 语义中,/ 是路径段分隔符。Spring 在调用 Controller 之前,必须先完成 Handler 匹配:
1 | |
这两个路径结构并不相同,因此通常在 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 | |
只会把一个路径段作为 {name},不会把任意多个 / 吞进去。
2.2 如果变量本身确实需要包含 / 怎么办
有三种更合理的设计思路。
方案一:把“路径”当查询参数
如果值本身就是一段路径、URL、对象 Key 等,通常不应该强行塞进单个 Path Variable:
1 | |
请求:
1 | |
这种 API 语义通常更清晰。
方案二:使用通配路径并显式提取
旧版基于 AntPathMatcher 的写法可以使用:
1 | |
这比直接:
1 | |
稳健得多,因为字符串 split 会把“业务数据”和“路由结构”混在一起处理,一旦数据内部再次出现 /hi/,就会出现意外结果。
方案三:重新设计资源标识
如果一个资源标识天然包含大量 /、?、# 等 URI 保留字符,通常值得考虑:
- URL Encode;
- Base64URL 编码;
- UUID / ID 替代原始路径;
- Query Parameter;
- Request Body。
一个好的 REST API 不应该要求路由器“猜”某个 / 到底是结构还是数据。
2.3 尾部 /:不要依赖“系统会帮我兼容”
旧版 Spring MVC 中常见:
1 | |
都可能匹配同一个 Handler。这来自可选 trailing slash 的历史行为。
旧实现可以理解为:
1 | |
版本提示:Spring 6 以后
现代 Spring MVC 默认使用 PathPatternParser,透明的 trailing-slash 自动匹配已经不再推荐依赖。更稳妥的做法是:
- 明确规范 URL;
- 在网关 / 代理层做规范化重定向;
- 或显式定义需要兼容的路径。
因此,不要把“多一个 / 也能访问”当成稳定 API 契约。
2.4 @RequestParam 不写参数名,为什么有时会失效
下面两种代码看起来都合理:
1 | |
1 | |
第二种写法依赖一个隐含条件:运行时必须能够获取 Java 方法参数名 name。
Spring 在解析 @RequestParam 时,如果注解中没有显式指定名称,就需要执行类似逻辑:
1 | |
所以真正的问题变成了:
JVM 运行时还能不能知道源码里的参数叫
name?
Java class 文件里的参数名从哪里来
历史上常见两个来源:
LocalVariableTable:通常与 debug 信息相关;MethodParameters:由 Java-parameters编译选项产生。
可以用:
1 | |
检查 class 文件是否保留了参数信息。
Maven 编译配置
现代项目如果依赖参数名反射,应显式开启:
1 | |
版本提示:Spring 6.1 后这件事更重要
Spring Framework 6.1 移除了 LocalVariableTableParameterNameDiscoverer,不再尝试通过解析字节码里的局部变量表兜底推断参数名。依赖方法参数名的场景,应使用标准 -parameters 编译标志。
因此,在公共 API Controller 中,一个非常朴素但稳定的工程实践仍然成立:
1 | |
显式契约通常比隐式推断更适合生产代码。
2.5 请求参数默认是 required 的
代码:
1 | |
请求:
1 | |
很多开发者会直觉认为:
1 | |
但 @RequestParam 默认:
1 | |
因此缺少 address 时,Spring 会把它当成客户端请求不完整,通常返回 400。
其核心判断可以概括为:
1 | |
四种表达“可选”的方式
1. 默认值
1 | |
2. required = false
1 | |
3. Optional<T>
1 | |
4. Nullable 语义
不同版本、不同 nullability 注解支持细节会变化。工程上如果接口契约强调“可以不传”,推荐优先使用:
1 | |
或:
1 | |
让 API 意图直接可读。
2.6 同名 Query 参数为什么会变成逗号拼接
请求:
1 | |
Controller:
1 | |
一个容易忽略的事实是:HTTP Query 参数天然可以是多值的。
Servlet 层获取时,本质更接近:
1 | |
得到:
1 | |
当 Controller 参数类型写成单个 String 时,Spring 还需要做一次:
1 | |
经典实现会通过 ConversionService 把多个值用 , 拼接:
1 | |
如果业务语义本来就是多值,应该直接声明:
1 | |
或者:
1 | |
这比依赖隐式的数组转字符串规则更清楚。
2.7 URL 参数最终为什么能自动变成 int、Date 等类型
HTTP Query 参数和 Path Variable 从协议层进入 Spring 时,本质通常仍然是字符串:
1 | |
但 Controller 可以声明:
1 | |
这是因为参数解析结束后还存在类型转换阶段:
flowchart LR
A[HTTP 原始值<br/>String / String[]] --> B[ArgumentResolver]
B --> C[WebDataBinder]
C --> D[ConversionService]
D --> E[Converter / ConverterFactory / Formatter]
E --> F[Controller 参数类型]
例如:
1 | |
可以找到数字转换器。
但日期转换没有“看到像日期的字符串就一定能猜对格式”这么简单。
不要依赖 java.util.Date(String) 的历史行为
下面这种接口:
1 | |
面对:
1 | |
并不保证能成功,因为普通对象转换器可能最终寻找构造器或工厂方法,而 Date 的历史字符串构造逻辑并不支持任意自定义格式。
更明确的写法是:
1 | |
现代项目更推荐 java.time
例如:
1 | |
如果接口需要表达时区,应考虑:
InstantOffsetDateTimeZonedDateTime
不要用一个没有时区语义的 Date / LocalDateTime 去承载跨时区协议含义。
@DateTimeFormat 与 JSON Body 不是一回事
@DateTimeFormat 主要参与 Spring 的字段格式化/绑定,例如 Query Parameter、Path Variable、Form 数据。
JSON Body 的序列化和反序列化通常交给 Jackson,因此日期格式应通过:
1 | |
或者统一配置 ObjectMapper。
这正体现了本文最重要的主线:URL 参数转换和 Body JSON 反序列化属于两条不同的处理链。
三、Header:协议上大小写不敏感,不代表所有 Map API 都不敏感
3.1 一个 Header 可以有多个值
HTTP Header 可能以两种形式表达多值:
1 | |
或:
1 | |
如果 Controller 写成:
1 | |
普通 Map<String, String> 天然只能表达:
1 | |
无法完整表达:
1 | |
旧版 RequestHeaderMapMethodArgumentResolver 的核心区别也很直接:
1 | |
因此,如果需要完整 Header 集合,应使用:
1 | |
更推荐:
1 | |
因为 HttpHeaders 不只是一个多值 Map,还提供了大量 HTTP 语义化方法,例如:
1 | |
3.2 Header 名称大小写不敏感,但普通 Map#get() 仍然大小写敏感
协议层:
1 | |
应视为同一个 Header 名称。
所以:
1 | |
通常可以接收到请求里的:
1 | |
因为底层 Servlet 容器查找 Header 时会做大小写不敏感匹配。
但下面这个代码就不一样:
1 | |
如果 Spring 最终把容器枚举出来的原始名称:
1 | |
作为普通 LinkedHashMap 的 key,后续:
1 | |
当然可能得到 null,因为 LinkedHashMap 是大小写敏感的。
为什么 HttpHeaders 更安全
经典实现的 HttpHeaders 使用 case-insensitive 的键结构,因此更符合 HTTP Header 的协议语义。
所以工程上不要为了“抽象成 Map”而丢失协议语义:
1 | |
通常比:
1 | |
更合适。
四、Content-Type 不是随手 addHeader 就能决定的
下面的代码很容易让人产生错觉:
1 | |
开发者可能认为:
1 | |
因此响应一定是:
1 | |
实际上,最终响应媒体类型还会受到:
- Servlet 容器实现;
Accept请求头;@RequestMapping(produces=...);- Controller 返回值类型;
- 已注册
HttpMessageConverter; - Converter 支持的 MediaType;
- Spring 内容协商顺序;
等因素影响。
4.1 Tomcat 中 Content-Type 是特殊 Header
经典 Tomcat 实现里,Content-Type 不一定像普通 Header 一样直接塞进 Header 集合,而可能被识别为响应对象的专门属性:
1 | |
随后 Spring MVC 在处理 Controller 返回值时,会进入:
1 | |
并重新决定最终 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 | |
常见候选是 StringHttpMessageConverter,因此最终很可能得到:
1 | |
4.3 更正确的控制方式
使用 produces
1 | |
但更推荐直接返回对象:
1 | |
这才真正让“Java 对象 -> JSON”这条链路成立。
让客户端通过 Accept 表达期望
1 | |
服务器会将客户端可接受类型和自身可生产类型进行匹配。
4.4 Tomcat 与 Jetty 行为可能不同
同一个:
1 | |
在 Tomcat 和 Jetty 中最终执行的是不同容器实现。
这意味着一些“测试出来的框架结论”其实是:
1 | |
共同作用的结果。
排查 Header、编码、连接、Servlet 行为时,不能忘记容器也是调用链的一部分。
五、Body:真正的核心是 HttpMessageConverter
URL 和 Header 主要解决字符串值的获取与绑定,而 Body 经常涉及:
1 | |
Spring MVC 并不自己实现所有 JSON/XML 编解码细节,而是通过 HttpMessageConverter 适配 Jackson、Gson 等库。
5.1 No converter found for return value of type 到底是什么意思
例如:
1 | |
1 | |
如果是一个纯 Spring MVC 项目,只依赖:
1 | |
但 classpath 中没有任何可用 JSON Converter,就可能出现:
1 | |
这句话真正的含义不是:
1 | |
而是:
1 | |
5.2 writeWithMessageConverters 的核心决策
可以概括为:
1 | |
伪代码:
1 | |
5.3 为什么 Spring Boot 通常“自动就有 JSON”
spring-boot-starter-web 会引入 Web 与 JSON 相关依赖,并通过自动配置根据 classpath 判断是否创建对应 Converter。
这种设计模式值得记住:
1 | |
Spring 生态里大量自动配置都遵循同样思路:
1 | |
所以遇到“之前能序列化,现在突然不能”时,除了看 Controller,更要检查:
1 | |
或 Gradle 依赖树。
5.4 新增一个 JSON 库,接口返回结果竟然变了
假设一开始应用实际使用 Gson,返回:
1 | |
其中:
1 | |
没有被序列化。
后来项目间接引入 Jackson,Controller 一行没改,却得到:
1 | |
这类问题的本质是:
依赖变化改变了默认
HttpMessageConverter,而不同 JSON 库的默认策略不完全相同。
Spring MVC 在发现多个实现时存在优先选择逻辑,因此“增加一个依赖”有时等价于“改变 Web API 行为”。
不要把序列化契约交给默认值
如果 API 明确要求不输出 null,应该显式定义,例如 Jackson:
1 | |
或者全局配置 ObjectMapper。
对外 API 的 JSON 形态是契约,应该被测试固定,而不是依赖“当前刚好加载的是哪个 JSON 库”。
建议至少建立:
- Controller/MockMvc 响应快照测试;
- JSON Schema / OpenAPI Contract;
- 关键字段 null/缺省语义测试。
5.5 Request Body 是流:读一次就少一次
这是 Spring Web 中极其重要的一条基础规律。
错误示例:
1 | |
Filter 已经把:
1 | |
读到底了。
Controller 再进入:
1 | |
RequestResponseBodyMethodProcessor 检测请求流时可能发现已经没有数据,于是得到:
1 | |
为什么 Spring 自己探测 Body 不会把第一个字节吃掉
内部通常会使用具备回退能力的流包装,例如读取一个字节判断是否为空,再通过 unread 放回去。
关键思想不是某个具体类,而是:
读取流之前必须明确所有权:你是消费它,还是仅观察它?
5.6 记录 Body 的更合理方式
方案一:RequestBodyAdvice
如果你的目标是记录已经反序列化成功的 @RequestBody 对象,可以使用:
1 | |
这避免了 Filter 提前消费原始 InputStream。
方案二:缓存型 Request Wrapper
如果确实要记录原始 Body,可以使用缓存请求包装器,但要注意使用时机:
1 | |
不要只是换成一个 Wrapper,然后仍然在 chain.doFilter() 之前把输入流读到底。
安全提醒
生产日志不要无脑记录所有 Body。必须考虑:
- Password;
- Token;
- Cookie;
- 身份证/手机号;
- 银行卡;
- 医疗信息;
- 超大文件上传;
- 二进制内容。
日志 Filter 往往比业务代码更容易造成数据泄露。
六、Validation:有约束注解,不等于校验一定会执行
Bean Validation 的最大误区是:
1 | |
并不自动等于:
1 | |
约束定义与触发校验是两件事。
6.1 @RequestBody 对象为什么没有被校验
DTO:
1 | |
Controller:
1 | |
仅仅字段存在 @Size,不一定会触发 Spring MVC 的 Bean Validation。
经典 RequestResponseBodyMethodProcessor 在解析 Body 后,会进入类似:
1 | |
它会检查 Controller 方法参数本身是否有:
1 | |
或:
1 | |
因此推荐:
1 | |
现代 Spring Boot 3+ 使用:
1 | |
而不是旧资料中的:
1 | |
6.2 @Valid 与 @Validated 怎么区分
可以从两个维度理解。
@Valid
来自 Jakarta Bean Validation:
1 | |
核心价值包括:
- 触发 Bean Validation;
- 标记级联校验。
@Validated
来自 Spring:
1 | |
在常规对象参数校验之外,常用于:
- 指定 Validation Groups;
- Spring 的方法级校验语义。
对于普通 @RequestBody DTO:
1 | |
通常已经足够清晰。
需要分组时再使用:
1 | |
6.3 嵌套对象为什么不会自动递归校验
1 | |
1 | |
即使外层:
1 | |
也不意味着:
1 | |
必然被递归校验。
级联校验需要在嵌套字段上显式:
1 | |
完整示例:
1 | |
这背后的元数据语义是:
1 | |
产品代码中 DTO 很容易嵌套 3~5 层,因此每一个对象边界都要问:
这里需要级联校验吗?
6.4 @Size(min = 1) 为什么挡不住 null
例如:
1 | |
很多人会以为:
1 | |
等于:
1 | |
实际上 Bean Validation 的许多约束遵循一个很重要的组合原则:
某个约束只负责自己的维度,null 是否允许由专门的 nullability 约束负责。
经典 SizeValidatorForCharSequence 逻辑类似:
1 | |
因此真正表达“必须存在,且长度 1~10”应写成:
1 | |
字符串业务里更常见:
1 | |
一个容易被口语化表述误导的点
@Size 对 CharSequence 约束的是字符序列长度,不是数据库字节长度,也不是 UTF-8 编码后的字节数。
如果数据库字段受“字节长度”限制,例如某些字符集下:
1 | |
就需要额外业务约束,而不能把 @Size(max = 10) 当作“最多 10 字节”。
6.5 Path Variable 上的约束属于另一条校验路径
例如:
1 | |
id 的解析器是 Path Variable 对应的 HandlerMethodArgumentResolver,它本身的主要职责是:
1 | |
方法参数约束的校验则属于方法校验语义,并不是每一个 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 | |
可以抽象成:
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 | |
伪代码:
1 | |
理解这几行代码后,Filter 中最常见的两个事故都很好解释。
7.2 chain.doFilter() 调两次:业务可能执行两次
错误代码:
1 | |
发生异常时:
1 | |
于是出现极其危险的现象:
- 查询执行两次,看起来只是日志重复;
- 下单执行两次,可能生成重复订单;
- 转账执行两次,直接变生产事故。
因此 Filter 必须保证控制流清晰:
1 | |
如果某个异常分支已经继续链路,必须:
1 | |
避免再次执行。
7.3 一次都不调用:后面的业务全部被截断
1 | |
很多人会误以为:
1 | |
实际上不会。
因为当前 Filter 返回后,ApplicationFilterChain#internalDoFilter() 就结束了,永远到不了:
1 | |
这就是 Filter 为什么既能做“前置处理”,又能做“拦截”:
1 | |
调用链是否继续,是 Filter 自己决定的。
八、Filter 的三种注册方式不要混着用
8.1 @WebFilter 是 Servlet 规范,不是 Spring @Component
1 | |
这里的 @WebFilter 来自 Servlet 规范。
在 Spring Boot 中通常需要配合:
1 | |
Spring Boot 扫描到 Servlet 组件后,会用自己的注册结构把它注册到 Web Server。
经典实现中会构建 FilterRegistrationBean,而原始 Filter 定义可能作为内部 Bean 存在。
所以不要理所当然认为:
1 | |
这两个概念不同。
8.2 @Component:让 Filter 成为普通 Spring Bean
如果 Filter 没有特殊 Servlet 注册需求,通常更简单:
1 | |
Spring Boot 能识别 Filter 类型 Bean,并完成 Web 注册。
8.3 FilterRegistrationBean:需要精确控制时使用
例如:
1 | |
它适合需要明确控制:
- URL Pattern;
- Order;
- DispatcherType;
- Async Supported;
- Filter Name;
- Servlet Name;
的场景。
8.4 为什么 @WebFilter + @Order 在一些版本中不按预期工作
经典 Spring Boot 实现中:
1 | |
而这个转换过程未必会把 Spring 的:
1 | |
同步写入 FilterRegistrationBean.order。
过滤器最终顺序却取决于 RegistrationBean 的 order,因此就可能出现:
1 | |
这也是为什么,排序是强需求时,显式 FilterRegistrationBean#setOrder() 往往更可控。
不同 Spring Boot 版本对此细节曾有变化,所以不要只凭一个老版本结论判断当前项目;最终应以注册后的 Filter 顺序为准进行集成测试。
8.5 @WebFilter + @Component 可能把同一个 Filter 注册两遍
这是一种更隐蔽的错误:
1 | |
它可能同时走两条路径:
1 | |
以及:
1 | |
结果:
1 | |
所以不要通过“两个注解都加上,总有一个能生效”的方式解决注册问题。
工程建议
从下面三种方案中选一种:
1 | |
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 | |
这解释了两个重要事实:
- Spring Bean 生命周期与 Servlet 容器组件注册生命周期是相互衔接但不完全相同的;
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 | |
因此需要 PasswordEncoder:
1 | |
它解决两个问题:
1 | |
10.2 DelegatingPasswordEncoder 与 {id} 格式
Spring Security 经典存储格式:
1 | |
例如:
1 | |
DelegatingPasswordEncoder 会先提取:
1 | |
再选择对应 Encoder。
伪代码:
1 | |
如果存储的是:
1 | |
没有 {id},就可能出现经典错误:
1 | |
不要把 {noop} 当生产方案
测试里可以看到:
1 | |
用于表达明文匹配,但生产密码存储不应该使用 NoOp,也不应使用 MD5/SHA-1 这类快速摘要算法作为密码哈希方案。
现代 Spring Security 的默认推荐仍是使用自适应单向密码哈希,并通过 DelegatingPasswordEncoder 保留未来算法升级能力。
10.3 ROLE_ADMIN 与 ADMIN 为什么不是一回事
Spring Security 中:
1 | |
不是简单地保存:
1 | |
经典 UserBuilder#roles 会自动加前缀:
1 | |
而如果你直接写:
1 | |
得到的就是:
1 | |
此时如果授权规则是:
1 | |
内部通常会按默认 role prefix 转成:
1 | |
再去 authorities 中查。
如果用户实际只有:
1 | |
匹配自然失败。
建议统一团队语义
二选一:
1 | |
不要在项目里混合:
1 | |
然后靠每个开发者记忆哪个 API 会自动补前缀。
10.4 旧版 Security 配置与现代写法
旧资料中常见:
1 | |
现代 Spring Security 已经采用组件式配置,不再推荐/使用 WebSecurityConfigurerAdapter。
典型写法:
1 | |
这也是阅读老 Spring Security 资料时最需要做的“机制与 API 分离”:
1 | |
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 | |
Advice:
1 | |
很多人期待:
1 | |
但调用链实际上是:
1 | |
@ControllerAdvice 背后的 ExceptionHandlerExceptionResolver 属于 DispatcherServlet 的异常解析体系。
如果异常在:
1 | |
就不会自然进入这套处理逻辑。
11.2 DispatcherServlet 内部异常如何被统一处理
经典 doDispatch() 可以抽象为:
1 | |
然后:
1 | |
ExceptionHandlerExceptionResolver 初始化时会扫描 @ControllerAdvice,缓存其中的异常映射。
所以:
1 | |
11.3 Filter 中的异常应该怎么处理
方案一:Filter 自己转换成 HTTP 响应
认证/鉴权场景很常见:
1 | |
对于 Spring Security,更应该使用其专门的:
AuthenticationEntryPointAccessDeniedHandler
而不是绕到 MVC Advice。
方案二:显式委托 HandlerExceptionResolver
如果确实希望 Filter 异常和 MVC 使用同一套异常格式,可以注入:
1 | |
然后:
1 | |
这是一种显式“跨边界”方案:
1 | |
而不是 MVC 自己天然能捕获。
十二、404 是一种非常特殊的“异常”
很多 REST API 希望统一:
1 | |
于是写:
1 | |
但旧版 Spring Boot 项目中经常发现完全不生效。
12.1 NoHandlerFoundException 的前提是“真的没有 Handler”
DispatcherServlet 大致逻辑:
1 | |
只有:
1 | |
才有机会走 no-handler 逻辑。
12.2 静态资源 Handler 会吃掉 /**
Spring Boot 默认静态资源处理历史上会注册:
1 | |
因此一个不存在的 API:
1 | |
仍然可能先匹配到 Resource Handler。
于是:
1 | |
自然不会触发 NoHandlerFoundException。
旧版方案常见:
1 | |
现代 Spring Boot 配置名
当前属性已经是:
1 | |
而现代 Spring MVC 的静态资源 Handler 在找不到资源时还可能抛出:
1 | |
因此现在统一 404 时,应同时理解:
1 | |
和:
1 | |
是两种不同路径。
不要照抄旧版本“打开一个配置就一定进入 NoHandlerFoundException”的结论。
12.3 现代 REST 异常响应可以考虑 ProblemDetail
现代 Spring Framework 提供基于 RFC 9457 的:
1 | |
例如:
1 | |
这样可以避免每个项目重新发明完全不同的错误 JSON 协议。
当然,如果公司已经有统一的:
1 | |
也可以继续使用自己的契约,关键是全链路一致,而不是“所有错误都强行挤进 @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 | |
就应该想到:
1 | |
看到:
1 | |
想到:
1 | |
看到:
1 | |
想到:
1 | |
看到:
1 | |
想到:
1 | |
Spring MVC 的参数解析本质上是策略集合:
1 | |
所以排查第一步经常不是:
1 | |
而是:
1 | |
14.2 从返回值反推 ReturnValueHandler 和 Converter
如果问题发生在响应:
1 | |
优先关注:
1 | |
然后看三个输入:
1 | |
14.3 从运行环境反推“谁真正实现了接口”
例如代码只看到:
1 | |
但运行时可能是:
1 | |
接口相同,不代表底层边界行为完全相同。
14.4 从“本地能跑,生产失败”反推构建产物
典型工具:
1 | |
检查:
- MethodParameters;
- RuntimeVisibleAnnotations;
- 泛型签名;
- 编译参数;
同时比较:
1 | |
很多“框架玄学”最后是:
1 | |
14.5 先找生命周期边界,再找异常处理器
看到异常时先问:
1 | |
然后再决定:
- Filter 自己处理;
- Security Handler;
HandlerExceptionResolver;@ControllerAdvice;- Container Error Page;
而不是看到异常就统一加一个:
1 | |
十五、面向生产环境的实践建议
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-Type与Accept。
15.3 Body 与 JSON
- 把
HttpMessageConverter当成 Web API 契约的一部分。 - JSON 库变化必须进入回归测试范围。
- 不要在 Filter 里提前消费 Request Body。
- 记录 Body 时做长度限制、Content-Type 白名单和敏感字段脱敏。
- 对外 JSON 的 null、日期、枚举、未知字段策略都应显式配置。
15.4 Validation
@RequestBodyDTO 显式@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 命名规范。
- 新项目使用
SecurityFilterChainBean 风格配置。 - 认证失败和鉴权失败用 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 | |
迁移时最重要的是用集成测试固定:
- URL 匹配;
- Security Matcher;
- JSON 协议;
- Validation 返回;
- 404;
- Filter 顺序;
这些外部行为。
十七、总结:真正需要记住的是“谁在处理当前请求”
Spring Web 的复杂感,很多时候来自“同一个 HTTP 请求被很多层依次加工”。但层次一旦分清,问题就会变得非常有规律。
可以把整篇文章压缩成七句话:
- URL 先决定 Handler 能不能匹配,参数值是否能绑定是下一阶段的问题。
- Controller 参数由不同
HandlerMethodArgumentResolver负责,解析完还可能继续经过 ConversionService、Formatter 和 Validation。 - Body 的核心是
HttpMessageConverter,JSON 库和 Converter 顺序都是 API 行为的一部分。 - Filter 是 Servlet 责任链,
chain.doFilter()既是继续链路的开关,也是最容易制造重复执行事故的地方。 - Spring Security 建立在 Filter Chain 上,认证、授权、登录页、PasswordEncoder 和角色前缀都应该放在这条链上理解。
@ControllerAdvice属于 DispatcherServlet 的异常处理体系,不能越过生命周期边界自动捕获所有异常。- 版本升级时不要机械背源码类名,要保留“阶段、职责、扩展点、数据形态”这四个稳定概念。
以后再遇到 Spring Web 的奇怪现象,可以先画出这条最小链路:
1 | |
然后问一句:
“当前异常发生在哪一层?这一层真正负责处理它的组件是谁?”
大多数所谓的 Spring Web “玄学”,到这里就已经不玄了。
参考资料与版本延伸
- Spring Framework - Spring MVC Reference
- Spring Framework - Filters
- Spring Framework - Validation
- Spring Framework - Error Responses
- Spring Boot - Servlet Web Applications
- Spring Security - Architecture
- Spring Security - Password Storage
- Spring Security - Authorize HttpServletRequests
- Spring Framework 6.1 - Parameter Name Retention