文件上传与 tuskott

2025-07-29, 星期二, 15:06

MAKEJava

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

碰到的问题主要有:

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

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

Aliyun OSS 的文档建议在上传 5GB 以上的文件时做一些额外的工作。笔者个人觉得针对具体的系统,上传动作要是超过 10 秒还未得到反馈,或是需要超过 40 秒的上传时间,就是大文件了,考虑到政务云互联网出口的默认带宽为 30Mbps,分界线在 150 MB 左右。

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

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

怎样造一个轮子?

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

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

  • 按照使用场景和保密等级判断是否需要对不同的用户上传数据做隔离;
  • 散列值的计算放在 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. (在开启 Creation 扩展的情况下,)客户端通过 GET 请求向服务端申请一个上传计划,说明需要上传的文件信息;
  2. 服务端分配一个 ID,客户端可使用这个 ID 通过 HEAD 请求查询文件的上传进度,通过 PATCH 请求上传文件;

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

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

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

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

项目仍在开发中,目前在 SNAPSHOT 仓库中提供了 0.0.3-SNAPSHOT 版本(注意 0.0.3-SNAPSHOT 版本在 API 和配置命名上和之前有所区别)

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

HowTo

如下展示了 application.yaml 配置的默认值:

tuskott:
  # tuskott 在何处创建 tus 服务端点
  base-path: /tus
  # 允许上传的最大文件大小,单位为字节,默认为 1GB
  max-upload-length: 1073741824
  # 允许上传的最大分片大小,单位为字节,默认为 50MB
  max-chunk-size: 52428800
  behind-proxy:
    # 服务是否部署在反向代理后面,影响 tuskott 分配上传地址的行为
    enable: false
#    header: 'X-Original-Request-Uri'
  # 是否启用 tus 协议的扩展功能
  extension:
    enable-creation: true
    enable-termination: true
  # 允许用户替换自己的 UploadResourceTracker 实现
  tracker:
    provider: 'cc.ddrpa.tuskott.tus.resource.InMemoryUploadResourceTracker'
  # 允许用户替换自己的 LockProvider 实现
  lock:
    provider: 'cc.ddrpa.tuskott.tus.lock.InMemoryLockProvider'
  # 允许用户替换自己的 Storage 实现
  storage:
    provider: 'cc.ddrpa.tuskott.tus.storage.LocalDiskStorage'
    config:
      # 文件将被存储到工作目录的 uploads 子目录下
      dir: 'uploads'

构成

Tuskott 提供了一些默认的组件,这些组件并不是为分布式环境设计的,也没有持久化设计,但可以满足大多数单机应用的需求。

用户可以直接使用这些组件,也可以按需替换为自己的实现。

  • UploadResourceTracker 负责管理上传计划信息,在 InMemoryUploadResourceTracker 中,上传文件的信息保存在一个 Map 结构中
  • LockProvider 负责管理锁,在 InMemoryLockProvider 中,锁信息保存在内存中
  • Storage 负责管理文件存储,LocalDiskStorage 将上传的文件保存在本地磁盘指定的目录中

注意 UploadResourceTracker 接口并未要求组件实现自动清理机制,因此在使用 InMemoryUploadResourceTracker 时用户需定期调用 filter 找出过期的上传计划并手动清理。

从 Web 页面上传文件

可以使用大部分 tus 客户端库,以 JavaScript 的 tus-js-client 为例,当设置 tuskott.base-path = '/tus' 时,在前端代码中可以这样创建上传实例:

let upload = new tus.Upload(file, {
    endpoint: '/tus/files',
    retryDelays: [0, 1000, 3000, 5000],
    chunkSize: currentChunkSize,
    metadata: {
        filename: file.name,
        filetype: file.type
    }
});
upload.start();

为了提升用户体验,你可以在前端代码中监听上传进度事件,更新进度条等 UI 元素。

事件回调

Tuskott 提供了一系列事件回调,允许用户在上传的不同节点注册需执行的方法。

  • PostCreateEvent 创建 Upload Resource 后触发
  • PostCompleteEvent 上传完成后触发
  • PostTerminateEvent 上传终止后触发

如下代码展示了上传完成后触发转存的操作:

@PostComplete
public void uploadSuccessEvent(PostCompleteEvent event) {
    UploadResource uploadResource = event.getUploadResource();
    Map<String, String> metadata = uploadResource.metadata();
    String originalFilename = metadata.get("filename");
    try(OutputStream ous = new FileOutputStream("/home/dandier/Temp/saved_" + originalFilename);
        InputStream ins = tuskottProcessor.getStorage().streaming(uploadResource.id())) {
        ins.transferTo(ous);
    } catch (Exception e) {
        logger.error("Failed to save uploaded file", e);
    }
}