阅读 NestJS 中文文档和神光的 Nest 通关秘籍后的学习收获。
NestJS 是一个高度模块化的node框架,推荐鼓励使用模块(Module)来组织代码。
下面就来学习一下模块的基本使用。
- 模块的基本使用
- 模块相互导入导出
- 全局模块
- 静态模块
- 动态模块
模块基本使用
NestJS 项目肯定会存在一个根模块(app.module.ts)
, 和可能会存在其他的业务模块。
模块是带有@Module()
装饰器的类。@Module()
装饰器提供元数据,供 nest 组织程序结构。
先来看看@Module模块的定义:
当然针对元数据里面的各自类型,可以自己具体去看看。
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
这是一个最简单的模块示例:注册控制器和注册提供者,自动实例化,添加到 IOC 容器中。
模块导入导出
通过 nest 指令:
nest g res test # 创建 test 模块
就会创建一个 CRUD 的 test 模块。其中 test.module.ts
的代码如下:
import { Module } from '@nestjs/common';
import { TestService } from './test.service';
import { TestController } from './test.controller';
@Module({
controllers: [TestController],
providers: [TestService],
exports: [TestService], // 导出
})
export class TestModule {}
当 test 模块导出了 TestService,那么就可以在 app.module.ts 中导入了(切记:导入的是 Test 整个模块)
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TestModule } from 'src/modules/test/test.module';
@Module({
imports: [TestModule], // 导入
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
当项目运行时,nest 就会从根模块开始解析,构建模块依赖图。在此过程中, test 模块就会被注入,就可以使用其中的服务。
这就是模块的导入导出。
还有一种特殊情况:当 test 模块引入了其他模块时候,让导入 test 模块时,也会把其他模块一起导入。
可以再创建一个公共模块common
:
nest g res common # 创建 common 模块
然后再 Test 模块中导入 common 模块
import { Module } from '@nestjs/common';
import { TestService } from './test.service';
import { TestController } from './test.controller';
import { CommonModule } from 'src/modules/common/common.module';
@Module({
imports: [CommonModule], // 导入 common 模块
controllers: [TestController],
providers: [TestService],
exports: [TestService, CommonModule], // 导出 common 模块(重导)
})
export class TestModule {}
当 app.module.ts 再次导入 test 模块时,也会顺便把 common 模块导入,且可以使用其中提供的服务。
重导:该机制在组织大型应用程序时非常有用,因为它可以减少模块间的耦合,同时简化外部模块的导入过程。
全局模块
针对某些通用的模块(比如上面的 common 模块),如果在多个模块中使用,就要导入多次,还是比较繁琐的,写很多重复的代码。
那么处理这样的情况,就可以设置成一个全局模块@Global()
,只需要在根模块到导入后(为了构建模块依赖图),当其他的模块使用时,就不需要导入了
特别声明:全局模块的不需要导入,是指不需要导入 CommonModule;但是在使用服务的时候,还是需要导入服务的,也就是 CommonService。简单理解,全局模块不等于全局变量,不能直接使用,需要间接导入使用。
在 common.module.ts
中申明全局模块。
import { Module, Global } from '@nestjs/common';
import { CommonService } from './common.service';
import { CommonController } from './common.controller';
@Global() // 申明全局模块
@Module({
controllers: [CommonController],
providers: [CommonService],
exports: [CommonService],
})
export class CommonModule {}
在 app.module.ts
中导入,目的是为了构建模块依赖图,加入到 IOC 容器中。
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CommonModule } from 'src/modules/common/common.module';
@Module({
imports: [
CommonModule, // here: 公共模块导入
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
做完上面操作之后,在其他模块中,只需要导入相应服务,使用即可。
使用注意事项:不推荐大量使用全局模块。
循环依赖模块
A 模块依赖 B 模块,B 模块又依赖 A 模块,这种就叫做循环依赖。
创建两个模块
nest g mo aaa # 创建 aaa 模块
nest g mo bbb # 创建 bbb 模块
然后它们之间相互引用
然后再 app.module.ts 中引入
@Module({
imports: [
BbbModule,
AaaModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
当运行项目时,,就会发现报错:
错误原因: 在解析 BbbModule 的时候,它的第一个 imports 是 undefined。因为 Nest 创建 Module 的时候会递归创建它的依赖,而它的依赖又依赖了这个 Module,所以没法创建成功,拿到的就是 undefined。
解决方案:用 forwardRef
方式来解决该错误。
这样程序就能正常运行了。
静态模块
何为模块:模块定义了一组组件,如提供者和控制器,它们作为整个应用程序的模块部分相互配合。
何为宿主模块:简单举例,UsersModule 是 UsersService 的宿主模块。
何为消费模块:一个模块的 Service 使用了另外一个模块的 Service,称其为消费模块
静态模块绑定:Nest 需要将模块连接在一起,所需的所有信息已经在宿主模块和消费模块中声明(类似,ES6 中的 import 关键词在编译时的静态分析)。
在上面的所有案例中,都是属于静态模块;其特点:消费模块没有机会去影响宿主模块中的提供者配置。
简单理解,也就是它的内容是固定不变的,每次 import 都是一样。
动态模块
在实际开发过程中,就会存在一种情况,有些提供者的配置,需要由消费模块来提供(例如根据环境变量),调用相同的 API,产生不一样的结果。
那么这时候动态模块,就可以帮助我们实现类似功能。(类似 vue 中的插槽)
案例细说
反向推导 Nest 中文文档: 动态模块 案例
先创建一个 config
模块
nest g res config # 创建 config 模块
第一步:注册动态模块
import { ConfigModule } from "./modules/config/config.module";
@Module({
imports: [ConfigModule.register({ folder: "../../../config" })],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
- 在 app.module.ts 中导入配置模块
ConfigModule
- ConfigModule 是一个类,但是具有一个静态方法
register
, 调用该方法,返回一个DynamicModule
类型的动态模块。 - register 方法接收一个入参,参数为配置对象,在其函数内部中读取处理。
第二步:定义一个动态模块(ConfigModule)
import { Module, DynamicModule } from "@nestjs/common";
import { ConfigService } from "./config.service";
import { CONFIG_OPTIONS_TOKEN } from "src/common/constants";
@Module({})
export class ConfigModule {
static register(options: Record<string, any>): DynamicModule {
return {
module: ConfigModule,
providers: [
{ provide: CONFIG_OPTIONS_TOKEN, useValue: options }, // 自定义提供者
ConfigService,
],
exports: [ConfigService],
};
}
}
- 定义一个具有 register 静态方法的类,使用装饰器
@Module()
。 - 对 register 的参数,使用
useValue
的形式进行自定义提供者
,进行 IOC 容器初始化,目的是为了在 ConfigService 中使用。 - register 的返回值是一个对象,其对象中必须包含 module 属性,其值为 module 类名,其他属性与静态模块保持一致
- 自定义提供者 provide 采用的字符串形式,在后面的
ConfigService
中又要使用该字符串,从而抽离成了一个常量(CONFIG_OPTIONS_TOKEN)。
第三步:读取配置,进行相应的逻辑操作
import { Injectable, Inject } from "@nestjs/common";
import { EnvConfig } from "./interfaces/index.interface";
import * as process from "process";
import * as path from "path";
import * as fs from "fs";
import * as dotenv from "dotenv";
import { CONFIG_OPTIONS_TOKEN } from "src/common/constants";
@Injectable()
export class ConfigService {
private readonly envConfig: EnvConfig;
// 构造函数注入自定义提供者
constructor(
@Inject(CONFIG_OPTIONS_TOKEN)
private readonly configOptions: { folder: string }
) {
/**
* 假设配置文件目录 config/.env.development 或者 config/.env.production
* 组装完整路径
* 读取配置文件信息
*/
const filePath = `.env.${process.env.NODE_ENV || "development"}`;
const envFile = path.resolve(__dirname, configOptions.folder, filePath);
this.envConfig = dotenv.parse(fs.readFileSync(envFile));
}
/**
*获取配置信息
*/
getAll(): EnvConfig {
return this.envConfig;
}
}
- 注入 ConfigModule 中的自定义提供者,获取配置信息
- 然后根据配置信息,读取文件内容,保存下来(模拟逻辑)。
上面就是动态模块的大致使用流程,在 import 一个模块的时候,传入参数,然后动态生成模块的内容。
这就是 DynamicModule。
在上面的案例中,register
方法其实叫啥都行,但 nest 约定了 3 种方法名(同步 / 异步):
- register / registerAsync
- forRoot / forRootAsync
- forFeature / forFeatureAsync
约定不同的函数名,干不同的事情:
- register:每次使用动态模块就传递不同的配置
- forRoot:配置一次动态模块用多次(类似全局配置)
- forFeature:forRoot 用于全局配置,但是针对某些模块,还需要一些独特的配置(局部配置),那么就使用 forFeature,产生局部的动态模块。也就是一个动态模块,就有两个静态方法(forRoot, forFeature)。
forRoot、forFeature、register 本质上没区别,只是我们约定了它们使用上的一些区别。
Nest 还提供了一种可配置的模块构建器:Nest 提供 ConfigurableModuleBuilder
类,来创建一个动态模块的简单模板类。
// config.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface ConfigModuleOptions {
folder: string;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().build();
实例化 ConfigurableModuleBuilder,调用 build 方法,生成一个 class,这个 class 里就带了 register、registerAsync 方法;也生成一个 token,用于自定义提供者。
简单的来说,ConfigurableModuleBuilder
内部帮我们实现了动态模块的第二步
- 定义了 register 方法和 registerAsync 方法。
- 定义了自定义提供者,其 token 为
MODULE_OPTIONS_TOKEN
接下里,ConfigModule 只需要继承 ConfigurableModuleClass 类即可。
// config.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurableModuleClass } from './config.module-definition';
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule extends ConfigurableModuleClass {} // 继承
然后就可以正常的使用动态模块了。
如果想修改动态模块的静态方法名呢?
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>()
.setClassMethodName('forRoot') // forFeature
.build();
当使用了 setClassMethodName
方法之后,就可以改变动态模块的静态方法名了。
并且还可以设置额外选项,怎么说呢?
在上面的示例中,其中的配置项为 {folder: string}
在此基础上,可以内置额外选项,也就是新增一个属性(比如:global 属性)。
那么在使用动态模块时,就会新增一个属性 global。
但是这种会存在一个问题,就是在 service 中使用,使用 global 属性时,就会提示类型找不到的报错信息。为什么呢?
因为原来的配置项定义类型为 {folder: string}
,但是现在多了一个 global,定义类型没有修改,那么肯定会提示属性不存在的相关错误。如何解决呢?
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } =
new ConfigurableModuleBuilder<ConfigModuleOptions>()
.setClassMethodName('forRoot') // forFeature
.setExtras({ global: true }, (definition, extras) => {
return {
...definition,
global: extras.global,
};
})
.build();
build()
之后, 生成了 OPTIONS_TYPE
对象,在 service 中使用类型的地方只需要 typeof OPTIONS_TYPE
, 就有完整的类型提示。
总结
- 模块的基本使用,使用
@Module
定义模块,里面的元数据接收哪些(imports,exports, controllers, providers) - 模块的导入导出,如何相互引用的。
- 全局模块使用
@Global
定义。 - 理解何为静态模块?何为动态模块?静态模块就是导入的东西始终不会变化,在大多数场景下都是静态模块;而动态模块就是根据外面的配置表现不同的形式。
- 理解动态模块的基本使用流程, 定义一个类,里面包含静态方法,返回一个动态模块
DynamicModule
。返回的对象中必须包含 module 属性,其他的跟静态模块的元数据保持一致。(手动的) - Nest 内部也提供一种创建动态模块的方法
ConfigurableModuleBuilder
,然后返回对应的信息(类,token 等等)(自动的)