跳转到主要内容

Nest 使用笔记

第六章:文件上传——multer 接收、大小类型限制、memoryStorage 转存 MinIO

打开 learnhub 的 UploadController,在真实代码里讲透 Nest 怎么用 multer 接 multipart/form-data:为什么用 memoryStorage 把文件全读进内存再流到 MinIO、limits + fileFilter 拦大文件和危险类型、Controller 和 MinioService 怎么分责。每段代码都能在 learnhub 里指到对应文件。第十九章再细讲 MinIO 和预签名直传。

  • Nest
  • 文件上传
  • Multer

接口不仅要收 JSON,还要收文件——头像、图片、附件。HTTP 上传文件的标配是 multipart/form-data:浏览器把表单字段和文件切成一段段 part 提交,后端要有人把这些 part 还原成文件对象交给业务。这一层在 Nest 生态里就是 multer。和「写个 FileInterceptor 收个文件存本地」的玩具教程不同,这一章打开 learnhub 真正在生产里跑的 UploadController——它把文件全读进内存(memoryStorage),再由 MinioService 把 buffer 流到自建对象存储 MinIO。重点讲清四件新手最容易踩的事:memoryStorage 的代价、大小和类型限制、Controller 和 Service 怎么分责、和第十九章 MinIO 预签名直传怎么分工

先搞懂:multer 是什么 / 为什么需要 / 企业级怎么用

multer 是什么:Express 生态最常用的 multipart/form-data 解析中间件。Nest 在上层封了 @UseInterceptors(FileInterceptor(...)) + @UploadedFile()——拦截器负责解析请求里的文件 part、交给业务,参数装饰器把文件对象塞进方法签名。它本身不存盘、不校验,存哪里、限制多大、什么类型才放行,全是配置项说了算。

为什么需要它:Nest 默认的 body 解析只认 JSON / urlencoded,碰到 multipart/form-data 不会自动解文件部分。判断:只要客户端要把一段二进制(图片、PDF、视频、附件)整体送到服务端,就绕不开 multer 这一层;纯结构化数据用不上。涉敏文件后续的病毒扫描、图片解码也要先有文件对象才能做。

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):

  • 别把文件落到应用本地磁盘——机器一换就丢、扩容时新节点看不到。直接转存到对象存储(learnhub 用自建 MinIO,公有云等价阿里云 OSS / AWS S3)。
  • memoryStorage 让文件只经过应用内存、不留临时文件,和对象存储天然契合。代价是整个文件都在 RAM 里,必须limits.fileSize,否则一个 2GB 的上传就能 OOM。
  • fileFilter 拦非预期 MIME(头像接口只放行 image/*)——但 MIME 是客户端自报的,不能完全信,严肃场景要再读文件头魔数(magic bytes)。
  • 大文件别走应用层:用预签名 PUT 让前端直传对象存储、流量完全不经过后端(learnhub 的 /upload/presigned 就是干这个的,细节第十九章讲)。

这一章你会做出什么

  • 打开 learnhub 的 src/modules/upload/upload.controller.ts,逐行讲清头像上传接口为什么这么写。
  • 在真实代码里吃透:memoryStorage vs diskStorage 的取舍limits + fileFilter 怎么挡大文件和危险类型Controller 拼 objectName + 调 MinioService、存储细节封装在 Service 后面
  • 跑通 POST /api/v1/upload/avatar,把一张图传进 learnhub 的 MinIO,回写用户头像。
  • 知道多文件上传(FilesInterceptor / FileFieldsInterceptor)什么时候用,以及为什么 learnhub 头像场景只用了单文件。

前置:learnhub 起来了,MinIO 容器在跑(cd learnhub/docker && docker compose up -d minio),登录拿到 token(第十一章细讲登录,这里先用)。

第一步:multer 在 Nest 里怎么接——FileInterceptor 接住单文件

新手写上传,多半是「FileInterceptor + diskStorage 存本地 uploads/」的玩具写法。learnhub 真正在生产里跑的代码完全不是这样——它把文件全读进内存,再交给 Service 落到 MinIO。先看头像上传接口长什么样:

// learnhub/src/modules/upload/upload.controller.ts
const AVATAR_MAX = 2 * 1024 * 1024; // 2MB

@ApiTags('上传')
@ApiBearerAuth()
@Controller({ path: 'upload', version: '1' })
export class UploadController {
  constructor(
    private readonly minio: MinioService,
    private readonly userService: UserService,
  ) {}

  /** 服务端代传:头像(限图片、≤2MB) */
  @Post('avatar')
  @UseInterceptors(
    FileInterceptor('file', {
      storage: memoryStorage(),
      limits: { fileSize: AVATAR_MAX },
      fileFilter: (_req, file, cb) => {
        if (!/^image\//.test(file.mimetype)) return cb(new BadRequestException('只能上传图片'), false);
        cb(null, true);
      },
    }),
  )
  async uploadAvatar(
    @UploadedFile() file: Express.Multer.File,
    @CurrentUser() user: RequestUser,
  ): Promise<{ url: string }> {
    if (!file) throw new BadRequestException('缺少文件(字段名 file)');
    const ext = (file.originalname.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase();
    const objectName = `avatar/${user.userId}/${Date.now()}${ext}`;
    const url = await this.minio.putObject(objectName, file.buffer, file.size, file.mimetype);
    await this.userService.updateProfile(user.userId, { avatar: url });
    return { url };
  }
}

逐行讲清每个关键点:

  • FileInterceptor('file', ...) 是这一节的灵魂。第一个参数 'file'前端表单字段名——前端 <input name="file"> 或 FormData 里 formData.append('file', blob) 必须用同一个名字,multer 才知道去抓哪个 part。写错了,@UploadedFile() 拿到的就是 undefined
  • storage: memoryStorage() 告诉 multer 不要落本地磁盘,把文件读成一个 Buffer 挂在 file.buffer 上。下一步细讲为什么这么选。
  • limits.fileSizefileFilter 是两道闸——分别挡大小和类型,第三步细讲。
  • @UploadedFile() file: Express.Multer.File 把 multer 解出来的文件对象注入方法。类型 Express.Multer.File 来自 @types/multer,没装的话先 npm i -D @types/multer(multer 本身随 @nestjs/platform-express 已经装好了)。
  • @CurrentUser() user 是从 JWT 解出来的当前用户(第十一章细讲)——头像上传必须知道是谁传的,object 名和回写用户表都要用 user.userId

注意控制器里没有一行「怎么存」的逻辑——没有 fs.writeFile、没有目录路径、没有 SDK 调用。Controller 只负责「接住、拼业务参数、转交、返回」,存储细节全在 this.minio.putObject(...) 后面。这是和玩具写法最大的区别,第四步细讲。

注意@UploadedFile()@Body() 可以一起用——multipart/form-data 里除了文件 part,还能塞普通表单字段,multer 把非文件字段拼回 req.body,控制器里 @Body() dto 就能拿到。比如改头像时附带「是否裁剪、宽高」就可以走 @Body(),文件走 @UploadedFile(),不用开两个接口。

第二步:memoryStorage vs diskStorage——为什么 learnhub 选内存

multer 的 storage 决定文件被解出来后去哪。两种内建选择:

  • diskStorage({ destination, filename }):流到磁盘文件,file.path 是落盘路径。
  • memoryStorage():留在内存里,file.bufferBuffer

大部分教程都用 diskStorage——直观、能立刻看到磁盘上有文件、用 app.useStaticAssets 把目录暴露成静态资源就能 URL 访问。但生产环境几乎都反过来选 memoryStorage,原因有三:

  1. 没有本地磁盘这一跳。文件落到 uploads/ 后你迟早要再读出来传到对象存储,等于多一次磁盘 IO 和一次「中间状态」要清理。memoryStorage 让 buffer 直接流进 MinIO,干净。
  2. 不依赖本地文件系统。容器化部署里应用随时可能被重启、被换到另一个节点,写到本地磁盘的文件重启就丢。learnhub 跑在 Docker 里,节点要保持无状态,本地盘上不能放任何业务数据。
  3. 不留临时文件diskStorage 如果中间挂了,uploads/ 会留下半截文件没人清。memoryStorage 进程一死内存就回收,没有残留。

代价是整个文件全部读进内存。learnhub 的头像场景单文件 2MB,并发就算 100 个也才 200MB,可控。但没有 limits.fileSize 的 memoryStorage 是定时炸弹——有人传个 2GB 的视频,Node 进程直接 OOM。所以 learnhub 在同一个 FileInterceptor 里硬性把 fileSize 卡到 2MB:

// learnhub/src/modules/upload/upload.controller.ts
storage: memoryStorage(),
limits: { fileSize: AVATAR_MAX }, // AVATAR_MAX = 2 * 1024 * 1024

注意memoryStorage 适合「小文件、高并发、转存对象存储」的场景——头像、缩略图、小附件。一旦单文件可能上 GB(视频、大日志、数据集),就不能memoryStorage,三条路:换 diskStorage 让文件先落盘再慢慢传 MinIO;或用 multer 的流式 handler 边读边传;最干净的是第十九章的预签名 PUT——前端直接 PUT 到 MinIO,流量完全不经过后端,Node 只签个 URL,OOM 风险归零。

思考:既然流式更省内存,为什么 multer 默认还是一次性 buffer?因为「先全读进内存再交给业务」是最简单的模型——业务拿到的就是一个完整 Buffer,可以直接 putObject、可以读 magic bytes 判类型、可以塞进 sharp 裁剪。流式处理(chunk-by-chunk)要把这些都做成 stream pipeline,复杂度陡增,而且一旦中途出错(用户断网、文件损坏)partial 状态处理特别烦。multer 选择「简单优先、靠 limits 兜底」,让大文件走另一条路(预签名直传)绕开应用层——这是工程上很常见的「按场景分通道」思路:简单场景享受简单方案的便宜,复杂场景换专用方案,而不是用一个万能方案把所有场景都搞得一样复杂。

第三步:限制大小和类型——limits + fileFilter 两道闸

FileInterceptor 的配置项 { limits, fileFilter } 是上传接口的两道闸——大小先过、类型再过,任何一道不放行就拒绝。learnhub 头像接口两道都设:

// learnhub/src/modules/upload/upload.controller.ts
limits: { fileSize: AVATAR_MAX }, // 2MB
fileFilter: (_req, file, cb) => {
  if (!/^image\//.test(file.mimetype)) return cb(new BadRequestException('只能上传图片'), false);
  cb(null, true);
},
  • limits.fileSize 是字节数。multer 在解析过程中一旦超过就立刻 abort,不会把文件读完才报错——对防 OOM 至关重要。除了 fileSizelimits 还能限 files(最多几个文件)、parts(总 part 数)、headerPairs(header 对数),后几个主要防恶意构造的 multipart 包,做公开上传接口时建议都设上。
  • fileFilter(req, file, cb) 在文件被接纳前调一次,cb(null, true) 放行、cb(err, false) 拒绝。learnhub 用 ^image/ 匹配 image/pngimage/jpegimage/webp 这类。

注意 fileFilter 拒绝时抛的是 BadRequestException(Nest 内置异常 → HTTP 400),不是裸 Error。新手写 cb(new Error('只能上传图片')) 的话,multer 默认会冒到顶层返回 500,前端拿到一个像服务器炸了似的错误,体验很差。抛 BadRequestException 让全局异常过滤器回 400 + 规范的错误体(第八章讲过这套统一异常处理)。

注意file.mimetype 来自 multipart part 的 Content-Type——这玩意儿是客户端自报的,不能全信。任何前端都能把 a.exeContent-Type 改成 image/png 骗过这层正则。learnhub 这里靠它挡「误传」(用户不小心选了 PDF),挡不了「故意攻击」。严肃场景要在 Service 里读文件**头几个字节(magic bytes)**判真实类型:PNG 头是 89 50 4E 47、JPEG 是 FF D8 FF、PDF 是 25 50 44 46,用 file-type 这类库一查便知。涉敏文件还要再过病毒扫描(ClamAV 之类),不能因为过了 fileFilter 就当无害。

另一个更 Nest 风格的写法是自定义文件校验 Pipe——把大小和类型校验抽成可复用的 Pipe,挂在 @UploadedFile() 上:

// 抽象示意(learnhub 直接用 fileFilter,没单独写 Pipe)
@UploadedFile(
  new ParseFilePipe({
    validators: [
      new MaxFileSizeValidator({ maxSize: AVATAR_MAX }),
      new FileTypeValidator({ fileType: /^image\// }),
    ],
  }),
)
file: Express.Multer.File,

ParseFilePipe + 内建的 MaxFileSizeValidator / FileTypeValidator(Nest 9+ 自带)是把校验从 FileInterceptor 配置里挪到 Pipe 链上,和第二章讲的 ValidationPipe 是一套思路——参数装饰器声明校验、框架统一执行。两套写法等价,选一套即可:learnhub 选的是 fileFilter(更贴近 multer 原生、对老代码友好),新项目可以直接用 Pipe。第二章详细讲过 Pipe 链怎么写、自定义 Validator 怎么挂,这里不重复。

第四步:存什么 Controller 定,怎么存 Service 管——buffer 怎么落到 MinIO

回到那段 controller 代码:拿到 file.buffer 之后,Controller 干的事是拼 object 名、调 MinioService.putObject、回写用户表。它知道 MinIO 在哪、bucket 叫什么、URL 怎么拼:

// learnhub/src/modules/upload/upload.controller.ts
const objectName = `avatar/${user.userId}/${Date.now()}${ext}`;
const url = await this.minio.putObject(objectName, file.buffer, file.size, file.mimetype);
await this.userService.updateProfile(user.userId, { avatar: url });
return { url };

objectName 的拼法是真实生产里的小讲究:按「用途 / 用户 / 时间戳」分层,avatar/3/1753000000000.png——既避免重名(同用户多次上传靠时间戳区分),又方便按前缀查(MinIO 控制台一眼看全用户头像)。ext 是从 originalname 正则抠出来的扩展名,强制小写,避免 .PNG / .png 在某些大小写敏感的文件系统上出问题。

存储细节全部封装在 MinioService 里。Controller 调的那个 putObject 长这样:

// learnhub/src/modules/upload/minio.service.ts
async putObject(
  objectName: string,
  buffer: Buffer,
  size: number,
  contentType: string,
): Promise<string> {
  await this.client.putObject(this.bucket, objectName, buffer, size, {
    'Content-Type': contentType,
  });
  return this.publicUrl(objectName);
}

client.putObject 是 MinIO JS SDK 的方法,把 Buffer 一次性 PUT 到指定 bucket / object。publicUrl(objectName) 拼出可访问的 URL(bucket 已经在 onModuleInit 里设过匿名读策略)。MinIO 客户端怎么建、bucket 怎么建、预签名怎么签、policy 怎么设——这些是第十九章的内容,这一章你只需要知道:Service 暴露一个 putObject(name, buffer, size, mime) → url 的接口,Controller 调它就完事。

整套上传的数据流是这样:

浏览器 multipart/form-data


 FileInterceptor('file') + memoryStorage   ← multer 解析、读进 buffer、过 limits/fileFilter 两道闸


 @UploadedFile() file                       ← 注入到 Controller 方法签名


 Controller 拼 objectName + 调 MinioService  ← 业务参数(存哪、回写谁)+ 转交


 MinioService.putObject(name, buffer, ...)  ← 存储细节全在这(SDK、bucket、URL 拼法)


 MinIO bucket                               ← 文件最终归宿(第十九章细讲)


 publicUrl + 回写 user.avatar              ← 返回给前端 + 业务库同步

为什么这么分层?因为「上传到哪」是个会变的事——开发期用本地 MinIO、生产可能换阿里云 OSS / AWS S3,每家 SDK 都不一样。如果 Controller 直接调 minio.client.putObject(...),换存储就把 Controller 改一遍;现在 Controller 只调 minio.putObject(...),换存储只改 MinioService 一个文件。Controller 和 Service 之间的边界是「业务参数」对「存储机制」——这是依赖注入 + 服务封装带来的替换自由,和第三章讲的 IoC 一脉相承。

UploadModule 把这一切组装起来:

// learnhub/src/modules/upload/upload.module.ts
@Module({
  imports: [UserModule], // 回写头像要用 UserService
  controllers: [UploadController],
})
export class UploadModule {}

注意这里没有MinioService 列在 providersimports 里——因为 MinioModule 是个 @Global() 全局模块(见 minio.module.ts),它的 MinioService 对全应用可见,任何模块都能直接构造函数注入,不用逐个 import。第四章讲过全局模块的用法。UserModule 显式 import 是因为要回写用户头像、要用 UserService

思考:为什么 learnhub 没有专门写一个 UploadService 做中间层(Controller → UploadService → MinioService)?因为这会是个「只转发、没逻辑」的壳——上传这一步没有独立的业务规则需要封装,Controller 自己拼 objectName + 回写用户表就够了,硬加一层 Service 就是仪式感。当以后上传逻辑变复杂(比如要生成缩略图、要扫病毒、要记审计日志),再抽出 UploadService 不迟。分层是手段不是目的,有真实职责才分层,否则就是无意义的间接层。

第五步:多文件上传——FilesInterceptor / FileFieldsInterceptor

learnhub 头像场景用的是 FileInterceptor(单字段单文件),但 multer 在 Nest 里还有两个变体,对应另外两种多文件场景。先认个脸:

拦截器场景取值
FileInterceptor('file')单字段单文件@UploadedFile() file
FilesInterceptor('files', 5)单字段多文件(最多 5)@UploadedFiles() files: Express.Multer.File[]
FileFieldsInterceptor([{ name: 'avatar', maxCount: 1 }, { name: 'gallery', maxCount: 9 }])多字段,每字段独立配额@UploadedFiles() { avatar, gallery }

举例,如果 learnhub 要做一个「一次最多 9 张图的相册接口」,写法是:

// 演示:learnhub 当前没这个接口,相册场景的标准写法长这样
@Post('gallery')
@UseInterceptors(
  FilesInterceptor('images', 9, {
    storage: memoryStorage(),
    limits: { fileSize: 5 * 1024 * 1024 },
    fileFilter: (_req, file, cb) =>
      /^image\//.test(file.mimetype) ? cb(null, true) : cb(new BadRequestException('只能上传图片'), false),
  }),
)
async uploadGallery(@UploadedFiles() files: Express.Multer.File[]) {
  const urls = await Promise.all(
    files.map((f) =>
      this.minio.putObject(`gallery/${Date.now()}-${f.originalname}`, f.buffer, f.size, f.mimetype),
    ),
  );
  return { urls };
}

而「头像 + 证件背面 + 手持证件」这种字段名不同的多文件,就用 FileFieldsInterceptor

// 演示:多字段实名认证上传
@UseInterceptors(
  FileFieldsInterceptor(
    [
      { name: 'avatar', maxCount: 1 },
      { name: 'idCardBack', maxCount: 1 },
      { name: 'idCardHand', maxCount: 1 },
    ],
    { storage: memoryStorage(), limits: { fileSize: AVATAR_MAX } },
  ),
)
async kyc(
  @UploadedFiles()
  files: {
    avatar?: Express.Multer.File[];
    idCardBack?: Express.Multer.File[];
    idCardHand?: Express.Multer.File[];
  },
) {
  // files.avatar[0], files.idCardBack[0], files.idCardHand[0]
}

learnhub 现在只有头像上传一种业务,所以只用了 FileInterceptor——上面这两段是标准写法示范,仓库里没出现。知道有两种变体、什么时候选哪个即可:字段名相同的一组文件用 FilesInterceptor字段名不同的几组用 FileFieldsInterceptor。一旦多文件总大小接近内存压力线(比如 9 张 5MB = 45MB 并发再来几路就过百 MB),就别再用 memoryStorage 走服务端,改成第十九章的预签名直传,前端拿到 9 个 PUT URL 自己 PUT。

第六步:跑起来

cd learnhub
docker compose -f docker/docker-compose.yml up -d minio   # 起对象存储
npm run start:dev

先登录拿 token(登录机制第十一章讲,这里先用):

curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

上传一张头像(注意字段名必须叫 file,和 FileInterceptor('file') 一致):

curl -X POST http://localhost:3000/api/v1/upload/avatar \
  -H "Authorization: Bearer eyJ..." \
  -F "file=@avatar.jpg"
# {"url":"http://127.0.0.1:9000/learnhub-assets/avatar/1/1753000000000.jpg"}

浏览器打开返回的 url——能直接看到图,因为 MinIO bucket 已经在 MinioService.onModuleInit 里设过匿名读策略。/upload/avatar 这个接口叫服务端代传:文件先经过 Nest(进内存 buffer),再由 Nest PUT 到 MinIO。小文件没问题,大文件就有第二步说过的 OOM 风险。

learnhub 还有另一个接口 /upload/presigned——后端只签发一个限时 PUT URL,前端拿到 URL 自己 PUT 到 MinIO,流量完全不经过后端。这是大文件和海量并发的正解,但涉及签名、bucket policy、前端怎么配合,整套细节留到第十九章 MinIO 那章细讲。这一章你只要记住一条边界:multer 管的是「Nest 怎么接住文件」,MinIO 管的是「文件存哪里 + 直传怎么签」,两件事分开

测一下两道闸:

# 传一个非图片,被 fileFilter 拦
curl -X POST http://localhost:3000/api/v1/upload/avatar \
  -H "Authorization: Bearer eyJ..." \
  -F "file=@readme.txt"
# 400 Bad Request: 只能上传图片

# 传一个超过 2MB 的图,被 limits.fileSize 拦(multer 解析过程中 abort)
curl -X POST http://localhost:3000/api/v1/upload/avatar \
  -H "Authorization: Bearer eyJ..." \
  -F "file=@huge.png"
# multer 抛 LIMIT_FILE_SIZE 错误(建议在全局异常过滤器里把它统一回 400,体验更一致)

这一章的成果

  1. FileInterceptor + memoryStorage + @UploadedFile() 在真实 learnhub 代码里接住单文件,明白 'file' 是前端表单字段名。
  2. 想清了 memoryStorage vs diskStorage 的取舍——小文件走内存转存对象存储、大文件必须走预签名直传或 diskStoragelimits.fileSize 是 memoryStorage 的安全带。
  3. limits + fileFilter 挡大小和类型,知道 MIME 不能全信、严肃场景要读 magic bytes,会把这些规则抽象成 ParseFilePipe + Validator(第二章 Pipe 链的延伸)。
  4. 看懂 Controller 和 MinioService 的分责——Controller 拼业务参数(objectName、回写用户表),MinioService 拥有存储细节(SDK、bucket、URL 拼法),换存储后端只改 Service。
  5. 知道多文件上传有 FilesInterceptor / FileFieldsInterceptor 两种变体,以及 learnhub 为什么头像场景只用单文件、什么场景该上多文件、什么场景该换预签名直传。
  6. 厘清了本章(multer 接收)和第十九章(MinIO 存储 + 预签名直传)的分工边界。

常见问题

  • @UploadedFile() fileundefined:前端字段名和 FileInterceptor('字段名') 不一致,或请求没带 Content-Type: multipart/form-data(curl 用 -F 自动带,前端用 FormData 也会自动带)。learnhub 这里的字段名是 file,前端必须 formData.append('file', blob)
  • memoryStorage 内存涨爆:没设 limits.fileSize,或业务允许传大文件却走了内存。两步——给 FileInterceptorlimits.fileSize,超大文件改走第十九章预签名直传。
  • fileFilter 抛了 Error 却返回 500:抛 BadRequestException 而不是裸 Error,让全局异常过滤器回 400。
  • 上传后 URL 打不开:对象存储 bucket 没设匿名读策略。learnhub 在 MinioService.onModuleInit 里设过;自己搭 MinIO 没设的话,要么改 bucket policy(第十九章),要么用 presignedGet 临时签名 URL。
  • multer 的 LIMIT_FILE_SIZE 错误冒到顶层返回 500:在全局异常过滤器(第八章)里 catch MulterError,按 code 映射成 400 + 友好错误体。
  • 想限制上传总数 / 总大小limits 里加 files(最多几个)、parts(最多几个 part),或在 Service 里对当前用户的上传量做业务校验。
  • 想校验图片尺寸 / 图片真实性:在 Service 里用 sharp 读 buffer 拿宽高、或用 file-type 读 magic bytes——别信 file.mimetype

下一章开始进入数据层——先用 Docker 起一个 MySQL,建库建表,看 learnhub 的真实表结构里列类型、字符集、索引、外键是怎么设计的。再后面一章就是 TypeORM 集成,把这一章里「Controller 调 Service、Service 封装存储」的分层思路延伸到「Service 调 Repository、Repository 封装数据访问」。