上篇聊了为什么从自研框架转向 Nest.js,这篇从项目起步、核心用法、请求管道机制、数据操作、认证授权、应用生命周期和工程化迁移七个方面,整理 Nest.js 开发的完整知识脉络。
一、项目起步
1.1 项目脚手架
Nest CLI 通过 nest new 生成标准项目结构:
npm i -g @nestjs/cli
nest new my-app
生成后的目录结构:
src/
├── main.ts # 应用入口,创建并启动 Nest 实例
├── app.module.ts # 根模块,组织所有子模块
├── app.controller.ts # 根控制器,处理 HTTP 请求
├── app.service.ts # 根服务,封装业务逻辑
└── app.controller.spec.ts # 单元测试
main.ts 是理解 Nest 启动流程的起点:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
NestFactory.create(AppModule) 这行代码背后发生了:扫描 AppModule 及其 imports 中的所有 Module → 实例化所有 Provider → 建立依赖注入图 → 注册路由映射 → 启动 HTTP 服务器。整个启动流程是同步的——只有当所有步骤完成后,app.listen() 才会开始接收请求。
1.2 三层架构
Nest 强制使用 Module → Controller → Service 的三层组织方式:
Module 是组织单元,定义这个模块用了哪些 Controller 和 Provider:
@Module({
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
Controller 是路由层,只处理 HTTP 请求的接收和响应,不包含业务逻辑:
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findById(id);
}
}
Service 是业务逻辑层,可被多个 Controller 共享:
@Injectable()
export class UserService {
findById(id: string) {
// 数据库查询、缓存检查、数据组装——Controller 不关心这些细节
}
}
这种分层的约束力来自 Nest 的 DI 容器——Controller 只能通过构造函数注入 Service,无法直接访问数据库或 HTTP 请求的原始对象(除非显式使用 @Req())。
二、核心用法
2.1 路由与请求参数
Nest 通过装饰器声明路由和提取请求参数:
@Controller('users')
export class UserController {
@Get() // GET /users
findAll(@Query('page') page: number) {}
@Get(':id') // GET /users/123
findOne(@Param('id') id: string) {}
@Post() // POST /users
create(@Body() dto: CreateUserDto) {}
@Patch(':id') // PATCH /users/123
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {}
@Delete(':id') // DELETE /users/123
remove(@Param('id') id: string) {}
}
装饰器与 Express 的 req 对象的映射关系:
| 装饰器 | 等价 Express 写法 |
|---|---|
@Param('id') |
req.params.id |
@Query('page') |
req.query.page |
@Body() |
req.body |
@Headers('authorization') |
req.headers.authorization |
@Req() |
req(原始请求对象) |
建议尽量避免使用 @Req()——它绕过了 Nest 的参数提取机制,使你的代码与 Express 耦合。只在确实需要访问原始请求对象时使用。
2.2 DTO 与参数校验
Nest 推荐使用 class-validator 和 class-transformer 进行声明式校验:
import { IsString, IsEmail, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
}
@Post()
create(@Body(new ValidationPipe()) dto: CreateUserDto) {
// ValidationPipe 校验失败直接抛出 BadRequestException
}
也可以全局注册,省去每个方法的手动声明:
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
whitelist: true 的效果:自动剔除 DTO 中未声明的字段——恶意请求中附加的 isAdmin: true 会被静默丢弃。
2.3 Provider 与依赖注入
Nest 将 @Injectable() 标记的类称为 Provider。DI 容器通过构造函数参数的类型来决定注入哪个实例:
@Injectable()
export class UserService {
// Nest 看到构造函数需要 PrismaService,自动从容器中找到对应的实例注入
constructor(private readonly prisma: PrismaService) {}
}
Provider 默认是单例——整个应用生命周期中只创建一个实例。大部分情况下这是正确的默认行为。
三、请求管道:Middleware、Guard、Interceptor、Pipe
Nest 将一个 HTTP 请求拆分为五个处理阶段。仅仅记住执行顺序远远不够——需要理解的是每个阶段能做什么、不能做什么,以及为什么不能互相替代。
3.1 能力矩阵
各层的能力差异来源于它们能访问的上下文不同:
Incoming Request → Middleware → Guards → Interceptors(Pre) → Pipes → Controller → Interceptors(Post) → ExceptionFilter
| 能力 | Middleware | Guard | Interceptor | Pipe |
|---|---|---|---|---|
访问原始 req / res |
✅ | ✅¹ | ✅¹ | ❌ |
| 注入 Nest Provider(DI) | 需构造 | ✅ | ✅ | ✅ |
访问 ExecutionContext |
❌ | ✅ | ✅ | ❌ |
读取装饰器元数据(Reflector) |
❌ | ✅ | ✅ | ✅² |
| 阻断请求 | ✅³ | ✅ | ✅⁴ | ❌⁵ |
| 修改请求体 | ✅ | ⚠️⁶ | ⚠️⁶ | ✅ |
| 修改响应体 | ✅ | ❌⁷ | ✅ | ❌ |
访问 Handler 参数 metatype |
❌ | ❌ | ❌ | ✅ |
¹ 通过
context.switchToHttp().getRequest()² 仅限方法参数元数据 ³ 不调用next()⁴ 不调用next.handle()⁵ 只能抛异常中断 ⁶ 技术可行但非设计意图 ⁷ 返回 false 时框架自动 403
3.2 Middleware:仅有原始请求,没有路由上下文
Middleware 接收 (req, res, next),但不知道即将执行的是哪个 Handler。「当前路由」由 Express 的路由匹配表确定,而 Nest 的 Controller 映射是后续阶段才解析的。
适合 Middleware 的是「无论后面是什么路由都要做的事」:body-parser、CORS、压缩。不适合任何涉及权限判断的逻辑。
3.3 Guard:为读取方法级元数据而生
Guard 接收 ExecutionContext,能拿到即将执行的 Handler 引用,进而读取该 Handler 上挂载的装饰器元数据:
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), // → 当前方法
context.getClass(), // → 当前 Controller
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some(role => user?.roles?.includes(role));
}
}
getAllAndOverride 的优先级是方法级高于类级——@Roles('admin') 在方法上的设置覆盖 Controller 级别的默认值。这是 Middleware 做不到的,也是 Guard 作为独立阶段存在的根本理由。
3.4 Interceptor:唯一能跨越 Controller 前后
next.handle() 返回一个 Observable,代表 Controller 执行后的结果流:
@Injectable()
export class CacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const key = context.switchToHttp().getRequest().url;
const cached = this.cache.get(key);
if (cached) return of(cached); // ← 跳过 Controller
return next.handle().pipe(
tap(data => this.cache.set(key, data)),
);
}
}
只有 Interceptor 能做的事:响应转换、缓存短路、超时控制(next.handle().pipe(timeout(5000)))。
3.5 Pipe:唯一能对参数做类型感知转换
Pipe 的 transform(value, metadata) 中的 metadata 带有参数的完整类型信息——metatype、type(param/query/body)、data(装饰器参数名)。这是唯一能在运行时根据参数类型做转换和校验的机制。
把认证放在 Pipe 里技术上可能——但它运行在 Guard 之后,未认证的请求已经穿过了 Middleware、Guard、Interceptor 才被拒绝。
3.6 决策表
| 需求 | 阶段 | 依据 |
|---|---|---|
| 请求体解析 / CORS / 压缩 | Middleware | 最早执行,与路由无关 |
| 认证 / 角色权限 | Guard | 需要 ExecutionContext + Reflector |
| 响应格式包装 / 缓存 | Interceptor | 唯一能拿到 Controller 返回值 |
| 请求耗时记录 | Interceptor | 唯一跨前后 |
| 参数校验 / 类型转换 / 默认值 | Pipe | 唯一能访问 metatype |
核心原则:把逻辑放在职责匹配的阶段,不放在技术上能碰巧工作的阶段。
四、TypeORM 与 DI 容器的集成
4.1 forRoot 与 forFeature 的隔离关系
TypeOrmModule.forRoot() 创建全局 DataSource 单例。forFeature([User, Post]) 为每个 Module 创建 Repository 实例。这些 Repository 共享同一个 DataSource,但各自的 EntityManager 是独立的。
@Injectable()
export class OrderService {
constructor(@InjectRepository(Order) private orderRepo: Repository<Order>) {}
async createOrder() {
// orderRepo 的 EntityManager 来自 OrderModule 的 forFeature
await this.orderRepo.save(order);
// 如果这里调用了 UserService.decrementBalance(),
// userRepo 的 EntityManager 来自 UserModule 的 forFeature——不同实例
}
}
这就是线上出现订单创建后库存扣减失败但订单未回滚的根本原因——两个 Repository 的事务互不可见。
4.2 事务的正确写法
注入 EntityManager,用它创建的所有 Repository 共享同一个 Manager:
@Injectable()
export class OrderService {
constructor(@InjectEntityManager() private readonly em: EntityManager) {}
async placeOrder(dto: CreateOrderDto) {
return this.em.transaction(async (manager) => {
const orderRepo = manager.getRepository(Order);
const userRepo = manager.getRepository(User);
const order = await orderRepo.save(dto);
await userRepo.decrement({ id: dto.userId }, 'balance', dto.amount);
return order;
});
}
}
@Transaction() 装饰器依赖 CLS(Continuation Local Storage)传递事务上下文,在高并发下曾出现上下文串号。推荐显式注入 EntityManager。
4.3 QueryBuilder 与 Repository API
// Repository API:单表简单 CRUD
const user = await this.userRepo.findOne({
where: { email: '[email protected]' },
relations: ['posts'],
});
// QueryBuilder:多表联查、聚合、动态条件
const users = await this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('post.comments', 'comment')
.select(['user.id', 'user.name', 'post.title', 'comment.body'])
.where('user.status = :status', { status: 'active' })
.getMany();
findOne({ relations: [...] }) 内部自动生成 LEFT JOIN,关联层级深时生成的 SQL 效率低。手动用 QueryBuilder 指定 select 字段能让查询计划更可控。
五、认证授权
5.1 JWT 签发与验证
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
async login(user: User) {
const payload = { sub: user.id, username: user.username };
return {
access_token: this.jwtService.sign(payload, { expiresIn: '15m' }),
refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}
}
JWT Strategy 的 validate 返回值自动挂载到 request.user:
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
5.2 PassportStrategy 如何接入 Nest DI
@nestjs/passport 的核心是将 Passport.js 的策略模式适配到 Nest 的 DI 容器。PassportStrategy(Strategy) 做了三件事:创建一个继承 Strategy 的匿名类;将该类注册为 Passport 的 'jwt' 策略(等同于 passport.use('jwt', new Strategy(...)));让 Nest DI 容器管理该实例。
AuthGuard('jwt') 执行时内部调用 passport.authenticate('jwt'),Passport 找到之前注册的策略实例,执行 validate,结果挂载到 request.user。
5.3 多策略共存与全局保护
登录接口用 local 策略,其余接口用 jwt 策略——策略名是 Passport 内部的路由键:
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
全局启用 JWT Guard,对公开接口打标记放行:
@Module({
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { super(); }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
context.getHandler(), context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
相比逐路由添加 @UseGuards(),全局 Guard 避免了新增接口时遗漏认证。
六、应用生命周期与生产部署
6.1 启动顺序
Nest 的启动流程按以下顺序执行:
- 实例化所有 Provider(按 Module 依赖图深度优先)
- 调用所有
OnModuleInit.onModuleInit()——所有依赖已注入,HTTP 服务器尚未监听 - 调用所有
OnApplicationBootstrap.onApplicationBootstrap()——服务器已监听,开始接收请求
如果需要在监听端口前确保数据库迁移完成,应在 main.ts 中手动控制:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const dataSource = app.get(DataSource);
await dataSource.runMigrations();
await app.listen(3000);
}
6.2 优雅关闭
app.enableShutdownHooks() 注册 SIGTERM/SIGINT 处理器:停止接收新请求 → 等待处理中的请求完成(默认 5 秒超时)→ 关闭 HTTP 服务器 → 依次调用 OnApplicationShutdown 钩子。在钩子中关闭数据库连接池、断开 Redis、停止后台任务。
6.3 未捕获异常的传播
Controller 中未被 await 的异步调用产生的 rejection,在 Express 适配器下会触发 Node.js 的 unhandledRejection 事件而非 Nest 的 ExceptionFilter——因为异常发生在 Controller 返回之后。客户端收到 200 OK,后台任务静默失败。始终 await 异步调用,或将独立失败的任务放入 @nestjs/bull 队列。
6.4 pm2 集群
module.exports = {
apps: [{ name: 'nest-app', script: './dist/main.js',
instances: 'max', exec_mode: 'cluster' }],
};
集群模式下每个 worker 有独立 V8 堆。基于内存的限流器需替换为 Redis 后端;WebSocket 连接不跨进程。
七、从 Express 迁移到 Nest.js
7.1 阶段性共存
第一阶段:只引入 DI 容器。创建 Nest 应用容器但不接管路由——零业务风险:
const app = await NestFactory.createApplicationContext(AppModule);
const userService = app.get(UserService);
app.get('/users/:id', async (req, res) => {
res.json(await userService.findById(req.params.id));
});
第二阶段:路由共存。Express 作为 Nest 的 HTTP 适配器嵌入,旧路由和新 Controller 在同一进程中按模块逐个迁移:
const server = express();
const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
await app.init(); // init 而非 listen——HTTP 由 Express 管理
server.listen(3000);
第三阶段:全部迁移完成后移除 Express,切换到 Nest 原生适配器。
7.2 类型迁移
Express 中常见的 req.user 类型扩展,在 Nest 中用自定义参数装饰器替代更安全——类型检查从「可能存在」变为「一定存在」:
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user;
},
);