合法 JSON 在默认路径先经过 parser,再进入审计和鉴权;同一条路由开启 deferred parsing 后,鉴权可以在 parser 之前返回 401。本批真实 HTTP 实验中,未授权的畸形 JSON 分别得到 400 与 401,业务计数均为零。两种结果对应不同执行边界。

Action 组合适合表达某一组控制器操作的公共条件,但注解本身不能证明拒绝请求的代价、审计覆盖范围或失败处理。需要检查 delegate 链的顺序,以及请求体解析发生在这条链的什么位置。

一个可以观察的 delegate 链

累计工程新增 PipelineController。同一个方法通过两条 routes 入口调用,JSON parser 限制为 32 字节,业务方法返回 CompletionStage<Result>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@With({AuditAction.class, AuthorizationAction.class})
@BodyParser.Of(CountingJsonParser.class)
public CompletionStage<Result> json(Http.Request request, String mode) {
recordBusiness(request);
if (mode.equals("sync")) {
throw new IllegalStateException("pipeline-sync-failure");
}
if (mode.equals("async")) {
return CompletableFuture.failedFuture(
new IllegalStateException("pipeline-async-failure"));
}
return CompletableFuture.completedFuture(
ok(Json.newObject().put("kind", "json")
.<JsonNode>set("body", request.body().asJson())));
}

recordBusiness 使用原子计数器并记录请求 id。它发生在方法开头,因此可以区分“控制器进入后失败”和“根本没有进入业务方法”。只看最终 500,无法区分这两种路径。

鉴权层使用合成请求头 X-Lab-Token: allow,其他值返回已完成的 401 Stage,不调用 delegate。这只是回环实验开关;它没有身份验证、令牌签名或资源权限模型,不能直接用作生产认证。

1
2
3
4
5
6
7
if (!request.headers().get("X-Lab-Token")
.filter("allow"::equals).isPresent()) {
trace.add(id, "auth-reject");
trace.add(id, "auth-exit");
return CompletableFuture.completedFuture(
unauthorized("lab authorization required"));
}

调用 delegate 是公共行为与业务之间的明确交接点。提前返回表示这一次调用不继续;正常路径必须把 delegate 的结果传回,不能再额外调用一次。请求签名检查、资源授权和幂等判断都可以采用这种组合形式,但它们的正确性仍取决于检查所用的数据及后续业务状态。

注解如何变成调用顺序

JavaAction.scala 收集方法和控制器上的注解,找出直接或通过元注解声明的 With,构造 action mixins,再设置各 Action 的 configuration 与 delegate。链条通过反转与折叠建立,不能只把反射返回的列表当作实际调用顺序。

在本工程的 @With({AuditAction.class, AuthorizationAction.class}) 下,审计位于鉴权外侧。合法输入的稳定阶段为:

1
2
3
4
5
6
parser-result-200
audit-enter
auth-enter
business
auth-exit
audit-exit

完整 trace 还包含两层 Filter 和 parser 的字节事件。stream 终止回调可能与结果事件交错,因此脚本只把具有调用依赖的事件用于固定顺序断言,保留其余原始事件用于查看流终态。

本篇没有同时叠加控制器级注解、方法级注解和自定义 ActionCreator。它们会扩大排序问题:固定版本组合指南 描述 controllerAnnotationsFirst 与 ActionCreator 的配置;对包含这些配置的工程,应重新取得实际链条。

如果把鉴权放在审计外侧,未授权时审计 delegate 不会被调用。需要记录拒绝事件时,审计必须覆盖拒绝路径,或者由拒绝者自己记录明确的安全事件。审核“所有请求都有审计”时,应该检查这种支配关系,而非仅检查类名中出现了 Audit。

Action 实例与控制器 scope 不能混同

Java Action 带有可变的 delegate 与 configuration。框架为请求装配组合时会写入这些字段。如果同一个 Action 对象被不同请求复用,后一次装配可能覆盖前一次仍在使用的 delegate;共享实例会引入错误路由或上下文混用风险。

3.0.6 的 JavaAction 实现主动检查显式的 javax.inject.Singleton 注解并拒绝这种 Action。官方指南也要求每次请求取得独立的 Action 实例。这与上一篇关于 Router 持有控制器引用的结论不同:控制器和包装 Action 具有不同的装配方式。

检查注解只是一个保护条件。自定义 DI 如果没有标注 Singleton,却仍然返回同一个 Action 实例,不能依赖这项检查自动发现全部复用。可变请求装配对象应保持请求独立;共享服务则需要自行保证线程安全。

工程中的 TraceLog 是线程安全队列,Action 只把它作为依赖保存,请求 id 从本次 request 获取。有限实验可以这样收集事件,但队列无界、查询线性扫描,不能直接变成生产审计存储。

同步抛出与 Stage 失败需要两条路径

对 delegate.call(request) 包一个普通 try/finally,只能覆盖方法调用的返回或抛出。delegate 返回未完成 Stage 时,finally 已经执行,异步业务可能仍未结束。把这时的退出事件解释成“业务执行完成”,会让日志与实际执行脱节。

本批两层 Action 采用相同的失败观察结构:

1
2
3
4
5
6
7
8
9
10
try {
return delegate.call(request).whenComplete((result, failure) -> {
if (failure != null) trace.add(id, "audit-failed");
trace.add(id, "audit-exit");
});
} catch (RuntimeException failure) {
trace.add(id, "audit-sync-failed");
trace.add(id, "audit-exit");
throw failure;
}

同步 throw 尚未返回 Stage,所以由 catch 记录并原样抛出。异步失败已经有 Stage,通过 whenComplete 观察其完成,再把该 Stage 的失败继续传给外层。Action 不自行恢复为成功或把异常拼入响应;后续错误处理器决定正文。本批 DEV 返回调试信息和堆栈,PROD 返回通用错误页,这两种配置不能混称为同一泄漏保证。

真实 HTTP 的同步模式得到:

1
2
3
audit-enter → auth-enter → business
→ auth-sync-failed → auth-exit
→ audit-sync-failed → audit-exit

异步模式则是 auth-failed 与 audit-failed,同样经过两层 exit。两者响应都是 500,业务计数各增加一次。这些结果来自当前普通 Java Action 适配路径;直接包装器测试也分别覆盖两种 delegate 失败,不能用单元测试输出替代真实 HTTP trace。

观察回调本身也可能失败。若审计写入异常从回调抛出,返回的 Stage 会因此失败,甚至影响原结果。生产审计必须明确失败策略:可容忍的日志故障应隔离,要求强制落库的审计则应与业务成功条件一起设计。本例的内存 trace 没有验证远程审计可靠性。

whenComplete 说明 Stage 已到终态,不说明响应正文已发送完毕。流式 Result 可以先完成 Stage,再持续发送内容;审计“业务结果可用”和“完整响应结束”应使用不同事件,04 篇的流实验已经区分过它们。

默认解析与 deferred 解析

两条路由共享同一方法:

1
2
3
POST  /pipeline/json      controllers.PipelineController.json(request: Request, mode: String ?= "ok")
+ deferBodyParsing
POST /pipeline/deferred controllers.PipelineController.json(request: Request, mode: String ?= "ok")

本版 BodyParser.Of 只有 parser 类这一项;不能凭其他版本的写法添加 deferred=true。路由 modifier 是本实验采用的开关。全局 play.server.deferBodyParsing 还涉及开发服务器 settings,不能只改 application.conf 就认定 DEV 与 PROD 都启用。

Action.scala 检查 deferred 属性。普通路径先运行 parser,解析成功才调用 Java Action;deferred 路径先建立尚未解析 body 的 Request,JavaAction 的根 Action 在后续 delegate 位置启动解析。

因此,依赖 body 的公共 Action 不能在 deferred 路径入口直接假定 request.body() 已经可用。头部鉴权可以提前执行;需要 JSON 字段的授权规则必须放在解析后,或者自行采用明确的解析与验证入口。

输入与开关 HTTP parser 调用 业务调用 审计进入
合法 JSON,默认,允许 200 1 1 是
合法 JSON,默认,拒绝 401 1 0 是
畸形 JSON,默认 400 1 0 否
畸形 JSON,deferred,拒绝 401 0 0 是
35 字节输入,deferred,拒绝 401 0 0 是
畸形 JSON,deferred,允许 400 1 0 是

表中的 parser 调用与字节数来自应用计数器。deferred 拒绝证明应用 parser 没有运行,不能证明客户端没发送字节、服务器没有预读或连接层没有丢弃剩余请求体。

2025 年的维护者讨论还涉及 ActionCreator 与解析器的后续排序调整。这说明版本边界需要保留;本篇结论冻结在 3.0.6,并由相同工程的 DEV、PROD 对照取得。

重跑与选择边界

从第 00 篇下载累计源码包,解压后进入 play-lab/。按 README 设置 JDK 21 和固定 sbt launcher,执行:

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

累计 12 项 JUnit 通过;开发与生产各 118 个 HTTP 请求,包括之前的 56 个回归请求和 31 组新增请求/计数查询。脚本使用空闲回环端口、临时密钥,finally 停止它启动的进程;输出保存在 evidence/batch06-08/。

需要的保证 合适入口 必须补的验证
一组业务操作共用头部条件 Action 组合 delegate 顺序与提前拒绝计数
畸形 body 也必须形成全局请求记录 外层 Filter 或 handler parser 拒绝和未知路由
未授权输入尽量不启动应用 parser deferred 或更早的头部拒绝 body 依赖、实际消费和后端行为
异步业务失败也有退出记录 Stage 完成观察,加同步 catch 两种失败、观察器自身失败策略
多请求共享组件 线程安全服务 可变请求装配对象不被复用

Action 的检查条件、共享实例和异步终态各有独立约束。正确组合先确定公共条件所在阶段,再让失败和短路路径进入所声明的记录范围。

参考资料与继续阅读

源码入口为 JavaAction、Java Action API 与组合指南。当前实验没有运行 Play 上游完整测试。

上一篇:依赖注入与配置。下一篇:Filter 与请求处理器。