写一个 @RequestMapping("/order/list"),浏览器就能访问到那个方法。可为什么有时 404、有时 405、有时 400,@RestController 又凭什么不用返回视图名——路径说不清,就只能加日志一遍遍试。
这篇按请求经过的顺序走:容器 → Filter → DispatcherServlet → HandlerMapping → 参数绑定 → 调用 → 返回值处理。
1. 第一站不是 DispatcherServlet
请求先进 Tomcat,按容器组好的 Filter 链一路下去,最后一环才是 DispatcherServlet。
| 关卡 | 归属 | 拿得到 | 拿不到 |
|---|---|---|---|
| Filter | Servlet 规范 | 原始 body 流、响应头 | 哪个 Controller 会处理 |
| Interceptor | Spring MVC | 已选中的 HandlerMethod |
未包装的原始流 |
| ArgumentResolver | Spring MVC | 方法签名、注解、类型 | filter 的包装顺序 |
判断标准是时机:跨域、编码、请求体缓存、token 解析这类与业务方法无关的事放 Filter;"这个人能不能调这个接口"需要知道目标方法,放 Interceptor。
public class TraceFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws Exception {
long t0 = System.nanoTime();
try { chain.doFilter(req, resp); } // 最终到达 DispatcherServlet
finally { log.info("{} {} {}ms", req.getMethod(), req.getRequestURI(),
(System.nanoTime() - t0) / 1_000_000); }
}
}
OncePerRequestFilter 保证一次请求只跑一遍(内部转发不重复),所以比裸 Filter 常用。
2. DispatcherServlet 的六步
doDispatch() 的主干拆成六步,每步失败对应一种状态码:
| 步 | 动作 | 组件 | 失败时 |
|---|---|---|---|
| 1 | 找 handler | HandlerMapping |
404 |
| 2 | 组拦截器链 | HandlerExecutionChain |
preHandle 返回 false 即终止 |
| 3 | 参数绑定 | HandlerAdapter |
400 |
| 4 | 调用方法 | 反射 | 抛异常 → 异常解析链 |
| 5 | 处理返回值 | ReturnValueHandler |
— |
| 6 | 写响应 | HttpMessageConverter / ViewResolver |
406 / 500 |
@GetMapping("/orders/{id}")
public OrderVO detail(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean withItems) {
return orderService.detail(id, withItems);
}
第 3 步实际要做:按名取路径变量、String 转 Long、缺参时补默认值。转换由 ConversionService 完成,失败抛 MethodArgumentTypeMismatchException,最终映射成 400。
3. 参数的三种来源
| 来源 | 注解 | 典型位置 | 缺省行为 |
|---|---|---|---|
| 路径 | @PathVariable |
/orders/{id} |
路径不匹配即 404 |
| 查询串 | @RequestParam |
?page=1 |
默认必填,缺失 400 |
| 表单 / JSON | @ModelAttribute / @RequestBody |
body | 表单按字段名绑;JSON 走转换器 |
@RequestParam 默认必填,这是漏传 ?page= 报 400 的头号原因。两种修法不等价:
@RequestParam(required = false) Integer page // 允许缺失,值为 null
@RequestParam(defaultValue = "1") int page // 允许缺失,值为 1
原始类型配 required=false 会在缺失时炸在拆箱上,所以 int 一律配 defaultValue。
4. @RequestBody 与消息转换器
@RequestBody 不走参数绑定,走 HttpMessageConverter:按 Content-Type 挑能读的转换器,按参数类型反序列化。
Content-Type |
命中的转换器 | 失败表现 |
|---|---|---|
application/json |
MappingJackson2HttpMessageConverter |
HttpMessageNotReadableException → 400 |
application/x-www-form-urlencoded |
FormHttpMessageConverter |
少见用于 @RequestBody |
text/plain |
StringHttpMessageConverter |
目标非 String → 415 |
| 缺失或不认识 | 无匹配 | 415 |
反方向同理:@ResponseBody 按 Accept 选转换器,选不到就 406。@RestController = @Controller + @ResponseBody,方法返回值直接写进 body。
@PostMapping(value = "/orders", consumes = "application/json")
public ResponseEntity<OrderVO> create(@RequestBody @Valid CreateOrderCmd cmd) {
return ResponseEntity.status(201).body(orderService.create(cmd));
}
@Valid 失败抛 MethodArgumentNotValidException,默认 400;要自定义错误体就得在 advice 里接。
5. 异常处理链:谁先接
| 顺序 | 处理器 | 范围 |
|---|---|---|
| 1 | 方法内 try/catch |
异常根本没外传 |
| 2 | 本类 @ExceptionHandler |
单个 Controller |
| 3 | @ControllerAdvice |
全局 |
| 4 | ResponseStatusExceptionResolver |
@ResponseStatus 异常 |
| 5 | DefaultHandlerExceptionResolver |
Spring 内置异常 |
| 6 | 容器 error-page | 兜底 |
@RestControllerAdvice
public class ApiExceptionAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> onInvalid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest().body(Map.of("code", 400, "msg", msg));
}
}
第 2 步优先于第 3 步:全局兜底"没生效",多半是某个 Controller 自己写了个宽泛的 handler。
6. Filter 在外、Interceptor 在内
@Configuration
public class WebConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/api/login", "/api/health");
}
}
| 维度 | Filter | Interceptor |
|---|---|---|
| 位置 | DispatcherServlet 之外 | 之内 |
| 能否拿到 handler | 否 | 是 |
| 改请求体 | 能(包装 request) | 一般不 |
| 跨域预检 | 必须在此放行 | 拦下即失败 |
预检 OPTIONS 不带业务 token,鉴权放 Interceptor 又没排除 OPTIONS,浏览器侧报 CORS 错误、服务端记 403,两边对不上就出在这。
7. 视图解析为什么在 REST 场景失效
ViewResolver 只在返回值被判定为"视图名"时才介入:
返回值 → 匹配 ReturnValueHandler
├─ 有 @ResponseBody → RequestResponseBodyMethodProcessor → 直接写 body(短路)
└─ 无 @ResponseBody → ViewNameMethodReturnValueHandler → ViewResolver
所以 @RestController 里返回 "index",浏览器收到的是 index 五个字节。要在同工程混用,就单独给需要页面的方法标 @Controller。
8. HttpSession 的生命周期
| 事件 | 触发 | 结果 |
|---|---|---|
| 创建 | 首次 getSession() |
生成 JSESSIONID 并写 Set-Cookie |
| 复用 | 请求带有效 JSESSIONID |
命中同一 session |
| 失效 | invalidate() / 超时 |
服务端标记失效,下次新建 |
| 超时 | server.servlet.session.timeout |
默认 30 分钟,从最后一次访问算 |
getSession(false) 是"没有就 null",getSession() 是"没有就创建"。只读场景用后者会凭空造 session,白占内存。
9. 异步与流式的三个入口
| 方式 | 返回类型 | 线程模型 | 适用 |
|---|---|---|---|
DeferredResult |
DeferredResult<T> |
请求线程释放,他线程 setResult |
等上游回调、长轮询 |
SseEmitter |
SseEmitter |
连接保持,多次 send |
推送、进度条 |
| WebFlux | Mono / Flux |
事件循环,无每请求线程 | 高并发 IO 密集 |
@GetMapping("/progress/{id}")
public SseEmitter progress(@PathVariable String id) {
SseEmitter emitter = new SseEmitter(60_000L);
emitter.onTimeout(emitter::complete);
executor.execute(() -> {
try { emitter.send(SseEmitter.event().name("tick").data("50%"));
emitter.complete(); }
catch (IOException e) { emitter.completeWithError(e); }
});
return emitter;
}
共同坑:Servlet 异步不等于线程池自动管好,队列与拒绝策略仍要自己配;WebFlux 则不容许阻塞调用混进来。
10. 最小可跑示例
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderVO detail(@PathVariable Long id) {
return new OrderVO(id, "demo");
}
}
mvn spring-boot:run
curl -i http://localhost:8080/api/orders/1 → 200
curl -i http://localhost:8080/api/orders/abc → 400(类型转换失败)
curl -i -X POST http://localhost:8080/api/orders/1 → 405(方法不支持,带 Allow 头)
curl -i http://localhost:8080/api/nope → 404(无 handler)
四个请求分别踩在第 3 步、第 1 步的方法匹配环节,这是区分 400/405/404 最快的办法。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 404 与 405 混淆 | 404 是路径无 handler,405 是方法不符 | 看 Allow 响应头 |
@RequestParam 报 400 |
默认必填 | 加 defaultValue 或改包装类型 |
| 预检失败 | 鉴权拦了 OPTIONS |
排除 OPTIONS,或鉴权下移 Filter |
| 异常被吞成 200 | handler 返回 String 且未加 @ResponseBody |
返回 ResponseEntity |
| 返回对象变 406 | Accept 与转换器不匹配 |
核对 produces 与 Jackson 依赖 |
@RequestBody 为 null |
用了 GET 或 Content-Type 不对 |
改 POST 并显式声明头 |
12. 边界
| 需求 | 更合适的做法 | 理由 |
|---|---|---|
| 静态资源 | nginx / CDN 直出 | 不必进 DispatcherServlet |
| 网关级限流鉴权 | Filter 或 API Gateway | 应在 MVC 之前决断 |
| 高并发长连接 | WebFlux 或独立 SSE 服务 | 每请求占线程的模型撑不住 |
| 表单页渲染 | 模板引擎 + ViewResolver |
@ResponseBody 会把视图短路 |
13. 自测
- 同一请求既过 Filter 又过 Interceptor,谁先执行?
- 找不到 handler 与找到 handler 但方法不匹配,各自返回什么?
@RequestBody的反序列化由谁完成,Content-Type缺失会怎样?- 本类
@ExceptionHandler与全局 advice 谁优先? @RestController返回"index"为什么不渲染页面?- 跨域预检为什么常失败在拦截器上?