从零开始手写一个「开箱即用的大文件分片上传库」

一、写在前面

相信各位小伙伴在实际做业务的时候都遇到过「大文件上传」的场景。在这种场景下,我们不能直接将大文件直接丢给服务器进行处理,这会对服务器的性能产生巨大的影响,并且上传速度也过于缓慢。因此我们会采用「大文件分片上传」的技术方案,尽可能快地上传文件,并对服务器的性能产生尽可能小的影响。

刚好最近趁着业余时间,详细了解了下「大文件分片上传」的技术细节,发现已有的一些分片上传库的使用体验都不太好,因此在这里从零开始手写一个大文件分片上传库,一是加深理解,二是方便大家后续直接使用。

二、大文件分片上传技术方案

一般来说大文件分片上传主要有以下几个步骤:

1、前端计算文件md5。计算文件的md5是为了检查上传到服务器的文件是否与用户所传的文件一致,同时也可以根据md5进行「秒传」等操作。

2、前端发送初始化请求,后端初始化上传。当计算好文件的md5后,就可以进入初始化上传的步骤,在这一步骤中,前端会发起初始化请求,包含这个文件计算的md5、文件名等信息,而后端则会根据md5初始化接收分片文件的目录。

3、前端进行文件分片,并将分片传输给后端。这个步骤自不必多说,前端会将文件分成多个小块,并按照一定的策略进行上传,如果遇到上传失败的分片,需要重新上传。

4、前端发送结束上传请求,后端合并分片。当发送成功所有的文件分片后,前端会发起结束上传请求,后端收到请求后,会将已有的文件分片合并,生成文件,并确认生成的文件的md5是否与初始化传入的md5一致。

值得注意的是,当文件比较大时,直接根据文件「计算md5」、「进行文件分片」、「合并文件」都是十分消耗内存的(甚至可能直接把内存吃满),因此在这三个步骤,需要使用管道来减小内存上的消耗。

三、easy-file-uploader

先贴一下我用Typescript写的「开箱即用的大文件分片上传库」的地址吧:easy-file-uploader

具体使用方式可以直接点击上述地址,查看README.md。

那么话不多说,让我们来看看这个库我具体是怎么实现的。

四、easy-file-uploader-server实现过程

从刚才「大文件分片上传技术方案」中,我们可以明确后端首先要提供以下几个最基础的能力:

1、初始化文件上传
2、接收文件分片
3、合并文件分片

其次,为了使用体验,我们还需要提供如下附加能力:

4、获取已上传的分片信息
5、清理分片存储目录(用于取消上传)

因此,我们首先要写一个FileUploaderServer类,提供上述这些能力。这样,当开发者在使用easy-file-uploader-server的时候,只需要实例化FileUploaderServer类,并在接口中使用这个类提供的方法即可。

这样做是为了提供了更好的可拓展性——毕竟,开发者可能用express/koa/原生nodejs等框架实现接口,如果我们挨个实现一遍。。。太不利于维护了。

那么我们能很快地写出来这个类的大框架,它大概长这样:

interface IFileUploaderOptions {
   
  tempFileLocation: string; // 分片存储路径
  mergedFileLocation: string; // 合并后的文件路径
}

class FileUploaderServer {
   
  private fileUploaderOptions: IFileUploaderOptions;
  
  /**
   * 初始化文件分片上传,实际上就是根据fileName和时间计算一个md5,并新建一个文件夹
   * @param fileName 文件名
   * @returns 上传Id
   */
  public async initFilePartUpload(fileName: string): Promise<string> {
   }

  /**
   * 上传分片,实际上是将partFile写入uploadId对应的文件夹中,写入的文件命名格式为`partIndex|md5`
   * @param uploadId 上传Id
   * @param partIndex 分片序号
   * @param partFile 分片内容
   * @returns 分片md5
   */
  public async uploadPartFile(
    uploadId: string,
    partIndex: number,
    partFile: Buffer,
  ): Promise<string> {
   }

  /**
   * 获取已上传的分片信息,实际上就是读取这个文件夹下面的内容
   * @param uploadId 上传Id
   * @returns 已上传的分片信息
   */
  public async listUploadedPartFile(
    uploadId: string,
  ): Promise<IUploadPartInfo[]> {
   }

  /**
   * 取消文件上传,硬删除会直接删除文件夹,软删除会给文件夹改个名字
   * @param uploadId 上传Id
   * @param deleteFolder 是否直接删除文件夹
   */
  async cancelFilePartUpload(
    uploadId: string,
    deleteFolder: boolean = false,
  ): Promise<void> {
   }

  /**
   * 完成分片上传,实际上就是将所有分片都读到一起,然后进行md5检查,最后存到一个新的路径下。
   * @param uploadId 上传Id
   * @param fileName 文件名
   * @param md5 文件md5
   * @returns 文件存储路径
   */
  async finishFilePartUpload(
    uploadId: string,
    fileName: string,
    md5: string,
  ): Promise<IMergedFileInfo> {
   }
}

4-1、初始化文件上传

在初始化上传的时候,我们要在tempFileLocation目录(也就是分片存储目录)下根据md5新建一个目录,用于保存上传的分片。这个目录名就是uploadId,是根据${fileName}-${Date.now()}计算的md5值。

  /**
   * 初始化文件分片上传,实际上就是根据fileName和时间计算一个md5,并新建一个文件夹
   * @param fileName 文件名
   * @returns 上传Id
   */
  public async initFilePartUpload(fileName: string): Promise<string> {
   
    const {
    tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadId = calculateMd5(`${
     fileName}-${
     Date.now()}`);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (uploadFolderExist) {
   
      throw new FolderExistException(
        'found same upload folder, maybe you meet hash collision',
      );
    }
    await fse.mkdir(uploadFolderPath);
    return uploadId;
  }

4-2、接收文件分片

在接收文件分片的时候,我们首先会获取分片存储位置,然后计算分片的md5,然后将分片命名为${partIndex}|${partFileMd5}.part,存储到对应路径下。

/**
   * 上传分片,实际上是将partFile写入uploadId对应的文件夹中,写入的文件命名格式为`partIndex|md5`
   * @param uploadId 上传Id
   * @param partIndex 分片序号
   * @param partFile 分片内容
   * @returns 分片md5
   */
  public async uploadPartFile(
    uploadId: string,
    partIndex: number,
    partFile: Buffer,
  ): Promise<string> {
   
    const {
    tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
   
      throw new NotFoundException('not found upload folder');
    }
    const partFileMd5 = calculateMd5(partFile);
    const partFileLocation = path.join(
      uploadFolderPath,
      `${
     partIndex}|${
     partFileMd5}.part`,
    );
    await fse.writeFile(partFileLocation, partFile);
    return partFileMd5;
  }

4-3、合并文件分片

在合并文件分片时,最重要的就是下面这个mergePartFile方法,这个方法会使用readStreamwriteStream来读取/写入文件分片,这样做的好处是能尽可能地减少内存占用。同时,使用MultiStream提供的pipe方法,来保证stream的顺序。

export async function mergePartFile(
  files: IFileInfo[],
  mergedFilePath: string,
): Promise<void> {
   
  const fileList = files.map((item) => {
   
    const [index] = item.name.replace(/\.part$/, '').split('|');
    return {
   
      index: parseInt(index),
      path: item.path,
    };
  });
  const sortedFileList = fileList.sort((a, b) => {
   
    return a.index - b.index;
  });
  const sortedFilePathList = sortedFileList.map((item) => item.path);
  merge(sortedFilePathList, mergedFilePath);
}

function merge(inputPathList: string[], outputPath: string) {
   
  const fd = fse.openSync(outputPath, 'w+');
  const writeStream = fse.createWriteStream(outputPath);
  const readStreamList = inputPathList.map((path) => {
   
    return fse.createReadStream(path);
  });
  return new Promise((resolve, reject) => {
   
    const multiStream = new MultiStream(readStreamList);
    multiStream.pipe(writeStream);
    multiStream.on('end', () => {
   
      fse.closeSync(fd);
      resolve(true);
    });
    multiStream.on('error', () => {
   
      fse.closeSync(fd);
      reject(false);
    });
  });
}

那么有了mergePartFile方法后,合并文件分片的finishFilePartUpload方法也就呼之欲出了,在mergePartFile的基础上,增加文件保存路径的获取以及md5的校验即可。

  /**
   * 完成分片上传,实际上就是将所有分片都读到一起,然后进行md5检查,最后存到一个新的路径下。
   * @param uploadId 上传Id
   * @param fileName 文件名
   * @param md5 文件md5
   * @returns 文件存储路径
   */
  async finishFilePartUpload(
    uploadId: string,
    fileName: string,
    md5: string,
  ): Promise<IMergedFileInfo> {
   
    const {
    mergedFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(mergedFileLocation);
    const {
    tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
   
      throw new NotFoundException('not found upload folder');
    }
    const dirList = await listDir(uploadFolderPath);
    const files = dirList.filter((item) => item.path.endsWith('.part'));
    const mergedFileDirLocation = path.join(mergedFileLocation, md5);
    await fse.ensureDir(mergedFileDirLocation);
    const mergedFilePath =

你可能感兴趣的:(前端,typescript,文件上传,nodejs,node.js)