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.fileSize和fileFilter是两道闸——分别挡大小和类型,第三步细讲。@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.buffer是Buffer。
大部分教程都用 diskStorage——直观、能立刻看到磁盘上有文件、用 app.useStaticAssets 把目录暴露成静态资源就能 URL 访问。但生产环境几乎都反过来选 memoryStorage,原因有三:
- 没有本地磁盘这一跳。文件落到
uploads/后你迟早要再读出来传到对象存储,等于多一次磁盘 IO 和一次「中间状态」要清理。memoryStorage让 buffer 直接流进 MinIO,干净。 - 不依赖本地文件系统。容器化部署里应用随时可能被重启、被换到另一个节点,写到本地磁盘的文件重启就丢。learnhub 跑在 Docker 里,节点要保持无状态,本地盘上不能放任何业务数据。
- 不留临时文件。
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 至关重要。除了fileSize,limits还能限files(最多几个文件)、parts(总 part 数)、headerPairs(header 对数),后几个主要防恶意构造的 multipart 包,做公开上传接口时建议都设上。fileFilter(req, file, cb)在文件被接纳前调一次,cb(null, true)放行、cb(err, false)拒绝。learnhub 用^image/匹配image/png、image/jpeg、image/webp这类。
注意 fileFilter 拒绝时抛的是 BadRequestException(Nest 内置异常 → HTTP 400),不是裸 Error。新手写 cb(new Error('只能上传图片')) 的话,multer 默认会冒到顶层返回 500,前端拿到一个像服务器炸了似的错误,体验很差。抛 BadRequestException 让全局异常过滤器回 400 + 规范的错误体(第八章讲过这套统一异常处理)。
注意:file.mimetype 来自 multipart part 的 Content-Type——这玩意儿是客户端自报的,不能全信。任何前端都能把 a.exe 的 Content-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 列在 providers 或 imports 里——因为 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,体验更一致)
这一章的成果
- 用
FileInterceptor+memoryStorage+@UploadedFile()在真实 learnhub 代码里接住单文件,明白'file'是前端表单字段名。 - 想清了
memoryStoragevsdiskStorage的取舍——小文件走内存转存对象存储、大文件必须走预签名直传或diskStorage,limits.fileSize是 memoryStorage 的安全带。 - 用
limits+fileFilter挡大小和类型,知道 MIME 不能全信、严肃场景要读 magic bytes,会把这些规则抽象成ParseFilePipe+Validator(第二章 Pipe 链的延伸)。 - 看懂 Controller 和
MinioService的分责——Controller 拼业务参数(objectName、回写用户表),MinioService拥有存储细节(SDK、bucket、URL 拼法),换存储后端只改 Service。 - 知道多文件上传有
FilesInterceptor/FileFieldsInterceptor两种变体,以及 learnhub 为什么头像场景只用单文件、什么场景该上多文件、什么场景该换预签名直传。 - 厘清了本章(multer 接收)和第十九章(MinIO 存储 + 预签名直传)的分工边界。
常见问题
@UploadedFile() file是undefined:前端字段名和FileInterceptor('字段名')不一致,或请求没带Content-Type: multipart/form-data(curl 用-F自动带,前端用 FormData 也会自动带)。learnhub 这里的字段名是file,前端必须formData.append('file', blob)。- memoryStorage 内存涨爆:没设
limits.fileSize,或业务允许传大文件却走了内存。两步——给FileInterceptor加limits.fileSize,超大文件改走第十九章预签名直传。 fileFilter抛了Error却返回 500:抛BadRequestException而不是裸Error,让全局异常过滤器回 400。- 上传后 URL 打不开:对象存储 bucket 没设匿名读策略。learnhub 在
MinioService.onModuleInit里设过;自己搭 MinIO 没设的话,要么改 bucket policy(第十九章),要么用presignedGet临时签名 URL。 - multer 的
LIMIT_FILE_SIZE错误冒到顶层返回 500:在全局异常过滤器(第八章)里 catchMulterError,按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 封装数据访问」。