覆盖文档: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— 包含fieldname、originalname、encoding、mimetype、size、buffer等属性
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 | 静态文件目录路径 | 必填 |
serveRoot | URL 前缀 | / |
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 };
}
}
关键设计:
- 双重入口:同时支持
Uint8Array(小数据)和Readable(大文件流),通过构造函数重载 - 自动计算 length:
Uint8Array输入时自动设置Content-Length - 错误隔离:
handleError和logError可分别自定义,解耦错误响应与错误日志 - 平台无关:返回标准 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-Type、Content-Disposition、Content-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 轮询
| 特性 | SSE | WebSocket | 短轮询 | 长轮询 |
|---|---|---|---|---|
| 通信方向 | 服务端 → 客户端 | 双向 | 客户端 → 服务端 | 客户端 → 服务端 |
| 协议 | HTTP/1.1+ | ws:// / wss:// | HTTP | HTTP |
| 自动重连 | 内置 | 需自行实现 | 不适用 | 不适用 |
| 数据格式 | 文本(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,回答:
- 为什么
Uint8Array输入后需要push(null)? handleError中为什么要判断res.destroyed和res.headersSent?errorHandler和errorLogger分离的设计意图是什么?
八、本课知识点总结
| 知识点 | 要点 |
|---|---|
| 文件上传 | Multer(Express only),5 种拦截器覆盖不同上传场景 |
| 文件验证 | ParseFilePipe + MaxFileSizeValidator + FileTypeValidator(magic bytes) |
| 流式下载 | StreamableFile 跨平台封装,接受 Uint8Array 或 Readable |
| HTTP 客户端 | @nestjs/axios 封装 Axios,返回 Observable,支持 firstValueFrom 和 axiosRef |
| SSE | @Sse() + Observable<MessageEvent>,服务端单向推送 |
| 压缩 | compression 中间件,生产推荐 Nginx 层处理 |
| MVC | setViewEngine() + @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 适配器方案。