《TypeScript编程实战》6.1 模块、提供者与依赖注入

本节讲解 NestJS 的模块、提供者与依赖注入三件套如何协作:用 @Injectable 与构造函数声明依赖,用 @Module 划清边界,用自定义提供者与注入令牌替换实现,并处理循环依赖与作用域陷阱。读完本节,你能为真实后端项目设计出层次清晰、可测试、可替换的模块结构,也能看懂任意一个 NestJS 项目的装配图。

本节目标:理解 NestJS 的模块(Module)、提供者(Provider)与依赖注入(DI)三者如何协作;能熟练使用 @Injectable、构造函数注入、自定义提供者(useClass / useValue / useFactory / useExisting)与注入令牌;能划清模块边界、处理循环依赖,并说清楚为什么 DI 让单元测试变得轻松。读完本节,你能为真实后端项目设计出层次清晰、可测试、可替换的模块结构。

6.1 模块、提供者与依赖注入

在第 5 章我们用 Fastify 手写 HTTP 服务与中间件时,所有依赖都是自己 new 出来的。项目一大,手动装配就会失控:谁依赖谁、谁先初始化、测试时怎么把数据库换成假实现,全变成人肉维护。NestJS 给出的答案是依赖注入容器——你只声明「我需要什么」,由框架负责「给你什么」。本节把模块、提供者、DI 三个概念拆开讲透。如果你对装饰器语法还不熟,可以先看 装饰器与元编程 ,NestJS 的整套语法都建立在它之上。

6.1.1 从手动装配说起

先看不使用 DI 的写法,它的问题一眼可见:

class UserRepository {
  async findById(id: string) {
    return { id, name: "小明" };
  }
}

class UserService {
  private repo = new UserRepository(); // 硬编码依赖

  async getProfile(id: string) {
    return this.repo.findById(id);
  }
}

这里的 UserService 被永久绑死在 UserRepository 上:单元测试时你无法注入一个「返回固定数据的假仓库」,除非去改源码。依赖注入要解决的就是这个耦合。它其实只有两个动作:

  1. 声明依赖:UserService 在构造函数里说「我需要一个 UserRepository」。
  2. 解析依赖:由一个容器在运行时负责创建并把实例塞进去。

NestJS 的容器叫 IoC 容器(Inversion of Control)。这套思想并非 NestJS 独创,Java 的 Spring IoC 容器 是同一模式的经典实现,对照阅读能更快建立直觉。

6.1.2 第一个模块与提供者

NestJS 里一切都是围绕**模块(Module)**组织的,模块是最小的装配单元。一个最小的模块长这样:

import { Module } from "@nestjs/common";
import { UserService } from "./user.service";
import { UserController } from "./user.controller";

@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

@Module 装饰器的元数据有四个关键字段,它们的含义必须记牢:

字段作用常见坑
imports导入其他模块,拿到它们 exports 出来的提供者只 imports 不等于能用自己的 provider
providers本模块内可被注入的类(服务、工厂、策略等)未注册的类无法注入
controllers本模块暴露的路由控制器控制器也走 DI,可注入本模块 provider
exports允许被其他模块使用的提供者子集不写 exports,别的模块就注入不到

其中 provider 是 DI 的核心。任何被 providers 注册的类,都成为容器可解析的「可注入项」。

6.1.3 @Injectable 与构造函数注入

让一个类可被注入,需要 @Injectable() 装饰器,并在构造函数里声明依赖:

import { Injectable } from "@nestjs/common";

@Injectable()
export class UserService {
  constructor(private readonly repo: UserRepository) {}

  async getProfile(id: string) {
    return this.repo.findById(id);
  }
}

private readonly repo: UserRepository 用的是 TypeScript 的参数属性写法,一行同时完成「声明字段 + 收参数 + 赋值」。NestJS 靠 reflect-metadata 读取构造函数参数的类型,从而知道该注入哪个类的实例。因此有三个前提缺一不可:

  • tsconfig.json 里开启 emitDecoratorMetadata: true 与 experimentalDecorators: true;
  • 入口文件(通常是 main.ts)顶部 import "reflect-metadata";
  • 被注入的类本身也在某个模块的 providers 里注册过。

漏掉任何一条,运行时会抛出经典错误:

Nest can't resolve dependencies of the UserService (?).
Please make sure that the argument UserRepository at index [0] is available in the UserModule context.

看到这句,先检查三件事:UserRepository 有没有进 providers?它是从别的模块来的、那个模块有没有 exports?当前模块有没有 imports 那个模块?这三步排查能解决九成的注入失败。

6.1.4 模块的边界:imports 与 exports

模块不是可有可无的分组,而是可见性边界。一个 provider 默认只在本模块内可见,想跨模块使用,必须在来源模块 exports、在消费模块 imports:

// database.module.ts
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService], // 对外暴露
})
export class DatabaseModule {}

// user.module.ts
@Module({
  imports: [DatabaseModule], // 才能注入 DatabaseService
  providers: [UserService],
  controllers: [UserController],
})
export class UserModule {}

一个常被忽略的点:模块之间不会自动传递 exports。若 A 导入了 B,B 导入了 C,A 并不能直接注入 C 的 provider——除非 B 把 C 重新 exports 出去(这叫「再导出」)。合理的做法是让核心模块(如 DatabaseModule、ConfigModule)设为全局,避免每个模块都写一遍 imports:

import { Global, Module } from "@nestjs/common";

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}

@Global() 只建议用在真正的基础设施上。滥用全局模块会让依赖关系变得隐形,反而损害可维护性。

6.1.5 自定义提供者

大多数时候,providers: [UserService] 这种「类提供者」就够了——容器会 new UserService() 并注入其构造参数。但真实工程常需要更灵活的方式,NestJS 提供四种:

useClass:替换实现

@Module({
  providers: [
    { provide: UserRepository, useClass: PostgresUserRepository },
  ],
})
export class UserModule {}

当 UserRepository 是抽象类或接口标记时,可以让容器在需要它时实例化具体实现。测试环境换成 InMemoryUserRepository 只需改这一处。

useValue:注入固定值

const provider = { provide: "APP_VERSION", useValue: "1.2.0" };

适合注入配置常量、外部客户端实例、mock 对象。

useFactory:按需构造,可依赖其他 provider

const provider = {
  provide: "REDIS_CLIENT",
  useFactory: (config: ConfigService) => {
    return createClient({ url: config.get("REDIS_URL") });
  },
  inject: [ConfigService],
};

工厂提供者通过 inject 显式声明参数依赖;useClass 的类也可通过构造函数声明依赖,inject 数组里的 provider 会先被解析再作为参数传入。异步初始化也在这里做(返回 Promise 即可)。

useExisting:别名

const provider = { provide: "AliasService", useExisting: UserService };

它不会创建新实例,而是让两个令牌指向同一个对象,常用于「新老接口名并存」的过渡期。

6.1.6 注入令牌与 @Inject

类作为令牌是常态,但当提供者不是类时(字符串、Symbol、接口),构造函数参数的类型信息不足以让容器找到它,必须显式用 @Inject 指定令牌:

import { Inject, Injectable } from "@nestjs/common";

@Injectable()
export class CacheService {
  constructor(
    @Inject("REDIS_CLIENT") private readonly redis: RedisClient,
  ) {}
}

这里 RedisClient 只是给 TypeScript 看的类型,真正起作用的是字符串令牌 "REDIS_CLIENT"。强烈建议把令牌集中定义成常量或 Symbol,避免散落各处的字符串字面量拼错:

export const REDIS_CLIENT = Symbol("REDIS_CLIENT");

用 Symbol 做令牌还能天然避免命名冲突,是生产项目的推荐做法。

6.1.7 循环依赖与 forwardRef

两个模块或两个服务互相依赖时,容器会陷入「先有鸡还是先有蛋」的死循环,报错如下:

Error: Nest cannot create the UserModule instance.
The module at index [0] of the UserModule "imports" array is undefined.
Potential causes:
- A circular dependency between modules. Use forwardRef() to avoid it.

解决办法是用 forwardRef 把「立即求值」推迟到「运行时求值」:

// 服务层
@Injectable()
export class UserService {
  constructor(
    @Inject(forwardRef(() => AuthService))
    private readonly auth: AuthService,
  ) {}
}

// 模块层
@Module({
  imports: [forwardRef(() => AuthModule)],
})
export class UserModule {}

但请注意:forwardRef 是止痛药,不是解药。服务之间互相依赖通常意味着职责划分有问题——把共享逻辑抽到第三个服务里,让依赖变成单向的,才是更健康的设计。把它当作代码坏味道的信号。

6.1.8 作用域与常见陷阱

NestJS 的 provider 默认是单例(singleton):整个应用生命周期内只创建一次,所有请求共享。此外还有两种作用域:

作用域行为代价
DEFAULT(单例)全局唯一实例无
REQUEST每个请求一个实例沿依赖链向上传播,性能开销大
TRANSIENT每次注入都新建实例数不可控

用 @Injectable({ scope: Scope.REQUEST }) 声明。要特别注意作用域会「传染」:若 A(单例)依赖 B(请求级),A 会被强制升级为请求级,一路向上,最终可能让整条链都变成请求级,显著拖慢性能。

因此不要用请求作用域来存「当前用户」这类请求数据。正确做法是用 AsyncLocalStorage 承载请求上下文——第 5 章 中间件与请求上下文 已经讲过这套模式,可以无缝迁移到 NestJS。

6.1.9 测试中的依赖替换

DI 最大的回报是可测试性。因为依赖是从构造函数进来的,测试里可以直接传入替身(stub / mock),无需任何框架魔法:

import { describe, it, expect, vi } from "vitest";
import { UserService } from "./user.service";

describe("UserService", () => {
  it("返回用户资料", async () => {
    const repo = { findById: vi.fn().mockResolvedValue({ id: "1", name: "小明" }) };
    const service = new UserService(repo as any);

    await expect(service.getProfile("1")).resolves.toEqual({
      id: "1",
      name: "小明",
    });
    expect(repo.findById).toHaveBeenCalledWith("1");
  });
});

如果需要更贴近容器真实行为的测试,可以用 @nestjs/testing 的 Test.createTestingModule,只覆写关心的 provider:

const moduleRef = await Test.createTestingModule({
  providers: [
    UserService,
    { provide: UserRepository, useValue: fakeRepo },
  ],
}).compile();

const service = moduleRef.get(UserService);

这正是第 4 章 Vitest 单元测试 里强调的「依赖注入换实现」在 NestJS 场景的落地。把每个 service 的依赖都显式声明出来,等价于给测试预留了所有接缝。

小结

  • 模块是装配与可见性的最小单元:imports 拿别人的 exports,providers 注册可注入项,exports 决定对外可见性,缺一不可。
  • @Injectable + 构造函数参数属性是最常用的注入方式,它依赖 emitDecoratorMetadata 与 reflect-metadata,两者缺失会在运行时抛「Nest can’t resolve dependencies」。
  • 四种自定义提供者各有其位:useClass 换实现、useValue 注固定值、useFactory 按需构造且可声明依赖、useExisting 做别名。
  • 非类令牌必须用 @Inject,建议用 Symbol 定义令牌,杜绝字符串拼写错误。
  • forwardRef 只是权宜之计,循环依赖往往是职责划分不当的信号,优先重构而非掩盖。
  • 作用域会沿依赖链传染,请求级 provider 会拖慢整条链;请求数据请用 AsyncLocalStorage,而不是请求作用域。
  • DI 的最终回报是可测试性:依赖从构造函数进来,测试里直接注入替身即可,这是「面向接口编程」在工程里的具体收益。
  • 服务之间「谁能进、谁先跑」已经清楚了,接下来要解决的是「请求进来后如何被校验、放行与包装」。下一节 管道、守卫与拦截器 就来补齐这条请求处理流水线。

阅读导航:上一节:5.3 优雅关闭与健康检查 · 下一节:6.2 管道、守卫与拦截器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes