文件、流式响应与 SSE:字节传输和异步任务生命周期
上传文件需要把客户端字节保存到受控位置;下载文件要把指定内容交给有权读取的人;SSE 则在同一响应中持续发送事件。三者都涉及输入输出流,但资源持有时间和失败处理明显不同。
先完成一份小文件的上传与下载,再把响应延长为多次写出,更容易看清临时文件、线程、连接和后台任务分别在什么时候释放。
文件从 multipart 到下载响应
请求中的各个部分
POST /files
Content-Type: multipart/form-data; boundary=...
multipart 主体
├── part:file
│ ├── Content-Disposition:字段名、客户端文件名
│ ├── Content-Type:客户端声明的文件类型
│ └── 文件字节
└── 其他 part:文字字段或单独声明类型的 JSONmultipart 的 boundary 由客户端构造报文时决定。使用 curl -F 或浏览器 FormData 时,让客户端生成对应 Content-Type;手工只写 multipart/form-data 而遗漏 boundary,会使服务端无法正确分隔各部分。
MultipartFile 表示解析后的文件部分,既可以读取流,也可以取得文件大小和原始文件名。@RequestPart 用于读取指定 part;若目标是 JSON DTO,还会通过消息转换器解析该 part 自己的媒体类型。普通表单文字可以用 @RequestParam 取得。用法见 Spring MVC Multipart。
Servlet multipart 解析可能先把内容写到临时目录。MVC 方法取得 MultipartFile 时,并不意味着文件从未落盘,也不意味着可以在请求结束后一直读取它。需要后台处理时,先复制到应用拥有的存储,再将稳定的文件标识传给任务。
体积、路径和内容分别检查
实验配置单个文件最大 1 MB,整个请求最大 2 MB:
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=2MB整体请求还包含 multipart 头部和其他部分,不能只限制单文件。生产入口的代理、网关、容器、临时盘以及应用存储还可能有各自的大小和时间限制,实际最先拒绝的层决定错误形态。配置字段见 Spring Boot multipart 属性。
客户端文件名和 Content-Type 都是不可信输入。文件名可能包含路径分隔符、控制字符或极长内容,不能直接拼到保存路径;声明 image/png 也不保证内容确实是 PNG。业务应根据允许类型做内容识别,必要时进行恶意文件检测、解压限制和隔离处理。OWASP 文件上传指南整理了相关控制点。
实验用服务器生成的 UUID 作为存储名,原始文件名完全不参与路径计算。应用私有临时目录最多保留 16 份文件,上传时流式计算 SHA-256,并返回 id、bytes 和散列。散列用于核对收到的字节,不代表文件安全或用户有权读取。
String id = UUID.randomUUID().toString();
Path target = directory.resolve(id);
var digest = MessageDigest.getInstance("SHA-256");
try (var input = new DigestInputStream(file.getInputStream(), digest)) {
long size = Files.copy(input, target);
// 保存 id 与服务端文件路径的对应关系。
}复制失败时尝试删除未完成文件,并在 finally 中归还名额。如果删除本身也失败,将清理异常附加到原始异常,保留最初的复制失败原因;残留文件由后续清理处理。工程另用非 root Linux 目录权限制造真实删除失败,检查原异常保留和名额归还。
实际存储还应有租户配额、过期清理和孤立文件处理;大文件常由对象存储和上传会话承担,不宜全部堆进应用容器的临时目录。
Resource 与 Range
下载接口先根据文件标识查找受控路径,再返回 FileSystemResource。响应采用 attachment、固定下载名和 application/octet-stream,避免直接在浏览器内执行未知上传内容。
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("Content-Disposition",
ContentDisposition.attachment().filename("download.bin").build().toString())
.header("X-Content-Type-Options", "nosniff")
.body(new FileSystemResource(file));Range: bytes=1-3 请求第 1 到第 3 字节,包含两端,偏移从 0 开始。完整内容为 abcdef 时,部分响应为 bcd,并带有 206 与 Content-Range: bytes 1-3/6。无效范围可能返回 416;客户端还需要检查实际状态,不能把服务器忽略 Range 后返回的完整 200 当成一个分片。
Spring MVC 可以自动处理 Resource 或 ResponseEntity<Resource> 的 Range 请求,条件包括资源不能是 InputStreamResource、ResponseEntity 的初始状态为 200。能够读取长度和重新打开资源很重要。Range Requests列出了具体条件。
文件在多次分片读取期间可能变化。需要可靠续传时,结合 ETag、Last-Modified、If-Range 等验证同一表示,或使用不可变文件标识。不要把多个不同版本的范围直接拼成一个文件。协议细节见 RFC 9110。
延长响应时,工作转移到哪里
几种异步返回类型
| 返回类型 | 工作由谁产生 | MVC 怎样继续 |
|---|---|---|
| Callable | MVC 配置的执行器调用任务 | 完成后进行 ASYNC 分派,处理结果或异常 |
| DeferredResult | 应用自己的线程、回调或事件源提供结果 | setResult / setErrorResult 后恢复 |
| StreamingResponseBody | MVC 异步执行器执行写流代码 | 直接多次写入响应输出流 |
| ResponseBodyEmitter | 应用多次发送对象 | 使用消息转换器逐次写出 |
| SseEmitter | 应用多次发送 SSE 事件 | 输出 text/event-stream 格式的事件帧 |
返回异步对象以后,初次 Servlet 线程可以退出,但连接、请求状态和工作任务仍在。Callable 并没有让其内部阻塞数据库调用消失,只是换到另一个执行器。
异步结果通常通过 ASYNC 分派恢复 MVC 处理;恢复的是已经产生的结果,不会重新调用一次原 Controller。Servlet 与所有相关 Filter 必须支持异步,执行器也应明确配置。Asynchronous Requests介绍了这些返回类型和处理顺序。
StreamingResponseBody 的写入仍然会阻塞
StreamingResponseBody body = output -> {
for (int i = 1; i <= 5; i++) {
output.write(("row-" + i + "\n").getBytes(StandardCharsets.UTF_8));
output.flush();
}
};实验把五行内容写入输出流。flush 尝试把缓冲内容推进下游,但代理、网络和客户端仍有各自的缓冲,不能据此判断浏览器已经呈现了这一行。
慢客户端可能让写操作占用线程。MVC 的响应式返回值适配可以利用背压协调生产和消费,底层 Servlet 响应写出仍是阻塞式的;不能笼统地说“返回 Flux 就全程非阻塞”,也不能说 MVC 完全没有响应式流控能力。具体适配行为由返回类型和媒体类型决定。
为这类工作配置有界线程池、队列和拒绝策略。实验 MVC 执行器使用 2 个核心线程、最多 4 个线程、8 个排队位置;这些数值只适合小型实验。导出任务若长期运行,通常应生成可查询的任务和文件,再让客户端下载,不持续占用一次 HTTP 请求等待全部计算。
已经写出去的状态无法收回
第一次提交响应后,200、Content-Type 和已经发送的字节已成为客户端看到的结果。后续数据库读取、文件读取或网络写出失败时,异常处理器不能再把它替换成完整 500 JSON。
流式业务需要额外说明完成条件。例如导出可以提供记录数和校验值,客户端只有在流正常结束并通过核对后才接纳文件;SSE 可定义业务完成事件。HTTP 连接结束本身也可能来自网络故障,不能在所有协议里都当作工作成功。
资源释放应放在与资源对应的 finally 或回调中。不要为了写流在整个下载期间占住数据库事务和连接;如果必须边查边输出,应明确游标、事务时长、取消和慢客户端所带来的压力。
SSE 事件、重连与取消
事件帧的结构
id:3
event:sample
data:item-3SSE 使用 UTF-8 文本。空行结束一条事件;data 可以跨多行,event 指定事件类型,id 用于客户端记录最后收到的事件标识,retry 可以提供重连等待提示。以冒号开头的注释行可用作心跳。字段规则见 HTML Server-sent events。
SseEmitter 可以构造这些帧:
emitter.send(SseEmitter.event()
.id(Integer.toString(id))
.name("sample")
.data("item-" + id));原生 EventSource 主要用于服务器到浏览器的单向事件。它与 WebSocket 的双向通信、fetch 的可定制请求头有所不同。需要自定义 Authorization 头时,不能假设原生 EventSource 构造器支持;应选择符合认证要求的接入方式,并避免把长期令牌放入 URL。
Last-Event-ID 需要服务端有可重读的数据
浏览器在重连时可以携带 Last-Event-ID,但服务端必须知道这个标识对应哪一条事件,以及之后的记录还是否存在。仅设置一个递增数字,并不会自动获得持久化重放能力。
实验使用范围 1–20 的可重复样本序列,只验证 SSE 编码和游标位置。带 Last-Event-ID: 2 请求时,从 3 开始发送;越出样本窗口返回冲突。这个序列没有业务事件存储,应用重启后也不代表恢复了真实消息历史。
实际业务需要保存事件日志、定义标识顺序及保留期。游标过期时明确通知客户端重新取得快照,随后从新的位置订阅。重连可能重复接收最后一条事件,客户端根据事件标识去重;筛选条件或权限改变后,要重新确定游标适用范围。
请求完成和任务停止需要关联
实验为每个 SSE 请求创建 FutureTask,使用两线程、两排队位置的专用发送池。生命周期回调和任务 finally 共同调用一次性释放逻辑:
创建 emitter 和任务句柄
→ 注册 onCompletion / onTimeout / onError
→ 提交 FutureTask
→ 循环 send
→ 正常完成、超时、断连或任务失败
→ 原子标记关闭 + cancel(true) + 归还活动名额原子标记用于防止多个回调重复归还名额。任务句柄在提交前保存,避免“请求先结束,却还找不到需要取消的任务”。队列满时立即拒绝新请求,返回 503,不创建无限后台任务。
cancel(true) 发出中断请求,不保证任何代码都会停。实验任务在 send 之间使用可中断等待;真实数据库或远程调用还需要自己的超时和取消支持。忽略中断的循环必须自行修复,不能只在外面加一个 Future。
发生 IOException 时,Servlet 异步错误处理会参与结束请求,应用不再另写一份错误响应。正常完成、超时和断连都释放名额;超时计数和断连计数是实际回调或写出异常产生的观察值。
代理缓冲、空闲超时和停机
Nginx 等代理可能缓冲上游响应。实验设置 X-Accel-Buffering: no,但代理是否接受该头仍取决于配置;其他代理也有自己的流式转发方式。需要确认上游读取超时、下游空闲超时、压缩和缓冲共同作用。Nginx 代理模块可查 proxy_buffering、proxy_read_timeout 与相关响应头。
心跳用于保持路径活跃并更早发现断连,不是客户端已经处理业务事件的确认。停机时应停止接收新订阅,按允许时间完成或关闭已有流,并停止发送任务。仅关闭 Web 端口而留下自建 Executor,会使应用迟迟无法退出。
在真实 HTTP 连接上运行
准备上传、下载和流端点
下载 MVC 实验工程,按Linux 准备步骤解包、clean verify 并启动 mvc09-binding。宿主使用普通 Linux 用户、Docker、curl、jq、sha256sum;应用使用 UID 10001,只开放 127.0.0.1:18091。/tmp 是可写临时文件系统,应用重启后上传索引失效,停止容器后临时文件丢失。
下面创建六字节文件并上传:
BASE=http://127.0.0.1:18091
FILES_DIR="$(mktemp -d)"
printf 'abcdef' > "$FILES_DIR/input.txt"
curl -q --noproxy '*' --fail-with-body --max-time 10 \
-F "file=@$FILES_DIR/input.txt;type=text/plain" \
-o "$FILES_DIR/upload.json" "$BASE/files"
jq . "$FILES_DIR/upload.json"
FILE_ID="$(jq -er '.id' "$FILES_DIR/upload.json")"
test -n "$FILE_ID" || exit 1应得到 UUID 文件标识、bytes=6 和 SHA-256。若返回 400,检查 part 名称是否为 file、文件是否为空;413 表示超过大小限制;503 表示实验存储的 16 个名额已用完,不应通过无限增加上限绕过容量问题。
下载完整内容和范围内容
curl -q --noproxy '*' --fail-with-body --max-time 10 \
-o "$FILES_DIR/download.txt" "$BASE/files/$FILE_ID"
cmp "$FILES_DIR/input.txt" "$FILES_DIR/download.txt" || exit 1
sha256sum "$FILES_DIR/download.txt"
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-H 'Range: bytes=1-3' "$BASE/files/$FILE_ID"cmp 成功说明两个文件逐字节一致。完整下载的 SHA-256 应与上传响应一致;范围请求应为 206、Content-Range: bytes 1-3/6,主体为 bcd。
FileStreamTest 使用 JDK HTTP 客户端,发送包含 filename="../../note.txt" 的 multipart,断言服务端仍只返回 UUID 路径;同时通过真实 Tomcat 验证大小拒绝与 Range 行为。文件名被忽略不等于完整安全审查,生产文件授权和内容检查仍需实现。
接收流式文本和 SSE
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--no-buffer "$BASE/streams/download"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--no-buffer "$BASE/streams/events?count=3"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--no-buffer -H 'Last-Event-ID: 2' "$BASE/streams/events?count=2"第一条输出 row-1 到 row-5。第二条依次出现 id:1、id:2、id:3;第三条出现 id:3 和 id:4。--no-buffer 关闭 curl 自己的输出缓冲,不会替代服务器或代理的配置。curl 手册提供对应选项说明。
在浏览器打开同源的 http://127.0.0.1:18091/lab/health 后,可以在 F12 的 Console 运行:
const stream = new EventSource("/streams/events?count=3");
stream.addEventListener("sample", event => {
console.log(event.lastEventId, event.data);
if (event.lastEventId === "3") stream.close();
});
stream.onerror = () => console.log("stream error or reconnect");使用自定义 event:sample 时监听 sample,而不是只写 onmessage。到第 3 条主动 close,防止浏览器在有限样本结束后反复重连。Network 中检查 text/event-stream 与持续接收时间;经代理访问时,再比较回环直连与代理路径,区分输出缓冲来源。
让客户端提前断开
curl -q --noproxy '*' --silent --show-error --no-buffer \
--max-time 0.4 "$BASE/streams/events?count=20" > "$FILES_DIR/partial.sse"
TRANSFER=$?
test "$TRANSFER" -eq 28 || exit 1
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/streams/active"curl 28 是本次主动设置的客户端超时。服务端通常在后续写入或容器错误回调中发现断连,不能要求 curl 退出的同一瞬间 active 已为 0。稍后再查询,应看到 active=0,disconnects 相比断开前增加。
工程测试读取真实 HTTP 输入流的一部分后关闭客户端流,等待发送任务释放,并检查断连计数增加。
服务器超时则用另一条请求观察:
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--no-buffer "$BASE/streams/events?count=20&timeout=100"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/streams/active"timeout 单位为毫秒,实验允许 100–5000。容器定时检查并非精确计时器,响应不一定恰好 100 毫秒结束。已开始写出的响应保持 200,但没有全部 20 条事件;timeouts 增加,active 最终回到 0。业务流需要自己的“完整完成”约定,不能只检查 200。
常见失败与下一步
| 现象 | 检查对象 | 下一步 |
|---|---|---|
| 上传进不了方法 | boundary、part 名、网关与 Servlet 限额 | 从最先返回错误的层修正 |
| 上传后空间持续增长 | 临时文件、应用复制文件、孤立记录 | 分开设置清理责任和配额 |
| Range 返回完整 200 | Resource 类型、初始状态、代理行为 | 先直连测试,再检查缓存和资源变化 |
| SSE 最后才一次性显示 | 客户端、代理或压缩缓冲 | 分别对照 curl --no-buffer 与回环端点 |
| active 长期不归零 | 取消句柄、阻塞调用与回调 | 查看任务栈,补实际终止路径 |
| 超时后出现第二份 JSON | 已提交状态与异常处理器 | 停止重写响应,按流协议处理 |
| 重连有重复或缺项 | 事件 ID、保留期和筛选条件 | 去重、补读或明确重新取得快照 |
结束后停止并删除 mvc09-binding。上传内容在这个实验中不可持久保存;FILES_DIR 中的下载结果由宿主保留,可以继续核对,不执行全局容器或目录清理。
权威资料与规范地址
| 完整地址 | 查阅内容 |
|---|---|
| https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/multipart-forms.html | 文件部分、JSON part 和校验 |
| https://docs.spring.io/spring-boot/appendix/application-properties/index.html | multipart 大小和位置配置 |
| https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html | 文件名、类型、配额和内容检查 |
| https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-range.html | Resource 的范围响应条件 |
| https://www.rfc-editor.org/rfc/rfc9110.html | Range、If-Range 与响应语义 |
| https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html | 异步类型、流写出、取消与响应式适配 |
| https://html.spec.whatwg.org/multipage/server-sent-events.html | 事件帧、EventSource 和重连 |
| https://nginx.org/en/docs/http/ngx_http_proxy_module.html | 缓冲、上游超时和控制头部 |
| https://curl.se/docs/manpage.html | multipart、缓冲与超时退出码 |
