Skip to content

Nest 实现大文件分片上传

介绍

大文件分片上传的原理是在前端文件上传的时候对上传的文件进行分割,把大的文件分割成一个一个的小文件。

在大文件上传中也是用类型 multipart/form-data 来进行文件处理。

分片

在对大文件上传之前,就是要对文件进行分片,那么用什么方式对文件进行分片呢?

浏览器对 Blob 文件有一个 slice 方法,这个方法可以对文件进行分片处理,因为 file 文件就是 Blob 的格式,可以进行分片。

合并

合并的原理就是用 node 的 fs 模块,把分片上传的每一个小片写到文件中,在写入的过程中需要注意每一个小片段的顺序,

然后指定 fs 写文件的起始位置,这样写入的文件就按照顺序把小的片段文件串起来了,合成了上传的大文件。

前端

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>大文件上传</title>
    <script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
  </head>
  <body>
    <input id="fileInput" type="file" />
    <script>
      const fileInput = document.querySelector("#fileInput");

      const chunkSize = 1024 * 20 * 1024; //10M

      fileInput.onchange = async function () {
        const file = fileInput.files[0];

        console.log(file);

        const chunks = [];
        let startPos = 0;
        while (startPos < file.size) {
          chunks.push(file.slice(startPos, startPos + chunkSize));
          startPos += chunkSize;
        }

        const randomStr = Math.random().toString().slice(2, 8);

        const tasks = [];
        chunks.map((chunk, index) => {
          const data = new FormData();
          data.set("name", randomStr + "_" + file.name + "-" + index);
          data.append("files", chunk);
          tasks.push(axios.post("http://localhost:5000/user/upload", data));
        });
        await Promise.all(tasks);

        axios.get(
          "http://localhost:5000/user/merge?name=" + randomStr + "_" + file.name
        );
      };
    </script>
  </body>
</html>

Nest

安装

bash
pnpm add @types/multer -D

上传 upload.service.ts

ts
import {
  Controller,
  Post,
  Body,
  UploadedFiles,
  UseInterceptors,
  Get,
  Query,
  Res,
} from "@nestjs/common";
import { UserService } from "./user.service";
import * as fs from "fs";
import { FilesInterceptor } from "@nestjs/platform-express";
import type { Response } from "express";
@Controller("user")
export class UserController {
  constructor(private readonly userService: UserService) {}

  // 上传接口
  @Post("upload")
  @UseInterceptors(
    FilesInterceptor("files", 20, {
      dest: "uploads",
    })
  )
  uploadFiles(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body
  ) {
    console.log("body", body);
    console.log("files", files);
    // 将分片移动到单独的目录
    const fileName = body.name.match(/(.+)\-\d+$/)[1];
    const chunkDir = "uploads/chunks_" + fileName;
    if (!fs.existsSync(chunkDir)) {
      fs.mkdirSync(chunkDir);
    }
    fs.cpSync(files[0].path, chunkDir + "/" + body.name);
    fs.rmSync(files[0].path);
    return {
      data: "分片上传成功",
    };
  }

  // 合并接口
  // 合并接口post 这里只有用自定义返回了,把这个接口放到配置文件的白名单
  // 这样不会被全局拦截
  @Get("merge")
  merge(@Query("name") name: string, @Res() res: Response) {
    console.log(name);
    const chunkDir = "uploads/chunks_" + name;

    const files = fs.readdirSync(chunkDir);
    files.sort((f1: string, f2: string) => {
      const idx1 = Number(f1.split("-").at(-1));
      const idx2 = Number(f2.split("-").at(-1));
      return idx1 < idx2 ? -1 : idx1 > idx2 ? 1 : 0;
    });
    console.log(files);
    // 定义开始位置
    let startPos = 0;

    files.forEach((file, index) => {
      const filePath = chunkDir + "/" + file;
      const outputFilePath = "uploads/" + name;
      const readStream = fs.createReadStream(filePath);
      const writeStream = fs.createWriteStream(outputFilePath, {
        start: startPos,
      });

      readStream.pipe(writeStream).on("finish", () => {
        if (index === files.length - 1) {
          fs.rm(chunkDir, { recursive: true }, () => {
            res.send({ data: "合并完成", code: 200 });
          });
        }
      });

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