MVC 消息转换、内容协商、校验分组与 multipart
同一接口 Accept: application/json 返回 200、Accept: text/csv 返回 406;@Valid 没拦住负数金额,因为约束标在了别的分组;multipart 文件在 MockMvc 里永远"不超限"。这些现象发生在 Controller 方法体之外的消息转换与校验层。固定基线 Framework 6.1.12 / Servlet 6.0;请求生命周期与拦截器/异步的权威讲解见Web 请求链路,异常体系与统一错误见异常与统一错误。
消息转换发生在两个位置
@RequestBody 的读取与 @ResponseBody/ResponseEntity 的写入都经过 HttpMessageConverter 链,但方向和失败码不同:
| 位置 | 输入 | 失败码与异常 | 时机 |
|---|---|---|---|
| 读取(参数) | 请求 Content-Type + 目标类型 | 415 HttpMediaTypeNotSupportedException(没有转换器能读);400 HttpMessageNotReadableException(能读但体损坏) | 进入 Controller 方法体之前 |
| 写入(返回值) | 请求 Accept + 产出类型 + produces | 406 HttpMediaTypeNotAcceptableException(协商不出可写类型) | adapter 内序列化时 |
所以"Controller 第一行没执行"在这层有两个独立原因:415 是没有转换器声明能读这个 Content-Type;400 是转换器读了但内容不合法(JSON 畸形、必填缺失由校验层另行处理)。排障先看状态码再追异常类型,别把 406/415/400 混成"参数错误"。
转换器选择是两步判定:类型是否 supports,媒体类型是否 includes。
原码连续摘录(仅统一公共缩进,截取方法局部):AbstractHttpMessageConverter.java,v6.1.12,L128–L157。
/**
* This implementation checks if the given class is {@linkplain #supports(Class) supported},
* and if the {@linkplain #getSupportedMediaTypes() supported media types}
* {@linkplain MediaType#includes(MediaType) include} the given media type.
*/
@Override
public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
return supports(clazz) && canRead(mediaType);
}
/**
* Returns {@code true} if any of the {@linkplain #setSupportedMediaTypes(List)
* supported} media types {@link MediaType#includes(MediaType) include} the
* given media type.
* @param mediaType the media type to read, can be {@code null} if not specified.
* Typically the value of a {@code Content-Type} header.
* @return {@code true} if the supported media types include the media type,
* or if the media type is {@code null}
*/
protected boolean canRead(@Nullable MediaType mediaType) {
if (mediaType == null) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.includes(mediaType)) {
return true;
}
}
return false;
}supports(clazz) 决定类型(StringHttpMessageConverter 只支持 String,MappingJackson2HttpMessageConverter 支持任意 Jackson 可映射类型),canRead(mediaType) 用 MediaType.includes 做兼容匹配。Content-Type 缺失时读取视为可尝试;这与客户端行为有关,不能假设每个调用方都发标准头。写入方向的 canWrite 是同一对判定的镜像。
内容协商:Accept、produces 与默认策略
MVC 默认只用 Accept 头协商(基于 HeaderContentNegotiationStrategy);按后缀 .json/.xml、按查询参数 ?format= 的策略在纯 MVC 中默认关闭,Boot 也不开启。客户端发 Accept: text/csv 而转换器链里只有 String/Jackson 时,写入端协商失败返回 406——这不是 Bug,是"没有任何注册转换器声明能写 text/csv"。
影响协商的三个来源叠加:方法/类上的 produces(窄化,匹配不上直接不进方法)、转换器的 supported types、请求 Accept。默认转换器顺序有意义:ByteArray、String、Resource、AllEncompassingForm(form/JSON/XML)、Jackson 等,StringHttpMessageConverter 声明 text/plain 与 */*。Accept: */* 会命中第一个能写的转换器——"默认变成 JSON"是转换器顺序的结果,不是协议规定。
排障 406 时核对三件事:请求真实 Accept(浏览器/网关可能重写或追加)、方法 produces 是否把范围收窄、自定义转换器是否真的注册进了 RequestMappingHandlerAdapter(只在容器里 new 一个 Bean 不等于进入 MVC 链)。406 的响应体还可能再触发一次协商失败,需保证错误响应有兜底类型。
字符集同理:StringHttpMessageConverter 默认写 text/plain;charset=ISO-8859-1 时代的争议在 6.x 已由 defaultCharset(默认 UTF-8)解决,但自定义转换器仍需显式设置,别让中文响应依赖容器默认。
校验:绑定校验、方法校验与分组
校验在 MVC 里有两条不同链路,抛的异常也不同:
- Bean 绑定校验:
@Valid/@Validated加在@RequestBody/@ModelAttribute参数上,RequestResponseBodyMethodProcessor在反序列化后调用Validator,失败抛MethodArgumentNotValidException(400)。BindingResult参数必须紧跟被校验参数,放错位置不会参与该参数的校验。 - 方法级校验(6.1 引入):约束直接标在
@RequestParam/@PathVariable等非 Bean 参数上(如@Min(1) @RequestParam int page),由RequestMappingHandlerAdapter在方法调用前执行,失败抛HandlerMethodValidationException。它与方法体里的业务校验不同层:前者是"输入形状",后者是业务不变量。
分组解决"同一 DTO 在不同操作有不同规则":
public interface Create {}
public interface Update {}
public record ItemReq(
@Null(groups = Create.class) Long id, // 创建时必须为空
@NotNull(groups = Update.class) Long id, // 更新时必须存在
@NotBlank(groups = {Create.class, Update.class}) String name) {}
@PostMapping @ResponseStatus(CREATED) void create(@RequestBody @Validated(Create.class) ItemReq req) {}
@PutMapping("/{id}") void update(@RequestBody @Validated(Update.class) ItemReq req) {}规则与陷阱:不指定组的约束属于 Default 组;@Validated(Update.class) 只校验 Update 组,不会顺带校验 Default 组——想让公共约束跟着走,需要 @GroupSequence 或把公共约束显式标进组。这解释了"@Valid 没拦住"的常见真相:约束在 Default 组而校验指定了别的组,或反之。@Valid(JSR 标准)不接分组参数,分组必须用 @Validated。
错误响应:MethodArgumentNotValidException/HandlerMethodValidationException 在 6.1 都实现了 ErrorResponse,可通过 ResponseEntityExceptionHandler(@RestControllerAdvice)统一转成 RFC 9457 ProblemDetail;Boot 侧 spring.mvc.problemdetail.enabled=true 只是让内置处理也走该格式,MVC 裸工程要自己暴露 advice。字段级错误要从 BindingResult 的 field errors 带字段名导出,不要把整个异常栈透给外部。
multipart:解析器、限制与边界
6.x 默认用 StandardServletMultipartResolver,解析委托给容器的 Servlet multipart 支持(Servlet 3.0+ request.getParts())。三个常见误区:
- 大小限制由容器执行,不是
StandardServletMultipartResolver本身:maxFileSize/maxRequestSize配置在 Servlet 的MultipartConfigElement(Boot 为spring.servlet.multipart.max-file-size等)。MockMvc的 mock 请求没有这层容器执行,所以在 MockMvc 里"传一个超大文件"不会触发MaxUploadSizeExceededException——该失败路径需要真实容器验证。 - 解析时机:
DispatcherServlet在doDispatch入口检查 multipart 并包装请求;maxFileSize超限抛出的MultipartException(Boot 默认映射 413/500 取决于版本与配置)发生在进入 handler 之前,@ControllerAdvice能否接住取决于异常解析链配置,超出maxRequestSize同理。 - 临时文件与清理:容器把超过
fileSizeThreshold的部分写临时文件,请求结束时清理;长时间持有MultipartFile引用或把流交给异步任务,要先把内容复制到自己的存储,不能假设容器临时文件一直存在。
最小配置与用法:
@Bean
MultipartConfigElement multipartConfig() {
MultipartConfigElement config = new MultipartConfigElement(
System.getProperty("java.io.tmpdir"), 5 * 1024 * 1024, 10 * 1024 * 1024, 0);
return config; // fileSizeThreshold=0:全部落盘由容器策略决定
}
@PostMapping(path = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String upload(@RequestPart("file") MultipartFile file) throws IOException {
byte[] bytes = file.getBytes(); // 立即取走,勿跨请求持有 MultipartFile
return "stored:" + bytes.length;
}consumes = MULTIPART_FORM_DATA_VALUE 让协商在方法选择阶段就排除非 multipart 调用(返回 415 而不是进方法后失败)。
错误输入与排障链
| 症状 | 直接原因 | 验证 | 处置 |
|---|---|---|---|
| 406 Not Acceptable | 写入端协商不出类型 | 请求 Accept、方法 produces、转换器链 | 补转换器或调整 produces;保证错误响应可写 |
| 415 Unsupported Media Type | 读取端无转换器可读 Content-Type | 415 响应里列出的支持类型 | 调用方改头,或注册对应转换器 |
| 400 且 Controller 未进 | 读取可读但体损坏(HttpMessageNotReadableException) | 断点/日志确认发生在参数解析层 | 修调用方负载或放宽反序列化并显式校验 |
@Validated(Update) 没校验公共字段 | 公共约束在 Default 组 | 打开 field errors 对照组归属 | 显式标组或用 @GroupSequence |
BindingResult 没生效 | 未紧跟被校验参数 | 参数顺序 | 移到被校验参数后一位 |
| MockMvc 大文件不抛超限 | 容器大小限制不在 mock 边界 | 对照本文 multipart 节 | 真实容器验证限制与异常映射 |
| JSON 字段多余被吞/拒绝 | Jackson FAIL_ON_UNKNOWN_PROPERTIES 配置差异 | 用目标 Jackson 版本实测 | 显式配置并写进契约文档 |
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| Boot 3.3.3 自动配置 | Boot 注册的转换器清单与顺序和裸 MVC 不同;Jackson2ObjectMapperBuilder 定制会传导到转换器 |
| Jackson 2 → 3(Boot 4/Framework 7) | 转换器实现与默认行为变化,本文以 6.1.12 为准 |
| WebFlux | 同名概念不同类(Encoder/Decoder),不能照搬本文结论 |
| 网关/LB 改写 Accept 或 Content-Type | 协商结果由改写后的头决定,排障要看服务端实际收到的头 |
| multipart 限制调整 | 容器(Tomcat maxSwallowSize)与 MVC 两层都要核对;客户端可能收到连接重置而非 413 |
实验:输入、步骤、预期与边界
实验目录:labs/spring-senior(MvcLab.java,run.sh mvc 运行,JDK 17 + Framework 6.1.12,MockMvc 边界)。
| 场景 | 输入 | 预期 |
|---|---|---|
| 协商成功 | GET /data,Accept: application/json | 200,application/json,Controller 执行 |
| 协商失败 | GET /data,Accept: text/csv | 406,HttpMediaTypeNotAcceptableException |
| 读取类型失败 | POST /items,Content-Type: text/plain | 415,Controller 未执行 |
| 体损坏 | POST /items,畸形 JSON | 400 HttpMessageNotReadableException,Controller 未执行 |
| 分组校验 | @Validated(Create):id 非 null;@Validated(Update):id null | 分别 400,且 field errors 指向不同字段(Default 组约束不参与 Update-only 校验的反例) |
| 方法级校验 | GET /items?page=-1(@Min(1)) | 400 HandlerMethodValidationException |
| multipart 正常 | POST /files(MockMvc multipart) | 200,字节数一致 |
| multipart 大小限制 | MockMvc 传超大文件 | 不抛容器限制(边界证明:限制在容器层,见本文) |
本机已运行(2026-10-25,JDK 17.0.19,Framework 6.1.12):上述场景实跑通过,输出存 labs/spring-senior/evidence/。未覆盖:真实容器 multipart 超限与连接重置、网关改写头、Jackson 3 行为。
U23于2026-10-26另以Boot4.0.9 / Framework7.0.10 / Tomcat11.0.27真实运行补证:128B文件200,2048B文件对1KB单文件上限精确413,日志有MaxUploadSizeExceededException,目标Controller调用计数未增加;Jackson3.1.7的未知字段严格/默认分支及null输出也分别验证。具体输入、输出与限制见Boot运行正文和U23证据。这是新版本独立运行,不改写上面的旧MockMvc结果;未触发连接重置,网关改写/生产拓扑仍未测。
追问链
第一层:为什么读取失败和写入失败是不同的状态码?
读取阶段客户端声明的是"我发的是什么"(Content-Type),服务端不能读就是 415——请求格式不被支持;写入阶段客户端声明的是"我能收什么"(Accept),服务端产不出匹配格式是 406——协商失败。两者都是"格式不匹配",但责任方向不同,HTTP 语义据此区分。
第二层:默认转换器顺序为什么会导致"突然变成 XML"?
类路径一旦出现 JAXB(或对应依赖),AllEncompassingFormHttpMessageConverter 及 XML 转换器进入链;Accept: application/xml 从"没人能写"变成"有人能写"。依赖变化改变了协商可行集,不是代码行为漂移。锁行为要么显式 produces,要么在契约测试里断言响应媒体类型。
第三层:分组校验和 DTO 拆分怎么选?
分组复用一个类但规则随操作漂移,类会积累所有操作的约束与组语义,阅读成本上升;DTO 按操作拆分(CreateItemReq/UpdateItemReq)类数更多但每个类自明。小项目/字段高度重叠用分组,公开 API/字段分歧大用拆分;两者都要在 advice 里把 field errors 映射到稳定的错误码,不把组名泄漏给客户端。
第四层:设计一个对外的字段错误协议要检查什么?
- 字段名是否稳定(DTO 重构后错误码不漂移)?
- 拒绝原因是否区分"格式错/约束错/业务错"三层,而不是一个 400 包所有?
ProblemDetail的type/title是否可文档化、可监控告警按错误码聚合?- 校验失败的日志是否记录输入摘要(脱敏)以便复现,而不是只记"校验失败"?
技术案例
- 开放 API 响应格式:只承诺
application/json(显式 produces),文档声明不支持内容协商;想支持 csv 时新增端点而不是让同一端点静默变格式。 - 导入接口:multipart + 限制(文件大小/类型白名单)+ 立即落对象存储 + 异步解析返回任务 ID;不在请求线程里同步解析大文件。
- 多端表单:H5/小程序共用 DTO 但校验分组不同(Create 有渠道字段、管理端没有),advice 统一字段错误结构,前端按字段回显。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring Framework Reference: MVC | 6.1 | 消息转换、内容协商、校验、multipart 官方语义 |
| Bean Validation 3.0 | Jakarta BV 3.0 | 分组、GroupSequence、约束语义 |
| RFC 9110 §12.5 Content Negotiation | 2022 | Accept/406/415 语义 |
| AbstractHttpMessageConverter.java | v6.1.12 | canRead 双重判定(L128–L157) |
本地 MvcLab | Framework 6.1.12 / JDK 17.0.19 | 协商/415/400/分组/方法校验/multipart 实测 |
访问日期:2026-10-25。摘录行号与整文件哈希固定在 v6.1.12;实验运行版本与源码 tag 分记。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。