【译】NestJS 进阶:如何构建一个完全动态的模块

原文地址:https://dev.to/nestjs/advanced-nestjs-how-to-build-completely-dynamic-nestjs-modules-1370

注:原文作者为 NestJS 核心开发成员之一。

你已经注意到了诸如 @nestjs/jwt@nestjs/passport@nestjs/typeorm 这些外置的 NestJS 模块中可以用很酷的方式进行动态配置,并且想着到如何在你自己的模块中实现?来对地方了!😃

简介

NestJS 的文档网站上,我们(原文作者)最近添加了一篇新章节来介绍动态模块。这是相当高级的一章,随着最近在 异步 Provider 章节的一些重大更新,Nest 开发者有了一些非常新颖的资源来帮助构建一个可配置的 module,使之可以集成到复杂、健全的应用中。

本文建立在这个基础之上,并更进一步。NestJS 的一个标志就是使得 异步编程 变得非常直截了当。Nest 完全拥抱 Node.js 的 Promise 和 async/await 范式。结合 Nest 标志性的依赖注入特性,是非常强有力的组合。让我们看下这些特性如何被用于创建 动态可配置模块(dynamically configurable modules)。掌握这些技巧可以使你的模块可以再任何情形下被复用。浙江完整启用上下文感知,可重用的软件包(库),让你能够组装可在云服务提供商和整个 DevOps 流程中顺利部署的程序 —— 从开发环境到生产环境。

基础的动态模块

动态模块章节,代码示例的最终结果是能够传入一个 options 对象来配置模块。读完那一章,我们知道了下面这段代码如何通过动态模块 API 进行工作。

1
2
3
4
5
6
7
8
9
10
11
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 模块中,你可以这样构造:

1
2
3
4
5
@Module({
imports: [
JwtModule.registerAsync({ useClass: ConfigService }),
]
})

这种构造方式下,不仅模块是动态配置的,传递给动态模块的选项本身就是动态构造的。这是最好的高阶功能。配置项是由 ConfigService 类从环境变量中解析出来的值提供的,这也意味着它们可以完全根据你的代码来改变。和 ConfigModule.register({ folder: './config' }) 这种硬编码参数的方法对比,你可以显而易见地看到谁赢了。

在这篇文章中,我们将会进一步探索为什么呃逆可能需要这个特性并且如何实现它。在继续阅读下面的部分之前首先确保你已经牢牢掌握在 自定义 Provider动态模块 章节中的概念。

异步配置项 Providers 用例

上面的段落里介绍的已经很详尽!那么什么是异步配置项 Provider?

要回答这个问题,首先再思考一遍上面的例子(ConfigModule.register({ folder: './config' }))传递了一个 静态对象register() 方法。根据我们在 动态模块 章节学到的,这个配置项对象是用来定制模块的行为的。(如果对这些概念赶到模式,在继续之前先复习这一章 )。如上所述,我们现在开始让这些概念更进一步,并且让我们的 配置项 对象在运行时动态提供。

异步(动态)配置项示例

为了给文章之后的部分提供一个具体的示例,我准备介绍一个新的模块。然后我们将逐步介绍如何在这种情况下使用配置项 Providers。

我最近发布了了 @nestjsplus/massive 模块 让一个 Nest 项目可以轻松使用 MassiveJS 库来处理一些数据库工作。我们将会学习这个库的结构和并使用部分代码在本文中进行分析。简要概括一下,这个包将 MassiveJS 封装为一个 Nest 模块。MassiveJS 通过 db 对象给每个消费者模块(例如每个功能模块)提供了它的全部 API,db 对象有如下一些方法:

  • db.find() 取回数据库记录
  • db.update() 更新数据库记录
  • db.myFunction() 执行脚本或者数据库程序/函数

@nestjsplus/massive 包的主要功能是创建一个数据库连接并且返回 db 对象。在我们的功能模块中,之后我们便可以使用挂载在 db 对象上的方法来进行数据库的访问操作,像上面所示那样。在最开始,应该清楚为了建立数据库连接,我们需要传递一些连接参数。就我们而言,使用 PostgresSQL,那些参数应该像这样:

1
2
3
4
5
6
7
{
user: 'john',
password: 'password',
host: 'localhost',
port: 5432,
database: 'nest',
}

我们很快意识到在我们的 Nest 应用中硬编码这些连接参数并不是最佳的。常规的方法是通过某些 配置服务 来提供它们。这正是如上所示,我们可以使用 @nestjs/jwt 模块中执行的操作。简而言之,这正是 异步配置项 Providers 的目的。现在让我们看看如何在我们的 Massive 模块中实现。

编码实现异步配置项 Providers

首先,我们可以想象支持像我们在 @nestjs/jwt 中找到的构造方法一样的导入语句,像这样:

1
2
3
4
5
@Module({
imports: [
MassiveModule.registerAsync({ useClass: ConfigService })
],
})

如果这看着不面熟,那就先快速过一遍 自定义 Providers 章节。相似之处是有意的。我们从自定义 Providers 学到的概念中获得灵感来更加灵活地为我们的动态模块提供配置项。

让我们深入探索这个实现。这将会是很长的一段,所以深呼吸,或者倒满你的咖啡杯,但是不要担心,我们能做到!😄。记住我们是在体验一种设计模式,一旦你理解它,你会 自信地 将其作为样板复制粘贴到任何你需要构建动态配置模块的地方。但是在复制粘贴开始之前,我们先确保理解模板使得我们可以按照我们的需求进行定制。只需要记住你将不必每次都从头开始编写!

首先,让我们回顾一下,我们期望上述的 registerAsync(...) 构造方法能够完成的事。最基本的要求是我们喊一声:“Hey,模块!我不想在代码里给你配置项。我给你一个类你调用它的方法来获取配置项如何?”。这将使得我们可以在运行时动态生成配置项方面具有很大的灵活性。

这意味着我们的动态模块在这种情况下需要比静态配置项技术多做一些工作,来获得它的连接选项。我们将努力完成目标。我们从声明一些定义开始。我们尝试给MassiveJS 提供它所期望的连接选项,所以我们首先创建一个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 connection options文档中查看更多),但是我们现在先看选出的基础的,这些是我们建立一个连接所必须的。PS:我们使用 JSDoc 来注释这些成员,这样可以再稍后的使用中带来很好的智能提示体验。

下一个概念如下。自从调用我们的模块(调用 registerAsync() 来引入 MassiveJS 的模块)交给我们一个类并且期望我们调用类中的一个方法,我们可以推测我们大概需要使用某种工厂模式。换句话说,我们必须在某处实例化这个类,调用一个它的方法,并且使用这个方法调用后的返回值作为连接参数,对吗?听起来像是一个工厂。让我们现在来实现这个概念。

让我们用一个接口来描述我们的预期的工厂。这个方法可以叫 createMassiveConnectOptions()。它需要返回一个 MassiveConnectOptions (一分钟前我们定义的接口)类型的对象,于是我们得到:

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

很好!我们可以直接返回这个对象,或者返回一个 Promise 包裹的对象。Nest 使得同时支持这两种返回值变得非常简单。因此,我们可以看到我们的异步配置项 Provider 的异步部分即将发挥作用。

现在,让我们问这么一个问题:使用什么机制在运行时调用我们的工厂函数,取到 options 对象,并且使之可以用在我们需要它的代码中?如果我们有某种通用的机制。也许我们可以再运行时注册任意一个对象(或返回对象的函数),然后将该对象传递到一个构造函数中。有人还有别的想法嘛?

当然,我们已经有了一个很棒的 NestJS 依赖注入系统了。那看起来很合适,让我们康康怎么来实现它。

将某样东西绑定到 Nest IoC 容器,并且稍后注入它,在一个对象中被捕获的方法称为 Provider。让我们来搞一个我们需要的 Provider。如果你需要快速复习一下自定义 Providers,重新读一下这一章节,不会花费太长时间,我在这等你哦= =。

好了,现在你记得我们可以使用如下的结构来定义我们的配置项 Provider。我们已经靠直觉知道我们需要一个工厂 Provider,这看起来是正确的结构:

1
2
3
4
5
{
provide: 'MASSIVE_CONNECT_OPTIONS',
useFactory: // <-- 我们需要在这里差我们的配置项工厂函数!
inject: // <-- 我们需要在这里给 useFactory 提供注入的参数!
}

让我们把一些东西绑在一起。我们已经很深入了,所以现在是纵观大局最好的复习时间并且评估我们所完成的进度:

  1. 我们正在写编码实现一个返回动态模块的结构(我们的 registerAsync() 方法将会收留这些代码)。
  2. 动态模块的返回值可以被其它功能模块导入,并且提供一个服务(连接到数据库并且返回一个 db 对象的那个东西)。
  3. 那个服务需要在模块构造的时候被配置妥当。用更容易理解的方式说,这个服务依赖于动态构建的配置项对象(options object)。
  4. 我们准备在运行时使用调用改模块的模块传入的类来构建这个配置项。
  5. 这个类包含一个知道如何返回恰当的参数对象的方法。
  6. 我们准备使用 NestJS 的依赖注入系统来为我们做这项繁重的管理配置项依赖的工作。

好了,我们现在正在处理第4、5、6步。我们尚未准备好组装整个动态模块。在此之前,我们必须先确定我们的参数项 Provider 的机制。回到我们的任务,我们应该能够填写先前勾勒的配置项 Provider(刚才声明 <-- 我们需要 的地方)中的空白。我们可以基于 registerAsync() 的调用方式来填写那些地方:

1
2
3
4
5
@Module({
imports: [
MassiveModule.registerAsync({ useClass: ConfigService })
],
})

我们继续并且现在来基于我们了解的来填写它们。我们将勾勒一个这个对象的静态版本,只是为了看看我们正在尝试的动态生成的代码该怎么写:

1
2
3
4
5
6
{
provide: 'MASSIVE_CONNECT_OPTIONS',
useFactory: async (ConfigService) =>
await ConfigService.createMassiveConnectOptions(),
inject: [ConfigService]
}

我们现在定好了我们的配置项 Provider 怎么写。目前还好?很重要的一点是需要记住 'MASSIVE_CONNECT_OPTIONS' Provider 只是在动态模块内部实现了一个依赖。现在我提一下,我们还没有看到依赖我们努力提供 'MASSIVE_CONNECT_OPTIONS' Provider 的服务呢。 那个连接到数据库并且返回 db 对象的服务,可以想象到定义在 MassiveService 类中,它显而易见是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Injectable()
export class MassiveService {
private _massiveClient;

// 注入我们的 配置项 Provider 依赖
constructor(@Inject('MASSIVE_CONNECT_OPTIONS') private _massiveConnectOptions) {}

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

MassiveService 类注入了连接配置项 Provider 并且使用这些信息异步创建了数据库连接(await massive(this._massiveConnectOptions))。一旦创建它会缓存连接,因此在后续的调用中可以返回已存在的连接。就是这样,这就是为什么我们跨越层层障碍给它传入我们的配置项 Provider 的原因。

我们现在已经弄清楚了概念,并且画好了我们的动态可配置模块的一部分草图。我们已经准备组装他们了。首先我们会写一些 粘合代码(glue code)将他们全部结合到一起。根据我们在 动态模块 章节所了解到的,所有的粘合代码应该在模块定义类中。我们创建一个 MassiveModule 模块吧。稍后再描述这段代码做了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Global()
@Module({})
export class MassiveModule {

/**
* public static register( ... )
* 简洁起见,在这先省略
*/

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: options.useClass,
}
];
}

注:其中作者遗漏了 MassiveConnectAsyncOptions 接口的定义,并在评论中补充:

1
2
3
4
5
6
7
8
9
export interface MassiveConnectAsyncOptions
extends Pick<ModuleMetadata, 'imports'> {
inject?: any[];
useExisting?: Type<MassiveOptionsFactory>;
useClass?: Type<MassiveOptionsFactory>;
useFactory?: (
...args: any[]
) => Promise<MassiveConnectOptions> | MassiveConnectOptions;
}

让我们来了解一下这段代码做了什么。我们真正开始上路了,所以花点时间小心理解。考虑到如果我们要插入一条打印日志的语句来显示下面的调用的返回值:

1
registerAsync({ useClass: ConfigService });

我们会看到很像下面的这个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
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)属性)。说人话:我们返回了一个声明了三个 Provider 的动态模块,导出了他们中的一个供其他可能引入这个模块的模块使用。

  • 第一个 Provider 显而易见是 MassiveService 自身,我们计划在引用该模块的模块中使用的,所以我们明确地导出了它。

  • 第二个 Provider('MASSIVE_CONNECT_OPTIONS')只供 MassiveService 内部获取它所需要的配置项使用(注意,我们没有导出它)。让我们再仔细端详下 useFactory 的结构。注意它还有一个 inject 属性,用来注入 ConfigService 到我们的工厂函数。在里的 自定义 Provider 章节 中有详细讲到,但是最基础的是,在 inject 参数数组中提供可 Provider,可以供这个工厂函数能够获取并使用。你可能想知道 ConfigService 从哪来的,继续读下去😉。

  • 最终,我们提供第三个 Provider,同样也只是供我们的模块内部使用(因此也不导出),这是我们的 ConfigService 独享的 moment 单个实例。然后,Nest 就准备在动态模块上下文(这很有道理吧?我们告诉我们的模块 useClass, 意思是:“创建你自己的实例”)中实例化了一个 ConfigService,并且会被注入到我们的工厂函数中。

如果你走到了这里,那么恭喜!这是最困难的部分。我们已经搞定了组织一个可动态配置的模块的所有结构。文章剩下的部分都是锦上添花!

另一件显而易见的的事情是我们刚才创建的 useFactory 语法要求 ConfigService 类必须声明一个 createMassiveConnectOptions() 方法。如果你已经用过一些可配置模块的话,那这声明各种方法来为每一个需要插入的服务返回特定形状的配置项的方式你一定不会陌生。现在可能你会清楚了他们都是如何适配的。

异步配置项的变体

我们目前实现的允许想要动态提供连接配置项的时候通过传给 MassiveModule 一个类来配置。让我们再次来回想一下,从使用者的角度来看:

1
2
3
4
5
@Module({
imports: [
MassiveModule.registerAsync({ useClass: ConfigService})
]
})

我们可以称之为使用 useClass 技术(又名:类 Provider)来配置我们的动态模块。还有别的技术吗?你可能回想起在 自定义 Provider 章节中还有几个其他相似的模式。我们可以基于那些模式定义我们的 registerAsync() 接口。我们先来勾勒一下那些技术从使用者模块的角度看应该是什么样子吧,之后我们便能轻松地支持他们。

工厂 Provider:useFactory

尽管我们在上一节中确实使用了工厂,但是对动态模块构造机制来说是完全内部的,而不是可调用API的一部分。当为我们的registerAsync() 暴露选项时使用useFactory会是怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
@Module({
imports: [MassiveModule.registerAsync({
useFactory: () => {
return {
host: "localhost",
port: 5432,
database: "nest",
user: "john",
password: "password"
}
}
})]
})

在上面的简单示例中,我们提供了一个非常简单的工厂函数,但是我们可以插入(或传入函数声明)任意复杂的工厂,主要它能够返回适当的连接配置项对象。

别名 Provider:useExisting

这个有时被忽略的构造方式实际上很有用。在我们的上下文中,这意味着我们可以复用已经存在的配置项 Provider 而不是实例化一个新的。例如,useClass: ConfigService 会导致 Nest 创建并注入一个新的ConfigService 私有实例。实际上,我们通常想要可以再任何需要的地方注入的可共享的 ConfigServer 单例,而不是一个私有的副本。useExisting 技术便是我们的好朋友。它可以长这个样子:

1
2
3
4
5
@Module({
imports: [MassiveModule.registerAsync({
useExisting: ConfigService
})]
})

支持多种异步配置项 Provider 的技巧

回到原点。我们现在准备关注于整理和优化我们的 registerAsync() 方法,使它支持上面所说的其他的技巧。当我们完成,我们的模块将会支持所有的三种技术:

  1. useClass:来获取一个配置项 Provider 的私有实例。
  2. useFactory:使用一个函数作为配置项 Provider。
  3. useExisting:复用一个已经存在的(共享的,单例(SINGLETON))服务作为配置项 Provider。

因为我们现在都很疲倦了,我准备直接跳到代码的部分😉。我会想下面这样描述关键要素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Global()
@Module({
providers: [MassiveService],
exports: [MassiveService],
})
export class MassiveModule {

/**
* public static register( ... )
* 简洁起见,在这先省略
*/

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

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

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

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

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

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

在开始讨论代码的细节之前,让我们先解释一些微小的变化,避免被这些变化绊倒。

  • 我们现在在需要使用字符值 token 的地方使用常量 MASSIVE_CONNECT_OPTIONS ,这是一个很简单的实践案例,在文档的这个章节有提到
  • 我们将动态构建模块的 providersexports 中的 MassiveService 提到了模块类外部的 @Module() 注解元信息中而不是在动态返回的模块结构中列出来。为什么呢?一部分是为了样式,另一部分是为了保持代码整洁。这两种方式是等价的。

充分理解代码

你应该能够通过代码追踪发现它如何唯一且正确地处理每一种情况。我强烈推荐你做一下下面的练习。构造任意一个 registerAsync() 注册调用的方法,然后通过这个方法来推测一个返回动态。这将极大地增强并帮助你连接所有的点。

例如,如果我们这么写:

1
2
3
4
5
@Module({
imports: [MassiveModule.registerAsync({
useExisting: ConfigService
})]
})

我们会期望一个动态模块具有以下属性:

1
2
3
4
5
6
7
8
9
10
11
12
{
module: MassiveModule,
imports: [],
providers: [
{
provide: MASSIVE_CONNECT_OPTIONS,
useFactory: async (optionsFactory: MassiveConnectOptionsFactory) =>
await optionsFactory.createMassiveConnectOptions(),
inject: [ ConfigService ],
},
],
}

(注意:由于这个模块已经有一个列出了 MassiveService 作为 providers 和 exports 的 @Module() 注解,我们最终动态生成的模块也会有这些属性。上面我们只是展示动态添加所需要的元素。)

思考一个问题。使用 useExisting 方式时,ConfigService 是如何注入并在工厂函数中可用的呢?哈哈哈(原文就在哈哈),这是个小问题。在上面的示例中,我假设在使用者模块中已经可以见到这个 ConfigService 了——可能是一个全局模块(通过 @Gloabl() 声明)。现在假设不是这样,在 ConfigModule 中并没有在某处注册 ConfigProvider 为一个全局的 Provider。那么我们的代码还有用吗?

我们的注册代码可能用如下的代码代替:

1
2
3
4
5
6
@Module({
imports: [MassiveModule.registerAsync({
useExisting: ConfigService,
imports: [ ConfigModule ]
})]
})

我们的动态模块将会看起来像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
{
module: MassiveModule,
imports: [ ConfigModule ],
providers: [
{
provide: MASSIVE_CONNECT_OPTIONS,
useFactory: async (optionsFactory: MassiveConnectOptionsFactory) =>
await optionsFactory.createMassiveConnectOptions(),
inject: [ ConfigService ],
},
],
}

你能看出来他们是如何组合在一起的吗?

另一个练习是思考代码在使用 useClassuseExisting 之间有什么不同。要点是我们是实例化一个一个新的 ConfigService 对象,还是注入一个已有的对象。搞定这些细节是很有价值的,因为这些概念将让你全面了解 NestJS 模块和 Provider 如何结合到一起的。但是这篇文章已经太长了,所以就当做留给亲爱的读者的一个练习题吧。

总结

上面说明的模式在 Nest 的附加模块中到处可见,诸如 @nestjs/jwt@nestjs/passport@nestjs/typeorm。希望你现在已经不止清楚了这些模式是什么,更明白了如何在你自己的模块中实现它们。

下一步,你可能想考虑看下这些模块的源代码,现在你有了一个路线图。你也可以看下本文章中写到的 @nestjsplus/massive 仓库 (希望能给一个小小的 ⭐,如果你喜欢这篇文章的话😉)。本文和仓库中的代码最主要的不同是生产版本需要处理多个异步的配置项 Providers,所以还是有一点不同的。

现在你可以自信地开始在你自己的代码中使用这些给力的模式来创建可以运行在各种各样的环境中的强大且灵活的模块了。

作为最终的福利,如果你正在构建一个开源的库,只需要结合我上一篇文章提到的发布 Nest 包到 NPM,你就都能搞定了。