读完这篇,你能做什么
能设计与实际请求一致的 DTO,并区分字段格式、数据状态和权限校验。
学习理由、准备要求与知识点
为什么学它
明确输入位置与服务端校验,才能阻止错误格式和越权请求进入业务。
先读这些内容
知识点清单
PathVariableRequestParamRequestBody- 消息转换器
- Valid 与字段约束
- 嵌套对象校验
- 格式与业务校验
- 版本命名空间和金额类型
运行前先确认以 Java 17、Spring Framework 6.2.11、Jakarta Validation 为边界。源码模型展示请求体转换后校验的普通路径;算法使用原生 Java 实现明确的教学输入规则,不冒称运行了 Spring 或 Validation Provider。
01先弄懂它是什么
请求里的数据进入 Java 方法前,要先找到它在哪里,再转换类型,最后检查值是否合法。路径编号、查询参数和 JSON 正文是不同位置,注解写错位置可能读不到字段;JSON 格式或类型不对,也可能还没进入业务方法就报错。先分清在哪一步失败,提示和排查才准确。
以新增菜品为例,名称不能为空、价格至少一分是输入规则;分类是否存在且启用,需要查业务数据;当前用户有没有权在这个分类发布,还要检查权限。把检查放在能拿到相应信息的位置,规则才可靠。前端校验让用户更快看到提示,但请求可以被重新构造,所以服务端仍必须检查。
入口 DTO 先规定调用方可以填写什么,不能照搬数据库实体的全部字段。当前用户、创建人等身份和审计信息,应由服务端根据可信身份补齐。价格用 BigDecimal 或明确以分等最小单位保存,避免二进制浮点误差。还要说清没传字段、传 null 和传空字符串分别算什么。选校验注解时,检查它是否允许 null、是否继续检查嵌套对象,以及这次请求会不会真正触发校验。
文中这几个词,先认一认
- 绑定 / 转换
- 把地址、查询参数或正文中的数据读出来并转换成 Java 类型,和检查业务是否合法是不同步骤。
- Bean Validation
- 根据对象上声明的约束检查值的机制,需要正确注册实现并触发校验才会执行。
- 级联校验
- 继续检查对象里嵌套的对象或集合元素,但并不自动表示
null元素也不允许。 - 错误路径
- 说明错误位于哪个具体字段,例如 parts[2].quantity 指第 3 项的 quantity。
02看看它怎么工作
对于 @RequestBody,框架根据媒体类型、目标类型和转换器读取正文,再创建绑定器并按注解判断是否执行校验。正文不是合法 JSON,通常到不了字段约束阶段;正文可解析但 price 为 null,则由相应约束处理。把这些错误全当数据库插入失败,会把真正的入口问题拖到更深层。
@Valid 可以让校验继续进入嵌套对象,但它不会自动替你规定哪些值非法。比如一个菜品 DTO 里有配料列表:列表能不能为 null、至少和最多有几项,需要各自的约束;每项配料编号和数量是否合法,还要检查配料对象的字段。嵌套属性和集合元素都要把约束及继续校验的位置写清楚,不能只在最外层加一个标记,就认定里面所有数据都已检查。
校验结果还需要稳定的处理规则。可以一次返回若干字段错误,帮助用户集中修正,但应限制数量并避免回显秘密。依赖数据库的唯一性不能只靠先查是否存在,因为查与写之间可能竞争;入口检查提供好反馈,数据库约束和事务守住最终不变量。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["请求位置与 Content-Type"] --> B["参数解析或消息转换"]
B --> C{"能否构造目标类型"}
C -->|"否"| D["转换错误响应"]
C -->|"是"| E["字段与嵌套约束"]
E --> F{"输入约束通过"}
F -->|"否"| G["返回可定位字段错误"]
F -->|"是"| H["业务状态与资源授权"]
H --> I["事务与数据库最终约束"]按顺序看,每一步做了什么
- 确认参数位于路径、查询还是正文。
- 使用匹配类型的转换方式,并定义媒体类型和容量。
- 校验字段与嵌套对象,保留确定的错误路径。
- 进入业务后检查真实状态与身份,再用数据库约束处理竞争。
03对照实现代码
Spring Framework v6.2.11 · RequestResponseBodyMethodProcessor#resolveArgument
已核验 readWithMessageConverters、createBinder、validateIfApplicable 与错误分支。下例是原创普通路径模型,省略可选参数、BindingResult 相邻参数、方法级校验及异常解析器;模型异常不等于框架真实异常类型。
import java.util.List;
class SourceModel {
interface Reader<T> { T read(); }
interface Validator<T> { List<String> validate(T value); }
static <T> T resolve(Reader<T> converter,Validator<T> validator,boolean requested) {
T argument=converter.read(); // JSON/类型转换失败会先从这里退出
if(argument!=null && requested) {
List<String> errors=validator.validate(argument); // 构造成功后才检查对象规则
if(!errors.isEmpty()) throw new IllegalArgumentException(String.join(",",errors));
}
return argument; // 这里只代表本层通过,不代表资源授权或写入成功
}
}resolveArgument 首先处理参数类型并调用消息转换器读取正文。这一步需要目标泛型信息,列表元素类型和普通 Object 不同;能否选中转换器、正文是否可读,都可能在 Bean Validation 前决定结果。源码顺序解释了为什么改字段注解无法修复错误媒体类型。
随后框架建立 WebDataBinder,并在参数满足条件时执行 validateIfApplicable。验证不是对任意普通对象随时自动发生;注解、调用路径和验证器配置共同决定是否触发。单元测试直接 new Controller 再调用方法,通常不会获得完整 MVC 参数解析流程。
绑定结果有错误并满足抛异常条件时,普通请求体路径会抛 MethodArgumentNotValidException。真实应用还可能存在方法级约束、类型转换等其他路径,不能只捕获这个异常就声称所有参数错误都已覆盖。应按所用版本与参数声明设计异常处理和集成样例。
框架保存了绑定与校验结果,但不会替你判断分类是否已停用,或订单是否属于当前用户。若在所有校验器里随意查数据库,还会增加负载并让事务和测试变复杂。格式等纯输入检查宜便宜且确定,依赖实时状态的判断则放进清楚的业务步骤。
代码先调用谁,再调用谁
- 参数解析器确认支持 @
RequestBody。 - 消息转换器读取并构造目标对象。
- 绑定器按约束元数据执行验证。
- 根据绑定结果进入业务方法或框架错误处理。
04动手写一个例子
校验嵌套菜品输入并收集确定的字段路径
输入菜品名称、BigDecimal 价格和配料列表。名称非空白且至多 80 个 Unicode 码点;价格为 0.01 至 1000000 且小数位不超过 2;配料 1 至 100 项,每项 SKU 为正且不重复,数量 1 至 99,总数量至多 1000。收集全部已定义规则的错误路径,超大列表只报列表大小而不继续扫描。
- 输入 1
名称汤,价格12.50,配料[(7,2)]- 输出 1
空错误列表
- 输入 2
空白名称与 null 价格- 输出 2
包含 name、price
- 输入 3
同一 SKU 出现两次- 输出 3
parts[1].skuId
- 输入 4
null 根对象- 输出 4
$
先验证根对象和简单字段,再判断列表是否存在并满足大小范围。列表超过上限时不继续深挖,可以控制诊断开销;它不是等到创建全部错误字符串后才做限制。
遍历每个有效配料,用索引形成稳定路径,用集合记录已经出现的合法 SKU。只有合法数量进入总数,避免负值抵消正值;非法元素仍继续处理其他项,让一次请求能够发现多个独立问题。
这套规则是教学契约的手工实现,不复制完整 Validation 框架。main 同时检查正常、空白、null、重复、极小价格、数量范围和超大列表;结果是错误路径集合或列表,调用方可以再映射本地化消息。
为什么这样写是对的处理到索引 i 时,seen 只包含前 i 项合法 SKU,总数只累计通过单项数量规则的值;每条错误路径指向实际失败规则。
n 是允许扫描的配料项数,L 是名称和数值表示相关处理长度;集合去重按通常哈希模型计。错误列表和去重集合线性增长,输入规模上限同时限制最坏诊断量。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.math.BigDecimal;
import java.util.*;
public class Main {
record Part(Long skuId,Integer quantity) {}
record Input(String name,BigDecimal price,List<Part> parts) {}
static List<String> validate(Input input) {
List<String> errors=new ArrayList<>();
if(input==null) return List.of("$"); // 根对象缺失,不能继续解引用
String name=input.name();
if(name==null || name.isBlank() || name.codePointCount(0,name.length())>80) errors.add("name");
BigDecimal price=input.price();
if(price==null || price.compareTo(new BigDecimal("0.01"))<0
|| price.compareTo(new BigDecimal("1000000"))>0 || price.scale()>2) errors.add("price");
List<Part> parts=input.parts();
if(parts==null) { errors.add("parts"); return errors; }
if(parts.isEmpty() || parts.size()>100) { errors.add("parts.size"); return errors; }
Set<Long> seen=new HashSet<>(); long total=0;
for(int i=0;i<parts.size();i++) {
Part part=parts.get(i); String path="parts["+i+"]";
if(part==null) { errors.add(path); continue; } // 空元素有独立错误路径
if(part.skuId()==null || part.skuId()<=0 || !seen.add(part.skuId())) errors.add(path+".skuId");
Integer q=part.quantity();
if(q==null || q<1 || q>99) errors.add(path+".quantity");
else total+=q; // 只累计合法数量,非法负数不能抵消总量
}
if(total>1000) errors.add("parts.totalQuantity");
return errors;
}
static Input good(List<Part> parts) { return new Input("汤",new BigDecimal("12.50"),parts); }
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
public static void main(String[] args) {
check(validate(good(List.of(new Part(7L,2)))).isEmpty());
check(validate(null).equals(List.of("$")));
check(validate(new Input(" ",null,List.of(new Part(7L,1)))).equals(List.of("name","price")));
check(validate(good(List.of(new Part(7L,1),new Part(7L,2)))).contains("parts[1].skuId"));
check(validate(new Input("汤",new BigDecimal("0.001"),List.of(new Part(7L,1)))).contains("price"));
check(validate(good(List.of(new Part(7L,0)))).contains("parts[0].quantity"));
check(validate(good(List.of())).equals(List.of("parts.size")));
check(validate(good(Collections.nCopies(101,new Part(7L,1)))).equals(List.of("parts.size")));
List<Part> many=new ArrayList<>(); for(long id=1;id<=11;id++) many.add(new Part(id,99));
check(validate(good(many)).contains("parts.totalQuantity"));
System.out.println("PASS 9 validation tests");
}
}这些特殊情况也要试一试
null根对象、null列表和空列表是不同路径。- 价格数值相等但 scale 不同可能影响本题精度约束,必须公开该契约。
- 集合中重复 SKU 与数据库里 SKU 是否存在是不同问题,算法不进行数据库查询。
进阶选读 · 生产拓展:分阶段验证与并发不变量
场景批量请求包含成千上万项,部分规则便宜,部分需要数据库并受到并发变化影响。
做法先做容量和结构检查,再批量查询合法候选,避免每项单独访问数据库;最终写入用约束和事务处理竞争。设定错误数量上限,保留必要索引路径。
复杂度可将逐项往返改为批量查询,但数据库计划与请求规模决定成本;网络往返减少不等于整个问题无条件线性。
好处与代价一次返回更多错误改善修正体验,却增加处理与响应成本;快速失败简单,但可能需要用户多次提交。
05项目里怎么用
入口 DTO 只暴露允许修改的字段,创建、更新与管理员操作可使用不同契约。不能因为实体上已有注解就把全部属性直接绑定到外部请求,否则容易让审核状态、用户归属等字段被意外赋值。
对输出错误定义稳定字段路径和机器码,再由展示层本地化文案。避免在响应中包含完整提交内容或底层异常。记录哪个阶段失败有助于诊断,而不是把所有错误都转成同一个 500。
测试要覆盖规则真正执行的位置。单独测 DTO 能验证约束,MVC 测试能检查是否触发和返回什么状态,数据库集成测试再验证唯一与事务。两个并发请求可能同时通过入口的“是否已存在”检查,最终仍要在数据库实际写入时阻止重复。
容易踩的坑
- 只做前端校验,默认其他客户端也遵守。
- 使用 @Valid 却遗漏
null、集合大小或嵌套元素约束。 - 把成功转换为 Long 的
userId当作可信当前身份。 - 先查询唯一性再插入,但没有数据库唯一约束。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 字段声明式约束 | 规则靠近 DTO,重复使用与错误路径清晰。 | 需理解触发、分组和嵌套规则。 | 确定且不依赖外部状态的输入约束。 |
| 业务层显式验证 | 能结合身份、状态与调用顺序。 | 需要保持规则组织与一致错误格式。 | 分类可用性、所有权和状态流转。 |
| 数据库约束 | 在并发写入边界守住最终不变量。 | 错误需转换成可理解业务结果。 | 唯一性、非空、引用完整性等持久规则。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01转换成功为什么不代表输入合法?
展开参考答案与推导
能构造 BigDecimal 或 Long 只说明语法与类型可表示,金额范围、数量和业务权限仍可能不合法,需要相应规则继续验证。
02@Valid 是否自动拒绝所有 null?
展开参考答案与推导
不能这样推断。级联与非空是不同约束,应对根对象、嵌套属性和集合元素分别表达需要的非空政策。
03为什么不能靠字段注解保证分类始终启用?
展开参考答案与推导
分类状态来自可变化的持久数据,还涉及查询时机与并发写入。需要在业务和事务边界使用真实状态,纯输入注解不足以提供该保证。
换个情况,还会处理吗
01直接调用 Controller 单元测试通过,HTTP 请求却报绑定错误,如何理解?
展开参考答案与推导
直接 Java 调用绕过了媒体类型、消息转换和 MVC 校验流程。补上 Web 层测试,核对实际请求字节、注解和目标类型。
02批量请求校验产生数万条错误,怎样控制?
展开参考答案与推导
在读取与解析阶段限制大小,在结构阶段限制项数与嵌套深度,并对错误收集设预算。不能仅在响应发送前截断字符串。
03两个请求都通过用户名可用检查,插入仍冲突,是否数据库异常?
展开参考答案与推导
这是典型检查后竞争。唯一约束应保留,应用把冲突转换为业务结果;入口检查只是改善反馈,不是并发正确性的唯一保障。
写代码试一试
01给嵌套错误保留确定索引。
- 输入 / 条件
第 2 项 quantity 为 0- 输出 / 预期
parts[1].quantity
提示 索引基于原输入顺序。
展开参考答案与推导
遍历时先生成当前元素路径,再追加字段名;不要过滤非法项后重新编号,否则客户端无法对应原始输入。
02防止非法负数量抵消总数。
- 输入 / 条件
数量 99 与 -98- 输出 / 预期
第二项数量错误,不能把总数解释为 1
提示 单项规则和汇总规则分开。
展开参考答案与推导
只把通过范围检查的数量纳入总数,并单独报告非法项。业务写入必须要求整个输入按定义通过。
03允许价格 1.000 与 1.00 视为相同精度政策时如何改?
- 输入 / 条件
BigDecimal("1.000")- 输出 / 预期
按新契约接受
提示 数值与表示精度是两种约束。
展开参考答案与推导
先决定是否允许规范化尾随零,再以 stripTrailingZeros 后的 scale 或精确缩放判断;不要不经约定直接四舍五入改变金额。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能设计与实际请求一致的 DTO,并区分字段格式、数据状态和权限校验。
本篇重点加练
重点检查嵌套输入完整性与业务校验时点,防止格式正确的数据绕过真实规则。
- 嵌套对象校验
- 格式与业务校验
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01设计评审
设计修改订单收货地址接口:订单号放路径、
dryRun放查询参数、地址对象放 JSON。标出每项数据经过的绑定步骤,定义媒体类型不支持和 JSON 无法解析的处理合同。- 提交内容
- 接口说明、绑定链图和失败表。
- 怎样算完成
- 路径查询消息内容职责明确
- 转换失败不与字段约束混同
练到的知识点PathVariable · RequestParam · RequestBody · 消息转换器
- 作业 02原理问答
地址请求含收件人、邮编和地址列表,允许列表为空但不允许出现
null元素。设计字段和嵌套对象校验规格,区分缺失、null、空串和空列表。- 提交内容
- 约束矩阵及待验证请求样本。
- 怎样算完成
- 覆盖嵌套触发边界
- 每项非法输入有定位路径
练到的知识点Valid 与字段约束 · 嵌套对象校验
综合应用
- 作业 03面试场景
商品单价请求出现 1.230、负数以及字符串“1,23”;提交前商品可售,真正写入前被停用。区分格式、数值政策和业务时效检查,并说明实际框架版本的注解包来源。
- 提交内容
- 三层校验流程和版本核验记录。
- 怎样算完成
- 金额政策可执行
- 业务状态有提交时检查边界
- 不混用不同命名空间
练到的知识点格式与业务校验 · 版本命名空间和金额类型
- 作业 04编程实践
实现课程报名请求的本地校验:
studentId、最多五个courseId、可选 note;自行规定重复courseId、null元素、超长 note 的政策,收集稳定字段路径。- 提交内容
- Java 校验器或本地接口、自写合法/边界/非法测试和运行证据。
- 怎样算完成
- 所有错误可定位输入位置
- 输入规模受控
- 输出顺序有明确合同
练到的知识点PathVariable · RequestParam · RequestBody · Valid 与字段约束 · 嵌套对象校验
- 作业 05排错实验
在独立测试工程中构造一个能绑定但未触发预期校验的请求,分别核对依赖、注解导入、控制器参数和实际 HTTP 请求;一次仅修改一个因素。
- 提交内容
- 失败复现、排查记录及修复前后请求证据。
- 怎样算完成
- 能定位根因而非批量改配置
- 测试经过真实消息转换边界
练到的知识点消息转换器 · Valid 与字段约束 · 版本命名空间和金额类型
- 作业 06编程实践
实现报名批次校验器:每个学员有多门课程,同批不能重复学员,每人最多三门,停用课程不可报名;课程状态由可控查询接口提供。
- 提交内容
- Java 实现、自写嵌套/重复/状态失败测试与运行证据。
- 怎样算完成
- 错误能关联学员与课程位置
- 本地结构检查和外部状态检查分别验证
练到的知识点嵌套对象校验 · 格式与业务校验
- 作业 07设计评审
嵌套请求包括可选监护人和必填联系方式,监护人缺失与提供不完整对象属于不同输入。建立每层
null、空对象和空字段的规则矩阵。- 提交内容
- 嵌套路径图与输入矩阵。
- 怎样算完成
- 父对象状态与子字段约束关系明确
- 每条规则可构造独立样本
练到的知识点嵌套对象校验
- 作业 08面试场景
促销资格校验通过到提交之间用户切换会员等级。定义资格究竟按校验时、提交时还是订单约定时确定,分别讨论所需证据。
- 提交内容
- 三种业务合同和时间线。
- 怎样算完成
- 校验时点来自业务需求
- 失败反馈与所选政策一致
练到的知识点格式与业务校验
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成集合与复杂度后:批量导入上限一万项,客户端只需要前二十条错误和总失败数。设计错误预算、嵌套深度与业务检查成本控制。
- 提交内容
- 验证器合同、复杂度估算与验收数据集。
- 怎样算完成
- 错误输出和计算工作量分别受控
- 不因截断消息而接受非法数据
练到的知识点嵌套对象校验 · 格式与业务校验
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
批量报名由“全部成功或全部拒绝”改为“允许逐人部分成功”。重新设计嵌套错误路径、重复学员处理和业务状态验证,说明旧响应格式能否完整表达。
- 提交内容
- 前后响应合同、最小样本及一致性检查。
- 怎样算完成
- 部分成功语义精确
- 结果能逐项对应原始输入
练到的知识点嵌套对象校验 · 格式与业务校验
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Spring MVC:RequestBody (新标签页)↗
阅读消息转换器、请求体绑定和校验失败行为,区分 JSON 与表单参数。
- 实践规范英文
Jakarta Validation 规范入口 (新标签页)↗
按依赖版本选择规范,阅读约束、嵌套校验和分组,区分格式与业务校验。
- 深入规范英文
IETF RFC 9110:HTTP 语义 (新标签页)↗
阅读方法、状态码、表示元数据和幂等性;结合真实请求记录理解。
- 深入参考文档英文
Spring 6.2.11 RequestResponseBodyMethodProcessor (新标签页)↗
核对
resolveArgument的转换、绑定器和校验顺序。 - 深入参考文档英文
Jakarta Bean Validation 3.0 Specification (新标签页)↗
阅读级联、约束、分组与
null的语义。