深入 Play 10:表单绑定、跨字段验证与库存竞争
quantity=1&quantity=5&confirmation=1 进入普通 Form 绑定后,当前实验取得 quantity=1,且没有验证错误。调换两个 quantity 的顺序,把 confirmation 改成5,绑定值也变成5。标量值已经被选择,后续验证器无法据此判断原始字段是否重复。
另一个问题发生在验证之后:两个输入完全有效的请求争抢一份库存,得到 201 与409。表单有效只说明当前输入满足约束;库存是否仍可扣减,由执行业务操作时的共享状态决定。
从请求体到业务结果
本篇使用 Play 3.0.6 的 FormFactory、Form 与 Bean Validation 集成。实验有三条不同入口:GET /content/form 生成带 CSRF token 的页面;POST 同一路径执行严格表单检查和预约;POST /content/form/ordinary 只观察普通绑定,不改变库存。
| 阶段 | 负责的问题 | 当前失败表达 |
|---|---|---|
| urlencoded parser | 媒体类型与请求字节 | parser 拒绝 |
| 原始字段检查 | 未知字段、重复标量 | 400,UNKNOWN_FIELD / DUPLICATE_FIELD |
| 类型绑定 | quantity 能否转换成 Integer | 400,FORM_INVALID |
| 字段与类约束 | 范围、空白、跨字段相等 | 400,FORM_INVALID |
| 库存操作 | 当前库存能否原子扣减 | 409,STOCK_CONFLICT |
本例把绑定与约束失败汇入一个页面码,页面错误列表仍展示对应字段。对外错误结构可以合并,机制上的发生位置仍需要区分,否则会把库存冲突错误归到“输入不合法”。
绑定前保留多值信息
控制器先取得 Map<String, String[]>,检查字段名属于 name、quantity、confirmation,并要求每个标量数组只有一项。随后才执行:
1 | |
完整 imports、注入字段与路由在累计工程的 ContentController 中。allowedFields 限定绑定范围,明确的 UNKNOWN_FIELD 响应来自前面的字段名检查;不能把该参数描述为自动拒绝所有未知字段。
Form.fillDataWith对普通标量取索引0。以 [] 结尾的字段有索引展开逻辑;当前业务只允许三个标量,不采用数组字段表达,因此在信息折叠之前拒绝重复。
requestData还支持其他 body 表达。通用 Form API 的能力不等于本接口全部接受:控制器显式指定 FormUrlEncoded parser,JSON 表单输入不是这个业务入口的契约。
POST、PUT、PATCH 的该提取路径不使用 query 补齐字段。实验将 name 放到 POST query,body 只包含数量和确认值,name 仍得到 required 错误。这个行为应按方法与入口说明,不能推广为所有绑定操作都会忽略 query。
Required、长度和类级校验
ReservationForm 使用 Integer 保存数量与确认值,避免把“缺失”混成原始类型的0。quantity 具有 Required、Min(1)、Max(5),confirmation 具有 Required。name 具有 Required 与 MaxLength(40),类本身再标注 @Constraints.Validate。
RequiredValidator对字符串检查是否 empty;只有空格的字符串并不 empty。当前类级方法另用 isBlank() 返回 name.blank,才实现空白拒绝。
类级 validate 还检查 quantity 与 confirmation 相等:
1 | |
null 防护是必要的。缺失字段或绑定错误可能使类内值不可用,不能假定所有字段验证已经成功才调用类级方法。返回空 List 表示当前类级检查没有错误,不表示业务库存已经满足条件。
MaxLengthValidator调用 String.length(),单位是 UTF-16 code unit。补充平面的 😀 占两个单位,本次表单提交21个得到长度42并拒绝;前一篇 JSON 接口按码点计数,40个仍可接受。这是两种明确不同的长度契约,不能都写成同一个“40字符限制”。
改成另一种计数规则时,应同时修改约束、前端说明和边界测试。码点也不等于字形,语言和组合字符的产品约定需要另行决定。
有效输入仍可能遇到业务竞争
StockService 是当前应用中的单例,初始库存为1,使用 AtomicInteger 的 compareAndSet 循环:
1 | |
读取足够并不形成承诺;只有 CAS 成功才完成扣减。竞争者修改值后,失败的 CAS 重新读取,再判断剩余库存。两个 quantity=1 的有效请求最终只允许一个成功。
JUnit 用两个 ready 与共同 start 建立可控提交时点;真实 HTTP 也使用双客户端屏障。两者均得到201/409,随后库存查询为0。脚本再将合成库存重置为1,继续运行 CSRF 验证,因此 summary 的 final_stock=1 是清理后的状态,不能当成预约未扣减的证据。
这只证明单 JVM 内该 AtomicInteger 的操作。它没有数据库事务、持久订单或跨实例共享库存,不能承担多进程订单唯一性。第22–23篇才引入真实 PostgreSQL、连接与事务终态。
失败页面保存原始表达
绑定失败后,如果只把 DTO 转回表单,quantity=oops 已无法表示为 Integer。模板使用原始 values 回填输入框,并把全部原始值放到 submitted-values 列表。重复字段虽然不允许继续业务,两个提交值仍可用于解释错误。
回填有输出安全要求:输入属性和列表文本都走 Twirl 的普通字符串转义,不把输入包成 Html。真实浏览器提交 <script>window.formProbe=1</script> 与非法数量 oops 后,页面显示 FORM_INVALID,数量仍为 oops,脚本文字也保留;submitted-values 内没有 script 元素。
GET 页面使用 @AddCSRFToken,模板的 action 经 helper.CSRF 携带 token。浏览器以自己的 Cookie 和该 action 提交,没有使用实验 bypass 头。真实 HTTP 另用相同 Cookie 对照:有 token 的非法数量到达验证并返回400;缺 token 的有效字段返回403。
这些结果只证明本机 Cookie/token 路径。实验矩阵其他请求采用的 X-Play-Lab 合成头仅用于减少无关前置条件,不能成为生产 CSRF 安全结论。跨域凭据与代理信任继续由第27篇覆盖。
重跑与练习
从第00篇取得累计工程,按 RUN.md 准备版本,在 play-lab/ 执行:
1 | |
累计19项 JUnit 通过,HTTP 矩阵同时保留 JSON、Form、库存、CSRF 与 Assets 观测。浏览器证据独立保存为 browser-receipt.json 与截图,不把 Helpers 路由测试称为真实浏览器测试。
| 表单输入 | 结果 | 库存影响 |
|---|---|---|
| quantity=abc | 400,绑定失败 | 不扣减 |
| quantity=0 或6 | 400,范围约束失败 | 不扣减 |
| quantity=1,confirmation=2 | 400,跨字段失败 | 不扣减 |
| 重复 quantity=1,5 | 严格入口400,普通入口取1 | 普通入口只观察 |
| 两个有效 quantity=1 | 一个201,一个409 | 剩余0,随后重置 |
反例题:将库存查询加入 validate,然后在控制器普通减一,能否避免两个有效请求超卖?验证与修改之间没有原子性,两个请求可以分别看到同一份库存。
改动练习:把 name 的表单约束改成40码点,保留 null/blank 规则,增加20、21、40、41个 😀 与组合字符用例。用结果解释计数口径,保持原始失败表达与安全回填。不要通过截断输入来让验证静默通过。
上一篇:JSON 字段与错误契约。下一篇:Twirl 与静态资源。源码与重跑基线:最小应用。

