参考文章:
前阵子在使用 nest 编写后端服务的时候,碰到了这个问题,并花了挺长一段时间才彻底的理清其本质。因此单独写一篇文章来进行记录,希望给同样在学习 nest 的大家一点帮助。
# 怎么样会导致循环依赖?
根据官方文档的说法:“当两个类相互依赖时,就会发生循环依赖。比如 A 类需要 B 类,B 类也需要 A 类。Nest 中模块之间和提供器之间可能会出现循环依赖。”
结合实际编写 nest 应用时的场景,我们可能有一个 user 模块和一个 article 模块,并且它们俩的 service 都封装了很多使用的工具方法,避免直接操作 typeorm 的 repository 来取数据。这个时候,我们可能会在 user.service.ts
中引用 article.service.ts
来进行相关的操作,并且可能同时需要在 article.service.ts
中引用对方来进行操作。 因为 service 本身就是用来封装方法的,在需要使用的地方进行注入使用是非常常见的操作,可以粗略的理解为 nest 中特殊的工具类。
你编写好两个 service 后,在 module 中进行导出以能够被别的模块进行引入,然后再在对方的 module 中分别引用:
// user.module.ts | |
import { Module } from "@nestjs/common"; | |
import { UserService } from "./user.service"; | |
import { ArticleModule } from "../article/article.module"; | |
@Module({ | |
imports: [ArticleModule], | |
providers: [UserService], | |
exports: [UserService], | |
}) | |
export class UserModule {} |
// user.service.ts | |
import { Injectable, Inject, forwardRef } from "@nestjs/common"; | |
import { ArticleService } from "../article/article.service"; | |
@Injectable() | |
export class UserService { | |
constructor( | |
@Inject(forwardRef(() => ArticleService)) | |
private readonly articleService: ArticleService | |
) {} | |
getUserDetails(userId: string): string { | |
// 调用 ArticleService 的方法 | |
return `User details for ${userId} with articles: ${this.articleService.getArticlesByUserId( | |
userId | |
)}`; | |
} | |
} |
// article.module.ts | |
import { Module, forwardRef } from "@nestjs/common"; | |
import { ArticleService } from "./article.service"; | |
import { UserModule } from "../user/user.module"; | |
@Module({ | |
imports: [UserModule], | |
providers: [ArticleService], | |
exports: [ArticleService], | |
}) | |
export class ArticleModule {} |
// article.service.ts | |
import { Injectable, Inject, forwardRef } from "@nestjs/common"; | |
import { UserService } from "../user/user.service"; | |
@Injectable() | |
export class ArticleService { | |
constructor( | |
@Inject(forwardRef(() => UserService)) | |
private readonly userService: UserService | |
) {} | |
getArticlesByUserId(userId: string): string { | |
// 调用 UserService 的方法 | |
return `Articles for user ${userId}`; | |
} | |
} |
这个时候就会出现 循环依赖 的报错信息:
可以看到提示信息非常明确,表示 A circular dependency between modules. 并且给出了方法: 使用 forwardRef () 来避免它。
那么,现在需要了解的问题就很清晰了:
- 循环依赖本质是怎么产生的?
- 怎么解决循环依赖?
- 这个 forwardRef() 又是什么东西呢?为什么使用它就可以避免循环依赖呢?
# 循环依赖的本质是什么?
循环依赖的概念并不是 nest 独有的,事实上,这里一直提到的模块甚至不一定是 nest 模块。它们只是代表了编程中模块的一般概念,主要告诉我们如何优雅的组织代码。
要解释循环依赖的本质,需要理解一下 nest 本身是怎么处理依赖的。
我们都知道 nest 最鲜明的特点就是 依赖注入机制,它本身自带了这一套机制。我们在使用 nest 的机制与规则去编写代码时,遵循的就是其标准的依赖解析的流程去构建应用,以保证各个模块之间 按照正确的顺序加载。
# 依赖注入(DI)
依赖注入(Dependency Injection, DI) 本身是一种 JS 的设计模式,它将对象的 创建 和 管理 责任从类中抽离出来,通过 外部容器 来控制依赖的实例化和注入。这种方式可以相对显著的减少代码的耦合,提高代码的灵活性和测试性。
在 nest 中,依赖注入主要依靠三个部分的共同配合来实现:
- 提供者(Provider):可以被注入的类或值,通常是通过
@Injectable()
装饰的服务类。 - 模块(Module):用于组织提供者的容器,通过
@Module()
装饰器定义。 - 依赖注入容器(DI Container):负责管理提供者的实例化和生命周期。在 nest 中,每个 Module 都可以被看作是一个独立的 DI Container。
# 实现一个依赖注入
# 步骤 1:定义模块和提供者
在 nest 中,每个模块都是一个独立的 DI 容器,管理该模块中的所有提供者。首先,需要通过 @Module()
装饰器定义一个模块,并声明它的提供者(Provider)。
// app.module.ts | |
import { Module } from "@nestjs/common"; | |
import { MyService } from "./my.service"; | |
@Module({ | |
providers: [MyService], | |
}) | |
export class AppModule {} |
上述配置实际上是下方的简写形式:
// app.module.ts | |
import { Module } from "@nestjs/common"; | |
import { MyService } from "./my.service"; | |
@Module({ | |
providers: [ | |
{ | |
provide: MyService, | |
useClass: MyService, | |
}, | |
], | |
}) | |
export class AppModule {} |
我们可以单独配置每个 provider 的令牌(token)和实际引用类。token 的值是一个注入令牌,当它被查询时,用于识别提供者。
# 步骤 2:创建可注入的提供者
接下来,通过 @Injectable()
装饰器定义提供者,使其成为可注入的依赖。
// my.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class MyService {
getHello(): string {
return 'Hello, World!';
}
}
@Injectable()
是一个类装饰器,标记了一个类,意味着它可以被注入到其他提供者中,需要被依赖注入容器进行管理。当 TypeScript 代码被编译器编译时,这个装饰器会发出元数据,nest 用它来管理依赖性注入。
# 步骤 3:使用构造函数注入依赖
在需要使用依赖的地方,可以通过构造函数参数注入依赖。注入依赖总共有三种不同形式:
- 构造函数注入;
@Inject
装饰器注入;- 构造函数中使用
@Inject
装饰器注入。
构造函数注入方式如下:
// app.controller.ts | |
import { Controller, Get } from "@nestjs/common"; | |
import { MyService } from "./my.service"; | |
@Controller() | |
export class AppController { | |
constructor(private readonly myService: MyService) {} | |
@Get() | |
getHello(): string { | |
return this.myService.getHello(); | |
} | |
} |
如果在 module 的 provider 处采用的是最基本的默认注入形式,也就是 provide token 就是原本的注入类本身,那么在构造函数中无需使用 @Inject
装饰器进行 token 查询匹配。
@Inject
装饰器注入方式如下:
// app.controller.ts | |
import { Controller, Get, Inject } from "@nestjs/common"; | |
import { MyService } from "./my.service"; | |
@Controller() | |
export class AppController { | |
@Inject() | |
private readonly myService: MyService; | |
@Get() | |
getHello(): string { | |
return this.myService.getHello(); | |
} | |
} |
可以看到,相较于前者直接取消了构造函数的编写。虽然在实例化的时候还是会根据 @Inject
装饰器触发构造函数,但是在一定程度上比较好的简化了代码。** 不过只适用于无需使用注入服务对类本身的属性进行初始化操作的时候。** 如果有指定特殊的 provide token,那么在 @Inject
装饰器内需要传入以进行匹配。
需要使用构造函数中使用 @Inject
装饰器注入的时候,通常是由于用户指定了特殊的 provide token 并且需要对当前类本身的一些属性调用注入的服务的函数进行初始化操作。这个时候直接结合以上两种方式进行调用即可。举个例子吧:
import { Inject, Injectable } from "@nestjs/common"; | |
import { ConfigService } from "@nestjs/config"; | |
import { createTransport } from "nodemailer"; | |
import type { Transporter } from "nodemailer"; | |
@Injectable() | |
export class EmailService { | |
transporter: Transporter; | |
constructor(@Inject("config") private readonly configService: ConfigService) { | |
this.transporter = createTransport({ | |
host: this.configService.get("NODEMAILER_HOST"), | |
port: this.configService.get("NODEMAILER_PORT"), | |
secure: false, | |
auth: { | |
user: this.configService.get("NODEMAILER_AUTH_USER"), | |
pass: this.configService.get("NODEMAILER_AUTH_PASS"), | |
}, | |
}); | |
} | |
//... 其他的邮件函数比如发邮件 | |
} |
在这个 EmailService
中,内部有一个属性 transporter
,而其中的一些配置项为了隐私保护不能直接暴露,那么我们就调用 ConfigService
对环境变量文件进行读取后对该属性进行初始化。并且我们手动指定了 ConfigService
的 provide token 为 config
字符串,因此我们需要进行结合引入,否则 nest 将会找不到这个 provider。
# 步骤 4:依赖解析与实例化
当应用启动时,nest 会构建一个依赖注入容器。容器会扫描所有模块和提供者,生成一个依赖关系图。然后,nest 会按照依赖关系解析和实例化提供者。
- 依赖关系图:nest 首先扫描应用的所有模块,生成一个依赖关系图,描述每个模块和提供者的依赖关系。
- 实例化顺序:根据依赖关系图,nest 确定提供者的实例化顺序。它会从没有依赖的提供者开始,逐步实例化每个提供者,并注入其依赖项。
- 依赖注入:当实例化某个提供者时,nest 会查找该提供者的构造函数参数,并注入相应的依赖实例。
# 步骤 5:处理循环依赖
这个就是待会儿要讲的。
# 循环依赖的本质
刚刚讲了 nest DI 的整个处理流程,我们可以知道 nest DI 在很大程度上依赖于 TypeScript 编译器发出的元数据(装饰器的一个最主要的特性功能),所以当两个模块或两个提供者之间存在循环引用时,如果没有进一步的帮助,编译器将无法编译任何模块。我们可以理一下循环依赖产生的条件和时机:
- 相互依赖的提供者:当两个提供者相互依赖时,nest 无法确定哪个提供者应该先被实例化。
- 模块的相互引用:当模块 A 导入了模块 B,而模块 B 又导入了模块 A,nest 无法解析模块的加载顺序。
- 延迟实例化:在实例化某个提供者时,nest 可能需要提前实例化它所依赖的其他提供者,如果这些提供者之间存在相互依赖,就会导致循环。
在循环依赖下,TS 甚至无法正常编译。
# 怎么解决循环依赖
经过调研,目前比较流行、符合 nest 最佳实践的方案有两种:
- 手动重构,从代码组织层面解决循环依赖;
- 使用
@forwardRef()
装饰器来 标记某个依赖先被解析。
# 手动重构
nest 的官方文档说,让我们在开发的时候 尽可能减少循环依赖。这也就意味着,nest 官方实际上是推崇第一种解决方式的。
循环依赖在所涉及的类或模块之间建立了紧密的耦合,这意味着每次改变其中任何一个类或模块时,都必须重新编译。紧耦合是违反 SOLID 原则的,我们应该努力避免它。
我们首先需要明确一点:既然会造成循环依赖问题,那么这两个服务中的某个方法必然有着共通之处。我们将这个方法提取出来,再每个服务中单独引用,就解决了循环依赖问题。 也就是说,第一种方式就是所谓的 封装。
在最上面的例子中,我们就可以将 user 和 article 服务中的方法抽离到 common.service.ts
中,再在两个 module 中引用即可。这里可以替换成你们更复杂的代码环境。
// common.module.ts | |
import { Module } from "@nestjs/common"; | |
import { CommonService } from "./common.service"; | |
@Module({ | |
providers: [CommonService], | |
exports: [CommonService], | |
}) | |
export class CommonModule {} |
// common.service.ts | |
import { Injectable } from "@nestjs/common"; | |
@Injectable() | |
export class CommonService { | |
// 假设这里有一些公共的方法 | |
getCommonData(): string { | |
return "Common data"; | |
} | |
} |
在这里随便写了一个比较简单的公共方法。我们之后分别修改 user.module.ts
、 user.service.ts
、 article.module.ts
、 article.service.ts
即可。
举个实际的例子(我自己碰到的):
你在 nest 中集成了 typeorm
。你有一个 user.entity.ts
和 article.entity.ts
,并且在它们对应的服务中分别写好了方法:
- 使用
userRepository
,根据 user_id 查询他发布的文章列表。 - 使用
articleRepository
,根据 article_id 查询这篇文章的作者。
并且这两个方法都需要在对方的 service 中进行使用。那么这样就可以根据上述步骤将这两个方法提取到统一的一个 service 中即可。
嗯,但是这样子还会导致另一个比较严重的后果:代码结构组织混乱。在 nest 项目中组织代码,比较好的方式是 一个实体相关的内容全部放在同一个目录中,公共的 service 则放在 src 根目录下方。这样会导致乱七八糟封装的服务越来越多,代码组织困难。
那么我们可以请出 forwardRef () 这个函数了。
# forwardRer()
使用它很简单:在相互依赖的两边同时使用这个函数对对方模块进行包裹。
// article.module.ts | |
import { Module, forwardRef } from "@nestjs/common"; | |
import { ArticleService } from "./article.service"; | |
import { UserModule } from "../user/user.module"; | |
@Module({ | |
imports: [forwardRef(() => UserModule)], | |
providers: [ArticleService], | |
exports: [ArticleService], | |
}) | |
export class ArticleModule {} |
// user.module.ts | |
import { Module, forwardRef } from "@nestjs/common"; | |
import { UserService } from "./user.service"; | |
import { ArticleModule } from "../article/article.module"; | |
@Module({ | |
imports: [forwardRef(() => ArticleModule)], | |
providers: [UserService], | |
exports: [UserService], | |
}) | |
export class UserModule {} |
这样子之后就可以正常跑起来了。
但是,我会用!== 我理解。接下来,我对 forwardRef()
这个函数的本质进行一些解析。
# forwardRef()
先说结论: forwardRef()
本身是一个函数,本质上是一个帮助解决循环依赖问题的工具。它通过创建一个 ForwardReference
对象,用于告诉 nest 在依赖解析时延迟解析被引用的模块或提供者,从而打破了循环依赖。
我们去看一下源码:
- 输入参数:
forwardRef
接受一个函数fn
作为参数,这个函数返回需要延迟解析的模块或提供者的类型。 - 返回值:
forwardRef
返回一个包含forwardRef
属性的对象,这个属性是输入函数fn
。
ForwardReference
是一个接口,定义如下:
-
forwardRef
: 是一个函数,它返回需要被延迟解析的依赖。
就是这两个看起来非常简单的函数,实现了最重要的两个功能:
- 延迟解析:
forwardRef
通过将依赖解析推迟到真正需要的时候来解决模块之间的循环依赖问题。在模块 A 和模块 B 互相依赖的情况下,通过forwardRef
延迟对模块 B 的解析,使得模块 A 可以先完成自身的加载。 - 动态引用:
forwardRef
创建了一个动态引用对象,这个对象在依赖解析过程中不会立即被解析,而是等到依赖被实际使用的时候再解析。这样就避免了因为循环依赖而导致的解析失败。
那么这个函数在依赖解析中是如何进行工作的呢?
- 模块加载:当 nest 加载模块时,它会扫描所有的依赖。
- 检测
forwardRef
:如果发现某个依赖被forwardRef
包装,** 它会记录这个依赖对象而不立即解析 **。 - 延迟解析:当某个模块需要这个
forwardRef
包装的依赖时,nest 会调用forwardRef
中的forwardRef
函数,获取实际的依赖,并完成依赖的注入。
所以简单来说, forwardRef()
本身实现的就是一个类似于我们平常写前端应用时的 懒加载 功能。当模块需要时再对其进行解析,而不是在一开始就直接对模块进行解析。
# 结语
不仅仅是 nest,我认为在编写无论哪一个项目,循环依赖都是一个值得探讨的问题,只不过在 nest 中提供了一个非常简单粗暴但是实用的懒加载方法解决了这个问题。