Node后端-NestJS工程化实操
得益于 Node.js,JavaScript 已成为前后端应用的“通用语言”。服务端涌现了 Express、Fastify、Hono 等 Web 框架,生态丰富、能快速搭起 HTTP 服务——但它们多是路由/中间件层,不解决架构这一核心问题。Nest 就是在这之上补的一层“架构框架”。
Nest(NestJS)是一个用于构建高效、可扩展的 Node.js 服务端应用的框架。它采用渐进式 JavaScript,使用 TypeScript 构建并全面支持 TypeScript(同时仍允许开发者使用纯 JavaScript 编码),融合了 OOP(面向对象编程)、FP(函数式编程)和 FRP(函数响应式编程)的元素。
在底层,Nest 使用了强大的 HTTP 服务器框架如 Express(默认),也可以配置使用 Fastify!
Nest 提供了一套开箱即用的应用架构,使开发者和团队能够创建高度可测试、可扩展、松耦合且易于维护的应用程序。
目录
- 1. 总览:NestJS 是怎么运转的
- 2. 全栈架构和内容讲解
- 3. 跑起第一个服务
- 4. 接口构件参考
- 5. 原理拆解
- 6. 工程实践
- 7. 装饰器:JS 提案、TS 演进与 NestJS 源码视角
1. 总览:NestJS 是怎么运转的
1.1 快速开始:CLI 初始化
Nest 提供官方 CLI 脚手架,一行命令生成一个能直接跑起来的最小工程:
pnpm dlx @nestjs/cli new my-app # 或 npx nest new my-app
cd my-app
pnpm start:dev # http://localhost:3000
1.2 初始化的目录结构
下面是 nest new my-app 实际生成的目录:
my-app/
├── src/
│ ├── main.ts # 入口:创建 app、挂全局件、listen(3000)
│ ├── app.module.ts # 根模块:装配所有子模块的入口
│ ├── app.controller.ts # 示例控制器(GET /)
│ ├── app.controller.spec.ts # 上面控制器的单元测试
│ └── app.service.ts # 示例 provider(被 controller 注入)
├── test/
│ ├── app.e2e-spec.ts # e2e 测试示例(supertest 打真实接口)
│ └── jest-e2e.json # e2e 的 jest 配置
├── ......
└── README.md
nest new 生成的就是“入口 + 根模块 + 一组示例 controller/service”的最小骨架。main.ts 起步、app.module.ts 装配、app.controller.ts / app.service.ts 是示例业务。真实项目并不会在app.controller.ts / app.service.ts 中写业务。而是在 src/ 下逐步长出 modules/<业务>/、common/(横切件)、config/(配置)等目录(见第 2 章)。
生成业务模块用 CLI:pnpm exec nest g resource notes(nest 是局部命令,见上文)会按 module / controller / service / dto / entity 的固定模板一次生成一整套,实际长出来的目录:
src/notes/
├── notes.module.ts # 业务模块:登记下面的 controller / service
├── notes.controller.ts # HTTP 层:路由 + 取参
├── notes.controller.spec.ts # controller 单元测试
├── notes.service.ts # 业务逻辑层(provider)
├── notes.service.spec.ts # service 单元测试
├── dto/
│ ├── create-note.dto.ts # 创建请求体类型
│ └── update-note.dto.ts # 更新请求体类型
└── entities/
└── note.entity.ts # 数据库表映射(ORM 实体)
注意它没生成 common/、config/——这俩永远得手动建。
1.3 设计理念:你填业务,框架装配
Nest 走 Spring 那一套(基本是 Spring 在 Node 上的翻版):把后端拆成一组有标准职责的零件(构件)(如 Controller、Service、Module、Guard、Pipe、Interceptor 等),你只写零件的业务内容,框架负责装配它们、按固定顺序调度。
① 启动期(装配,跑一次)
NestFactory.create(AppModule)
→ 扫描器从根模块沿 imports 递归,收集所有 @Module
→ 读元数据建"模块图"
→ 容器按依赖链把所有 provider / controller 实例化、注入
→ 从 controller 的 @Get / @Post 生成路由表
→ app.listen()
② 请求期(运行,每个请求跑一遍)
请求 → Middleware → Guard → Interceptor(前) → Pipe → Controller 方法 → Interceptor(后) → 响应
(任何一步抛异常 → Exception Filter)
那 Nest 是怎么装配起来的?就拿你刚 nest g resource notes 生成、已经挂进 AppModule 的 notes 模块串一遍——真实代码(对照 D:\my-app),一条链:
src/main.ts —— 入口,把根模块交给容器:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule); // 【装配】把 AppModule 交给容器,触发扫描 + 实例化
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
src/app.module.ts —— 根模块,把业务模块挂进 imports:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { NotesModule } from './notes/notes.module';
@Module({
imports: [NotesModule], // 【装配】把 notes 业务模块挂进模块图
controllers: [AppController], // 示例控制器(Hello World,可删)
providers: [AppService], // 示例 provider(可删)
})
export class AppModule {}
src/notes/notes.module.ts —— 业务模块,声明“我这有哪些零件”:
import { Module } from '@nestjs/common';
import { NotesService } from './notes.service';
import { NotesController } from './notes.controller';
@Module({
controllers: [NotesController], // 【装配】要实例化的控制器
providers: [NotesService], // 【装配】要造、能注入的 provider
})
export class NotesModule {}
src/notes/notes.controller.ts —— 贴标签(路由 + 取参)+ 声明依赖:
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { NotesService } from './notes.service';
import { CreateNoteDto } from './dto/create-note.dto';
import { UpdateNoteDto } from './dto/update-note.dto';
@Controller('notes') // 【贴标签】控制器,前缀 /notes
export class NotesController {
constructor(private readonly notesService: NotesService) {} // 【声明依赖】要 NotesService
@Post() // 【贴标签】POST /notes
create(@Body() createNoteDto: CreateNoteDto) { return this.notesService.create(createNoteDto); }
@Get(':id') // 【贴标签】GET /notes/:id
findOne(@Param('id') id: string) { return this.notesService.findOne(+id); }
// 还有 findAll / update / remove,写法一样
}
src/notes/notes.service.ts —— 贴 provider 标签,写业务(生成的是桩,后续接库):
import { Injectable } from '@nestjs/common';
import { CreateNoteDto } from './dto/create-note.dto';
@Injectable() // 【贴标签】可注入 provider
export class NotesService {
create(createNoteDto: CreateNoteDto) { return 'This action adds a new note'; } // 桩,先不接库
findOne(id: number) { return `This action returns a #${id} note`; }
// 还有 findAll / update / remove
}
串起来看装配: main.ts 把 AppModule 交给容器 → 容器沿 imports 扫到 NotesModule → 读它的 @Module 元数据,要实例化 NotesController 和 NotesService → 实例化 NotesController 时,从 design:paramtypes 读出构造函数要 NotesService → 把(单例的)NotesService 塞进去 → 再从 @Controller('notes') + @Post()/@Get(':id') 元数据把 POST /notes、GET /notes/:id 等路由注册到底层 Express。请求一来,流水线跑完,NotesService 的返回就是响应。
这条链里 装饰器贴标签 + 容器读标签装配 + 流水线处理请求——一件没少。每个业务模块都是这条链的复制品,挂进根模块的 imports 即生效。
1.4 概念速查
真实代码的装配追踪见 1.3。这一节用一张大白话表,把第 1 章反复出现的核心概念一行一个钉死:
| 概念 | 一句话 |
|---|---|
| 容器 | @nestjs/core 里的运行时登记簿,一个应用只有一个;按配方造实例、管单例 |
| 模块 | @Module 标注的声明单元,说清“我这有哪些 controller / provider,导入谁、导出谁” |
| 依赖 | A 干活要用到 B,B 就是 A 的依赖(写在构造函数参数里) |
| provider | 登记进容器的“造 B 配方”(类 / 值 / 工厂),告诉容器“我能造 B” |
| DI(依赖注入) | 别自己 new——容器把造好的依赖,自动塞进构造函数 |
| 装配 | 启动时容器读元数据,按依赖链(design:paramtypes)把所有实例造好、注入,再生成路由表 |
| 装饰器 | 一个 @xxx 函数,定义时介入类/方法/参数——本质能包装/重写它们(如 @log);Nest 里基本只用来贴元数据 |
| 贴标签 | 装饰器执行时往目标贴元数据;@Module/@Controller/@Injectable/@Get/@Body 都是在贴标签,框架据此装配 |
记住一句:Nest = 装饰器贴标签 + 容器按标签装配 + 固定流水线处理请求。 以后看到任何 Nest 概念,先问它属于这三件里的哪一件。
1.5 构件分布
NestJS 把后端拆成一组可装配的"构件",几乎每一个都对应一个装饰器:
应用对象
├── @Module() # 模块:声明一组 providers / controllers / imports / exports
├── @Controller() # 控制器:聚合一组路由
└── @Injectable() # provider 标记:可被 DI 容器管理的类
路由与参数
├── @Get @Post @Put @Patch @Delete # 路由处理器
├── @Param @Query @Body # 取路径参数 / 查询参数 / 请求体
├── @Headers @Ip @Session # 取协议层信息
└── @Req @Res # 原始 Express / Fastify 对象
横切构件(按请求生命周期顺序)
├── Middleware # 中间件:最外层,能拿到
Request/Response
├── Guard # 守卫:决定请求是否被允许进入
├── Interceptor # 拦截器:方法执行前后都能介入
├── Pipe # 管道:参数转换或校验
└── Exception Filter # 异常过滤器:异常到响应的映射
异常与响应
├── HttpException 及其子类 # 内置异常:BadRequestException、NotFoundException 等
├── @Catch() # 自定义异常过滤器的作用范围
└── @Render() # 模板渲染(SSR 场景)
这些构件的关系压成一句:
平台适配器(Express/Fastify) -> Nest app -> Module -> Controller -> Guard -> Interceptor -> Pipe -> Service
1.6 后端依赖图
NestJS 本身只是"装配框架",真正的 HTTP 能力来自底层平台,校验、ORM 等都靠生态包。典型依赖主线:
框架核心
├── @nestjs/common # 装饰器、构件接口、工具
├── @nestjs/core # DI 容器、NestFactory、扫描器、实例加载器
└── @nestjs/platform-express # 默认 HTTP 平台(基于 Express)
└── express # 底层 Web 框架
元数据基础设施(DI 能工作的前提)
├── reflect-metadata # 装饰器元数据的运行时存储
└── tsconfig: emitDecoratorMetadata + experimentalDecorators # 编译期把类型信息写进元数据
配置层
└── @nestjs/config # 读 .env / 环境变量,注入 ConfigService
数据校验层
├── class-validator # 装饰器驱动的字段校验
├── class-transformer # 普通对象 -> DTO 类实例
└── ValidationPipe # Nest 自带管道,串起上面两个
数据库层
├── @nestjs/typeorm + typeorm # TypeORM 集成(最主流)
├── @prisma/client / Prisma # 或 Prisma
└── @nestjs/mongoose # 或 Mongoose
这一组覆盖了 Nest 后端的最小闭环:路由、配置、DI、校验、数据库、测试。扩展到更完整的大型后端时还会长出:
认证授权层
├── @nestjs/jwt # JWT 签发与校验
├── @nestjs/passport + passport # 护照策略集成(本地 / JWT / OAuth)
└── bcrypt / argon2 # 密码哈希
缓存与消息层
├── @nestjs/cache-manager # 缓存抽象
├── @nestjs/bull / BullMQ # 队列
└── @nestjs/microservices # 微服务 / RPC(TCP、Redis、gRPC、Kafka)
运维与启动层
├── @nestjs/swagger # 自动生成 OpenAPI 文档
├── @nestjs/cli # 脚手架与构建
└── pm2 / docker # 进程管理 / 容器化
2. 全栈架构和内容讲解
第 1 章讲了 CLI 生成的最小骨架和总览机制。本章先把它演进成前后端共享类型的全栈(monorepo)结构,再逐个深入每个文件——既讲 nest new 生成的文件(main.ts / app.module / controller / service / test),也讲业务模块才有的 dto / entity,以及 nest new 不生成、要自己建的 common/、config/。
2.1 全栈(monorepo)结构
上面的 src/ 是后端单应用结构。一旦前端要复用后端的类型与校验(见 4.3 / 4.4),就把它演进成 monorepo——后端挪进 apps/api/、前端放 apps/web/、共享的 schema/DTO 抽到 packages/schemas/:
my-app/ # 工作区根(pnpm workspaces)
├── package.json # 声明 workspaces + 公共 dev 脚本
├── pnpm-workspace.yaml # 列出 apps/* 和 packages/*
├── tsconfig.base.json # 所有子包 extends 的基础 TS 配置
│
├── apps/
│ ├── api/ # Nest 后端(内部就是上面的 src/ 结构)
│ │ ├── package.json # "@my/api",依赖 @my/schemas
│ │ ├── tsconfig.json
│ │ └── src/ … # main.ts / app.module.ts / modules/ …
│ └── web/ # 前端(Vite / Next / React)
│ ├── package.json # "@my/web",依赖 @my/schemas
│ └── src/ …
│
└── packages/
└── schemas/ # ★ 共享契约层(单一事实源)
├── package.json # "@my/schemas"
├── tsconfig.json
└── src/
├── index.ts
└── notes.schema.ts # Zod schema / class DTO,前后端都 import
工作区管理器(pnpm workspaces / npm workspaces)把 apps/ 和 packages/ 绑成一个整体:@my/schemas 是独立包,api 和 web 都用 "@my/schemas": "workspace:*" 依赖它。
关键认知:类型共享发生在 build 时,不是运行时。两端打包器各自把 @my/schemas 编译进自己的 bundle,运行时是两份独立副本——所以 api 和 web 部署到不同服务器也不影响共享,它们靠网络传 JSON 通信,类型一致是因为源自同一个源文件。
工具选择:api + web 两个 app 起步用 pnpm workspaces 即可;等 app > 2 或 CI 构建变慢,再考虑 turborepo(别一上来就上 nx)。
2.2 main.ts
main.ts 是应用装配层,不是业务实现层。
它主要负责:
NestFactory.create(AppModule)创建应用- 注册全局管道(如
ValidationPipe)、全局过滤器、全局拦截器、全局前缀 app.listen(port)监听端口- 可选:
enableShutdownHooks()启用优雅关闭
它和 HTTP 平台的关系是:
浏览器 -> Express/Fastify -> Nest app(main.ts 装配出的)
常见启动命令(Nest 默认用 npm/pnpm 脚本封装):
pnpm start:dev # = nest start --watch,开发热重启
node dist/main.js # 生产环境直接跑编译产物
不要把业务逻辑、数据库访问、校验规则写进 main.ts。它的角色等同于 FastAPI 的 main.py。
典型内容:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // 所有路由加 /api 前缀
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
await app.listen(3000);
}
bootstrap();
2.3 app.module.ts
app.module.ts 是根模块,是整个依赖图的入口。Nest 从这里开始扫描所有子模块。
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NotesModule } from './modules/notes/notes.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // 全局配置
NotesModule,
],
})
export class AppModule {}
根模块通常只做 imports,不放具体业务 provider。每个业务域有自己的模块,根模块把它们拼起来——这一点和 FastAPI 在 main.py 里集中注册 router 的作用对等,但 Nest 把"注册"显式做成了 @Module 的 imports。
2.4 controller
控制器是 HTTP 层,职责等同 FastAPI 的 router.py:
- 定义路由前缀(
@Controller('notes')) - 声明方法与 HTTP 动词(
@Get、@Post...) - 取参数(
@Param、@Query、@Body) - 声明状态码、响应类型
- 把工作交给
service
控制器本身不写业务规则,只做协议层映射。
2.5 service 与 provider
service 是业务逻辑层,是 Nest 里最典型的 provider。
provider 是 Nest 对"可被 DI 容器管理的对象"的统称,包括:
service:业务逻辑repository:数据访问(封装 TypeORM / Prisma)factory/useValue:配置值、第三方客户端、连接池guard/interceptor/pipe/filter:横切构件,本身也是 provider
provider 的标记是 @Injectable()。它告诉 DI 容器:"这个类可以实例化、可以被注入到别的类的构造函数里。"
2.6 dto
DTO(Data Transfer Object)对应 FastAPI 的 Pydantic 模型,放在 modules/<业务>/dto/。
一组接口的 DTO 通常分几类:
CreateXxxDto:创建请求体UpdateXxxDto:更新请求体(常基于PartialType(CreateXxxDto))- 响应类型:可用 TS 接口或
class,需要进 Swagger 文档时用带装饰器的class
与 FastAPI 的关键区别:FastAPI 的 BaseModel 框架原生解析并强校验;Nest 的 DTO 是普通类,校验由 class-validator 装饰器驱动,必须显式挂上 ValidationPipe 才会校验,否则请求体只是被 class-transformer 转成一个实例(甚至直接是普通对象),字段约束全部失效。
2.7 entity 与 ORM
实体是数据库表的映射,放在 modules/<业务>/entities/。
- TypeORM:
@Entity()+@Column()装饰器定义表 - Prisma:实体定义在
schema.prisma,运行时用生成的PrismaClient
repository 封装对实体的操作,和 FastAPI 的 repository.py 角色一致。
2.8 测试
Nest 自带测试工具 @nestjs/testing,测试分两类:
- 单元测试(
*.spec.ts):用Test.createTestingModule构建一个最小模块,用useValue/useFactory替换真实依赖(如把 service 依赖的 repository 换成 mock 对象) - 端到端测试(
test/*.e2e-spec.ts):用NestFactory.createApplicationContext或supertest直接打真实 HTTP 接口
测试替身机制见 5.2 依赖注入机制,原理和 FastAPI 的 app.dependency_overrides 同构。
3. 跑起第一个服务
第 1 章用 CLI 生成了骨架,第 2 章讲了文件分工。这一章亲手把一个最小业务接口“写出来 → 装进去 → 跑起来”,过程中用源码视角看清几个关键装饰器到底做了什么。跑通这一遍,Nest 的“贴标签 + 装配 + 流水线”就不再是概念,而是你能复现的东西。
3.1 目标
实现一个接口:POST /notes,传 { title, content },返回创建好的笔记。这里先不接数据库,service 里直接造一条返回——重点放在“模块怎么写、怎么装、路由怎么通”。
3.2 写模块:controller + service + module
三个文件,放 src/modules/notes/。
notes.controller.ts —— HTTP 层,贴路由标签:
import { Body, Controller, Post } from '@nestjs/common';
import { NotesService } from './notes.service';
@Controller('notes') // 【贴标签】控制器,路由前缀 /notes
export class NotesController {
constructor(private readonly notes: NotesService) {} // 【声明依赖】要 NotesService
@Post() // 【贴标签】POST /notes
create(@Body() body: { title: string; content?: string }) {
return this.notes.create(body); // 交给 service,controller 不写业务
}
}
notes.service.ts —— 业务层,贴 provider 标签:
import { Injectable } from '@nestjs/common';
@Injectable() // 【贴标签】可注入的 provider
export class NotesService {
create(body: { title: string; content?: string }) {
return { id: Date.now(), ...body }; // 暂不接库,直接造一条返回
}
}
notes.module.ts —— 把上面两个登记成一个模块:
import { Module } from '@nestjs/common';
import { NotesController } from './notes.controller';
import { NotesService } from './notes.service';
@Module({
controllers: [NotesController], // 【装配】要实例化的控制器
providers: [NotesService], // 【装配】能造、能注入的 provider
})
export class NotesModule {}
3.3 装配进 app.module
模块写好不会自动生效,必须挂进根模块的 imports:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { NotesModule } from './modules/notes/notes.module';
@Module({ imports: [NotesModule] })
export class AppModule {}
这一步是装配链的关键一环:main.ts 把 AppModule 交给容器 → 容器沿 imports 扫到 NotesModule → 实例化它的 controller/service → 从 @Controller/@Post 元数据生成 POST /notes 路由。少挂一个 imports,接口就 404。
3.4 跑起来
pnpm start:dev
另开终端验证:
curl -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title":"hello","content":"第一条"}'
# => {"id":1747...,"title":"hello","content":"第一条"}
能返回,说明“贴标签 → 容器装配 → 路由表 → 流水线”整条链路通了。
3.5 这几个装饰器到底干了什么(源码视角)
上面用到的 @Controller / @Post / @Body / @Module / @Injectable,去掉框架错误处理后,本质都是“往目标上贴 metadata 的普通函数”。以 @Controller / @Post 为例:
// @Controller('notes') 本质是:一个接收前缀、返回“类装饰器”的函数
export const Controller = (prefix = '') =>
(target) => {
Reflect.defineMetadata('nest:controller', true, target); // 标记“这是控制器”
Reflect.defineMetadata('nest:path', prefix, target); // 记下前缀 /notes
};
// @Controller('notes') 执行时,只是在 NotesController 类上贴了两个键值对——不改类的行为,只是登记。
// @Post() 同理,只是标签贴在方法上(方法装饰器签名:target, key, descriptor)
export const Post = (path = '') =>
(target, key, descriptor) => {
Reflect.defineMetadata('nest:method', 'POST', target, key);
Reflect.defineMetadata('nest:path', path, target, key);
};
启动时 Nest 的扫描器遍历所有带 nest:controller 标记的类,读它方法的 nest:method / nest:path,据此把 POST /notes 注册到底层 Express。装饰器本身不实现路由逻辑,路由逻辑在框架启动期“读 metadata”时才发生。
@Body()是参数装饰器(签名(target, key, index)),记下“第 0 个参数从请求 body 取值”,路由命中后据此从req.body提取。@Injectable()贴“可注入”标记;构造函数要NotesService这件事,靠 TS 编译期写入的design:paramtypes(见 5.1),容器据此 new 并注入。@Module({ controllers, providers })把这组元数据整个贴到类上,容器据此知道要实例化什么。
完整的装饰器提案、TS 版本演进、以及 Nest 为什么卡在旧装饰器上,见 第 7 章(尤其 7.4);DI 的完整机制见 5.2。
4. 接口构件参考
4.1 控制器与路由
入口装饰器:
@Controller('prefix'):声明控制器及其路由前缀@Get @Post @Put @Patch @Delete @All:方法级路由
FastAPI 把路由拆到 APIRouter 再注册到 app;Nest 把路由写在 @Controller() 类的方法上,再把控制器注册到 @Module({ controllers })。两者的拆分粒度不同:FastAPI 是"函数 + router",Nest 是"类 + controller"。
典型写法:
// src/modules/notes/notes.controller.ts
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { NotesService } from './notes.service';
import { CreateNoteDto } from './dto/create-note.dto';
@Controller('notes')
export class NotesController {
// 构造函数注入:service 由 DI 容器提供
constructor(private readonly notesService: NotesService) {}
@Post()
create(@Body() dto: CreateNoteDto) {
return this.notesService.create(dto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.notesService.findOne(id);
}
}
这里的 notesService 不是从请求里来的,而是 Nest 在实例化 NotesController 时通过构造函数注入的——见 5.2。
4.2 路径参数、查询参数、请求体
Nest 通过参数装饰器显式声明取值来源,不靠类型推断(这点和 FastAPI 不同):
@Param('id'):路径参数,取:id@Param():拿全部路径参数对象@Query('skip'):查询参数@Body():请求体,会被管道转成 DTO 实例@Headers('authorization')、@Ip()、@Session():协议层信息
@Get()
findAll(@Query('skip') skip?: number, @Query('take') take?: number) {
return this.notesService.findAll({ skip, take });
}
不写装饰器的参数不会被注入——FastAPI 是"看类型猜来源",Nest 是"看装饰器定来源"。这是两套设计哲学的核心差异。
4.3 DTO 与校验
DTO 是带校验装饰器的普通类:
// src/modules/notes/dto/create-note.dto.ts
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
export class CreateNoteDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@IsString()
content?: string;
}
校验生效的前提是在 main.ts 全局挂上 ValidationPipe:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 自动剥离 DTO 上没有声明的字段(防注入额外字段)
transform: true, // 把普通对象转成 DTO 类实例,并按类型转换
forbidNonWhitelisted: true, // 出现未声明字段直接报错
}),
);
关键点:
- 没挂
ValidationPipe,@Body()拿到的是普通对象,@IsString()这类装饰器根本不会执行。 transform: true后,函数里拿到的才是真正的CreateNoteDto实例,而不是裸对象。- 这套校验由
class-transformer(转实例)+class-validator(跑校验)完成,Nest 的ValidationPipe只是调用它们,机制见 5.4 中管道这一环。
DTO 的更新复用:
import { PartialType } from '@nestjs/swagger'; // 或 @nestjs/mapped-types
export class UpdateNoteDto extends PartialType(CreateNoteDto) {}
4.4 Zod 校验方案
Zod 是 4.3 那套 class-validator 的替代方案,不是它的子集或插件。两者是两种范式:class-validator 是「类 + 装饰器」(校验规则和类型两套定义),Zod 是「schema 单源」(一个 schema 既是运行时校验器,又通过 z.infer 推导出 TS 类型)。
考虑用 Zod 的三个结构性理由:
- schema 单源:类型从 schema 推导(
z.infer),改一处两端类型一起变,不存在「校验装饰器和类型对不上」的漂移。 - 纯 TS、零装饰器:不依赖
experimentalDecorators/emitDecoratorMetadata,因此绕开了 5.7 那条雷。 - 跨前后端共享:同一份 schema 前后端复用,实现同类型 + 同运行时校验(见 4.4.3)。
4.4.1 定义 schema
schema 放在共享层(如 monorepo 的 packages/schemas/)或模块内 dto/,一个文件同时产出 schema 和类型:
// notes.schema.ts
import { z } from 'zod';
// 请求 schema —— 校验进来的 body
export const createNoteSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
});
export type CreateNote = z.infer<typeof createNoteSchema>; // 类型从 schema 推导,不手写
// 响应 schema —— 给返回数据定型
export const noteSchema = z.object({
id: z.number().int(),
title: z.string(),
content: z.string(),
createdAt: z.string(),
});
export type Note = z.infer<typeof noteSchema>;
schema 复用(Zod 原生方法,对应 class-validator 的 PartialType / PickType):
export const updateNoteSchema = createNoteSchema.partial(); // 全部可选
export const patchTitleSchema = createNoteSchema.pick({ title: true }); // 只取部分字段
export const withAuthorSchema = createNoteSchema.extend({ authorId: z.number().int() }); // 扩展
4.4.2 后端:挂上校验
接法 A:手写 ZodValidationPipe(Nest 官方推荐,10 行,零第三方依赖)
// src/common/pipes/zod-validation.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<T> {
constructor(private schema: ZodSchema<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException(result.error.flatten().fieldErrors); // 字段级错误
}
return result.data; // 已校验、已转型,类型为 T
}
}
在 controller 里按参数挂(per-param):
import { createNoteSchema, CreateNote } from './notes.schema';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
const CreateNotePipe = new ZodValidationPipe(createNoteSchema);
@Post()
create(@Body(CreateNotePipe) body: CreateNote) {
return this.notes.create(body); // body 已被 schema 校验过
}
管道本身的工作机制见 4.7(请求生命周期里位于 handler 之前)。
接法 B:nestjs-zod(社区包,DTO 外壳 + 全局校验 + Swagger 桥)
import { createZodDto } from 'nestjs-zod';
import { createNoteSchema } from './notes.schema';
export class CreateNoteDto extends createZodDto(createNoteSchema) {}
// 配合全局 ZodValidationPipe,Controller 写法退回到 @Body() body: CreateNoteDto
底层校验仍是那个 schema,只是套了 DTO 外壳,并附带 Zod → OpenAPI 的桥(补回 Swagger)。
per-param vs 全局:接法 A 是显式 per-param(每个 @Body 指明 schema,清晰但要写一行);接法 B 配全局 pipe 是「声明即校验」(DTO 类自带 schema,全局 pipe 自动跑)。小项目用 A;要全局统一 + DTO 体验 + Swagger 用 B。
4.4.3 前端:同类型 + 同校验
同一份 schema 被前端直接 import,既当类型又当校验器——这是 Zod 相对 class-validator 最大的红利:
// 前端:react-hook-form 用 zodResolver 跑同一套校验规则
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createNoteSchema, CreateNote } from '@my/schemas';
const { register, handleSubmit } = useForm<CreateNote>({
resolver: zodResolver(createNoteSchema), // ★ 前端校验规则 = 后端,同一个 schema 对象
});
关键点:
- 前端类型
CreateNote与后端是同一个z.infer,改 schema 两端编译期一起变。 - 前端校验(
zodResolver内部safeParse)与后端(ZodValidationPipe内部safeParse)跑的是同一个 schema 对象——不会出现「前端放过、后端拒绝」。 - 对比 class-validator:它搬到前端要带
reflect-metadatapolyfill,且 react-hook-form 没有它的官方 resolver;Zod 是@hookform/resolvers一等支持。
4.4.4 选型:库与范式
用哪个库(实测 2026-06):
| 指标 | nestjs-zod(BenLorantfy) | @anatine/zod-nestjs |
|---|---|---|
| 周下载 | 82 万 | 15.6 万(约 1/5) |
| GitHub stars | 1080 | — |
| open issues | 20(低) | — |
| 最近 push | 2026-06-17(活跃) | 末版 ~2025-04(停更一年多) |
| Zod 兼容 | ^3.25 || ^4.0 ✅ | ^3.20 ❌ 不支持 v4 |
| Nest 兼容 | ^10 || ^11 | ^7..^11 |
| 发布可信度 | GitHub OIDC + SLSA provenance | 个人发布 |
结论:用 Zod 就直接上 nestjs-zod,别碰 @anatine/zod-nestjs(卡 Zod v3、停更一年)。安全边界:即便它停更,也只是个薄胶水库——退回接法 A 那 10 行 pipe 即可。选社区 wrapper 的前提:薄、可替换。
Zod vs class-validator 怎么选:
| 维度 | class-validator(4.3) | Zod(本节) |
|---|---|---|
| 单一事实源 | 类(校验)+ 类型,两套 | ✅ schema 一个,z.infer 推类型 |
| 前端表单校验 | 需 reflect-metadata + 手搓 resolver | ✅ zodResolver 一行 |
| Swagger/OpenAPI | ✅ @nestjs/swagger 自动 | ⚠️ 需 nestjs-zod 桥 / zod-to-openapi |
| TS 7 装饰器雷(见 [5.7](<.md#5.7 装饰器的废弃语法与 TypeScript 版本锁>)) | ❌ 深陷其中 | ✅ 纯 TS,完全免疫 |
一句话:重度依赖 Swagger 自动文档 / 要最小认知成本 → 留 class-validator;前端要和后端逐条一致的校验 + 在意 5.7 那条雷 + 前后端同 TS → 上 Zod。 两者也能并存(不同模块各用各的)。
4.5 Providers 与依赖注入
依赖注入的入口是构造函数 + @Injectable()。
@Injectable() 标注 service:
// src/modules/notes/notes.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotesService {
constructor(private readonly notesRepository: NotesRepository) {}
create(dto: CreateNoteDto) { /* 业务规则 */ }
}
在模块里注册:
@Module({
controllers: [NotesController],
providers: [NotesService, NotesRepository],
})
export class NotesModule {}
注册时写的是类,Nest 把它当作 token,实例化后注入到任何构造函数声明该类型的地方。providers 数组的简写形式等价于:
providers: [
{ provide: NotesService, useClass: NotesService },
]
四种 provider 注册形态:
useClass:注入时 new 一个类(默认)useValue:注入一个现成对象/常量(如配置值、mock)useFactory:用工厂函数构造,可依赖其它 provider(如根据配置选不同实现)useExisting:给已有 provider 起别名
{ provide: 'CONFIG', useValue: { pageSize: 20 } }
// 注入非类 token 时用 @Inject
constructor(@Inject('CONFIG') config: { pageSize: number }) {}
一个实用原则(和 FastAPI 一致):
- 业务数据走
@Param/@Query/@Body - 运行时对象走构造函数注入(service、repository、config、当前用户)
底层是怎么从构造函数读出依赖并注入的,见 5.2。
4.6 Guard 守卫
守卫决定请求"是否被允许进入",返回 boolean / Promise<boolean>。返回 false(或抛异常)会阻止后续流程。最典型的用途是鉴权。
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
return !!req.user; // 没登录就拦截
}
}
使用:
@UseGuards(AuthGuard)
@Get('profile')
getProfile() { ... }
Reflector 用来读自定义装饰器写在元数据里的标记(如 @Roles('admin')),机制见 5.1。
4.7 Pipe 管道
管道做两件事:转换(把输入变成想要的形式)和校验(不合法就抛异常)。在路由方法执行前、参数拿到后运行。
内置管道:ValidationPipe(最常用)、ParseIntPipe、ParseUUIDPipe 等。
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { ... }
// id 在进入方法体前已被转成 number,非数字直接抛 400
自定义管道:
@Injectable()
export class IntRangePipe implements PipeTransform {
transform(value: number) {
if (value < 0) throw new BadRequestException('必须为正数');
return value;
}
}
挂载层级:参数级(@Param('id', Pipe))、方法级(@UsePipes)、控制器级、全局级(main.ts 的 useGlobalPipes)。层级越靠后覆盖越广。
4.8 Interceptor 拦截器
拦截器是"面向切面"的构件,既能改方法返回值,也能在方法执行前后插入逻辑。用 rxjs 的 tap / map 操作流。
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const now = Date.now();
return next
.handle() // 返回的是 Observable,不是直接结果
.pipe(tap(() => console.log(`耗时 ${Date.now() - now}ms`)));
}
}
next.handle() 返回的是 RxJS Observable——方法返回值是流中的一个值。要改返回值就用 map,要在执行后做事就用 tap。
典型用途:日志、缓存、响应结构统一包装(如把 { data } 包成 { code, data, message })、超时控制(timeout 操作符)。
4.9 Exception Filter 异常过滤器
过滤器把异常映射成响应。Nest 自带一层全局异常处理:业务里抛 HttpException(及其子类),它会被自动转成对应状态码的 JSON。
throw new NotFoundException('笔记不存在');
// 自动响应: 404 { statusCode: 404, message: "笔记不存在", error: "Not Found" }
自定义过滤器处理非标准异常或自定义异常类型:
@Catch(BusinessError)
export class BusinessFilter implements ExceptionFilter {
catch(exception: BusinessError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
res.status(200).json({ code: exception.code, message: exception.message });
}
}
@Catch() 决定过滤器捕获哪些异常;不传参数则捕获所有异常。
4.10 Middleware 中间件
中间件是 Express/Fastify 风格的"请求包装器",能拿到 req / res / next,在最外层执行。典型用途:日志、CORS、请求体预处理、写 trace id。
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`${req.method} ${req.url}`);
next();
}
}
中间件在模块里用 configure(consumer) 绑定路由:
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('notes');
}
}
中间件 vs 拦截器:中间件能直接操作 res、能提前结束请求,但不能"在方法返回后再做事";拦截器能改返回值但不能提前 send。两者职责互补,执行顺序见 5.4。
5. 原理拆解
5.1 装饰器元数据机制
Nest 的整套装配都建立在"装饰器写元数据 + 运行时读元数据"之上。这是理解 Nest 的地基。
TypeScript 装饰器本身只是函数。@Injectable()、@Controller()、@Get()、@Module() 这些装饰器被调用时,会把"这是一段元信息"通过 Reflect.defineMetadata 存到目标对象上。Nest 在启动时再把这些元数据读出来,据此搭出模块图、路由表、依赖关系。
Reflect.defineMetadata(key, value, target) 是 reflect-metadata 这个 polyfill 提供的,本质是把元信息挂在目标对象的隐藏属性里。TS 编译时如果开了 emitDecoratorMetadata: true,还会自动把构造函数参数的类型(design:paramtypes)、属性类型(design:type)一并写进元数据——这正是 DI 能"按类型注入"的来源。
@Injectable() 执行
-> 调用 Reflect.defineMetadata('nest:injectable', ..., NotesService)
-> 把"可被容器管理"的标记挂在 NotesService 上
Nest 实例化 NotesController 时
-> Reflect.getMetadata('design:paramtypes', NotesController)
-> 读出 [NotesRepository] (编译期自动写入的构造参数类型)
-> 去容器里找 NotesRepository 对应的实例
-> 注入到构造函数
读取自定义标记的工具是 Reflector(一个内置 provider),它封装了 Reflect.getMetadata:
const roles = this.reflector.get<string[]>('roles', context.getHandler());
@Roles('admin') 这种自定义装饰器就是 SetMetadata('roles', ['admin']) 的封装:
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
关键认识:
- 装饰器在"定义阶段"执行,写的是描述信息,不是运行时逻辑。
- Nest 在"启动阶段"读这些描述信息,据此构建模块图和路由表。
- "按类型注入"依赖 TS 编译期写入的
design:paramtypes,所以 DI 的类型来源是编译产物,不是运行时反射——这也是为什么 Nest 项目 tsconfig 必须开emitDecoratorMetadata和experimentalDecorators并引入reflect-metadata。
5.2 依赖注入机制
依赖注入的核心和 FastAPI 一样:对象不要在函数内部手动 new,而是由容器装配并注入。
Nest 的 DI 容器在 @nestjs/core 里。它的工作分两步:先扫描构建依赖图,再按依赖顺序实例化。
没有 DI 时,控制器里会出现这种代码:
@Controller('notes')
export class NotesController {
private service: NotesService;
constructor() {
const repo = new NotesRepository(); // 装配责任泄漏到了控制器
this.service = new NotesService(repo);
}
}
问题:控制器承担了对象装配、实现写死、难以替换(测试时塞不进 mock)。
Nest 的做法是只声明"我需要什么":
constructor(private readonly notesService: NotesService) {}
容器读取这个声明(靠 design:paramtypes),找到 NotesService 已注册的实例,调用 new NotesController(service) 完成注入。
(顺带说清几件事,免得卡住。)
那个 import 干了啥? 普通 JS 模块导入,把 NotesService 类引用弄进作用域。它不是 DI 的一部分,但有个关键副作用:让 emitDecoratorMetadata 能把真实类引用写进 design:paramtypes(写类本体,不是字符串)。容器"按类型找"靠的就是这个。
凭什么 new、凭什么注入? 容器能 new NotesService 并注入,凭的是它被登记成 provider(providers: [NotesService]),@Injectable() 是配套的声明标签:
providers: [NotesService]—— 真正的登记,告诉容器"这是个 provider,按 useClass new 它"。这是 new 的依据。@Injectable()—— 贴在类上的标签,声明"我是可注入 provider"(还带 scope)。是标记 / 约定。
光有 @Injectable() 没登记 → 容器里没它 → 找不到;登记了 → 容器 new 它,且默认只 new 一次(单例)。
如果注入一个没登记的类:
@Controller('notes')
export class NotesController {
constructor(private readonly foo: SomePlainClass) {} // SomePlainClass 没登记、没 @Injectable
}
启动直接报错,Nest 拒绝 new:
Error: Nest can't resolve dependencies of the NotesController (SomePlainClass, ?).
Please make sure that the argument SomePlainClass at index [0] is available in the current context.
关键认知:Nest 不会因为你写了个类型就自动 new 那个类。 类型只是"查找 token",容器里没登记这个 token 就不认、启动失败。"能不能注入"取决于有没有登记,不是"有没有 import / 写没写类型"。
依赖解析顺序(容器内部):
实例化 NotesController
-> 读取 design:paramtypes = [NotesService]
-> 发现构造参数依赖 NotesService
-> 实例化 NotesService
-> 读取 design:paramtypes = [NotesRepository]
-> 发现构造参数依赖 NotesRepository
-> 实例化 NotesRepository(叶子节点,无依赖)
-> 用它构造 NotesService
-> 用 NotesService 构造 NotesController
所以构造函数里的依赖声明,等于声明了一条"实例化依赖链"。容器按拓扑序从叶子节点开始实例化,避免循环依赖(Nest 默认不处理循环依赖,会报错;要打破循环得用 forwardRef)。
provider 注册形态对应不同的注入方式:
{ provide: NotesService, useClass: NotesService }:按 token 找类,new出来{ provide: 'CONFIG', useValue: {...} }:直接给现成值,注入处用@Inject('CONFIG')指定 token{ provide: DbClient, useFactory: (cfg: ConfigService) => new Client(cfg.url), inject: [ConfigService] }:工厂构造,inject声明工厂自身的依赖
测试里的替身就是这个机制的直接应用:
const moduleRef = await Test.createTestingModule({
controllers: [NotesController],
providers: [
NotesService,
{ provide: NotesRepository, useValue: { findAll: jest.fn() } }, // mock 替身
],
}).compile();
const controller = moduleRef.get(NotesController);
容器看到 NotesService 依赖 NotesRepository,但它注册的是个现成 mock 对象,于是直接注入这个对象。和 FastAPI 的 app.dependency_overrides[get_db] = lambda: ... 是同一个思路:上层 service/controller 不变,底层依赖被换掉。
provider 默认是单例(整个应用一个实例)。可改作用域:
Scope.DEFAULT(默认):单例,应用级Scope.REQUEST:每个 HTTP 请求新建一个实例(注入链上相关 provider 也会变成请求级,代价大)Scope.TRANSIENT:每次注入都新建
请求级 provider 是性能陷阱——它会让整条注入链都按请求重建,非必要不用。
5.3 模块系统与依赖扫描
Nest 启动时不是逐个看文件,而是从根模块出发,沿着 imports 递归扫描,构建出一张"模块图"。
@Module() 的四个属性定义了一个模块的边界:
providers:本模块内部用的 provider(默认对外不可见)controllers:本模块的控制器imports:依赖哪些其它模块exports:把哪些 provider 暴露给导入本模块的其它模块
exports 是模块的"公共 API"。一个 provider 没写进 exports,别的模块就算 imports 了它也拿不到——这是 Nest 封装模块内部实现的方式。
扫描与实例化由 @nestjs/core 的几个内部类完成:
DependenciesScanner:从根模块沿imports递归,收集所有模块、控制器、providerContainer:维护模块图,记录每个模块注册了什么、依赖什么Injector/InstanceLoader:按拓扑顺序实例化所有 provider 和 controller
NestFactory.create(AppModule)
-> DependenciesScanner 扫描 imports 树
-> Container 建立模块图(每个模块的 providers / controllers / exports / imports)
-> InstanceLoader 按拓扑序实例化
-> 叶子 provider 先建,依赖它们的后建
-> 所有 controller / provider 就绪
-> 路由表从 controller 的 @Get/@Post 元数据生成
-> app.listen() 开始接请求
路由表的来源也是元数据:@Controller('notes') + @Get(':id') 在定义阶段把 { method: 'GET', path: 'notes/:id', handlerName } 写进控制器元数据,Nest 启动时把这些注册到底层 Express/Fastify。
全局模块:@Global() 装饰的模块一旦被导入,其 providers 全应用可见,不用每个模块重复 imports。ConfigModule.forRoot({ isGlobal: true }) 就是这个用途。
5.4 请求生命周期
一个请求进来后,Nest 构件的执行顺序是固定的。这是 Nest 最需要记的一张图:
请求进入
-> 1. Middleware (能拿 req/res/next,能提前结束)
-> 2. Guard (鉴权,false 即拦截)
-> 3. Interceptor(前置) (方法执行前)
-> 4. Pipe (参数转换 / 校验)
-> 5. Controller 方法 (业务,调用 service)
-> 6. Interceptor(后置) (能改返回值)
-> 响应返回
如果以上任何一步抛出异常,流程跳到:
-> Exception Filter (按 @Catch 匹配的过滤器接住,转成响应)
几个容易踩错的顺序点:
- Guard 在 Interceptor 前面:拦截器的前置逻辑即使写了,也跑在鉴权之后——没权限的人根本到不了拦截器。
- Pipe 在方法前:参数校验失败时(
ValidationPipe抛 400),方法体根本不执行。这点和 FastAPI 一致:校验通过才进函数。 - Interceptor 包裹方法:拦截器同时有"前置"和"后置"两段,
next.handle()之前是前置,.pipe(...)里的tap/map是后置。异常过滤器能接住方法抛出的异常,但默认接不住拦截器后置里抛的(除非过滤器明确@Catch())。 - Middleware 不在 DI 主链里能感知的范围:中间件能操作原始
res直接send,而拦截器不能;反过来中间件拿不到方法返回值。
构件对应的"职责边界":
| 构件 | 能否改返回值 | 能否提前结束请求 | 典型用途 |
|---|---|---|---|
| Middleware | 否 | 能(直接 send) | 日志、CORS、trace |
| Guard | 否 | 能(返回 false) | 鉴权 |
| Interceptor | 能 | 否 | 日志、缓存、响应包装 |
| Pipe | 否 | 能(抛异常) | 校验、类型转换 |
| Exception Filter | 否(只改异常响应) | 否 | 统一错误响应 |
5.5 异常处理机制
Nest 有一层内置的全局异常处理。流程是:业务代码抛异常 -> 框架捕获 -> 找匹配的过滤器 -> 没有就用内置处理 -> 转响应。
近似伪代码:
try {
response = run(handler, pipes, interceptors, ...);
} catch (e) {
for (const filter of filtersFromOutsideIn) {
if (filter.catches(e)) return filter.handle(e);
}
// 兜底:HttpException 走默认映射,其它统一 500
}
两层:
- 内置层:
HttpException及其子类(BadRequestException、UnauthorizedException、NotFoundException...)会被映射成{ statusCode, message, error }。非HttpException的异常默认变 500。 - 自定义层:
@Catch(SomeError)的过滤器按"从最具体到最通用"的顺序匹配,先匹配到的处理。
实际意义:
- 业务里抛语义化异常(
throw new NotFoundException(...)) - HTTP 层统一接住,响应结构保持一致
- 想改默认结构,就注册全局自定义过滤器
// main.ts
app.useGlobalFilters(new AllExceptionsFilter());
5.6 应用生命周期
应用除了处理请求,还有启动和关闭阶段。Nest 通过一组生命周期钩子接口暴露这些时机。
按顺序:
1. 所有模块实例化完成
2. onModuleInit() —— 实现 OnModuleInit 的 provider 在此被调用
3. onApplicationBootstrap() —— 实现 OnApplicationBootstrap 的 provider 被调用
4. app.listen(),开始接请求
...
5. 收到关闭信号
6. onModuleDestroy() —— 实现 OnModuleDestroy 的 provider 被调用
7. onApplicationShutdown()
典型用途:
onModuleInit:建立数据库连接、预热缓存onApplicationBootstrap:订阅消息队列、启动定时任务(此时所有模块都就绪了)onModuleDestroy/onApplicationShutdown:关闭连接池、清理资源
关闭钩子默认不监听系统信号,需要显式开启:
app.enableShutdownHooks(); // 监听 SIGINT/SIGTERM,触发上面的 destroy 钩子
5.7 装饰器的废弃语法与 TypeScript 版本锁
5.1 建立的整套机制,踩在 tsconfig 的两个 flag 上,而这两个 flag 正在被 TypeScript 废弃。这是 Nest 最大的技术债,也直接决定了"跑 Nest 项目要锁 TS 版本"。
踩的两个 flag:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true, // 旧版装饰器规范(TC39 Stage 2,已被推翻重做)
"emitDecoratorMetadata": true // 把类型信息写进元数据;只对旧装饰器有效
}
}
experimentalDecorators实现的是旧版装饰器规范,该规范当年只走到 TC39 Stage 2 就被推翻重做。新装饰器提案后来到 Stage 3,TypeScript 5.0 已支持。emitDecoratorMetadata自动把design:paramtypes等写进元数据——这正是 Nest DI 按类型注入的数据来源(见 5.1),且只对旧装饰器有效。
为什么 Nest 搬不动(两个硬阻塞):
- 设计期元数据缺口:新 Stage 3 装饰器不 emit 元数据,而 Nest DI 靠它。官方阻塞在 microsoft/TypeScript#57533。
- Stage 3 没有参数装饰器:Nest 的
@Body()、@Param()、@Query()全是参数装饰器,而新规范故意砍掉了参数装饰器。这意味着 Nest 要迁,不只是等元数据,而是整个取参模型都得重设计。
Nest 官方在 nestjs/nest#5030 跟踪此事,没有迁移时间表——因为语言层还没给出路。
TS 版本现实(实测):
| TS 版本 | 两个 flag 的状态 | Nest |
|---|---|---|
| 5.x | 正常,无警告 | ✅ 当前主战场 |
| 6.0(2026-03 已发) | 正式 deprecated,编译有警告 | ⚠️ 能跑,tsconfig 要加 "ignoreDeprecations": "6.0" |
| 7.0(RC,Go 原生重写) | 硬移除(ignoreDeprecations 失效) | ❌ 跑不了 |
实测 @nestjs/cli 当前 peerDependency pin 的是 typescript: 5.9.3——Nest 连 TS 6 都还没跟上,而 TS 当前稳定版已是 6.0.3。所以约束现在就生效:跑 Nest 锁在 TS 5.9.x,既低于稳定版(6.0.3),更够不着 7.0。
Nest 大概率的对策(推断):
- 短期:pin 死 TS 6.x +
ignoreDeprecations顶一阵,能买好几年。 - 技术出路:编译期 transform(SWC/tsc 插件)注入元数据,绕开
emitDecoratorMetadata——最可能的逃生路径,社区已有这类方案。 - 远期:真迁移(重设计参数解析),遥遥无期。
实操约束(跑 Nest 必须接受):
- 跟着 Nest 走,TS 锁 5.9.x,别自己升 6/7。
- tsconfig 保持
experimentalDecorators: true+emitDecoratorMetadata: true(上 TS 6 时再加ignoreDeprecations: "6.0")。 - TS 7:Nest 官方宣布支持前别碰。
一句话:这不是"Nest 要崩",是它踩废弃语法的账单到期日——会靠"锁 TS 6 + transform 续命"扛过去,但现在起 TS 版本就是被它锁住的。
6. 工程实践
6.1 分层边界
controller负责 HTTP:路由、参数、状态码、响应结构service负责业务:规则、流程编排、抛业务异常repository负责数据访问:封装 ORM / Prismamodule负责装配:声明 providers / controllers / imports / exportscommon/*负责横切:通用 guard / pipe / interceptor / filter
6.2 测试组织
测试至少分两类:
- 单元测试:直接构造 module,用
useValue注入 mock,验证 service 业务规则 - 端到端测试:用
supertest打真实接口,验证路由、校验、响应
DI 的核心价值在测试里直接兑现:替换 repository 为 mock,service 不用改一行代码就能测。机制见 5.2。
6.3 常见反模式
- 在控制器里写业务规则或直接操作数据库(控制器应是协议层薄壳)
- 用了 DTO 但没挂
ValidationPipe,以为字段校验会自动生效 - 把所有 provider 都设成
Scope.REQUEST,引入不必要的请求级实例化开销 - 模块不导出
exports却抱怨跨模块注入不到;或把内部 provider 全导出,破坏封装 - 在拦截器里用同步逻辑改流,忘了返回
Observable,导致响应被吞 - 只有手测接口,没有
supertest端到端测试,路由层行为无保障 - tsconfig 没开
emitDecoratorMetadata,DI 拿不到构造参数类型,注入直接失败
7. 装饰器:JS 提案、TS 演进与 NestJS 源码视角
这一章把"装饰器"从根上讲透,串起前面散落的三处:5.1(元数据怎么存)、5.7(为什么被锁版本)、以及"reflect-metadata 是 JS 的"那个追问(运行时是 JS,类型信息是 TS 给的)。读完这章你会明白:Nest 为什么踩在一套"已废弃"的语法上、它源码里装饰器到底干了什么、以及为什么切不到新语法。
7.1 装饰器到底是什么
装饰器是语法糖,本质是一个函数。它不是魔法——在类/方法/参数被定义的那一刻,既运行时调用这个函数,把"标签"贴到目标上。Legacy 写法下:
@injectable()
class NotesService {}
// 等价于:
class NotesService {}
NotesService = injectable()(NotesService); // 装饰器就是个接收类、返回(可能改造过的)类的函数
关键认知:装饰器本身只是"在定义时被调用的函数"。它做什么,完全取决于这个函数内部怎么写——Nest 的装饰器内部就是"往类上贴 metadata"(见 7.4)。另外,装饰器只能贴在类与类成员(方法/属性/参数)上,贴不到独立函数。
7.2 JS 的两套提案:Legacy 与 Stage 3
装饰器是 ECMAScript 提案(TC39 标准化流程),经历过一次"推翻重做",于是有了两套不兼容的规范。
7.2.1 Legacy(早期 Stage 2)
2014–2016 年的早期提案(Yehuda Katz 等),descriptor 风格。
方法/属性装饰器——拿到方法的属性描述符 descriptor,直接改它:
function log(target, propertyKey, descriptor: PropertyDescriptor) {
const orig = descriptor.value; // descriptor.value 就是原方法
descriptor.value = function (...args) {
console.log(`调用 ${propertyKey}`); // 前置逻辑
return orig.apply(this, args); // 再调原方法(apply 保住 this)
};
}
class NotesService {
@log // 等价于:log(类原型, 'findAll', 方法描述符)
findAll() { return [...]; }
}
这里两个参数要拎清:
target是方法所在的那个对象:实例方法挂在类的原型(NotesService.prototype)上,所以target就是类原型;静态方法(static)挂在类本身,target是NotesService。descriptor是这个方法(被装饰的那个成员)自己的属性描述符——也就是Object.getOwnPropertyDescriptor(NotesService.prototype, 'findAll')返回的那个对象(传给log的第 3 个参数就是它);descriptor.value就是那个函数本身(可读可改)。log正是靠改descriptor.value来“包一层”。
descriptor 长这样(就是上面说的那个方法描述符的结构):
{
value: [Function: findAll], // 那个方法函数本身
writable: true, // 能否重新赋值
enumerable: false, // 是否出现在 for...in
configurable: true, // 能否删除 / 改配置
}
参数装饰器——拿到参数的位置 index。它和上面的方法装饰器有个关键不同:用法带括号、带参数(@Inject('DB')),所以它是个工厂——返回一个函数。
@Inject('DB') 贴在参数上时,分两步执行:
class Foo {
constructor(@Inject('DB') db: Db, logger: Logger) {}
// ↑ 第 1 步:先执行 Inject('DB'),返回内层函数
// ↑ 第 2 步:用内层函数去装饰“第 0 个参数”(db)
}
所以 Inject 要套两层——外层收 token,返回的内层才是真装饰器:
function Inject(token: string) {
return (target, propertyKey, parameterIndex) => { // ← 这才是真装饰器
// target = Foo(构造函数参数的 target 是类本身)
// propertyKey = undefined(构造函数参数没有具体方法名)
// parameterIndex = 0(db 是第 0 个参数;logger 会是 1)
Reflect.defineMetadata('inject:' + parameterIndex, token, target);
// ↑ 记一条元数据:“第 0 个参数要注入 token='DB'”
};
}
三个参数:
target—— 参数所在的方法 / 类。构造函数参数时是类本身(Foo);普通方法参数时是类的原型。propertyKey—— 属于哪个方法。构造函数参数时没有具体方法名(undefined)。parameterIndex—— 这个参数排第几个,从 0 数。
它到底做了什么? 关键:装饰器在类定义时就跑,那时还没有实例、参数还没有值——它根本拿不到运行时 db 是什么。它唯一能做的是记一条元数据(“第 0 个参数要注入 'DB'”),以后 DI 容器实例化 Foo 时读到这条记录,把对的依赖塞进去。
连回 Nest:@Body() / @Param('id') / @Query() 就是参数装饰器——记下“第 N 个参数,从请求的 body / path / query 取值”,路由时 Nest 据此从请求里提取。
特点:
- 能改 descriptor(mutation 风格)。
- 支持参数装饰器
(target, key, index)。 - 需要传参的装饰器用工厂(
@Xxx(...)带括号),不传参的直接用(@log不带括号)。 - 配
reflect-metadata+emitDecoratorMetadata拿类型元数据。
这套卡在 Stage 2 后被推翻重做,但 TS(1.5 起)和 Babel 早已实现,Angular / Nest / TypeORM / MobX 全建在上面——Legacy 装饰器因此成了事实标准,尽管它从未正式进入语言。
7.2.2 新版(Stage 3)
2022 年 3 月达到 Stage 3 的新提案(Daniel Ehrenberg 等),context 风格:
function log(value, context: DecoratorContext) {
// context = { kind: 'method'|'class'|..., name, addInitializer, ... }
// 不再改 descriptor,而是返回一个"替换",或用 context.addInitializer 注册初始化逻辑
}
特点:
- 不改 descriptor,改用返回替换 /
addInitializer(更安全、更可组合)。 - 砍掉了参数装饰器——这是和 Nest 最相关的差异。
- 没有内置的类型元数据机制(没有
design:paramtypes的等价物)。
7.2.3 对比
| 维度 | Legacy(Stage 2) | 新版(Stage 3) |
|---|---|---|
| 签名 | (target, key?, descriptor?) | (value, context) |
| 改造方式 | 改 descriptor(mutation) | 返回替换 / addInitializer |
| 参数装饰器 | ✅ 有 (target, key, index) | ❌ 没有 |
| 类型元数据 | ✅ emitDecoratorMetadata 发 design:paramtypes | ❌ 无内置机制 |
| 谁在用 | Nest / TypeORM / Angular / MobX | 新项目、新库(官方推荐) |
一句话:两套不兼容,Nest 用的是被推翻的那套(Legacy)。
7.3 TypeScript 的版本演进
TS 跟着提案走,关键节点:
| TS 版本 | 装饰器状态 |
|---|---|
| 1.5(2015) | 实现 Legacy,experimentalDecorators 开关 |
| 2019–2022 | 提案卡住、推翻、重写 |
| Stage 3 达成(2022.03) | 提案层面定了新规范 |
| 5.0(2023.03) | 实现 Stage 3;两套并存,experimentalDecorators 开关决定用哪套(关掉=新,开着=Legacy) |
| 6.0(当前 6.0.3) | experimentalDecorators + emitDecoratorMetadata deprecated;"ignoreDeprecations": "6.0" 抑制告警 |
| 7.0(未发布) | 将完全移除这两项(官方 TS 6.0 公告原话:"TypeScript 7.0 [will remove them entirely]") |
| Go 原生编译器(2025.03 宣布) | 7.0 的承载——用 Go 重写、提速 10×,顺带移除 legacy 装饰器 |
关键认知:两套装饰器在 TS 5.0+ 可以并存,但语义完全不同。 你 tsconfig 里 experimentalDecorators 开不开,决定了 TS 用哪套语义去编译同一份 @xxx 代码——这也是为什么这个"开关"这么重要。
7.4 NestJS 用哪套 + 源码层面
7.4.1 Nest 踩的是 Legacy
Nest 的 tsconfig 三件套(experimentalDecorators + emitDecoratorMetadata + 运行时 import 'reflect-metadata'),并且 @nestjs/cli 把 typescript pin 在 5.9.x——直接锁死在 Legacy + 5.x 上:不上 6.0(避免 deprecation 噪音),更不可能上 7.0(直接编译失败)。
7.4.2 Nest 的装饰器源码在做什么
Nest 的装饰器,本质就是"给类贴 metadata 标签的普通函数"。去掉错误处理后,@Module() / @Controller() / @Injectable() 长这样(代表性源码,键名做了简化):
// @Module():把传入的 { providers, controllers, imports, exports } 整个贴到类上
export const Module = (metadata: ModuleMetadata): ClassDecorator =>
(target) => {
Reflect.defineMetadata('nest:module', metadata, target);
};
// @Controller():贴两个标签——"我是 controller" + 路由前缀
export const Controller = (prefix = ''): ClassDecorator =>
(target) => {
Reflect.defineMetadata('nest:controller', true, target);
Reflect.defineMetadata('nest:path', prefix, target);
};
// @Injectable():贴"我是可注入 provider"的标签
export const Injectable = (): ClassDecorator =>
(target) => {
Reflect.defineMetadata('nest:injectable', true, target);
};
看到没——这些装饰器不实现任何业务逻辑,只是 Reflect.defineMetadata 往类上贴键值对。真正的逻辑在 Nest 启动时:
- 扫描器遍历所有
@Module标记的类,读Reflect.getMetadata('nest:module', cls)拿到 providers/controllers/imports/exports,构建模块图; - DI 容器据此知道要实例化哪些 provider、按什么顺序注入。
参数装饰器(@Body / @Param / @Query)同理,只是标签贴在"第几个参数"上,路由匹配后据此从请求里提取:
export const Body = () =>
(target, propertyKey, parameterIndex) => { // ← 参数装饰器签名:(target, key, index)
Reflect.defineMetadata('nest:param', { index: parameterIndex, type: 'body' }, target, propertyKey);
};
7.4.3 DI 的来源:design:paramtypes
上面 @Injectable() 只贴了"我是可注入的"。那容器怎么知道 NotesService 构造时要注入哪些依赖?靠的是 TS 编译器塞进来的 design:paramtypes(详见 5.1 和"reflect-metadata 是 JS"那条追问):
@Injectable()
class NotesService {
constructor(private repo: NotesRepository, private logger: Logger) {}
}
// emitDecoratorMetadata 让 TS 编译出:
// __metadata("design:paramtypes", [NotesRepository, Logger])
// → Reflect.defineMetadata 把 [NotesRepository, Logger] 贴到 NotesService 上
Nest 容器读这个数组,递归地把 NotesRepository、Logger 也实例化并注入。这个类型数组只有 TS 编译器能产生(plain JS 里类型标注不存在),reflect-metadata 只是仓库——这正是上一条追问的答案。
7.4.4 为什么切不到 Stage 3
Nest 想从 Legacy 迁到新标准,撞上两堵硬墙:
- 参数装饰器没了。Nest 的
@Body()/@Param()/@Query()/@Headers()全是参数装饰器(签名(target, key, index))。Stage 3 砍掉了参数装饰器 → 整套参数提取机制失效。这是最致命的。 - 类型元数据没了。Stage 3 没有
design:paramtypes的等价物 → DI 按类型注入的来源直接断掉。
这两点对应社区追踪的两个 issue:microsoft/TypeScript#57533(新装饰器的设计期类型元数据缺口)、nestjs/nest#5030(Nest 跟踪迁移)。结论:Nest 无法"轻量"迁移——要么等 TS 给新装饰器补类型元数据(短期不会),要么 Nest 自己写编译期 transform 来注入(大改)。
7.5 Nest 的处境与出路
把上面串起来:
- Nest 把地基焊在了 Legacy Stage 2 装饰器 +
emitDecoratorMetadata上——这套是"被推翻、已废弃"的语法。 - TS 6.0 已 deprecated → Nest 必须锁 TS 5.9.x(加
ignoreDeprecations才能上 6.0)。 - TS 7.0 要移除 → 在那之前 Nest 必须有出路,最可能是:写一个编译期 transform(类似 SWC/tsc 插件),在 Stage 3 装饰器之上自己注入
design:paramtypes等元数据,绕开"新装饰器无类型元数据"的缺口。这是大工程,短期不会落地。
一个旁证:选 Zod 能绕开这整套问题——Zod 是纯 TS、零装饰器(见 4.4),它的 schema 不依赖 experimentalDecorators 也不依赖 design:paramtypes。所以"用 Zod"不只是换个校验库,而是把一部分逻辑从"装饰器地基"上挪走,降低未来被 TS 7 卡住的面(但 Nest 本身的 @Module/@Controller/@Injectable 仍是装饰器,这部分短期无解)。
一句话:Nest 踩在 Legacy 装饰器这个"已废弃地基"上,短期靠锁 TS 5.9.x + ignoreDeprecations 续命,长期得靠编译期 transform 自行注入元数据。这不是 Nest 的 bug,而是它"Spring 式装饰器架构"与"JS 装饰器标准化走了另一条路"的结构性冲突。