深入 Play 09:JSON 字段、金额精度与错误契约
向同一个接口提交 {"name":"Ada","amount":"12.34"} 与 {"name":"Ada","amount":12.34},实验分别得到 201 和 400。两个请求都是合法 JSON,区别来自接口对金额表达的约定:只接受受限十进制字符串。
另一个请求包含两次 name,{"name":"first","name":"last","amount":"1"},结果却是 201,输出 name 为 last。控制器检查了字段白名单,仍无法从已经折叠的树恢复原始重复键。语法、字段、数值与原始 token 是不同的检查位置。
接口先约定输入和输出
本篇沿用 Play 3.0.6、Scala 2.13.15、JDK 21 的累计工程,入口为 POST /content/json。它只返回一个合成回执,没有订单写入、支付或数据库副作用。
| 对象 | 当前契约 | 拒绝示例 |
|---|---|---|
| 根节点 | 对象,最终树中恰好两个允许字段 | null、数组、未知字段 |
| name | 非 null 字符串,非 blank,最多 40 个 Unicode 码点 | 缺失、布尔值、41 个码点 |
| amount | 正数,整数部分至多 9 位,小数至多 2 位的十进制字符串 | 数字节点、负数、指数、前导零 |
| 响应 | 只含 name 与规范化 amount | 不输出内部对象或异常详情 |
这里的“字段”指解析后对象的成员,不包含原始重复键拒绝保证。JSON 字符串也不等于允许的业务字符串:"1e2"、"01" 与 "0" 都能完成语法解析,却不满足金额契约。
响应把 "12" 规范化为 "12.00"。如果客户端需要原始输入,应该另行保存明确字段;不应依靠服务端偶然保留的小数位数或浮点输出格式。
parser 错误与字段错误分开处理
@BodyParser.Of(ApiJsonParser.class) 指定本接口的入口。ApiJsonParser 包装内置 Json parser,保留媒体类型、语法和配置的字节限制,再统一其正常拒绝结果的响应体:
1 | |
这是实际类的核心方法;注入字段及完整类见源码包的 app/content/ApiJsonParser.java。包装没有重新实现 JSON 语法,也没有把 415、413 全部改成 400。错误码保留输入阶段的分类,响应体只保留公开契约。
本工程的 play.http.parser.maxMemoryBuffer = 1k 使 1025 字节输入得到 413;畸形 JSON 得到 400,text/plain 得到 415。这些 Left 结果不会调用业务字段判断。空 JSON body 在当前 parser 路径得到 null,控制器再将它拒绝为 400,不能把“没有解析异常”当成有效输入。
该包装只转换 parser 返回的 Left。流失败、程序错误、路由绑定失败、过滤器拒绝仍由各自路径处理;它不是应用全部异常的统一 JSON handler。生产错误处理、安全过滤器和观察接口需要分别建立契约。
最终树的白名单与 DTO 边界
控制器的入口检查同时验证根类型、字段数量、必需字段存在且非 null,以及两个值都是文本:
1 | |
检查数量的同时必须检查允许名字。仅有 size() == 2,无法区分 name/amount 与 name/internalId。反过来,只检查两个必需字段,又可能接受第三个不允许的成员。这里的组合给最终树建立了封闭字段集合。
随后调用 textValue(),不使用 asText() 将任意节点转成看似可用的文本。若把数值、布尔值静默转换成字符串,接口对客户端类型错误的拒绝规则就被改变了。
Json.fromJson(node, Dto.class) 提供 Java 对象转换能力,不能替代这组业务决定。是否接受未知属性、如何转换数字、哪个验证器执行,都取决于具体 mapper、DTO 与调用方式。本接口直接检查树,不宣称运行了普通 DTO 的全部绑定分支。
输出同样采用白名单:构造新的 ObjectNode,只写 name 和规范化金额。把完整业务实体交给通用序列化器,可能随新增 getter、关联属性或注解改变公开字段。这里没有业务实体,显式输出仍能固定回执契约。
金额在进入 BigDecimal 前已经受限
实际金额判断为:
1 | |
正则限制表达形式和规模;signum 再排除零。输入没有超过两位小数,补到两位无需舍入。若以后允许三位小数,必须明确舍入方式或拒绝规则,不能依靠 setScale(2) 偶然抛出的异常决定业务响应。
new BigDecimal(String) 保留这一十进制字符串的数值。转换之前也要限制字符串长度、位数与请求字节数,避免只因 BigDecimal 支持较大数值就允许无限业务输入。
本实验额外读取字面量 123456789.123456789。注入的 ObjectMapper 与 Json.parse 的活动 mapper 都返回 DoubleNode,树文本为 1.2345678912345679E8;直接从原字符串构造 BigDecimal 则保持原值。
ObjectMapperModule取得 Pekko 名为 play 的 mapper,并安装给静态 Json。因而运行中的 mapper 配置,应从实际注入对象与 Json.mapper() 核验,不能直接用另一个 mapper 工厂的默认值代替。
JsonNodeDeserializer.fromFloat根据数值类型与 USE_BIG_DECIMAL_FOR_FLOATS 选择节点。树已经保存了舍入后的浮点值,再转换成 BigDecimal 不能恢复原始十进制 token。字符串金额是本接口选定的表达;其他接口可以采用经过实际验证的精确数值解析,不能推广为“JSON 数字必定不精确”。
重复键要在信息丢失前检查
冻结实现构造 ObjectNode 时逐项调用 node.set(k, v),同名成员被折叠。本次重复 name 请求输出 last,仍通过最终树的两个字段检查。这个输入没有绕过金额限制,却说明“拒绝未知字段”与“拒绝重复键”不是同一保证。
若业务要求重复键必须报错,需要在 token 层或保留原始成员的解析阶段检测。只在控制器遍历 ObjectNode,已没有足够信息。Play 的自定义树分支也没有查询 FAIL_ON_READING_DUP_TREE_KEY;开启这一 databind 选项是否有效,必须针对活动 mapper 和 parser 实测,不能照搬另一个 Jackson 路径的结论。
本篇保留该接受行为作为边界,并未新增严格重复检测。name 的 40 码点限制也只是一种计数口径,不是人眼看到的 40 个字形;组合字符可能占多个码点。下一篇表单约束的 UTF-16 长度还会形成另一个对照。
重跑、反例与改动练习
从第 00 篇下载累计工程,按 RUN.md 准备 JDK 21 与固定 sbt launcher,在解压后的 play-lab/ 执行:
1 | |
累计 19 项 JUnit 通过。更新矩阵的生产模式包含 185 次真实 HTTP 请求、37 项内容用例,也包含前两批回归;开发模式使用同一矩阵。原始观测和 summary 位于 evidence/batch09-11/,请求值均为合成数据。脚本停止自己启动的服务器,预期受控 SIGTERM 退出码为 143。
| 输入 | 本次响应 | 检查位置 |
|---|---|---|
| amount=“12” | 201,“12.00” | 格式、正数与输出 |
| amount=12.34 | 400,INVALID_INPUT | 节点类型 |
| amount=“1e2” 或 “0.001” | 400,INVALID_INPUT | 表达约束 |
| name=null 或增加 internalId | 400,INVALID_INPUT | 最终树字段集合 |
| 畸形 JSON / 错误类型 / 超限 | 400 / 415 / 413,公开错误体 | parser |
| 重复 name=first,last | 201,name=last | 树解析已折叠 |
反例题:先用 DoubleNode 读取金额,再 treeToValue 到含 BigDecimal 的 DTO,能否保证恢复原始 token?依据应是树节点已经保留的信息,而不是 DTO 字段类型。
改动练习:在独立实验配置中加入 token 层的重复键检测,增加重复 name、嵌套对象重复键与正常请求对照。确认检测实际作用于 BodyParser 使用的 mapper,并让拒绝仍进入公开错误契约;不要修改全局 mapper 后只测试一次工具方法。
上一篇:BodyParser。下一篇:表单与验证。源码下载与基线:最小应用。
