NestJS - 循环依赖地狱及其避免方法

难题

许多开发团队,尤其是那些构建 CRUD 应用的团队,经常遇到令人头疼的循环依赖错误。这个神秘的消息暗示着应用架构中存在更深层次的问题。循环依赖是一种代码坏味道,因为它使模块紧密耦合,难以测试和重用,并且难以重构。

那么,为什么这会发生在我们这么多人身上呢?最常见的原因是一个简单但根本性的思维模型错误。

思维模型错误:将数据库中的数据与模块的"工作"混淆

这是导致 NestJS 中大多数循环依赖的核心缺陷。构建应用的开发者经常试图在模块中直接建模数据库关系。

  • 数据库关系可以是双向的:一个 Author 可以有多本 Books,一本 Book 可以有一个 Author。这是数据库和 ORM 设计用来轻松处理的双向关系。
  • 模块依赖必须是单向的:AuthorModule 可以暴露一个被 BookModule 使用的 AuthorService。但如果 BookModule 然后尝试从 AuthorModule 导入某些东西------而 AuthorModule 已经依赖于 BookModule------你就创建了一个循环。我绝对确信每个人都遇到过这个问题。

你的应用模块不是数据库的镜像。它们的目的是封装功能,它们的依赖关系应该反映应用逻辑的流程,而不是数据的结构。

正确的思维模型:模块作为单行道城市

让我们用你的应用作为城市的类比。但是,不要将你的城市想象成有双向街道,而是将它们想象成严格的单行道城市。每个模块都是城市中的一个街区(例如,UserModule、AuthModule、AuthorModule、BookModule 等),依赖关系就是道路。一辆车可以从 BookModule 街区行驶到 AuthorModule 获取作者信息,但同一辆车不能从 AuthorModule 返回到 BookModule。

你用模块依赖关系在这个单行道城市中所可视化的是一个有向无环图(DAG)

  • 有向:关系沿单一方向流动。A 依赖于 B,而不是相反。
  • 无环:没有循环。你不能从 A 开始,沿着依赖关系走,最后又回到 A。

包裹快递员类比:你在城市中的配送路线

这就是你的 NestJS 应用成为快递服务的地方。将进入应用的请求想象成包裹快递员开始配送路线。快递员进入城市,沿着单行道前进,访问每个模块执行任务。关键规则是快递员永远不会转身回到他们已经访问过的地方。

整个"配送路线"形成了有向无环图。快递员从起点(AppModule)开始,通过依赖关系前进,在路线的末端,最后一个模块返回结果,确认"配送完成"。这个模型提醒我们,执行流程应该始终向前且有目的,永远不会绕回自身。

避免循环的实用规则

  1. 定义清晰的层次结构:将模块分层排列。核心模块应该在底部,特定功能模块在中间,入口点模块在顶部。依赖关系应该只向下流动。这个原则是像 Clean Architecture 这样的架构模式的基石,由 Robert C. Martin("Uncle Bob")推广。
  2. 分离共享逻辑:如果两个模块都需要相同的共享工具,创建第三个独立的 UtilModule,两者都可以导入。这是"提取公共关注点"规则。这些东西放入"common"或"shared"模块。
  3. 使用更高级别的模块进行编排:与其让两个模块直接相互依赖,不如创建一个依赖于两者的更高级别模块。这个模块充当"中间人",在不创建循环依赖的情况下编排数据流。这种模块应该"做事情",而不是代表特定的数据(库)模型。

具体示例:统计作者的书籍数量

让我们使用 Author 和 Book 示例。我们需要获取作者写了多少本书。

  • AuthorsModule:负责所有与作者相关的事情。
  • BooksModule:负责所有与书籍相关的事情。

我们不让 AuthorsModule 导入 BooksModule(以获取书籍数量)和 BooksModule 导入 AuthorsModule(以查找作者信息),而是引入一个新的更高级别模块:PublishingModule。这个模块充当我们的"包裹快递员",编排请求。

src/authors/authors.module.ts

ts 复制代码
import { Module } from '@nestjs/common'
import { AuthorsService } from './authors.service'

@Module({
  providers: [AuthorsService],
  exports: [AuthorsService],
})
export class AuthorsModule {}

src/books/books.module.ts

ts 复制代码
import { Module } from '@nestjs/common'
import { BooksService } from './books.service'

@Module({
  providers: [BooksService],
  exports: [BooksService],
})
export class BooksModule {}

src/publishing/publishing.module.ts

ts 复制代码
import { Module } from '@nestjs/common'
import { AuthorsModule } from '../authors/authors.module'
import { BooksModule } from '../books/books.module'
import { PublishingService } from './publishing.service'
import { PublishingResolver } from './publishing.resolver'

@Module({
  imports: [
    AuthorsModule,
    BooksModule,
  ],
  providers: [PublishingService, PublishingResolver],
})
export class PublishingModule {}

PublishingModule 正确地建模了包裹快递员的路线。它通过访问 AuthorsModule 获取作者,然后访问 BooksModule 获取书籍来编排流程,同时保持单向依赖流。AuthorsModule 和 BooksModule 对 PublishingModule 一无所知,保持解耦和可重用。

更进一步:使用接口进行抽象

上面的具体示例是一个很好的起点,但如果我们的应用增长了怎么办?如果我们添加新的内容类型,如 Blogs 或 Articles?我们将不得不更新 PublishingModule 以导入 BlogsModule、ArticlesModule 等,使模块变得杂乱且难以管理。

这就是抽象力量的体现。我们可以依赖共享契约或接口,而不是依赖具体实现。这使我们的代码更加灵活和可扩展。

1. 定义接口和共享令牌

首先,你需要一个共享接口来为所有服务建立通用契约。这提供了类型安全。此外,使用 Symbol 定义一个唯一令牌以避免命名冲突,并作为注入的键。

src/publishing/interfaces/publishable.interface.ts

ts 复制代码
export interface IPublishable {
  getPublishableType(): string;
  getContentCountByAuthorId(authorId: string): Promise<number>;
}

src/publishing/interfaces/publishable-service-token.ts

ini 复制代码
export const PUBLISHABLE_SERVICE_TOKEN = Symbol('PUBLISHABLE_SERVICE');

2. 在每个服务中实现接口

你的每个服务,如 BooksService 和新的 BlogsService,将实现 IPublishable 接口。它们各自都有一个 getPublishableType() 方法来唯一标识自己。

src/books/books.service.ts

ts 复制代码
import { Injectable } from '@nestjs/common';
import { IPublishable } from '../publishing/interfaces/publishable.interface';

@Injectable()
export class BooksService implements IPublishable {
  getPublishableType(): string {
    return 'book';
  }

  getContentCountByAuthorId(authorId: string): Promise<number> {
    // 获取书籍数量的逻辑
    return Promise.resolve(10);
  }
}

src/blogs/blogs.service.ts

ts 复制代码
import { Injectable } from '@nestjs/common';
import { IPublishable } from '../content/interfaces/publishable.interface';

@Injectable()
export class BlogsService implements IPublishable {
  getPublishableType(): string {
    return 'blog';
  }

  getContentCountByAuthorId(authorId: string): Promise<number> {
    // 获取博客数量的逻辑
    return Promise.resolve(25);
  }
}

3. 在中央模块中创建多提供者工厂

这是最关键的一步。每个模块不是使用相同的令牌导出提供者,而是创建一个单一的中央模块,收集所有单独的服务,并在共享令牌下将它们作为数组提供。这可以防止先前的提供者被覆盖。

在这种模式中,中央模块知道所有具体实现并编排它们的提供。其他模块,如 BooksModule 和 BlogsModule,可以简单且专注于它们的特定业务逻辑。

src/publishing/publishing.module.ts

ts 复制代码
import { Module } from '@nestjs/common';
import { PUBLISHABLE_SERVICE_TOKEN } from './interfaces/publishable-service-token';
import { BooksService } from '../books/books.service';
import { BlogsService } from '../blogs/blogs.service';
import { PublishingService } from './publishing.service';

@Module({
  imports: [], 
  providers: [
    BooksService,
    BlogsService,
    {
      provide: PUBLISHABLE_SERVICE_TOKEN,
      useFactory: (booksService: BooksService, blogsService: BlogsService) => {
        return [booksService, blogsService];
      },
      inject: [BooksService, BlogsService],
    },
    PublishingService,
  ],
  exports: [PUBLISHABLE_SERVICE_TOKEN],
})
export class PublishingModule {}

4. 在服务中注入和使用数组

现在,你的 PublishingService 可以正确注入 IPublishable 服务数组。NestJS 的容器将使用我们定义的工厂来提供包含所有服务的单个数组。这允许你迭代它们并多态地执行操作。

src/publishing/publishing.service.ts

ts 复制代码
import { Injectable, Inject } from '@nestjs/common';
import { IPublishable } from '../content/interfaces/publishable.interface';
import { PUBLISHABLE_SERVICE_TOKEN } from './token';

@Injectable()
export class PublishingService {
  constructor(
    @Inject(PUBLISHABLE_SERVICE_TOKEN)
    private readonly publishableServices: IPublishable[],
  ) {}

  async getAuthorTotalContentCount(authorId: string): Promise<number> {
    const counts = await Promise.all(
      this.publishableServices.map(service =>
        service.getContentCountByAuthorId(authorId),
      ),
    );
    return counts.reduce((sum, count) => sum + count, 0);
  }

  async getAuthorCountByPublishableType(authorId: string, type: string): Promise<number> {
    const service = this.publishableServices.find(s => s.getPublishableType() === type);

    if (!service) {
      throw new Error(`No service found for publishable type: ${type}`);
    }

    return service.getContentCountByAuthorId(authorId);
  }
}

这就是单行道类比的真正力量。我们的 PublishingService 不关心内容是书籍、博客还是我们下周创建的新内容类型。它只关心它可以与满足 IPublishable 契约的服务交互,保持干净、解耦的架构。这个新方法展示了快递员如何使用一个键('book')绕过所有其他模块,直接到达它需要的那个模块,同时遵循单行道。

结论

下次你构建新模块时,停下来想一想。不要想着数据检索("我需要为这个用户获取帖子"),而是想着正在完成的过程("我需要获取这个作者发布的所有内容")。这种微妙但强大的视角转变,结合单行道思维框架,将引导你走向干净、可维护和可扩展的架构。你将最终避免循环依赖地狱这个令人抓狂的问题。

你如何避免循环依赖?或者你如何努力使模块之间的依赖更少?请在下面的评论中告诉我。

相关推荐
鼓掌MVP2 小时前
Java框架的发展历程体现了软件工程思想的持续进化
java·spring·架构
小马哥编程4 小时前
【软考架构】案例分析-Web应用设计(应用服务器概念)
前端·架构
花姐夫Jun4 小时前
在 Ubuntu ARM 架构系统中安装并使用花生壳实现内网穿透
arm开发·ubuntu·架构
Wang's Blog6 小时前
Nestjs框架: 微服务事件驱动通信与超时处理机制优化基于Event-Based 通信及异常捕获实践
微服务·云原生·架构·nestjs
brzhang6 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构
YXWik66 小时前
新版若依微服务增强swagger增强集成knife4j
微服务·云原生·架构
Wang's Blog6 小时前
Nestjs框架: 微服务断路器实现原理与OPOSSUM库实践
运维·微服务·nestjs
深思慎考6 小时前
微服务即时通讯系统(服务端)——文件存储模块全链路设计与实现(3)
linux·微服务·架构·c++项目·聊天系统
交换机路由器测试之路6 小时前
交换机路由器基础(二)-运营商网络架构和接入网
网络·架构
开发者如是说7 小时前
Compose 开发桌面程序的一些问题
前端·架构