类型转换、参数校验与消息协商
接口收到 quantity=abc、quantity=0 和 quantity=2,需要作出三种处理:第一种无法成为整数;第二种已经是整数,但可能违反最小购买量;第三种才满足当前输入条件。
Spring MVC 用不同组件完成这些判断。文本转换处理 Java 类型,消息转换器处理 HTTP 主体,Validator 检查约束。把它们分开配置,错误才能准确落在对应的输入上。
从字符串和主体字节取得 Java 值
两条输入路径
路径变量 / 查询参数 / 表单字段
"2" → 参数解析器或 WebDataBinder → ConversionService → int 2
JSON 请求主体
{"quantity":2} → HttpMessageConverter → Jackson → Item 对象
取得 Java 值以后
配置了相应校验 → Validator → 接受,或形成校验错误
未触发校验 → 继续调用方法ConversionService 不负责解析整个 JSON。向它注册 String → SkuCode 的转换器,能够处理请求参数中的 SKU,却不会自动成为 Jackson 的反序列化规则。反过来,JSON 字段上的 Jackson 注解通常也不改变查询参数的转换。
这两条路径共享 Java 类型,但入口协议不同。相同的时间类型可能分别需要 MVC Formatter 与 Jackson 时间配置,应该用两种请求各测一次。
Converter 与 Formatter
Converter 适合不依赖区域设置的类型转换,例如把 SKU-001 变为值对象。实验中的注册如下:
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(String.class, SkuCode.class, value -> {
if (!value.matches("SKU-[0-9]{3}")) {
throw new IllegalArgumentException("Expected SKU and three digits");
}
return new SkuCode(value);
});
}/validation/sku?sku=SKU-001 返回 value=SKU-001;传 bad 则在进入业务逻辑前失败。转换器应只做确定的类型转换,避免在这里查询远程商品服务。否则一次普通绑定就可能引入网络超时、重试和重复查库。
Converter 实例可能被多个线程使用,内部不能存放某次请求的可变状态。接口约定与集合转换支持见 Spring Type Conversion。
Formatter 在文本解析和输出时可以考虑 Locale,适合数字、日期等显示格式。实验使用 @DateTimeFormat(iso = ISO.TIME) 将 time=12:30:00 转为 LocalTime。LocalTime 只有本地时间,不携带时区;需要记录全球唯一时刻时,应选择 Instant 或包含偏移量的类型,并明确接口格式。字段格式化说明了 Formatter 与注解格式化的关系。
Content-Type 决定怎样读取主体
record Item(@NotBlank String sku, @Min(1) int quantity) {}
@PostMapping(path = "/body",
consumes = "application/json",
produces = "application/json")
Item create(@Valid @RequestBody Item item) {
return item;
}Content-Type: application/json 声明主体采用 JSON 格式,参数解析器据此寻找能够读取 Item 的消息转换器。匹配还要考虑 Java 类型,只有媒体类型相符仍不足以保证可读。
请求头写 JSON,实际发送单个左大括号,会在反序列化时失败;发送合法 JSON,但 quantity 的值不能转为目标类型,也可能在读取阶段失败。两者都还没有到 Bean Validation。
Boot 4.1 默认使用 Jackson 3,相关核心类型位于 tools.jackson 命名空间。升级旧项目时,不能照搬全部 Jackson 2 自定义配置类;应核对实际依赖和自动配置入口。Boot Servlet Web 文档说明了 JSON 与消息转换器定制方式。实验跟随 Boot 4.1.1 管理的依赖,不单独覆盖转换器版本。
约束怎样被触发和组合
有注解,还需要验证入口
Item 声明 @NotBlank 和 @Min,仅表示它具有这些约束。实验同时提供 /validation/body 和 /validation/unchecked:前者在参数上使用 @Valid,后者没有。
向两者提交 {"sku":"","quantity":0},前者拒绝,后者能够返回该对象。这个对照说明校验触发发生在哪里;不能把“DTO 上写了注解”当成所有使用位置都会自动执行验证。
工程依赖 spring-boot-starter-validation,包含 Jakarta Validation API 的实现。只有 API、没有 provider,或者自行替换了 MVC Validator,都可能改变运行结果。默认 Validator 与 Spring 集成方式见 Java Bean Validation。
空值、嵌套对象与分组
| 约束或标记 | 典型用途 | 常见误用 |
|---|---|---|
| @NotNull | 要求值存在 | 用它检查字符串不能只含空格 |
| @NotBlank | 字符序列必须有非空白内容 | 用在不支持的类型上 |
| @Size | 字符串、集合等的长度或大小 | 当作数值大小比较 |
| @Min / @Max | 数值上下限 | 认为它们同时要求包装类型非 null |
| @Valid | 向嵌套对象继续验证 | 认为它本身是“不能为空”的约束 |
| @Validated(Group.class) | 选择需要执行的约束组 | 忽略未选择的组和默认组 |
许多数值、长度约束允许 null,将“允许缺失”与“值存在时是否合法”分开处理。需要两者都成立时组合 @NotNull 和数值或长度约束。完整定义以 Jakarta Validation 规范为准。
record Shipment(@NotNull @Valid Item item) {}外层参数使用 @Valid 后,@NotNull 检查 item 是否存在,字段上的 @Valid 继续进入 Item 的 sku、quantity。没有级联标记时,外层对象被验证也不意味着内部所有对象自动遍历。
创建和修改接口的必填字段差别很大时,可以选择分组,也可以拆成两个输入 DTO。后者让方法签名直接展示允许的字段;分组则适合对象结构确实相同、只有少量规则变化的情况。组之间的顺序依赖要用明确的组序列,不能依赖注解排列。
涉及库存余额、账户权限或唯一性时,还要由业务逻辑和数据库处理实时状态。输入 quantity=2 合法,只能让请求进入后续扣库存过程;并发下是否还有两件库存,需要事务或其他一致性措施。
对象级、方法级与返回值校验
Spring MVC 的内置校验有两种常见入口:
// 单个请求对象验证。
Item create(@Valid @RequestBody Item item)
// 方法参数直接声明约束。
int quantity(@RequestParam("quantity") @Min(1) int quantity)
// 方法返回结果声明约束。
@Min(1)
int quantityProducedByServer()单个对象验证失败通常产生 MethodArgumentNotValidException。方法参数上直接声明 @Min 等约束,或方法声明返回值约束时,MVC 方法验证可能接管,产生 HandlerMethodValidationException。
@Valid 自身用于级联,不是约束注解;仅添加它不会单独触发方法级验证。Controller 类上的 @Validated 则可能让 AOP 代理承担方法验证。采用 MVC 内置方式时,不要再习惯性地加上这个类级注解。具体选择条件见 MVC Validation。
错误处理还要区分输入与输出。用户提交 quantity=0,输入约束失败可返回 400;服务器方法自己返回 0,却声明结果必须至少为 1,这属于服务器违反返回契约,应为 500。实验分别断言异常类型、isForReturnValue 和 HTTP 状态。
BindingResult 接管之后需要处理错误
对于支持的参数位置,在待验证对象后紧跟 BindingResult,可以让方法处理其绑定和校验错误。此时不能继续无条件写入数据库:先检查错误,返回可理解的字段提示,成功才进入业务操作。
多个参数同时有约束时,只给一个对象加 BindingResult,并不能吸收其他参数的错误。方法验证发现未被相邻结果参数承接的违规项,仍然会抛出异常。
公开响应可以包含字段名称、稳定错误码和简短说明。密码、令牌、完整身份证件等 rejectedValue 不应原样回传,也不要把底层异常消息直接放到 detail 中。
输出媒体类型与响应体处理
Accept 表达客户端愿意接收什么
请求 Content-Type 描述发送内容;Accept 描述期望响应。一个 JSON POST 可以使用 Accept: application/json,也可以尝试 Accept: application/xml。若方法和当前转换器无法产生 XML,协商会失败,通常返回 406。
请求输入
Content-Type + consumes + 目标 Java 类型
↓
选择读取转换器
响应输出
Accept + produces + 返回值类型
↓
选择写出转换器
↓
ResponseBodyAdvice
↓
输出主体不要通过统一增加 produces="/" 消除协商错误。这个声明无法凭空安装 XML 转换器,也不能让任意返回对象自动成为任意格式。对于只提供 JSON 的接口,明确声明并保持测试一致通常更清楚。支持的消息转换器见 HTTP Message Conversion。
ResponseBodyAdvice 不能任意更换类型
ResponseBodyAdvice 执行时已经选择了媒体类型和转换器。若原方法返回 String,框架可能选择 StringHttpMessageConverter;Advice 把它替换成 Envelope 对象,字符串转换器仍会按原来的要求处理,可能发生 ClassCastException。
实验 ErrorsAndAdviceTest 为这个负例单独构建真实 MVC 调用:String 接口加上无条件包装器,断言实际转换失败。这个错误不会因为所有成功接口都“统一返回一层 data”而自动消失。
更简单的实现是让 Controller 声明真实返回类型:
record Envelope<T>(T data) {}
@GetMapping("/envelope")
Envelope<Map<String, Integer>> envelope() {
return new Envelope<>(Map.of("quantity", 2));
}框架从一开始就按 Envelope 选择 JSON 转换器。如果项目确实需要公共 Advice,应采用明确的适用标记,并同时检查返回类型和已选择的转换器;文件、流、ProblemDetail、字符串及已包装对象都要有清楚的处理策略。
本工程的 BodyObservationAdvice 只增加 X-Before-Body 响应头,并原样返回 body。接口约定见 ResponseBodyAdvice。
返回对象还会影响数据库访问
序列化器读取对象属性时,可能触发 JPA 懒加载、执行 getter 内逻辑,或沿双向关系继续遍历。Controller 方法已返回,不代表输出阶段的所有工作都结束。
接口返回独立 DTO,可以把数据库查询与响应字段生成放到明确的位置,避免依赖序列化过程补查询。金额使用明确的数值与币种契约,时间说明时区,超出 JavaScript 安全整数范围的标识符约定为字符串;这些是客户端可观察的协议选择,应由响应测试覆盖。
运行对照实验并定位失败
启动与测试入口
下载 MVC 实验工程。Linux Bash 中按映射与绑定篇的准备步骤解包、编译,并启动 mvc09-binding;该步骤包含非 root 身份、可写 Maven 缓存、回环端口和启动检查。以下使用同一服务 http://127.0.0.1:18091。
可在解包工程内单独运行相关测试。PROJECT_DIR 与 LAB_DIR 沿用准备步骤中的绝对目录:
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=MappingValidationTest,ErrorsAndAdviceTest test预期测试无失败。MappingValidationTest 检查真实绑定、转换器和 Validator;ErrorsAndAdviceTest 包含错误包装负例。负例已经由断言接住,因此整个测试任务仍应成功退出。
成功输入与可选校验
BASE=http://127.0.0.1:18091
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/json' \
--data '{"sku":"SKU-001","quantity":2}' "$BASE/validation/body"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/json' \
--data '{"sku":"","quantity":0}' "$BASE/validation/unchecked"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/validation/sku?sku=SKU-001"第一条得到合法 Item,第二条返回空 SKU 和零数量,第三条返回转换后的 SKU 值对象。再把第二条地址改成 /validation/body,应得到 400。
下面的 Bash 函数同时检查传输和预期 HTTP 状态,便于只改变一个输入条件:
expect_status() {
expected="$1"
shift
output="$(mktemp)"
actual="$(curl -q --noproxy '*' -sS --max-time 5 \
-o "$output" -w '%{http_code}' "$@")"
transfer=$?
cat "$output"
rm -- "$output"
test "$transfer" -eq 0 && test "$actual" = "$expected"
}
expect_status 400 -H 'Content-Type: application/json' \
--data '{"sku":"","quantity":0}' "$BASE/validation/body" || exit 1
expect_status 400 -H 'Content-Type: application/json' \
--data '{' "$BASE/validation/body" || exit 1
expect_status 415 -H 'Content-Type: text/plain' \
--data '{"sku":"SKU-001","quantity":2}' "$BASE/validation/body" || exit 1
expect_status 406 -H 'Content-Type: application/json' -H 'Accept: application/xml' \
--data '{"sku":"SKU-001","quantity":2}' "$BASE/validation/body" || exit 1
expect_status 400 "$BASE/validation/quantity?quantity=0" || exit 1
expect_status 500 "$BASE/validation/broken-output" || exit 1这些请求分别跨过不同处理阶段。媒体类型错误先检查头部和接口声明;JSON 无法读取先检查字节内容;约束错误检查对应参数和校验入口。最后两个接口故意对照客户端输入错误与服务器输出错误。
-q 关闭默认 curlrc,--noproxy '*' 避免代理改变回环请求路径。超时、连接失败及响应读取错误通过 transfer 判断,不能只看三位状态字符串。选项定义见 curl 手册。
修改了配置但结果未改变
| 现象 | 先核对什么 |
|---|---|
| Converter 已注册,JSON 仍不按预期解析 | 当前输入是否走 Jackson,而不是 ConversionService |
| 约束注解存在但没有拒绝 | provider、@Valid / @Validated、方法级入口及实际 DTO |
| 嵌套对象没有检查 | 字段上的级联 @Valid 与所选分组 |
| 所有验证错误都成了 400 | 是否把返回值验证也统一当作用户输入错误 |
| String 接口加包装后变成 500 | Advice 是否改变已选转换器支持的类型 |
| 成功对象序列化时又查数据库 | getter、懒加载关系及是否直接返回实体 |
| XML 请求失败 | 输入与输出的媒体类型是否分别受支持 |
改动转换器或 Validator 后,重新执行成功、语法错误、约束错误和返回错误四类请求。只重试最初失败的一条,容易把原来正常的输入协议改坏。
