大文件分片上传、断点续传及文件秒传

647 阅读6分钟

前端:Vue2 + IView2 + Spark-md5

服务端:nest.js

分片上传

核心思路:文件切片 ---> 并发上传 ---> 对切片进行排序 ---> 合并切片

前端

分片上传的核心是利用 Blob.prototype.slice方法,该方法返回原文件的某个切片,我们将文件按照指定大小切割并放到一个数组中。

File的原型是Blob,所以可以使用该方法进行文件切片。

// 文件切片
chunkFile(file) {
  const fileChunkList = [];
  let cur = 0;
  while (cur < file.size) {
    fileChunkList.push({
      file: file.slice(cur, cur + CHUNK_SIZE),
    });
    cur += CHUNK_SIZE;
  }
  return fileChunkList;
},

文件切片后,为每个切片添加一个hashfileHash

hash的主要作用是为了标识切片的顺序,这里采用文件名 + index去标识。因为切片是并发上传的,服务端收到切片的顺序可能与我们上传的顺序不一致,有了hash服务端就可以通过index对文件切片进行排序。

fileHash则是断点续传和文件秒传需要用到,它是通过spark-md5生成的单独的文件标识,只要文件内容不变,该值就不会发生变化。

setChunkListWithHash(fileChunkList, fileName) {
  return fileChunkList.map(({ file }, index) => {
    return {
      chunk: file,
      fileHash: this.fileHash,
      hash: fileName + "-" + index,
    };
  });
},

设置完后将每个切片都包装为一个请求,然后通过Promise.allSettled上传切片,它返回一个对象数组,包含每个切片promise的结果。

// 上传切片
async uploadChunks(chunkListWithHash, fileName) {
  const requestList = chunkListWithHash
    .map(({ chunk, hash, fileHash }) => {
      const formData = new FormData();
      formData.append("chunk", chunk);
      formData.append("hash", hash);
      formData.append("filename", fileName);
      formData.append("fileHash", fileHash);
      return { formData };
    })
    .map(({ formData }) => {
      return axios.post(UPLOAD_URL, formData);
    });
  return Promise.allSettled(requestList);
}

所有的切片均上传成功后,再调用合并切片的接口,通过query参数传入文件名(fileHash+文件后缀),通知后端进行切片的合并。

// 上传切片
const res = await this.uploadChunks(chunkListWithHash, file.name);
const successList = res.filter(promise=>promise.status==='fulfilled');
// 上传成功
if (successList.length === chunkListWithHash.length) {
    // 合并切片
    const res = await this.mergeChunks(`${this.fileHash}.${suffix}`);
}
// 合并切片
async mergeChunks(fileName) {
  const res = await axios.get(`${MERGE_URL}?name=${fileName}`);
  return res;
},

服务端

服务端需要提供两个接口:

  1. 文件切片上传的接口
  2. 文件切片合并的接口

切片上传接口

我们需要将前端传来的切片保存到一个文件夹中,为了避免文件夹 or 文件名的重复,我们使用 'chunkDir' + fileHash作为文件夹的名字,为了方便切片的排序,我们使用 fileHash + hash作为切片的名字。

在将切片拷贝并放到对应的目录后,再删掉原来的切片。

@Post('upload')
@UseInterceptors(
    FilesInterceptor('chunk', 20, {
      dest: 'uploads',
    }),
)
uploadFiles(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body: { hash: string; fileHash: string },
) {
    // 文件夹名
    const chunkDir = `uploads/chunkDir_${body.fileHash}`;
    // 切片名
    const fileName = body.fileHash + '_' + body.hash;

    if (!fs.existsSync(chunkDir)) {
      fs.mkdirSync(chunkDir);
    }
    fs.cpSync(files[0].path, `${chunkDir}/${fileName}`);
    fs.rmSync(files[0].path);
}

切片合并接口

通过前端传来query参数中的name来找到对应的切片文件夹,然后按照文件夹中切片名的index进行排序,有了正确的顺序,再依次读取文件并写入到新的文件中,所有切片均写入完成后,再删掉切片文件夹及其中的切片。

  @Get('merge')
  merge(@Query('name') name: string) {
    const chunkDir = `uploads/chunkDir_${name.split('.')[0]}`;
    console.log(chunkDir);
    const files = fs.readdirSync(chunkDir);

    let count = 0;
    let start = 0;
    files.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
    files.map((file) => {
      const filePath = chunkDir + '/' + file;
      const stream = fs.createReadStream(filePath);
      stream
        .pipe(fs.createWriteStream('uploads/' + name, { start }))
        .on('finish', () => {
          count++;
          if (count === files.length) {
            stream.close();
            fs.rm(
              chunkDir,
              {
                recursive: true,
              },
              () => {},
            );
          }
        });

      start += fs.statSync(filePath).size;
    });
  }

断点续传及文件秒传

核心思路:

  1. 断点续传:判断切片文件夹是否存在,如果存在则返回存在切片的index,前端根据index筛选出不需要上传的切片,只上传切片文件夹中不存在的切片
  2. 文件秒传:判断文件是否存在,如果存在则无需上传直接返回上传成功的结果。

生成fileHash

文件hash的生成是使用spark-md5这个库来实现的,由于大文件计算耗时过长,如果放到主线程可能会引起UI阻塞,所以使用Web Worker在 worker 线程计算 hash。

Web Worker 是在浏览器中运行的独立 JavaScript 线程。为了确保线程的隔离性,浏览器要求 Worker 的脚本必须通过独立的网络请求加载,而不能直接从模块系统导入(如使用 import 语句)。

我们使用的是vue-cli构建的Vue2工程,在pulic文件夹建立hash.js,内容如下(参考文章):

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

self.onmessage = (e) => {
  const { fileChunkList } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0;
  let count = 0;

  const loadNext = (index) => {
    const reader = new FileReader();
    reader.readAsArrayBuffer(fileChunkList[index].file);
    reader.onload = (e) => {
      count++;
      spark.append(e.target.result);
      if (count === fileChunkList.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end(),
        });
        self.close();
      } else {
        percentage += 100 / fileChunkList.length;
        self.postMessage({
          percentage,
        });
        loadNext(count);
      }
    };
  };
  loadNext(0);
};

同时需要将spark-md5.min.js也放到该目录中。

前端

调用Worker线程生成文件hash

// 生成文件hash
calculateHash(fileChunkList) {
  return new Promise((resolve) => {
    const startTime = Date.now();
    this.hashWorker = new Worker("/utils/hash.js");
    this.hashWorker.postMessage({
      fileChunkList,
    });
    this.hashWorker.onmessage = ({ data }) => {
      const { hash } = data;
      if (hash) {
        const endTime = Date.now();
        console.log(`总共用时${(endTime - startTime) / 1000}秒`);
        resolve(hash);
      }
    };
  });
},

验证文件是否存在,用来实现断点续传和文件秒传

// 文件秒传、断点续传
async checkFileExist(fileHash, suffix) {
  const res = await axios.get(
    `${VERIFY_URL}?fileHash=${fileHash}&suffix=${suffix}`
  );
  return res;
},

完整的流程如下:

生成切片---> 生成文件hash---> 验证文件是否上传过或存在上传过的切片---> 上传切片---> 合并切片

  // 文件切片
  const fileChunkList = this.chunkFile(file);
  // 生成文件hash
  this.fileHash = await this.calculateHash(fileChunkList);
  // 合并后的文件名为 fileHash + 文件后缀
  const suffix = file.name.split(".").pop();

  // 验证,文件秒传和断点续传的关键点
  const {
    data: { shouldUpload, alreadyUpload, complete },
  } = await this.checkFileExist(this.fileHash, suffix);
  // 已经上传过了,直接返回
  if (!shouldUpload) {
    alert("文件秒传-上传成功");
    return;
  }
  // 添加hash值
  let chunkListWithHash = this.setChunkListWithHash(
    fileChunkList,
    file.name
  );
  // 需要上传的切片
  if (!complete) {
    chunkListWithHash = chunkListWithHash.filter(
      ({ hash }) => !alreadyUpload.includes(hash.slice(-1))
    )
  }
  // 上传切片
  const res = await this.uploadChunks(chunkListWithHash, file.name);
  const successList = res.filter(promise=>promise.status==='fulfilled');
  // 上传成功
  if (successList.length === chunkListWithHash.length) {
    // 合并切片
    const res = await this.mergeChunks(`${this.fileHash}.${suffix}`);
    console.log(res);
  }

服务端

服务端需要提供验证文件 or 切片文件夹是否存在的接口,根据前端传递的query参数中的文件hash和文件后缀找到指定的文件或文件夹。

如果存在文件则返回已完成,如果存在切片文件夹则返回一个数组,包含文件切片名的index

@Get('verify')
verify(@Query('fileHash') fileHash: string, @Query('suffix') suffix: string) {
    // 已上传完毕,文件秒传
    if (fs.existsSync(`uploads/${fileHash}.${suffix}`)) {
      return {
        complete: true,
        alreadyUpload: 'all',
        shouldUpload: false,
      };
    }

    // 存在切片,断点续传
    if (fs.existsSync(`uploads/chunkDir_${fileHash}`)) {
      const filesNameList = fs.readdirSync(`uploads/chunkDir_${fileHash}`);
      return {
        complete: false,
        alreadyUpload: filesNameList.map((item) => item.slice(-1)),
        shouldUpload: true,
      };
    }

    // 未上传过,正常执行
    return {
      complete: false,
      alreadyUpload: [],
      shouldUpload: true,
    };
}

总结

  1. 分片上传的流程

    • 前端:利用 Blob.prototype.slice 方法将文件切割为多个小片段,并为每个切片生成一个唯一的 hashfileHash,确保文件可以并发上传并按照正确顺序合并。前端通过 Promise.allSettled 实现并发上传,上传成功后调用后端合并接口。
    • 服务端:提供切片上传和切片合并的接口。前端上传的切片会存放到一个以 fileHash 命名的文件夹中,上传完成后,服务端会按照切片的顺序进行合并,并将合并后的文件存储。
  2. 断点续传与文件秒传

    • 前端:通过生成文件的 fileHash 来判断文件或部分切片是否已经上传,避免重复上传。断点续传根据服务端返回的切片信息,只上传缺少的部分,文件秒传则无需再次上传。
    • 服务端:验证文件或切片文件夹是否存在,如果存在则返回相关信息,帮助前端判断是否需要继续上传。
  3. 文件 Hash 计算

    • 使用 spark-md5 生成 fileHash。为避免大文件导致主线程阻塞,采用 Web Worker 在后台线程执行 hash 计算。

仓库地址:github.com/zhuhaohe-co…

参考文章: