07-SpringMVC请求全链路
一次 HTTP 请求怎样穿过 Spring MVC?从入口到 JSON 响应
Controller 没有进入,返回值却能变成 JSON,参数无效时又是谁提前拦下请求?沿着一次请求逐层观察,比记住一串组件名称更容易理解 Spring MVC,也能更快定位 404、400 和异常响应。
涉及 Spring 的示例沿用 Java 17、Spring Boot 3.5.3;不同示例项目独立阅读,文件名与依赖在相应代码段说明。
1. 先建立完整请求地图
Servlet 容器、Filter、DispatcherServlet 与业务代理承担不同职责。
Spring MVC 与 Spring Boot 分别负责什么?
Spring MVC 是基于 Servlet 的 Web 框架,负责请求映射、参数处理、Controller 调用、返回值处理和异常解析。Spring Boot 为应用提供启动和自动配置,常见配置包括嵌入式服务器及 MVC 基础设施。
因此,Spring MVC 不是 Spring Boot 的另一种叫法。本文使用 Java 17、Spring Boot 3.5.3,以及它管理的 Spring Framework 6.2.8,展示同步 Servlet 请求;WebFlux 的响应式处理链路不能直接套用这张流程图。
Servlet 是 Java Web 的一套接口与生命周期约定,容器负责接收请求并调用相应组件。DispatcherServlet 是其中一个 Servlet,而 Controller 通常是由它间接调用的 Spring Bean,不是每写一个 Controller 就创建一个新的 Servlet。
MVC 中的 Model 表达页面展示数据,View 负责呈现,Controller 协调请求处理。今天常见的前后端分离接口仍然可以使用 Spring MVC,只是返回 JSON,页面渲染交给前端完成。
先建立请求链路
flowchart TD
A[客户端 HTTP 请求] --> B[Servlet 容器]
B --> C[Filter 链]
C --> D[DispatcherServlet]
D --> E[HandlerMapping 查找处理器及拦截器]
E --> F[Interceptor preHandle]
F --> G[HandlerAdapter]
G --> H[参数解析与必要的校验]
H --> I[Controller 方法]
I --> J[返回值处理:响应体或视图]
J --> K[Interceptor postHandle / afterCompletion]
K --> L[Filter 返回阶段]
DispatcherServlet 是集中入口,把具体工作交给不同组件。HandlerMapping 回答“谁处理这个请求”,HandlerAdapter 回答“如何调用这个处理器”。对于注解式 Controller,常见组合是 RequestMappingHandlerMapping 与 RequestMappingHandlerAdapter。
异常会走异常解析分支;拦截器可以提前终止;异步处理也有不同的回调时机。流程图描述的是正常同步请求,不代表每个请求都执行全部节点。MVC 处理流程
三种机制分别位于哪里?
| 机制 | 所属层次 | 容易取得的信息 | 常见用途 |
|---|---|---|---|
| Filter | Servlet 容器请求链 | 请求、响应、请求头 | 请求关联信息、跨域、认证链 |
| HandlerInterceptor | Spring MVC 执行链 | 已匹配处理器,可能是 HandlerMethod | MVC 方法信息、局部请求处理 |
| Spring AOP | Bean 代理方法调用 | 方法、参数、返回值、异常 | 业务操作审计、事务等 |
Filter 不依赖请求一定匹配到 Controller。Interceptor 依赖相应 HandlerMapping 的执行链;AOP 依赖实际调用经过代理。后台任务可以进入业务 AOP,却不会因为它调用了 Service 就自动经历 HTTP Filter。
本篇使用 Java 17、Spring Boot 3.5.3 和它管理的 Spring Framework 6.2.8,展示同步 HTTP 请求。
为什么不能只在 Interceptor 中做全部认证?
拦截器的路径匹配、MVC 的注解映射与其他资源处理入口,需要分别考虑。只依赖某个 MVC 拦截器,并不能自然获得完整的认证与授权边界。
Spring 官方建议把相应安全处理整合到 Servlet Filter 链,例如 Spring Security。本例因此使用 SecurityFilterChain 完成 HTTP Basic 认证和请求访问规则,而不是读取一个自定义用户请求头就当作已登录。MVC 拦截器的作用范围
认证回答“调用者是谁”,授权回答“他能执行什么”。登录成功不代表能读取其他人的文章,资源所有权仍需要明确检查。
2. 找到方法之后,还要解决参数与返回值
映射、参数解析、校验和消息转换决定接口契约。
为什么 URL 对了,仍然找不到方法?
映射条件不只有路径,还可能包括 HTTP 方法、参数、请求头、consumes 和 produces。
例如 @PostMapping(path="/greetings", consumes="application/json") 要求 POST,并接受相应的请求媒体类型。路径正确但发送 GET,可能得到 405;使用不支持的 Content-Type,可能得到 415。
还要区分“不存在资源”与“未找到映射”。静态资源处理器也可能接收某些请求,所以 404 不一定来自 Controller 的业务查询。请求映射说明
参数是怎样进入方法的?
Controller 方法参数由参数解析器处理。不同注解决定不同的数据来源:
| 写法 | 来源与处理 |
|---|---|
@PathVariable("id") long id |
路径变量,经类型转换得到 long |
@RequestParam("name") String name |
请求参数,常见于查询字符串和表单 |
@RequestHeader |
请求头 |
@RequestBody |
请求体,经 HttpMessageConverter 读取 |
JSON 通常由 Jackson 对应的消息转换器反序列化。@RequestBody 不意味着所有字符串都自动成为合法 JSON,也不能把表单字段与 JSON 请求体混为一谈。
@Valid 会触发相应对象校验,但需要校验实现和字段约束。JSON 不合法是在建立对象时失败;标题为空是在对象建立后校验失败。两者都可能发生在 Controller 方法执行之前。请求体与校验
返回对象为什么会成为 JSON?
@RestController 组合了 Controller 和 ResponseBody 语义。返回对象交给响应体处理器,再由支持的消息转换器写入 HTTP 响应。
@Controller 中普通 String 返回值常被解释为视图名称;带 @ResponseBody 的 String 才是响应体内容。ResponseEntity 还能明确设置状态码、响应头和响应体。
JSON 响应一般不经过 ViewResolver 渲染页面。对于响应体方法,写入响应可能在 HandlerAdapter 内已经完成,因此 postHandle 并不是修改 JSON 的可靠入口。响应体处理
3. 完整示例如何组织
以下示例保留依赖与文件说明,代码可直接从正文复制;运行命令作为读者使用方式。
完整示例:两个文件即可启动
在一个空目录中创建下面两个文件。所有代码均在正文,无需下载配套示例;Spring 项目需要 Maven 解析依赖,普通单文件 Java 在线运行器不能直接执行。
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/>
</parent>
<groupId>demo</groupId>
<artifactId>mvc-flow</artifactId>
<version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
</project>
src/main/java/demo/Main.java:
package demo;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.config.annotation.*;
@SpringBootApplication
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
public record Greeting(String message) { }
public record GreetingRequest(@NotBlank String name) { }
public record ApiError(String code) { }
@RestController
public static class GreetingController {
@GetMapping("/hello")
public Greeting hello(@RequestParam(name = "name", defaultValue = "world") String name) {
System.out.println("controller");
return new Greeting("Hello, " + name);
}
@PostMapping(path = "/greetings", consumes = "application/json")
public Greeting create(@Valid @RequestBody GreetingRequest request) {
System.out.println("controller");
return new Greeting("Hello, " + request.name());
}
@GetMapping("/books/{id}")
public Greeting book(@PathVariable("id") long id) {
return new Greeting("book-" + id);
}
}
@RestControllerAdvice
public static class Errors {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError validation() {
return new ApiError("VALIDATION_FAILED");
}
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError unreadable() {
return new ApiError("INVALID_JSON");
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError typeMismatch() {
return new ApiError("INVALID_PARAMETER");
}
}
@Bean
OncePerRequestFilter traceFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
System.out.println("filter.before");
try {
chain.doFilter(request, response);
} finally {
System.out.println("filter.after");
}
}
};
}
@Bean
WebMvcConfigurer mvcTrace() {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
System.out.println("mvc.pre");
response.setHeader("X-MVC", "visited");
return true;
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler, ModelAndView model) {
System.out.println("mvc.post");
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) {
System.out.println("mvc.complete");
}
});
}
};
}
}
在项目目录执行 mvn spring-boot:run,然后用浏览器访问 http://localhost:8080/hello?name=Alice。返回 200,响应体为:
{"message":"Hello, Alice"}
这次同步请求的业务日志顺序为:
filter.before
mvc.pre
controller
mvc.post
mvc.complete
filter.after
示例用单文件里的静态内部类减少文件数量;正式项目可以拆成 Controller、配置和异常处理类。日志标记用于观察顺序,不是完整的生产访问日志方案。
验证没有进入 Controller 的情况
使用 Postman 或其他 HTTP 客户端向 /greetings 发送 POST,Content-Type 为 application/json。下面结果已经在本地应用中核对:
| 请求 | 状态码 | 响应体 |
|---|---|---|
{"name":"Alice"} |
200 | {"message":"Hello, Alice"} |
{"name":" "} |
400 | {"code":"VALIDATION_FAILED"} |
不合法 JSON,例如 { |
400 | {"code":"INVALID_JSON"} |
GET /books/abc |
400 | {"code":"INVALID_PARAMETER"} |
GET /greetings |
405 | 默认错误处理响应 |
POST /greetings,Content-Type 为 text/plain |
415 | 默认错误处理响应 |
校验失败与 JSON 解析失败时,日志中不会出现对应方法的 controller 标记,但 Filter 和已经执行的拦截器仍参与处理。异常路径通常不会执行正常的 postHandle。
@RestControllerAdvice 通过 MVC 异常解析机制处理对应异常。它不是应用所有异常的总入口;Filter 在进入 DispatcherServlet 前抛出的异常,并不会自动进入这些 Controller 异常处理方法。
4. 沿调用链排查,而不是盲目修改注解
结合请求状态与层次,确定实际发生错误的位置。
用链路定位常见接口问题
返回 404 时,先核对端口、应用上下文路径和实际映射;405 看请求方法;415 看 Content-Type;400 区分解析失败、类型转换和约束校验。
返回 500 时,结合异常栈定位业务调用或返回值序列化。例如对象可以在内存中存在,不代表消息转换器一定能够序列化它。响应已经提交后再次出现异常,框架也未必还能完整重写状态码与响应体。
这套方法把“是不是 Spring 出问题”转为“请求停在哪一层”,排查会更具体。
请求日志与业务审计如何分工?
一次请求可以产生多次业务调用,所以请求关联号适合放在请求层,为后续日志提供统一上下文。服务方法级审计则适合记录具体操作的进入、结果和失败。
MDC 可以把关联号放进当前线程的日志上下文,但线程复用时必须清理。若把工作交给另一个线程,也不能假定 MDC 自动完整传播。本文只验证同步请求的建立与清理。
业务审计的成功要明确:目标方法返回、事务已经提交、外部消息已发送,是不同的完成阶段。一个普通 Around 返回日志,不能自动证明所有副作用已经持久化。
为什么要禁用重复 Filter 注册?
示例的自定义 Filter 是一个 Bean,又显式加入 SecurityFilterChain。Boot 可能同时把 Filter Bean 注册到 Servlet 容器,于是它可能在两个链路位置出现。
FilterRegistrationBean 在这里禁用了容器的自动注册,仅保留安全链中的位置。OncePerRequestFilter 的标记也不能代替清晰的注册设计;重复注册会影响执行位置与观察结果。Spring Security Filter 架构
OncePerRequestFilter 的“once”还需要结合分派类型理解。异步分派和错误分派有相应扩展方法,不应推断任何情况下都只执行一次所有逻辑。OncePerRequestFilter API
5. 设计判断与适用边界
接口设计同时约定输入、输出和失败语义。绑定失败发生在业务方法之前,业务异常则来自执行过程,二者不应该使用含混的成功响应。认证需要覆盖实际请求入口,不能因为 Controller 有注解就忽略文件、异步和错误分派路径。
联调时保存请求路径、方法、Content-Type、状态和关联号。前端报错不一定来自 JSON 转换,代理路径和权限也可能改变结果。把各层观测对应起来,才能知道应该修改路由、参数还是业务逻辑。
6. 问答
问:HandlerMapping 和 HandlerAdapter 有什么区别?
前者查找处理器及执行链,后者适配并调用处理器。
问:有了 @RequestBody,为什么仍然得到 400?
请求体可能无法读取、JSON 不合法、字段无法转换,或者对象约束校验失败。
问:Filter 和 Interceptor 都能处理请求,为什么要区分?
它们的作用层次和覆盖范围不同,Interceptor 还能取得匹配的 MVC 处理器信息。
问:所有权检查能只放在 Controller 吗?
业务还可能有其他入口,应把核心规则放在合适的业务边界,并明确身份来源。