DispatcherServlet 内核:一次请求的完整旅程
在地址栏输入 http://localhost:8080/users/42 回车,短短几百毫秒里发生了很多事。请求先到 Tomcat:它解析 HTTP 报文,把方法、路径、请求头、请求体封装成一个 HttpServletRequest 对象。
接下来的问题很有意思:Tomcat 只认识 Servlet 这个接口——service(request, response)。可我们写的是 @Controller 里的普通方法,一个带 @GetMapping("/users/{id}") 的方法,Tomcat 根本不认识它。那么,是谁把「Servlet 的调用」翻译成了「调用你写的那个方法」?
答案就是 DispatcherServlet——Spring MVC 的前端控制器(Front Controller)。它本身是一个 Servlet,注册在所有请求路径上(映射 /),于是所有 HTTP 请求都会先落到它手里,再由它分派给具体的处理器。这就是「一个入口、统一调度」的前端控制器模式。
在 Spring Boot 里,你甚至看不到它的注册代码,因为它由自动配置完成:
// 核心就是这一行:把 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」,答案依然是——自动配置在替你注册。
DispatcherServlet 顺着继承链往上找,逻辑其实分散在几层里:
HttpServlet └─ HttpServletBean # 把 init-param 绑定成 Bean 属性 └─ FrameworkServlet # 桥接 Servlet 与 Spring 容器;重写 service └─ DispatcherServlet # 前端控制器本体,doDispatch 在这里关键是它启动时会调用 initStrategies(),把九个「助手组件」初始化好:
| 组件 | 职责 | 默认实现 |
|---|---|---|
HandlerMapping | 请求 → 处理器(含拦截器链) | RequestMappingHandlerMapping |
HandlerAdapter | 调用处理器的适配器 | RequestMappingHandlerAdapter |
HandlerExceptionResolver | 异常 → 视图 / 状态码 | ExceptionHandlerExceptionResolver 等 |
ViewResolver | 视图名 → 具体视图 | ContentNegotiatingViewResolver |
LocaleResolver | 判断请求的区域 | AcceptHeaderLocaleResolver |
ThemeResolver | 主题解析 | FixedThemeResolver |
MultipartResolver | 文件上传解析 | StandardServletMultipartResolver |
RequestToViewNameTranslator | 无视图名时推导默认视图名 | DefaultRequestToViewNameTranslator |
FlashMapManager | 重定向间传参(flash 属性) | SessionFlashMapManager |
- 九个组件是「策略」:
DispatcherServlet只负责调度,把「怎么匹配」「怎么调用」「怎么渲染」都委托出去 - 其中
HandlerMapping与HandlerAdapter是主流程的主角,后面几节重点讲 - 想定制任何一环,只需在容器里注册自己实现的 Bean,
initStrategies()会优先取容器里的实现
整个分派的骨架就在 doDispatch 里。去掉细枝末节后,核心结构如下:

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(异常时),再决定是渲染视图还是写出响应体

上面那段是「读」的。读和会的差别就在有没有一步对上号,所以下面把它摊成调试台:左边八行就是 doDispatch 的骨架,右边同步刷新「此刻的变量」和「调用栈」。连点下一步,盯住 mappedHandler 与 dispatchException 这两个变量——第 ⑦ 格(异常只被暂存)是本篇最容易想岔的地方。
mappedHandler = getHandler(request); // ① 挂号:查表if (mappedHandler == null) { noHandlerFound(request, response); return; } // ② 404 的唯一产地HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // ③ 分诊if (!mappedHandler.applyPreHandle(request, response)) { return; } // ④ 拦截器前置mv = ha.handle(request, response, mappedHandler.getHandler()); // ⑤ 参数解析 + 调用 + 返回值mappedHandler.applyPostHandle(request, response, mv); // ⑥ 拦截器后置} catch (Exception ex) { dispatchException = ex; } // ⑦ 只暂存,不处理processDispatchResult(request, response, mappedHandler, mv, dispatchException); // ⑧ 统一出口| 线程名 | http-nio-8080-exec-3 |
| 请求 | GET /users/42 |
| mappingRegistry | 12 条已登记映射 |
DispatcherServlet.doDispatchgetHandlergetHandler 内部遍历所有 HandlerMapping,谁先返回非空就用谁。最常用的是 RequestMappingHandlerMapping,它继承自 AbstractHandlerMethodMapping,核心是一张「请求 → 方法」的注册表:
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 都归它管。
一个自然的疑问是:DispatcherServlet 已经拿到了「要调用的方法」,为什么还要多一层 HandlerAdapter?
因为「处理器」的实现方式可以很不一样:
@Controller的方法(HandlerMethod)需要参数解析 + 反射调用 + 返回值处理- 实现
HttpRequestHandler接口的 Bean 只需要调用handleRequest(request, response) - 一个普通的
Controller接口实现(老式 Spring MVC)又是另一种签名
DispatcherServlet 不可能为每种处理器写一套 if-else。适配器模式正是为此而生:DispatcherServlet 只说「我需要一个能跑这个 handler 的适配器」,由适配器去适配具体调用方式。
// 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 + "]");}// 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 无关。
getMethodArgumentValues 内部藏着一个「参数解析器库」。每个参数带什么注解,由对应的解析器负责从请求里取值并转换类型:
| 注解 | 解析器 | 数据来源 |
|---|---|---|
@RequestParam | RequestParamMethodArgumentResolver | query string / 表单参数 |
@PathVariable | PathVariableMethodArgumentResolver | URI 模板变量 {id} |
@RequestBody | RequestResponseBodyMethodProcessor | 请求体(经 HttpMessageConverter 反序列化) |
@RequestHeader | RequestHeaderMethodArgumentResolver | 请求头 |
@ModelAttribute | ModelAttributeMethodProcessor | 表单字段绑定到对象 |
@SessionAttribute | SessionAttributeMethodArgumentResolver | HttpSession 域 |
@RequestAttribute | RequestAttributeMethodArgumentResolver | request 域 |
无注解的 User | ServletModelAttributeMethodProcessor | 整体数据绑定 |
@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比较特殊:它不靠请求参数,而是把整个请求体交给消息转换器反序列化
上面那张表有九行,但表格里看不见的是顺序:解析器是一条链,每个参数都要从头问一遍。把它点着走一遍,尤其是第 ⑤ 格——它对应你项目里那句最难看懂的英文报错:
@RequestBody 与 @RequestParam 不能混用在同一个「读请求体」的场景上——请求体只能被读取一次。想同时拿表单字段和 JSON 体,本质上是不可能的,因为 request.getInputStream() 读一次就没了,这也是为什么 @RequestBody 后 @RequestParam 常拿不到表单值。
方法执行完,返回值还要被「翻译」成 HTTP 响应。这由一组 HandlerMethodReturnValueHandler + HttpMessageConverter 配合完成:
| 返回值类型 | 处理器 | 输出方式 |
|---|---|---|
带 @ResponseBody 的对象 | RequestResponseBodyMethodProcessor | 消息转换器序列化为 JSON |
String | ViewNameMethodReturnValueHandler | 当作视图名,交给 ViewResolver |
ModelAndView | ModelAndViewMethodReturnValueHandler | 视图 + 模型 |
ResponseEntity<T> | HttpEntityMethodProcessor | 状态码 + 响应头 + 体 |
Map / List(@RestController) | RequestResponseBodyMethodProcessor | JSON |
Callable / DeferredResult | 异步处理器 | 异步返回,MVC 异步支持 |
@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)。这个「字符串到底是视图名还是响应体」的分歧,曾让无数人对着一个白页发呆。
这一段有两个实验,都是「点一下就看得到分岔」的那种。先走出门方向:返回值怎么变回字节。四个参数依次点,accept 那格解释的是内容协商,fail 那格就是 406 的出生现场:
再回到那条「字符串两种命运」的坑。同一个 return user,只换类上的注解,走的是完全不同的出口:
doDispatch 里用 catch 把异常暂存,最终交给 processDispatchResult 处理。它内部会依次询问一组 HandlerExceptionResolver,谁先返回非空,谁就负责:
| 顺序 | 解析器 | 处理什么 |
|---|---|---|
| 1 | ExceptionHandlerExceptionResolver | @ExceptionHandler 标注的方法(最常用) |
| 2 | ResponseStatusExceptionResolver | 带 @ResponseStatus 的异常 |
| 3 | DefaultHandlerExceptionResolver | Spring MVC 内置异常(见第九节) |
@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
这条接力有六步,值得单独动一遍——尤其是第 ② 步「只暂存不处理」和第 ⑥ 步「没人接手才 500」,它们决定了你去哪一行找 bug:

processDispatchResult 不仅处理异常,也处理成功场景——它根据 ModelAndView 决定是「渲染视图」还是「直接写出响应体」。所以它是所有分支的统一出口,这也是 doDispatch 把异常先 catch 再统一处理的用意。
把错误和主流程对上号,排查时就不会乱。这件事背表格是背不住的——六个状态码,六个发生位置,来点一局:先点左边的状态码,再点你认为的那一站,配错当场告诉你差在哪。
404 与 405 的区别值得记住。404 是 getHandler 压根没找到匹配(URL 不对);405 是找到了映射、但 HTTP 方法不被允许——它由 RequestMappingInfo.matches 在匹配阶段判掉,最终由 DefaultHandlerExceptionResolver 转成 405。很多人把「POST 报 405」当成路径写错,其实是注解用了 @GetMapping。
@ResponseBody 返回中文乱码,是历史遗留问题。早期 StringHttpMessageConverter 默认用 ISO-8859-1 编码,中文自然变成乱码;如今 Spring Boot 已默认 UTF-8,但如果你手工 new 了一个 StringHttpMessageConverter 而没设编码,乱码会复现。排查乱码先看响应头的 Content-Type 里有没有 charset=UTF-8。
下面这个演示把 doDispatch 的主流程做成了可切换的路径。依次试试 /users/42、/users、/nope,观察每一步是哪个组件在工作、在什么时候返回 404:
先把术语放一边。想象你走进一家公立医院,什么流程都不懂,但你不会迷路——因为所有窗口都围着同一个服务台转。
浏览器发出的那个 HTTP 请求,就是「一位没挂过号的病人」。DispatcherServlet 是大厅正中的一站式服务台(全医院只有一个入口,这叫前端控制器模式);HandlerMapping 是挂号窗口,它手里有一张「症状 → 科室」的表,负责回答「这趟该找哪个医生」;HandlerAdapter 是分诊台护士,它不看病,只决定「这个医生用什么方式接诊——门诊、专家号还是急诊」;你的 @Controller 方法就是那位医生,真正下诊断的地方;最后 HttpMessageConverter 是检验报告打印机,把医生脑子里的结构化结论打成你能带走的纸质报告(JSON)。门口查健康码的是过滤器 Filter(比服务台还早,它不认识医生),走廊里跟着你的陪护是拦截器 Interceptor(只有挂上号的人才有陪护)。
这条链上每个角色只做一件事,所以你永远可以问一句话来定位问题:「我这个请求死在哪一站?」
那为什么要绕这一大圈?对比一下自己写原生 Servlet 的滋味:那等于你在医院里既当病人又当挂号员、分诊护士、医生和报告打印员——request.getRequestURI() 手工切字符串取 id、Long.valueOf 手工转换、if-else 分发动作、StringBuilder 手工拼 JSON、response.setContentType 手工设头、异常手工 try-catch。Spring MVC 做的事只有一件:把这六份工分别交给六个专职窗口。你写的就只剩「医生的诊断」那一句业务代码。


学完这一篇,你要能脱口而出地回答三个问题:
- 为什么 Spring MVC 只需要一个 Servlet 就能接管全站请求?(因为它映射在
/,是唯一入口) - 返回 404 和返回 405,分别说明「哪一站」出了问题?(挂号台 vs 分诊台)
- 为什么你在过滤器里拿不到「当前正在执行哪个 Controller 方法」?(那时
getHandler还没跑)
第十一节用 dispatch 走完了主干,但 doDispatch 里还有两条「挨个问一遍」的责任链最容易让人发懵:一条在进门时(谁来把我的方法参数填好),一条在出错时(谁来把我的异常变成响应)。这两条链都是同一个套路——按顺序问,谁先说「我能处理」谁就上,全部说不能就报错。
先看参数侧。ha.handle() 内部会对方法签名的每一个参数都跑一遍解析器链:
@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——这是新手最常撞见的一句英文
再看异常侧。processDispatchResult 里的链同样是一串 if:
// 简化自 DispatcherServlet#processHandlerExceptionfor (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) { ModelAndView exMv = resolver.resolveException(request, response, handler, ex); if (exMv != null) { break; // 第一个给出非 null 的就赢了,后面的根本不会被问 }}// 三个解析器都返回 null → 异常继续往外抛 → 容器兜底 → 500 页下面两个演示分别把这两条链做成了可切换的现场。建议按顺序把每个参数按钮都点一遍,尤其是最后一个「没有解析器接手」——它就是你在自己项目里看到的那句报错:
第九节讲了状态码落在哪一站,但排查线上问题时你还得能回答另一类问题:日志打了、TraceId 没传下去、跨域挂了、postHandle 没执行。这些都发生在「服务台」和「门口」之间的那几米走廊上。
先把两个概念用一句话说清:
- Filter(过滤器):Servlet 规范的东西,不属于 Spring。它在
DispatcherServlet之前运行,所以它连「这次请求要调哪个方法」都不知道 - Interceptor(拦截器):Spring MVC 的东西,注册在
HandlerExecutionChain里,跟着某一条处理器链走,能拿到HandlerMethod
@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 的那些) }}两者最容易被忽略的三条差异,配合动图记最省事:
| 能力 | Filter | Interceptor |
|---|---|---|
能不能看见 HandlerMethod(哪个类的哪个方法) | 不能 | 能(preHandle 的第三个参数) |
抛异常后 postHandle 还执行吗 | 不涉及 | 不执行,直接进异常解析器链 |
CORS 预检 OPTIONS 请求 | 必须在这里放行 | preHandle 容易把预检挡成 403 |

把下面这个演示的五个参数逐个点开,尤其看「preHandle 返回 false」「异常时还走 postHandle 吗」「跨域预检」这三格——它们对应三类真实工单:
实验点完可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 boot 建容器,再逐条把本篇的六个断言敲出来验一遍:
lab dispatch /nope 与 curl /api/kernel/beans 是两类动作——前者把分派过程一步步打给你看,后者真的走一遍虚拟 8080。新手最容易混淆的就是这两处:一个是解剖台,一个是接线板。
下表四列固定为「报错原文片段 / 真实原因 / 30 秒自救 / 深挖看第几篇」。报错原文请整段复制去搜索,不要意译——搜索引擎认全类名,不认你的描述。
| 报错原文(片段) | 真实原因 | 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 第六节 |
表格里那句「POST 报 405」是新手最容易被带偏的一行——它会让人去查 URL。下面这段真堆栈就是那道题的现场,先别看解析,点出你认为的凶手行:
本地页面点提交就 405。你盯着 @GetMapping("/users/{id}") 和浏览器的 /users/42 看了三遍,觉得地址完全一样。
静态资源被拦,是「加了 @EnableWebMvc」的经典副作用。 一旦写上 @EnableWebMvc,Spring Boot 默认的 MVC 配置(含静态资源映射、/webjars/** 资源处理器、消息转换器定制)会被整体关掉,页面瞬间只剩一张白 HTML。修复办法二选一:① 直接删掉 @EnableWebMvc,改用实现 WebMvcConfigurer 的方式做定制(Boot 推荐);② 保留它并在配置类上手工补回资源映射:
@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"); }}最后把本篇真正会用到的几行配置一次勾全。别去抄别人的 yml——每勾一项,都对应上面某一节的一个现象:勾 logging 出的是第十五节那条「看启动日志里的映射表」,勾 server 出的是 404 头号来源 context-path,勾 upload 对应第三节 doDispatch 第一行的 checkMultipart,勾 profile 让 dev 与 prod 用不同的前缀而不用改代码:
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 }
这个沙盘不做计算,做的是故障还原:每关掉一个组件,就模拟一次真实的线上形态。选一组组合,下面的输出会直接给你「现象 + 第一现场该怎么查」。
200 OKHandlerMapping ✓ HandlerAdapter ✓ 参数解析 ✓ 消息转换 ✓#基线:五站全通
目标:从零跑通「一个请求穿过九站」,并且亲眼看到四个不同状态码分别从哪一站出来。
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 }}启动类与验证命令(curl 逐条贴进终端即可):
# 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预期响应(第 1 条):
{ "id": 42, "name": "user-42"}第 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。
三个小改动,每个都只需改一两行,重点是观察报错原文的变化:
- 把类上的
@RequestMapping("/api/users")删掉,再跑第 1 条 curl → 你会观察到 404,且日志变成No mapping for GET /api/users/42:URL 是「类前缀 + 方法路径」拼出来的,缺一半都不算匹配 - 把
@RestController改成@Controller,其余不动 → 你会观察到javax.servlet.ServletException: Circular view path [get](或 404 on template),因为返回值UserVO不再被序列化,而是被当成视图信息处理。这就是第七节说的「String/对象的两种命运」 - 把
@GetMapping("/{id}")改成@GetMapping("/{id:\\d+}"),然后跑第 5 条 curl → 你会观察到状态码从 400 变成 404:正则约束在挂号阶段就把/abc挡在门外,压根轮不到类型转换。用「让它更早失败」换「让它更干净」,这是设计取舍而非语法技巧
做一个「请求旅程记录仪」:给任意接口都能打出它穿过了哪几站、各花多少毫秒。
要求:
- 一个
TraceIdFilter(继承OncePerRequestFilter),生成 traceId 放进 MDC,并在finally里打印总耗时 - 一个
JourneyInterceptor(实现HandlerInterceptor),preHandle记录进入时间并把HandlerMethod的「类名#方法名」存进 request 属性 - 一个
@RestControllerAdvice,把MethodArgumentTypeMismatchException统一转成{"code":40000,...}且 HTTP 仍为 400
验收清单:
- [ ]
curl -i localhost:8080/api/users/42的响应里能看到X-Trace-Id头(证明过滤器活着) - [ ] 日志同时包含「命中的控制器方法名」(证明拦截器拿到了
HandlerMethod,过滤器做不到) - [ ] 打
/api/users/abc返回 400 且 body 是你的统一错误结构,不是 Whitelabel 页 - [ ] 把拦截器的
preHandle改成return false,重跑同一命令,确认响应变成「空 body + 200」——亲手复现第十六节第一题的坑 - [ ] 关掉某个过滤器(注释掉
@Component),确认耗时日志消失但接口照常,理解「外圈可摘、内圈不可摘」
不看上文,能否按顺序说出一次请求经过的九站,并指出 404 / 405 / 406 / 500 各死在哪一站?(404 在挂号台,405 在分诊条件,406 在取报告,参数没人认领的 500 在见医生之前)
能否解释「为什么 DispatcherServlet 不认识 @Controller」?(它只消费 HandlerMapping 给的 HandlerMethod 和 HandlerAdapter;扫描注解的是 RequestMappingHandlerMapping,两件事发生在不同组件里)
postHandle 和 afterCompletion 的区别是什么,为什么埋点代码要写在后者?(控制器抛异常时 postHandle 被跳过,afterCompletion 一定执行——清理 ThreadLocal/MDC 只能放这里)
@RequestBody 之后为什么常拿不到 @RequestParam 的表单值?(请求体是一次性的字节流,getInputStream() 读到底就没了)
服务台只有一个入口(/),挂号定「找谁」、分诊定「怎么跑」、医生定业务、打印机定格式;404 是没挂上号,405 是科室对但动作错,406 是报告打不出来。
一次请求的完整旅程是 Tomcat → DispatcherServlet → getHandler(HandlerMapping 查表)→ getHandlerAdapter(适配器)→ applyPreHandle(拦截器前置)→ ha.handle(参数解析 + 反射调用 + 返回值处理)→ applyPostHandle(拦截器后置)→ processDispatchResult(异常解析 / 视图渲染 / 响应写出)。记住四个关键认知:DispatcherServlet 不认识 @Controller,它只认识 HandlerMethod 和 HandlerAdapter;HandlerMapping 的「请求→方法」注册表在启动时就建好了,请求时只是查表;404 是没匹配到 Handler,405 是路径匹配但方法不符,415 是消息转换器不支持请求体类型;拦截器要中断并返回标准错误时,抛异常优于返回 false。