向同一个接口提交 {"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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.pekko.util.ByteString;
import play.libs.F;
import play.libs.Json;
import play.libs.streams.Accumulator;
import play.mvc.Http;
import play.mvc.Result;
import play.mvc.Results;

public Accumulator<ByteString, F.Either<Result, JsonNode>>
apply(Http.RequestHeader request) {
return parser.apply(request).map(parsed -> parsed.left
.map(result -> F.Either.<Result, JsonNode>Left(
Results.status(result.status(),
Json.newObject().put("code", "INVALID_INPUT"))))
.orElse(parsed), Runnable::run);
}

这是实际类的核心方法;注入字段及完整类见源码包的 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
2
3
4
5
6
7
JsonNode body = request.body().asJson();
if (body == null || !body.isObject() || body.size() != 2
|| !body.hasNonNull("name") || !body.hasNonNull("amount")
|| !body.get("name").isTextual()
|| !body.get("amount").isTextual()) {
return invalidJson();
}

检查数量的同时必须检查允许名字。仅有 size() == 2,无法区分 name/amount 与 name/internalId。反过来,只检查两个必需字段,又可能接受第三个不允许的成员。这里的组合给最终树建立了封闭字段集合。

随后调用 textValue(),不使用 asText() 将任意节点转成看似可用的文本。若把数值、布尔值静默转换成字符串,接口对客户端类型错误的拒绝规则就被改变了。

Json.fromJson(node, Dto.class) 提供 Java 对象转换能力,不能替代这组业务决定。是否接受未知属性、如何转换数字、哪个验证器执行,都取决于具体 mapper、DTO 与调用方式。本接口直接检查树,不宣称运行了普通 DTO 的全部绑定分支。

输出同样采用白名单:构造新的 ObjectNode,只写 name 和规范化金额。把完整业务实体交给通用序列化器,可能随新增 getter、关联属性或注解改变公开字段。这里没有业务实体,显式输出仍能固定回执契约。

金额在进入 BigDecimal 前已经受限

实际金额判断为:

1
2
3
4
5
6
7
String amount = body.get("amount").textValue();
if (!amount.matches("(?:0|[1-9][0-9]{0,8})(?:\\.[0-9]{1,2})?")) {
return invalidJson();
}
BigDecimal decimal = new BigDecimal(amount);
if (decimal.signum() <= 0) return invalidJson();
String normalized = decimal.setScale(2).toPlainString();

正则限制表达形式和规模;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
2
3
bash sbtw test stage
python3 lab/content_checks.py --dev
python3 lab/content_checks.py

累计 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。下一篇:表单与验证。源码下载与基线:最小应用。