同样是 32 字节上限,已知 Content-Length 为 35 的 JSON 请求在本批得到 413,parser 字节计数为零;没有 Content-Length 的分块请求也得到 413,却已经向计数流交付了 35 字节。长度声明可以让拒绝提前,实际字节限制仍需要流级检查。

parser 成功也不代表业务输入有效。显式 JSON parser 接收空请求体后,本版得到 null 并进入控制器;默认 parser 的空请求则返回 empty 表示。接口需要自己决定空输入能否接受,不能把两种入口的结果合并为“JSON 已经验证”。

BodyParser 返回消费器而非立即返回对象

Java BodyParser 的核心接口是:

1
2
Accumulator<ByteString, F.Either<Result, A>>
apply(Http.RequestHeader request);

它接收请求头,返回一个可以消费 ByteString 的 Accumulator。正文可能还在到达,不能把 apply 返回解释成已经取得整个输入对象。

Either.Right(A) 表示得到解析值,后续可以构造带 body 的 Request;Either.Left(Result) 表示 parser 已经形成提前响应,不再进入对应业务方法。流也可能失败,这与得到 Left 的正常拒绝结果是不同终态。

“异步接收”与“完整缓冲”可以同时成立。JSON 和文本 parser 会先收集允许范围内的字节,再转换成树或字符串;不会因为接口返回 Accumulator,就自动逐元素运行 JSON 业务逻辑。大文件、流协议或逐块处理需要采用相应消费方式。

默认选择与显式约束

没有显式指定 parser 的 /pipeline/any 使用 Default。它检查请求是否具有 body,再依据 Content-Type 选择表达。实验方法只返回当前表示的名称,不尝试把任意 body 都强转 JSON。

输入 默认入口结果 显式入口结果
application/json,{} json,200 JSON,200
text/plain,hello text,200 JSON 拒绝,415
application/octet-stream,abc raw,200 本批未配置原始字节业务
空 body,application/json empty,200 JSON null,200
空 body,text/plain 默认入口未单独测此类型 Text 空字符串,200
application/json,{ parser 拒绝,400 JSON 拒绝,400

默认选择不是内容协商,也不是“自动发现内容语义”。客户端声称 text/plain,Default 按文本路径处理,即使正文恰好长得像 JSON。创建订单接口如果只接受 JSON,应使用显式 Json parser 并做结构约束。

BodyParser.Json 在本版接受 application/json 与 text/json;错误类型先返回 415。它继承 TolerantJson,但先加上类型检查。“Tolerant” 指不要求这一 Content-Type 条件,不代表忽略畸形 JSON,也不取消长度限制。

BodyParser.Text 同样检查 text/plain。3.0.6 的实现未显式给 charset 时使用 US-ASCII;本批要让中文保持原值,采用 charset=utf-8。请求头字符集、字节到字符转换和最大长度检查分别负责不同约束。

Text 的 decoder 首次解码采用 REPORT,但 catch 后会调用同一字符集的 decodeString 返回替换文本。真实 HTTP 中,不声明 charset 的“你好”得到替换字符;声明 UTF-8 却发送字节 ff 同样返回200与替换字符。因此这个 parser 没有提供“非法编码必定400”的严格拒绝契约。

默认 text/plain 路径使用另一个类 TolerantText。它先尝试声明的字符集或 US-ASCII,再依次回退 UTF-8、ISO-8859-1,最后才使用替换解码。显式 Text 与默认文本路径连字符集策略都不同,不能只因都返回 String 就推广相同语义。该回退链已核对冻结源码,本批没有穷举全部非法编码组合。

自定义计数器保留内置限制

工程的 CountingJsonParser 包装内置 Json,不重新实现 JSON 语法。构造时固定 32 字节上限,然后在输入 flow 上记录实际交付的每个块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
parser = new BodyParser.Json(MAX_BYTES, errorHandler);

Flow<ByteString, ByteString, ?> counting =
Flow.of(ByteString.class).map(bytes -> {
trace.add(id, "parser-bytes-" + bytes.size());
return bytes;
}).watchTermination((unused, completion) -> {
completion.whenComplete((done, failure) ->
trace.add(id, failure == null
? "parser-stream-end" : "parser-stream-failed"));
return completion;
});

return parser.apply(request).through(counting).map(parsed -> {
trace.add(id, "parser-result-" +
parsed.left.map(Result::status).orElse(200));
return parsed;
}, Runnable::run);

这个包装保留内置类型验证、最大长度与错误处理。字节 flow 只观测进入该 parser 的数据,没有声称记录 TCP socket 接收量。若内置 parser 依据请求头直接完成,flow 可以没有数据而结束。

parser-result-200 是实验中对 Right 的命名,不等于 parser 单独发送了 HTTP 200;最终业务仍可产生其他状态或失败。事件名称必须结合记录位置解释,不能直接当作响应状态的唯一来源。

计数器不拥有文件、socket 或额外缓冲池。其流终止由包装链传播,实验队列保存有限样本。这种“包住已有实现”的方式减少了一套重复的长度与语法判断,也使观测范围可以精确到组件交接点。

若自定义 parser 自行打开临时文件,就必须把成功、Left、流失败和取消都接到清理路径。当前没有文件资源,不能用这份实验替代上传临时文件释放验收。

长度检查有声明与实际流两层

MaxLengthBodyParser.apply 首先检查 Content-Length 是否超过 maxLength。若超过,直接返回 requestEntityTooLarge 的 done Accumulator。这个分支可以避免继续给缓冲 parser 交付数据。

未超限或没有该头时,代码将 takeUpTo(maxLength) 流与实际 parser 的 sink 组合。终态被标记为 MaxSizeNotExceeded 才返回 parser 结果;超限状态改为 413。这一层覆盖未知长度的请求体。

BufferingBodyParser 在范围内通过严格输入或 Sink.fold(ByteString.emptyByteString(), ByteString::concat) 收集数据,再调用 parse。异常解码交给 HttpErrorHandler 产生 400。限制发生在字节收集链上,不能只在拿到大字符串后判断 length。

已知超限与未知长度超限的处理代价自然不同。前者使用声明提前拒绝,后者必须看到足够多实际输入才能确认超限。一个过大的已知请求在应用 parser 中计数为零,仍可能已经进入服务器或代理缓冲;应用与传输观测不是同一层。

冻结版本的上游 MaxLengthBodyParserSpec分别覆盖有头超限、有头允许、无头允许与无头超限。本批阅读该测试后,在累计应用中运行自己的 parser 与真实 HTTP 对照,没有运行整个上游 suite。

UTF-8 边界与分块请求

JSON 字符串的引号也属于输入字节。十个“中”各占三个 UTF-8 字节,加上两个 ASCII 引号,合计 32 字节;再加 x 得到 33 字节,十一个“中”则是 35 字节。

1
2
3
exact = ('"' + "中" * 10 + '"').encode("utf-8")
assert len(exact) == 32
chunks = [exact[:16], exact[16:] + b"xxx"]

边界必须在真实编码后计算。Java String.length 计数 UTF-16 code unit,中文字符数量也不是 UTF-8 大小。对包含补充平面字符的输入,这几种数量还可能进一步不同。

场景 实际输入字节 HTTP 计数流交付字节 业务调用
JSON 固定长度,刚好上限 32 200 32 1
JSON 固定长度,上限加一 33 413 0 0
JSON 固定长度,十一中文字符 35 413 0 0
JSON chunked,两个允许块 32 200 32 1
JSON chunked,16 + 19 35 413 35 0
Text,341 个中加 x,UTF-8 1024 200 此表未包装 Text 计数器 1
Text,再增加一个 x 1025 413 此表未包装 Text 计数器 0

Text 的 1024 来自本工程 play.http.parser.maxMemoryBuffer=1k;JSON 的 32 来自显式构造。不能把这两个实验值写成所有 Play 应用的默认上限。

Python http.client 对字节列表使用 chunked 传输,未预先提供总 Content-Length。原始结果保存分块情形与字节数;在本次样本中两个块分别交付 16、19 字节。网络和后端有权改变块的划分,生产代码不能依赖每次都保持客户端写入边界。

允许输入的内存上限、并发请求数和其他副本决定应用的总资源占用。每个 parser 有 1k 上限,不等于整个服务只占 1k。代理、服务器、解码后的对象树与日志还会使用其他内存。

取消、流失败与 HTTP 拒绝

chunked 超限时观察到 parser-stream-failed。这个事件属于流终止,不应直接翻译成“客户端断开”:超限引起的消费终止或取消也可能进入这个分支。

JUnit 另外向 parser 交付 failed Source,要求原始失败传播,并记录 stream-failed。它验证 parser 组合面对失败流的行为,不是一次真实 TCP 中途断线。真实网络断开后的资源释放仍需要持有资源的组件和后端另做验收。

stream 终止回调与结果映射属于不同完成观察,原始日志中二者可以交错。断言因果关系时,应固定“鉴权先于业务”等真实调用关系;不能给所有并发回调强行规定一个全序。

长度超限、语法畸形和流异常也应采用不同指标。413 是容量拒绝,400 是本接口解析拒绝,失败流没有解析结果;它们触发不同的排查与客户端修正,统一计成“JSON错误”会丢失原因。

解析之后还有输入契约

显式 Json 读取空字节时调用 Json.parse(InputStream),再交给 mapper.readTree。本次得到 null,没有抛出解析异常,于是 Right 到达控制器,返回 {"kind":"json","body":null}。

这不授权创建空订单。API 需要检查根节点是否对象、必填字段是否缺失或 null、数值与字符串范围、未知字段以及跨字段业务约束。字段反序列化与这些规则还具有不同边界,09 篇继续用同一工程验证。

当接口宣称“畸形输入不进入业务”,也应说清畸形的范围。本批 { 触发语法拒绝且业务计数为零;空输入属于 parser 接受但业务契约应主动判断的情况。仅断言响应状态,容易漏掉这条路径。

重跑与证据范围

解压累计源码包,进入 play-lab/,按 README 准备固定工具链后执行:

1
2
3
bash sbtw test stage
python3 lab/pipeline_checks.py --dev
python3 lab/pipeline_checks.py

累计 12 个 JUnit 与两种模式各 118 个请求通过。脚本包含 00–05 的回归,也核验 deferred 拒绝、Filter 短路与本篇字节矩阵。它会停止自己启动的服务器,并保存 dev-summary.json、http-summary.json、请求观测和运行日志。

本篇没有数据库、文件上传或压测。源码提供实现依据,应用测试提供当前环境的功能证据;它们都没有证明跨后端传输行为或生产容量。

参考资料与继续阅读

参考 Java BodyParser 实现、Java BodyParser 指南、Accumulator 与源码 Json。所有引用冻结到同一 Play SHA。

上一篇:Filter 与请求处理器。下一批从 JSON 字段契约开始,发布前不生成文章链接。