主题
SpringMVC 请求全链路:DispatcherServlet 到响应
本文是 Spring 系统学习系列的 L2 核心篇。前置:30. bean-lifecycle-extension-points。 学完可以配合面试题食用:12. DispatcherServlet 处理请求、13. 拦截器 Filter AOP、14. 全局异常处理
从 Servlet 说起:为什么需要 DispatcherServlet
写原生 Servlet 的年代,一个 URL 对应一个 Servlet 类,参数解析、类型转换、视图跳转全靠手写。SpringMVC 的做法是把这套流程抽成一个"总入口":所有请求先进 DispatcherServlet,由它统一分发给对应的处理器。你写的 @RestController 只负责业务本身,路由、参数绑定、返回值转换这些杂活都在入口层解决了。
继承链值得看一眼:
text
HttpServlet
└─ FrameworkServlet // 把 service() 收敛到 doService()
└─ DispatcherServlet // doService() -> doDispatch(),核心分发逻辑DispatcherServlet 本身还是一个 Servlet,没有魔法。它做的事可以类比公司前台:访客(请求)先到前台,前台查通讯录(HandlerMapping)找到对应员工(Handler),把资料递过去,员工处理完的结果再由前台按对方习惯的格式(JSON/页面)回复。
doDispatch 九步流程
doDispatch() 是整个框架的心脏,一次请求大致走这几步:
DispatcherServlet接到请求,先调getHandler()遍历所有HandlerMappingHandlerMapping按 URL + 请求方法匹配到HandlerExecutionChain(处理器 + 一组拦截器)- 依次执行拦截器的
preHandle(),任一个返回 false 就走异步或直接返回 HandlerAdapter包装 Handler 并调用——你的 Controller 方法在这里被执行- 返回值交给
HandlerMethodReturnValueHandler处理 @ResponseBody场景下走HttpMessageConverter把对象写成 JSON- 拦截器
postHandle() - 渲染视图或直接写响应
- 拦截器
afterCompletion(),finally 里执行,异常也会走
mermaid
sequenceDiagram
participant C as Client
participant D as DispatcherServlet
participant H as HandlerMapping
participant I as 拦截器链
participant A as HandlerAdapter
participant M as HttpMessageConverter
C->>D: HTTP 请求
D->>H: getHandler()
H-->>D: HandlerExecutionChain
D->>I: preHandle()
I-->>D: true
D->>A: handle()
A->>A: 反射调用 Controller 方法
A-->>D: 返回值 + ModelAndView
D->>M: writeWithMessageConverters()
M-->>C: JSON 响应
D->>I: postHandle() / afterCompletion()有一个细节容易被忽略:HandlerMapping 是列表遍历,不是字典精确查找。常见的 RequestMappingHandlerMapping 只是其中一项,前面还有 BeanNameUrlHandlerMapping 等更老的映射方式。这解释了为什么老项目里一个 URL 不会被两套映射同时接住——先匹配先得,匹配到就短路返回。
@RestController 的返回值是怎么变 JSON 的
Controller 方法返回一个 POJO,浏览器收到的是 JSON 字符串,中间的转换发生在 HandlerAdapter 内部。RequestMappingHandlerAdapter 调用完方法后,按返回值类型选一个 HandlerMethodReturnValueHandler;方法标了 @ResponseBody(@RestController 等价于类级 @ResponseBody),就走 RequestResponseBodyMethodProcessor。
这个 Processor 做的事叫内容协商:先看请求头 Accept,再看响应默认的 Content-Type,从 HttpMessageConverter 列表里挑出第一个既能写 Java 对象又能产出版本兼容 MIME 类型的转换器。classpath 里有 Jackson 就是 MappingJackson2HttpMessageConverter,有 Fastjson 就换对应的。所以"返回 JSON"不是 Spring 的硬编码,是 classpath 里恰好有 Jackson 这个默认选项。想在同一个接口上支持 Accept: application/xml,加一个 jackson-dataformat-xml 依赖就行,代码一行不改。
拦截器 vs Filter vs AOP
三个都能在请求前后插逻辑,选型看切面位置:
text
请求 -> Filter -> DispatcherServlet -> 拿到 Handler 前后(拦截器) -> Controller 方法调用前后(AOP)- Filter 是 Servlet 规范,在
DispatcherServlet之前。拿不到 Handler 是哪个方法,但能改请求/响应流。字符编码、CORS、XSS 过滤适合放这。 - 拦截器 是 SpringMVC 概念,知道目标 Handler,能拿到 HandlerMethod 上的注解。登录校验、接口权限、打点耗时适合放这。
- AOP 作用在 Bean 方法级,不限于 Controller。事务、日志、监控埋点适合放这。
顺序上,preHandle 先注册先执行,postHandle/afterCompletion 反过来。和 Filter 的关系是:FilterChain 包在最外层,preHandle 在所有 Filter 之后。异常场景下 postHandle 会被跳过(抛异常时后面的逻辑不执行),afterCompletion 一定执行,类似 finally。
全局异常:@ControllerAdvice 的匹配逻辑
Controller 抛出异常后,doDispatch 捕获并委托 HandlerExceptionResolver 链处理。ExceptionHandlerExceptionResolver 会先在当前 Controller 里找 @ExceptionHandler,找不到再去所有 @ControllerAdvice Bean 里扫。匹配规则按异常类型的最短继承距离选——你同时声明了 Exception 和 IllegalStateException 的 handler,抛 IllegalStateException 时走后者,因为继承距离更近。
这个设计让"业务异常转 HTTP 状态码"成为一行注解的事:
java
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User detail(@PathVariable Long id) {
if (id <= 0) {
throw new IllegalArgumentException("id 必须为正数");
}
return userService.findById(id); // 找不到会抛 NotFoundException
}
}
// 全局兜底,任意 Controller 抛的异常都能接住
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResp> badArgs(IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(new ErrorResp(400, e.getMessage()));
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResp> notFound(NotFoundException e) {
return ResponseEntity.status(404)
.body(new ErrorResp(404, e.getMessage()));
}
}注意 @RestControllerAdvice 组合注解等价于 @ControllerAdvice + @ResponseBody,返回的对象同样走 HttpMessageConverter 转 JSON,不用手动拼字符串。
异步请求:DeferredResult 的价值
Servlet 3.0 之前,一个请求占死一条 Tomcat 工作线程直到响应完成。如果 Controller 里要调一个 3 秒的下游接口,这条线程就干等 3 秒。Tomcat 默认 200 条工作线程,并发一上来就线程耗尽。
DeferredResult 的思路:Controller 立刻返回一个空的 DeferredResult,Tomcat 线程马上释放回池子;等下游结果就绪(比如消息队列回调、另一个线程 setResult()),再由 SpringMVC 派发一次"完成事件",重新走一遍 doDispatch 完成响应。
java
@GetMapping("/async")
public DeferredResult<Result<User>> asyncQuery(@RequestParam Long id) {
DeferredResult<Result<User>> dr = new DeferredResult<>(5000L); // 5s 超时
// 交给业务线程池去干等,容器线程立刻归还
bizExecutor.execute(() -> {
try {
dr.setResult(Result.ok(userService.slowFind(id))); // 触发响应
} catch (Exception e) {
dr.setErrorResult(Result.fail(e.getMessage()));
}
});
return dr;
}适用场景是下游慢但连接要保住:支付结果轮询、第三方回调前的占位响应。如果业务本身是 IO 密集且量大,更彻底的方案是 WebFlux 的非阻塞栈,那是另一个话题。
动手实操
把上面几块串成一个可运行的完整例子。建一个 Spring Boot 项目(2.7+ 均可),三个文件:
java
// 1. 拦截器:验证 header 里的 token,并记录请求耗时
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true; // 静态资源等非方法型 Handler 直接放行
}
String token = req.getHeader("X-Token");
if (token == null || token.isBlank()) {
resp.setStatus(401);
resp.getWriter().write("missing token");
return false; // 短路,Controller 不执行
}
req.setAttribute("startTime", System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object handler, Exception ex) {
Long start = (Long) req.getAttribute("startTime");
if (start != null) {
System.out.printf("%s cost %dms, exception=%s%n",
req.getRequestURI(), System.currentTimeMillis() - start, ex);
}
}
}
// 2. 配置拦截器 + 全局异常
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/api/**")
.order(1); // 多个拦截器时控制顺序
}
}
// 3. Controller + 全局异常处理
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public Map<String, Object> detail(@PathVariable Long id,
@RequestHeader("X-Token") String token) {
if (id <= 0) {
throw new IllegalArgumentException("id 必须为正数");
}
return Map.of("id", id, "status", "PAID", "token", token);
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public Map<String, Object> badArgs(IllegalArgumentException e) {
return Map.of("code", 400, "msg", e.getMessage());
}
}断点建议打这四个位置,用 curl 带上 X-Token 请求 /api/orders/1,观察执行顺序:
AuthInterceptor.preHandle()—— 最早的业务逻辑切入点RequestMappingHandlerAdapter.invokeHandlerMethod()—— 反射调用 Controller 前一刻RequestResponseBodyMethodProcessor.writeWithMessageConverters()—— Map 转 JSON 的现场GlobalExceptionHandler.badArgs()—— 把 id 改成 0 再请求一次,看异常如何被接住
常见误区与小结
- 拦截器
preHandle返回 false 后,postHandle不会执行,但已执行过的拦截器的afterCompletion会补调用,别在这两个方法里做对称的假设 @ControllerAdvice不是万能兜底:Filter 抛的异常它接不住,因为那时还没进DispatcherServlet;想接住要在 Filter 层自己 try-catchDeferredResult释放的是 Tomcat 容器线程,业务线程还得自己出;把慢调用扔到bizExecutor只是换了地方等待,吞吐改善来自容器线程不被长占- 内容协商失败(客户端
Accept一个不支持的类型)会抛HttpMediaTypeNotAcceptableException返回 406,别和 404 混为一谈 - Filter 的顺序由
@Order或注册顺序决定,字符编码 Filter 必须排在写响应的 Filter 前面,否则 setContentType 无效
小结:SpringMVC 的核心是一个 Servlet 入口加一条职责清晰的分发链。HandlerMapping 定位方法,HandlerAdapter 屏蔽处理器差异,ReturnValueHandler + MessageConverter 完成输出,拦截器和 ControllerAdvice 提供横向切面。理解了这条链,排查"参数没绑定上""响应 406""异常没被全局处理"这类问题就能直接定位到链上的具体环节。下一篇讲自动装配:@EnableAutoConfiguration 背后的加载链和条件评估。
参考
参考:Spring Framework 官方文档 Web MVC 章节(DispatcherServlet request processing workflow);《Spring 技术内幕》第 2 版 计文柯;Spring 源码
org.springframework.web.servlet.DispatcherServlet#doDispatch