【功能实现】分片上传

2022/09/01 11:57:31

为什么要分片上传

最简单的上传功能逻辑是:浏览器选择文件,调用后端上传接口,后端保存文件。

这一系列操作是将文件的二进制数据全部存到缓存里,然后一股脑上传,后端接收到完整数据后再保存。

这种方式在上传大文件或出现以外导致上传中断时(网络中断)无法处理,必须从头开始重新上传。

分片上传的思路

  1. 客户端获取文件的唯一 key,这个 key 值用于在上传完成后验证文件完整性。
  2. 客户端拆分文件,并上传拆分后的文件块。
  3. 后端接收上传的文件块并保存。
  4. 客户端将文件块上传完毕后告诉后端,后端将下载的文件块合并成原文件。
  5. 后端获取合并文件的 key(与客户端获取 key 的算法一致),与客户端传递的 key 值进行比对,如果一致则说明上传成功,文件没有损坏。

浏览器拆分文件块

想在在浏览器中读取用户计算机上的文件内容需要使用FileReaderopen in new window对象。

FileReader 仅用于以安全的方式从用户(远程)系统读取文件内容,它不能用于从文件系统中按路径名简单地读取文件。

拆分文件块则需要使用 Fileopen in new window 对象提供的 slice() 方法。

const blobSlice =
  File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;

const fileReader = new FileReader();

fileReader.onload = function (e) {
  e.target.result; // e.target.result 为读取的 ArrayBuffer 格式数据
};

// 文件块读取失败
fileReader.onerror = function (e) {
  console.log("文件读取产生错误", e.message);
};

fileReader.readAsArrayBuffer(file);

MD5 加密与文件完整性校验

使用 spark-md5 库对文件进行 md5 加密。

spark-md5open in new window 支持 ArrayBuffer 格式的数据加密,因此可以将文件内容读取为 ArrayBuffer 格式生成 md5 加密字符串。

import SparkMD5 from "spark-md5";

function generateFileMD5(file) {
  const chunkSize = 50 * 1024 * 1024; // 分片大小为50M
  const fileSize = file.size;
  return new Promise((resolve) => {
    const blobSlice =
        File.prototype.slice ||
        File.prototype.mozSlice ||
        File.prototype.webkitSlice,
      chunks = Math.ceil(fileSize / chunkSize), // 数据块数量
      curChunk = 0, // 当前块
      spark = new SparkMD5.ArrayBuffer(),
      fileReader = new FileReader();

    // 监听文件块读取完毕
    fileReader.onload = function (e) {
      spark.append(e.target.result); // 追加到ArrayBuffer中

      curChunk += 1;
      if (curChunk > chunks) {
        console.log("文件读取完毕");
        // 返回文件哈希值
        resolve(spark.end());
      } else {
        loadNext();
      }
    };

    // 文件块读取失败
    fileReader.onerror = function (e) {
      console.log("文件块读取产生错误", e.message);
    };

    function loadNext() {
      var start = curChunk * chunkSize,
        end = start + chunkSize > fileSize ? fileSize : start + chunkSize;

      // 将文件数据读到ArrayBuffer中
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
    }

    loadNext();
  });
}

服务端合并文件块

使用 concat-filesopen in new window 完成文件合并。

import * as concat from "concat-files";

function mergeChunks(chunksFolderPath, saveFilePath): Promise<void> {
  return new Promise(async (resolve, reject) => {
    const fileArr = await fs.readdir(chunksFolderPath);

    // 排序
    fileArr.sort((x: any, y: any) => {
      return x - y;
    });
    for (let i = 0; i < fileArr.length; i++) {
      fileArr[i] = chunksFolderPath + "/" + fileArr[i];
    }
    concat(fileArr, saveFilePath, (err) => {
      if (err) {
        reject(err);
      } else {
        console.log("chunks已合并,文件保存至:", saveFilePath);
        resolve();
      }
    });
  });
}

文件块校验与断点续传

按照上述流程实现的分片上传无法处理文件块损坏的情况,要处理这种情况可以对每一个文件块都进行 key 值比对以校验文件块的完整性,然后上传需要重新上传的文件块。

这样需要在客户端上传文件块之前先调接口,由后端返回需要上传的文件块。

由后端告诉客户端应该上传哪些文件块还有一个好处就是可以实现断点续传,客户端在中断上传后重新上传时,只需要在后台判断下之前的上传位置,只上传此位置之后的文件块即可。

示例代码

代码地址open in new window

此示例来源于Node+H5 实现大文件分片上传(有源码)open in new window,有两个问题需要自己解决:

  1. 代码 clone 后需要在项目目录下新建 /resource/upload 目录以存放上传文件。

  2. upload.js 中需要在合并逻辑处添加一段排序逻辑。

    // let fileArr = await listDir(srcDir); 这句下面加一句
    
    fileArr.sort((x, y) => {
      return x - y;
    });
    

参考

Node+H5 实现大文件分片上传(有源码)open in new window