一个 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 的字符规则,也不会顺带扩大文件系统路径能力。这种分离比“先拼接、再检查是否看起来正常”更容易审计。

上传 parser 与下载 FileIO 的生命周期

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
2
3
4
5
6
7
HTTP body ──> 限长 multipart parser ──> FileIO.toPath ──> 文件部分结果
│ │
│解析超限/断连 └─ materialized IOResult 完成
└─ 清理本请求临时路径

解析成功 ──> controller 校验 ──> UUID 目标路径
└─ finally 清理尚未移交的临时文件

文件 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 结果。

目录里看不到文件,不足以单独证明句柄已经关闭。某些文件系统允许先删除名字、仍由打开的句柄继续持有内容。文件 IO 的完成回调和目录快照回答两个不同问题,实验同时保留它们。

下面是 JDK 21 下可在累计工程中编译的完整文件 sink 工厂。它记录 FileIO 何时物化、何时完成;完成不保证成功,因此错误仍通过原 Stage 交给调用者,不能仅增加计数后吞掉失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.nio.file.Path;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.pekko.stream.IOResult;
import org.apache.pekko.stream.javadsl.FileIO;
import org.apache.pekko.stream.javadsl.Sink;
import org.apache.pekko.util.ByteString;

public final class TrackedFileSink {
public static Sink<ByteString, CompletionStage<IOResult>> create(
Path path, AtomicInteger started, AtomicInteger closed) {
return FileIO.toPath(path).mapMaterializedValue(done -> {
started.incrementAndGet();
return done.whenComplete((result, failure) -> {
closed.incrementAndGet();
});
});
}
}

计数相等只证明已开始的文件 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
2
3
4
5
6
7
8
9
10
11
12
import java.nio.file.Path;
import java.util.UUID;

public final class UploadDestination {
public static Path create(Path directory, String originalName) {
if (!originalName.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|| originalName.contains("..")) {
throw new IllegalArgumentException("invalid filename metadata");
}
return directory.resolve(UUID.randomUUID().toString());
}
}

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
2
bash sbtw clean test stage
python3 lab/stream_checks.py --evidence evidence/batch18-21/replay

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 与连接池,将进一步检查阻塞调用、连接占用和流式结果之间的关系。