Nest.js 开发指南

上篇聊了为什么从自研框架转向 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 的启动流程按以下顺序执行:

  1. 实例化所有 Provider(按 Module 依赖图深度优先)
  2. 调用所有 OnModuleInit.onModuleInit()——所有依赖已注入,HTTP 服务器尚未监听
  3. 调用所有 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;
  },
);