MVC 异常统一错误、CORS、HTTP 缓存、会话与视图静态资源
@ControllerAdvice 接不住 Filter 里抛的异常;allowCredentials=true 配 * 直接被框架拒绝;ETag 有了但 304 只对 GET/2xx 生效;会话里放的对象在集群下换了机器就消失。这些横切能力各自有明确的作用边界。固定基线 Framework 6.1.12 / Servlet 6.0;请求分发与拦截器回调顺序见Web 请求链路,消息转换与校验见消息转换与校验,安全过滤链与 CSRF 归安全正文。
异常解析链与统一错误
DispatcherServlet 处理异常的链路由三个 resolver 组成:ExceptionHandlerExceptionResolver(@ExceptionHandler,本控制器优先于 @ControllerAdvice)→ ResponseStatusExceptionResolver(@ResponseStatus/ResponseStatusException)→ DefaultHandlerExceptionResolver(内置 MVC 异常到状态码映射,如 MethodArgumentNotValidException→400)。都返回 null 时异常继续外抛给容器 ERROR 分派。
多个 advice 声明能匹配同一异常时怎么选?看匹配深度:
原码连续摘录(仅统一公共缩进,截取方法局部):ExceptionHandlerMethodResolver.java,v6.1.12,L177–L193。
@Nullable
private Method getMappedMethod(Class<? extends Throwable> exceptionType) {
List<Class<? extends Throwable>> matches = new ArrayList<>();
for (Class<? extends Throwable> mappedException : this.mappedMethods.keySet()) {
if (mappedException.isAssignableFrom(exceptionType)) {
matches.add(mappedException);
}
}
if (!matches.isEmpty()) {
if (matches.size() > 1) {
matches.sort(new ExceptionDepthComparator(exceptionType));
}
return this.mappedMethods.get(matches.get(0));
}
else {
return NO_MATCHING_EXCEPTION_HANDLER_METHOD;
}按"异常类到被声明类型的继承距离"排序取最近者:控制器内 @ExceptionHandler(IllegalStateException.class) 对 IllegalStateException 是距离 0,压过 advice 里的 @ExceptionHandler(Exception.class)。这不是注册顺序,是深度比较;同深度多匹配时行为不确定,应避免在两个 advice 里声明同一具体异常。
统一错误响应推荐 RFC 9457 ProblemDetail:6.1 里 MethodArgumentNotValidException、HandlerMethodValidationException 等内置异常实现 ErrorResponse,ResponseEntityExceptionHandler 基类可把常见 MVC 异常统一转成 application/problem+json。扩展它时注意:只覆盖需要的钩子并调用 super,别吞掉内置处理;ProblemDetail 的 type/title 是契约,detail 可含变量但别放敏感信息。
响应已提交后的边界:序列化开始后抛出的异常(例如写流中途失败)无法回滚已发送的字节。异常处理器再写一份完整错误 JSON 会造成混合响应或 IllegalStateException。处置顺序是:记日志、标记请求失败、让连接终止由容器/网关处理;对支持范围断点/重试的下载,用幂等键或 Range 重试协议兜底。这一边界同样约束 ResponseBodyEmitter/SseEmitter:onError/onTimeout 回调后不得继续写。
CORS:MVC 层的处理与预检
MVC 的 CORS 在 AbstractHandlerMapping 构建执行链时处理:带 Origin 的预检(OPTIONS + Access-Control-Request-Method)改由 pre-flight handler 回答;简单/实际请求由 CorsProcessor 在 handler 执行前校验并写响应头。配置入口是 @CrossOrigin、WebMvcConfigurer#addCorsMappings 或 CorsFilter(Filter 层,适合非 MVC 路由也要 CORS 的场景)。
原码连续摘录(仅统一公共缩进,截取方法局部):CorsConfiguration.java,v6.1.12,L694–L719。
public String checkOrigin(@Nullable String origin) {
if (!StringUtils.hasText(origin)) {
return null;
}
String originToCheck = trimTrailingSlash(origin);
if (!ObjectUtils.isEmpty(this.allowedOrigins)) {
if (this.allowedOrigins.contains(ALL)) {
validateAllowCredentials();
validateAllowPrivateNetwork();
return ALL;
}
for (String allowedOrigin : this.allowedOrigins) {
if (originToCheck.equalsIgnoreCase(allowedOrigin)) {
return origin;
}
}
}
if (!ObjectUtils.isEmpty(this.allowedOriginPatterns)) {
for (OriginPattern p : this.allowedOriginPatterns) {
if (p.getDeclaredPattern().equals(ALL) || p.getPattern().matcher(originToCheck).matches()) {
return origin;
}
}
}
return null;
}origin 为空或不在名单返回 null——预检失败,PreFlightRequestHandler 回 403;* 与凭据组合由 validateAllowCredentials 显式拒绝(带 Cookie 的跨域必须列具体源或用 allowedOriginPatterns)。排障 CORS 不看"浏览器报什么",看三件事:请求是否真的发到了服务端(预检被网关拦截则服务端无日志)、响应头是否由 MVC/Filter/网关其中一层写出(多层都配会互相覆盖)、Access-Control-Allow-Credentials 与源列表是否匹配。
HTTP 缓存:ETag 与条件请求
MVC 侧两条路径:业务自管(WebRequest#checkNotModified/返回 ResponseEntity 带 ETag/Cache-Control,语义精确)与 ShallowEtagHeaderFilter(响应完成后按内容算 ETag,默认强格式、可配 setWriteWeakETag(true) 生成弱格式;命中回 304,省带宽不省计算)。
原码连续摘录(仅统一公共缩进,截取方法局部):ShallowEtagHeaderFilter.java,v6.1.12,L114–L132。
private void updateResponse(HttpServletRequest request, HttpServletResponse response) throws IOException {
ConditionalContentCachingResponseWrapper wrapper =
WebUtils.getNativeResponse(response, ConditionalContentCachingResponseWrapper.class);
Assert.notNull(wrapper, "ContentCachingResponseWrapper not found");
HttpServletResponse rawResponse = (HttpServletResponse) wrapper.getResponse();
if (isEligibleForEtag(request, wrapper, wrapper.getStatus(), wrapper.getContentInputStream())) {
String eTag = wrapper.getHeader(HttpHeaders.ETAG);
if (!StringUtils.hasText(eTag)) {
eTag = generateETagHeaderValue(wrapper.getContentInputStream(), this.writeWeakETag);
rawResponse.setHeader(HttpHeaders.ETAG, eTag);
}
if (new ServletWebRequest(request, rawResponse).checkNotModified(eTag)) {
return;
}
}
wrapper.copyBodyToResponse();
}实现是缓存响应体再判断:不满足条件(非 GET/HEAD、非 2xx、响应已带 ETag、流式等)直接透传;满足则算哈希写 ETag(默认强格式带引号,setWriteWeakETag(true) 才生成 W/ 前缀弱格式),If-None-Match 命中就不复制正文。代价是全量缓冲——大响应/流式(StreamingResponseBody、SSE)不适用;哈希基于字节,"内容相同但序列化字段顺序不同"会得到不同 ETag。业务语义缓存(按聚合版本生成强 ETag)应走 WebRequest#checkNotModified,在计算响应前短路。
Cache-Control 与 ETag 分工:前者控制新鲜度(期间连条件请求都不发),后者控制重验证(304 省传输)。no-store 与 ETag 并存时缓存语义以前者为准;认证响应默认不应被共享缓存存储。
会话:作用域与边界
MVC 相关入口:@SessionAttribute(读已有会话属性,缺失在 6.1 抛 ServletRequestBindingException 转 400,可 required=false)、@SessionAttributes+@ModelAttribute(同一控制器多次请求间暂存表单对象,配合 setComplete() 清理)、HttpSession 直接访问。会话固定防护、并发会话控制、remember-me 属于 Security 领域(见安全正文),这里只定 MVC 语义。
边界三条:序列化(内嵌容器/分布式会话下对象需可序列化,存大对象拖慢复制与故障切换);清理(@SessionAttributes 用完不 setComplete 会把表单对象留到会话结束);集群(粘性路由或复制/集中存储由部署层决定,代码里不能假设单机会话语义)。会话放认证态/购物车这类小状态可以;放业务工作集(大量领域对象)应改为服务端持久化 + 请求携带 ID。
视图解析与静态资源
前后端分离下视图仍会出现在服务端渲染、邮件模板、管理后台。DispatcherServlet 按 ViewResolver 链解析视图名;ContentNegotiatingViewResolver 在链首按请求媒体类型在多视图技术里协商。渲染发生在 render()(postHandle 之后),所以拦截器能改 Model 但视图已解析后改返回结构就晚了——与消息转换写在 adapter 内的时点不同,两者别混。
静态资源由 ResourceHandlerRegistry 映射到 ResourceHttpRequestHandler:支持 Cache-Control/版本化资源链(resourceChain(true) + content-hash 文件名)、Last-Modified/ETag 与 304、Range 请求(断点续传)。安全边界:映射目录必须是显式白名单(classpath:/static/、/public/ 等),不能映射类路径根或可写上传目录,否则任意文件读取;spring.web.resources.static-locations(Boot)改动要过安全评审。JSR-380 校验无关此节;SPA 路由转发到 index.html 属 Boot 票(U22/U23)。
错误输入与排障链
| 症状 | 直接原因 | 验证 | 处置 |
|---|---|---|---|
| Advice 没接住异常 | 异常发生在 Filter/容器层或响应已提交 | 打断点确认抛出点层级 | 对应层处理;提交后只记日志终止 |
| 意外的 advice 生效 | 深度比较选中别的声明 | 对照 getMappedMethod 深度 | 避免同异常多处声明;本控制器具体类型优先 |
| 预检 403 | origin 不在名单或与凭据冲突 | 服务端收到的 Origin 头与配置 | 列具体源;allowedOriginPatterns 做模式 |
| ETag 不生效 | 非 GET/2xx、响应已带 ETag、流式被缓冲跳过 | 对照 isEligibleForEtag 条件 | 业务侧 checkNotModified 或调整过滤范围 |
| 304 后仍重算全量 | 用的是 Shallow ETag | 响应耗时对比 | 强 ETag/版本号 + 提前短路 |
@SessionAttribute 抛异常 | 会话中不存在该属性 | required=false 试探 | 显式判空或初始化流程 |
| 静态资源 404/泄漏风险 | 映射路径未配或过宽 | 请求实际映射到哪个 handler | 白名单目录 + 安全评审 |
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| Boot 3.3.3 的 error 处理 | Boot 的 /error 与 MVC resolver 的先后;server.error.* 配置语义 |
| Framework 7 / Boot 4 | 异常/ProblemDetail 支持面变化、CORS API 调整,以新基线重核 |
| 网关做 CORS/缓存 | 头由谁写要唯一;多层写头互相覆盖 |
| 分布式会话(Redis 等) | 序列化、失效传播、粘性路由假设;见 Boot 集成票(U24) |
| 流式响应 | ShallowEtagHeaderFilter 不适用;见 Web 请求链路异步/流式节 |
实验:输入、步骤、预期与边界
实验目录:labs/spring-senior(MvcLab.java,run.sh mvc 运行,MockMvc 边界)。
| 场景 | 输入 | 预期 |
|---|---|---|
| 本地 handler 优先 | Controller 抛 IllegalStateException,本地与 advice 都声明 | 本地 @ExceptionHandler 生效(深度 0 压过 advice 的 Exception) |
| advice 兜底 | Controller 抛未本地声明的 IllegalArgumentException | advice 返回 application/problem+json(ResponseEntityExceptionHandler) |
| 预检通过 | OPTIONS + Origin 白名单 + ACRM | 200,带 Access-Control-Allow-Origin/-Methods |
| 预检拒绝 | Origin 不在名单 | 403,无 Allow-Origin |
| 浅 ETag 304 | ShallowEtagHeaderFilter 下 GET 两次,第二次带 If-None-Match | 首次 200+强格式内容哈希 ETag;二次 304 |
| 会话属性 | 控制器写入后另一请求 @SessionAttribute 读取 | 200 读到;未写入路径抛异常转 400 |
本机已运行(2026-10-25,JDK 17.0.19,Framework 6.1.12)。未覆盖:容器 ERROR 分派页、网关 CORS 覆盖、真实浏览器预检缓存(Access-Control-Max-Age)、集群会话。
追问链
第一层:为什么"统一异常处理"不能统一所有层的异常?
Advice 只在 MVC 处理域内(adapter 执行/渲染阶段抛出的异常)被咨询。Filter 在 DispatcherServlet 之前、容器在 Servlet 之外、响应提交之后的失败都在域外。每层的错误协议要各配各的:Filter 链自己做错误响应,容器 ERROR 分派给错误页,提交后失败只记日志。把"统一"理解成"一个 advice 接所有"会在第一处域外异常破功。
第二层:同深度异常声明冲突时框架为什么不报错?
ExceptionDepthComparator 只定义全序;两个 advice 对同一异常深度相同(都声明了该具体类型)时选择结果依赖遍历/注册顺序,框架未强制唯一。设计约束应放在规范层(同一异常只在一处声明),而不是指望框架报错。
第三层:CORS 放 Filter 还是 MVC?
非 MVC 路由(静态资源由容器直出、其他 Servlet)也要跨域 → Filter(CorsFilter/网关);只有 MVC 端点需要且要与方法级配置(@CrossOrigin)协作 → MVC 配置。两层同时配置时响应头由两处叠加/覆盖,行为取决于顺序,排障要打印最终响应头。安全过滤器链之后做 CORS(Security 6 默认先 CORS)可避免预检被认证拦截。
第四层:给公开 API 设计缓存策略按什么顺序决策?
- 资源是否可缓存(认证/个性化内容默认 no-store 或 private)?
- 新鲜度窗口多大(数据可接受陈旧度,业务给数不是拍
max-age=86400)? - 重验证用业务 ETag(内容/版本哈希,可在计算响应前短路)还是浅 ETag(过滤器算内容哈希,只省带宽)?
- 失效路径:数据变更后是等 TTL、主动 purge(CDN)还是版本化 URL?
- 观测:命中率、304 比例、陈旧读取的事故案例,缓存策略是可回滚的配置不是永久决定。
技术案例
- 开放 API 错误协议:
@RestControllerAdvice extends ResponseEntityExceptionHandler,业务异常带稳定错误码映射到ProblemDetail.type;日志记 traceId 不回传堆栈。 - 多端静态资源:内容哈希文件名 +
immutable长缓存;index.html 短缓存或 no-cache 保证发布可见。 - 表单向导:
@SessionAttributes暂存中间步骤,提交后setComplete();超时清理与会话上限由容器/Security 管。
U23真实容器补证(2026-10-26):Boot4.0.9 / Framework7.0.10 / Tomcat11.0.27中调用sendError(500),记录到REQUEST /lab/error后发生ERROR /error、original=/lab/error;TLS的信任/主机名/错误密码恢复另有真实测试。见Boot运行与U23证据。本篇原Framework6.1.12/MockMvc实测史保留;新分派证据不宣称已覆盖所有Filter异常、已提交响应、浏览器CORS缓存或网关行为。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring Framework Reference: MVC Annotated Controllers / CORS | 6.1 | 异常解析、CORS、缓存、会话、视图/资源官方语义 |
| RFC 9110 §13 Conditionals、RFC 9457 Problem Details | 2022/2023 | If-None-Match/304、ProblemDetail 结构 |
| W3C Fetch/CORS | Living | 预检触发条件、凭据模式 |
| ExceptionHandlerMethodResolver.java | v6.1.12 | 深度选择(L177–L193) |
| CorsConfiguration.java、ShallowEtagHeaderFilter.java | v6.1.12 | checkOrigin(L694–L719)、浅 ETag 条件(L114–L132) |
本地 MvcLab | Framework 6.1.12 / JDK 17.0.19 | 深度选择/advice/预检/304/会话实测 |
访问日期:2026-10-25。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。