2022 字
约 6 分钟
15
S07-B07 · 一次 HTTP 请求在 Spring MVC 里走完的路径

写一个 @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. 自测

  1. 同一请求既过 Filter 又过 Interceptor,谁先执行?
  2. 找不到 handler 与找到 handler 但方法不匹配,各自返回什么?
  3. @RequestBody 的反序列化由谁完成,Content-Type 缺失会怎样?
  4. 本类 @ExceptionHandler 与全局 advice 谁优先?
  5. @RestController 返回 "index" 为什么不渲染页面?
  6. 跨域预检为什么常失败在拦截器上?
S07-B07 · 一次 HTTP 请求在 Spring MVC 里走完的路径
https://www.clxhxhhr.top/posts/6077/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。