深入 Spring 27:查询参数表单与 JSON 的绑定边界
同一个 DTO 为什么得到不同权限字段
表单接口设置了 name、quantity 两个允许绑定的字段,提交 admin=true 后,DTO 的 admin 仍为 false。把请求换成 JSON,使用同一个 DTO 与同一个 InitBinder,admin 却成为 true。类名相同不意味着对象由同一机制填充;允许字段的配置必须对应实际的数据进入路径。
Spring MVC 的参数解析由有序的 HandlerMethodArgumentResolver 处理。@RequestParam 从请求参数中取值,@ModelAttribute 建立模型对象并进行绑定,@RequestBody 读取正文并交给消息转换器。它们最终都产生 Java 方法实参,却经过不同的构造、转换和错误处理路径。
实验冻结 Spring Framework 6.2.11、JDK 21、Tomcat 10.1.46、Jackson 2.18.4,固定源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。完整实现位于下载工程 mvc-lab,执行入口 MvcLab 27。所有参数都从真实 HTTP 请求进入,未直接调用控制器绕过绑定。
方法参数不是一个统一的 Map
RequestMappingHandlerAdapter 准备 InvocableHandlerMethod 及其参数解析器集合。每个参数根据注解、声明类型与解析器支持规则找到对应解析器。解析器返回实参后,方法才具备调用条件。业务日志里没有请求记录,可能只是某个参数在进入方法前就转换失败。
RequestParam 的“请求参数”并不只指 URL 查询串。在 Servlet 模型下,合适媒体类型的表单正文也可以参与参数集合;JSON 正文则不会自动变成同一个参数 Map。把 @RequestBody 改成 @RequestParam 并保留 JSON 客户端,不是等价重构。RequestParamMethodArgumentResolver
本实验使用明确的参数名,并打开 Maven 编译器 parameters 选项,使 Java 方法参数名称可被反射读取。若项目关闭这项元数据,又没有在注解中给出名称,解析失败会发生得更早。请求形状与编译产物同样是可复现条件,不能只复制控制器里的一个注解。
缺失、空串与重复值
/number 声明必需的 int n。?n=4 成功转成整数,完全没有 n 得到 400,?n= 同样得到 400。但两个 400 不能合并成同一种输入:前者缺少命名参数,后者出现了参数名但提供空值,在转换为不可为空的 primitive 时不能得到合法实参。
为了让差别直接可见,/optional 声明非必需 String 参数。没有 n 时程序把 null 显示为 <missing>;?n= 则返回空字符串。业务若把缺失解释成“不修改”、把空串解释成“清空”,必须保留这种差别。过早统一 trim 和默认值,可能丢失更新协议需要的语义。
/repeated?tag=a&tag=b 声明 List<String>,本次得到 ["a","b"]。重复值不是 HTTP 格式错误,也不是默认只保留最后一项。标量声明如何处理多个值还取决于转换规则,不能把集合实测结果泛化到所有类型。对于权限、账户或签名相关字段,应用应明确是否接受重复值,避免前后组件取值规则不同。
这组断言把输入状态先分类,再检验转换结果。用一条“非法参数返回 400”的测试覆盖所有情况,会遗漏 null、空集合、空字符串及 primitive 默认值之间的重要区别。参数层的测试目标应落在控制器实际收到的值,而不仅是响应成功。
ModelAttribute 的绑定与错误集合
表单入口使用 @ModelAttribute("order") Order order,紧跟一个 BindingResult。Order 提供 name、quantity、admin 属性;@InitBinder("order") 对应相同的模型名称,只允许 name、quantity。提交 name=desk&quantity=2&admin=true&extra=x 后,name 与 quantity 正常写入,admin 保持 false,admin 和 extra 出现在 suppressed 字段集合中。
这里的 allowlist 控制 WebDataBinder 的属性绑定。允许列表以目标用途为准,比逐一禁止当前已知危险字段更容易审查:将来 DTO 新增一个敏感属性,不会因遗漏 denylist 自动暴露。模型名称也属于配置的一部分,InitBinder 限定了 order,却把 ModelAttribute 改名为 account,原有定制就可能不再应用。InitBinder 文档
绑定过程可能产生字段错误,而不立即终止方法调用。提交 quantity=oops 时,字符串不能转换成 int,BindingResult 保存一个字段错误;由于它紧邻对应 ModelAttribute,本实验控制器仍执行并将错误数返回。因此得到 HTTP 200 只表示这段控制器选择了 200,并不表示绑定成功。
该示例用于暴露错误状态,不能照搬为生产成功响应。业务入口应在继续处理前检查 BindingResult,或者不声明该参数,让框架沿异常路径处理。忽略字段错误而读取 quantity 的默认值 0,会把“用户输入不合法”误解释成一个有效的零值订单。
BindingResult 的位置用于关联目标参数,不是随意放在签名末尾的全局错误袋。多个模型对象时,应分别放置各自结果并明确处理范围。模型绑定还可能涉及构造器、嵌套路径、集合增长等行为;本篇使用简单 JavaBean setter 路径,未把它扩张为所有绑定方式的结论。ModelAttributeMethodProcessor
JSON 对象先由转换器创建
RequestResponseBodyMethodProcessor 首先调用 readWithMessageConverters,由匹配的转换器读取正文并产生对象。之后才建立 WebDataBinder,用于适用的校验与错误结果处理。对象的字段反序列化已经发生,因而设置 DataBinder 的 allowedFields 不会倒退执行一次 JSON 字段过滤。固定版本 RequestBody 参数解析
POST /json 发送如下正文。代码和请求都完整保留在 MvcLab 中,本例刻意使用含敏感字段的 DTO 来展示风险。
1 | |
当前 MVC 默认 Jackson 构建方式忽略未知属性 extra,返回 200;已知可写属性 admin 被设成 true。两件事应分别讨论:未知字段策略决定额外属性是否报错,DTO 的可反序列化属性集合决定调用者能赋值什么。开启未知字段报错,并不能阻止客户端给一个本来就存在的 admin 属性赋值。
同样,这不是“Jackson 总会忽略未知字段”的结论。直接创建的 ObjectMapper、自定义 Jackson 配置、注解或不同转换器可能有不同策略。本文验证的是 Spring 6.2.11 的当前 MVC 配置配合 Jackson 2.18.4 的结果,未手动替换 ObjectMapper。Jackson2ObjectMapperBuilder
安全的请求 DTO 应只暴露该操作允许客户端提供的字段,服务器管理的权限、状态、价格等从可信来源计算。DTO 与数据库实体直接复用会扩大外部可写面;即使配有 Bean Validation,admin=true 也可能在格式上完全合法,校验框架不会自动替业务决定权限。
转换与校验的先后关系
绑定回答“怎样得到一个 Java 值”,校验回答“这个值是否满足约束”。quantity=oops 连 int 都无法形成,应首先报告转换失败;quantity=0 可以形成 int,随后才可能违反 @Min(1)。把这两类错误都称为“校验异常”,会让定位停在错误的层次。
JSON 同样如此。语法损坏、无法构造 DTO、属性类型不兼容都可能在 Bean Validation 之前失败;完整合法 JSON 也可能缺少业务要求的字段。只有在对象已创建后,适用的 @Valid 或方法校验才能检查约束。下一篇将这些阶段与异常类型逐一对应。
在接口设计中,应为缺值、空值、默认值与服务端字段制定独立约定,再决定解析注解和 DTO。先选择一个“能绑定成功”的注解,之后通过大量 if 修补语义,容易遗漏未知字段及重复参数等边界。
复现与反例题
在下载工程执行:
1 | |
本章 13 条断言保存于 evidence/27/local-20261002/,其中四条验证执行器、上下文和端口关闭。响应 JSON 属性顺序不作为契约;关键值、suppressed 集合与绑定错误数才是观察对象。
推导题:只给 Order.admin 添加 @NotNull 能否阻止客户端传 admin=true?不能。对本例 primitive boolean 使用该约束也没有取得权限控制的意义;约束关注值是否可接受,字段是否应由客户端赋值必须在请求模型和授权逻辑中决定。
改动练习:新建只含 name、quantity 的 JSON 请求 DTO,保留包含 admin 的服务端对象。让控制器在校验后显式映射允许字段,并从服务端固定 admin=false。重复发送上述 JSON,分别启用和关闭未知字段报错,记录状态码与服务端结果。预期安全目标不依赖于“是否允许多余 JSON 属性”这项兼容性选择。
完整工程下载见 深入 Spring(00):从手动组装到可验证的容器实验。六章共用独立的 mvc-lab 模块,单章参数决定执行场景;正文中的改动练习未计入已通过断言。
参考资料
- WebDataBinder 固定源码。
- ServletModelAttributeMethodProcessor。
- RequestResponseBodyMethodProcessorTests:转换与校验的测试入口;本地只运行随文的真实 HTTP 实验。

