NestJS 流式文件上传实践:从 Multer 到 Busboy 的进阶之路
前言
在 Node.js 后端开发中,文件上传是一个常见需求。NestJS 官方推荐使用 Multer 中间件处理文件上传,但在大文件场景下,Multer 的"先接收后处理"模式会带来内存压力。本文将介绍如何基于 Busboy 实现真正的流式文件上传,实现"边收边传",内存占用极小。
问题:官方 Multer 方案的局限性
Multer 的工作原理
NestJS 官方的 FileInterceptor 基于 Multer,其工作流程如下:
客户端上传 → Multer 完整接收文件 → 写入临时文件/内存 → 触发回调 → Controller 处理
问题分析
假设上传一个 500MB 的视频文件:
typescript
@Post('upload')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 1024 * 1024 * 1024 }, // 1GB
dest: resolve(process.cwd(), 'uploads'),
}))
async upload(@UploadedFile() file: Multer.File) {
// 此时文件已经完整接收并写入磁盘
return await this.fileService.upload2Obs(file);
}
问题:
- 内存/磁盘占用:文件先写入临时目录,再读取上传到 OBS,造成双重 I/O
- 响应延迟:用户必须等待文件完全上传后才能得到响应
- 磁盘空间:高并发时临时文件可能撑爆磁盘
解决方案:基于 Busboy 的流式上传
设计思路
markdown
HTTP 请求 → Busboy 解析(边解析边触发事件)
↓
stream.pipe(passThrough) ← 检测到文件立即 resolve
↓
Controller 开始消费流
↓
passThrough → pipeline → OBS
核心原理:
- 使用 Busboy 解析 multipart/form-data
- 检测到文件字段时立即 resolve,不等待完整接收
- PassThrough 作为管道,数据边接收边传递给 Controller
- Controller 消费流的同时,数据持续从客户端流向 OBS
目录结构
bash
src/shared/upload/
├── interceptor/
│ └── stream-upload.interceptor.ts # 流式上传拦截器
├── strategies/
│ └── obs.service.ts # OBS 上传服务
├── upload.service.ts # 上传服务
└── upload.module.ts # 模块定义
核心代码实现
1. 流式上传拦截器
typescript
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
BadRequestException,
UnsupportedMediaTypeException,
PayloadTooLargeException,
} from '@nestjs/common';
import { from, Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { PassThrough } from 'stream';
import * as Busboy from 'busboy';
import { IRequest } from '@/interface';
export interface StreamFile {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
stream: PassThrough;
}
export interface StreamUploadOptions {
/** 表单字段名,默认 'file' */
fieldName?: string;
/** Busboy limits 配置 */
limits?: {
fileSize?: number;
fieldNameSize?: number;
fieldSize?: number;
fields?: number;
files?: number;
parts?: number;
headerPairs?: number;
};
/** 文件过滤器 */
fileFilter?: (
req: IRequest,
file: Pick<StreamFile, 'fieldname' | 'originalname' | 'mimetype'>,
) => boolean | Promise<boolean>;
}
@Injectable()
export class StreamUploadInterceptor implements NestInterceptor {
private logger = new Logger(StreamUploadInterceptor.name);
constructor(private readonly options: StreamUploadOptions = {}) {}
static create(options: StreamUploadOptions = {}): NestInterceptor {
return new StreamUploadInterceptor(options);
}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const request: IRequest = ctx.getRequest();
const contentType = request.headers['content-type'] || '';
if (!contentType.includes('multipart/form-data')) {
throw new BadRequestException('Content-Type 必须是 multipart/form-data');
}
// 提前检查 Content-Length,快速拒绝超大文件
const { limits = {} } = this.options;
const maxFileSize = limits.fileSize || 300 * 1024 * 1024;
const contentLength = parseInt(request.headers['content-length'] || '0', 10);
if (contentLength > 0 && contentLength > maxFileSize * 1.1) {
throw new PayloadTooLargeException(
`文件大小超过限制: ${maxFileSize / 1024 / 1024}MB`,
);
}
return from(this.parseMultipartAsync(request)).pipe(
switchMap((file: StreamFile) => {
request.streamFile = file;
return next.handle();
}),
);
}
private parseMultipartAsync(req: IRequest): Promise<StreamFile> {
return new Promise((resolve, reject) => {
const passThrough = new PassThrough();
let resolved = false;
const { fieldName = 'file', limits = {}, fileFilter } = this.options;
const defaultLimits = {
fileSize: 300 * 1024 * 1024,
...limits,
};
const bb = Busboy({
headers: req.headers,
limits: defaultLimits,
});
bb.on(
'file',
async (field: string, stream: NodeJS.ReadableStream, info) => {
if (resolved) return;
if (field !== fieldName) return;
const fileInfo = {
fieldname: field,
originalname: info.filename,
mimetype: info.mimeType,
};
// 执行文件类型过滤
if (fileFilter) {
try {
const allowed = await fileFilter(req, fileInfo);
if (!allowed) {
throw new UnsupportedMediaTypeException(
`不支持的文件类型: ${info.mimeType}`,
);
}
} catch (err) {
(stream as PassThrough).destroy();
passThrough.destroy(err);
if (!resolved) {
resolved = true;
reject(err);
}
return;
}
}
resolved = true;
this.logger.log(`[StreamUpload] 检测到文件: ${info.filename}`);
// 关键:将流 pipe 到 PassThrough,数据开始流动
(stream as PassThrough).pipe(passThrough);
// 立即 resolve,Controller 开始消费流
resolve({
...fileInfo,
encoding: '7bit',
stream: passThrough,
});
},
);
bb.on('error', (err: Error) => {
this.logger.error(`[StreamUpload] Busboy error: ${err.message}`);
passThrough.destroy(err);
if (!resolved) {
reject(err);
}
});
req.pipe(bb as any);
});
}
}
2. OBS 流式上传服务
typescript
import { Injectable, Logger } from '@nestjs/common';
import * as ObsClient from 'esdk-obs-nodejs';
import { PassThrough, pipeline } from 'stream';
@Injectable()
export class ObsService {
private obsClient: ObsClient;
private logger = new Logger(ObsService.name);
constructor() {
this.obsClient = new ObsClient({
access_key_id: process.env.OBS_KEY,
secret_access_key: process.env.OBS_SECRET,
server: process.env.OBS_ENDPOINT,
});
}
async uploadStream(
bucket: string,
fileStream: PassThrough,
key: string,
userId?: number,
): Promise<void> {
const pass = new PassThrough();
// 先启动 OBS 上传
const uploadPromise = this.obsClient.putObject({
Bucket: bucket,
Key: key,
Body: pass,
ContentType: 'application/octet-stream',
Metadata: { userId: String(userId || 0) },
});
// 管道:输入流 → pass → OBS
await new Promise<void>((resolve, reject) => {
pipeline(fileStream, pass, (err) => {
if (err) reject(err);
else resolve();
});
});
const result = await uploadPromise;
if (result.CommonMsg.Status !== 200) {
throw new Error(`上传失败: ${JSON.stringify(result)}`);
}
}
}
3. Controller 使用示例
typescript
@Controller('file')
export class FileController {
constructor(private readonly uploadService: UploadService) {}
@Post('uploadByStream')
@UseInterceptors(
StreamUploadInterceptor.create({
fieldName: 'uploadFile',
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
fileFilter: (req, file) => {
const allowedTypes = ['image/jpeg', 'image/png', 'video/mp4'];
return allowedTypes.includes(file.mimetype);
},
}),
)
async uploadByStream(@Req() req: IRequest) {
return await this.uploadService.upload2ObsByStream(
req.streamFile.stream,
req.streamFile.originalname,
);
}
}
配置化设计
参考 NestJS 官方 FileInterceptor 的设计模式,我们提供了灵活的配置选项:
StreamUploadOptions 配置项
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
fieldName |
string | 'file' | 表单字段名 |
limits.fileSize |
number | 300MB | 最大文件大小 |
limits.files |
number | - | 最大文件数量 |
fileFilter |
function | - | 文件类型过滤器 |
使用示例
typescript
// 1. 默认配置
@UseInterceptors(StreamUploadInterceptor.create())
// 2. 自定义字段名
@UseInterceptors(
StreamUploadInterceptor.create({ fieldName: 'avatar' })
)
// 3. 限制文件大小
@UseInterceptors(
StreamUploadInterceptor.create({
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
})
)
// 4. 限制文件类型
@UseInterceptors(
StreamUploadInterceptor.create({
fileFilter: (req, file) => {
return ['image/jpeg', 'image/png'].includes(file.mimetype);
},
})
)
// 5. 完整配置
@UseInterceptors(
StreamUploadInterceptor.create({
fieldName: 'document',
limits: {
fileSize: 50 * 1024 * 1024,
files: 1,
},
fileFilter: (req, file) => {
const allowed = ['application/pdf', 'application/msword'];
return allowed.includes(file.mimetype);
},
})
)
内存占用对比
我们通过 process.memoryUsage() 对比两种方案的内存占用:
测试场景:上传 500MB 文件
┌─────────────────┬────────────────┬────────────────┐
│ 方案 │ 内存峰值 │ 磁盘写入 │
├─────────────────┼────────────────┼────────────────┤
│ Multer + dest │ ~60MB │ 500MB 临时 │
│ StreamUpload │ ~15MB │ 无 │
└─────────────────┴────────────────┴────────────────┘
结论:流式上传方案内存占用仅为 Multer 的 1/4,且无磁盘写入。
时序图对比
Multer 方案
ini
时间线 ─────────────────────────────────────────────→
客户端: [发送文件数据........................]
↓
Multer: [等待][等待][等待][接收完成]
↓
Controller: [开始处理]
↓
OBS: [──上传──]
流式上传方案
ini
时间线 ─────────────────────────────────────────────→
客户端: [发送文件数据........................]
↓
Busboy: [解析][触发file事件]─[继续解析...]
↓
拦截器: [resolve]
↓
Controller: [开始消费流]
↓
OBS: [←── 边收边传 ──→]
注意事项
1. 文件大小限制
流式上传的文件大小限制有两层:
- Content-Length 检查:请求开始前快速拒绝
- Busboy limits:传输过程中截断(兜底)
typescript
// Content-Length 检查(推荐)
if (contentLength > maxFileSize * 1.1) {
throw new PayloadTooLargeException();
}
2. 错误处理
流式上传过程中可能发生的错误:
- 网络中断:流会被销毁,Controller 应捕获错误
- 文件过大:
limit事件触发 - 文件类型不符:
fileFilter抛出异常
3. 客户端兼容
客户端必须使用 multipart/form-data 格式:
javascript
const formData = new FormData();
formData.append('file', fileBlob);
fetch('/api/file/uploadByStream', {
method: 'POST',
body: formData,
// 不要手动设置 Content-Type
});
总结
| 对比项 | Multer | 流式上传 |
|---|---|---|
| 内存占用 | 较大 | 极小 |
| 磁盘 I/O | 有 | 无 |
| 响应时机 | 接收完成后 | 边收边处理 |
| 配置灵活性 | 高 | 高 |
| 实现复杂度 | 简单 | 中等 |
| 适用场景 | 小文件 | 大文件/高并发 |
推荐策略:
- 小文件(<10MB):使用 Multer,简单可靠
- 大文件(>10MB):使用流式上传,节省资源
- 混合场景:根据文件类型/大小动态选择