高级 NestJS:如何构建完全动态的 NestJS 模块

2025-05-25

高级 NestJS:如何构建完全动态的 NestJS 模块

John 是 NestJS 核心团队的成员

你可能已经注意到了可以动态配置一些开箱即用的 NestJS 模块(例如@nestjs/jwt@nestjs/passport@nestjs/typeorm)的酷炫方法,并且想知道如何在你自己的模块中实现这些功能?这篇文章正适合你!😃

简介

在NestJS 文档网站上,我们最近添加了关于动态模块的新章节。这是一个相当先进的章节,加上最近对异步提供程序章节的一些重大改进,Nest 开发人员拥有了一些很棒的新资源,可帮助构建可配置模块,这些模块可以组装成复杂、健壮的应用程序。

本文在此基础上更进一步。NestJS 的标志之一是使异步编程非常简单。Nest 完全拥抱 Node.js 的 Promises 和async/await范式。再加上 Nest 标志性的依赖注入功能,这是一个非常强大的组合。让我们看看如何使用这些功能来创建可动态配置的模块。掌握这项技能可以让你构建可在任何上下文中重复使用的模块。这使得完全上下文感知、可重复使用的包(库)成为可能,并允许你组装可跨云提供商和整个 DevOps 范围(从开发到暂存再到生产)顺利部署的应用程序。

基本动态模块

在动态模块章节,代码示例的最终结果是能够传入一个options对象来配置要导入的模块。读完该章节后,我们将了解以下代码片段如何通过动态模块 API 实现。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [ConfigModule.register({ folder: './config' })],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

如果您使用过 NestJS 模块(例如@nestjs/typeorm@nestjs/passport@nestjs/jwt),您会注意到它们超越了该章节中描述的功能。除了支持register(...)上面显示的方法外,它们还支持该方法的完全动态和异步版本。例如,使用@nestjs/jwt模块,您可以使用如下构造:

@Module({
  imports: [
    JwtModule.registerAsync({ useClass: ConfigService }),
  ]
})

使用此构造,不仅可以动态配置模块,而且传递给动态模块的选项本身也是动态构建的。这是最佳的高阶功能。配置选项基于环境中提取的ConfigService值提供,这意味着它们可以完全在功能代码外部进行更改。与使用 那样对参数进行硬编码相比,ConfigModule.register({ folder: './config'})您可以立即看到优势。

在本文中,我们将进一步探讨为什么您可能需要此功能以及如何构建它。在继续阅读下一部分之前,请确保您已经牢牢掌握了“自定义提供程序”“动态模块”章节中的概念。

异步选项提供程序用例

上面的标题真是拗口!异步选项提供程序到底是什么?

要回答这个问题,首先要再次思考一下我们上面的例子(ConfigModule.register({ folder: './config'})部分)是如何将一个静态选项对象传递给register()方法的。正如我们在动态模块章节中所学到的,这个选项对象用于自定义模块的行为。(如果不熟悉这个概念,请在继续之前复习一下该章节)。正如之前提到的,我们现在将更进一步地阐述这个概念,让我们的选项对象在运行时动态提供。

异步(动态)选项示例

为了给本文的剩余部分提供一个具体的工作示例,我将介绍一个新模块。然后,我们将在该模块中演示如何使用异步选项提供程序。

我最近发布了@nestjsplus/massive 包,以便 Nest 项目能够轻松使用强大的MassiveJS库来处理数据库工作。我们将研究该包的架构,并将其部分内容用于本文分析的代码。简而言之,该包将 MassiveJS 库包装db到 Nest 模块中。MassiveJS 通过一个包含如下方法的对象将其全部 API 提供给每个使用模块(例如,每个功能模块) :

  • db.find()检索数据库记录
  • db.update()更新数据库记录
  • db.myFunction()执行脚本或数据库过程/函数

该包的主要功能@nestjsplus/massive是建立与数据库的连接并返回db对象。在我们的功能模块中,我们使用对象上的方法访问数据库db,如上所示。首先,应该清楚的是,为了建立数据库连接,我们需要传入一些连接参数。在我们的例子中,使用 PostgreSQL,这些参数如下所示:

{
  user: 'john',
  password: 'password',
  host: 'localhost',
  port: 5432,
  database: 'nest',
}

我们很快意识到,在 Nest 应用中硬编码这些连接参数并非最佳选择。常规解决方案是通过某种配置模块 (Configuration Module)来提供它们。这正是我们可以使用 Nest@nestjs/jwt模块实现的功能,如上例所示。简而言之,这就是异步选项提供程序 (async options provider)的用途。现在,让我们弄清楚如何在 Massive 模块中实现这一点。

异步选项提供程序的编码

首先,我们可以想象使用在中找到的相同构造来支持模块导入语句@nestjs/jwt,如下所示:

@Module({
  imports: [
    MassiveModule.registerAsync({ useClass: ConfigService })
  ],
})

如果这看起来不熟悉,请快速浏览一下自定义提供程序章节的这一节。这些相似之处是经过精心设计的。我们从自定义提供程序中汲取的灵感,构建了一种更灵活的方式,为动态模块提供选项。

让我们深入研究一下实现。这会很,所以请深呼吸一下,或许再加满咖啡,但别担心——我们能做到!😄。请记住,我们正在讲解一种设计模式,一旦您理解了它,就可以放心地将其剪切粘贴为样板,开始构建任何需要动态配置的模块。但在开始剪切粘贴之前,我们先来了解一下模板,这样我们就可以根据需要进行自定义。记住,您不必每次都从头开始编写!

首先,让我们回顾一下我们期望通过registerAsync(...)上述结构实现的目标。简单来说,我们想说:“嘿,模块!我不想在代码中提供选项属性值。不如我给你一个类,里面有一个方法,你可以调用它获取选项值?” 这样一来,我们在运行时动态生成选项时就拥有了极大的灵活性。

这意味着,与静态选项技术相比,我们的动态模块在这种情况下需要做更多工作来获取其连接选项。我们将逐步实现这一目标。首先,我们将规范化一些定义。我们试图为 MassiveJS 提供其预期的连接选项,因此首先我们将创建一个接口来对其进行建模:

export interface MassiveConnectOptions {
  /**
   * server name or IP address
   */
  host: string;
  /**
   * server port number
   */
  port: number;
  /**
   * database name
   */
  database: string;
  /**
   * user name
   */
  user: string;
  /**
   * user password, or a function that returns one
   */
  password: string;
  /**
   * use SSL (it also can be a TSSLConfig-like object)
   */

  ...
}

实际上还有更多可用的选项(我们可以查看MassiveJS 连接选项文档来了解它们),但目前我们先保留基本的选项。我们建模的选项是建立连接所必需的。顺便提一下,我们使用 JSDoc 来记录它们,以便以后使用该模块时获得良好的 Intellisense 开发体验。

下一个需要理解的概念如下。由于我们的消费者模块(调用 registerAsync()导入 MassiveJS 模块的模块)传递给我们一个类,并期望我们调用该类的一个方法,因此我们可以推测,我们可能需要使用某种工厂模式。换句话说,我们需要在某个地方实例化该类,调用其方法,并将该方法返回的结果用作连接选项,对吗?听起来(有点像)工厂模式。现在,我们就先来了解一下这个概念。

让我们用一个接口来描述我们未来的工厂。方法可以是类似这样的createMassiveConnectOptions()。它需要返回一个类型MassiveConnectOptions(我们刚才定义的接口)的对象。所以我们有:

interface MassiveOptionsFactory {
  createMassiveConnectOptions(): Promise<MassiveConnectOptions> | MassiveConnectOptions;
}

太棒了!我们可以直接返回选项对象,或者返回一个解析为选项对象的 Promise。Nest 让支持这两种方式变得非常容易。因此,我们现在看到了异步选项提供程序的“异步”部分发挥作用。

现在,让我们来思考一下:究竟是什么机制会在运行时真正调用options我们的工厂函数,获取返回的对象,并将其提供给需要它的代码部分呢?嗯……如果我们能有一些通用的机制就好了。或许可以尝试一个功能,让我们可以在运行时注册任意对象(或返回对象的函数),然后将该对象传递给构造函数。有人有什么想法吗?😉

当然,我们有很棒的 NestJS 依赖注入系统。看起来很合适!让我们来看看怎么做。

将某些内容绑定到 Nest IoC 容器,并随后将其注入的方法,被封装在一个称为 provider 的对象中我们快速创建一个满足我们需求的选项提供程序。如果您需要快速复习一下自定义提供程序,请立即重读“自定义提供程序”章节。这不会花很长时间。我就在这里等着。

好的,现在你记得我们可以使用如下结构来定义选项提供程序。我们已经直觉地知道我们需要一个工厂提供程序,所以这似乎是正确的结构:

{
  provide: 'MASSIVE_CONNECT_OPTIONS',
  useFactory: // <-- we need to get our options factory inserted here!
  inject:     // <-- we need to supply injectable parameters for useFactory here!
}

让我们把一些事情联系起来。我们已经深入其中了,所以现在是时候快速回顾一下总体情况,并评估一下我们目前所处的位置了:

  1. 我们正在编写构造然后返回动态模块的代码(我们的registerAsync()静态方法将容纳该代码)。
  2. 它返回的动态模块可以导入到其他功能模块中,并提供服务(连接数据库并返回db对象的东西)。
  3. 该服务需要在模块构建时进行配置。更直观的说法是,该服务依赖于动态构建的选项对象
  4. 我们将在运行时使用消费模块提供给我们的类来构建该配置选项对象。
  5. 该类包含一个知道如何返回适当的选项对象的方法。
  6. 我们将使用 NestJS 依赖注入系统来完成繁重的工作,以管理选项对象依赖关系。

好的,我们现在正在进行第 4、5 和 6 步。我们还没有准备好组装整个动态模块。在此之前,我们必须先搞清楚选项提供程序的机制。回到这个任务,我们应该能够看到如何填写之前勾勒出的选项提供程序框架中的空白(参见上面注释的几行)。我们可以根据调用的<-- we need to...方式填写这些值:registerAsync()

@Module({
  imports: [
    MassiveModule.registerAsync({ useClass: ConfigService })
  ],
})

现在让我们根据已知的知识来填充它们。我们将绘制一个对象的静态版本,只是为了看看我们试图在即将编写的代码中动态生成什么:

{
  provide: 'MASSIVE_CONNECT_OPTIONS',
  useFactory: async (ConfigService) =>
    await ConfigService.createMassiveConnectOptions(),
  inject: [ConfigService]
}

现在我们已经弄清楚了生成的选项提供程序应该是什么样子。到目前为止还好吗?重要的是要记住,'MASSIVE_CONNECT_OPTIONS'提供程序只是在动态模块内部实现依赖关系。既然我提到了这一点,我们还没有真正研究依赖于'MASSIVE_CONNECT_OPTIONS'我们努力提供的提供程序的服务。让我们再连接几个点,花点时间快速考虑一下那个服务。这个服务——连接并返回对象的服务db——不出所料,是在MassiveService类中声明的。它出奇的简单:

@Injectable()
export class MassiveService {
  private _massiveClient;

  constructor(@Inject('MASSIVE_CONNECT_OPTIONS') private _massiveConnectOptions) {}

  async connect(): Promise<any> {
    return this._massiveClient
      ? this._massiveClient
      : (this._massiveClient = await massive(this._massiveConnectOptions));
  }
}

该类MassiveService注入了连接选项提供程序,并使用该信息进行异步创建数据库连接所需的 API 调用 ( await massive(this._massiveConnectOptions))。创建连接后,它会缓存该连接,以便在后续调用中返回现有连接。就是这样。这就是为什么我们要费尽周折才能传入选项提供程序

现在,我们已经理清了概念,并勾勒出了动态可配置模块的各个部分。现在,我们准备开始组装它们了。首先,我们将编写一些粘合代码来将它们组合在一起。正如我们在动态模块章节中所学到的,所有这些粘合代码都应该位于模块定义类中。让我们MassiveModule为此创建一个类。我们将在下面的代码中描述具体操作。

@Global()
@Module({})
export class MassiveModule {

  /**
   *  public static register( ... )
   *  omitted here for brevity
   */

  public static registerAsync(
    connectOptions: MassiveConnectAsyncOptions,
  ): DynamicModule {
    return {
      module: MassiveModule,
      providers: [
        MassiveService,
        ...this.createConnectProviders(connectOptions),
      ],
      exports: [MassiveService],
    };
  }

  private static createConnectProviders(
    options: MassiveConnectAsyncOptions,
  ): Provider[] {
    return [
      {
        provide: 'MASSIVE_CONNECT_OPTIONS',
        useFactory: async (optionsFactory: MassiveOptionsFactory) =>
          await optionsFactory.createMassiveConnectOptions(),
        inject: [options.useClass],
      },
      {
        provide: options.useClass,
        useClass: useClass,
      }
    ];
  }

让我们仔细看看这段代码的作用。这才是关键所在,所以花点时间仔细理解一下。想象一下,如果我们插入一条日志语句,显示以下调用的返回值:

registerAsync({ useClass: ConfigService });

我们会看到一个类似这样的物体:

{
  module: MassiveModule,
  providers: [
    MassiveService,
    {
      provide: 'MASSIVE_CONNECT_OPTIONS',
      useFactory: async (optionsFactory: MassiveOptionsFactory) =>
        await optionsFactory.createMassiveConnectOptions(),
      inject: [ ConfigService ],
    },
    {
      provide: ConfigService,
      useClass: ConfigService,
    }
  ],
  exports: [ MassiveService ]
}

这应该很容易识别,因为它可以直接插入到标准@Module()装饰器中来声明模块元数据(嗯,除了属性之外的所有元数据,属性是动态模块 API 的一部分)。用英语描述的话,我们返回一个声明了三个module提供程序的动态模块,并导出其中一个,供其他可能导入它的模块使用。

  • 第一个提供者显然是它MassiveService本身,我们计划在消费者的功能模块中使用它,所以我们适时地导出它。

  • 第二个提供程序('MASSIVE_CONNECT_OPTIONS'仅供 内部使用,用于MassiveService提取其所需的连接选项(请注意,我们没有导出它)。让我们仔细看看这个useFactory构造。请注意,它还有一个inject属性,用于将 注入到工厂函数中。这在“自定义提供程序”章节中ConfigService有详细描述,但基本上,其思路是工厂函数接受可选的输入参数,如果指定了这些参数,则通过从属性数组中注入提供程序来解析。您可能想知道这个可注入项来自哪里。继续阅读😉。injectConfigService

  • 最后,我们还有第三个提供程序,它也仅由我们的动态模块内部使用(因此不会导出),它是我们的 的唯一私有实例ConfigService。因此,Nest 将ConfigService 在动态模块上下文中实例化(这很合理,对吧?我们告诉了模块useClass,这意味着“创建自己的实例”),然后它将被注入到工厂中。

如果你读到这里——恭喜!这是最难的部分。我们刚刚搞清楚了组装动态可配置模块的所有机制。文章的其余部分就很精彩了!

useFactory从上面生成的语法中,还有一点应该显而易见:该类ConfigService必须实现一个createMassiveConnectOptions()方法。如果您已经在使用某种配置模块,该模块实现了各种函数,用于为每个插入的服务返回特定形状的选项,那么这应该是一个熟悉的模式。现在,也许您可​​以更清楚地看到这一切是如何组合在一起的。

异步选项提供程序的变体形式

到目前为止,我们构建的内容允许我们MassiveModule通过传入一个旨在动态提供连接选项的类来配置它。让我们再次回顾一下从消费者的角度来看它是什么样子的:

@Module({
  imports: [
    MassiveModule.registerAsync({ useClass: ConfigService})
  ]
})

我们可以将此称为使用一种useClass技术(又称类提供程序)来配置我们的动态模块。还有其他技术吗?您可能还记得在自定义提供程序章节中看到过其他几种类似的模式。我们可以基于这些模式来建模我们的registerAsync()接口。让我们从消费者模块的角度勾勒出这些技术的样子,然后我们可以轻松地添加对它们的支持。

工厂提供商:useFactory

虽然我们在上一节中确实使用了工厂,但它严格来说是动态模块构造机制的内部实现,而不是可调用 API 的一部分。useFactory当它作为我们registerAsync()方法的一个选项暴露出来时,会是什么样子呢?

@Module({
  imports: [MassiveModule.registerAsync({
    useFactory: () => {
      return {
        host: "localhost",
        port: 5432,
        database: "nest",
        user: "john",
        password: "password"
      }
    }
  })]
})

在上面的示例中,我们提供了一个非常简单的工厂但我们当然可以插入(或传入实现的函数)任何任意复杂的工厂,只要它返回适当的连接对象。

别名提供程序:useExisting

这种有时被忽视的结构实际上非常有用。在我们的上下文中,这意味着我们可以确保复用现有的选项提供程序,而不是实例化一个新的。例如,useClass: ConfigService将导致 Nest 创建并注入我们的 的一个新私有实例ConfigService。在现实世界中,我们通常希望在需要的地方注入一个 的共享实例ConfigService,而不是一个私有副本。这项useExisting技术在这里是我们的朋友。它看起来如下:

@Module({
  imports: [MassiveModule.registerAsync({
    useExisting: ConfigService
  })]
})

支持多种异步选项提供程序技术

我们已经到了最后冲刺阶段。接下来我们将专注于推广和优化我们的registerAsync()方法,以支持上述其他技术。完成后,我们的模块将支持所有三种技术:

  1. useClass - 获取选项提供者的私有实例。
  2. useFactory - 使用函数作为选项提供者。
  3. useExisting - 重新使用现有(共享SINGLETON)服务作为选项提供者。

既然大家都累了,我就直接上代码吧😉。下面我会描述一下关键元素。

@Global()
@Module({
  providers: [MassiveService],
  exports: [MassiveService],
})
export class MassiveModule {

  /**
   *  public static register( ... )
   *  omitted here for brevity
   */

  public static registerAsync(connectOptions: MassiveConnectAsyncOptions): DynamicModule {
    return {
      module: MassivconnectOptions.imports || [],eModule,
      imports:
      providers: [this.createConnectProviders(connectOptions)],
    };
  }

  private static createConnectProviders(
    options: MassiveConnectAsyncOptions,
  ): Provider[] {
    if (options.useExisting || options.useFactory) {
      return [this.createConnectOptionsProvider(options)];
    }

    // for useClass
    return [
      this.createConnectOptionsProvider(options),
      {
        provide: options.useClass,
        useClass: options.useClass,
      },
    ];
  }

  private static createConnectOptionsProvider(
    options: MassiveConnectAsyncOptions,
  ): Provider {
    if (options.useFactory) {

      // for useFactory
      return {
        provide: MASSIVE_CONNECT_OPTIONS,
        useFactory: options.useFactory,
        inject: options.inject || [],
      };
    }

    // For useExisting...
    return {
      provide: MASSIVE_CONNECT_OPTIONS,
      useFactory: async (optionsFactory: MassiveConnectOptionsFactory) =>
        await optionsFactory.createMassiveConnectOptions(),
      inject: [options.useExisting || options.useClass],
    };
  }
}

在讨论代码细节之前,让我们先介绍一些表面上的变化,以确保它们不会让您感到困惑。

  • 我们现在使用常量代替字符串值标记。这是一个简单的最佳实践惯例,在本文档的本节MASSIVE_CONNECT_OPTIONS末尾有介绍
  • 我们没有将它们列在动态构造模块的和属性MassiveService中,而是将它们提升到装饰器元数据中。为什么?一方面是为了风格,另一方面是为了保持代码简洁。这两种方法是等效的。providersexports@Module()

充分理解代码

你应该能够追溯这段代码的路径,了解它是如何独特地处理每种情况的。我强烈建议你做以下练习。registerAsync()在纸上构建一个任意的注册调用,并遍历代码,预测返回的动态模块会是什么样子。这将有力地强化这些模式,并帮助你将所有点牢牢地连接起来。

例如,如果我们要编码:

@Module({
  imports: [MassiveModule.registerAsync({
    useExisting: ConfigService
  })]
})

我们可以预期构建的动态模块具有以下属性:

{
  module: MassiveModule,
  imports: [],
  providers: [
    {
      provide: MASSIVE_CONNECT_OPTIONS,
      useFactory: async (optionsFactory: MassiveConnectOptionsFactory) =>
        await optionsFactory.createMassiveConnectOptions(),
      inject: [ ConfigService ],
    },
  ],
}

(注意:因为模块前面有一个@Module()装饰器,现在列为MassiveService提供者并导出,所以我们生成的动态模块将具有这些属性。上面我们只是展示了动态添加的元素。)

ConfigService再考虑一个问题。在这种情况下,工厂内部的是如何被注入的useExisting?嗯——嘿嘿——这个问题有点棘手。在上面的示例中,我假设它已经在消费模块内部可见——也许是一个全局模块(用 声明的模块@Global())。假设情况并非如此,并且它位于 中,并且ConfigModule尚未以某种方式将 ConfigService 注册为全局提供程序。我们的代码能处理这种情况吗?让我们拭目以待。

我们的注册看起来应该是这样的:

@Module({
  imports: [MassiveModule.registerAsync({
    useExisting: ConfigService,
    imports: [ ConfigModule ]
  })]
})

我们最终的动态模块将如下所示:

{
  module: MassiveModule,
  imports: [ ConfigModule ],
  providers: [
    {
      provide: MASSIVE_CONNECT_OPTIONS,
      useFactory: async (optionsFactory: MassiveConnectOptionsFactory) =>
        await optionsFactory.createMassiveConnectOptions(),
      inject: [ ConfigService ],
    },
  ],
}

你知道这些碎片是如何组合在一起的吗?

另一个练习是思考使用useClassvs.时代码路径的差异useExisting。重点在于我们如何实例化一个新ConfigService对象,或者如何注入一个现有对象。这些细节值得研究,因为这些概念将帮助你全面了解 NestJS 模块和提供程序如何以一致的方式协同工作。但这篇文章已经太长了,所以我把这留给你作为练习,亲爱的读者。😄

如果您有任何疑问,请随时在下面的评论中提问!

结论

上面演示的模式在 Nest 的附加模块中得到广泛应用,例如@nestjs/jwt@nestjs/passport@nestjs/typeorm。希望您现在不仅了解了这些模式的强大功能,还了解如何在自己的项目中使用它们。

既然您已经有了路线图,下一步不妨考虑浏览一下这些模块的源代码。您还可以在@nestjsplus/massive 仓库中看到本文代码的略微改进版本(如果您喜欢这篇文章,不妨在这里快速点个⭐😉)。本文代码与该仓库的主要区别在于,生产版本需要处理多个异步选项提供程序,因此需要稍微多一点的管道工作。

现在,您可以自信地开始在自己的代码中使用这些强大的模式来创建能够在各种环境中可靠工作的强大而灵活的模块。

作为最后的奖励,如果您正在构建一个供公众使用的开源包,只需将此技术与我上一篇关于发布 NPM 包的文章中描述的步骤结合起来,就可以了。

欢迎在下方评论区提问、评论或建议,或者直接打个招呼。欢迎加入我们的Discord,一起愉快地讨论 NestJS。我在那里的用户名是Y Prospect

文章来源:https://dev.to/nestjs/advanced-nestjs-how-to-build-completely-dynamic-nestjs-modules-1370
PREV
HowTo:我的终端和 Shell 设置 - Hyper.js + ZSH + starship ☄🔥
NEXT
JavaScript 中的基本函数式编程模式