Skip to content

循环依赖 (Circular Dependency)

解开编程中的“死结”:NestJS 循环依赖完全指南

想象一个有点滑稽的办公室场景:

新来的员工小 A 需要他的直属上司 B 签字,才能激活他的员工账户。但公司的规定是,上司 B 必须先用自己的账户登录系统,才能为下属签字。而今天 B 的账户也恰好被锁了,需要下属 A 登录后帮他确认身份才能解锁。

于是,一个死结出现了:

  • A 需要 B 先完成操作。
  • B 需要 A 先完成操作。

他们两个互相等待,谁也无法继续工作。这就是一个循环依赖

在 NestJS(乃至许多依赖注入框架)中,当两个类(或模块)在它们的构造函数中直接或间接地互相需要对方时,就会发生同样的情况。NestJS 的依赖注入容器在启动时会尝试构建依赖关系图,当它遇到这种“我需要你,你需要我”的死循环时,它会感到困惑并抛出错误,因为它不知道该先创建哪一个。

1. 制造一个“死结”:服务之间的循环依赖

让我们亲手写一个循环依赖的例子,看看它是什么样子,以及 NestJS 会给我们什么提示。

第一步:创建两个互相需要的服务

src/cats/cats.service.ts

typescript
import { Injectable } from '@nestjs/common';
import { DogsService } from '../dogs/dogs.service';

@Injectable()
export class CatsService {
  // CatsService 需要 DogsService
  constructor(private readonly dogsService: DogsService) {}

  meow() {
    return 'Meow!';
  }

  barkFromDog() {
    // 它想调用狗狗的服务
    return `My friend a dog says: ${this.dogsService.bark()}`;
  }
}

src/dogs/dogs.service.ts

typescript
import { Injectable } from '@nestjs/common';
import { CatsService } from '../cats/cats.service';

@Injectable()
export class DogsService {
  // DogsService 也需要 CatsService
  constructor(private readonly catsService: CatsService) {}

  bark() {
    return 'Woof!';
  }

  meowFromCat() {
    // 它也想调用猫猫的服务
    return `My friend a cat says: ${this.catsService.meow()}`;
  }
}

第二步:在模块中注册它们

src/app.module.ts

typescript
import { Module } from '@nestjs/common';
import { CatsService } from './cats/cats.service';
import { DogsService } from './dogs/dogs.service';

@Module({
  providers: [CatsService, DogsService],
})
export class AppModule {}

第三步:启动应用,迎接错误!

当你运行 npm run start:dev,你的应用会立即崩溃,并抛出一个长长的错误,其中最关键的信息是:

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

Potential solutions:
- If CatsService is a provider, is it part of the current AppModule?
- If CatsService is exported from a separate @Module, is that module imported within AppModule?
...

Potential circular dependency detected:
AppModule -> DogsService -> CatsService -> DogsService

NestJS 非常友好地告诉了我们问题所在:Potential circular dependency detected (检测到潜在的循环依赖),并指出了循环路径:DogsService 需要 CatsService,而 CatsService 又需要 DogsService

2. 解开死结的“金钥匙”:forwardRef()

为了解决这个问题,我们需要有一种方法来告诉 NestJS:“嘿,我知道我现在需要 CatsService,但你先别急着去创建它,把它当作一个‘待办事项’。等你有空把它创建好之后,再把它交给我。”

这个“待办事项”或者说“转发引用”的机制,就是 forwardRef()

forwardRef() 是一个工具函数,它接收一个返回类名的箭头函数 (() => ClassName)。它能延迟对类名的解析,直到所有类都已经被定义。这打破了同步实例化的死循环。

如何使用 forwardRef()

我们需要在循环依赖的两端都使用它。

第一步:在注入点使用 forwardRef

我们需要修改两个服务的构造函数,使用 @Inject() 装饰器和 forwardRef()

src/cats/cats.service.ts (修复后)

typescript
// 1. 从 @nestjs/common 导入 forwardRef 和 Inject
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { DogsService } from '../dogs/dogs.service';

@Injectable()
export class CatsService {
  constructor(
    // 2. 使用 @Inject 和 forwardRef 包装依赖
    @Inject(forwardRef(() => DogsService))
    private readonly dogsService: DogsService
  ) {}

  // ... 其他代码不变
  meow() {
    return 'Meow!';
  }
  barkFromDog() {
    return `My friend a dog says: ${this.dogsService.bark()}`;
  }
}

src/dogs/dogs.service.ts (修复后)

typescript
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { CatsService } from '../cats/cats.service';

@Injectable()
export class DogsService {
  constructor(
    @Inject(forwardRef(() => CatsService))
    private readonly catsService: CatsService
  ) {}

  // ... 其他代码不变
  bark() {
    return 'Woof!';
  }
  meowFromCat() {
    return `My friend a cat says: ${this.catsService.meow()}`;
  }
}

现在,当你再次启动应用时,它会顺利启动!forwardRef() 成功地解开了这个死结。

3. 模块之间的循环依赖

循环依赖不仅会发生在服务之间,也会发生在模块之间。

例如,CatsModule 导出了 CatsService,并且 importsDogsModule。同时,DogsModule 导出了 DogsService,并且 importsCatsModule

src/cats/cats.module.ts

typescript
import { Module } from '@nestjs/common';
import { CatsService } from './cats.service';
import { DogsModule } from '../dogs/dogs.module';

@Module({
  imports: [DogsModule], // 需要狗狗模块
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

src/dogs/dogs.module.ts

typescript
import { Module } from '@nestjs/common';
import { DogsService } from './dogs.service';
import { CatsModule } from '../cats/cats.module';

@Module({
  imports: [CatsModule], // 需要猫猫模块
  providers: [DogsService],
  exports: [DogsService],
})
export class DogsModule {}

这同样会造成一个无法启动的死循环。解决方法完全相同:在 @Module() 装饰器的 imports 数组中使用 forwardRef()

src/cats/cats.module.ts (修复后)

typescript
import { Module, forwardRef } from '@nestjs/common';
// ...
@Module({
  imports: [forwardRef(() => DogsModule)], // 使用 forwardRef
  // ...
})
export class CatsModule {}

src/dogs/dogs.module.ts (修复后)

typescript
import { Module, forwardRef } from '@nestjs/common';
// ...
@Module({
  imports: [forwardRef(() => CatsModule)], // 使用 forwardRef
  // ...
})
export class DogsModule {}

4. 一个善意的警告:循环依赖是“代码异味”

虽然 NestJS 提供了 forwardRef() 这个强大的工具来解决循环依赖,但循环依赖的出现,通常是你代码设计上一个危险的信号,我们称之为“代码异味 (Code Smell)”。

它往往意味着:

  • 职责不清:这两个互相依赖的服务(或模块)的职责划分可能不清晰。它们之间的关系过于紧密,也许它们本应该被合并成一个更大的服务。
  • 耦合度过高:你的代码模块像一盘意大利面一样缠绕在一起,修改其中一个很容易影响另一个,难以维护和测试。

在伸手去用 forwardRef() 之前,请先问自己几个问题

  1. 我真的需要 A 调用 B,同时 B 又调用 A 吗?
  2. 有没有可能提取出一个第三方的、更高层次的 C 服务来协调 AB?这样 AB 就不需要直接互相了解了。
  3. 我是不是违反了单一职责原则?

重构代码,消除循环依赖,通常是比使用 forwardRef() 更好的选择。

总结

循环依赖是 NestJS(及许多框架)中的一个经典问题,但并不可怕。

  • 它是什么:两个或多个提供者(或模块)形成了一个相互依赖的闭环。
  • 如何解决:使用 forwardRef() 函数来包裹循环依赖的一方或双方,打破同步的依赖链。
  • 最佳实践尽可能避免它!将循环依赖的出现视为一个重新思考和重构代码设计的机会。forwardRef() 是你的“救生筏”,但你更应该学会如何避免“掉进水里”。