文件上传与 tuskott

2025-02-10, 星期一, 15:08

MAKEJava

笔者在去年年底参与了一个项目,其中有个功能涉及用户向系统提交材料。直到系统预上线才发现客户希望上传的「文件材料」(500MB + 的活动相关材料压缩包)与 PM 预期的「文件材料」(37.5 MB 以下的 Office 文档)还是有区别的,因此这个功能对用户来说,确实做得不太好。

碰到的问题主要有:

  • 大文件导致的传输时间过长,被客户端 / 网关掐断了连接,导致需要重新上传
    • 以及神秘的「38MB 文件触发 HTTP 413,而更大一些会让网关返回 HTTP 500」,最终发现是网关的缓冲区耗尽
  • 不能够暂停上传,一直占用客户的带宽资源直到完成(一般还都失败了)
  • 需要通过额外的接口提交其他信息,例如文件的相对路径(客户希望可以上传整个目录)
  • 没有上传进度展示

这些问题在大文件上传场景中尤为明显,而何为大文件?笔者认为这不是一个固定的数值。

Aliyun OSS 的文档建议在上传 5GB 以上的文件时做一些额外的工作。笔者个人觉得针对具体的系统,上传动作要是超过 10 秒还未得到反馈,或是需要超过 40 秒的上传时间,就是大文件了。

如何增强用户上传文件的体验?可以:

  • 支持断点续传
  • 支持秒传
  • 分片并行上传
  • 展示上传进度

怎样造一个轮子?

要支持断点续传,服务端需要追踪每个文件(或上传计划)的进度,其实就是这个文件传到哪个字节了。续传开始时要求客户端从这个位置继续上传。

秒传需要在服务端存储已有文件的散列值,客户端提交要上传文件的散列值进行匹配,匹配到则说明服务端以及有这个文件,不需要再上传。需要注意的有:

  • 按照使用场景和保密等级判断是否需要对不同的用户上传数据做隔离;
  • 散列值的计算放在 Worker 进程里,可以防止影响 UI;

散列值同样可以用来做上传文件的完整性校验。

分片并行上传则是将文件拆分成多个小片,然后按照一定的规则顺序或乱序并行上传,在全部完成后通知服务端合并。

不过仅当当客户端具有感知网络带宽的能力并能够动态调整并发和分片大小时,并行分片上传才可以说是一种有效提升效率的行为。如果分片策略是固定的,甚至不能调整并发,那么分片可能会造成资源浪费,降低性能。

至于传输进度,这可是 XMLHttpRequest 中都有的东西。

对象存储 OSS

S3 协议中通过 PutObject 上传的普通对象(Normal Object)不支持追加写入,所以一旦在随机位置断开(例如网络波动),就只能重新来过了。要支持断点续传,需要使用 AppendObject 操作创建 Appendable Object

注意 PutObject 和 AppendObject 都只支持上传 5GB 的对象(以 Aliyun OSS 为例),要上传更大的文件,需要应用层自己实现合并逻辑,或使用分片上传(MultipartUpload),分片支持 100KB(默认)到 5GB,Aliyun OSS 有一个 Checkpoint 功能,算是把这个简化了。

这些操作默认情况下都需要经过业务服务器倒腾一手,和其他方式一样也没有摆脱 ECS 资源的限制。有的项目组会使用反向代理将内网的 OSS 端点暴露给用户,创建预签名的上传 URL,以此允许客户端直接向 OSS 传输文件,可以减轻一些压力。

tus 协议

tus 是一种通过 HTTP 上传文件的开放协议,最著名的用户包括视频平台 Vimeo(见 Vimeo is adopting tus!),Cloudflare 和 Git LFS。该协议定义了一系列用于支持可中断与恢复的文件上传的服务端与客户端行为。

作为一种开放协议,社区的 Implementations 板块 收录了许多客户端和服务端实现。有独立的应用程序 tus-node-servertusdotnettusd 等。对于一些希望将其集成到项目中的 Java 开发者,社区中的 terrischwartz/tus_servlet 以简明扼要的代码演示了 Core Protocol 的逻辑,还有实现了所有扩展功能的 tomdesair/tus-java-server

tus 以非常 REST 的风格规定了所有操作,因此实现自己的服务端和客户端也并非难事。

简单来说:

  1. 客户端需要通过 GET 请求向服务端申请一个上传计划,说明需要上传的文件信息;
  2. 服务端分配一个 ID,客户端可使用这个 ID 进行查询和上传;
  3. 客户端通过 HEAD 请求查询文件的上传进度,通过 PATCH 请求上传文件;

文件校验、合并等功能则在这些核心接口上通过添加不同的请求头进行扩展。

tuskott:为 Spring Boot 应用程序添加 tus 支持

tuskott 是一个开发中的 Spring Boot Starter,使用 JDK 17 编写,支持 Spring Boot 3。目前实现了 tus 协议中规定的大部分功能:

  • 核心协议
    • [x] 查询上传进度
    • [x] 断点续传
  • 扩展功能
    • [x] 创建上传
    • [x] 创建时即开始上传
    • [x] 终止上传
    • [ ] 文件合并
    • [x] 文件校验
    • [x] 过期

用户在 POM 中引入项目后,通过 application.yaml 声明提供服务的端点路径,tuskott 会在这个路径下创建一系列标准接口,之后就可以使用任意 tus 客户端,如 (tus-js-client)[https://github.com/tus/tus-js-client] 实现上传。

默认情况下 tuskott 将上传的文件保存在本地磁盘,使用内存中的数据结构保存进行中的上传计划信息和锁状态,这是为了保持开箱即用的能力。在较严肃的场景中用户可以替换为自己的持久化、分布式的解决方案。

文件上传成功(以及其他一些重要的事件发生)时,tuskott 会异步调用 @OnUploadSuccess 等注解修饰的方法发送通知,允许使用者扩展更多业务功能。

该项目目前可通过 Maven Central 的 SNAPSHOT 仓库获取:

<dependency>
  <groupId>cc.ddrpa.tuskott</groupId>
  <artifactId>tuskott-spring-boot-starter</artifactId>
  <version>0.0.1-SNAPSHOT</version>
</dependency>