跳转至

文件上传

到目前为止,在本系列中,我们已经描述了在服务器上存储文件的两种方法。 在第 10 篇文章中,我们向 Amazon S3 上传了文件。 虽然它的可伸缩性很强,但出于各种原因,我们可能不希望使用 AWS 等云服务。 因此,在本系列的第 54 部分中,我们学习了如何直接在 PostgreSQL 数据库中存储文件。 虽然它有一些优势,但它可能被认为在性能方面不够理想。

在本文中,我们将研究如何使用 NestJS 在服务器上存储上传的文件。 同样,我们将一些信息持久化到数据库中,但这一次只是元数据。

在服务器上保存文件

幸运的是,NestJS 使得在服务器上存储文件变得非常容易。 我们需要向 FileInterceptor 传递额外的参数。

users.service.ts;

import { UsersService } from "./users.service";
import { Controller, Post, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import JwtAuthenticationGuard from "../authentication/jwt-authentication.guard";
import RequestWithUser from "../authentication/requestWithUser.interface";
import { Express } from "express";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post("avatar")
  @UseGuards(JwtAuthenticationGuard)
  @UseInterceptors(
    FileInterceptor("file", {
      storage: diskStorage({
        destination: "./uploadedFiles/avatars",
      }),
    })
  )
  async addAvatar(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
    return this.usersService.addAvatar(request.user.id, {
      path: file.path,
      filename: file.originalname,
      mimetype: file.mimetype,
    });
  }
}

当我们执行上述操作时,NestJS 将上传的文件存储在./uploadefiles/avatars目录中。

不过,上述方法存在一些问题。 首先,我们可能需要多个端点来接受文件。 在这种情况下,我们需要为它们每个重复配置的某些部分。 此外,我们应该将目标的。/uploaddfiles 部分放在一个环境变量中,以根据应用程序运行的环境来更改它。

扩展 FileInterceptor

实现上述目标的一种方法是扩展 FileInterceptor。 在查看了 NestJS 的底层之后,我们可以看到它使用了 mixin 模式。 因为 FileInterceptor 不是类,所以不能使用 extend 关键字。

我们想要扩展 FileInterceptor 的功能,当:

  • 使用依赖注入来注入 ConfigService
  • 能够从控制器传递额外的属性。

为此,我们可以创建我们的 mixin:

localFiles.interceptor.ts

import { FileInterceptor } from "@nestjs/platform-express";
import { Injectable, mixin, NestInterceptor, Type } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
import { diskStorage } from "multer";

interface LocalFilesInterceptorOptions {
  fieldName: string;
  path?: string;
}

function LocalFilesInterceptor(options: LocalFilesInterceptorOptions): Type<NestInterceptor> {
  @Injectable()
  class Interceptor implements NestInterceptor {
    fileInterceptor: NestInterceptor;
    constructor(configService: ConfigService) {
      const filesDestination = configService.get("UPLOADED_FILES_DESTINATION");

      const destination = `${filesDestination}${options.path}`;

      const multerOptions: MulterOptions = {
        storage: diskStorage({
          destination,
        }),
      };

      this.fileInterceptor = new (FileInterceptor(options.fieldName, multerOptions))();
    }

    intercept(...args: Parameters<NestInterceptor["intercept"]>) {
      return this.fileInterceptor.intercept(...args);
    }
  }
  return mixin(Interceptor);
}

export default LocalFilesInterceptor;

在上面,我们使用 UPLOADED_FILES_DESTINATION 变量,并将其与提供的路径连接起来。 为此,让我们定义必要的环境变量。

app.module.ts

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import * as Joi from "@hapi/joi";

@Module({
  imports: [
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        UPLOADED_FILES_DESTINATION: Joi.string().required(),
        // ...
      }),
    }),
    // ...
  ],
  // ...
})
export class AppModule {
  // ...
}

.env

UPLOADED_FILES_DESTINATION=./uploadedFiles
# ...

当上面所有的准备就绪,我们可以在控制器中使用 LocalFilesInterceptor:

users.controller.ts

import { UsersService } from "./users.service";
import { Controller, Post, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import JwtAuthenticationGuard from "../authentication/jwt-authentication.guard";
import RequestWithUser from "../authentication/requestWithUser.interface";
import { Express } from "express";
import LocalFilesInterceptor from "../localFiles/localFiles.interceptor";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post("avatar")
  @UseGuards(JwtAuthenticationGuard)
  @UseInterceptors(
    LocalFilesInterceptor({
      fieldName: "file",
      path: "/avatars",
    })
  )
  async addAvatar(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
    return this.usersService.addAvatar(request.user.id, {
      path: file.path,
      filename: file.originalname,
      mimetype: file.mimetype,
    });
  }
}

将元数据保存在数据库中

除了将文件存储在服务器上,我们还需要将文件的元数据保存在数据库中。 由于 NestJS 为上传的文件生成一个随机的文件名,我们还想存储原始的文件名。 要完成上述所有工作,我们需要为元数据创建一个实体。

localFile.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
class LocalFile {
  @PrimaryGeneratedColumn()
  public id: number;

  @Column()
  filename: string;

  @Column()
  path: string;

  @Column()
  mimetype: string;
}

export default LocalFile;
localFile.dto.ts;
interface LocalFileDto {
  filename: string;
  path: string;
  mimetype: string;
}

我们还需要在用户和文件之间创建一个关系。

user.entity.ts

import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm";
import LocalFile from "../localFiles/localFile.entity";

@Entity()
class User {
  @PrimaryGeneratedColumn()
  public id: number;

  @JoinColumn({ name: "avatarId" })
  @OneToOne(() => LocalFile, {
    nullable: true,
  })
  public avatar?: LocalFile;

  @Column({ nullable: true })
  public avatarId?: number;

  // ...
}

export default User;

我们在上面添加了 avatarId 列,这样用户的实体就可以保存角色的 id,而不用连接角色的所有数据。

同时,我们还需要创建 LocalFilesService 的基础:

localFiles.service.ts

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import LocalFile from "./localFile.entity";

@Injectable()
class LocalFilesService {
  constructor(
    @InjectRepository(LocalFile)
    private localFilesRepository: Repository<LocalFile>
  ) {}

  async saveLocalFileData(fileData: LocalFileDto) {
    const newFile = await this.localFilesRepository.create(fileData);
    await this.localFilesRepository.save(newFile);
    return newFile;
  }
}

export default LocalFilesService;

最后一步是在 UsersService 中使用 saveLocalFileData 方法:

users.service.ts

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, Connection, In } from "typeorm";
import User from "./user.entity";
import LocalFilesService from "../localFiles/localFiles.service";

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
    private localFilesService: LocalFilesService
  ) {}

  async addAvatar(userId: number, fileData: LocalFileDto) {
    const avatar = await this.localFilesService.saveLocalFileData(fileData);
    await this.usersRepository.update(userId, {
      avatarId: avatar.id,
    });
  }

  // ...
}

检索文件

现在,用户可以检索他们化身的 id。

要下载具有给定 id 的文件,我们可以创建一个传输内容的控制器。

实现上述功能的第一步是扩展 LocalFilesService:

import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import LocalFile from "./localFile.entity";

@Injectable()
class LocalFilesService {
  constructor(
    @InjectRepository(LocalFile)
    private localFilesRepository: Repository<LocalFile>
  ) {}

  async getFileById(fileId: number) {
    const file = await this.localFilesRepository.findOne(fileId);
    if (!file) {
      throw new NotFoundException();
    }
    return file;
  }

  // ...
}

export default LocalFilesService;

我们还需要创建一个使用上述方法的控制器:

localFiles.controller.ts

import {
  Controller,
  Get,
  Param,
  UseInterceptors,
  ClassSerializerInterceptor,
  StreamableFile,
  Res,
  ParseIntPipe,
} from "@nestjs/common";
import LocalFilesService from "./localFiles.service";
import { Response } from "express";
import { createReadStream } from "fs";
import { join } from "path";

@Controller("local-files")
@UseInterceptors(ClassSerializerInterceptor)
export default class LocalFilesController {
  constructor(private readonly localFilesService: LocalFilesService) {}

  @Get(":id")
  async getDatabaseFileById(@Param("id", ParseIntPipe) id: number, @Res({ passthrough: true }) response: Response) {
    const file = await this.localFilesService.getFileById(id);

    const stream = createReadStream(join(process.cwd(), file.path));

    response.set({
      "Content-Disposition": `inline; filename="${file.filename}"`,
      "Content-Type": file.mimetype,
    });
    return new StreamableFile(stream);
  }
}

我们在本系列的前一部分中了解了 StreamableFile 类和 Content-Disposition 头文件。

执行上述操作允许用户检索具有给定 id 的文件。

过滤传入的文件

我们不应该总是相信用户上传的文件。 幸运的是,我们可以很容易地使用 filfilter 过滤它们,并限制 multer 支持的属性。

localFiles.interceptor.ts

import { FileInterceptor } from "@nestjs/platform-express";
import { Injectable, mixin, NestInterceptor, Type } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
import { diskStorage } from "multer";

interface LocalFilesInterceptorOptions {
  fieldName: string;
  path?: string;
  fileFilter?: MulterOptions["fileFilter"];
  limits?: MulterOptions["limits"];
}

function LocalFilesInterceptor(options: LocalFilesInterceptorOptions): Type<NestInterceptor> {
  @Injectable()
  class Interceptor implements NestInterceptor {
    fileInterceptor: NestInterceptor;
    constructor(configService: ConfigService) {
      const filesDestination = configService.get("UPLOADED_FILES_DESTINATION");

      const destination = `${filesDestination}${options.path}`;

      const multerOptions: MulterOptions = {
        storage: diskStorage({
          destination,
        }),
        fileFilter: options.fileFilter,
        limits: options.limits,
      };

      this.fileInterceptor = new (FileInterceptor(options.fieldName, multerOptions))();
    }

    intercept(...args: Parameters<NestInterceptor["intercept"]>) {
      return this.fileInterceptor.intercept(...args);
    }
  }
  return mixin(Interceptor);
}

export default LocalFilesInterceptor;

让我们只允许在 mimetype 中包含“image”且小于 1MB 的文件。

import { UsersService } from "./users.service";
import { BadRequestException, Controller, Post, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import JwtAuthenticationGuard from "../authentication/jwt-authentication.guard";
import RequestWithUser from "../authentication/requestWithUser.interface";
import { Express } from "express";
import LocalFilesInterceptor from "../localFiles/localFiles.interceptor";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post("avatar")
  @UseGuards(JwtAuthenticationGuard)
  @UseInterceptors(
    LocalFilesInterceptor({
      fieldName: "file",
      path: "/avatars",
      fileFilter: (request, file, callback) => {
        if (!file.mimetype.includes("image")) {
          return callback(new BadRequestException("Provide a valid image"), false);
        }
        callback(null, true);
      },
      limits: {
        fileSize: Math.pow(1024, 2), // 1MB
      },
    })
  )
  async addAvatar(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
    return this.usersService.addAvatar(request.user.id, {
      path: file.path,
      filename: file.originalname,
      mimetype: file.mimetype,
    });
  }
}

如果文件不满足大小要求,NestJS 抛出 413 Payload Too Large。 不只是检查 mimetype 和使用文件类型库可能是一个好主意。

总结

在本文中,我们介绍了通过 NestJS 管理服务器上文件的基础知识。 我们已经学习了如何将它们存储在服务器上并返回给用户。 当这样做时,我们扩展了内置的 FileInterceptor 并实现了过滤。 仍然有一些方法可以扩展本文中的代码。 如本系列第 15 部分所述,您可以自由地实现文件删除和使用事务。

通过学习各种存储文件的方法,您现在可以自由地比较其优缺点,并使用最适合自己需求的方法。