文件切片上传实现以及“断点续传”-“秒传”思想

4,855 阅读7分钟

文件切片上传

想法与步骤

  • 切片上传: 将文件切成多个小块,然后通过上传文件的接口发送给服务端,当切片发送完成后,再通过接口请求让后端进行合并
  • 计算 md5 值,作为唯一标识,是断点续传与秒传的基础
  • 断点续传: 先请求接口,返回已上传完成的切片,本地对文件切割后与已接口返回的已上传切片进行对比,只发送已上传的内容
  • 秒传:请求校验接口,判断是否已存在该文件

想法中存在的问题

  • 用什么东西对文件切片 ?
  • 文件怎么读取?
  • 大文件计算 md5 会不会长时间占用主线程,导致页面卡顿?
  • 切片过多,会不会占用很多网络请求 ?

读法统一

文章下面对 Blob 对象【类文件对象】统称为文件

尝试

文件切片

// 单个切片大小
const chunkSize = 1 * 1024 * 1024;

/**
 * file: 文件上传时,通过e.target.files[0]拿到的值
 */
createFileChunk(file: File) {
      let current = 0;
      // 保存与返回所有切片的参数
      let chunks: Array<Blob> = [];
      while (current < file.size) {
        // 文件进行切片
        const chunk = file.slice(current, current + chunkSize);
        chunks.push(chunk);
        current = current + chunkSize;
      }
      return chunks;
    }

这里解决了第一个问题,用什么对文件进行切片,我们可以利用 File 中的 slice 进行切片

计算 MD5 值

如果我们直接使用 md5 库对文件进行 md5 计算,文件比较大的时候,可能比较慢。所以推荐使用库spark-md5,这个库提供了一种增量计算 md5 的方法,并且比计算速度比原先的 md5 库更快

    /**
     * 计算md5值
     */
    calculationChunksMd5(chunks: Array<Blob>) {
      return new Promise((resolve) => {
        // 创建FileReader对文件进行读取
        const reader = new FileReader();
        // 创建sparkMd5的ArrayBuffer对象,进行增量计算md5值
        const spark = new sparkMD5.ArrayBuffer();
        let readIndex = 0;

        function loadNext() {
          // 递归调用结束条件,当没有chunk可以读取的时候
          if (chunks[readIndex]) {
            return reader.readAsArrayBuffer(chunks[readIndex]);
          }
          // 最终的md5值
          resolve(spark.end());
        }

        reader.onload = (ev: ProgressEvent<FileReader>) => {
          readIndex++;
          // 获取读取到的内容,这里可以计算一下进度
          const result = ev.target?.result as ArrayBuffer;
          // 将内容添加到spark中,进行计算
          spark.append(result);
          // 继续下一个文件读取
          loadNext();
        };
        // 启动计算
        loadNext();
      });
    }

这里需要注意一点:FileReader 是无法并行读取多个文件的,所以需要使用递归的方式,读取完成后进行下一次读取,如果使用 forEach 直接全部读取,会报:

DOMException: Failed to execute 'readAsArrayBuffer' on 'FileReader': The object is already busy reading Blobs.

翻译:DomeException:未能在“FileReader”上执行“readAsArrayBuffer”:对象已在忙于读取Blob。

我们这里已经解决了第二个问题:文件怎么读取。但是我们还没有解决掉第三个问题,大文件读取 md5 卡顿的问题

使用 webWorker 优化代码

worker MDN 文档

calculationChunksMd5 方法修改

    /**
     * 计算md5值
     */
    calculationChunksMd5(chunks: Array<Blob>): Promise<string> {
      return new Promise((resolve) => {
        const worker = new Worker("/calculationMd5.js");
        worker.postMessage(chunks);
        worker.onmessage = (e: MessageEvent<any>) => {
          resolve(e.data);
        };
      });
    }

注意点:

  • calculationMd5.js 文件必须同源【文档中有】

  • 创建 worker 后,通过 postMessage 发送消息

  • 通过 onmessage 来接收创建新线程的消息

  • 内容保存在参数的 data 里

新建文件calculationMd5.js,并将我们的计算逻辑放入这个文件中

self.importScripts("spark-md5.min.js");

onmessage = function (e) {
      let chunks = e.data;
      const reader = new FileReader();
      const spark = new SparkMD5.ArrayBuffer();
      let readIndex = 0;

      function loadNext() {
            if (chunks[readIndex]) {
                  return reader.readAsArrayBuffer(chunks[readIndex]);
            }
            postMessage(spark.end());
      }

      reader.onload = (ev) => {
            readIndex++;
            // 获取读取到的内容,这里可以计算一下进度
            const result = ev.target?.result;
            spark.append(result);
            loadNext();
      };
      loadNext();
};

注意五点:

  • worker 文件中的引入使用的是self.importScripts("spark-md5.min.js")
  • 使用 onmessage 方法获取主线程传过来的消息
  • 传递过来的内容在参数的 data 中
  • 通过 postMessage 方法将计算结果传递给主线程
  • spark-md5.min.js 导出的是 SparkMD5

通过浏览器中的性能分析工具,我录制了上传过程中的 5 秒做一个简单对比,我们能看到效果

未使用webWorker
4491 毫秒  正在执行脚本
1 毫秒  渲染
1 毫秒  绘制
790 毫秒  系统
267 毫秒  空闲
5550 毫秒  总计


使用webworker
130 毫秒  正在执行脚本
2 毫秒  渲染
1 毫秒  绘制
32 毫秒  系统
6398 毫秒  空闲
6562 毫秒  总计

到此我们的切片上传基本完成,只需要将计算的 hash 和文件流,当然你可以再计算 MD5 的时候给出进度,让用户感觉更加友好

创建请求参数列表

    type formDatasType = {
      formData: FormData; // 切片上传请求的参数
      index: number; // 当前切片是第几个
      error: number; // 当前切片上传错误次数
      progress: number; // 当前切片上传进度
    };

    /**
     * 创建请求参数数组
     */
    createPostFormData(
      chunks: Array<Blob>,
      fileHash: string
    ): Array<formDatasType> {
      const formDatas = chunks.map((chunk, index) => {
        // 请求的参数创建
        const formData = new FormData();
        formData.append("chunk", chunk);
        formData.append("hash", fileHash);
        formData.append("fileName", fileHash + index);
        // 添加额外参数,可以做错误重试,进度等操作
        return { formData, index, error: 0, progress: 0 };
      });

      return formDatas;
    }

这里并没有直接创建请求,然后用 promise.all 一次性发出。是因为想解决第四个问题,我们要控制请求并发数

上传切片文件并控制并发数

    /**
     * 将文件上传服务器
     */
    postToServer(postFormData: Array<formDatasType>, limt: number = 3) {
      return new Promise((resolve) => {
        let len = postFormData.length;
        let counter = 0;
        const startPost: Function = async () => {
          // 注意这个方法会改变原数组
          const formDatas = postFormData.shift();
          if (!formDatas) {
            return;
          }
          await this.$http.post("/uploadfile", formDatas.formData, {
            onUploadProgress: (progress: any) => {
              // 这里可以获取切片上传进度: Number(((progress.loaded / progress.total) * 100).toFixed(2));
            },
          });

          // 所有请求都已结束,我们需要结束外面的Promise
          if (counter == len - 1) {
            resolve(true);
            return;
          }

          counter++;
          // 请求还未结束,继续启动任务
          startPost();
        };

        // 初始启动limt个任务
        for (let index = 0; index < limt; index++) {
          startPost();
        }
      });
    }

稍微解释一下代码:

  • 这种方式控制并发数,并没有使用采用先分组,然后使用 Promise.all 一起请求,是因为这样子能将并发请求控制在三个,当有一个请求完成,就会开启下一个请求,这样始终保持三个请求在运行,更好一点
  • postFormData.shift这个方法会改变原数组,如果你需要计算进度等,记得一定要 copy 一份下来进行本次操作,否则会丢失数据
  • 这个方法是没有错误重试的,我们等会可以尝试实现一下错误重试功能

添加错误重试

postToServer(postFormData: Array<formDatasType>, limt: number = 3) {
    return new Promise((resolve) => {
      let len = postFormData.length;
      let counter = 0;
      let isStop = false;
      const startPost: Function = async () => {
        // 注意这个方法会改变原数组
        const formDatas = postFormData.shift();
        if (!formDatas || isStop) return;
        try {
          await this.$http.post("/uploadfile", formDatas.formData, {
            onUploadProgress: (progress: any) => {
              // 这里可以获取进度: Number(((progress.loaded / progress.total) * 100).toFixed(2));
            },
          });

          // 所有请求都已结束,我们需要结束外面的Promise
          if (counter == len - 1) {
            resolve(true);
            return;
          }

          counter++;
          // 请求还未结束,继续启动任务
          startPost();
        } catch (error) {
          // 超过三次就放弃了
          if (formDatas.error > 3) {
            return (isStop = true);
          }
          // 将错误的内容放入数据列表中,然后立马进行重试
          formDatas.error++;
          postFormData.unshift(formDatas);
          // 继续启动任务
          startPost();
        }
      };

      // 初始启动limt个任务
      for (let index = 0; index < limt; index++) {
        startPost();
      }
    });
}

方法调用过程

async uploadFile(e: any) {
      const chunks = this.createFileChunk(e.target.files[0]);
      const fileHash: string = await this.calculationChunksMd5(chunks);
      console.log("是否已上传? 是否已上传部分? 如果上传了部分,那么上传了那部分");
      const postFormData = this.createPostFormData(chunks, fileHash);
      await this.postToServer(postFormData);
      console.log("将hash和文件后缀发送给后端,让其合并文件");
    }

总结

项目地址-服务端 + 前端

到此就完成了切片上传,断点续传和秒传没有细代码演示出来,因为这一部分前端没有什么难点,主要是后端找文件,逻辑再说一遍

断点续传与秒传逻辑

  • 计算出文件 hash 值【fileHash】
  • 带上文件 hash 值与文件后缀,请求后端校验文件是否存在的接口
  • 后端返回类似这样的数据{ hasFile : 是否存在文件, fileList:已存在的切片文件名列表}
  • hasFile === true ? "秒传成功" : "进入断点续传"
  • 用 fileList 过滤 postFormData,并记录已存在的进度
  • 将过滤的数据传入 postToServer,完成上传
  • 带上文件 hash + 文件后缀,请求接口进行文件合并

回答一下刚开始的问题

  • 用什么东西对文件切片 ?

    直接调用类文件对象的 slice 方法即可进行切片

  • 文件怎么读取?

    使用FileReader读取文件为 ArrayBuffer 数据

  • 大文件计算 md5 会不会长时间占用主线程,导致页面卡顿?

    使用spark-md5进行文件 md5 计算,还要通过webWorker的多线程机制进行计算

  • 切片过多,会不会占用很多网络请求 ?

    我们通过请求并发数量控制,将文件请求数控制在三个

还可以完善

在当前示例中,我并没有管进度问题。实际上计算 md5,切片文件上传,都是比较耗时操作,需要计算进度的,可以在已上代码标注计算进度的位置添加上进度并显示在页面上,用户体验更加友好

其他内容拓展【布隆过滤器】

以上内容中,如果是大文件通过 md5 计算 hash 是比较耗时的。如果精度要求不高的话,其实可以使用抽样方式计算 md5

抽样规则:

  • 取文件前 2M
  • 中间切片取前两个字节,后两个字节(注意:这里的中间部分,不是说中间就单独一块,而是说将中间部份分若干份,然后取一份中的前 2 和后 2)
  • 取文件后 2M

抽样的优缺点:

  • 优点:抽样数据量少,计算速度快
  • 缺点:抽样导致精度丢失

最后

有什么说的不对的地方希望评论区指出,期待你的反馈!!!