Nest.js 入门指南:模块化架构、依赖注入与 TypeScript 企业级后端

Nest.js 是 Node.js 的企业级框架,受 Angular 启发。本文详解模块(Module)、控制器(Controller)、服务(Service)、依赖注入(DI)、管道(Pipe)、守卫(Guard)与拦截器(Interceptor)核心概念。

NestJS 是 Node.js 生态中最具框架感的后端解决方案,受 Angular 装饰器模式的启发,原生基于 TypeScript 设计,提供了模块化架构、依赖注入容器、面向切面编程(AOP)等工程化能力。它并非 Express 的替代品,而是构建于 Express(或 Fastify)之上的元框架,通过应用层抽象将企业级开发模式引入 Node.js。

1. Module 封装边界与模块关系图

NestJS 中的 Module 不仅是一个代码组织单位,更是依赖注入容器的边界。每个模块通过 @Module() 装饰器声明其内部的 controllers、providers、imports 和 exports。imports 引入了其他模块中 exports 的 provider,使得跨模块依赖注入成为可能;未被导出的 provider 则严格封装在模块内部,外部模块无法访问。

// users/users.module.ts
@Module({
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  imports: [TypeOrmModule.forFeature([UserEntity])],
  exports: [UsersService], // 仅导出 UsersService,UsersRepository 保持私有
})
export class UsersModule {}

// orders/orders.module.ts
@Module({
  controllers: [OrdersController],
  providers: [OrdersService],
  imports: [UsersModule], // 通过导入获得 UsersService 的注入能力
})
export class OrdersModule {}

全局模块通过 @Global() 装饰器注册,其导出的 provider 可被任意模块消费,无需显式导入。这种方式减少了深层嵌套依赖中的重复 imports,但应谨慎使用,过多全局模块会破坏模块边界的隔离性,导致模块间的隐式耦合增加。理想状态下,全局模块只应包含基础设施级别的 provider:配置服务、日志服务、数据库连接池等通用横切关注点。

@Global()
@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}

动态模块允许模块在导入时根据运行时参数配置 provider。这是实现数据库连接、缓存配置等可复用基础设施模块的核心机制。

@Module({})
export class DatabaseModule {
  static forRoot(options: DatabaseOptions): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [
        {
          provide: 'DATABASE_OPTIONS',
          useValue: options,
        },
        ConnectionFactory,
      ],
      exports: [ConnectionFactory],
    };
  }
}

2. Provider 生命周期:Singleton / Request / Transient

NestJS 依赖注入容器内置三种作用域控制,决定了 provider 实例的创建与销毁时机。

Singleton(默认):在应用启动时创建单个实例,并在整个应用生命周期中复用。适用于无状态服务、工具类、数据访问对象。Singleton 的内存占用最低,但绝不应该在其中存储请求级状态,否则会导致跨请求的数据污染。

Request:每个 HTTP 请求进入 NestJS 时,容器会为该请求创建独立的子注入器及一组专属实例。当请求结束、响应发送完毕后,Request 作用域的 provider 会被垃圾回收。这种作用域适用于需要追踪请求级上下文的场景,如多租户数据库连接切换、请求链路追踪器。

Transient:每次注入时都会生成新的实例。即使同一请求中多个 provider 依赖同一个 transient provider,每个注入点都会获得独立实例。开销最大,仅适用于极短生命周期的有状态对象。

@Injectable({ scope: Scope.REQUEST })
export class TenantConnectionService {
  private connection: DataSource;

  constructor(@Inject(REQUEST) private request: Request) {
    const tenantId = request.headers['x-tenant-id'] as string;
    this.connection = createConnectionForTenant(tenantId);
  }

  getRepository<T>(entity: EntityTarget<T>): Repository<T> {
    return this.connection.getRepository(entity);
  }
}

// 警告:注入 Request 作用域 provider 的控制器/服务
//       会自动继承 REQUEST 作用域,波及整个依赖树
@Controller('orders')
export class OrdersController {
  constructor(
    private ordersService: OrdersService,   // 若依赖了 TenantConnectionService,会自动变为 REQUEST 作用域
    private tenantConn: TenantConnectionService,
  ) {}
}

Request 作用域带来的性能损耗不可忽视:每个请求都需重新实例化依赖链上的所有 provider。应通过 AsyncLocalStorage 或自定义装饰器在 Singleton 中安全访问请求上下文,以减少作用域蔓延。例如可以将请求级别的 tenant ID 存入 AsyncLocalStorage,而数据库连接池本身保持 Singleton,仅在查询时切换 search_path 或 schema。

const als = new AsyncLocalStorage<{ tenantId: string }>();

@Injectable()
export class TenantContextService {
    setTenant(tenantId: string): void {
        const store = als.getStore();
        if (store) store.tenantId = tenantId;
    }

    getTenant(): string | undefined {
        return als.getStore()?.tenantId;
    }
}

// 在中间件中初始化上下文
app.use((req, res, next) => {
    const tenantId = req.headers['x-tenant-id'] as string;
    als.run({ tenantId }, () => next());
});

这种方式让依赖树保持在 Singleton 作用域,每个请求的上下文通过异步上下文传递,性能开销远低于完整的 Request 作用域实例化。

3. 自定义装饰器与 reflect-metadata

NestJS 的装饰器系统建立在 reflect-metadata 之上。@Injectable()、@Controller()、@Get() 等内置装饰器在编译阶段通过 Reflect.metadata 将元数据挂载到类和属性上,运行时 NestJS 通过 Reflect.getMetadata 读取这些信息,完成路由注册和依赖解析。

自定义装饰器可显著降低重复样板代码。以下示例实现了一个基于角色的权限装饰器,将角色信息写入元数据,供 Guard 读取。

import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

// 使用方式
@Controller('admin')
export class AdminController {
  @Post('users')
  @Roles('super_admin', 'user_manager')
  createUser() {
    return { created: true };
  }
}

// 对应的 Guard 实现
@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(),
    ]);
    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some(role => user.roles?.includes(role));
  }
}

组合装饰器(Composition)能进一步简化控制器声明。通过 applyDecorators 将多个常用装饰器打包为一个语义化装饰器。

import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common';

export function Auth(...roles: string[]) {
  return applyDecorators(
    Roles(...roles),
    UseGuards(JwtAuthGuard, RolesGuard),
    UseInterceptors(LoggingInterceptor),
  );
}

// 使用方式
@Controller('reports')
export class ReportsController {
  @Get('monthly')
  @Auth('finance_reader', 'admin')
  getMonthlyReport() {
    return this.reportsService.generateMonthly();
  }
}

4. Guards、Interceptors 与 Pipes 的执行顺序

在 NestJS 的请求处理管道中,三类组件承担不同职责,且以严格的顺序调用。理解它们的执行位置有助于准确定位日志记录、权限校验和数据验证的切入点。实际开发中一个常见的困惑是:为什么 Guards 先于 Interceptors 执行?这是因为 Guards 负责准入控制(CanActivate),如果请求未被授权,它应该在任何业务逻辑(包括拦截器中的前置逻辑,如审计日志或参数解密)运行之前被拒绝。Interceptors 则更多用于横切关注点(Cross-cutting Concerns),如响应转换、缓存和超时控制,这些逻辑在权限已确认后是安全的。

执行顺序如下:

  1. Middleware(全局/模块级):与 Express 中间件等价,最早介入,可操作原始 req / res 对象。
  2. Guards:在路由处理之前执行,返回值 true / false 决定是否允许请求继续。负责对身份、权限、IP 白名单等进行准入控制。
  3. Interceptors(before):在路由处理之前执行的拦截器 intercept 方法前半段。
  4. Pipes:在路由参数绑定阶段执行,负责数据转换(Transformation)和验证(Validation)。ValidationPipe 是最常用的内置管道。
  5. Controller / Provider:业务逻辑主体。
  6. Interceptors(after):拦截器的后半段,可修改返回的响应数据。
  7. Exception Filters:捕获全局或局部抛出的异常,格式化错误响应。
@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    const now = Date.now();

    return next.handle().pipe(
      map(data => ({
        data,
        timestamp: new Date().toISOString(),
        duration: `${Date.now() - now}ms`,
      })),
    );
  }
}

// 全局注册
declare module '@nestjs/common' {
  interface Response<T> {
    data: T;
    timestamp: string;
    duration: string;
  }
}

app.useGlobalInterceptors(new TransformInterceptor());

异常过滤器通过 @Catch() 声明其捕获的异常类型,可完全接管 NestJS 的默认异常响应格式。ArgumentsHost 不仅支持 HTTP 上下文,还支持 WebSocket 和 RPC 上下文,这意味着同一个 Exception Filter 可以在三种通信协议之间复用,只需通过 host.getType() 判断当前上下文类型并切换响应方式。

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message: exception.message,
    });
  }
}

5. GraphQL Code First vs Schema First

NestJS 支持两种 GraphQL 开发范式。Schema First 要求开发者手写 .graphql 文件,通过代码生成 TypeScript 类型;Code First 则基于 TypeScript 的装饰器自动生成 SDL(Schema Definition Language)。

Schema First 更适合已有 GraphQL 团队规范、需要精确控制 schema 文本的场景。NestJS 通过 @nestjs/graphql 的 forRoot 配置 typePaths 和 resolvers 来加载手写 schema。

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  typePaths: ['./**/*.graphql'],
  resolvers: { DateTime: GraphQLDateTime },
})

Code First 更符合 NestJS 装饰器驱动的心智模型:开发者使用 @ObjectType()、@Field() 和 @Resolver() 定义类型和解析器,NestJS 在启动时自动生成可执行的 schema。这种方式减少了 schema 与类型定义之间的重复,利用 TypeScript 编译器确保模型与 API 的一致性。

@ObjectType()
export class User {
  @Field(() => ID)
  id: string;

  @Field()
  email: string;

  @Field({ nullable: true })
  displayName?: string;

  @Field(() => [Post])
  posts: Post[];
}

@Resolver(() => User)
export class UsersResolver {
  constructor(private usersService: UsersService) {}

  @Query(() => User)
  async user(@Args('id') id: string): Promise<User> {
    return this.usersService.findById(id);
  }

  @ResolveField(() => [Post])
  async posts(@Parent() user: User): Promise<Post[]> {
    return this.usersService.findPostsByUserId(user.id);
  }
}

Code First 模式下,@ResolveField 用于解析对象类型的嵌套字段,自动实现 N+1 查询的批处理需要结合 DataLoader。NestJS 社区提供 nestjs-dataloader 包将 DataLoader 实例的生命周期与请求作用域绑定。

两种范式的选择没有绝对标准。Schema First 在前后端分离且已有 GraphQL Schema 为标准合同(contract)的团队中表现更好,后端严格遵循 Schema 定义,前端根据 Schema 自动生成类型安全的查询客户端。Code First 则更适合后端驱动 API 演进的场景,开发者可以用单一语言(TypeScript)维护类型系统和业务逻辑,避免 schema 文件与 TS 接口之间的二义性。但 Code First 的隐式 schema 生成可能在复杂类型联合(Union Types)和接口继承场景中生成出乎意料的 SDL,需要额外注意 @InterfaceType() 和 @UnionType() 的用法。

6. 微服务模式:TCP、gRPC 与 RabbitMQ

NestJS 的微服务封装层将底层传输协议抽象为统一的 ClientProxy 和 @MessagePattern() / @EventPattern() 接口,开发者只需关注业务消息格式,而非 Socket 或 AMQP 的底层细节。这种抽象屏蔽了不同传输协议之间的差异,但也意味着开发者必须理解每种传输的语义限制:TCP 微服务不支持原生的事件广播(fire and forget 需要特殊配置),RabbitMQ 的 request/response 模式依赖回复队列的自动管理,gRPC 的流式 RPC 需要使用 RxJS Observable 进行适配。

6.1 TCP 传输

TCP 是 NestJS 默认的微服务传输方式,适合服务间处于同一私有网络、对通信协议无强制要求的场景。启动时创建 TCP 服务端监听指定端口,客户端通过 ClientsModule 配置连接信息。

// 服务端
@Controller()
export class MathController {
  @MessagePattern({ cmd: 'sum' })
  sum(data: number[]): number {
    return data.reduce((a, b) => a + b, 0);
  }
}

const microserviceOptions: TcpOptions = {
  transport: Transport.TCP,
  options: { host: '0.0.0.0', port: 8877 },
};

const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, microserviceOptions);
await app.listen();

// 客户端
@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'MATH_SERVICE',
        transport: Transport.TCP,
        options: { host: 'math-service', port: 8877 },
      },
    ]),
  ],
})
export class AppModule {}

@Injectable()
export class AppService {
  constructor(@Inject('MATH_SERVICE') private mathClient: ClientProxy) {}

  async accumulate(data: number[]): Promise<number> {
    return lastValueFrom(this.mathClient.send<number>({ cmd: 'sum' }, data));
  }
}

6.2 gRPC 传输

gRPC 基于 HTTP/2 和 Protocol Buffers,具备强类型接口定义、双向流通信和原生负载均衡能力。NestJS 的 gRPC 微服务需要手写 .proto 文件(Schema First),然后由 @grpc/proto-loader 动态加载。

syntax = "proto3";

package hero;

service HeroService {
  rpc FindOne (HeroById) returns (Hero);
  rpc FindMany (stream HeroById) returns (stream Hero);
}

message HeroById {
  int32 id = 1;
}

message Hero {
  int32 id = 1;
  string name = 2;
}
@Injectable()
export class HeroService implements OnModuleInit {
  @Client({
    transport: Transport.GRPC,
    options: {
      package: 'hero',
      protoPath: join(__dirname, 'hero/hero.proto'),
    },
  })
  private client: ClientGrpc;

  private heroService: HeroServiceClient;

  onModuleInit() {
    this.heroService = this.client.getService<HeroServiceClient>('HeroService');
  }

  findOne(id: number): Observable<Hero> {
    return this.heroService.findOne({ id });
  }
}

TCP 微服务在生产环境中需要处理连接中断后的自动重连。NestJS 底层基于 net.Socket,默认情况下 TCP 连接一旦断开,后续的 send() 调用会抛出 ECONNREFUSED 或 EPIPE 错误。应在外层实现指数退避重试,并通过 connection pool 管理多个下游微服务实例的可用性。

@Injectable()
export class CircuitBreakerProxy {
    private failures = 0;
    private nextTry = Date.now();
    private readonly threshold = 5;
    private readonly timeout = 30000;

    constructor(@Inject('MATH_SERVICE') private client: ClientProxy) {}

    async safeSend<T>(pattern: any, data: any): Promise<T> {
        if (Date.now() < this.nextTry) {
            throw new Error('Circuit breaker is open');
        }
        try {
            const result = await lastValueFrom(
                this.client.send<T>(pattern, data).pipe(timeout(this.timeout))
            );
            this.failures = 0;
            return result;
        } catch (err) {
            this.failures++;
            if (this.failures >= this.threshold) {
                this.nextTry = Date.now() + 60000; // 熔断一分钟
            }
            throw err;
        }
    }
}

6.3 RabbitMQ 传输

RabbitMQ 通过 @MessagePattern() 实现请求/响应模式,通过 @EventPattern() 实现事件驱动模式。事件模式下,消息被发布到 exchange,不关心是否有消费者在线,天然支持服务解耦和最终一致性。

const rabbitOptions: RmqOptions = {
  transport: Transport.RMQ,
  options: {
    urls: ['amqp://guest:guest@rabbitmq:5672'],
    queue: 'orders_queue',
    queueOptions: { durable: true },
    noAck: false, // 手动确认
  },
};

// 事件消费者(事件模式:fire and forget)
@Controller()
export class OrdersEventsController {
  @EventPattern('order_created')
  async handleOrderCreated(data: OrderCreatedEvent) {
    await this.notificationService.sendConfirmationEmail(data.userId, data.orderId);
  }
}

// 发布事件
this.clientProxy.emit('order_created', { userId, orderId, total });

对于需要可靠投递的场景,应关闭 noAck 并在业务处理完成后手动调用 channel.ack()。若处理失败,消息会重新进入队列或进入死信队列(DLQ),防止消息丢失。

@EventPattern('order_created')
async handleOrderCreated(
  @Payload() data: OrderCreatedEvent,
  @Ctx() context: RmqContext,
) {
  const channel = context.getChannelRef();
  const originalMsg = context.getMessage();

  try {
    await this.processOrder(data);
    channel.ack(originalMsg);
  } catch (err) {
    channel.nack(originalMsg, false, false); // 不重新入队,进入死信队列
    this.logger.error(`Order processing failed: ${err.message}`);
  }
}
传输协议模式序列化流支持适用场景
TCP请求/响应JSON无内网同构服务通信
gRPC请求/响应 + 双向流Protobuf原生支持跨语言服务网格、高性能 RPC
RabbitMQ请求/响应 + 事件JSON / MessagePack无异步事件驱动、削峰填谷

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈