深入 Play 21:上传下载、临时文件与 IO 关闭
一个 multipart 请求声明 50000 字节,实际发送文件前缀后就关闭 TCP。断连前,临时目录里已经存在 part 文件;断连后,controller 没有机会处理完整请求,但 parser 的失败路径清理了文件,FileIO 的开始与完成计数均为一。
把删除操作只放在 controller 的 finally 中,覆盖不了这个生命周期。临时文件在 body parser 阶段创建,清理责任也必须从这个阶段开始,直到成功移交给应用或失败终止。
请求尺寸、文件尺寸与路径是三个约束
文件接口接收的不只是文件字节。multipart 请求还包含边界、字段头、普通表单项和其他文件部分,因此“单个文件不超过 64 KiB”与“整个请求不超过 64 KiB”不是同一个限制。实验为整个 multipart body 设置 65536 字节上限,同时只允许一个文件。
Content-Length 提供提前拒绝的机会,但不能作为唯一依据。chunked 请求没有可直接使用的总长度,parser 必须在消费字节的过程中累计。若只在 controller 中读取文件大小再比较,请求可能已经消耗了解析、磁盘与连接资源。
路径约束则处理另一种输入。客户端 filename 是不可信 metadata,不能直接拼接到目标目录。实验拒绝 ../bad、/absolute、反斜杠与超出限制的字符,并为成功文件生成独立 UUID 文件名。原文件名只作为受限 metadata 返回。
| 约束 | 实施位置 | 被约束对象 |
|---|---|---|
| 总请求 65536 字节 | multipart parser | 文件内容、字段头与边界等完整 body |
| 恰好一个、非空文件 | controller | 已解析的文件部分 |
| 限制原始文件名格式 | controller | 不可信 metadata |
| UUID 存储名 | 目标路径构造 | 服务器实际保存位置 |
| IO 完成与删除 | parser 失败路径或成功移交后的清理 | 临时资源生命周期 |
目标路径不包含客户端原文件名,即使未来放宽 metadata 的字符规则,也不会顺带扩大文件系统路径能力。这种分离比“先拼接、再检查是否看起来正常”更容易审计。
BodyParser 在 action 之前持有资源
Play 3.0.6 固定提交为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。core/play/src/main/java/play/mvc/BodyParser.java 提供显式 maxLength 的 MultipartFormData 构造方式;同文件的 DelegatingMultipartFormDataBodyParser 支持自定义文件部分处理器。
实验的 app/streamlab/UploadLabParser.java 选择后一种扩展点,以 maxMemoryBuffer=1024、maxLength=65536、allowEmptyFiles=true 构造每请求 parser。允许解析空文件,是为了让 controller 明确返回 empty file,而不是将空部分静默排除后变成另一个含义的“缺少文件”。
每次 apply 都建立自己的 created 路径集合和 failed 标记。文件 handler 在专用 tmp 目录创建随机临时文件,把入站字节接到 FileIO.toPath。文件 IO 的物化完成 Stage 负责增加关闭计数;解析结果为 Left 或异常时,parser 清理本请求已经创建的路径。
1 | |
文件 sink 与总 parser 的终止回调可能先后到达,因此实现不能假设“外层报错时所有文件 sink 已完成”。外层先设置 failed 并清理已知路径;文件 handler 在完成时再次检查 failed,必要时再执行幂等删除。测试在断言终态前同时等待 fileIoClosed 与 fileIoStarted 相等。
这仍然是有限实验实现。它用同步文件系统操作创建目录、删除与移动文件,没有测量繁忙磁盘上的调度影响。部署时若要把这些操作放入专用阻塞执行器,需要进一步测试线程池容量、拒绝与关闭,而不能从上传字节使用 FileIO 推出所有文件操作都已非阻塞化。
文件 IO 的物化值比文件名更接近关闭事实
Pekko Streams 1.0.3 固定提交为 4f77c8108aaf548a65531d2c8807da13dbba8146。stream/src/main/scala/org/apache/pekko/stream/javadsl/FileIO.scala 给 toPath 和 fromPath 的物化类型均使用 CompletionStage
目录里看不到文件,不足以单独证明句柄已经关闭。某些文件系统允许先删除名字、仍由打开的句柄继续持有内容。文件 IO 的完成回调和目录快照回答两个不同问题,实验同时保留它们。
下面是 JDK 21 下可在累计工程中编译的完整文件 sink 工厂。它记录 FileIO 何时物化、何时完成;完成不保证成功,因此错误仍通过原 Stage 交给调用者,不能仅增加计数后吞掉失败。
1 | |
计数相等只证明已开始的文件 IO 均进入终态,还要读取 IOResult 或异常判断写入是否成功。零开始、零完成则可能表示提前拒绝,请求根本没有创建文件 sink;它不能被解读成写入成功。
默认 TemporaryFile 也有自己的删除机制。core/play/src/main/scala/play/api/libs/Files.scala 展示显式 delete 与引用跟踪相关实现。实验使用自建路径和自定义 parser,是为了记录确定的失败清理时序;没有通过等待 GC 来验证默认引用回收器,也不把目录清理结果归因于未测试的 PhantomReference 路径。
上传结果与保存路径
成功路径只接受 [A-Za-z0-9][A-Za-z0-9._-]{0,63} 形式的原文件名,并进一步拒绝 ..。文件非空且数量恰好为一后,将临时路径移动到 saved 目录下的 UUID 名称。所有文件部分都经过 finally 的 deleteIfExists,因此无论校验失败还是文件已经移走,原临时路径都执行幂等清理。
下面的完整类展示同样的路径分离规则。调用方应传入由服务端配置确定的目录,不能把请求参数当作 directory。它只构造路径,不执行实际移动。
1 | |
UUID 命名减少与原名冲突的耦合,但不自动解决磁盘配额、重复请求或恶意内容。上传接口还需要根据业务定义存储容量、保留时间与访问权限;本实验在每个场景 finally 删除自己成功保存的 UUID 文件,没有声称提供长期存储服务。
返回的 contentType 固定为 application/octet-stream,下载也使用该类型。客户端上传的 MIME 字段没有被当作可信内容识别结果。本实验没有进行图片解析、病毒扫描或媒体类型嗅探,因此不宣称这些能力存在。
已知长度、chunked 与中断上传
真实 HTTP 客户端发送 70000 字节文件内容,并加上 multipart 元数据,分别采用 Content-Length 与 chunked。两个请求都超过整体 65536 上限,均得到 413,但已经发生的文件 IO 不同。
| 场景 | HTTP 状态 | FileIO started / closed | 最终临时目录 |
|---|---|---|---|
| good.bin,3 字节 | 200 | 1 / 1 | 恢复基线,成功文件以 UUID 保存 |
| …/bad | 400 | 1 / 1 | 恢复基线 |
| /absolute | 400 | 1 / 1 | 恢复基线 |
| empty.bin | 400 | 1 / 1 | 恢复基线 |
| 已知总长度超限 | 413 | 0 / 0 | 恢复基线 |
| chunked 超限 | 413 | 1 / 1 | 恢复基线 |
| body 尚未结束便关闭 TCP | 无完整业务响应 | 1 / 1 | 恢复基线 |
已知长度分支在创建文件 sink 之前拒绝。chunked 只有随着字节消费才能发现总量超限,所以已经创建并写入过临时文件;其正确性不仅要求返回 413,还要求这个部分写入也被清理。
中断场景先发送约 20 KiB 文件前缀并等待临时文件出现在目录中,然后对真实 socket 执行 SHUT_RDWR 与 close。脚本通过第二条连接轮询账本,确认 parser 终止和 fileIoStarted=fileIoClosed=1,再检查目录回到基线。这样避免用“请求发出去后马上断开但 parser 可能尚未启动”的空场景冒充中断清理测试。
HTTP 断连后没有可继续读取的业务响应,验收依据必须转向服务器状态与文件 IO。不能要求已经关闭的客户端还必须收到一个 JSON 错误,或把没有错误 JSON 解释成服务端未处理失败。
下载取消怎样触发关闭回调
下载端点用一个可复用的 16 KiB 数组生成 32 MiB 临时文件,然后调用 sendPath。生成过程没有把全部文件读进 byte[];实际响应由 FileIO 读取。这个有限文件准备阶段同步写盘,目的是让取消测试具有稳定的大文件输入,不代表高并发导出任务的最终架构。
Play 的 core/play/src/main/java/play/mvc/StatusHeader.java 将 onClose 连接到文件 Source 的 IOResult 完成 Stage。Scala Results.scala 也提供对应文件流与关闭关联入口。资源观察应跟随这次文件流运行,不能在 controller 返回 Result 的 finally 里提前删除仍待读取的文件。
本次响应为 Content-Length: 33554432、Content-Type: application/octet-stream,并带 attachment 文件名 export.bin。客户端只读取 16384 字节,短暂等待后关闭连接。服务器 onClose 增加 closeCount,删除生成的临时文件;有限轮询最终确认 closeCount=1、目录恢复基线。
sendPath 的 IO 完成属于服务端流生命周期。即使没有取消,它也不是“对端已经收到并保存全部文件”的业务确认;网络缓冲、客户端写盘与应用处理还在其他阶段。需要下载成功证明时,应设计客户端回执或独立校验,不能重命名一个服务器 onClose 回调来获得这个语义。
删除回调也可能失败。实验将删除异常写入账本并继续抛出,不把 closeCount=1 当作删除成功。真实系统还需要为清理失败留下可重试记录,而不是在日志里报错后永久遗留无法定位的文件。
重跑与练习
共享累计工程已另行验收: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 | |
lab/stream_checks.py 的 files 场景记录请求前目录基线、每次状态、FileIO 计数和最终目录。成功上传的 UUID 保存在客户端的清理列表里,并在 finally 逐个删除;脚本不清空其他文件,也不依赖手工删除整个临时目录来使测试通过。
历史网络证据在 evidence/batch18-21/isolated/run-final-io/observations.json 的 files 节点,生产服务器日志在同目录 production.log。本轮隔离 clean test 共 36 项 JUnit 通过,stage 与五组真实 socket 检查通过。默认临时文件 GC 回收、磁盘耗尽、移动失败、跨文件系统移动、生产存储配额与客户端文件落盘回执均未在这些场景中执行。
反例题:上传中断后目录已经为空,是否可以省略 FileIO 终止观察?不能;名称删除与已打开资源关闭是不同事件。反过来,FileIO 完成也不保证临时路径已被删除,需要同时断言。
改动练习:将文件大小设在总上限附近,保持内容相同但改变 multipart 文件名长度与普通字段数量。比较请求总长度和文件内容长度,验证 413 依据完整 body;不要把恰好 65536 字节文件内容当成必然允许的上传。
另一个练习是在成功解析之后、移动文件之前注入失败。断言返回错误、原临时文件清理完成、没有新增 UUID 目标。随后单独测试目标目录不可写,观察失败发生在创建、移动还是清理;不同失败点的恢复责任不能合并为一次成功路径测试。
上一篇:WebSocket生命周期。下一篇为第22篇 JDBC 与连接池,将进一步检查阻塞调用、连接占用和流式结果之间的关系。

