qinghuan
← Java Web / 全部笔记

单元 43 / Java Web

P0 · 必会主线L1 · 基础

参数绑定与校验:别让错误输入走进业务

先从正确位置读取并转换请求,再分别检查字段格式、当前业务状态和用户权限,给出准确错误。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能设计与实际请求一致的 DTO,并区分字段格式、数据状态和权限校验。

学习理由、准备要求与知识点

为什么学它

明确输入位置与服务端校验,才能阻止错误格式和越权请求进入业务。

先读这些内容

知识点清单

  • PathVariable
  • RequestParam
  • RequestBody
  • 消息转换器
  • 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["事务与数据库最终约束"]

按顺序看,每一步做了什么

  1. 确认参数位于路径、查询还是正文。
  2. 使用匹配类型的转换方式,并定义媒体类型和容量。
  3. 校验字段与嵌套对象,保留确定的错误路径。
  4. 进入业务后检查真实状态与身份,再用数据库约束处理竞争。

03对照实现代码

RequestResponseBodyMethodProcessor 先转换再校验 (新标签页)↗

Spring Framework v6.2.11 · RequestResponseBodyMethodProcessor#resolveArgument

已核验 readWithMessageConverters、createBinder、validateIfApplicable 与错误分支。下例是原创普通路径模型,省略可选参数、BindingResult 相邻参数、方法级校验及异常解析器;模型异常不等于框架真实异常类型。

核心机制代码 · java
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。真实应用还可能存在方法级约束、类型转换等其他路径,不能只捕获这个异常就声称所有参数错误都已覆盖。应按所用版本与参数声明设计异常处理和集成样例。

框架保存了绑定与校验结果,但不会替你判断分类是否已停用,或订单是否属于当前用户。若在所有校验器里随意查数据库,还会增加负载并让事务和测试变复杂。格式等纯输入检查宜便宜且确定,依赖实时状态的判断则放进清楚的业务步骤。

代码先调用谁,再调用谁

  1. 参数解析器确认支持 @RequestBody。
  2. 消息转换器读取并构造目标对象。
  3. 绑定器按约束元数据执行验证。
  4. 根据绑定结果进入业务方法或框架错误处理。

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,总数只累计通过单项数量规则的值;每条错误路径指向实际失败规则。

时间 期望 O(n+L)额外空间 O(n)

n 是允许扫描的配料项数,L 是名称和数值表示相关处理长度;集合去重按通常哈希模型计。错误列表和去重集合线性增长,输入规模上限同时限制最坏诊断量。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 44 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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,并区分字段格式、数据状态和权限校验。

本篇重点加练

重点检查嵌套输入完整性与业务校验时点,防止格式正确的数据绕过真实规则。

  • 嵌套对象校验
  • 格式与业务校验

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    设计修改订单收货地址接口:订单号放路径、dryRun 放查询参数、地址对象放 JSON。标出每项数据经过的绑定步骤,定义媒体类型不支持和 JSON 无法解析的处理合同。

    提交内容
    接口说明、绑定链图和失败表。
    怎样算完成
    • 路径查询消息内容职责明确
    • 转换失败不与字段约束混同

    练到的知识点PathVariable · RequestParam · RequestBody · 消息转换器

  2. 作业 02原理问答

    地址请求含收件人、邮编和地址列表,允许列表为空但不允许出现 null 元素。设计字段和嵌套对象校验规格,区分缺失、null、空串和空列表。

    提交内容
    约束矩阵及待验证请求样本。
    怎样算完成
    • 覆盖嵌套触发边界
    • 每项非法输入有定位路径

    练到的知识点Valid 与字段约束 · 嵌套对象校验

综合应用

  1. 作业 03面试场景

    商品单价请求出现 1.230、负数以及字符串“1,23”;提交前商品可售,真正写入前被停用。区分格式、数值政策和业务时效检查,并说明实际框架版本的注解包来源。

    提交内容
    三层校验流程和版本核验记录。
    怎样算完成
    • 金额政策可执行
    • 业务状态有提交时检查边界
    • 不混用不同命名空间

    练到的知识点格式与业务校验 · 版本命名空间和金额类型

  2. 作业 04编程实践

    实现课程报名请求的本地校验:studentId、最多五个 courseId、可选 note;自行规定重复 courseId、null 元素、超长 note 的政策,收集稳定字段路径。

    提交内容
    Java 校验器或本地接口、自写合法/边界/非法测试和运行证据。
    怎样算完成
    • 所有错误可定位输入位置
    • 输入规模受控
    • 输出顺序有明确合同

    练到的知识点PathVariable · RequestParam · RequestBody · Valid 与字段约束 · 嵌套对象校验

  3. 作业 05排错实验

    在独立测试工程中构造一个能绑定但未触发预期校验的请求,分别核对依赖、注解导入、控制器参数和实际 HTTP 请求;一次仅修改一个因素。

    提交内容
    失败复现、排查记录及修复前后请求证据。
    怎样算完成
    • 能定位根因而非批量改配置
    • 测试经过真实消息转换边界

    练到的知识点消息转换器 · Valid 与字段约束 · 版本命名空间和金额类型

  4. 作业 06编程实践

    实现报名批次校验器:每个学员有多门课程,同批不能重复学员,每人最多三门,停用课程不可报名;课程状态由可控查询接口提供。

    提交内容
    Java 实现、自写嵌套/重复/状态失败测试与运行证据。
    怎样算完成
    • 错误能关联学员与课程位置
    • 本地结构检查和外部状态检查分别验证

    练到的知识点嵌套对象校验 · 格式与业务校验

  5. 作业 07设计评审

    嵌套请求包括可选监护人和必填联系方式,监护人缺失与提供不完整对象属于不同输入。建立每层 null、空对象和空字段的规则矩阵。

    提交内容
    嵌套路径图与输入矩阵。
    怎样算完成
    • 父对象状态与子字段约束关系明确
    • 每条规则可构造独立样本

    练到的知识点嵌套对象校验

  6. 作业 08面试场景

    促销资格校验通过到提交之间用户切换会员等级。定义资格究竟按校验时、提交时还是订单约定时确定,分别讨论所需证据。

    提交内容
    三种业务合同和时间线。
    怎样算完成
    • 校验时点来自业务需求
    • 失败反馈与所选政策一致

    练到的知识点格式与业务校验

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成集合与复杂度后:批量导入上限一万项,客户端只需要前二十条错误和总失败数。设计错误预算、嵌套深度与业务检查成本控制。

    提交内容
    验证器合同、复杂度估算与验收数据集。
    怎样算完成
    • 错误输出和计算工作量分别受控
    • 不因截断消息而接受非法数据

    练到的知识点嵌套对象校验 · 格式与业务校验

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    批量报名由“全部成功或全部拒绝”改为“允许逐人部分成功”。重新设计嵌套错误路径、重复学员处理和业务状态验证,说明旧响应格式能否完整表达。

    提交内容
    前后响应合同、最小样本及一致性检查。
    怎样算完成
    • 部分成功语义精确
    • 结果能逐项对应原始输入

    练到的知识点嵌套对象校验 · 格式与业务校验

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗