Skip to content

在 nest 中上传文件与大文件分片上传

前言

上传文件对平常业务中来说是一个不可避免的需求,本文将会介绍如何在 nest 中实现文件上传。而且有时候会遇到文件比较大的情况,那么直接上传文件就会出现时间很长

大的文件可能会传几十分钟在那等着肯定不行,这时候就要用分片上传的方式了,而且这个也是面试中经常问到的。

上传文件

在 nest 中上传文件一般使用 multer 中间件,multer 是一个 node.js 中间件,用于处理 multipart/form-data 类型的表单数据,它主要用于上传文件。

首先我们需要安装 multer 中间件:

安装

ts
npm install --save @nestjs/platform-express multer

npm install -D @types/multer

然后在 nest 控制器中定义一个上传文件的接口:

ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFile,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";

@Controller("upload")
export class UploadController {
  @Post("upload")
  @UseInterceptors(
    // file 对应前端的字段名
    FileInterceptor("file", {
      dest: "uploads",
    })
  )
  uploadFile(@UploadedFile() file: Express.Multer.File, @Body() body) {
    console.log("body", body);
    console.log("file", file);
  }
}

上面只是一个单文件上传,如果是多个文件上传该怎么做呢?看下后端接口代码该如何编写

ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFiles,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";

@Controller("upload")
export class UploadController {
  @Post("upload")
  @UseInterceptors(
    // files 对应前端的字段名
    FilesInterceptor("files", 3, {
      dest: "uploads",
    })
  )
  uploadFile(@UploadedFiles() files: Express.Multer.File[], @Body() body) {
    console.log("body", body);
    console.log("files", files);
  }
}

如果有多个字段来接收多个文件,那么我们可以这样写:

ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFiles,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";

@Controller("upload")
export class UploadController {
  @Post("upload")
  @UseInterceptors(
    FilesInterceptor(
      [
        { name: "a", maxCount: 2 },
        { name: "b", maxCount: 3 },
      ],
      {
        dest: "uploads",
      }
    )
  )
  uploadFile(
    @UploadedFiles()
    files: { a?: Express.Multer.File[]; b?: Express.Multer.File[] },
    @Body() body
  ) {
    console.log("body", body);
    console.log("files", files);
  }
}

注意

这里面对应 FilesInterceptor 的 files 属性,在请求的时候需要对应上,看前端传递过来的名称

当不知道文件字段名的时候,我们可以使用 AnyFilesInterceptor 来接收所有的文件:

ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFiles,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";

@Controller("upload")
export class UploadController {
  @Post("upload")
  @UseInterceptors(
    AnyFilesInterceptor({
      dest: "uploads",
    })
  )
  uploadFile(@UploadedFiles() files: Express.Multer.File[], @Body() body) {
    console.log("body", body);
    console.log("files", files);
  }
}

自定义文件名和路径

  • 全局(不推荐)

(1) 新建 upload 模块

ts
import { Global, Module } from "@nestjs/common";
import { UploadController } from "./controller/upload.controller";
import { UploadService } from "./services/upload.service";

// 设置全局
import { MulterModule } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import * as path from "path";
import * as fs from "fs";

@Module({
  imports: [
    MulterModule.register({
      storage: diskStorage({
        destination: (req, file, cb) => {
          const dir = path.join(__dirname, "../../", "public/ceshiupload");
          if (!fs.existsSync(dir)) {
            fs.mkdirSync(dir, { recursive: true });
          }
          cb(null, dir);
        },
        filename: (req, file, cb) => {
          const randomName = Array(32)
            .fill(null)
            .map(() => Math.round(Math.random() * 16).toString(16))
            .join("");
          return cb(null, `${randomName}${path.extname(file.originalname)}`);
        },
      }),
    }),
  ],
  controllers: [UploadController],
  providers: [UploadService],
  exports: [UploadModule, UploadService],
})
export class UploadModule {}

(2) 自己写一个验证管道 upload.pipe.ts 管道

ts
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";

@Injectable()
export class UploadPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    if (value.length > 0) {
      let count = 0;
      // 多图
      value.map((item: any) => {
        if (item.size > 1024 * 1024 * 10) {
          count++;
        }
      });
      if (count > 0) {
        return {
          status: 400,
          message: "图片大小不能超过10M",
          value: value,
        };
      } else {
        return value;
      }
    } else {
      // 单图
      if (value.size > 1024 * 1024 * 10) {
        return {
          status: 400,
          message: "图片大小不能超过10M",
        };
      } else {
        return value;
      }
    }
  }
}

(3) 新建一个 storage.ts 文件,用来配置文件上传的路径和文件名

ts
import * as multer from "multer";
import * as path from "path";
import * as fs from "fs";

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const dir = path.join(__dirname, "../../", "public/ceshiupload");
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }
    cb(null, dir);
  },
  filename: (req, file, cb) => {
    const randomName = Array(32)
      .fill(null)
      .map(() => Math.round(Math.random() * 16).toString(16))
      .join("");
    return cb(null, `${randomName}${path.extname(file.originalname)}`);
  },
});
/**
 * @param { delPath:String } (需要删除文件的地址)
 * @param { direct:Boolean } (是否需要处理地址)
 */
const deleteFile = (delPath, direct) => {
  delPath = direct ? delPath : path.join(__dirname, delPath);
  try {
    /**
     * @des 判断文件或文件夹是否存在
     */
    if (fs.existsSync(delPath)) {
      console.log(delPath);
      fs.unlinkSync(delPath);
    } else {
      console.log("inexistence path:", delPath);
    }
  } catch (error) {
    console.log("del error", error);
  }
};

export { storage, deleteFile };

(4) 新建一个文件 起名 upload.service.ts 文件

ts
import { Injectable } from "@nestjs/common";
import { deleteFile } from "../../utils/storage";
@Injectable()
export class UploadService {
  async findone(files) {
    if (files["status"] == 400) {
      for (let i = 0; i < files["value"].length; i++) {
        deleteFile(files["value"][i].path, true);
      }
      return {
        code: 200,
        message: "上传失败",
      };
    } else {
      const url = [];
      files.forEach((item) => {
        url.push("http://localhost:3000/public/images/" + item.filename);
      });
      return {
        code: 200,
        message: "上传成功",
        url,
      };
    }
  }
}

(5) 控制器里面使用

ts
import {
  Body,
  Controller,
  Delete,
  FileTypeValidator,
  Get,
  Inject,
  MaxFileSizeValidator,
  Param,
  ParseFilePipe,
  Patch,
  Post,
  UploadedFiles,
  UseInterceptors,
  UsePipes,
} from "@nestjs/common";
import { UploadService } from "../services/upload.service";
import { FilesInterceptor } from "@nestjs/platform-express";
// 管道
import { UploadPipe } from "../pipe/upload.pipe";
// 删除文件
@Controller("uploads")
export class UploadController {
  constructor() {}
  @Inject()
  private readonly uploadService: UploadService;

  @Post()
  @UseInterceptors(FilesInterceptor("image", 3))
  async upload(
    @Body() body: any,
    @UploadedFiles(UploadPipe)
    files: Array<Express.Multer.File>
  ) {
    return this.uploadService.findone(files);
  }
}
  • 局部(推荐)

(1) 新建一个 storage.ts 文件,用来配置文件上传的路径和文件名

ts
import * as multer from "multer";
import * as path from "path";
import * as fs from "fs";

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const dir = path.join(__dirname, "../../", "public/ceshiupload");
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }
    cb(null, dir);
  },
  filename: (req, file, cb) => {
    const randomName = Array(32)
      .fill(null)
      .map(() => Math.round(Math.random() * 16).toString(16))
      .join("");
    return cb(null, `${randomName}${path.extname(file.originalname)}`);
  },
});
/**
 * @param { delPath:String } (需要删除文件的地址)
 * @param { direct:Boolean } (是否需要处理地址)
 */
const deleteFile = (delPath, direct) => {
  delPath = direct ? delPath : path.join(__dirname, delPath);
  try {
    /**
     * @des 判断文件或文件夹是否存在
     */
    if (fs.existsSync(delPath)) {
      console.log(delPath);
      fs.unlinkSync(delPath);
    } else {
      console.log("inexistence path:", delPath);
    }
  } catch (error) {
    console.log("del error", error);
  }
};

export { storage, deleteFile };

(2) 自己写一个验证管道 upload.pipe.ts 管道

ts
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";

@Injectable()
export class UploadPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    if (value.length > 0) {
      let count = 0;
      // 多图
      value.map((item: any) => {
        if (item.size > 1024 * 1024 * 10) {
          count++;
        }
      });
      if (count > 0) {
        return {
          status: 400,
          message: "图片大小不能超过10M",
          value: value,
        };
      } else {
        return value;
      }
    } else {
      // 单图
      if (value.size > 1024 * 1024 * 10) {
        return {
          status: 400,
          message: "图片大小不能超过10M",
        };
      } else {
        return value;
      }
    }
  }
}

(3) 新建一个文件起名 upload.service.ts 里面

ts
import { Injectable } from "@nestjs/common";
import { deleteFile } from "../../utils/stage";
@Injectable()
export class UploadService {
  async findone(files) {
    if (files["status"] == 400) {
      for (let i = 0; i < files["value"].length; i++) {
        deleteFile(files["value"][i].path, true);
      }
      return {
        code: 200,
        message: "上传失败",
      };
    } else {
      const url = [];
      files.forEach((item) => {
        url.push("http://localhost:3000/public/images/" + item.filename);
      });
      return {
        code: 200,
        message: "上传成功",
        url,
      };
    }
  }
}

(4) 控制器里面使用

ts
import {
  Body,
  Controller,
  Delete,
  Get,
  Inject,
  Param,
  Patch,
  Post,
  UploadedFiles,
  UseInterceptors,
} from "@nestjs/common";

// 引入uploadservice
import { UploadService } from "../../upload/services/upload.service";
import { FilesInterceptor } from "@nestjs/platform-express";

import { UploadPipe } from "../../upload/pipe/upload.pipe";

// 上传位置
import { storage } from "../../utils/stage";

@Controller("login")
export class LoginController {
  constructor() {}

  @Inject()
  uploadService: UploadService;

  @Post("upload")
  @UseInterceptors(
    // 独立自己的
    FilesInterceptor("image", 3, {
      storage: storage,
    })
  )
  async upload(
    @Body() body: any,
    @UploadedFiles(UploadPipe)
    files: Array<Express.Multer.File>
  ) {
    return this.uploadService.findone(files);
  }
}

注意

因为机制的原因 哪怕验证不通过他也会上传.但是我这里写了要是验证不通过就删除文件

使用自带的验证器(不推荐)

ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFiles,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";

@Controller("upload")
export class UploadController {
  @Post("upload")
  @UseInterceptors(
    FileInterceptor("file", {
      dest: "uploads",
    })
  )
  uploadFile(
    @UploadedFile(
      new ParseFilePipe({
        validators: [
          new MaxFileSizeValidator({ maxSize: 1000 }),
          new FileTypeValidator({ fileType: "image/jpeg" }),
        ],
      })
    )
    file: Express.Multer.File,
    @Body() body
  ) {
    console.log("body", body);
    console.log("file", file);
  }
}

静态资源

安装

ts
pnpm install -S @nestjs/serve-static

配置

找到 app.module.ts 里面设置

ts
import { Global, Module } from "@nestjs/common";
import { UploadModule } from "./upload/upload.module";
import { UserModule } from "./user/user.module";

// 设置静态
import { ServeStaticModule } from "@nestjs/serve-static";
// 路径
import { join } from "path";

@Global()
@Module({
  imports: [
    UploadModule,
    UserModule,
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, "../public/ceshiupload"), // 这里式设置静态资源路径
      serveRoot: "/public/images", // 这里就是访问路径比如前端要是访问 http://localhost:3000/public/images/xxxxx.jpg
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

分片上传

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

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

分片

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

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

合并

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

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

实战

首先和上传文件一样,先用 cli 工具新建一个项目

ts
nest new large-file-upload

app.controller.ts中添加一个上传文件的路由

ts
@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);
  }

会出现类型报错,也和文件上传上面一样,安装一下类型包

ts
pnpm add @types/multer -D

开启接口跨域支持,在 main.ts 中添加 app.enableCors()

ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors();
  await app.listen(3000);
}
bootstrap();

写一个简单的静态页用来上传文件和处理分片

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>Document</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 = 20 * 1024;

      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:3000/upload", data));
        });
        await Promise.all(tasks);
        axios.get(
          "http://localhost:3000/merge?name=" + randomStr + "_" + file.name
        );
      };
    </script>
  </body>
</html>

这时候上传的文件是直接在定义的 uploads 文件夹中的,我们给一个单文件的的所有分片归到一个子文件夹中

ts
@Post('upload')
@UseInterceptors(FilesInterceptor('files', 20, {
  dest: 'uploads'
}))
uploadFiles(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body: { name: string }) {
  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);
}

现在分片这个步骤已经完成了,然后是合并,定义一个合并的接口 merge

ts
@Get('merge')
merge(@Query('name') name: string) {
    const chunkDir = 'uploads/chunks_'+ name;

    const files = fs.readdirSync(chunkDir);

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

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

浏览器调用接口就可以把文件进行合并了

ts
localhost:3000/merge?name=497791_02.jpg

总结

注意

本文介绍了如何在 nest 中上传文件与大文件上传,主要是使用 multer 中间件,

在接口方面使用@UploadedFile()@UploadedFiles()来接收文件,同时也介绍了如何对上传的文件做校验。

如果是大文件的话,就采用分片上传,在前端使用 blob 的分割方法,然后在后端使用合并方法合并成文件,就完成了大文件上传。