超详细的大文件分片上传⏫实战与优化(后端部分)

1,110 阅读5分钟

大文件分片上传实战与优化(后端Part)

1. 需求分析

1.1 前端部分

详见: 超详细的大文件分片上传⏫实战与优化(前端部分) juejin.cn/post/735310…

1.2 后端接口设计
  • 检查文件是否已经上传过
  • 查询待上传的文件分片
  • 接收文件分片
  • 校验分片(此处校验逻辑同前端)
  • 合并分片

后端这里没什么亮点都基操 ...

1.3 技术选型

前端: Angular + NG-Zorro

后端: Nest.js + Prisma + MySQL + Minio(文件存储)

(OOP 的大胜利)

1.4 表结构

主要字段如下

bucket_name: 桶名字

filename: 文件名

hash: 文件 Hash

size: 文件大小

meta_data: 文件元数据

2. 检查文件是否已经上传过

2.1 思路

根据文件的 Hash 或默克尔树的树根 Hash 值和文件的元数据来确定文件是否已经上传过了, 将数据存在数据库中

如果已经存在了就直接返回文件的 URL

其中文件元数据包括: 文件大小, 上次修改时间, 文件类型

2.2 实现

此处可以再加上元数据的传参(过于简单, 我就没加)

Controller

@Get('exist')
async checkIfExist(@Query('hash') hash: string, @Query('size') size: string) {
  const fileUrl = await this.fileInfoSvc.checkIfExist(hash, size);
  return R.ok(fileUrl);
}

Service

async checkIfExist(hash: string, size: string) {
  const foundRecord = await this.prismaSvc.bizFileInfo.findFirst({
    where: {
      hash,
      size,
    },
    select: {
      bucketName: true,
      filename: true,
    },
  });
  if (foundRecord) {
    return foundRecord.bucketName + '/' + foundRecord.filename;
  }
  return '';
}

3. 查询待上传的文件分片

3.1 思路

将文件的 hash 值作为存放文件分片的目录名, 然后比对这个目录下的文件名

其中文件分片的名字就是分片的 hash 值 + 分片的序号(合并分片时使用)

3.2 实现

此处先将后续要用到的 fs/promise 包下的部分 API 做了封装

这里要注意的是, 尽量不要使用带 XXXSync 名字的 fs 中的 API, 这会导致整个应用阻塞, 直到 XXXSync 的操作完成!

// fs-util.ts

import * as fsp from 'fs/promises';
import * as fsc from 'fs';

/**
 * 检查文件夹是否存在
 * @param dirPath 文件夹的路径
 */
async function checkDirExists(dirPath: string) {
  try {
    await fsp.access(dirPath);
    return true; // 文件夹存在
  } catch {
    return false; // 文件夹不存在或无法访问
  }
}

/**
 * 创建目录, 如果父级目录不存在则会被自动创建
 * @param dirPath
 */
async function createDir(dirPath: string) {
  return await fsp.mkdir(dirPath, { recursive: true });
}

/**
 * 移动文件 (先复制后删除)
 * @param source 源路径
 * @param destination 目标路径
 * @param destinationParent 目标路径的父路径, 用于检查文件是否移动成功
 */
async function moveFile(
  source: string,
  destination: string,
  destinationParent: string,
) {
  return new Promise<void>((rs, rj) => {
    try {
      const readStream = fsc.createReadStream(source);
      const writeStream = fsc.createWriteStream(destination);
      readStream.pipe(writeStream);
      readStream.on('end', async () => {
        await fsp.readdir(destinationParent);
        await fsp.unlink(source);
        rs();
      });
    } catch (e) {
      rj(e);
    }
  });
}

/**
 * 读取一个文件并将它转成 Buffer
 * @param path
 */
async function readFileAsBuffer(path: string) {
  const readStream = fsc.createReadStream(path);
  const chunks = [];
  return new Promise<Buffer>((rs, rj) => {
    readStream.on('data', (chunk) => {
      chunks.push(chunk); // 收集数据块
    });

    readStream.on('end', () => {
      const buffer = Buffer.concat(chunks); // 合并所有数据块构成Buffer
      rs(buffer);
    });

    readStream.on('error', (e) => {
      rj(e);
    });
  });
}

export { checkDirExists, createDir, moveFile, readFileAsBuffer };

Controller

export class MinioController {
  separator = '__';
  chunkDir = 'uploads' + path.sep + 'chunks' + this.separator;
  bucketName = 'my-file';
  
  @Post('chunks')
  async getExistChunks(
    @Body('hash') hash: string,
    @Body('hashList') hashList: string[],
  ) {
    const chunkDir = this.chunkDir + hash;
    if (await checkDirExists(chunkDir)) {
      const existHashList = await fsp.readdir(chunkDir);
      const hashSet = new Set<string>(
        existHashList.map((file) => file.split(this.separator)[1]),
      );
      return R.ok(hashList.filter((_hash: string) => !hashSet.has(_hash)));
    } else {
      return R.ok(hashList);
    }
  }
}

4. 接收文件分片

4.1 思路

使用集成好的 multer 的包来接收分片

4.2 实现

Controller

其中 @UseInterceptors() 用来先接收前端传来的分片, 其中 dest 是临时存储分片的地方

当分片完整接收后, 会将它移动到之前以文件 Hash 命名的目录中

注意: 此处上传不完整的分片不会被移动到目标目录中

@Post('upload')
@UseInterceptors(
  FilesInterceptor('files', 100, { dest: `uploads${path.sep}temp` }),
)
async uploadFiles(
  @UploadedFiles() files: Array<Express.Multer.File>,
  @Body()
  body: {
    name: string; // 文件名
    index: string; // chunk index
    fileHash: string; // 文件 Hash
    chunkHash: string; // chunk Hash
  },
) {
  const chunkDir = `uploads${path.sep}chunks${this.separator}${body.fileHash}`;

  if (!(await checkDirExists(chunkDir))) await createDir(chunkDir);

  // 移动缓存文件到目录中, 并改文件名
  const source = files[0].path;
  const destination =
    chunkDir +
    path.sep +
    body.name +
    this.separator +
    body.chunkHash +
    this.separator +
    body.index;
  await moveFile(source, destination, chunkDir);
  return R.ok();
}

5. 校验分片

5.1 思路

校验分片时的 hash 策略需要与前端一致

当本地分片的 hash 值与前端传来的 hash 值不同时删除文件分片

此处在 Node 下计算 MD5 速度足够快, 暂不需要使用线程池 / Worker 池, 也可以考虑使用 pm2 多开 node 服务

5.2 实现

// Utils.ts

import * as crypto from 'crypto';
import { buf } from 'crc-32';
import { readFileAsBuffer } from './fs-util';

async function md5ForFile(path: string) {
  const buffer = await readFileAsBuffer(path);
  const hash = crypto.createHash('md5');
  hash.update(buffer);
  return hash.digest('hex');
}

async function crc32ForFile(path: string, seed = 0) {
  const getCrcHex = (crc: number) => (crc >>> 0).toString(16);
  const buffer = await readFileAsBuffer(path);
  const crc = buf(buffer, seed);
  return getCrcHex(crc);
}

export { md5ForFile, crc32ForFile };

Controller

@Post('verify2')
async verifyChunks2(
  @Body('hash') hash: string,
  @Body('hashList') hashList: string[],
) {
  // 分片数量小于 borderCount 用 MD5, 否则用 CRC32 算 Hash
  const BORDER_COUNT = 100;
  const chunkDir = this.chunkDir + hash;
  if (await checkDirExists(chunkDir)) {
    let localChunkHashList: string[] = [];
    const chunkNames = await this.readChunksDirWithSorted(chunkDir);

    if (hashList.length <= BORDER_COUNT) {
      const getMd5ForFile = chunkNames.map((fileName) =>
        md5ForFile(chunkDir + path.sep + fileName),
      );
      localChunkHashList = await Promise.all(getMd5ForFile);
    } else {
      // 直接算分片的 crc 32
      const getCrc32ForFile = chunkNames.map((fileName) =>
        crc32ForFile(chunkDir + path.sep + fileName),
      );
      localChunkHashList = await Promise.all(getCrc32ForFile);
    }

    // 删除损坏的 chunk
    const existHashSet = new Set<string>(localChunkHashList);
    const brokenChunkList = hashList.filter(
      (_hash: string) => !existHashSet.has(_hash),
    );
    const brokenChunkSet = new Set<string>(brokenChunkList);
    const deleteBrokenChunks = chunkNames
      .filter((chunkName) =>
        brokenChunkSet.has(chunkName.split(this.separator).at(-2)),
      )
      .map((chunkName) => fsp.unlink(chunkDir + path.sep + chunkName));
    await Promise.all(deleteBrokenChunks);
    return R.ok(brokenChunkList);
  } else {
    return R.error(SysCodeEnum.NO_SUCH_FILE);
  }
}

6. 合并分片

6.1 思路

!!! 在 Node 下使用 fs 包合并文件概率导致合并后文件损坏, 原因未知 !!!

在 Minio 中提供了合并分片的 API, 所以直接在 Minio 中做合并操作, 而不是在 Node 中

6.2 实现

Controller

@Post('merge2')
async mergeInMinio(
  @Body('hash') hash: string,
  @Body('name') name: string,
  @Body('size') size: string,
  @Body('metadata') metadata: string,
) {
  const chunkDir = this.chunkDir + hash;
  const chunkNames = await this.readChunksDirWithSorted(chunkDir);

  for (const chunkName of chunkNames) {
    await this.minioSvc.uploadFilePromisify(
      chunkDir + path.sep + chunkName,
      chunkName,
      this.bucketName,
    );
  }

  // 使用 uuid 多用户同时 Merge 一个文件时会 Merge 出两个不同文件
  // const fileName = generateUUID() + this.separator + name;
  // 改为使用 hash 加文件名
  const fileName = hash + this.separator + name;
  await this.minioSvc.mergeFile(chunkNames, fileName, this.bucketName);
  await this.minioSvc.removeFile(this.bucketName, chunkNames);
  await this.fileInfoSvc.recordHash(
    hash,
    this.bucketName,
    fileName,
    size,
    metadata,
  );
  await fsp.rm(chunkDir, { recursive: true, force: true });

  return R.ok(`/${this.bucketName}/${fileName}`);
}

// minioSvc.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio';

@Injectable()
export class MinioService {
  minio: typeof Minio;
  minioClient: Minio.Client;

  constructor(private configSvc: ConfigService) {
    this.minio = Minio;
    this.minioClient = new Minio.Client({
      endPoint: this.configSvc.get<string>('MINIO_HOST'),
      port: parseInt(this.configSvc.get<string>('MINIO_PORT')),
      useSSL: false,
      accessKey: this.configSvc.get<string>('MINIO_ACCESS_KEY'),
      secretKey: this.configSvc.get<string>('MINIO_SECRET_KEY'),
    });
  }

  async uploadFilePromisify(
    path: string,
    fileName: string,
    bucketName: string,
    metaData: any = {},
  ) {
    await this.minioClient.fPutObject(bucketName, fileName, path, metaData);
    return `/${bucketName}/${fileName}`;
  }

  mergeFile(chunksName: string[], fileName: string, bucketName: string) {
    const sourceList = chunksName.map(
      (name) =>
        new this.minio.CopySourceOptions({
          Bucket: bucketName,
          Object: name,
        }),
    );
    const destOption = new this.minio.CopyDestinationOptions({
      Bucket: bucketName,
      Object: fileName,
    });
    return this.minioClient.composeObject(destOption, sourceList);
  }

  async removeFile(bucketName: string, fileNameList: string[]) {
    await this.minioClient.removeObjects(bucketName, fileNameList);
  }
}

// fileInfoSvc.ts

/**
 * 记录 merge 好的文件
 * @param hash 文件 Hash
 * @param bucketName
 * @param filename 文件名
 * @param size 文件大小, 单位 KB
 * @param metadata 元数据, Json 字符串
 */
async recordHash(
  hash: string,
  bucketName: string,
  filename: string,
  size: string, // 单位 KB
  metadata: string,
) {
  await this.prismaSvc.bizFileInfo.create({
    data: {
      id: generateUUID(),
      bucketName,
      filename,
      hash,
      size,
      metaData: metadata,
      uploadDate: new Date(),
    },
  });
}

7. 总结

完整 Demo 仓库:

前端项目: github.com/Tkunl/kun-u…

后端项目: github.com/Tkunl/kun-u…

此外本文可能有少许不准确或者有误的地方,欢迎评论区赐教。最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发 ❤❤❤

文章参考: 一文吃透👉大文件分片上传、断点续传、秒传⏫ juejin.cn/post/732414…

转载请注明原作者, 和原文链接, 谢谢 ! ! !