第十五课:文件、HTTP 与其他实用技术

3 阅读9分钟

覆盖文档:File Upload, Streaming Files, HTTP Module, Server-Sent Events, Compression, MVC, Serve Static 前置知识:第13课(配置与验证技术) 源码重点:packages/common/file-stream/streamable-file.ts, Express/Fastify 文件流处理差异


一、文件上传

[基础] 本节面向首次处理文件上传的读者。

1.1 Multer 与基础配置

NestJS 使用 Multer(基于 Express)处理文件上传。Multer 处理 multipart/form-data 格式的表单数据,这是文件上传最常用的编码格式。

注意:Multer 仅适用于 Express 平台。Fastify 平台需使用 @fastify/multipart 等替代方案。

npm i -D @types/multer

1.2 单文件上传

import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller('files')
export class FilesController {
  @Post('upload')
  @UseInterceptors(FileInterceptor('file'))
  uploadFile(@UploadedFile() file: Express.Multer.File) {
    return {
      originalName: file.originalname,
      size: file.size,
      mimeType: file.mimetype,
    };
  }
}
  • FileInterceptor('file') — 提取表单中 name="file" 的单个文件字段
  • @UploadedFile() — 将解析后的文件对象注入方法参数
  • Express.Multer.File — 包含 fieldnameoriginalnameencodingmimetypesizebuffer 等属性

1.3 多文件上传

// 同一字段名的多个文件
@Post('uploads')
@UseInterceptors(FilesInterceptor('files', 10))  // 最多 10 个
uploadFiles(@UploadedFiles() files: Express.Multer.File[]) {
  return files.map(f => ({ name: f.originalname, size: f.size }));
}

1.4 多字段文件上传

// 不同字段名的文件
@Post('multi-field')
@UseInterceptors(FileFieldsInterceptor([
  { name: 'avatar', maxCount: 1 },
  { name: 'documents', maxCount: 5 },
]))
uploadMultiField(
  @UploadedFiles() files: {
    avatar?: Express.Multer.File[];
    documents?: Express.Multer.File[];
  },
) {
  return {
    avatar: files.avatar?.[0]?.originalname,
    documentCount: files.documents?.length ?? 0,
  };
}

1.5 任意字段与无文件拦截器

// 接受任意字段名的文件
@Post('any')
@UseInterceptors(AnyFilesInterceptor())
uploadAny(@UploadedFiles() files: Express.Multer.File[]) {
  return { count: files.length };
}

// 明确声明不接受文件(若客户端发文件则报错)
@Post('no-file')
@UseInterceptors(NoFilesInterceptor())
noFile(@Body() body: CreateDto) {
  return body;
}

文件拦截器汇总:

拦截器用途参数
FileInterceptor(field)单个文件字段名
FilesInterceptor(field, max?)同字段多文件字段名 + 最大数量
FileFieldsInterceptor(fields)多字段[{ name, maxCount }]
AnyFilesInterceptor()任意字段
NoFilesInterceptor()禁止文件

1.6 文件验证

使用 ParseFilePipe 结合内置验证器对上传文件进行大小和类型校验:

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(
  @UploadedFile(
    new ParseFilePipe({
      validators: [
        new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 5 }),  // 5MB
        new FileTypeValidator({ fileType: 'image/png' }),         // 仅 PNG
      ],
    }),
  )
  file: Express.Multer.File,
) {
  return { name: file.originalname };
}

关键细节

  • MaxFileSizeValidator — 校验文件大小(字节)
  • FileTypeValidator — 校验 MIME 类型,**基于 magic bytes(文件头魔数)**而非文件扩展名,更安全
  • fileType 支持正则表达式:fileType: /(jpg|jpeg|png|gif)$/

1.7 ParseFilePipeBuilder

ParseFilePipeBuilder 提供更流畅的链式 API:

@UploadedFile(
  new ParseFilePipeBuilder()
    .addFileTypeValidator({ fileType: 'jpeg' })
    .addMaxSizeValidator({ maxSize: 1024 * 1024 * 10 })  // 10MB
    .build({
      errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,  // 422
      fileIsRequired: false,  // 文件可选
    }),
)
file?: Express.Multer.File,

1.8 全局 Multer 配置

// app.module.ts
import { MulterModule } from '@nestjs/platform-express';

@Module({
  imports: [
    MulterModule.register({
      dest: './uploads',          // 文件存储目录
      limits: {
        fileSize: 1024 * 1024 * 50,  // 全局最大 50MB
        files: 10,                    // 最多 10 个文件
      },
    }),
  ],
})
export class AppModule {}

也可通过 MulterModule.registerAsync() 异步配置:

MulterModule.registerAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    dest: config.get('UPLOAD_DIR'),
  }),
  inject: [ConfigService],
}),

二、流式传输与 HTTP 客户端

[中阶] 本节面向需要实现文件下载或调用外部 API 的读者。

2.1 StreamableFile — 跨平台文件下载

StreamableFile 是 NestJS 提供的跨平台文件流封装,同时兼容 Express 和 Fastify

import { Controller, Get, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'fs';
import { join } from 'path';

@Controller('download')
export class DownloadController {
  @Get()
  getFile(): StreamableFile {
    const file = createReadStream(join(process.cwd(), 'report.pdf'));
    return new StreamableFile(file, {
      type: 'application/pdf',
      disposition: 'attachment; filename="report.pdf"',
      length: 1024 * 500,  // 文件大小(字节)
    });
  }
}

StreamableFile 的构造函数接受两种输入:

┌──────────────────────────────────────────────┐
│             StreamableFile                    │
│                                              │
│  输入: Uint8Array  ──→  内部转为 Readable     │
│  输入: Readable    ──→  直接使用              │
│                                              │
│  options:                                    │
│    type         Content-Type(默认 octet-stream)│
│    disposition  Content-Disposition          │
│    length       Content-Length               │
└──────────────────────────────────────────────┘

Buffer 方式(适合小文件):

@Get('buffer')
getBuffer(): StreamableFile {
  const data = Buffer.from('Hello, World!');
  return new StreamableFile(data, {
    type: 'text/plain',
    disposition: 'attachment; filename="hello.txt"',
  });
}

错误处理

const streamable = new StreamableFile(readStream);
streamable.setErrorHandler((err, res) => {
  // 自定义错误处理逻辑
  if (!res.headersSent) {
    res.statusCode = 400;
    res.send(err.message);
  }
});
return streamable;

2.2 HTTP 客户端模块

@nestjs/axios 封装了 Axios HTTP 客户端,与 NestJS 的 DI 系统深度集成:

npm i @nestjs/axios axios

基础配置

// http-client.module.ts
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';

@Module({
  imports: [
    HttpModule.register({
      baseURL: 'https://api.example.com',
      timeout: 5000,
      maxRedirects: 3,
    }),
  ],
  providers: [ExternalApiService],
  exports: [ExternalApiService],
})
export class HttpClientModule {}

使用 HttpService

HttpService 的所有方法返回 RxJS Observable

import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { AxiosError } from 'axios';

interface UserResponse {
  id: number;
  name: string;
}

@Injectable()
export class ExternalApiService {
  private readonly logger = new Logger(ExternalApiService.name);

  constructor(private readonly httpService: HttpService) {}

  // 方式一:Observable(推荐用于流式处理)
  findAllUsers() {
    return this.httpService.get<UserResponse[]>('/users');
  }

  // 方式二:Promise(推荐用于简单场景)
  async findUserById(id: number): Promise<UserResponse> {
    const { data } = await firstValueFrom(
      this.httpService.get<UserResponse>(`/users/${id}`),
    );
    return data;
  }

  // 方式三:直接使用原生 Axios(绕过 RxJS)
  async createUser(dto: CreateUserDto): Promise<UserResponse> {
    const { data } = await this.httpService.axiosRef.post('/users', dto);
    return data;
  }
}

三种使用方式对比:

方式返回类型适用场景
httpService.get()Observable<AxiosResponse>需要 RxJS 操作符(retry、timeout、merge)
firstValueFrom(httpService.get())Promise<AxiosResponse>简单的 async/await 场景
httpService.axiosRef.get()Promise<AxiosResponse>需要原生 Axios 能力(拦截器、取消令牌)

异步配置

HttpModule.registerAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    baseURL: config.get('EXTERNAL_API_URL'),
    timeout: config.get('HTTP_TIMEOUT', 5000),
    headers: {
      'X-API-Key': config.get('EXTERNAL_API_KEY'),
    },
  }),
  inject: [ConfigService],
}),

三、SSE 与其他实用技术

[中阶] 本节涵盖 SSE、压缩、MVC、静态文件服务等常用技术。

3.1 Server-Sent Events(SSE)

SSE 是一种服务器向客户端单向推送事件的技术,基于 HTTP 长连接:

import { Controller, Sse, MessageEvent } from '@nestjs/common';
import { Observable, interval, map } from 'rxjs';

@Controller('events')
export class EventsController {
  @Sse('stream')
  sendEvents(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map((num) => ({
        data: { timestamp: new Date().toISOString(), count: num },
        id: String(num),
        type: 'heartbeat',
        retry: 10000,  // 客户端重连间隔(毫秒)
      })),
    );
  }
}

MessageEvent 接口:

interface MessageEvent {
  data: string | object;  // 事件数据
  id?: string;            // 事件 ID(客户端断线重连时用 Last-Event-ID 恢复)
  type?: string;          // 事件类型(客户端通过 addEventListener 监听)
  retry?: number;         // 重连间隔(毫秒)
}

客户端使用

const eventSource = new EventSource('/events/stream');

// 监听默认 message 事件
eventSource.onmessage = (event) => {
  console.log('收到数据:', JSON.parse(event.data));
};

// 监听自定义事件类型
eventSource.addEventListener('heartbeat', (event) => {
  console.log('心跳:', JSON.parse(event.data));
});

// 错误处理
eventSource.onerror = (err) => {
  console.error('SSE 连接异常:', err);
  eventSource.close();
};

3.2 Compression(压缩中间件)

使用 compression 中间件对响应体进行 gzip/deflate 压缩:

npm i compression
npm i -D @types/compression
// main.ts
import compression from 'compression';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(compression({
    threshold: 1024,    // 仅压缩 > 1KB 的响应
    level: 6,           // 压缩级别 (1-9)
  }));
  await app.listen(3000);
}

生产建议:在反向代理(Nginx、CDN)层面处理压缩通常比在 Node.js 进程中更高效。

3.3 MVC — 模板渲染

NestJS 支持传统的 MVC 模式,通过模板引擎渲染 HTML:

npm i hbs  # Handlebars 模板引擎
// main.ts
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');
  
  await app.listen(3000);
}
// app.controller.ts
@Controller()
export class AppController {
  @Get('dashboard')
  @Render('dashboard')  // 渲染 views/dashboard.hbs
  getDashboard() {
    return { title: '控制台', items: ['用户管理', '订单管理'] };
  }
}

3.4 静态文件服务

@nestjs/serve-static 可直接提供静态文件(SPA、图片、CSS、JS 等):

npm i @nestjs/serve-static
// app.module.ts
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),  // 静态文件根目录
      serveRoot: '/static',                        // URL 前缀(可选)
      exclude: ['/api/(.*)'],                      // 排除 API 路由
    }),
  ],
})
export class AppModule {}

配置选项:

选项说明默认值
rootPath静态文件目录路径必填
serveRootURL 前缀/
exclude排除的路由模式[]
serveStaticOptions底层库选项(Express/Fastify){}

四、跨技术通用配置模式

[高阶] 本节面向需要理解 NestJS 模块配置统一范式的读者。

4.1 forRoot / forRootAsync / forFeature 统一模式

NestJS 生态中几乎所有第三方模块都遵循一致的配置模式:

┌──────────────────────────────────────────────────────────────┐
│                      配置模式金字塔                           │
│                                                              │
│  ┌──────────────────────────────┐                            │
│  │     forRoot(options)         │  ← 全局配置(整个应用一次) │
│  │     例:数据库连接、Redis连接  │                            │
│  └──────────────────────────────┘                            │
│  ┌──────────────────────────────┐                            │
│  │  forRootAsync({ useFactory })│  ← 异步全局配置             │
│  │     例:从 ConfigService 读取│                            │
│  └──────────────────────────────┘                            │
│  ┌──────────────────────────────┐                            │
│  │  forFeature(featureOptions)  │  ← 功能模块配置             │
│  │     例:注册实体、注册队列    │                            │
│  └──────────────────────────────┘                            │
│  ┌──────────────────────────────┐                            │
│  │     register(options)        │  ← 每个消费者独立配置       │
│  │     例:HttpModule.register()│                            │
│  └──────────────────────────────┘                            │
└──────────────────────────────────────────────────────────────┘

4.2 registerAsync 三种配置方式

// 方式一:useFactory(最常用)
SomeModule.registerAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    host: config.get('SERVICE_HOST'),
    port: config.get('SERVICE_PORT'),
  }),
  inject: [ConfigService],
}),

// 方式二:useClass(提供完整配置类)
SomeModule.registerAsync({
  useClass: SomeOptionsFactory,
}),

// 方式三:useExisting(复用已注册的配置工厂)
SomeModule.registerAsync({
  imports: [SharedConfigModule],
  useExisting: SharedOptionsFactory,
}),

4.3 全局注册 vs 模块级注册

// 全局注册:所有模块可用
@Module({
  imports: [
    CacheModule.register({
      isGlobal: true,  // 关键选项
      ttl: 300,
    }),
  ],
})
export class AppModule {}

// 模块级注册:仅当前模块可用
@Module({
  imports: [
    HttpModule.register({
      baseURL: 'https://api.specific-service.com',
    }),
  ],
})
export class SpecificModule {}

4.4 extraProviders 模式

某些模块支持 extraProviders,允许在模块注册时注入额外的 Provider:

SomeModule.registerAsync({
  useFactory: (config: ConfigService) => ({
    // 模块配置
  }),
  inject: [ConfigService],
  extraProviders: [
    CustomLogger,
    { provide: 'CUSTOM_TOKEN', useValue: 'extra' },
  ],
}),

这在需要向动态模块的工厂函数中注入非全局 Provider 时特别有用。


五、源码解读:StreamableFile

[资深] 本节面向希望深入理解文件流处理的读者。

5.1 StreamableFile 核心实现

文件位置:packages/common/file-stream/streamable-file.ts

export class StreamableFile {
  private readonly stream: Readable;

  constructor(
    bufferOrReadStream: Uint8Array | Readable,
    readonly options: StreamableFileOptions = {},
  ) {
    // 分支一:Uint8Array → 转为 Readable
    if (types.isUint8Array(bufferOrReadStream)) {
      this.stream = new Readable();
      this.stream.push(bufferOrReadStream);
      this.stream.push(null);  // 标记流结束
      this.options.length ??= bufferOrReadStream.length;
    }
    // 分支二:Readable → 直接使用
    else if (bufferOrReadStream.pipe && isFunction(bufferOrReadStream.pipe)) {
      this.stream = bufferOrReadStream;
    }
  }

  getHeaders() {
    const {
      type = 'application/octet-stream',  // 默认二进制流
      disposition = undefined,
      length = undefined,
    } = this.options;
    return { type, disposition, length };
  }
}

关键设计:

  1. 双重入口:同时支持 Uint8Array(小数据)和 Readable(大文件流),通过构造函数重载
  2. 自动计算 lengthUint8Array 输入时自动设置 Content-Length
  3. 错误隔离handleErrorlogError 可分别自定义,解耦错误响应与错误日志
  4. 平台无关:返回标准 Node.js Readable,由各平台适配器自行处理流的写入

5.2 Express vs Fastify 文件流处理差异

Express 平台通过 res.pipe()Readable 流输送到响应:

StreamableFile.getStream()
       │
       ├── Express: stream.pipe(res)
       │   └── 利用 Node.js 原生 HTTP response 的流接口
       │
       └── Fastify: reply.send(stream)
           └── Fastify 内置流处理,自动设置 Transfer-Encoding

关键差异:

  • Express 需要手动设置 Content-TypeContent-DispositionContent-Length
  • Fastify 的 reply.send() 可以直接接受 Readable
  • 错误处理时机不同:Express 在流 error 事件中处理,Fastify 在 onError 钩子中处理

六、文件与通信策略

[架构] 本节面向技术负责人和架构师。

6.1 大文件上传策略

┌────────────────────────────────────────────────────────────┐
│                    大文件上传架构                            │
│                                                            │
│  客户端                                                    │
│  ┌──────────────┐                                          │
│  │ 文件切片      │  ─→  每片 5MB                            │
│  │ (File.slice) │                                          │
│  └──────┬───────┘                                          │
│         │                                                  │
│         ▼                                                  │
│  NestJS API 层                                             │
│  ┌──────────────┐                                          │
│  │ 分片接收      │  ─→  校验 MD5 / 断点续传                 │
│  │ 合并确认      │  ─→  所有分片到齐后合并                   │
│  └──────┬───────┘                                          │
│         │                                                  │
│         ▼                                                  │
│  对象存储(S3 / OSS / MinIO)                               │
│  ┌──────────────┐                                          │
│  │ 分片上传 API  │  ─→  Multipart Upload                    │
│  │ 直传(预签名)│  ─→  客户端直传 OSS,减轻 API 压力        │
│  └──────────────┘                                          │
└────────────────────────────────────────────────────────────┘

建议策略:

  • 小文件(< 10MB):直接通过 NestJS Multer 上传到本地或对象存储
  • 中文件(10-100MB):使用分片上传 + 断点续传
  • 大文件(> 100MB):客户端通过预签名 URL 直传对象存储,NestJS 仅生成签名

6.2 SSE vs WebSocket vs 轮询

特性SSEWebSocket短轮询长轮询
通信方向服务端 → 客户端双向客户端 → 服务端客户端 → 服务端
协议HTTP/1.1+ws:// / wss://HTTPHTTP
自动重连内置需自行实现不适用不适用
数据格式文本(text/event-stream)文本 / 二进制任意任意
浏览器支持除 IE 外全支持全支持全支持全支持
连接数HTTP/1.1 每域 6 个限制无限制无持久连接每请求一个
适用场景通知推送、实时日志、股票行情聊天、协同编辑、游戏简单状态检查低频更新

选型决策树:

需要双向通信?
├── 是 → WebSocket(第16课)
└── 否 → 需要高频推送?
    ├── 是 → SSE
    └── 否 → 短轮询 / 长轮询

6.3 文件存储策略演进

阶段方案适用规模优缺点
起步本地磁盘 ./uploads单实例简单,不支持多实例
成长NFS 共享存储小集群多实例共享,性能瓶颈
规模化对象存储(S3/OSS)中大型高可用、CDN 加速
企业级对象存储 + CDN + 预签名直传大型最优架构

七、课后实践

练习 1:文件上传服务(基础)

实现一个文件上传控制器:

// 要求:
// 1. 单文件上传接口 POST /files/upload
// 2. 限制文件大小 5MB,仅允许图片类型
// 3. 返回文件信息(名称、大小、类型)

练习 2:文件下载服务(中阶)

使用 StreamableFile 实现文件下载:

// 要求:
// 1. GET /files/download/:filename 下载指定文件
// 2. 正确设置 Content-Type 和 Content-Disposition
// 3. 处理文件不存在的情况(404)

练习 3:HTTP 客户端集成(中阶)

使用 HttpModule 调用外部 API:

// 要求:
// 1. 创建 ExternalApiModule,注册 HttpModule
// 2. 实现一个 Service 调用外部 REST API
// 3. 分别用 Observable 和 Promise 方式获取数据
// 4. 通过 registerAsync 从 ConfigService 读取 baseURL

练习 4:SSE 实时通知(中阶)

实现一个 SSE 实时推送端点:

// 要求:
// 1. GET /events/notifications 返回 SSE 流
// 2. 每 3 秒推送一条模拟通知
// 3. 包含 id、type、retry 字段
// 4. 客户端用 EventSource 消费

练习 5:源码阅读(资深)

打开 packages/common/file-stream/streamable-file.ts,回答:

  1. 为什么 Uint8Array 输入后需要 push(null)
  2. handleError 中为什么要判断 res.destroyedres.headersSent
  3. errorHandlererrorLogger 分离的设计意图是什么?

八、本课知识点总结

知识点要点
文件上传Multer(Express only),5 种拦截器覆盖不同上传场景
文件验证ParseFilePipe + MaxFileSizeValidator + FileTypeValidator(magic bytes)
流式下载StreamableFile 跨平台封装,接受 Uint8ArrayReadable
HTTP 客户端@nestjs/axios 封装 Axios,返回 Observable,支持 firstValueFromaxiosRef
SSE@Sse() + Observable<MessageEvent>,服务端单向推送
压缩compression 中间件,生产推荐 Nginx 层处理
MVCsetViewEngine() + @Render(),支持 hbs/ejs/pug 等模板
静态文件ServeStaticModule.forRoot({ rootPath }),支持排除 API 路由
配置模式forRoot / forRootAsync / forFeature / register 统一范式
源码入口packages/common/file-stream/streamable-file.ts

下一课预告:第十六课将深入 WebSocket 实时通信,学习 Gateway 网关、Socket.IO/ws 平台选择、WebSocket 增强器(Guard/Pipe/Interceptor/Filter)以及多实例部署的 Redis 适配器方案。