DispatcherServlet 请求总链:映射、调用与响应处理
DispatcherServlet 接收 Servlet 请求,选择能够处理它的对象,再协调方法调用、视图渲染或响应体写出。Controller 是其中一个执行对象;同一套分发机制也可以处理静态资源和其他类型的 handler。
一个 JSON 接口返回订单对象,一个页面接口返回视图名。两者都会经过 HandlerMapping 和 HandlerAdapter,但响应的生成位置不同。这处分叉决定了拦截器、消息转换和异常处理应该放在哪里。
DispatcherServlet 管理哪些运行对象
容器入口与 MVC 入口
Servlet 容器先根据 Servlet 注册映射选择入口。匹配该分派的 Filter 围绕 Servlet 执行;到达 DispatcherServlet 后,才开始 MVC 的 handler 选择。
Servlet 容器
├── Connector:连接、HTTP 报文、超时和连接资源
├── Filter 链:按 URL / Servlet 名与 dispatcher type 匹配
└── DispatcherServlet
├── WebApplicationContext:取得 MVC 策略和应用 Bean
├── HandlerMapping:请求 → handler 与拦截器链
├── HandlerAdapter:调用指定类型的 handler
├── HandlerExceptionResolver:处理分发过程中的异常
├── ViewResolver:视图名 → View
└── MultipartResolver / LocaleResolver / FlashMapManager 等辅助策略这里有两次不同的匹配:容器选择 Servlet,MVC 选择 handler。网关改写后的路径、应用的 context path、Servlet mapping 都可能影响后者看到的路径。排查 Controller 没执行,首先确认请求确实进入了这个 DispatcherServlet。Spring 的处理流程说明列出了请求上下文、multipart 检查、handler 执行和可选视图渲染的关系。
Spring Boot 通常通过自动配置创建 Web 容器、DispatcherServlet 和 MVC 基础设施。已有 Boot 应用需要添加格式转换、拦截器等扩展时,可以实现 WebMvcConfigurer;添加 @EnableWebMvc 则表示接管 MVC 配置,不能把它当作开启 Controller 的必要注解。Boot MVC 配置说明解释了这两种配置方式的区别。
启动注册与请求匹配
RequestMappingHandlerMapping 在初始化时发现 Controller 方法,组合类级和方法级条件,建立 RequestMappingInfo 与 HandlerMethod 的对应关系。请求到来后,框架使用已建立的注册表查找候选、筛选条件并比较优先级。
@Controller
@RequestMapping("/chain")
class OrderPageController {
@GetMapping("/view")
ModelAndView view() {
return new ModelAndView("lab-view", "id", 42);
}
}这个方法注册的路径是 /chain/view,HTTP 方法为 GET。HandlerMethod 保存方法和所属 Bean 等元数据,并不承担 Servlet 输入输出的全部工作。
映射找到 handler 后,会连同适用的 HandlerInterceptor 组成 HandlerExecutionChain。DispatcherServlet 再从 HandlerAdapter 中选择支持该 handler 类型的适配器。注解式 Controller 通常由 RequestMappingHandlerAdapter 执行;ResourceHttpRequestHandler 等对象则使用其他适配器。
| 对象 | 请求处理中取得什么 | 产生什么 |
|---|---|---|
| RequestMappingHandlerMapping | 路径、方法、媒体类型、参数等条件 | HandlerMethod 与匹配的拦截器 |
| RequestMappingHandlerAdapter | HandlerMethod、request、response | 已处理响应、待渲染 ModelAndView,或异步处理状态 |
| 参数解析器 | 一个方法参数及请求数据 | 能传给 Java 方法的参数值 |
| 返回值处理器 | 方法返回类型、返回值和模型容器 | 模型属性、视图信息、响应体或异步任务 |
| 消息转换器 | Java 类型、媒体类型和输入/输出流 | Java 对象或 HTTP 主体字节 |
映射决定由谁处理,适配器负责怎样调用。参数解析器和返回值处理器位于适配器内部,并不是 DispatcherServlet 外围另一条通用 Filter 链。常用匹配条件及组合方式见 Mapping Requests。
一次分发怎样执行和返回
preHandle 成功之后才进入 handler
设请求命中了两个拦截器 A、B。preHandle 按注册顺序调用,两个都返回 true 才执行 handler;正常的 postHandle 和最终的 afterCompletion 按逆序回调。
正常视图:
A.pre → B.pre → Controller → B.post → A.post → View.render → B.after → A.after
B.pre 返回 false:
A.pre → B.pre → A.after第二条路径没有 Controller,也没有 B.after。HandlerExecutionChain 维护最后一个成功执行 preHandle 的拦截器索引;B 没有成功通过,所以不会收到对应 afterCompletion。B 若已经分配临时资源,应在拒绝返回前清理。这个索引及逆序规则可在固定版本的 HandlerExecutionChain 实现中对应到运行对象。
返回 false 不会自动生成错误响应。拦截器需要明确设置状态和主体,或者抛出能被 MVC 异常解析器处理的异常。只返回 false,可能留下一个空响应。
响应体在适配器内写,视图在适配器外渲染
ResponseEntity 携带状态、响应头和主体。对应的返回值处理器设置这些信息,选择 HttpMessageConverter,并在 HandlerAdapter 内部处理响应体。postHandle 被调用时,这些动作已经发生。
视图路径则不同。Controller 返回 ModelAndView 后,postHandle 仍可调整模型;DispatcherServlet 随后解析 View 并执行 render。视图中的模板引擎读取模型,将生成的内容写入 response。
因此,在 JSON 响应上增加公共头部应使用 Filter 的前置部分或 ResponseBodyAdvice 等位置。不能依赖 postHandle 修改已经写出的响应。Interception 文档专门说明了响应体路径的时序。
ResponseBodyAdvice 运行时,媒体类型和转换器已经选定。仅增加头部、原样返回 body,不会改变转换器要求;若把 String 换成包装对象,而仍由字符串转换器写出,就可能在输出阶段失败。具体处理方式见转换、校验与消息协商。
写入、提交与返回是不同动作
ServletResponse 通常有缓冲区。向缓冲区写字符,与将 HTTP 状态、头部及已有字节发送给客户端,发生的时间可能不同。显式 flush、缓冲区满、框架输出处理和容器完成请求都可能推动提交。
response.isCommitted() == false
当前仍未提交;先前可能已经写入缓冲区
response.isCommitted() == true
状态和头部已提交;不能重新选择一份完整错误响应同步 ResponseEntity 实验中,postHandle 会看到 committed=true;小型视图实验中,Filter 返回时仍可看到 false,容器之后完成发送。不要把“Filter 返回后统一提交”画成所有响应的固定最后一步,也不要把一次观测中的 false 推广为所有异步响应的承诺。
Servlet 对缓冲、flush 和 committed 的定义见 Servlet 6.1 规范。这些规则与 MVC 的返回值处理共同决定扩展点还能修改什么。
异常解析与异步分派
Controller 抛出异常时,正常的 postHandle 路径被跳过。DispatcherServlet 尝试用 HandlerExceptionResolver 处理;异常已解析时,最终 afterCompletion 收到的 Exception 参数可能为 null。需要统计错误时,还应结合最终状态、解析器中的错误分类和日志。具体分支对应 DispatcherServlet 实现。
Controller 返回 Callable 时会启动 Servlet 异步。第一次分派不继续执行正常 postHandle、afterCompletion,而是通知 AsyncHandlerInterceptor.afterConcurrentHandlingStarted。后台结果就绪后,容器发起 ASYNC 分派,MVC 恢复返回值处理。
Filter 是否参与第二次分派,由 dispatcher type 注册和 Filter 自身规则共同决定。后台工作线程也需要自己的上下文传播与清理,原请求线程已经归还线程池。异步请求文档说明了 Callable、DeferredResult 及异步拦截回调的协作。
运行真实 Controller 并观察回调
准备工程与启动服务
下载 MVC 运行实验源码包,解压后进入 mvc-runtime-lab。工程采用 Spring Boot 4.1.1、Spring Framework 7.0.9,编译目标 Java 17;运行可使用 Java 17 或 Java 25。支持范围可查 Spring Boot 系统要求。
Linux 主机需要 Docker Engine、curl 和 unzip。操作者为具备 Docker 使用权限的普通用户;该权限允许控制容器和挂载主机目录。Maven 容器使用宿主 UID/GID,应用运行容器使用 10001。工作目录与缓存必须对指定身份可写。
test "$(id -u)" -ne 0 || { echo '请使用普通用户'; exit 1; }
unzip mvc-runtime-lab.zip
cd mvc-runtime-lab
PROJECT_DIR="$(pwd)"
CACHE_DIR="$PROJECT_DIR/.m2"
mkdir -p "$CACHE_DIR"
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$CACHE_DIR:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache clean verify第一次运行需要下载镜像和 Maven 依赖。网络失败时先处理 registry 或 Maven 仓库访问,保留错误中的具体地址;权限失败则检查 /work 与 /cache 挂载。不要通过跳过测试掩盖准备问题。
成功后产生 target/mvc-runtime-lab-1.0.0.jar。DispatcherChainTest 启动随机端口的真实 Tomcat,通过 JDK HTTP 客户端请求并断言回调关系,不占用后面的固定实验端口。
docker run -d --name mvc09-lab \
--user 10001:10001 --read-only --tmpfs /tmp:rw,nosuid,size=64m \
-p 127.0.0.1:18090:8080 \
-v "$PROJECT_DIR/target/mvc-runtime-lab-1.0.0.jar:/app/app.jar:ro" \
eclipse-temurin:25.0.4_7-jdk java -jar /app/app.jar
docker logs --tail 40 mvc09-lab
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18090/lab/health健康接口返回 {"status":"UP"}。日志中的 Started 表示应用已启动;连接被拒绝时检查容器是否仍运行、端口映射和启动异常。
/lab/events 是有界实验观察端点,会保存实际发生的回调。应用未配置生产认证,端口只发布到回环地址,不应把观察端点暴露到公网。
比较 JSON 与视图
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-H 'X-Request-Id: body-01' http://127.0.0.1:18090/chain/body
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18090/lab/events/body-01JSON 包含 id=42 与 status=CREATED。X-Before-Body 由 ResponseBodyAdvice 添加;拦截器在 postHandle 中设置的 X-Post-Handle 已经太晚,不会进入这个响应。观察记录的关键部分为:
A.pre:REQUEST
B.pre:REQUEST
controller.body
body.beforeWrite
B.post:committed=true
A.post:committed=true
B.after:error=none
A.after:error=nonebody.beforeWrite 先于两个 post,B 先于 A 执行后置回调。这个结果同时展示适配器内部写响应和拦截器逆序。
换成视图请求:
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-H 'X-Request-Id: view-01' http://127.0.0.1:18090/chain/view
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18090/lab/events/view-01主体为 <p>Order 42</p>,这次 X-Post-Handle 可以出现在响应中。关键顺序变为 controller.view → B.post:committed=false → A.post:committed=false → view.render → B.after → A.after。
让拦截器拒绝请求
负例预计得到 403,不使用 --fail-with-body 把预期 HTTP 错误变成传输失败。分别检查 curl 退出状态、HTTP 状态与主体:
BODY_FILE="$(mktemp)"
STATUS="$(curl -q --noproxy '*' -sS --max-time 5 \
-o "$BODY_FILE" -w '%{http_code}' \
-H 'X-Request-Id: deny-01' -H 'X-Deny: yes' \
http://127.0.0.1:18090/chain/body)"
TRANSPORT=$?
test "$TRANSPORT" -eq 0 || exit 1
test "$STATUS" = 403 || exit 1
test "$(cat "$BODY_FILE")" = 'denied-by-B' || exit 1
rm -- "$BODY_FILE"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18090/lab/events/deny-01记录只有 A.pre、B.pre、A.after 及 Filter 回程,没有 Controller 和 B.after。移除 X-Deny 后重发 /chain/body,恢复正常 JSON 路径,便能区分“拦截器主动拒绝”和“Controller 返回 403”。
curl 的 -q 放在第一个选项,禁止默认 curlrc;--noproxy '*' 防止代理环境改变回环路径。curl 报超时或响应截断时,即使状态字符串为 403,也应先处理传输失败。curl 手册可查对应选项与退出码。
观察异步与异常
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'X-Request-Id: async-01' http://127.0.0.1:18090/chain/async
curl -q --noproxy '*' --fail-with-body --max-time 5 \
http://127.0.0.1:18090/lab/events/async-01返回 {"status":"READY"}。记录出现 REQUEST 与 ASYNC 两次 Filter 进入、两次 preHandle,但 controller.async 只出现一次。worker.callable 一行携带由 TaskDecorator 复制到工作线程的 requestId。
不同线程的事件可能交错。例如工作线程开始运行与第一次分派退出的先后,不应依赖固定睡眠时间。判断时使用必要的局部顺序:Controller 产生 Callable,结果就绪后发生 ASYNC 返回值处理,最终完成回调随后执行。
/chain/missing 返回 404、application/problem+json 和 code=ORDER_NOT_FOUND。事件为 controller.throw → advice.orderMissing → body.beforeWrite → afterCompletion,没有正常 postHandle。测试 resolvedExceptionSkipsPostAndHasNullCompletionException 同时检查这些条件。
完成实验后只回收指定容器,保留工程与 Maven 缓存:
docker stop --time 10 mvc09-lab
docker rm mvc09-lab按实际处理阶段定位故障
映射阶段:路径正确仍找不到 Controller
先固定方法、原始 URL、网关改写和 context path,再看映射记录。实验应用可以在原容器停止后,以映射 TRACE 日志重新启动:
docker run --rm --name mvc09-mappings \
--user 10001:10001 --read-only --tmpfs /tmp \
-p 127.0.0.1:18090:8080 \
-v "$PROJECT_DIR/target/mvc-runtime-lab-1.0.0.jar:/app/app.jar:ro" \
eclipse-temurin:25.0.4_7-jdk java -jar /app/app.jar \
--logging.level.org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping=TRACE启动日志应包含 ChainController 映射,收到请求时能看到命中方法。如果映射不存在,检查组件扫描、条件配置和应用实例。若映射存在但请求命中其他对象,比较代理处理后的真实路径与匹配条件。修复后用相同 URL、方法及请求头重发,并关闭临时 TRACE,避免请求信息持续进入生产日志。上述前台容器可用另一终端执行 docker stop mvc09-mappings 结束,--rm 会自动移除它。
404 也可能由资源 handler、Controller 业务查询或代理生成。实验关闭了默认静态资源映射,便于观察没有 handler 的路径;普通 Boot 应用可能先命中资源处理器,再产生 NoResourceFoundException。handler 类型和异常类能帮助区分两者。
调用阶段:400、415 与 406
| 观察 | 首先查什么 | 修复后的同条件请求 |
|---|---|---|
| 405 且包含 Allow | 路径匹配后 HTTP 方法不支持 | 保留路径,改为支持的方法 |
| 400,参数类型不匹配 | 参数来源、参数名、目标 Java 类型 | 改正非法值,其他条件不变 |
| 415 | Content-Type、consumes、可读转换器 | 使用支持的主体格式与媒体类型 |
| 406 | Accept、produces、返回类型的可写转换器 | 使用支持的响应表示 |
| 方法成功、输出失败 | 序列化属性、Advice 是否改了类型 | 保留输入,修正输出对象或转换配置 |
这些错误发生在不同阶段。完整绑定与错误处理分别见请求映射与参数绑定、异常解析与 ProblemDetail。在 Controller 内增加宽泛 catch,无法处理方法调用前发生的所有失败。
回程阶段:公共头丢失、错误体被截断
/chain/body 的 X-Post-Handle 丢失提供了稳定反例。业务中出现相同现象时,先观察 committed 和扩展点位置;把头部移到写出前,再用相同方法、Accept、返回类型复测。更换返回类型可能改变处理器,仅在某个字符串接口上成功,不能推导所有接口都相同。
流已经输出部分字节后再抛异常,另写一份 JSON 无法恢复原响应。保留传输失败、已写字节和终止原因,停止生产者并关闭资源;具体恢复设计见文件、流式响应与 Servlet 异步。
MockMvc 使用真实 MVC 处理链和模拟 Servlet 请求响应,适合映射、绑定、异常类和主体断言;真实 HTTP 测试再覆盖网络、容器分派、提交及客户端断开。直接调用 Controller 方法不会经过前述策略链。MockMvc 官方说明给出了它的执行模型。
