SPRING MVC · DISPATCHERSERVLET · DODISPATCH

一个请求,后厨怎么出餐

Spring MVC 像一间后厨。所有请求都先交到前台 DispatcherServlet 手上,它自己不做菜,只负责找对厨师、让人备好料、装好盘,再端出去。

前台调度DispatcherServlet

每个请求都先到它这儿。它只安排,不做菜。

一个普通的 Servlet,路径是 /
排班表HandlerMapping

查这个地址归哪个方法管,顺便带上要过的拦截器。

启动时就扫好了
帮厨HandlerAdapter

下锅前备料(解析参数),出锅后装盘(处理返回值)。

不同的厨师,配不同的帮厨
厨师Controller 方法

你写的那个方法。拿到现成的参数,只管做菜。

正式名字叫 handler
配角 打包 HttpMessageConverter,对象和 JSON 互转 摆盘 ViewResolver + View,视图名变成 HTML 善后 HandlerExceptionResolver,出错了找它 拦截器 进门、出锅、收尾各拦一次,第 01 篇

开门营业之前

排班表在应用启动时就排好了;DispatcherServlet 自己,要等第一个请求来了才初始化。

先问RequestMappingHandlerMapping你写的 @RequestMapping,启动时扫一遍全记下
GET /orders/{id}→OrderApi#get
POST /orders→OrderApi#create
GET /orders→OrderPage#list
……函数式路由、按 Bean 名字找的,等等
最后问SimpleUrlHandlerMapping静态资源,Boot 默认配好
/**→ResourceHttpRequestHandler

按顺序问,第一个查到的就用。同一张表里多条都能匹配时,挑最具体的:/orders/7 优先给 /orders/{id},轮不到 /orders/**。最后那张表管 /**,什么都接,所以在 Boot 里,没人认领的地址最后都落到静态资源处理器手上。

第一个请求来了,才初始化

Tomcat started on port 8080 (http)
Started ShopApplication in 2.3 seconds
—— 过了一会儿,第一个请求来了 ——
Initializing Spring DispatcherServlet 'dispatcherServlet'
Initializing Servlet 'dispatcherServlet'
Completed initialization in 1 ms

初始化就是从 Spring 容器里把下面九个组件取出来备用(initStrategies)。想启动时就做好,配 spring.mvc.servlet.load-on-startup=1。

九大组件

MultipartResolver文件上传
LocaleResolver当前语言
ThemeResolver主题,6.0 起过时
HandlerMapping排班表
HandlerAdapter帮厨
HandlerExceptionResolver善后
RequestToViewNameTranslator没给视图名,就按 URL 猜一个
ViewResolver视图名 → 页面
FlashMapManager重定向时捎句话

主线:doDispatch 八步

每个请求都走 DispatcherServlet.doDispatch() 这一个方法。看懂它,Spring MVC 就懂了一大半。

找人谁来做
1文件上传就包一层checkMultipart
2查排班表:厨师 + 拦截器getHandler → HandlerExecutionChain
3找帮厨getHandlerAdapter
进门拦截器
4preHandle,正序。有一个 false 就收工applyPreHandle
做菜帮厨 + 厨师
5备料 → 下厨 → 装盘ha.handle(request, response, handler)
出锅拦截器
6postHandle,倒序。出了异常就跳过applyPostHandle
收尾结果和善后
7有异常找善后,有视图就渲染processDispatchResult
8afterCompletion,倒序。怎样都会执行triggerAfterCompletion
源码简化版(去掉了异步、缓存和清理)
protected void doDispatch(HttpServletRequest req, HttpServletResponse resp) throws Exception {
    HandlerExecutionChain chain = null;
    ModelAndView mv = null;
    Exception ex = null;
    try {
        try {
            req = checkMultipart(req);                                // ① 文件上传就包一层
            chain = getHandler(req);                                  // ② 查排班表:handler + 拦截器
            if (chain == null) { noHandlerFound(req, resp); return; }
            HandlerAdapter ha = getHandlerAdapter(chain.getHandler());  // ③ 找帮厨
            if (!chain.applyPreHandle(req, resp)) return;             // ④ preHandle,有 false 就收工
            mv = ha.handle(req, resp, chain.getHandler());            // ⑤ 备料 → 下厨 → 装盘
            applyDefaultViewName(req, mv);
            chain.applyPostHandle(req, resp, mv);                     // ⑥ postHandle,倒序
        } catch (Exception e) {
            ex = e;                                                   // 出了错先记下,不马上抛
        }
        processDispatchResult(req, resp, chain, mv, ex);              // ⑦ 善后或渲染,最后 ⑧ afterCompletion
    } catch (Exception e) {
        triggerAfterCompletion(req, resp, chain, e);                  // 善后也没接住:照样 afterCompletion
        throw e;                                                      // 抛给 Tomcat,转发到 /error
    }
}

跟着一个请求走一遍

选一个剧情,一步一步看:走到了哪一步,执行链上的拦截器调到了哪个方法,doDispatch 手里拿着什么,最后返回了什么。

GET /orders/7
  1. 1接单doDispatch
  2. 2查排班表getHandler
  3. 3找帮厨getHandlerAdapter
  4. 4拦截器 · 进门preHandle,正序
  5. ha.handle(…) 里面,帮厨的活
  6. 5a备料参数解析器
  7. 5b下厨调用 handler
  8. 5c装盘返回值处理器
  9. 6拦截器 · 出锅postHandle,倒序
  10. 7善后 / 渲染processDispatchResult
  11. 8拦截器 · 收尾afterCompletion,倒序
剧情里注册了两个拦截器:
registry.addInterceptor(new LogInterceptor());                                  // 所有路径
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/orders/**");

执行链 · HandlerExecutionChain

doDispatch 手里的两个变量

返回给浏览器

1/10
doDispatch

备料:参数是从哪儿来的

帮厨手下有二十多个参数解析器,挨个问“这个参数你管吗”,第一个说管的负责。点右边的参数,看它从请求的哪一块取值。

浏览器发来的请求
POST /orders/7/items?coupon=NEW10 HTTP/1.1
Host: shop.example.com
Content-Type: application/json
X-Request-Id: a1b2c3
Cookie: JSESSIONID=9F2C…; lang=zh

{"sku":"A-1001","qty":2}
@PostMapping("/orders/{orderId}/items")
public ItemVO addItem(
)
解析器
从哪来
得到
要注意

装盘:端成页面,还是打包成 JSON

厨师做完,帮厨看方法上有没有 @ResponseBody,决定走哪条路。

端成页面

@Controller,返回视图名
@Controller
public class OrderPage {
    @GetMapping("/orders")
    public String list(Model model) {
        model.addAttribute("orders", orderService.list());
        return "orders/list";           // 视图名
    }
}
返回 "orders/list",装进 ModelAndView ViewResolver 找模板classpath:/templates/orders/list.html View 用 model 填好页面Thymeleaf、FreeMarker、JSP…… <html> 订单列表 … </html>

打包成 JSON

@RestController,返回对象
@RestController                // = @Controller + @ResponseBody
public class OrderApi {
    @GetMapping("/orders/{id}")
    public OrderVO get(@PathVariable Long id) {
        return orderService.get(id);    // 对象
    }
}
返回 OrderVO 对象 @ResponseBody 的返回值处理器接手RequestResponseBodyMethodProcessor 按 Accept 挑一个转换器MappingJackson2HttpMessageConverter {"id":7,"status":"PAID"}
返回值处理器也是一排

ModelAndView、视图名、@ResponseBody、ResponseEntity……挨个问“这种返回值你管吗”。

ResponseEntity

和 @ResponseBody 一样直接写响应,还能顺手定状态码和响应头。

想要 XML

请求带 Accept: application/xml:项目里有 jackson-dataformat-xml 就给 XML,没有就 406。

善后:出了错交给谁

doDispatch 把异常交给一排 HandlerExceptionResolver,按顺序问,谁处理了就算完。拦截器、参数解析、Controller 里抛的都算,过滤器里的不算。

1ExceptionHandlerExceptionResolver

找 @ExceptionHandler:先找 Controller 自己类里的,再找 @ControllerAdvice 里的。

2ResponseStatusExceptionResolver

异常类上有 @ResponseStatus,或者抛的是 ResponseStatusException。

3DefaultHandlerExceptionResolver

Spring MVC 自己的标准异常,翻成对应的 4xx,见下表。

✕谁都不认识

抛给 Tomcat,转发到 /error。Boot 的 BasicErrorController 返回 500:浏览器看到 Whitelabel 页面,接口拿到一段 JSON。

异常状态码常见原因
HttpRequestMethodNotSupportedException405GET 打到了 @PostMapping
HttpMediaTypeNotSupportedException415@RequestBody 收到的是表单格式
HttpMediaTypeNotAcceptableException406Accept 要的格式给不了
MissingServletRequestParameterException400必填的 @RequestParam 没传
MethodArgumentTypeMismatchException400/orders/abc,abc 转不成 Long
HttpMessageNotReadableException400请求体不是合法 JSON
MethodArgumentNotValidException400@Valid 校验没过
NoHandlerFoundException / NoResourceFoundException404没有这个地址
最常用的写法:全局善后
@RestControllerAdvice
public class GlobalErrors {

    @ExceptionHandler(OrderNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)          // 不写,状态码就是 200
    public ErrorBody notFound(OrderNotFoundException e) {
        return new ErrorBody("ORDER_NOT_FOUND", e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorBody invalid(MethodArgumentNotValidException e) {
        return new ErrorBody("INVALID", e.getBindingResult().getFieldErrors().toString());
    }
}
  • 拦截器的 preHandle 也在 doDispatch 的 try 里面,它抛的异常 @ControllerAdvice 接得到。
  • 过滤器在 DispatcherServlet 外面,它抛的接不到。第 01 篇、第 05 篇都碰到过这个问题。
  • 标准异常(上表)走第 3 位时,用的是 response.sendError(…),最后由 /error 写出响应体。想统一格式,就在 @ControllerAdvice 里把它们也接住。

JDK 8 和 JDK 17:主线没变,细节变了

Boot 2 配 Spring 5,Boot 3 配 Spring 6。doDispatch 八步完全一样,变的是路径匹配和错误处理的默认值。

Spring 5 · Boot 2

JDK 8+
  • javax.servlet 包
  • 路径匹配默认用 AntPathMatcher;Boot 2.6 起默认换成 PathPatternParser,老版 Springfox 因此启动失败
  • /orders/ 也能匹配到 /orders
  • 找不到的地址,静态资源处理器直接 sendError(404),@ExceptionHandler 接不到
  • 错误响应的 JSON 长什么样,各家自己定

Spring 6 · Boot 3

JDK 17+
  • jakarta.servlet 包
  • 默认 PathPatternParser:** 只能写在末尾,/**/*.html 这种写法启动就报错
  • 结尾斜杠不再自动匹配:/orders/ 会 404
  • 6.1 起找不到的地址抛 NoResourceFoundException,能用 @ExceptionHandler 接
  • 内置标准错误格式 ProblemDetail(application/problem+json),配 spring.mvc.problemdetails.enabled=true 打开
  • 6.1 起参数名只认编译参数 -parameters
没变的:doDispatch 八步九大组件拦截器三个方法参数解析器、返回值处理器三个异常解析器

常踩的坑

先判断卡在了哪一步:查排班表、备料、下厨,还是装盘。

@Controller 返回字符串,结果 404、模板找不到或 Circular view path

没有 @ResponseBody,字符串被当成视图名去找页面了。写接口用 @RestController。

415 Unsupported Media Type

@RequestBody 只读 JSON 这类请求体,前端发的却是表单。前端改发 JSON;或者去掉 @RequestBody,用对象直接接表单字段。

接口返回 400,Controller 里的断点根本没进

备料就失败了:缺参数、类型转不过去、JSON 写错。日志里有一行 WARN:Resolved [org.springframework.web…],原因写在里面。

405 Method Not Allowed

地址对了,请求方法不对:GET 打到了 @PostMapping。

@ExceptionHandler 返回了错误信息,状态码却是 200

没写 @ResponseStatus,也没返回 ResponseEntity,状态码就保持默认的 200。

postHandle 里加的响应头,前端收不到

@ResponseBody 的响应在装盘时就写出去了。改用 ResponseBodyAdvice,或者在 preHandle 里加。

404 用 @ExceptionHandler 接不住

没人认领的地址被静态资源处理器(/**)接走了。Boot 2:配 spring.mvc.throw-exception-if-no-handler-found=true 并关掉 spring.web.resources.add-mappings;Boot 3.2+:接 NoResourceFoundException。

升级 Boot 3 后,结尾带斜杠的地址 404

Spring 6 不再把 /orders/ 当成 /orders。前端去掉斜杠,或者两个地址都写上。

升级后报 parameter name information not available via reflection

Spring 6.1 起,参数名只靠编译参数 -parameters。用 Boot 的 Maven / Gradle 插件会自动加;否则把名字写全:@PathVariable("id")。

第一个请求特别慢

DispatcherServlet 第一次有请求才初始化。配 spring.mvc.servlet.load-on-startup=1,启动时就做好。