如何避免NestJS中的循环依赖

1,342 阅读8分钟

简介

NestJS的优点之一是它允许我们在我们的应用程序中分开关注。NestJS架构倾向于将逻辑层创建为依赖关系(即服务),可以被访问层(即控制器)所消费。

即使NestJS有一个内置的依赖注入系统,负责解决我们代码不同部分所需的依赖关系,但在处理依赖关系时仍然必须小心。在这方面遇到的常见问题之一是循环依赖。如果有一个未解决的循环依赖,NestJS代码甚至不会被编译。

在这篇文章中,我们将学习NestJS中的循环依赖,为什么它们会出现,以及我们如何避免它们。我不只是介绍NestJS提供的解决方法,我将引导我们如何通过重新思考如何耦合依赖关系来避免循环依赖,这将有助于我们在后端代码中更好地架构。

避免循环依赖还可以确保我们的代码更容易理解和修改,因为循环依赖意味着我们的代码存在紧密耦合。

内容

什么是循环依赖?

在编程中,当两个或多个模块(或类)直接或间接地相互依赖时,就会出现循环依赖。假设A,B,CD 是四个模块,一个直接循环依赖的例子是ABA 。模块A 依赖于模块B ,而模块A 又依赖于 。

一个间接循环依赖的例子是:ABCA 。模块A 依赖于B ,而 并不直接依赖A ,但在其依赖链的后面引用了A

请注意,循环依赖的概念并不是NestJS独有的,事实上,这里用作例子的模块甚至不一定是NestJS模块。它们只是代表了编程中模块的一般概念,指的是我们如何组织代码。

在我们谈论NestJS中的循环依赖(以及如何避免它们)之前,让我们首先讨论NestJS的依赖注入系统是如何工作的。

了解NestJS是如何处理依赖注入的,将使我们更容易理解循环引用是如何在我们的依赖中发生的,以及为什么NestJS的编译在解决循环引用之前无法编译。

NestJS的依赖注入系统

在NestJS中,通过依赖注入(DI),我们可以将依赖的实例化委托给运行时系统,而不是在我们自己的代码中强制进行。

例如,假设我们有一个UserService ,定义如下。

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

@Injectable()
export class UserService {
  constructor() {}
  public async getUserById(userId: string) {
    ...
  }
  ...
}

现在,假设我们在UserController 类中使用UserService ,如下所示。

import { Controller } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  public async getUserById(userId: string) {
    return await this.userService.getUserById(userId);
  }
}

在这种情况下,UserController 是一个查询器,要求将UserService 作为其依赖之一。NestJS依赖性注入器将在一个容器中检查所请求的依赖性,在那里它存储了对NestJS项目中定义的提供者的引用。

UserService 定义中使用的@Injectable() 装饰器将该类标记为NestJS依赖注入系统应该可以注入的提供者,也就是说,它应该由容器管理。当TypeScript代码被编译器编译时,这个装饰器会发出元数据,NestJS用它来管理依赖性注入。

dependency injection Nestjs visualization

在NestJS中,每个模块都有自己的注入器,可以访问容器。当你声明一个模块时,你必须指定模块应该可用的提供者,除非提供者是一个全局提供者的情况。

例如,UserModule ,其定义如下。

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

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

在正常的执行模式下,当询问者请求一个依赖关系时,注入者会检查容器,看该依赖关系的一个对象以前是否被缓存过。如果是这样,该对象将被返回给询问者。否则,NestJS将实例化一个新的依赖对象,将其缓存,然后将该对象返回给询问者。

声明providers: [UserService] ,实际上是以下内容的速记。

providers: [
    {
      provide: UserService,
      useClass: UserService,
    },
]

provide 的值是一个注入令牌,当它被查询时,用于识别提供者。

循环依赖性问题是如何产生的

NestJS DI系统在很大程度上依赖于TypeScript编译器发出的元数据,所以当两个模块或两个提供者之间存在循环引用时,如果没有进一步的帮助,编译器将无法编译任何模块。

例如,假设我们有一个FileService ,我们用它来管理上传到我们应用程序的文件,定义如下。

import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { File } from './interfaces/file.interface';

@Injectable()
export class FileService {
  constructor(private readonly userService: UserService) {}
  public getById(pictureId: string): File {
    // not real implementation
    return {
      id: pictureId,
      url: 'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50',
    };
  }

  public async getUserProfilePicture(userId: string): Promise<File> {
    const user = await this.userService.getUserById(userId);
    return this.getById(user.profilePictureId);
  }
}

该服务有一个getUserProfilePicture 方法,用来获取作为用户资料图片的附件的图像文件。FileService 需要将UserService 作为依赖关系注入,以便能够获取用户。

UserService 也被更新如下。

import { Injectable } from '@nestjs/common';
import { FileService } from '../file-service/file.service';

@Injectable()
export class UserService {
  constructor(private readonly fileService: FileService) {}
  public async getUserById(userId: string) {
    // actual work of retrieving user
    return {
      id: userId,
      name: 'Sam',
      profilePictureId: 'kdkf43',
    };
  }

  public async addUserProfilePicture(userId: string, pictureId: string) {
    const picture = await this.fileService.getById(pictureId);
    // update user with the picture url
    return { id: userId, name: 'Sam', profilePictureId: picture.id };
  }
}

在这种情况下,我们有一个循环的依赖关系,因为UserServiceFileService 都是相互依赖的(UserServiceFileServiceUserService )。

circular dependency visualization

在循环引用的情况下,代码将无法编译。

通过重构避免循环依赖

NestJS文档建议尽可能地避免循环依赖。

循环依赖在所涉及的类或模块之间建立了紧密的耦合,这意味着每次改变其中任何一个类或模块时,都必须重新编译。正如我在之前的文章中提到的,紧耦合是违反SOLID原则的,我们应该努力避免它。

在这个例子中,我们可以很容易地消除循环依赖。我们的循环引用也可以表示为如下。

UserService → FileService
and 
FileService → UserService

为了打破这个循环,我们可以将两个服务的共同特征提取到一个新的服务中,这个服务依赖于两个服务。在这种情况下,我们可以有一个依赖于UserServiceFileServiceProfilePictureService

ProfilePictureService 将有它自己的模块,定义如下。

import { Module } from '@nestjs/common';
import { FileModule } from '../file-service/file.module';
import { UserModule } from '../user/user.module';
import { ProfilePictureService } from './profile-picture.service';
@Module({
  imports: [FileModule, UserModule],
  providers: [ProfilePictureService],
})
export class ProfilePictureModule {}

请注意,这个模块同时导入了FileModuleUserModule 。这两个导入的模块都必须导出我们想在ProfilePictureService 中使用的服务。

ProfilePictureService 将被定义如下。

import { Injectable } from '@nestjs/common';
import { File } from '../file-service/interfaces/file.interface';
import { FileService } from '../file-service/file.service';
import { UserService } from '../user/user.service';

@Injectable()
export class ProfilePictureService {
  constructor(
    private readonly fileService: FileService,
    private readonly userService: UserService,
  ) {}

  public async addUserProfilePicture(userId: string, pictureId: string) {
    const picture = await this.fileService.getById(pictureId);
    // update user with the picture url
    return { id: userId, name: 'Sam', profilePictureId: picture.id };
  }

  public async getUserProfilePicture(userId: string): Promise<File> {
    const user = await this.userService.getUserById(userId);
    return this.fileService.getById(user.profilePictureId);
  }
}

ProfilePictureService 需要 和 作为它的依赖,并包含执行我们之前在 和 中的动作的方法。UserService FileService UserService FileService

UserService 不需要再依赖FileService ,你可以在这里看到。

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

@Injectable()
export class UserService {
  public async getUserById(userId: string) {
    // actual work of retrieving user
    return {
      id: userId,
      name: 'Sam',
      profilePictureId: 'kdkf43',
    };
  }
}

同样地,FileService 也不需要知道关于UserService 的任何事情。

import { Injectable } from '@nestjs/common';
import { File } from './interfaces/file.interface';

@Injectable()
export class FileService {
  public getById(pictureId: string): File {
    return {
      id: pictureId,
      url: 'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50',
    };
  }
}

现在这三个服务之间的关系可以表示如下。

refactoring visualization

从图中你可以看到,服务之间没有循环引用。

虽然这个关于重构的例子是关于提供者之间的循环依赖关系,但我们可以用同样的想法来避免模块之间的循环依赖关系。

利用前向引用解决循环依赖问题

理想情况下,循环依赖应该被避免,但在不可能的情况下,Nest 提供了一种方法来解决它们。

前向引用允许Nest通过使用forwardRef() 工具函数来引用尚未定义的类。我们必须在循环引用的两边都使用这个函数。

例如,我们可以将UserService 修改如下。

import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { FileService } from '../file-service/file.service';

@Injectable()
export class UserService {
  constructor(
    @Inject(forwardRef(() => FileService))
    private readonly fileService: FileService,
  ) {}

  public async getUserById(userId: string) {
    ...
  }
  public async addFile(userId: string, pictureId: string) {
    const picture = await this.fileService.getById(pictureId);
    // update user with the picture url
    return { id: userId, name: 'Sam', profilePictureUrl: picture.url };
  }
}

然后再把FileService ,像这样。

import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { File } from './interfaces/file.interface';

@Injectable()
export class FileService {
  constructor(
    @Inject(forwardRef(() => UserService))
    private readonly userService: UserService,
  ) {}

  public getById(pictureId: string): File {
    ...
  }

  public async getUserProfilePicture(userId: string): Promise<File> {
    const user = await this.userService.getUserById(userId);
    return this.getById(user.id);
  }
}

有了这个正向引用,代码的编译就不会出错。

模块的正向引用

forwardRef() 工具函数也可以用来解决模块之间的循环依赖关系,但它必须在模块关联的两边使用。例如,我们可以在循环模块引用的一侧做如下操作。

@Module({
  imports: [forwardRef(() => SecondCircularModule)],
})
export class FirstCircularModule {}

结论

在这篇文章中,我们了解了什么是循环依赖,NestJS中的依赖注入是如何工作的,以及循环依赖的问题是如何产生的。

我们还学习了如何避免NestJS中的循环依赖,以及为什么我们应该总是试图避免它。希望你现在知道在无法使用前向引用来避免的情况下如何解决这个问题。

本文的代码示例托管GitHub上;仓库中有三个分支,分别名为circularfix/forward-referencingfix/refactoring 。你可以使用这些分支来导航到项目的不同阶段。