跳转到主要内容

Nest 使用笔记

第三章:IoC 与依赖注入——为什么构造函数里写个类型就能直接用

打开 learnhub 的 PostService(7 个真实依赖)和 RedisModule(useFactory),在真实代码里讲透 IoC 容器怎么工作、provider 的四种形式、单例与 REQUEST scope 的取舍、为什么 ConfigService 能随处注入。每段代码都指向 learnhub 的真实文件。

  • Nest
  • IoC
  • 入门

前两章里你可能有个疑惑:controller 里只是写了 constructor(private readonly helloService: HelloService),从来没 new HelloService(),它怎么就能用了?这一章解开这个谜——它叫 IoC(控制反转)/ 依赖注入,是 Nest 的核心。和「写个 HelloService 演示一遍注入」不同,这一章直接打开 learnhub 已经在生产里跑的 PostService——它的构造函数挂着 7 个依赖,比任何玩具都能说清 IoC 到底解决了什么。

先搞懂:IoC 是什么 / 为什么需要 / Nest 怎么做

IoC 是什么:IoC(Inverse of Control,控制反转)的思路一句话能说清——把「创建和组装对象」的活交给一个容器,你只声明「我需要什么」,容器负责 new、负责把依赖传进去。这种「被动接收依赖」的方式叫 依赖注入(DI,Dependency Injection)。控制「反转」在哪:对象的创建不再由你自己控制,而是反转给容器。

为什么需要:手写 new 在小项目里没问题,对象一多就三个痛点一起爆——

  • 你得记牢谁依赖谁、先 new 哪个、是不是单例,组装逻辑散得满项目都是;
  • 改一个依赖要动一堆地方(比如 EmailService 多要一个参数,所有 new EmailService(...) 都得改);
  • 配置(数据库账号、Redis 地址)没地方统一管,容易硬编码进代码。

Nest 怎么做(三步走,下面每一步都在 learnhub 真做一遍):

  1. 标记可注入:类上加 @Injectable(),告诉容器「这个类可以被注入、也可以注入别人」;
  2. 登记进容器:在 module 的 providers 里写上它,容器才会管它;
  3. 构造函数声明依赖:在 controller/service 的构造函数参数里写「我需要 X」,容器启动时自动 new X、传进来。

容器实例化 provider 时默认是单例(DEFAULT scope)——整个应用只 new 一次,所有注入方共享同一个实例。这一点很关键,下面第四步讲为什么 learnhub 全程用单例。

这一章你会做出什么

  • 用一个「手动 new」的反面例子,亲眼看到不用 IoC 有多烦。
  • 打开 learnhub 的 PostService(7 个真实依赖),看 IoC 怎么撑起一个非玩具的业务类。
  • RedisModule 真实代码里吃透 useFactory:动态创建 provider、用别的 provider 当参数、字符串 token 怎么注入。
  • 理解 provider scope:DEFAULT(单例)vs REQUEST(每请求一个)的取舍,为什么 learnhub 全用 DEFAULT。
  • 搞懂 ConfigService 为什么随处可注入(全局动态模块)。

前置:第一、二章的 hello-nest 能正常跑,learnhub 代码已拉到本地能打开看。

第一步:先看痛点——手动 new 有多烦

假设 UserService 要发邮件,依赖一个 EmailServiceEmailService 自己又要读 SMTP 配置。没有 IoC 时你得自己组装:

// 反面教材:不用 IoC,手动创建和组装(别学这个)
const config = { host: "smtp.qq.com", port: 587, user: "账号", pass: "授权码" };
const emailService = new EmailService(config);
const userService = new UserService(emailService); // 手动把依赖传进去
userService.register("小明");

对象一多,问题就来了:你得记住谁依赖谁、先 new 哪个、是不是单例、配置从哪来……代码里到处是 new 和组装逻辑,改一个依赖要动一堆地方。

IoC 的解法是:你只写 constructor(private email: EmailService),剩下交给容器。一张图对比两种写法:

flowchart TB subgraph A["不用 IoC:自己 new 自己组装"] direction LR U1["UserService"] -->|"主动 new"| E1["EmailService"] end subgraph B["用 IoC:容器负责创建和注入"] direction LR IoC["IoC 容器"] -->|"new 并注入"| E2["EmailService"] IoC -->|"new 并注入"| U2["UserService"] end

第二步:声明依赖——打开 learnhub 的 PostService

这是和「写个 HelloService 演示」差距最大的一节。learnhub 的 PostService 是一个真实在跑的业务类,它的构造函数挂了 7 个依赖——比任何玩具都能说明 IoC 在干嘛。

// learnhub/src/modules/post/post.service.ts
@Injectable()
export class PostService {
  constructor(
    @InjectRepository(Post) private readonly postRepo: Repository<Post>,
    @InjectRepository(Tag) private readonly tagRepo: Repository<Tag>,
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly userService: UserService,
    private readonly ranking: RankingService,
    private readonly amqp: AmqpService,
    private readonly searchService: SearchService,
  ) {}
  // ...
}

7 个形参,逐个看容器是怎么把它们塞进来的——

  • postRepo / tagRepoRepository<Post> / Repository<Tag> 不是普通的 service,是 TypeORM 的仓库。@InjectRepository(Post) 这种类 token 之外的特殊 token 是必须的——因为同一个 Repository<T> 泛型,Post 和 Tag 各有一份,必须用这个装饰器指定是哪个实体的。这些 repo 是在 PostModule.imports 里的 TypeOrmModule.forFeature([Post, Tag]) 注册进容器的(细节第八章讲)。
  • dataSource:TypeORM 的 DataSource 本体,事务要用(create 方法靠它)。@InjectDataSource() 同样是个特殊装饰器。
  • userService / ranking / amqp / searchService:四个普通的 service,没有加任何装饰器。这是因为它们的类型本身就是 class——Nest 拿 TS 反射出的类型当 token,去容器里找同名 class 的 provider,找到就注入。class token 的注入可以省 @Inject(...),写起来最干净。

这 7 个依赖分两组:TypeORM 那三个靠装饰器注入(特殊 token),后四个靠类型注入(class token)。背后是同一个机制——容器在 new PostService 之前,先扫构造函数的参数列表,一个个去 providers 里找对应 token,全部就绪后一次性 new PostService(...)。任何一个找不到,启动时就报 Nest can't resolve dependencies of PostService (...),连不上就是连不上,不会等到运行时才炸。

PostService 自己是怎么被容器管的?三件事,缺一不可:

(1)类标了 @Injectable():见上面代码顶部,告诉容器「我可以被 new、可以注入别人」。

(2)在 module 的 providers 里登记

// learnhub/src/modules/post/post.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Post, Tag]), UserModule, RankingModule],
  controllers: [PostController],
  providers: [PostService], // 登记:容器要管这个对象
})
export class PostModule {}

providers: [PostService] 是简写,完整形式是 { provide: PostService, useClass: PostService }——意思是「需要 PostService 这个 token 时,容器 new 一个 PostService 类的实例」。

(3)容器启动时自动 new:Nest 启动时做这件事——扫到 PostController 构造函数需要 PostService → 去 PostModule.providers 里找 → 看到 PostServiceuseClass → 自动 new PostService(...) → 注入到 controller。你从没写过 new PostService(),但它就是能用

注意 PostModule.imports 里还有 UserModuleRankingModule——它们各自 export 了 UserServiceRankingService,PostModule 才能拿到这两个依赖注入给 PostService。跨模块共享 provider 是第四步讲的 exports / imports 机制;AmqpService / SearchService 来自被标了 @Global() 的模块,所以不用写进 imports(第五步讲为什么)。

思考:如果 PostService 明天要加一个新依赖(比如新写的 NotificationService),你具体要改什么?——只在构造函数里加一行 private readonly notify: NotificationService前提NotificationService 已经在某个 module 的 providers 里登记、并且 PostModule 能看到它(要么 PostModule 自己 import 那个模块,要么那个模块是 @Global())。符合这两条,容器自动 new 它、传进来,业务代码一行不改。这就是 IoC 的核心好处:加依赖只改一行,组装逻辑一行不写

第三步:provider 的四种形式——以 RedisModule 为锚

平时写 providers: [PostService] 是简写,等价于 { provide: PostService, useClass: PostService }。完整形式有四种,先看一张表,下面用 learnhub 真实代码展开 useFactory

形式写法用途
useClass{ provide: PostService, useClass: PostService }默认,容器帮你 new 这个类
useValue{ provide: "TOKEN", useValue: { ... } }注入一个现成对象 / 常量
useFactory{ provide: "TOKEN", useFactory: (dep) => ..., inject: [Dep] }动态创建,可异步、可用别的 provider 当参数
useExisting{ provide: "B", useExisting: "A" }给已有 provider 起个别名

learnhub 真在用的是 useClass(到处都是)和 useFactory(接外部系统的地方都用它);useValue 项目里没用——配置走 ConfigService(更灵活,第五步讲),常量没必要单独做成 provider;useExisting 是给别名用的边角,知道有这么个东西就行。

useFactory:动态创建——以 RedisModule 为锚

凡是「provider 的实例要靠运行时才能造出来」的场景,都用 useFactory。最典型就是接外部系统的客户端:Redis、MinIO、Elasticsearch——它们的构造参数(host、port、密码)要从 ConfigService 读,而 ConfigService 自己也是个 provider,得等 IoC 容器起来才能注入。learnhub 的 RedisModule 就是一个干净的最小样本:

// learnhub/src/modules/redis/redis.module.ts
@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: REDIS_CLIENT,          // token 是个字符串常量
      inject: [ConfigService],        // 工厂函数的依赖:ConfigService
      useFactory: (config: ConfigService) =>
        new Redis({                    // 工厂函数:用 ConfigService 读配置、new 出 ioredis 客户端
          host: config.get<string>("redis.host") || "127.0.0.1",
          port: config.get<number>("redis.port") || 6379,
          retryStrategy: (times) => Math.min(times * 500, 3000),
          maxRetriesPerRequest: null,
          enableOfflineQueue: true,
        }),
    },
    RedisService,
  ],
  exports: [RedisService],
})
export class RedisModule {}

读这张 provider 注册的几个要点——

  • token 是字符串REDIS_CLIENT,定义见 redis.constants.ts 里的 export const REDIS_CLIENT = 'REDIS_CLIENT')。class token(比如 PostService)Nest 能用 TS 类型反射自动匹配,字符串 token 必须显式 @Inject(...),下面马上看。
  • inject: [ConfigService]:工厂函数依赖 ConfigService。容器会先确保 ConfigService 就绪,再把它作为参数传给 useFactory。这条 inject 顺序和 useFactory 的形参顺序一一对应——要两个依赖就写 inject: [A, B],形参 (a, b) => ...
  • useFactory 可以是 async:要 await 初始化(比如连数据库握手)就在工厂里 return await ...。Nest 会等工厂的 Promise resolve 再继续装配下游。learnhub 这里是同步返回 client、靠 ioredis 内部重连,所以没写 async。

字符串 token 怎么注入:必须 @Inject

RedisService 消费这个 REDIS_CLIENT

// learnhub/src/modules/redis/redis.service.ts
@Injectable()
export class RedisService implements OnModuleDestroy {
  constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}

  async onModuleDestroy(): Promise<void> {
    await this.client.quit().catch(() => undefined);
  }
  // ...
}

@Inject(REDIS_CLIENT) 是关键——字符串 token 没有类型可反射,不加 @Inject Nest 就不知道要注入哪个 provider,启动会报 Nest can't resolve dependencies of RedisService。class token(如 PostService 里的 UserService)才能省掉这个装饰器。

注意RedisModuleexports: [RedisService] 只导出了 RedisService没导出 REDIS_CLIENT 这个 token——也就是说别的模块只能注入 RedisService,不能直接注入原始的 ioredis client。这是有意的封装:外部代码只该用 RedisService 提供的封装方法(incr / zincrby / getdel…),需要原始 client 时走 redisService.getClient()。把实现细节(ioredis 的 API 表面)关在模块内部,是后面第八章「模块边界」要展开的话题。

另外注意模块头上的 @Global() 装饰器——它让 RedisModule 不必被每个使用方写进 imports,下一节细讲。

第四步:provider scope——为什么 learnhub 全用 DEFAULT(单例)

这是旧版课程完全跳过、但上线一定会问的一节。provider 注册时可以选 scope(生命周期),Nest 给三种:

  • DEFAULT(默认):整个应用一个实例,所有注入方共享。单例
  • REQUEST:每个 HTTP 请求各 new 一个实例,请求结束销毁。
  • TRANSIENT:每次注入都 new 一个新的。

learnhub 所有 provider 都是 DEFAULT——不是图省事,是有意为之。看 PostService:它持有 postRepouserServiceamqp 这些依赖,这些依赖下面又各自持有连接、客户端。单例意味着这些重型资源(数据库连接池、Redis client、AMQP channel)整个应用只造一次、所有请求共享,启动成本摊到第一次注入,之后零开销。RedisService 那个 ioredis client 全应用就一个,几万次请求复用同一条连接,这是 Redis 性能的前提。

REQUEST scope 听起来「隔离干净、每个请求一个全新的 service」,代价其实很高:

  • 每个请求都要重新 new 一整条依赖链(PostService 依赖 UserServiceUserService 又依赖一堆……整条链都要重建),GC 压力陡增;
  • 更致命的是它会让下游所有 provider 也被迫变成 REQUEST scope——单例不能依赖一个比它生命周期短的对象(不然单例持有的 service 在请求结束后会变成悬空引用)。一旦 PostService 标 REQUEST,它的 7 个依赖也全部跟着降级,连 Repository 这种本该全局共享的东西都被迫每请求重建,数据库连接瞬间打爆。

那「每个请求需要隔离的数据」(当前用户、租户 id、trace id)怎么办?learnhub 的做法是把请求数据挂在 request 对象上,service 保持单例、靠参数传递请求级数据。具体落地:守卫解析 JWT 后把 user 挂到 req.user@CurrentUser() 装饰器从 req.user 取出来、作为方法参数传给 service(第十一章细讲)。service 本身始终是单例,不持有任何请求级状态——所以并发安全。

注意:你 99% 的时间都用 DEFAULT,不要因为「听起来隔离更好」就给 service 加 REQUEST scope——它带来的性能代价远超收益。REQUEST scope 真正合理的场景非常少(比如基于请求的租户隔离缓存),不属于这一章的范围。

第五步:ConfigService 为什么随处可注入——全局动态模块

回头看 AuthService,它的构造函数直接注入了 ConfigService,但 AuthModule 里并没写 imports: [ConfigModule]

// learnhub/src/modules/auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
    private readonly config: ConfigService,  // 随处可注入,为什么?
  ) {}

  async login(dto: LoginDto): Promise<TokenPair> {
    // ...
    const salt = this.config.get<string>("passwordSalt");  // 读 .env 里的 passwordSalt
    // ...
  }
}

秘密在 AppModule 的 imports 第一项:

// learnhub/src/app.module.ts
ConfigModule.forRoot({
  isGlobal: true,   // 关键:全局模块,所有模块都能注入 ConfigService,不用逐个 import
  envFilePath: ".env",
  load: [configuration],
}),

isGlobal: trueConfigModule 变成全局模块——它导出的 ConfigService 在整个应用里随处可注入,任何 module 都不必写 imports: [ConfigModule]。这就是上一节 RedisModule 头上那个 @Global() 装饰器干的同一件事:省掉每个使用方写 import 的繁琐。learnhub 把基础设施类的模块(Config、Redis、MinIO、Search、Amqp、Etcd)都做成了全局,业务模块(Post、Comment、Tag)则正常用 imports 显式声明依赖——边界更清楚。

ConfigService 的能力很直白:config.get<string>("passwordSalt").env 里的 PASSWORD_SALTconfig.get<number>("mysql.port")MYSQL_PORT。learnhub 在 src/config/configuration.ts 里把扁平的 env 变量解析成嵌套对象(mysql.host / jwt.secret / redis.port 这种点路径),所以 config.get("mysql.host") 能拿到值。这是配置管理的标准做法——.env 不进 git、按环境(开发/测试/生产)切换,比把配置写进代码、或塞进 useValue 当常量注入灵活得多(这也是上面 learnhub 不用 useValue 的原因)。

JwtModule 的注册方式也值得看一眼,它是动态模块的标准写法:

// learnhub/src/modules/auth/auth.module.ts
JwtModule.registerAsync({
  global: true,
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    secret: config.get<string>("jwt.secret"),
    signOptions: { expiresIn: config.get<string>("jwt.accessExpires") },
  }),
}),

registerAsync 和上面的 forRootAsync、RedisModule 的 useFactory 是同一套机制——ConfigService 就绪 → 跑工厂函数 → 用返回值配置 JwtModuleJwtModule 内部按这个配置注册自己的 provider(JwtService。注意它 global: true,所以全局的 JwtAuthGuard 也能直接注入 JwtService。动态模块是下一章的主题,这里先认个脸。

第六步:循环依赖与 forwardRef

真实项目偶尔会撞上:A 依赖 B、B 又依赖 A。比如 PostService 要发通知调 NotificationService,而 NotificationService 又要查帖子信息调 PostService——两边构造函数互相要对方,容器 new A 时发现要先 new B、new B 时又发现要先 new A,启动直接报环。

Nest 给的出路是 forwardRef:两边在构造函数里都写 @Inject(forwardRef(() => OtherService)),告诉容器「这个依赖先别管,先把自己造出来,回头再补」。容器就能解开这个环。

forwardRef 是创可贴不是解药——它说明模块边界画错了。绝大多数循环依赖可以靠把公共逻辑抽到第三个 service(A、B 都依赖 C,A 和 B 之间不再互相依赖)或者改成方法内按需取(不在构造函数注入、用到时从容器拿)来化解。learnhub 全项目搜不到一处 forwardRef——它的模块边界经过了拆分(PostService 只依赖 UserService 而不反向、SearchService 只被 PostService 调而不调它),从源头避开了环。

循环依赖、动态模块(forRoot / forFeature / register 三件套)、@Global 模块怎么自己写一个、OnModuleInit / OnModuleDestroy 生命周期钩子——这些都是下一章的主题。

这一章的成果

  1. 用「手动 new」的反面例子看到了不用 IoC 的痛;理解 IoC 把对象创建/组装交给容器,DI 是被动接收依赖。
  2. 在 learnhub 真实的 PostService(7 个依赖)里吃透 IoC 三件套:@Injectable() 标记 + providers 登记 + 构造函数声明,容器自动 new 和注入;分清 class token(可省 @Inject)和字符串 token(必须 @Inject)。
  3. RedisModule 真实代码里吃透 useFactory:动态创建、inject 声明依赖、@Global 模块、exports 封装边界。
  4. 知道 provider scope 三档,为什么 learnhub 全用 DEFAULT(单例),以及 REQUEST scope 为什么几乎不该用。
  5. 搞懂 ConfigService 为什么随处可注入(ConfigModule.forRoot({ isGlobal: true })),以及 JwtModule.registerAsync 这类动态模块的标准写法。

常见问题

  • Nest can't resolve dependencies of XxxService:多半是字符串 token 没加 @Inject(...)(看 RedisService@Inject(REDIS_CLIENT)),或者忘了在 module 的 providers 里登记,或者跨模块时被依赖的 module 没 exports 那个 provider。
  • 跨模块注入失败:默认一个 provider 只在当前 module 能注入。要么定义处 exports + 使用处 imports(像 PostModule imports UserModule),要么定义处加 @Global()(像 RedisModule)。
  • useClass / useValue / useFactory 怎么选:平时用 useClass(简写 providers: [Xxx] 就行);接外部系统、要按配置或异步创建的用 useFactory(看 RedisModule);要注入一个静态常量对象用 useValue(learnhub 没用,配置统一走 ConfigService)。
  • 该不该用 REQUEST scope:默认别用。它会让整条依赖链每请求重建,性能代价大。请求级数据走 req.user / @CurrentUser() 这类机制按参数传递,service 保持单例。
  • 构造函数里能干活吗:尽量别。构造函数只该「声明依赖、存好引用」。要做的初始化(连数据库握手、读文件、起定时器)放进 OnModuleInit.onModuleInit()——它由容器在所有 provider 都 new 完之后调用,这时你的依赖也都就绪了。learnhub 的 PrismaService / AmqpService / SearchService / MinioService 都用了这个钩子,下一章细讲。

下一章讲 Module 进阶:全局模块(@Global)、动态模块(forRoot / forFeature / register / registerAsync)、循环依赖(forwardRef)、OnModuleInit / OnModuleDestroy 生命周期——这是接 TypeORM、JWT、Redis 等所有库的基础,也是这一章反复预告过的那一章。