Skip to content
Go back

安全可靠的前端大文件分片上传

Updated:

前端大文件分片上传

数据上传是经典的数据传输场景,本文介绍如何实现一个可恢复、解耦服务商、安全的大文件分片上传方案。

背景

如果你是用阿里云的OSS服务,在你的前端项目中可能见过ali-oss的代码:

import OSS from "ali-oss";
const store = new OSS({
  region: "oss-cn-hangzhou",
  accessKeyId: "<access-key-id>",
  accessKeySecret: "<access-key-secret>",
  bucket: "<bucket-name>",
  stsToken: "<security-token>",
});

使用阿里云官方提供的数据传输库,可以满足大部分的上传功能需求,然而他的缺点是:

目标

我们的目的是实现一个可恢复、解耦服务商、安全的大文件分片上传方案。分片上传增加了上传成功率,断点续传增强了用户体验。既然要造轮子,这两者我都要,并且在此功能基础上规避掉上面提到的两个问题。

方案选型

基于预签名 URL 提供上传服务 应该是OSS的基本能力之一,比如阿里云AWSMinio都提供了这个能力。使用预签名 URL 可以让客户端在未拥有安全凭证或权限的情况下进行上传。而这个能力恰好可以把前端跟运营商之间解耦,也无需传递ak/sk等安全凭证。

基于预签名URL设计

时序图

前端流程图

预签名URL上传流程图

方案实现

开始上传

完成上传任务的创建,获取本次上传任务ID。

const { uploadId } = await this.api.createUploadTask({
  fileName: "",
});

fileId计算

// worker.js
self.importScripts("https://cdn.jsdelivr.net/npm/hash-wasm@4");

self.onmessage = async (e) => {
  const file = e.data;
  const stream = file.stream();
  const reader = stream.getReader();
  const hasher = await self.hashwasm.createMD5();
  while (true) {
    const { done, value } = await reader.read();

    if (done) {
      const md5 = hasher.digest();
      self.postMessage({ data: md5 });
      break;
    }

    hasher.update(value);
  }
};
const handleFileChange = async (event: Event) => {
  const input = event.target as HTMLInputElement;
  if (input.files) {
    const file = input.files[0];

    const myWorker = new Worker(new URL("./worker.js", import.meta.url));

    myWorker.postMessage(file);

    myWorker.onmessage = (e) => {
      const { data } = e.data;
      console.log("md5 结果:", data);
    };

    myWorker.onerror = (error) => {
      console.error("Worker error:", error);
    };
  }
};

是否可续传

const { uploadId } = getLocalTask(fileId);
if (uploadId) {
  // 根据响应结果获取已上传的分片数据
  const res = this.api.getUploadTask({
    uploadId,
  });
} else {
  setLocalTask({
    uploadId: "",
    fileId: "",
    partSize: "",
    // ...
  });
}

切片

将原始文件分割成多个小片段,提高上传稳定性。

sliceFile = (): UploadChunk[] => {
  const file = this.file;
  let current = 0;
  let idx = 1;
  let chunks: UploadChunk[] = [];
  const { partSize, uploadId, retryTimes } = this.options;
  while (current < file.size) {
    // 已上传的分片可以跳过
    const chunk = file.slice(current, current + partSize);
    chunks.push({
      id: uploadId,
      index: idx,
      start: current,
      end: current + chunk.size,
      data: chunk,
      retryTimes: retryTimes,
    });
    current += partSize;
    idx++;
  }
  return chunks;
};

切片的预签名与上传

由异步任务队列来控制并发上传分片,每个分片上传包含错误重试机制。

const uploadChunkTasks = chunks.map((chunk) => {
  return () => {
    let lastPartProgress = 0;
    return new Promise<void>(async (resolve, reject) => {
      try {
        const onProgress = (newPartProgress: number) => {
          totalProgress = totalProgress - lastPartProgress + newPartProgress;
          lastPartProgress = newPartProgress;
          this.emitProgress(totalProgress, this.file.size);
        };
        // 分片签名
        const { url, method } = await this.createChunkSignedUrl(chunk.index);
        // 分片上传
        await pRetry(
          this.uploadChunk.bind(this, {
            url,
            chunk: chunk.data,
            onProgress,
            method,
          }),
          {
            retries: retryTimes,
            onFailedAttempt: (error) => {
              console.log("onFailedAttempt - ", error);
            },
          }
        );
        resolve();
      } catch (error) {
        reject(error);
      }
    });
  };
});
this.queue.addAll(uploadChunkTasks);

完成上传

完成任务上传,服务侧接收到通知后开始处理分片合并,并检验文件完整性。

await this.api.completeFileUpload(uploadId);
removeLocalTask(fileId);

总结

通过这个思路,即可实现前端大文件上传方案,且拥有:

Q & A

为什么不校验每个分片数据的 HASH 值?
分片的校验成功并不能代表文件整体的完整性,文件的完整性就应该校验文件整体。
既然前端解耦了云服务商,那么整体复杂度是不是降低了?
从工程角度来看,复杂度不会消失,只是转移到了后端。
为什么不缓存原始文件?
这是平衡性的选择,不缓存原始文件的代价是:如果用户上传中断了,那么需要重新选取下本地文件进行上传。它的好处是在多文件上传过程中不会占据Chrome过多的磁盘空间,不需要考虑磁盘空间不足的边缘场景。另一个成熟的可恢复的文件上传方案也是采取这种方式。
为什么分片上传比分片下载复杂?
HTTP协议原生支持Range请求头,允许客户端获取文件的部分数据,在这个标准化的前提下,分片下载非常容易。而分片上传目前还没有标准化,社区上已经有团队在推进:Resumable Uploads for HTTP。该提案目前还处于Draft阶段,不过已经更新了至少9个版本,期待分片上传早日实现标准化。

Share this post on:

Previous Post
彻底搞懂 forEach 与 for...of 在循环体中执行 await 时的区别