12k
All articles

使用 NestJS 构建大型 Node.js 应用

了解如何按功能组织大型 NestJS 应用,涵盖模块、控制器、服务、DTO 验证、守卫、配置和测试,并通过显式导入与导出管理依赖。

OpenReplay Team
OpenReplay Team
使用 NestJS 构建大型 Node.js 应用

大型 NestJS 应用应按功能(feature)组织代码:为每个领域(users、orders、billing)分配独立的模块目录,存放其控制器、服务、DTO 和测试;模块之间只能通过显式的 exports 和 imports 共享代码。

如果你接手过一个不断膨胀的 Express API,下面这些症状一定不陌生:一个路由文件同时负责鉴权、校验和数据库写入,process.env 在十几个地方被直接读取,没人能确定哪个工具函数可以放心修改。

本文将构建一个小型的用户与订单 API,把每个部分放到它该在的位置:模块、控制器、提供者、校验、守卫、配置、共享模块以及单元测试。如果你还在挑选框架,可以参考《JavaScript 有没有自己的 Rails?》一文中对各种方案的比较。

核心要点

  • NestJS 模块默认将其提供者设为私有。只有当所属模块导出该提供者、且使用方模块导入了所属模块时,其他模块才能注入它。
  • 按功能组织代码,可以把每个领域的模块、控制器、服务、DTO 和测试集中在同一目录中,这样对订单的修改只会局限在 src/orders/ 之内。
  • 启用 whitelist 后,ValidationPipe 会剔除没有校验装饰器的属性。再加上 forbidNonWhitelisted,这类请求将直接失败。
  • 守卫返回 false 时,Nest 会抛出 ForbiddenException,因此鉴权逻辑可以集中在一个类中,而不必分散在每个处理函数里。
  • 模块之间未解决的循环导入会导致 Nest 在启动阶段失败。相比使用 forwardRef(),把共享的概念抽离出来是更好的解决办法。

问题所在:毫无规则的 Express 应用

Express 对代码放在哪里没有任何约束,因此随着应用增长,其结构往往取决于最初几个处理函数碰巧是怎么写的。大多数项目的起点都和《使用 TypeScript 和 Express 搭建 Node.js 项目》中描述的一样:一个 app.ts、一个路由目录,再加一些中间件。几个月后,处理函数就会变成这样:

app.post('/orders', (req, res) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
  const { userId, items } = req.body;
  if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
  const user = users.find((u) => u.id === userId);
  if (!user) return res.sendStatus(404);
  orders.push({ id: orders.length + 1, userId, items });
  res.status(201).json(orders.at(-1));
});

这个处理函数混杂了五种关注点。NestJS 为每一种都提供了固定的归属:

Express 中的习惯做法NestJS 中的对应结构
路由文件控制器(Controller)
包含业务逻辑的工具模块提供者(服务)
在中间件或处理函数中做鉴权检查守卫(Guard)
读取 process.envConfigService
手动检查 req.bodyDTO + 校验管道

当然,这一切都是有代价的。对于只有寥寥几个路由的 API 来说,NestJS 的装饰器、模块和 DI 容器只是不必要的繁文缛节。只有当多人各自修改某个功能、且无需通读整个代码库时,这些设计才开始体现价值。

如何组织大型 NestJS 项目的结构?

可维护的 NestJS 项目结构的核心是:每个功能一个目录,每个目录有自己的模块。采用这种布局后,修改订单相关逻辑只需打开一个目录,而不必在分散的 controllers/、services/ 和 dto/ 目录树中来回翻找。

src/
├── users/
│   ├── dto/create-user.dto.ts
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.service.spec.ts
│   └── users.module.ts
├── orders/
│   ├── dto/create-order.dto.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.service.spec.ts
│   └── orders.module.ts
├── shared/
│   ├── api-key.guard.ts
│   ├── audit-log.service.ts
│   └── shared.module.ts
├── app.module.ts
└── main.ts

NestJS 模块默认会封装其提供者。OrdersService 依赖 UsersService,因此 UsersModule 必须将其导出:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [SharedModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [UsersModule, SharedModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

依赖图是单向的:OrdersModule → UsersModule → SharedModule。凡是 UsersModule 没有导出的内容都属于内部实现,正因如此,你才能放心地重构某个功能。

NestJS 中的控制器、提供者和依赖注入是什么?

控制器负责将 HTTP 路由映射到方法。提供者是任何标注了 @Injectable()、可由 Nest 创建并注入到其他类中的类。依赖注入是指:类通过构造函数参数声明自己需要什么,由框架提供相应的实例。同样的机制也使得测试可以传入一个替身(fake),而不是真实的依赖。

// users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface User { id: number; email: string }

@Injectable()
export class UsersService {
  private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];

  findOne(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';

@Injectable()
export class OrdersService {
  private readonly orders: Array<CreateOrderDto & { id: number }> = [];

  constructor(
    private readonly users: UsersService,
    private readonly audit: AuditLogService,
  ) {}

  create(dto: CreateOrderDto) {
    if (!this.users.findOne(dto.userId)) {
      throw new NotFoundException(`User ${dto.userId} not found`);
    }
    const order = { id: this.orders.length + 1, ...dto };
    this.orders.push(order);
    this.audit.record('order.created', order.id);
    return order;
  }
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';

@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.orders.create(dto);
  }
}

控制器只是把请求转换为一次服务调用。“用户必须存在”这条规则放在服务中,其他调用方可以复用它,测试也能直接覆盖它。NotFoundException 等内置 HTTP 异常会被转换为相应的错误响应。

NestJS 中的 DTO 和校验管道是如何工作的?

DTO(数据传输对象)是描述请求体结构的类。与校验管道配合使用时,它能在控制器执行之前拒绝非法输入。如果你手头有示例载荷,可以先用 JSON 转 TypeScript 工具生成初始类型,然后再添加 class-validator 装饰器:

// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';

export class CreateOrderDto {
  @IsInt()
  userId!: number;

  @IsArray()
  @ArrayNotEmpty()
  @IsString({ each: true })
  items!: string[];
}

在 main.ts 中全局注册一次管道:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );
  await app.listen(3000);
}
bootstrap();

校验文档对每个选项的作用都有定义。whitelist 会剔除没有装饰器的属性;forbidNonWhitelisted 会把这种静默剔除改为返回错误响应;transform 会将载荷转换为 DTO 类的实例。ValidationPipe 依赖 class-validator 和 class-transformer。

2026 年 8 月发布的 NestJS 12 新增了第二种方式:StandardSchemaValidationPipe,它可以校验通过 @Body({ schema }) 传入的 Zod、Valibot 或 ArkType schema。单独使用时,schema 选项只是把 schema 记录在参数上;只有注册了该管道之后才会真正执行校验,例如 app.useGlobalPipes(new StandardSchemaValidationPipe())。

如果没有注册管道,前端会第一时间暴露问题。在依赖 API 的 UI 的会话回放(session replay)中,用户的输入、格式错误的请求以及笼统的错误提示会并排呈现,这清楚地表明修复应当放在 DTO 这一边界上。

守卫和配置如何处理鉴权与环境变量?

守卫是实现了 CanActivate 接口的 @Injectable() 类。它在路由处理函数之前运行,决定请求是否继续执行。如果返回 false,Nest 会抛出 ForbiddenException(403)。结合 @nestjs/config,守卫可以从 ConfigService 而非 process.env 中读取密钥:

// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private readonly config: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
  }
}

读取 request.headers 在 Express 和 Fastify 适配器上都能正常工作。在 AppModule 中,应在启动时校验环境变量,让缺失的密钥在启动阶段就报错,而不是等到第一个请求到来时才暴露。从 NestJS 12 开始,validationSchema 可以接受任意 Standard Schema 对象,例如 Zod schema;更早的版本则要求使用 Joi schema:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: z.object({ API_KEY: z.string().min(1) }),
    }),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

配置是全局标记唯一值得使用的地方,因为几乎每个模块都要读取它。

共享模块与依赖替换测试

跨功能使用的代码应放入一个共享模块并由其导出。每个功能模块再导入这个共享模块,这样所有依赖都会清晰地出现在 imports 数组中。而将模块标记为 @Global() 则会把这层依赖关系隐藏起来:

// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';

@Module({
  providers: [AuditLogService, ApiKeyGuard],
  exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}

依赖注入的价值在测试中体现得最为明显。Test.createTestingModule 可以构建一个小型模块,并用普通对象替换真实的提供者:

// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';

describe('OrdersService', () => {
  it('throws when the user does not exist', async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: UsersService, useValue: { findOne: () => undefined } },
        { provide: AuditLogService, useValue: { record: () => {} } },
      ],
    }).compile();

    const service = moduleRef.get(OrdersService);
    expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
  });
});

从 NestJS 12 开始,nest new 会为 ESM 项目配置 Vitest,为 CommonJS 项目配置 Jest。使用 Vitest 时,除非启用了 globals,否则需要从 vitest 中导入 describe、it 和 expect。如果要针对真实数据库进行集成测试,可参考《使用 Docker Compose 运行 NestJS》中的配置方法。

大型 NestJS 代码库中有哪些常见错误?

大型 NestJS 应用中的结构性问题大多源于以下三种模式:

  • 模块间的循环依赖。 如果 UsersModule 和 OrdersModule 相互导入,Nest 会因无法创建模块实例而在启动阶段失败。forwardRef() 可以化解这种循环,但把共享概念移到独立的模块中,才能从根本上消除它。
  • 臃肿的 AppModule。 根模块应该只导入功能模块和全局配置,而不是自己声明几十个控制器和提供者。
  • 把逻辑写在控制器里。 一旦控制器开始做决策,这些规则就无法复用,也无法在脱离 HTTP 的情况下进行单元测试。让每个控制器方法只做一次服务调用。

结语

当每个功能都位于独立的模块中、模块之间只通过显式导出相互关联、控制器保持精简时,大型 NestJS 应用就能始终保持可控。从你当前的 API 中挑选一个路由,比如 POST /orders,把它的业务规则移到服务中,把请求体检查移到 DTO 中,把鉴权移到守卫中,然后按照这一模式逐个功能地推进。

常见问题

NestJS 的鉴权逻辑应该放在中间件还是守卫中?

与路由相关的鉴权应放在守卫中。中间件在 Nest 选定路由处理函数之前运行,因此只能看到原始的请求和响应对象,无法获知控制器、处理函数或装饰器元数据。守卫在所有中间件执行完毕后、任何拦截器或管道之前运行,并且会接收到一个 ExecutionContext,通过它可以访问处理函数及其所属的类。对于适用于所有请求的工作(例如日志记录),中间件仍然是合适的选择。

是什么导致了 Nest can't resolve dependencies 错误?

该错误表示 Nest 在错误信息所指的模块中,找不到某个构造函数参数对应的提供者。通常是因为该提供者既不在该模块的 providers 数组中,也没有被其 imports 数组中的任何模块导出。解决方法是在拥有该提供者的模块中将其导出,并导入该模块。此外,还要检查是否有标注了 @Injectable() 的类被误放进了 imports 数组。

为什么即使启用了 transform,请求体中的数字字符串仍然无法通过 @IsInt 校验?

当 transform 设为 true 时,ValidationPipe 会把 JSON 请求体转换为 DTO 类的实例,但不会改变原始值的类型。以 '30' 形式发送的 userId 仍然是字符串,因此无法通过 @IsInt 校验。你可以让客户端发送真正的数字,或者在 transformOptions 中将 enableImplicitConversion 设为 true。隐式转换也有它自己的陷阱:对于布尔字段,任何非空字符串(包括 'false')都会被转换为 true。

如何在 NestJS DTO 中校验嵌套对象或对象数组?

为该属性添加 class-validator 中的 @ValidateNested() 以及 class-transformer 中的 @Type(() => ItemDto)。如果没有 @Type,嵌套值将保持为普通对象,ItemDto 上的校验装饰器永远不会执行。对于对象数组,应将 @ValidateNested({ each: true }) 与同样的 @Type 装饰器搭配使用。嵌套的 DTO 类需要有自己的 class-validator 装饰器,此时 whitelist 也会同样作用于其属性。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.