深入 Play 04:Request 与 Result 的状态边界
把请求属性加入 Request,或给 Result 加一个头,都会得到新的对象;忽略返回值,后续代码可能继续使用旧状态。把用户名字写入 session,数据出现在浏览器 Cookie 中;签名阻止篡改,却没有让名字不可读。拿到一个流式 Result,也只说明响应描述已经存在,尚不能说明客户端收完了正文。
这三组边界分别涉及对象更新、客户端状态和实体消费。它们共享一个原则:对外观察需要明确时间与载体,不能用“请求成功了”概括所有变化。
Request 保存了什么,更新影响谁
Play 的 Http.Request 包含方法、路径、头、属性以及已解析的请求体等信息;Http.RequestHeader 不要求持有已解析的 body。第 02 篇默认 parser 成功后,才把 body 放进交给业务方法的 request。
对象更新接口返回新请求:
1 | |
固定的 Java request 适配实现 创建新的 request 视图。本批 JUnit 断言验证旧对象没有这个属性,新对象具有对应值。实际代码若只执行 request.addAttr(key, value); 而继续传原 request,属性没有自动进入下一层。
这种不可变更新没有递归冻结所有引用。如果属性值是一份可变列表,两份 request 仍可能引用同一列表;typed key 约束取值类型,不提供线程安全或深复制。跨线程传递身份上下文时,应该使用明确的不可变值,并把更新后的 request 显式传递给下游,而不是依赖静态变量模拟“当前请求”。
当前示例没有 ThreadLocal 上下文;后续异步章节再验证换执行器与日志串值。这里的对象断言不能证明 MDC 或第三方遥测上下文会自动传播。
Result 的头、状态和实体是不同部分
Result 描述响应状态、头、Cookie 变化与 HTTP 实体。增加头仍返回新结果:
1 | |
这与 request 更新类似:必须返回或传递 second,不能期待 first 已被修改。Result 的 session 更新实现 同样创建新的结果对象,而非直接修改传入 request 的会话。
本批 /shape/:kind 对照三种结果:
| 路径 | 当前状态 | 实体 |
|---|---|---|
| /shape/text | 200 | 明确的 UTF-8 文本 hello |
| /shape/empty | 204 | 无内容 |
| /shape/other | 422 | 文本 unknown shape |
204 用于表达无内容,不能用 ok("") 推断成同一个 HTTP 契约。状态与正文类型也没有自动的一一关系:业务可以返回状态为 422 的文本或 JSON,但应固定客户端可理解的错误格式。
HttpEntity 把内容类型、可能已知的长度和数据流分别表示。该 API 中 contentLength() 返回 Optional,不知道长度不等于长度为零。consumeData 还会真正消费流,对于一次性实体,这种消费可能影响后续使用;调试时不能为了打印大 body 就无条件收集整个流。
session 变化通过下一次请求观察
设置与读取采用显式 request:
1 | |
设置方法构造包含 Cookie 更新的响应。当前 request 的 session 表示这次入站请求携带的数据;只有客户端接收并保存响应 Cookie,后续请求再发送它,读取方法才看到新的名字。把同一次 request 当作已经被客户端回传的状态,会混淆通信方向。
Play 默认 session 数据在客户端 Cookie 中,值是字符串映射,不是服务器 HttpSession 对象。固定 Java session 文档 说明了这个边界与 Cookie 容量限制。不要往其中塞大对象、完整订单或敏感凭据,也不要认为用户 Cookie 未过期就自动代表服务器上的账号仍可访问资源。
客户端先请求 /session/set?name=Ada,保存 PLAY_SESSION,随后带 Cookie 请求 /session/read,得到 Ada。无 Cookie 时返回 absent。响应属性实测包含 Path=/、SameSite=Lax 和 HTTPOnly;属性名大小写不影响其含义,测试按大小写不敏感方式比较。
当前服务器只监听本机 HTTP,没有 TLS,因此本实验没有验证 Secure Cookie 在 HTTPS 站点的发送条件。HttpOnly 限制脚本读取,SameSite 影响跨站发送,两者都没有给 payload 加密,也不替代授权。真实 Cookie 策略需要结合站点协议与请求来源核验。
默认 JWT 签名提供完整性,不提供机密性
当前默认编码器写 JWT,并保留读取旧签名编码的 fallback;DefaultSessionCookieBaker 与 FallbackCookieDataCodec 说明了写入和兼容读取的区别。不能把旧版 URL 编码示例当作 3.0.6 的默认写格式。
本次 Cookie 有三个点分段。客户端不掌握签名密钥,仍可以 Base64url 解码 payload,读到:
1 | |
时间字段是本次样本,重跑会改变;这里只用姓名可读证明 payload 没被加密。JWT 的签名覆盖其表示,阻止无密钥修改后仍通过验证,不能阻止持有 Cookie 的人查看已有数据。
实验修改签名段的第一个字符,再请求 /session/read。响应是 200,正文 absent。拒绝的是被篡改的 session 数据,框架没有自动把整个业务请求变成 401。若该接口要求已登录用户,应由应用授权逻辑决定缺失身份的状态,不能把 session 验签当作完整登录策略。
日志和源码包中的 Cookie 都是临时进程、合成名字与随机本机密钥产生的实验工件;密钥没有保存到证据。每次启动新进程使用新密钥,旧样本不保证在新进程中有效。本次没有验证密钥轮换、跨节点共享或服务器撤销。
flash 的“一次”依赖客户端删除
/flash/set 返回重定向并设置 notice=saved;客户端带 PLAY_FLASH 请求 /flash/read,第一次看到 saved,响应要求删除该 Cookie。客户端按响应处理删除后,下一次无 Cookie 请求看到 absent。这与“服务器保存一个原子消费标记”不同。
本批还重放原来的 flash Cookie,再次获得 saved。默认客户端状态并没有一个跨请求的服务器已消费集合,所以重放仍可能有效;不能用 flash 作为只能执行一次的支付或确认凭证。若业务需要一次性语义,需要服务器侧状态、事务或唯一约束。
flash 默认写入也走 JWT 编码器,DefaultFlashCookieBaker 可核对;“flash 总是不签名”不适用于这里的默认写入。它的生命周期较短,不改变完整性与保密性分开的原则。
官方 flash 说明 还提示并发请求影响消息出现的位置。两个请求同时发送同一个 Cookie,不能期望它们像一个串行浏览器跳转那样拥有唯一的下一次请求。当前实验只验证顺序发送与显式重放,没有声称完成并发 flash 测试。
流式结果为什么不能当作完成时间
/stream 返回三段文本,Source 每 150 毫秒允许一个元素通过。控制器先返回包含 Source 的 Result,服务器再物化并消费流。真实客户端先记录响应头到达,随后读取完整实体,生产样本大约是 28 毫秒和 350 毫秒。
这些数字只描述这次本机运行,不是 Play 的延迟指标。受控实验中的有效结论是:收到 X-Lab-Result: available 时,客户端仍需等待正文完成,filter 的成功回调没有覆盖完整响应耗时。断连、取消、背压与异常释放需要第 18 篇进一步观测。
响应头已经发出后发生的流失败也不能重新发送一个正常的 500 状态行。第 02 篇同步或异步 Stage 在产生结果前失败的路径,和实体流执行期间失败,是两个不同边界。错误契约应明确客户端可能观察到的不完整正文或连接失败,不能只统计状态码。
重跑 python3 lab/verify.py 可核验 Cookie 解码、篡改、flash 删除与重放、以及流读取。对象复制的断言在 bash sbtw test 中运行。实验包同时保留客户端观察与对应源码引用,便于将一次响应的不同阶段分开核对。
参考资料与继续阅读
参考固定版本的 Result、HttpEntity 和 session/flash 指南。
