SpringBoot 请求链路(二):从 DispatcherServlet 到方法调用
引子:所有分岔都发生在一个方法里
DispatcherServlet 是个
HttpServlet,service() → doGet()
那一串都是规范规定的样板代码。真正开始干活只有一行:doDispatch(request, response)。
你后来遇到的大部分现象——404、405、400、406,以及”拦截器没执行”“参数解析器选了别人”——都是这一个方法内部某一步的结果。 它值得逐行读,而不是背五个步骤的名字。
本篇接第一篇(
FilterChain已经放行),覆盖系列导读里的第六~九节:doDispatch()的完整流程、HandlerMapping如何把 URL 变成一个HandlerMethod、27 个参数解析器怎么排队、拦截器的顺序与中断语义。
本篇要回答的问题:
doDispatch()按什么顺序做事?哪一步失败是 404,哪一步是 400?- 一个 URL 是怎么匹配到方法的?为什么线上生效的路由顺序和配置文件里写的顺序不一样?
- 27 个内置参数解析器的先后顺序是怎么定的?自己注册的为什么会被”抢走”?
@RequestParam和@RequestBody取的到底是不是同一个”参数”?preHandle返回false之后,为什么afterCompletion还会被调用?- 为什么 404 和 304 的请求,一个拦截器回调都不会触发?
六、DispatcherServlet:把 doDispatch 完整读一遍
请求穿过滤镜链后,落到 DispatcherServlet。它是 Spring
MVC 的前端控制器,继承自 HttpServlet,核心方法是
doDispatch()。
这个方法是整个系列的枢纽——前面所有铺垫是为了走到这里,后面所有细节都是从这里分出去的。所以它值得逐行读。
6.1 6.1.8 的真实源码
1 | // DispatcherServlet.java:1049-1129(Spring Framework 6.1.8,逐字) |
6.2 几个容易被看漏的地方
① 三个方法级变量,是为了 finally
里还能用。
processedRequest、mappedHandler、multipartRequestParsed
声明在最外层 try 之外;而 mv 和
dispatchException 在内层。这不是风格问题——外层
catch 拿不到 mv,它只负责
triggerAfterCompletion,不负责渲染。
② 304 的短路发生在拦截器之前。
1 | if (isGet || HttpMethod.HEAD.matches(method)) { |
浏览器带 If-Modified-Since 且资源未变时,直接返回 304 并
return。此时 preHandle 和
afterCompletion 都不会执行。
如果你的拦截器里做了”统计所有请求耗时”,304
的请求是统计不到的——这类差异在压测数据对不齐时很致命。
③ 404 分支不会触发任何
afterCompletion。
mappedHandler == null 时调用
noHandlerFound() 然后 return。chain
根本不存在,自然没有拦截器可回调。 所以”为什么 404
请求没有触发我的 afterCompletion
日志”这个问题,答案是:它连拦截器链都没建立。
noHandlerFound() 的默认行为是
response.sendError(404),只有把
throwExceptionIfNoHandlerFound 打开,才会抛
NoHandlerFoundException(从而能被
@ControllerAdvice 捕获)。
④ 异常被”收敛”而不是抛出。
1 | catch (Exception ex) { |
Controller 抛的异常不会直接冲出
doDispatch,而是被存进
dispatchException,交给统一的
processDispatchResult() 处理。这是
@ExceptionHandler
能生效的结构前提:异常必须在同一个方法里被接住,才有机会转交给
HandlerExceptionResolver。
⑤ Throwable
也被接住,但包装方式变了。
1 | catch (Throwable err) { |
这是 5.3 → 6.x 的一个真实差异:Spring 5.3 用的是
NestedServletException("Handler dispatch failed", err),6.x
改成 ServletException,消息里还多了
": " + err。NestedServletException
类本身仍然存在,只是标了
@Deprecated(since = "6.0")。
这意味着:如果你的监控或告警规则里靠匹配
NestedServletException 来识别异常,升级到 6.x
之后这条规则会静默失效。
⑥ finally
里的异步分支——最容易被整段忽略的一段。
1 | finally { |
异步请求启动后,doDispatch 会在 ha.handle()
之后提前 return,postHandle 和
afterCompletion 都不会执行,取而代之的是
applyAfterConcurrentHandlingStarted():
1 | // HandlerExecutionChain.java |
它逆序调用每个实现了
AsyncHandlerInterceptor 的拦截器。官方 javadoc
说得很直白:Called instead of postHandle and afterCompletion when the handler is being executed concurrently,典型用途是清理
ThreadLocal。
这就是一个经典线上事故的根因:日志 Filter / 拦截器在
preHandle里MDC.put(requestId),本来指望afterCompletion里清掉。一旦某个接口是异步的,afterCompletion不执行,requestId就留在了那个 Worker 线程上——下一个复用到这个线程的请求,日志里会带上别人的 trace id。修法不是”记得清 MDC”,而是实现
AsyncHandlerInterceptor,在afterConcurrentHandlingStarted里也清一次。
6.3 出口:processDispatchResult
1 | // DispatcherServlet.java:1178-1186 |
所有正常结束的请求,最终都在这里以 ex = null 调用
triggerAfterCompletion。注意那个
null——正常返回和”被拦截器中断”在
afterCompletion
里看起来是一样的,这一点在第九节还会用到。
七、路由:HandlerMapping 找到了什么
7.1 getHandler 只是顺序遍历
1 | // DispatcherServlet.java:1280-1290 |
命中第一个非 null 就返回。所以 handlerMappings
的顺序直接决定了路由结果——某个宽泛的映射排在前面,后面的映射就永远没机会执行。
7.2 真实顺序不是配置文件里的顺序
DispatcherServlet.properties 里写着:
1 | org.springframework.web.servlet.HandlerMapping=\ |
但这只是”容器里一个 HandlerMapping Bean
都没有”时的兜底。真实应用(有 Spring Boot 或
@EnableWebMvc)走的是排序:
1 | // DispatcherServlet.java:626-628 |
各 HandlerMapping 的真实 order 值:
| HandlerMapping | order | 出处 |
|---|---|---|
RouterFunctionMapping |
-1 | WebMvcConfigurationSupport.java:555(注释:go before RequestMappingHandlerMapping) |
RequestMappingHandlerMapping |
0 | WebMvcConfigurationSupport.java:309 |
viewControllerHandlerMapping |
1 | WebMvcConfigurationSupport.java:477 |
BeanNameUrlHandlerMapping |
2 | WebMvcConfigurationSupport.java:532 |
resourceHandlerMapping(SimpleUrlHandlerMapping) |
LOWEST_PRECEDENCE - 1 |
ResourceHandlerRegistry.java:77 |
defaultServletHandlerMapping |
Integer.MAX_VALUE |
WebMvcConfigurationSupport.java:614 |
这张表的实用价值在于最后两行:静态资源永远排在
@RequestMapping 之后。
于是可以解释一个非常常见的问题:“为什么我的静态资源 404
了?”——只要你写了一个能匹配到该路径的 @Controller(比如
@GetMapping("/**")
之类过度宽泛的映射),ResourceHttpRequestHandler
根本没有机会执行。DefaultServletHttpRequestHandler
更是排在最后,它基本只在”其他人都没匹配上”时才兜底。
顺带纠正一个流传的说法:SimpleUrlHandlerMapping
并不在 DispatcherServlet.properties 里。
静态资源用的那个实例,是 ResourceHandlerRegistry 自己
new 出来并注册成 Bean 的。
7.3 AbstractHandlerMapping 的模板方法
getHandler() 在 AbstractHandlerMapping 里是
final 的,子类只能覆写
getHandlerInternal():
1 | // AbstractHandlerMapping.java:506-549(节选) |
这段骨架固定做了四件事,顺序不能变:
- 委托子类找 handler;
- 找不到就用
defaultHandler(这正是defaultServletHandlerMapping的兜底机制); - 如果返回的是
String,当成 Bean 名字去容器里取——这是BeanNameUrlHandlerMapping的实现基础; - 把拦截器附加成
HandlerExecutionChain。
第 4 步值得多看一眼,因为拦截器不是”每次请求现算”的:
1 | // AbstractHandlerMapping.java:622-637(节选) |
adaptedInterceptors
在启动期就装配好了。MappedInterceptor
会带上路径匹配条件,其他拦截器则无差别附加到每一个 chain
上——所以不写 addPathPatterns
的拦截器会作用到所有请求,包括静态资源和错误页。
7.4 RequestMappingHandlerMapping:路由表的建立与查找
@Controller
的扫描和映射表建立发生在启动期:RequestMappingHandlerMapping
遍历所有 Bean,把类和方法上的 @RequestMapping 合并成
RequestMappingInfo,和 HandlerMethod 一起存进
mappingRegistry。
查找时有一条快路径:
1 | // AbstractHandlerMethodMapping.java:400-408(节选) |
先按完整路径查索引,没命中才全量扫描所有注册项。
这个设计意味着:路径里带变量(/user/{id})的映射无法走直接索引,只能落到全量扫描那一支。如果路由表特别大(几千个映射),带路径变量的接口会明显比固定路径的接口慢——这是”路由表膨胀”的隐性代价。
RequestMappingInfo 的匹配维度不止 URL:
| 维度 | 来源 |
|---|---|
| 路径 | value /
path |
| HTTP 方法 | method |
| 请求参数条件 | params |
| 请求头条件 | headers |
Content-Type |
consumes |
Accept |
produces |
params / headers
条件也是路由的一部分,因此”路径明明对得上却
404”的另一种可能是:params 条件没满足(比如要求
?type=json 而请求没带)。
7.5 路径匹配:PathPatternParser 已经成为默认
1 | // AbstractHandlerMapping.java:93 |
这是 6.x 的一个硬性变化。 在 Spring 5.3 里默认还是
AntPathMatcher,从 Spring 6.0 起
PathPatternParser 成为默认;只有显式把
patternParser 设成 null 才会退回
AntPathMatcher。
同时,另一处让很多人踩坑的默认值也变了:
1 | // RequestMappingHandlerMapping.java:98 |
1 | // RequestMappingHandlerMapping.java:161-162 |
尾斜杠匹配默认关闭。 /user 和
/user/ 在 6.x 下是两个不同的路径。这正是 Spring Boot 2.6
之后大量”升级完突然 404”的隐秘原因——这不是
bug,是被有意收紧的行为。
还有一条连带约束,在 afterPropertiesSet()
里以断言的形式写死:
1 | // RequestMappingHandlerMapping.java:229-232 |
后缀匹配(/users 也能匹配
/users.json)与 PathPatternParser
不能共存。 一旦你显式开启后缀匹配,patternParser
会被置空、退回
AntPathMatcher。这也是为什么第七节看起来只是”路由”,却和第十一节的内容协商紧紧绑在一起——后缀匹配、内容协商、安全(RFD)在
Spring 6 里被一起收紧了。
八、参数解析:注解决定”从哪里取值”
这一节回到系列导读里那个 400。
8.1 从 HandlerAdapter 到方法调用
拿到 handler 之后,DispatcherServlet
需要找能执行它的适配器:
1 | // DispatcherServlet.java:1316-1326 |
@RequestMapping 方法由
RequestMappingHandlerAdapter 处理。注意它判断的是
HandlerMethod 这个类型,而不是”类上有没有
@Controller“:
1 | // AbstractHandlerMethodAdapter.java:68-70 |
8.2 getMethodArgumentValues:逐个参数找解析器
真正的参数解析在这里:
1 | // InvocableHandlerMethod.java:204-222(spring-web,注意包的归属) |
三个细节:
initParameterNameDiscovery()并不立即反射取参数名,它只是把 discoverer 挂到参数上;真正的解析延迟到getParameterName()被调用时。findProvidedArgument()是解析器之前的捷径:@ModelAttribute之类已经准备好的对象直接命中,不必再走解析器。- 找不到解析器抛的是
IllegalStateException("No suitable resolver"),不是IllegalArgumentException。这个区别在写全局异常处理器时很实际——它属于”配置错误”,而不属于”客户端错误”,不应该被翻译成 400。
注意这个类的位置:InvocableHandlerMethod 在
spring-web 的
org.springframework.web.method.support 包下,不在
spring-webmvc。参数解析机制本身与 Servlet 无关,这也是
WebFlux 能复用同一套 HandlerMethodArgumentResolver
接口的原因。
8.3 27 个内置解析器,顺序就是优先级
1 | // RequestMappingHandlerAdapter.java:653-698(节选) |
先说清楚数字怎么来的:这个方法体里有 29 处 add,其中 1
处是 addAll(getCustomArgumentResolvers())(自定义块);28
个内置里又有 1 个被 KotlinDetector.isKotlinPresent()
守着。所以:
- 非 Kotlin 工程:27 个内置解析器
- Kotlin 在 classpath 上:28 个(多一个
ContinuationHandlerMethodArgumentResolver)
(顺带一提,new ArrayList<>(30) 里的 30
是容量预分配,不是元素个数,别被它带偏。)
按”注解驱动”和”类型驱动”分成两组:
| # | 解析器 | 认的注解 / 类型 |
|---|---|---|
| 1 | RequestParamMethodArgumentResolver(false) |
@RequestParam |
| 2 | RequestParamMapMethodArgumentResolver |
@RequestParam Map |
| 3 | PathVariableMethodArgumentResolver |
@PathVariable |
| 4 | PathVariableMapMethodArgumentResolver |
@PathVariable Map |
| 5 | MatrixVariableMethodArgumentResolver |
@MatrixVariable |
| 6 | MatrixVariableMapMethodArgumentResolver |
@MatrixVariable Map |
| 7 | ServletModelAttributeMethodProcessor(false) |
@ModelAttribute |
| 8 | RequestResponseBodyMethodProcessor |
@RequestBody |
| 9 | RequestPartMethodArgumentResolver |
@RequestPart |
| 10 | RequestHeaderMethodArgumentResolver |
@RequestHeader |
| 11 | RequestHeaderMapMethodArgumentResolver |
@RequestHeader Map |
| 12 | ServletCookieValueMethodArgumentResolver |
@CookieValue |
| 13 | ExpressionValueMethodArgumentResolver |
@Value |
| 14 | SessionAttributeMethodArgumentResolver |
@SessionAttribute |
| 15 | RequestAttributeMethodArgumentResolver |
@RequestAttribute |
| 16 | ServletRequestMethodArgumentResolver |
WebRequest /
ServletRequest / InputStream /
Reader … |
| 17 | ServletResponseMethodArgumentResolver |
ServletResponse /
OutputStream / Writer |
| 18 | HttpEntityMethodProcessor |
HttpEntity |
| 19 | RedirectAttributesMethodArgumentResolver |
RedirectAttributes |
| 20 | ModelMethodProcessor |
Model |
| 21 | MapMethodProcessor |
Map |
| 22 | ErrorsMethodArgumentResolver |
Errors /
BindingResult |
| 23 | SessionStatusMethodArgumentResolver |
SessionStatus |
| 24 | UriComponentsBuilderMethodArgumentResolver |
UriComponentsBuilder |
| — | 自定义解析器插入点 | — |
| 25 | PrincipalMethodArgumentResolver |
Principal |
| 26 | RequestParamMethodArgumentResolver(true) |
兜底:简单类型 |
| 27 | ServletModelAttributeMethodProcessor(true) |
兜底:复杂类型 |
8.4 缓存:为什么第二次请求不再遍历
匹配结果会被缓存,但缓存不在你以为的地方——它在 composite 上:
1 | // HandlerMethodArgumentResolverComposite.java:43 |
1 | // HandlerMethodArgumentResolverComposite.java:130-142 |
首次匹配即锁定:顺序遍历,第一个
supportsParameter() 返回 true 的被
break 选中,然后按 MethodParameter
缓存。后续请求直接命中缓存,不再重复遍历。
8.5 自定义解析器排在哪里(一个广为流传的错误说法)
很多资料(包括网上很多中文资料)说”自定义解析器排在所有内置解析器之后,所以会被内置的抢走”。这个说法不准确。
真实插入点是中间:
1 | // RequestMappingHandlerAdapter.java:687-695 |
结论应该这样表述:
自定义解析器插在 24 个内置解析器之后、3 个兜底解析器之前。
由此得到两条精确的推论:
- ✅ 自定义解析器能抢在兜底逻辑之前——比如一个无注解的
User user参数,你可以用自己的解析器接管,而不必担心被ServletModelAttributeMethodProcessor(true)抢先。 - ❌ 自定义解析器覆盖不了前面那 24
个内置解析器。只要参数上写了
@PathVariable,它就会被PathVariableMethodArgumentResolver接走,轮不到你。
同样的规律也适用于返回值处理器:15 个内置处理器里,自定义插在 14 个之后、兜底之前,只能覆盖兜底。
8.6 没有注解的参数会怎样
规则由两个兜底解析器的先后顺序决定。RequestParamMethodArgumentResolver(true)
排在前面,于是形成一条二分法则:
1 | // RequestParamMethodArgumentResolver.java:126-152(节选) |
1 | // ModelAttributeMethodProcessor.java:90-94 |
| 参数类型 | 交给谁 | 等价于 |
|---|---|---|
简单类型(String、int、Long、boolean…) |
RequestParamMethodArgumentResolver(true) |
@RequestParam |
| 复杂类型(自定义 POJO) | ServletModelAttributeMethodProcessor(true) |
@ModelAttribute |
这里有一个很容易踩的不对称:RequestParamMethodArgumentResolver
用的是
parameter.nestedIfOptional().getNestedParameterType()(会解包
Optional<T>),而
ModelAttributeMethodProcessor 用的是
parameter.getParameterType()(不解包)。
后果是:一个无注解的 Optional<Foo>
参数不会被兜底当成模型属性,它会落到”都不认领”的境地,最终抛出
IllegalStateException("No suitable resolver")。这不是理论推演,而是那两条
supportsParameter() 直接决定的。
路径变量则是彻底的例外。 它的守卫在第一行,没有任何默认兜底:
1 | // PathVariableMethodArgumentResolver.java:72-75 |
原因很自然:路径变量只存在于 URI
模板里,无法像请求参数那样靠”参数名”反推。所以即使参数名和
@GetMapping("/user/{id}") 里的 {id}
完全一致,缺了 @PathVariable
也不会绑定——它会一路落到兜底,被当成查询参数
?id= 处理,然后在引子里的那个报错上撞墙。
8.7 编译期就必须确定的事:-parameters
无注解参数和未指定 name
的注解,都依赖”从字节码里读出参数名”。这个能力由
DefaultParameterNameDiscoverer 提供:
1 | // DefaultParameterNameDiscoverer.java:38-45 |
在 6.1.8
里,LocalVariableTableParameterNameDiscoverer
已经彻底移除——整个源码树里搜不到这个类,也没有保留任何
@Deprecated 占位。现在只剩两个:条件性的 Kotlin
反射,和依赖 Parameter.isNamePresent() 的标准反射。后者正是
-parameters 编译参数在起作用。
没有 -parameters 时,报错长这样:
1 | // AbstractNamedValueMethodArgumentResolver.java:179-189(节选) |
这次的异常类型是
IllegalArgumentException(和 8.2 里的
IllegalStateException 不是一回事,排查时注意区分)。Spring
Boot 的 spring-boot-starter-parent 默认已开启
-parameters,所以这个坑通常只在自定义构建配置(或某些
Kotlin/Gradle 组合)里出现。
8.8 Converter、Formatter、HttpMessageConverter 的分工
这三者经常被混为一谈,其实分工很清晰。
| 接口 | 处理对象 | 何时生效 | 典型实现 |
|---|---|---|---|
Converter<S,T> |
单个值的类型转换 | 参数绑定阶段 | StringToIntegerConverter |
Formatter<T> |
字符串 ↔︎ 对象的双向转换(支持 locale) | 参数绑定阶段 | DateFormatter |
HttpMessageConverter<T> |
整个请求体 / 响应体 | @RequestBody /
@ResponseBody |
MappingJackson2HttpMessageConverter |
一句话区分:Converter / Formatter 处理”一个参数值”,HttpMessageConverter 处理”整个请求体或响应体”。
?id=123 里的 "123" 是字符串,方法参数是
Long——这是 Converter 的活。而一个 JSON
请求体变成一个对象,是 HttpMessageConverter 的活。
8.9 @RequestBody 的读取路径:只看 Content-Type
@RequestBody 的解析由
RequestResponseBodyMethodProcessor
承担,它同时实现了入参解析和返回值处理两个接口(这是它能同时管
@RequestBody 和 @ResponseBody 的原因)。
1 | // RequestResponseBodyMethodProcessor.java:155-167 |
真正的选择逻辑在父类:读的判定依据是请求的
Content-Type,乘以转换器的
canRead()。
1 | // AbstractMessageConverterMethodArgumentResolver.java:155-180(节选) |
两个要点:
produces完全不参与读路径。 它只影响写。这是很常见的误解。Content-Type缺失时会被当成application/octet-stream(不是”跳过校验”),于是通常匹配不到 JSON 转换器,直接 415。
Content-Type 不支持时抛
HttpMediaTypeNotSupportedException → 415;JSON
语法错误或反序列化失败时抛 HttpMessageNotReadableException
→ 400。 这两个错误码的边界就在这里。
8.10 回到引子:为什么 @RequestParam 取不到 JSON 里的字段
现在可以把引子里的报错完整解释了。
@RequestParam |
@RequestBody |
|
|---|---|---|
| 解析器 | RequestParamMethodArgumentResolver |
RequestResponseBodyMethodProcessor |
| 取值位置 | query
string、表单参数(application/x-www-form-urlencoded) |
请求体(经
HttpMessageConverter 反序列化) |
| 依据 | 参数名 / name 属性 |
Content-Type |
| 取值失败 | 400
MissingServletRequestParameterException |
400
HttpMessageNotReadableException |
它们取的根本不是同一个”参数”。
@RequestParam 是”从 URL
和表单里按名字捞一个值”,@RequestBody
是”把整个请求体反序列化成一个对象”。参数确实传了——它传在请求体里;但解析
@RequestParam 的那个解析器,只会在 query string
和表单里找。
修法有三种,选哪种取决于接口语义:
1 | // ① 前端改成 query 参数:GET /order?userId=123 |
**不要用”给
@RequestParam加required = false“来糊过去。** 那只是把 400 换成了null,然后在下游某处变成 NPE 或者一条脏数据——把”客户端明确报错”改成了”数据静默出错”,是更差的交易。
8.11 自定义参数解析器(修正版)
理解了策略链之后,@CurrentUser 这种注解就顺手了。
1 |
|
1 | // 注册成 Bean,让依赖注入正常工作 |
1 |
|
之前的写法在
addArgumentResolvers里直接new CurrentUserArgumentResolver(tokenService),那样这个解析器就不是 Spring Bean 了——它内部再用@Autowired、@Transactional、AOP 代理,全部都不会生效。注册进 MVC 的组件,尽量从容器里取。
@CurrentUser 会和 @RequestBody
冲突吗?不会。
RequestResponseBodyMethodProcessor.supportsParameter() 只认
@RequestBody,自定义解析器只认
@CurrentUser,注解不同,认领者就不同。但如果同一个参数上同时写了两个注解,就会变成”谁排在前面谁赢”——而这正是
8.5 节那张顺序表在做的事。
九、Interceptor:顺序与中断语义
9.1 三个方法的真实实现
HandlerExecutionChain 的三个字段决定了全部行为:
1 | // HandlerExecutionChain.java:44-48 |
interceptorIndex 是整个中断语义的核心。
看 applyPreHandle:
1 | // HandlerExecutionChain.java:143-153 |
注意赋值那一行的位置:this.interceptorIndex = i; 在
if 判断之后。也就是说,它只在
preHandle 成功之后才被推进。
1 | // HandlerExecutionChain.java:158-165 |
1 | // HandlerExecutionChain.java:172-182 |
postHandle 从尾到头;afterCompletion 从
interceptorIndex 倒着走到
0。两者都是逆序。
9.2 中断时到底谁收到了 afterCompletion
假设有三个拦截器 [I0, I1, I2],I1 的
preHandle 返回 false:
| 拦截器 | preHandle |
postHandle |
afterCompletion(收到什么异常) |
|---|---|---|---|
I0 |
✅ 执行 | ❌ 不执行 | ✅ 执行,ex = null |
I1 |
❌ 返回 false | ❌ 不执行 | ❌ 不执行 |
I2 |
❌ 未执行 | ❌ 不执行 | ❌ 不执行 |
推导过程:I1 的 preHandle 返回 false
时,interceptorIndex 还停在 0(I0
成功时赋的值),于是 triggerAfterCompletion 从
i = 0 开始倒序执行,只碰到
I0。
这里有两个极易写错的点:
① 返回 false 的那个拦截器,自己的
afterCompletion 不会被调用。
很多人以为”我拦截了,那我总能收到自己的 afterCompletion
做清理”——不会。要清理资源,就得在那个 preHandle 返回
false 之前自己处理掉。
② 中断时传下去的 ex 是
null。
1 | triggerAfterCompletion(request, response, null); |
这是最反直觉的地方:“被拦截器主动拦下”和”请求正常结束”,在
afterCompletion 里长得一模一样(ex 都是
null)。
而 processDispatchResult() 正常结束时的调用也是
null:
1 | // DispatcherServlet.java:1183-1186 |
所以如果你在 afterCompletion
里写”ex == null
就记成功日志”,被拦截器拒绝的请求会被记成成功。真实的失败信息只能从
HTTP 状态码或你自己放进 request attribute 的标记里拿。
③ afterCompletion
里抛异常不会影响其他拦截器。 catch (Throwable ex2)
把它吞掉并记
error,循环继续。这是有意的:清理逻辑不该阻断链上其他清理。
9.3 三种”不执行”的情况
| 场景 | preHandle | postHandle | afterCompletion |
|---|---|---|---|
| 正常结束 | ✅ | ✅ 逆序 | ✅ 逆序,ex = null |
某个 preHandle 返回
false |
到它为止 | ❌ | 只有它之前的,ex = null |
| 抛异常 | ✅ | ❌(异常在 ha.handle
抛出) |
✅ 全部,ex 非 null |
| 404(无 chain) | ❌ | ❌ | ❌ |
| 304 短路 | ❌ | ❌ | ❌ |
| 异步启动 | ✅ | ❌ →
afterConcurrentHandlingStarted |
❌ →
afterConcurrentHandlingStarted |
最后两行值得再强调一次,因为它们是”埋点数据对不上”的常见来源:
- 404 的请求不会触发任何拦截器回调。 因为
mappedHandler == null,HandlerExecutionChain从未被创建(见 6.2 节③)。 - 304 的请求同样不触发。 因为
checkNotModified的短路发生在applyPreHandle之前(见 6.2 节②)。
如果你的访问统计依赖拦截器,那么”静态资源 + 未修改的 GET 请求”这一大批流量是统计不到的——它们会显得比实际少。
小结
这一篇把请求从 DispatcherServlet
门口送到方法调用之前:doDispatch()
的主干、HandlerMapping 的匹配顺序、27
个参数解析器谁先被问、拦截器的三段语义。四件事都在回答同一个问题——到底哪个方法、带什么参数被执行。
拦截器全部放行,InvocableHandlerMethod
反射调用的那一行已经在眼前。到这里,“请求能不能被执行”已经答完;再往后是另一个问题——执行的结果怎么变成
HTTP 响应,执行时抛的异常又变成哪个状态码。
那是第三篇的内容。
下一篇
SpringBoot 请求链路(三):方法执行、异步与响应写回——异常解析器的优先级、15 个返回值处理器与内容协商、异步请求的三根线程,一路写到
OutputBuffer和 Socket。