NestJS 流式文件上传实践:从 Multer 到 Busboy 的进阶之路

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

问题

  1. 内存/磁盘占用:文件先写入临时目录,再读取上传到 OBS,造成双重 I/O
  2. 响应延迟:用户必须等待文件完全上传后才能得到响应
  3. 磁盘空间:高并发时临时文件可能撑爆磁盘

解决方案:基于 Busboy 的流式上传

设计思路

markdown 复制代码
HTTP 请求 → Busboy 解析(边解析边触发事件)
                  ↓
           stream.pipe(passThrough)  ← 检测到文件立即 resolve
                  ↓
           Controller 开始消费流
                  ↓
           passThrough → pipeline → OBS

核心原理

  1. 使用 Busboy 解析 multipart/form-data
  2. 检测到文件字段时立即 resolve,不等待完整接收
  3. PassThrough 作为管道,数据边接收边传递给 Controller
  4. 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. 文件大小限制

流式上传的文件大小限制有两层:

  1. Content-Length 检查:请求开始前快速拒绝
  2. 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):使用流式上传,节省资源
  • 混合场景:根据文件类型/大小动态选择

参考资料

相关推荐
148612 小时前
Redis 删除缓存失败怎么办?重试、死信、补偿的工程化方案
后端
海浪浪2 小时前
Symbol 产生的背景以及应用场景
前端·javascript
148612 小时前
MySQL 复合索引怎么设计?从业务 SQL 反推索引顺序
后端
DROm RAPS2 小时前
十七:Spring Boot依赖 (2)-- spring-boot-starter-web 依赖详解
前端·spring boot·后端
OpenTiny社区2 小时前
GenUI SDK v1.1.0 正式发布|全端体验革新,能力与稳定性进阶
前端·ai编程
IAUTOMOBILE2 小时前
Code Marathon 项目源码解析与技术实践
java·前端·算法
Flying pigs~~2 小时前
基于Deepseek大模型API完成文本分类预测功能
java·前端·人工智能·python·langchain·deepseek
名字很费劲2 小时前
vue项目,刷新后出现404错误,怎么解决
前端·javascript·vue·404
ZzT2 小时前
深扒 Claude Code Buddy 模式:一只仙人掌背后的确定性随机算法
前端