深入 Play 20:WebSocket 握手、消息上限与连接生命周期
同样一个 65537 字节的 WebSocket 帧,用一次 socket 写入发送时,本次实验观察到应用的 4096 字符限制报错;改成每次写 8192 字节,服务端返回了 1009 关闭码。这里变化的是 TCP 写入与后端帧聚合路径,业务消息内容没有变化。
因此,验证 WebSocket 限制需要同时记录帧的构造、消息分片、应用处理量和关闭终态。只看到配置里有一个 64k,或者只看到连接已关闭,都不足以定位到底由哪一层拒绝了输入。
握手以前能拒绝,握手以后要管理流
WebSocket 连接先经过 HTTP 握手。实验使用 WebSocket.Text.acceptOrResult,验证合成 token,再验证精确 Origin。错误 token 返回 401,错误 Origin 返回 403;两条拒绝路径都在创建连接账本与 Flow 之前结束。
“拒绝没有分配应用流”需要证据。脚本先发送真实 Upgrade 请求,读取 HTTP 状态,然后查询同一个 id 的账本。两次查询均不存在记录。若在认证以前先建立 Actor、队列或数据库订阅,即使最终返回 401,仍需另外处理已经分配的资源。
Play 3.0.6 固定提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6 的 core/play/src/main/java/play/mvc/WebSocket.java 将请求头映射为 CompletionStage<Either<Result, Flow<...>>>。Result 分支保留 HTTP 拒绝能力,Flow 分支进入双向消息处理。
实验只接受 Origin http://localhost:19018,不使用后缀匹配或子串匹配。真实认证还应核验用户与资源授权;Origin 也不能当作非浏览器客户端无法伪造的凭据。这里的 token 是公开的合成值,查询串只用于固定实验,不提供生产凭据传输范例。
一次物化对应一份连接状态
正常握手后,服务端接收完整文本消息,验证其 Java String 长度不超过 4096,产生 echo: 前缀的响应。输入最多取 20 个消息,随后连接结束;输出前设置四元素缓冲,溢出策略为 fail,整个流还受十秒完成期限限制。
这些上限约束的是不同对象。20 限制每连接处理次数,4096 限制每次应用消息大小,4 限制这一缓冲阶段的元素数,十秒限制本实验连接运行时长。框架和内核仍有其他缓冲,不能把应用 buffer(4) 解释成整条连接最多存在四个消息。
Pekko Streams 1.0.3 固定提交为 4f77c8108aaf548a65531d2c8807da13dbba8146。Java DSL Flow 的 buffer 接受显式 OverflowStrategy,watchTermination 暴露该次流运行的终态。应用应根据结果是否允许丢失来选择溢出处理,不能因为队列 API 易用就使用无限容量。
下面是可在累计工程中编译的完整流工厂。它接收终止观察器,不创建独立线程或无界队列;实际带认证与账本的入口是 app/controllers/WebSocketLabController.java。
1 | |
String.length() 使用 UTF-16 code unit 数量,这个 4096 不是 UTF-8 字节数,也不是所有 Unicode 字符的数量。实验使用 ASCII 字符,使编码字节数与字符单元数一致。非 ASCII 输入仍需要明确字节预算,否则字符限制与网络负载限制会产生不同边界。
这里在取得完整 String 后才做长度判断。它能够拒绝业务处理,却无法撤销框架为组装这条消息已经进行的缓冲。防护需要在能够观察原始字节或分片的位置另设容量限制,应用消息限制不应承担它无法提前控制的内存责任。
帧与分片消息不能互换
一个文本消息可以由单个终结帧携带,也可以由起始文本帧和多个 continuation 帧组合。两个分别含 3000 个 ASCII 字符的片段,每个都低于 4096,但组合后的 String 长度为 6000。实验明确发送 FIN=false 的文本帧,再发送 FIN=true 的 continuation 帧,应用拒绝组合后的消息,业务处理计数为零。
1 | |
最后一行与消息分片不同。把同一个已经编码的帧分多次 socket.send,不会增加 WebSocket continuation 帧,只改变字节到达与后端解析的机会。测试客户端必须保留 FIN、opcode 与 mask,不能用 TCP 分块替代协议分片实验。
所有客户端帧都带 mask。原始客户端同时检查 101 握手的 Sec-WebSocket-Accept,逐帧读取返回消息,限制待读 payload 大小,并给 socket 设置期限。这使测试覆盖真正的 WebSocket 协议入口,不是直接向一个 Java Flow 传字符串。
64k 配置在固定后端中的两个路径
默认配置位于 transport/server/play-server/src/main/resources/reference.conf,其中 play.server.websocket.frame.maxLength = 64k。Pekko 后端读取该值作为 frame 聚合的 bufferLimit。
实际分支位于 transport/server/play-pekko-http-server/src/main/scala/org/apache/pekko/http/play/WebSocketHandler.scala。在 FrameData 到来时,它检查 currentFrameData.size + data.size > bufferLimit 并产生 TooBig;若 FrameStart 已包含完整帧,fs.lastPart 分支直接转换为 RawMessage。
这两个分支说明,源码中的配置检查点与“所有帧必经的统一检查点”并不相同。一次 send 与多次 send 也不保证严格对应某个 FrameStart 形状,操作系统仍可拆分或合并接收。因此测试保存具体样本,而不把一次写入次数固化为跨平台语义。
| 输入及发送方式 | 本次关闭码 | 应用计数 | 账本 failure |
|---|---|---|---|
| 4097 字符单消息 | 1000 | 0 | IllegalArgumentException |
| 3000 + 3000 的分片消息 | 1000 | 0 | IllegalArgumentException |
| 65537 字节同帧,每次写 8192 字节 | 1009 | 0 | 空 |
| 65537 字节同帧,一次 send | 1000 | 0 | IllegalArgumentException |
应用异常与关闭码之间不能只凭直觉建立一一映射。前两行账本明确失败,客户端却观察到 1000;第三行传输层拒绝过大帧,应用流账本没有 IllegalArgumentException。定位时必须联合查看服务器异常、应用处理量与关闭帧,不能把 1000 单独当作业务成功证明。
这组结果限定于固定 Play/Pekko 后端。它没有核验修补版本或其他后端的实现,不能用来宣称所有 WebSocket 实现都存在相同路径。改变后端或版本后,应重新运行同一帧矩阵。
慢消费者与突发输入是两种场景
慢消费者场景发送 20 条、每条 4096 字符,先暂停读取,再有限慢读服务端响应。本次连接最终处理 20 条,closeCount=1。负载总量只有 81920 个输入字符,服务器和内核可能吸收这段有限突发;这次结果没有证明应用队列已经被填满。
为了区分“输入有限导致正常结束”和“缓冲先溢出”,脚本另外运行两组时序。顺序场景每发送一条就等待 echo,前 19 条严格交替,最后发送第 20 条后等关闭;最终 producedChunks=20。突发场景连续发送 21 条,本次记录 BufferOverflowException 和 1011,实际处理量在达到 20 前终止。
正常上限与溢出策略都需要测试。若只发送少量消息再主动关客户端,take(20) 从未被触发;若只做突发测试,队列可能先失败,同样不能证明正常处理到第 20 条时的行为。
失败策略适合不能随意丢弃命令的演示。若消息是可覆盖的最新状态,可能选择只保留最新值;若每条都是需要确认的交易指令,则应设计持久化、确认和重发,不能把丢弃队列解释成背压。这些业务协议超出了当前 echo 实验。
Close、半关闭与无 Close 断连
正常关闭先发送带 1000 的 Close 帧,客户端收到服务端 Close,账本随后终止。无 Close 场景直接关闭TCP。最终共享半关闭实验先执行SHUT_WR,保留读取半侧,实际recv观察到TCP EOF;完整close之前另一个连接已读到terminated=true、closeCount=1。约0.0032秒包含EOF与独立终态查询,不是纯recv耗时。最终记录在evidence/batch18-21/shared-socket-final/observations.json。旧隔离与初轮共享只保留短暂窗口后完整close,旧记录只能证明最终释放,不能单独证明半关闭效果。
| 场景 | 客户端动作 | 服务端终态 |
|---|---|---|
| 正常 Close | 发送协议 Close 并读取回应 | closeCount=1 |
| TCP 无 Close | 完成一次 echo 后直接 close | closeCount=1 |
| 真实 TCP 半关闭 | echo后SHUT_WR,完整close前实际读取EOF | 完整close前closeCount=1 |
| 拒绝握手 | 401 或 403 | 未分配应用账本 |
半关闭是 TCP 层的输入,与 WebSocket 协议的 Close 握手并不相同。测试没有将 SHUT_WR 包装成一个伪造 Close 帧。框架观察到 EOF 后如何关闭另一侧,应通过实际连接终态核对;只测浏览器正常 close 无法覆盖这条路径。
watchTermination 回调在服务器中执行,不能证明客户端已成功应用最后一个 echo。发送确认、业务完成确认和连接资源释放分别属于不同协议层,测试报告不能用一个 done 替代全部结果。
重跑与练习
共享累计工程已另行验收:39项JUnit与stage通过,8个流/文件class与生产jar字节一致;DEV/PROD各198次既有HTTP和五组真实socket通过。共享记录在evidence/batch18-21/shared-socket/observations.json;隔离样本保留原测量值,不将两轮耗时、RSS、块数混为同一轮。
从第00篇取得累计工程,配置 JDK 21 后,在 play-lab 目录执行:
1 | |
脚本的 websocket 组使用标准库原始客户端,不需要第三方 WebSocket 包。服务端端口与精确 Origin 均为实验约定的 19018;自定义端口时需同时修改 Origin 契约与客户端请求,不能只移动监听地址。
历史观测保存在 evidence/batch18-21/isolated/run-final-io/observations.json 的 websocket 数组。隔离累计工程 36 项 JUnit、stage 和五组 socket 场景通过;这些数字不替代后续共享工程验证。认证、Origin、消息数、两种分片层次、缓冲失败和三种关闭方式均保留独立 case。
反例题:两个 3000 字符片段都低于 4096,是否可以删除完整消息的长度检查?不能;被处理的 String 是组合后的 6000 字符,按单帧验证会遗漏应用消息上限。
改动练习:保持应用限制 4096,分别发送 ASCII、中文及辅助平面 Unicode 字符,记录 UTF-16 单元、UTF-8 字节和拒绝位置。随后调整 socket 单次写入大小,检查 65537 字节同帧是否仍走相同后端分支。不得将网络分段变化当作输入业务含义变化。
另一个练习是把消息缓冲从四个元素改成两个,保留顺序发送与突发发送两套测试。顺序的第 20 条上限应仍成立;突发失败的具体处理数可能改变。验收应记录这个变化,而非要求不同调度下始终在同一条消息失败。

