SPRING MVC · DISPATCHERSERVLET · DODISPATCH
一个请求,后厨怎么出餐
Spring MVC 像一间后厨。所有请求都先交到前台 DispatcherServlet 手上,它自己不做菜,只负责找对厨师、让人备好料、装好盘,再端出去。
DispatcherServlet每个请求都先到它这儿。它只安排,不做菜。
一个普通的 Servlet,路径是 /HandlerMapping查这个地址归哪个方法管,顺便带上要过的拦截器。
启动时就扫好了HandlerAdapter下锅前备料(解析参数),出锅后装盘(处理返回值)。
不同的厨师,配不同的帮厨Controller 方法你写的那个方法。拿到现成的参数,只管做菜。
正式名字叫 handler开门营业之前
排班表在应用启动时就排好了;DispatcherServlet 自己,要等第一个请求来了才初始化。
GET /orders/{id}→OrderApi#getPOST /orders→OrderApi#createGET /orders→OrderPage#list/**→ResourceHttpRequestHandler按顺序问,第一个查到的就用。同一张表里多条都能匹配时,挑最具体的:/orders/7 优先给 /orders/{id},轮不到 /orders/**。最后那张表管 /**,什么都接,所以在 Boot 里,没人认领的地址最后都落到静态资源处理器手上。
第一个请求来了,才初始化
初始化就是从 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 就懂了一大半。
checkMultipartgetHandler → HandlerExecutionChaingetHandlerAdapterapplyPreHandleha.handle(request, response, handler)applyPostHandleprocessDispatchResulttriggerAfterCompletionprotected 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 手里拿着什么,最后返回了什么。
- 1接单
doDispatch - 2查排班表
getHandler - 3找帮厨
getHandlerAdapter - 4拦截器 · 进门
preHandle,正序 - ha.handle(…) 里面,帮厨的活
- 5a备料
参数解析器 - 5b下厨
调用 handler - 5c装盘
返回值处理器 - 6拦截器 · 出锅
postHandle,倒序 - 7善后 / 渲染
processDispatchResult - 8拦截器 · 收尾
afterCompletion,倒序
registry.addInterceptor(new LogInterceptor()); // 所有路径
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/orders/**");执行链 · HandlerExecutionChain
doDispatch 手里的两个变量
返回给浏览器
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}
- 解析器
- 从哪来
- 得到
- 要注意
装盘:端成页面,还是打包成 JSON
厨师做完,帮厨看方法上有没有 @ResponseBody,决定走哪条路。
端成页面
@Controller,返回视图名@Controller
public class OrderPage {
@GetMapping("/orders")
public String list(Model model) {
model.addAttribute("orders", orderService.list());
return "orders/list"; // 视图名
}
}
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); // 对象
}
}
RequestResponseBodyMethodProcessor
按 Accept 挑一个转换器MappingJackson2HttpMessageConverter
{"id":7,"status":"PAID"}
ModelAndView、视图名、@ResponseBody、ResponseEntity……挨个问“这种返回值你管吗”。
和 @ResponseBody 一样直接写响应,还能顺手定状态码和响应头。
请求带 Accept: application/xml:项目里有 jackson-dataformat-xml 就给 XML,没有就 406。
善后:出了错交给谁
doDispatch 把异常交给一排 HandlerExceptionResolver,按顺序问,谁处理了就算完。拦截器、参数解析、Controller 里抛的都算,过滤器里的不算。
找 @ExceptionHandler:先找 Controller 自己类里的,再找 @ControllerAdvice 里的。
异常类上有 @ResponseStatus,或者抛的是 ResponseStatusException。
Spring MVC 自己的标准异常,翻成对应的 4xx,见下表。
抛给 Tomcat,转发到 /error。Boot 的 BasicErrorController 返回 500:浏览器看到 Whitelabel 页面,接口拿到一段 JSON。
| 异常 | 状态码 | 常见原因 |
|---|---|---|
HttpRequestMethodNotSupportedException | 405 | GET 打到了 @PostMapping |
HttpMediaTypeNotSupportedException | 415 | @RequestBody 收到的是表单格式 |
HttpMediaTypeNotAcceptableException | 406 | Accept 要的格式给不了 |
MissingServletRequestParameterException | 400 | 必填的 @RequestParam 没传 |
MethodArgumentTypeMismatchException | 400 | /orders/abc,abc 转不成 Long |
HttpMessageNotReadableException | 400 | 请求体不是合法 JSON |
MethodArgumentNotValidException | 400 | @Valid 校验没过 |
NoHandlerFoundException / NoResourceFoundException | 404 | 没有这个地址 |
@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());
}
}
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
常踩的坑
先判断卡在了哪一步:查排班表、备料、下厨,还是装盘。
没有 @ResponseBody,字符串被当成视图名去找页面了。写接口用 @RestController。
@RequestBody 只读 JSON 这类请求体,前端发的却是表单。前端改发 JSON;或者去掉 @RequestBody,用对象直接接表单字段。
备料就失败了:缺参数、类型转不过去、JSON 写错。日志里有一行 WARN:Resolved [org.springframework.web…],原因写在里面。
地址对了,请求方法不对:GET 打到了 @PostMapping。
没写 @ResponseStatus,也没返回 ResponseEntity,状态码就保持默认的 200。
@ResponseBody 的响应在装盘时就写出去了。改用 ResponseBodyAdvice,或者在 preHandle 里加。
没人认领的地址被静态资源处理器(/**)接走了。Boot 2:配 spring.mvc.throw-exception-if-no-handler-found=true 并关掉 spring.web.resources.add-mappings;Boot 3.2+:接 NoResourceFoundException。
Spring 6 不再把 /orders/ 当成 /orders。前端去掉斜杠,或者两个地址都写上。
Spring 6.1 起,参数名只靠编译参数 -parameters。用 Boot 的 Maven / Gradle 插件会自动加;否则把名字写全:@PathVariable("id")。
DispatcherServlet 第一次有请求才初始化。配 spring.mvc.servlet.load-on-startup=1,启动时就做好。