DispatcherServlet 内核:一次请求的完整旅程

bee2026-10-0873 分钟0 次阅读
九个组件接力完成一次请求:HandlerMapping 匹配、HandlerAdapter 调用、参数解析、返回值处理、消息转换器序列化——每一步都能亲手在 WASM 内核里跑出来。
1 / 132
小节
一、从浏览器敲下 URL 开始
2 / 132

在地址栏输入 http://localhost:8080/users/42 回车,短短几百毫秒里发生了很多事。请求先到 Tomcat:它解析 HTTP 报文,把方法、路径、请求头、请求体封装成一个 HttpServletRequest 对象。

3 / 132

接下来的问题很有意思:Tomcat 只认识 Servlet 这个接口——service(request, response)。可我们写的是 @Controller 里的普通方法,一个带 @GetMapping("/users/{id}") 的方法,Tomcat 根本不认识它。那么,是谁把「Servlet 的调用」翻译成了「调用你写的那个方法」?

4 / 132

答案就是 DispatcherServlet——Spring MVC 的前端控制器(Front Controller)。它本身是一个 Servlet,注册在所有请求路径上(映射 /),于是所有 HTTP 请求都会先落到它手里,再由它分派给具体的处理器。这就是「一个入口、统一调度」的前端控制器模式。

5 / 132

在 Spring Boot 里,你甚至看不到它的注册代码,因为它由自动配置完成:

6 / 132
代码对照
代码java
// 核心就是这一行:把 DispatcherServlet 映射到 "/"// ServletWebServerApplicationContext 启动时自动完成ServletRegistrationBean<DispatcherServlet> registration =        new ServletRegistrationBean<>(new DispatcherServlet(), "/");registration.setLoadOnStartup(1);   // 启动即初始化
解读
  • DispatcherServlet 是一个 HttpServlet,重写 service() / doGet() / doPost(),最终都汇聚到核心方法 doDispatch
  • 映射路径 / 表示「接管所有请求」,这就是它成为唯一入口的原因
  • loadOnStartup = 1 让它在容器启动时初始化,触发九大组件的装配(下一节)

提示:过去在 web.xml 里手工声明 DispatcherServlet,现在由 Spring Boot 的 DispatcherServletAutoConfiguration 自动完成。所以「Spring MVC 为什么默认就有 DispatcherServlet」,答案依然是——自动配置在替你注册。

7 / 132
小节
二、继承体系与九大组件
8 / 132

DispatcherServlet 顺着继承链往上找,逻辑其实分散在几层里:

9 / 132
text
HttpServlet  └─ HttpServletBean                 # 把 init-param 绑定成 Bean 属性      └─ FrameworkServlet            # 桥接 Servlet 与 Spring 容器;重写 service          └─ DispatcherServlet       # 前端控制器本体,doDispatch 在这里
10 / 132

关键是它启动时会调用 initStrategies(),把九个「助手组件」初始化好:

11 / 132
对照表
组件职责默认实现
HandlerMapping请求 → 处理器(含拦截器链)RequestMappingHandlerMapping
HandlerAdapter调用处理器的适配器RequestMappingHandlerAdapter
HandlerExceptionResolver异常 → 视图 / 状态码ExceptionHandlerExceptionResolver 等
ViewResolver视图名 → 具体视图ContentNegotiatingViewResolver
LocaleResolver判断请求的区域AcceptHeaderLocaleResolver
ThemeResolver主题解析FixedThemeResolver
MultipartResolver文件上传解析StandardServletMultipartResolver
RequestToViewNameTranslator无视图名时推导默认视图名DefaultRequestToViewNameTranslator
FlashMapManager重定向间传参(flash 属性)SessionFlashMapManager
12 / 132
  • 九个组件是「策略」:DispatcherServlet 只负责调度,把「怎么匹配」「怎么调用」「怎么渲染」都委托出去
  • 其中 HandlerMapping 与 HandlerAdapter 是主流程的主角,后面几节重点讲
  • 想定制任何一环,只需在容器里注册自己实现的 Bean,initStrategies() 会优先取容器里的实现
13 / 132
小节
三、doDispatch 主流程:逐行拆
14 / 132

整个分派的骨架就在 doDispatch 里。去掉细枝末节后,核心结构如下:

15 / 132
架构图
图 1 · 请求的九站旅程
图 1 · 请求的九站旅程
16 / 132
代码对照
代码java
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {    HttpServletRequest processedRequest = request;    HandlerExecutionChain mappedHandler = null;    ModelAndView mv = null;    Exception dispatchException = null;    try {        processedRequest = checkMultipart(request);        // ① 找到能处理该请求的 Handler(连同它的拦截器链)        mappedHandler = getHandler(processedRequest);        if (mappedHandler == null) {            noHandlerFound(processedRequest, response);            return;                                   // → 404 在这一行产生        }        // ② 找到能把 Handler 跑起来的适配器        HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());        // ③ 拦截器前置        if (!mappedHandler.applyPreHandle(processedRequest, response)) {            return;                                   // 前置返回 false,整条链中断        }        // ④ 真正执行:参数解析 + 调用方法 + 处理返回值        mv = ha.handle(processedRequest, response, mappedHandler.getHandler());        applyDefaultViewName(processedRequest, mv);        // ⑤ 拦截器后置        mappedHandler.applyPostHandle(processedRequest, response, mv);    }    catch (Exception ex) {        dispatchException = ex;                       // 不在这里处理,交给下面统一处理    }    // ⑥ 处理结果:异常解析、视图渲染、响应写出    processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);}
解读
  • getHandler / getHandlerAdapter:分别回答「谁来处理」和「用什么去执行」,一一对应第六、七节
  • applyPreHandle:拦截器前置。注意它的返回值是 boolean——返回 false 时整个方法直接 return,ha.handle 根本没被调用
  • ha.handle(...):这一行里藏着「参数解析 + 方法调用 + 返回值处理」三件大事,是第五节的主角
  • catch 里只是把异常暂存下来,真正的处理交给 processDispatchResult——这样无论成功失败,出口是同一个
  • processDispatchResult:内部先走 HandlerExceptionResolver(异常时),再决定是渲染视图还是写出响应体
17 / 132
原理动画
动图 · doDispatch 主流程
动图 · doDispatch 主流程
18 / 132

上面那段是「读」的。读和会的差别就在有没有一步对上号,所以下面把它摊成调试台:左边八行就是 doDispatch 的骨架,右边同步刷新「此刻的变量」和「调用栈」。连点下一步,盯住 mappedHandler 与 dispatchException 这两个变量——第 ⑦ 格(异常只被暂存)是本篇最容易想岔的地方。

19 / 132
单步调试台
单步台单步台:把 doDispatch 一行行走完1 / 8
连点「下一步」,盯两件事:第 ② 格 mappedHandler 什么时候为 null,第 ⑦ 格为什么异常只是被「暂存」
被调试的代码
1mappedHandler = getHandler(request); // ① 挂号:查表
2if (mappedHandler == null) { noHandlerFound(request, response); return; } // ② 404 的唯一产地
3HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // ③ 分诊
4if (!mappedHandler.applyPreHandle(request, response)) { return; } // ④ 拦截器前置
5mv = ha.handle(request, response, mappedHandler.getHandler()); // ⑤ 参数解析 + 调用 + 返回值
6mappedHandler.applyPostHandle(request, response, mv); // ⑥ 拦截器后置
7} catch (Exception ex) { dispatchException = ex; } // ⑦ 只暂存,不处理
8processDispatchResult(request, response, mappedHandler, mv, dispatchException); // ⑧ 统一出口
此刻的变量
线程名http-nio-8080-exec-3
请求GET /users/42
mappingRegistry12 条已登记映射
调用栈
1DispatcherServlet.doDispatch
2getHandler
1这一步只是查表。注册表在启动扫描 @Controller 时就建好了,此刻没有反射扫描、没有字符串比对全表——12 条映射按「路径模式 + 请求方法」比完,命中 UserController#getUser。
20 / 132
小节
四、HandlerMapping:请求是怎么找到方法的
21 / 132

getHandler 内部遍历所有 HandlerMapping,谁先返回非空就用谁。最常用的是 RequestMappingHandlerMapping,它继承自 AbstractHandlerMethodMapping,核心是一张「请求 → 方法」的注册表:

22 / 132
代码对照
代码java
public abstract class AbstractHandlerMethodMapping<T> implements HandlerMapping {    // 注册表:每个 RequestMappingInfo(含路径、方法、参数等条件)映射到一个 HandlerMethod    private final MappingRegistry mappingRegistry = new MappingRegistry();    @Override    public HandlerExecutionChain getHandler(HttpServletRequest request) {        // 遍历所有已注册的映射,找到第一个匹配当前请求的        return this.mappingRegistry.getMappings().stream()                .filter(m -> m.getKey().matches(request))     // 按路径/方法/头/参数匹配                .findFirst()                .map(m -> new HandlerExecutionChain(m.getValue(), /* 拦截器 */))                .orElse(null);    }}
解读
  • 这张注册表在启动时就建好了:容器启动时扫描所有 @Controller,把每个 @RequestMapping / @GetMapping 解析成 RequestMappingInfo,连同对应的 HandlerMethod 一起塞进 mappingRegistry
  • 所以请求到来时不需要反射扫描,只是查表匹配——这是 Spring MVC 高效的根基
  • HandlerMethod 封装了「哪个 Bean 的哪个方法」,而不是方法本身;后续适配器要靠它去调用

要点:HandlerMapping 家族不止一个。RequestMappingHandlerMapping 处理注解式控制器,SimpleUrlHandlerMapping 处理 XML 里手工配置的 URL→Bean 映射,BeanNameUrlHandlerMapping 按 Bean 名映射。默认情况下 RequestMappingHandlerMapping 排在最前,绝大多数的 @Controller 都归它管。

23 / 132
小节
五、HandlerAdapter:为什么要适配器模式
24 / 132

一个自然的疑问是:DispatcherServlet 已经拿到了「要调用的方法」,为什么还要多一层 HandlerAdapter?

25 / 132

因为「处理器」的实现方式可以很不一样:

26 / 132
  • @Controller 的方法(HandlerMethod)需要参数解析 + 反射调用 + 返回值处理
  • 实现 HttpRequestHandler 接口的 Bean 只需要调用 handleRequest(request, response)
  • 一个普通的 Controller 接口实现(老式 Spring MVC)又是另一种签名
27 / 132

DispatcherServlet 不可能为每种处理器写一套 if-else。适配器模式正是为此而生:DispatcherServlet 只说「我需要一个能跑这个 handler 的适配器」,由适配器去适配具体调用方式。

28 / 132
java
// DispatcherServlet 完全不关心 handler 是什么类型protected HandlerAdapter getHandlerAdapter(Object handler) {    for (HandlerAdapter adapter : this.handlerAdapters) {        if (adapter.supports(handler)) {       // 这个适配器能不能处理它?            return adapter;        }    }    throw new ServletException("No adapter for handler [" + handler + "]");}
29 / 132
代码对照
代码java
// RequestMappingHandlerAdapter 内部最终调用 InvocableHandlerMethodprotected ModelAndView invokeHandlerMethod(HttpServletRequest request,        HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {    // 1. 参数解析:把请求里的值转换成方法参数    Object[] args = getMethodArgumentValues(request, response, handlerMethod);    // 2. 反射调用你写的方法    Object returnValue = handlerMethod.invokeForRequest(request, response, args);    // 3. 返回值处理:交给 HandlerMethodReturnValueHandler    return ...;   // 见第七节}
解读
  • supports(handler):每个适配器声明「我能处理哪一类 handler」,DispatcherServlet 只需线性找一个能用的
  • getMethodArgumentValues:参数解析就在这一步(第六节展开)
  • invokeForRequest:反射调用目标方法,你的业务代码在这里被执行
  • 返回值:交给一组 HandlerMethodReturnValueHandler,消息转换器在这一步介入(第七节)

提示:这一层设计回答了一个常见的面试题「DispatcherServlet 认识 @Controller 吗?」——不认识。它只认识 HandlerMapping 返回的 HandlerMethod 和能执行它的 HandlerAdapter。@Controller 是 RequestMappingHandlerMapping 在启动扫描时认识的东西,与 DispatcherServlet 无关。

30 / 132
小节
六、参数解析器矩阵
31 / 132

getMethodArgumentValues 内部藏着一个「参数解析器库」。每个参数带什么注解,由对应的解析器负责从请求里取值并转换类型:

32 / 132
对照表
注解解析器数据来源
@RequestParamRequestParamMethodArgumentResolverquery string / 表单参数
@PathVariablePathVariableMethodArgumentResolverURI 模板变量 {id}
@RequestBodyRequestResponseBodyMethodProcessor请求体(经 HttpMessageConverter 反序列化)
@RequestHeaderRequestHeaderMethodArgumentResolver请求头
@ModelAttributeModelAttributeMethodProcessor表单字段绑定到对象
@SessionAttributeSessionAttributeMethodArgumentResolverHttpSession 域
@RequestAttributeRequestAttributeMethodArgumentResolverrequest 域
无注解的 UserServletModelAttributeMethodProcessor整体数据绑定
33 / 132
代码对照
代码java
@GetMapping("/users/{id}")public User getUser(        @PathVariable Long id,                        // 来自 URI:/users/42        @RequestParam(defaultValue = "false") boolean detail,  // 来自 ?detail=true        @RequestHeader("X-Trace-Id") String traceId) { // 来自请求头    // 三个参数各自由不同解析器提供    return userService.findById(id, detail, traceId);}
解读
  • 解析器按顺序尝试,第一个 supportsParameter 返回 true 的负责该参数
  • 类型转换由 WebDataBinder / ConversionService 完成,"42" → Long 就发生在这里
  • @RequestBody 比较特殊:它不靠请求参数,而是把整个请求体交给消息转换器反序列化
34 / 132

上面那张表有九行,但表格里看不见的是顺序:解析器是一条链,每个参数都要从头问一遍。把它点着走一遍,尤其是第 ⑤ 格——它对应你项目里那句最难看懂的英文报错:

35 / 132
交互图解
流程参数解析器责任链:一格一格点着看1 / 5
从 ① 点到 ⑤,重点在第 ③ 与第 ⑤ 格:一个决定「谁说了算」,一个决定「报 500 还是报 400」
→
→
→
→
① 取出一个参数
InvocableHandlerMethod 拿着方法签名逐个取参数,手里只有两样信息:参数上的注解、参数的类型。此刻它还不知道这个值在 URL 里还是在 body 里——这正是注解存在的意义。
全部看懂了「没人认领」是签名的问题(500),「值不对」是调用方的问题(400)——这两种故障的修复人完全不同。
36 / 132
坑

@RequestBody 与 @RequestParam 不能混用在同一个「读请求体」的场景上——请求体只能被读取一次。想同时拿表单字段和 JSON 体,本质上是不可能的,因为 request.getInputStream() 读一次就没了,这也是为什么 @RequestBody 后 @RequestParam 常拿不到表单值。

37 / 132
小节
七、返回值处理器与 HttpMessageConverter
38 / 132

方法执行完,返回值还要被「翻译」成 HTTP 响应。这由一组 HandlerMethodReturnValueHandler + HttpMessageConverter 配合完成:

39 / 132
对照表
返回值类型处理器输出方式
带 @ResponseBody 的对象RequestResponseBodyMethodProcessor消息转换器序列化为 JSON
StringViewNameMethodReturnValueHandler当作视图名,交给 ViewResolver
ModelAndViewModelAndViewMethodReturnValueHandler视图 + 模型
ResponseEntity<T>HttpEntityMethodProcessor状态码 + 响应头 + 体
Map / List(@RestController)RequestResponseBodyMethodProcessorJSON
Callable / DeferredResult异步处理器异步返回,MVC 异步支持
40 / 132
代码对照
代码java
@RestController                       // = @Controller + @ResponseBodypublic class UserController {    @GetMapping("/users/{id}")    public User getUser(@PathVariable Long id) {        return userService.findById(id);   // User 对象 → Jackson → JSON    }}
解读
  • @RestController 是 @Controller + @ResponseBody 的组合,所以每个返回值都走「序列化成 JSON」
  • 序列化由 HttpMessageConverter 完成:MappingJackson2HttpMessageConverter 负责 JSON,StringHttpMessageConverter 负责纯文本
  • 内容协商(Accept 头)决定用哪个转换器:浏览器要 HTML、Accept: application/json 要 JSON,协商规则可配

提示:String 返回值是最容易踩坑的一个——方法返回 "users/list" 时,它不是把这段文字写给浏览器,而是被当作视图名去找模板。想让 String 直接成为响应体,必须加 @ResponseBody(或用 @RestController)。这个「字符串到底是视图名还是响应体」的分歧,曾让无数人对着一个白页发呆。

41 / 132

这一段有两个实验,都是「点一下就看得到分岔」的那种。先走出门方向:返回值怎么变回字节。四个参数依次点,accept 那格解释的是内容协商,fail 那格就是 406 的出生现场:

42 / 132
内核实验
TeaVM消息转换器与内容协商:返回值怎么变回字节未启动
依次切 json / accept / string / fail,注意 fail 那一格的 HttpMediaTypeNotAcceptableException 是从哪一步冒出来的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
43 / 132

再回到那条「字符串两种命运」的坑。同一个 return user,只换类上的注解,走的是完全不同的出口:

44 / 132
内核实验
TeaVM@Controller 还是 @RestController:一句 return 的四种结局未启动
先点 view 看字符串怎么被当成视图名去找模板,再点 json 对照;missing 那格是白标 404 现场,string 那格演示 return 一个 ok 为什么浏览器只看到三个字母
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
45 / 132
小节
八、异常处理链:三家 Resolver 的顺序
46 / 132

doDispatch 里用 catch 把异常暂存,最终交给 processDispatchResult 处理。它内部会依次询问一组 HandlerExceptionResolver,谁先返回非空,谁就负责:

47 / 132
对照表
顺序解析器处理什么
1ExceptionHandlerExceptionResolver@ExceptionHandler 标注的方法(最常用)
2ResponseStatusExceptionResolver带 @ResponseStatus 的异常
3DefaultHandlerExceptionResolverSpring MVC 内置异常(见第九节)
48 / 132
代码对照
代码java
@RestControllerAdvicepublic class GlobalExceptionHandler {    @ExceptionHandler(BizException.class)   // 由 ExceptionHandlerExceptionResolver 处理    @ResponseStatus(HttpStatus.BAD_REQUEST)    public ErrorResult handle(BizException e) {        return new ErrorResult(e.getCode(), e.getMessage());    }}
解读
  • 三个解析器是链式的:前一个处理不了(返回 null)才轮到下一个
  • @ControllerAdvice / @RestControllerAdvice 里的 @ExceptionHandler 由第一个解析器接管,是业务里最常自定义的一环
  • 如果三个都处理不了(都返回 null),异常会向容器抛出,最终变成 500
49 / 132

这条接力有六步,值得单独动一遍——尤其是第 ② 步「只暂存不处理」和第 ⑥ 步「没人接手才 500」,它们决定了你去哪一行找 bug:

50 / 132
原理动画
动图 · 异常的接力路线
动图 · 异常的接力路线
51 / 132
说明

processDispatchResult 不仅处理异常,也处理成功场景——它根据 ModelAndView 决定是「渲染视图」还是「直接写出响应体」。所以它是所有分支的统一出口,这也是 doDispatch 把异常先 catch 再统一处理的用意。

52 / 132
小节
九、404 / 405 / 415 分别发生在哪一步
53 / 132

把错误和主流程对上号,排查时就不会乱。这件事背表格是背不住的——六个状态码,六个发生位置,来点一局:先点左边的状态码,再点你认为的那一站,配错当场告诉你差在哪。

54 / 132
配对闯关
闯关状态码配发生的那一站已配对 0/6 · 配错 0
六个状态码都来自第三节那八行代码里的某一格,点对位置才算真的读懂了 doDispatch
先点左边一个
55 / 132
坑

404 与 405 的区别值得记住。404 是 getHandler 压根没找到匹配(URL 不对);405 是找到了映射、但 HTTP 方法不被允许——它由 RequestMappingInfo.matches 在匹配阶段判掉,最终由 DefaultHandlerExceptionResolver 转成 405。很多人把「POST 报 405」当成路径写错,其实是注解用了 @GetMapping。

56 / 132
坑

@ResponseBody 返回中文乱码,是历史遗留问题。早期 StringHttpMessageConverter 默认用 ISO-8859-1 编码,中文自然变成乱码;如今 Spring Boot 已默认 UTF-8,但如果你手工 new 了一个 StringHttpMessageConverter 而没设编码,乱码会复现。排查乱码先看响应头的 Content-Type 里有没有 charset=UTF-8。

57 / 132
小节
十、动手体验:亲手分派一次请求
58 / 132

下面这个演示把 doDispatch 的主流程做成了可切换的路径。依次试试 /users/42、/users、/nope,观察每一步是哪个组件在工作、在什么时候返回 404:

59 / 132
内核实验
TeaVM亲手分派一次请求未启动
试试 /users/42、/users、/nope 三条路径,看每一步组件在做什么
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
60 / 132
小节
十一、决策:拦截器前置返回 false 还是抛异常
61 / 132
决策
决策一个登录校验拦截器发现用户未登录,应该怎么中断请求?
62 / 132
小节
十二、30 秒看懂:Spring MVC 就是一家医院的一站式服务台
63 / 132

先把术语放一边。想象你走进一家公立医院,什么流程都不懂,但你不会迷路——因为所有窗口都围着同一个服务台转。

64 / 132
类比

浏览器发出的那个 HTTP 请求,就是「一位没挂过号的病人」。DispatcherServlet 是大厅正中的一站式服务台(全医院只有一个入口,这叫前端控制器模式);HandlerMapping 是挂号窗口,它手里有一张「症状 → 科室」的表,负责回答「这趟该找哪个医生」;HandlerAdapter 是分诊台护士,它不看病,只决定「这个医生用什么方式接诊——门诊、专家号还是急诊」;你的 @Controller 方法就是那位医生,真正下诊断的地方;最后 HttpMessageConverter 是检验报告打印机,把医生脑子里的结构化结论打成你能带走的纸质报告(JSON)。门口查健康码的是过滤器 Filter(比服务台还早,它不认识医生),走廊里跟着你的陪护是拦截器 Interceptor(只有挂上号的人才有陪护)。

65 / 132

这条链上每个角色只做一件事,所以你永远可以问一句话来定位问题:「我这个请求死在哪一站?」

66 / 132
类比

那为什么要绕这一大圈?对比一下自己写原生 Servlet 的滋味:那等于你在医院里既当病人又当挂号员、分诊护士、医生和报告打印员——request.getRequestURI() 手工切字符串取 id、Long.valueOf 手工转换、if-else 分发动作、StringBuilder 手工拼 JSON、response.setContentType 手工设头、异常手工 try-catch。Spring MVC 做的事只有一件:把这六份工分别交给六个专职窗口。你写的就只剩「医生的诊断」那一句业务代码。

67 / 132
架构图
图 4 · Servlet 原生写法 vs DispatcherServlet
图 4 · Servlet 原生写法 vs DispatcherServlet
68 / 132
架构图
图 3 · 一个请求穿过 MVC 的九站
图 3 · 一个请求穿过 MVC 的九站
69 / 132

学完这一篇,你要能脱口而出地回答三个问题:

70 / 132
  • 为什么 Spring MVC 只需要一个 Servlet 就能接管全站请求?(因为它映射在 /,是唯一入口)
  • 返回 404 和返回 405,分别说明「哪一站」出了问题?(挂号台 vs 分诊台)
  • 为什么你在过滤器里拿不到「当前正在执行哪个 Controller 方法」?(那时 getHandler 还没跑)
71 / 132
小节
十三、上手实验二:参数解析器与异常解析器的责任链
72 / 132

第十一节用 dispatch 走完了主干,但 doDispatch 里还有两条「挨个问一遍」的责任链最容易让人发懵:一条在进门时(谁来把我的方法参数填好),一条在出错时(谁来把我的异常变成响应)。这两条链都是同一个套路——按顺序问,谁先说「我能处理」谁就上,全部说不能就报错。

73 / 132

先看参数侧。ha.handle() 内部会对方法签名的每一个参数都跑一遍解析器链:

74 / 132
代码对照
代码java
@GetMapping("/users/{id}")public User getUser(        @PathVariable Long id,                    // PathVariableMethodArgumentResolver 接手        @RequestParam(required = false) String tag, // RequestParamMethodArgumentResolver 接手        HttpServletRequest request) {             // ServletRequestMethodArgumentResolver 接手    return userService.findById(id, tag, request.getRemoteAddr());}
解读
  • 三个参数走三条不同的路,但都由同一条链逐个认领:解析器先用 supportsParameter(参数) 回答「这个归我吗」
  • 认领之后才谈取值与转换:"42" 变成 Long 由 ConversionService 完成
  • 没有任何解析器认领时,抛 IllegalStateException: Could not resolve parameter [0] ... No suitable resolver ——这是新手最常撞见的一句英文
75 / 132

再看异常侧。processDispatchResult 里的链同样是一串 if:

76 / 132
java
// 简化自 DispatcherServlet#processHandlerExceptionfor (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {    ModelAndView exMv = resolver.resolveException(request, response, handler, ex);    if (exMv != null) {        break;                        // 第一个给出非 null 的就赢了,后面的根本不会被问    }}// 三个解析器都返回 null → 异常继续往外抛 → 容器兜底 → 500 页
77 / 132

下面两个演示分别把这两条链做成了可切换的现场。建议按顺序把每个参数按钮都点一遍,尤其是最后一个「没有解析器接手」——它就是你在自己项目里看到的那句报错:

78 / 132
内核实验
TeaVM参数解析器责任链现场未启动
依次切到 @PathVariable / @RequestParam / @RequestBody / 内置类型,最后看「没有解析器接手」怎么炸
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
79 / 132
内核实验
TeaVM异常解析器链现场未启动
从「校验失败 400」切到「没人接管 → 500」,看清是谁把异常接住的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
80 / 132
小节
十四、上手实验三:过滤器与拦截器到底谁先跑
81 / 132

第九节讲了状态码落在哪一站,但排查线上问题时你还得能回答另一类问题:日志打了、TraceId 没传下去、跨域挂了、postHandle 没执行。这些都发生在「服务台」和「门口」之间的那几米走廊上。

82 / 132

先把两个概念用一句话说清:

83 / 132
  • Filter(过滤器):Servlet 规范的东西,不属于 Spring。它在 DispatcherServlet 之前运行,所以它连「这次请求要调哪个方法」都不知道
  • Interceptor(拦截器):Spring MVC 的东西,注册在 HandlerExecutionChain 里,跟着某一条处理器链走,能拿到 HandlerMethod
84 / 132
java
@Component@Order(1)                                   // 过滤器排序用 @Order / FilterRegistrationBeanpublic class TraceIdFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,                                    FilterChain chain) throws ServletException, IOException {        long start = System.currentTimeMillis();        try {            MDC.put("traceId", UUID.randomUUID().toString().replace("-", ""));            chain.doFilter(request, response);        // ← 这一行之后才是 DispatcherServlet        } finally {            log.info("{} {} cost={}ms", request.getMethod(), request.getRequestURI(),                    System.currentTimeMillis() - start);            MDC.remove("traceId");                    // 放在 finally,否则线程复用会串号        }    }}@Componentpublic class AuthInterceptor implements HandlerInterceptor {    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {        // 这里的 handler 已经是 HandlerMethod —— 过滤器阶段拿不到的东西        if (!(handler instanceof HandlerMethod hm)) {            return true;                              // 静态资源等不是方法处理器,直接放行        }        return LoginUserHolder.get() != null || !hm.hasMethodAnnotation(NeedLogin.class);    }    @Override    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,                                Object handler, Exception ex) {        // 唯一保证执行的回调:preHandle 返回 false 时它也会被调到(且只调已执行过 preHandle 的那些)    }}
85 / 132

两者最容易被忽略的三条差异,配合动图记最省事:

86 / 132
对照表
能力FilterInterceptor
能不能看见 HandlerMethod(哪个类的哪个方法)不能能(preHandle 的第三个参数)
抛异常后 postHandle 还执行吗不涉及不执行,直接进异常解析器链
CORS 预检 OPTIONS 请求必须在这里放行preHandle 容易把预检挡成 403
87 / 132
原理动画
动图 · 过滤器与拦截器的先后次序
动图 · 过滤器与拦截器的先后次序
88 / 132

把下面这个演示的五个参数逐个点开,尤其看「preHandle 返回 false」「异常时还走 postHandle 吗」「跨域预检」这三格——它们对应三类真实工单:

89 / 132
内核实验
TeaVM过滤器与拦截器执行顺序实测未启动
依次试 order / pre / ex / cors / scope,重点看 pre 和 ex 两格里 postHandle 的命运
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 132

实验点完可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 boot 建容器,再逐条把本篇的六个断言敲出来验一遍:

91 / 132
内核控制台
92 / 132
说明

lab dispatch /nope 与 curl /api/kernel/beans 是两类动作——前者把分派过程一步步打给你看,后者真的走一遍虚拟 8080。新手最容易混淆的就是这两处:一个是解剖台,一个是接线板。

93 / 132
小节
十五、常见报错速查
94 / 132

下表四列固定为「报错原文片段 / 真实原因 / 30 秒自救 / 深挖看第几篇」。报错原文请整段复制去搜索,不要意译——搜索引擎认全类名,不认你的描述。

95 / 132
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
404 + 日志里出现 No mapping for GET /api/users路径字符串对不上:类上的 @RequestMapping 前缀漏了、context-path 没算进去、或包不在 @ComponentScan 范围内启动时加 logging.level.org.springframework.web=DEBUG,看启动日志一共登记了哪些映射,逐字比对本篇第四节
404 + 根本没有 No mapping 日志过滤器或拦截器提前把请求截断了(chain.doFilter 忘记调用 / preHandle 返回 false)在过滤器的 finally 打一行日志,确认它是否真的走到了 chain.doFilter本篇第十四节
404 + Circular view path [index]: would dispatch back to the current handler URL [/index] too many times返回值被当成了视图名,而模板引擎不存在,于是又派发回同一个 URL给方法或类补 @ResponseBody(或改 @RestController);真要用模板就加 spring-boot-starter-thymeleaf#23 返回值语义
404 只丢静态文件(/favicon.ico、/js/app.js 找不到)自定义的 WebMvcConfigurer#addViewControllers / 拦截器把 /** 全拦了,或静态资源目录不是 classpath:/static/检查 spring.web.resources.static-locations,并确认拦截器排除了 /static/**本篇第十五节末
javax.servlet.ServletException: No adapter for handler [...]处理器类型不在任何 HandlerAdapter 的能力范围里(多为手工注册的非常规 handler)换成标准 @Controller 方法,或注册自定义 HandlerAdapter本篇第五节
IllegalStateException: Could not resolve parameter [0] ... No suitable resolver方法参数既没有支持的注解、也不是内置支持类型删掉拼错的注解名(如 @PathParam,那是 JAX-RS 的);显式声明数据来自哪里#23
HTTP status 405 「POST 报 405」路径匹配上了,但注解是 @GetMapping,方法不允许改成 @PostMapping 或 @RequestMapping(method = {GET, POST})本篇第九节
HTTP status 415 + HttpMediaTypeNotSupportedException请求头 Content-Type 与 consumes / 消息转换器不支持补 -H "Content-Type: application/json"#23 第六节
96 / 132

表格里那句「POST 报 405」是新手最容易被带偏的一行——它会让人去查 URL。下面这段真堆栈就是那道题的现场,先别看解析,点出你认为的凶手行:

97 / 132
报错急救
报错急救HttpRequestMethodNotSupportedException: Request method 'POST' not supported
路径明明是对的,为什么 POST 就 405

本地页面点提交就 405。你盯着 @GetMapping("/users/{id}") 和浏览器的 /users/42 看了三遍,觉得地址完全一样。

org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported
at org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.handleNoMatch(RequestMappingInfoHandlerMapping.java:253)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.lookupHandlerMethod(AbstractHandlerMethodMapping.java:422)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.getHandlerInternal(AbstractHandlerMethodMapping.java:365)
at org.springframework.web.servlet.DispatcherServlet.getHandler(DispatcherServlet.java:1261)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1043)
supported methods = [GET]
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
98 / 132
坑

静态资源被拦,是「加了 @EnableWebMvc」的经典副作用。 一旦写上 @EnableWebMvc,Spring Boot 默认的 MVC 配置(含静态资源映射、/webjars/** 资源处理器、消息转换器定制)会被整体关掉,页面瞬间只剩一张白 HTML。修复办法二选一:① 直接删掉 @EnableWebMvc,改用实现 WebMvcConfigurer 的方式做定制(Boot 推荐);② 保留它并在配置类上手工补回资源映射:

99 / 132
java
@Configuration@EnableWebMvc                      // 除非你确实要接管全部 MVC 配置,否则别写这行public class WebConfig implements WebMvcConfigurer {    @Override    public void addResourceHandlers(ResourceHandlerRegistry registry) {        registry.addResourceHandler("/static/**")                .addResourceLocations("classpath:/static/");        registry.addResourceHandler("/webjars/**")                .addResourceLocations("classpath:/META-INF/resources/webjars/");    }    @Override    public void addInterceptors(InterceptorRegistry registry) {        // 别忘了让拦截器放过静态资源,否则 CSS/JS 也一起被登录校验挡掉        registry.addInterceptor(authInterceptor).addPathPatterns("/**")                .excludePathPatterns("/static/**", "/favicon.ico", "/error");    }}
100 / 132

最后把本篇真正会用到的几行配置一次勾全。别去抄别人的 yml——每勾一项,都对应上面某一节的一个现象:勾 logging 出的是第十五节那条「看启动日志里的映射表」,勾 server 出的是 404 头号来源 context-path,勾 upload 对应第三节 doDispatch 第一行的 checkMultipart,勾 profile 让 dev 与 prod 用不同的前缀而不用改代码:

101 / 132
生成器
生成器排障最小集:本篇这四组配置application.yml2 / 4
先只勾 server + logging 跑一遍,你会在启动日志里看到每一条映射的完整路径——那时第九节讲的 404 就不必再猜了。再加上 upload,回头看 multipart 的上限卡在哪一站;profile 用来把 dev 与 prod 的 context-path 分开
产物
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
勾了这些,代价与理由在这里
serverserver.port 被命令行 --server.port=8081 覆盖,也吃 SERVER_PORT 环境变量。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
102 / 132
小节
十六、随堂自测
103 / 132
随堂自测
随堂自测一个请求打进来了,DispatcherServlet 想调用你的 @Controller 方法,但它压根不认识 @Controller 这个注解。那么「谁能执行你的方法」这件事,是谁回答的?
先自己选一个,选中立刻告诉你对不对
104 / 132
随堂自测
随堂自测拦截器的 preHandle 发现用户未登录,于是 return false。此时客户端最可能收到什么?
先自己选一个,选中立刻告诉你对不对
105 / 132
小节
十七、沙盘:把组件一个个开关掉,看请求死在哪一站
106 / 132

这个沙盘不做计算,做的是故障还原:每关掉一个组件,就模拟一次真实的线上形态。选一组组合,下面的输出会直接给你「现象 + 第一现场该怎么查」。

107 / 132
沙盘
沙盘关掉一个组件,看看会发生什么
运行结果
200 OK
HandlerMapping ✓ HandlerAdapter ✓ 参数解析 ✓ 消息转换 ✓
#基线:五站全通
一切正常,这就是你本地开发时的样子
108 / 132
小节
十八、动手练习
109 / 132
小节
第一档 · 照做
110 / 132

目标:从零跑通「一个请求穿过九站」,并且亲眼看到四个不同状态码分别从哪一站出来。

111 / 132
java
package com.example.lab.mvc;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.List;import java.util.Map;@RestController                                  // = @Controller + @ResponseBody@RequestMapping("/api/users")                    // 类级前缀:漏写它是 404 的头号原因public class UserLabController {    record UserVO(Long id, String name) {}    // 站③挂号 + 站⑦见医生:@PathVariable 由 PathVariableMethodArgumentResolver 装配    @GetMapping("/{id}")    public UserVO get(@PathVariable Long id) {   // "42" → Long 由 ConversionService 完成        return new UserVO(id, "user-" + id);    }    // 集合查询:@RequestParam 由 RequestParamMethodArgumentResolver 装配    @GetMapping    public List<UserVO> list(@RequestParam(defaultValue = "10") int size) {        return List.of(new UserVO(1L, "alice"), new UserVO(2L, "bob")).subList(0, Math.min(size, 2));    }    // 故意留一个 405:只允许 GET,用 POST 打它就是「路径对、动作不对」    @PutMapping("/{id}")    @ResponseStatus(HttpStatus.NOT_IMPLEMENTED)    public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody Map<String, Object> body) {        return ResponseEntity.noContent().build();       // 正常 PUT → 204    }}
112 / 132

启动类与验证命令(curl 逐条贴进终端即可):

113 / 132
bash
# 1) 正常路径变量 → 200curl -i http://localhost:8080/api/users/42# 2) 集合查询 → 200curl -i "http://localhost:8080/api/users?size=1"# 3) 路径存在但方法不对 → 405(不是 404!)curl -i -X POST http://localhost:8080/api/users/42# 4) 路径压根没登记 → 404curl -i http://localhost:8080/nope# 5) 把 id 写成非数字 → 400 MethodArgumentTypeMismatchExceptioncurl -i http://localhost:8080/api/users/abc
114 / 132

预期响应(第 1 条):

115 / 132
json
{  "id": 42,  "name": "user-42"}
116 / 132

第 3 条预期响应头关键行:HTTP/1.1 405,并且控制台不会打印 No mapping——因为映射找到了,只是方法不被允许。第 4 条预期:HTTP/1.1 404 + 日志一行 No mapping for GET /nope。第 5 条预期:HTTP/1.1 400 + 异常全名 org.springframework.web.method.annotation.MethodArgumentTypeMismatchException。

117 / 132
小节
第二档 · 变体
118 / 132

三个小改动,每个都只需改一两行,重点是观察报错原文的变化:

119 / 132
  1. 把类上的 @RequestMapping("/api/users") 删掉,再跑第 1 条 curl → 你会观察到 404,且日志变成 No mapping for GET /api/users/42:URL 是「类前缀 + 方法路径」拼出来的,缺一半都不算匹配
  2. 把 @RestController 改成 @Controller,其余不动 → 你会观察到 javax.servlet.ServletException: Circular view path [get](或 404 on template),因为返回值 UserVO 不再被序列化,而是被当成视图信息处理。这就是第七节说的「String/对象的两种命运」
  3. 把 @GetMapping("/{id}") 改成 @GetMapping("/{id:\\d+}"),然后跑第 5 条 curl → 你会观察到状态码从 400 变成 404:正则约束在挂号阶段就把 /abc 挡在门外,压根轮不到类型转换。用「让它更早失败」换「让它更干净」,这是设计取舍而非语法技巧
120 / 132
小节
第三档 · 造一个
121 / 132

做一个「请求旅程记录仪」:给任意接口都能打出它穿过了哪几站、各花多少毫秒。

122 / 132

要求:

123 / 132
  • 一个 TraceIdFilter(继承 OncePerRequestFilter),生成 traceId 放进 MDC,并在 finally 里打印总耗时
  • 一个 JourneyInterceptor(实现 HandlerInterceptor),preHandle 记录进入时间并把 HandlerMethod 的「类名#方法名」存进 request 属性
  • 一个 @RestControllerAdvice,把 MethodArgumentTypeMismatchException 统一转成 {"code":40000,...} 且 HTTP 仍为 400
124 / 132

验收清单:

125 / 132
  • [ ] curl -i localhost:8080/api/users/42 的响应里能看到 X-Trace-Id 头(证明过滤器活着)
  • [ ] 日志同时包含「命中的控制器方法名」(证明拦截器拿到了 HandlerMethod,过滤器做不到)
  • [ ] 打 /api/users/abc 返回 400 且 body 是你的统一错误结构,不是 Whitelabel 页
  • [ ] 把拦截器的 preHandle 改成 return false,重跑同一命令,确认响应变成「空 body + 200」——亲手复现第十六节第一题的坑
  • [ ] 关掉某个过滤器(注释掉 @Component),确认耗时日志消失但接口照常,理解「外圈可摘、内圈不可摘」
126 / 132
小节
十九、要点自查
127 / 132
自检

不看上文,能否按顺序说出一次请求经过的九站,并指出 404 / 405 / 406 / 500 各死在哪一站?(404 在挂号台,405 在分诊条件,406 在取报告,参数没人认领的 500 在见医生之前)

128 / 132
自检

能否解释「为什么 DispatcherServlet 不认识 @Controller」?(它只消费 HandlerMapping 给的 HandlerMethod 和 HandlerAdapter;扫描注解的是 RequestMappingHandlerMapping,两件事发生在不同组件里)

129 / 132
自检

postHandle 和 afterCompletion 的区别是什么,为什么埋点代码要写在后者?(控制器抛异常时 postHandle 被跳过,afterCompletion 一定执行——清理 ThreadLocal/MDC 只能放这里)

130 / 132
自检

@RequestBody 之后为什么常拿不到 @RequestParam 的表单值?(请求体是一次性的字节流,getInputStream() 读到底就没了)

131 / 132
口诀

服务台只有一个入口(/),挂号定「找谁」、分诊定「怎么跑」、医生定业务、打印机定格式;404 是没挂上号,405 是科室对但动作错,406 是报告打不出来。

132 / 132
总结

一次请求的完整旅程是 Tomcat → DispatcherServlet → getHandler(HandlerMapping 查表)→ getHandlerAdapter(适配器)→ applyPreHandle(拦截器前置)→ ha.handle(参数解析 + 反射调用 + 返回值处理)→ applyPostHandle(拦截器后置)→ processDispatchResult(异常解析 / 视图渲染 / 响应写出)。记住四个关键认知:DispatcherServlet 不认识 @Controller,它只认识 HandlerMethod 和 HandlerAdapter;HandlerMapping 的「请求→方法」注册表在启动时就建好了,请求时只是查表;404 是没匹配到 Handler,405 是路径匹配但方法不符,415 是消息转换器不支持请求体类型;拦截器要中断并返回标准错误时,抛异常优于返回 false。