掘金 后端 ( ) • 2024-05-05 23:11

theme: smartblue highlight: a11y-light

02_cover.png

阅读 NestJS 中文文档和神光的 Nest 通关秘籍后的学习收获。

NestJS 是一个高度模块化的node框架,推荐鼓励使用模块(Module)来组织代码。

下面就来学习一下模块的基本使用。

模块基本使用

NestJS 项目肯定会存在一个根模块(app.module.ts), 和可能会存在其他的业务模块。

模块是带有@Module()装饰器的类。@Module()装饰器提供元数据,供 nest 组织程序结构。

先来看看@Module模块的定义:

02_01.png

当然针对元数据里面的各自类型,可以自己具体去看看。

// 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 {}

做完上面操作之后,在其他模块中,只需要导入相应服务,使用即可。

使用注意事项:不推荐大量使用全局模块。

静态模块

何为模块:模块定义了一组组件,如提供者控制器,它们作为整个应用程序的模块部分相互配合。

何为宿主模块:简单举例,UsersModuleUsersService 的宿主模块。

何为消费模块:一个模块的 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 {}
  1. 在 app.module.ts 中导入配置模块 ConfigModule
  2. ConfigModule 是一个类,但是具有一个静态方法 register, 调用该方法,返回一个 DynamicModule类型的动态模块。
  3. 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],
    };
  }
}
  1. 定义一个具有 register 静态方法的类,使用装饰器 @Module()
  2. 对 register 的参数,使用 useValue 的形式进行自定义提供者,进行 IOC 容器初始化,目的是为了在 ConfigService 中使用。
  3. register 的返回值是一个对象,其对象中必须包含 module 属性,其值为 module 类名,其他属性与静态模块保持一致
  4. 自定义提供者 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;
  }
}
  1. 注入 ConfigModule 中的自定义提供者,获取配置信息
  2. 然后根据配置信息,读取文件内容,保存下来(模拟逻辑)。

上面就是动态模块的大致使用流程,在 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 内部帮我们实现了动态模块的第二步

  1. 定义了 register 方法和 registerAsync 方法。
  2. 定义了自定义提供者,其 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}

02_02.png

在此基础上,可以内置额外选项,也就是新增一个属性(比如:global 属性)。

02_03.png

那么在使用动态模块时,就会新增一个属性 global。

02_04.png

但是这种会存在一个问题,就是在 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 , 就有完整的类型提示。

总结

  1. 模块的基本使用,使用 @Module定义模块,里面的元数据接收哪些(imports,exports, controllers, providers)
  2. 模块的导入导出,如何相互引用的。
  3. 全局模块使用 @Global 定义。
  4. 理解何为静态模块?何为动态模块?静态模块就是导入的东西始终不会变化,在大多数场景下都是静态模块;而动态模块就是根据外面的配置表现不同的形式。
  5. 理解动态模块的基本使用流程, 定义一个类,里面包含静态方法,返回一个动态模块 DynamicModule。返回的对象中必须包含 module 属性,其他的跟静态模块的元数据保持一致。(手动的)
  6. Nest 内部也提供一种创建动态模块的方法ConfigurableModuleBuilder,然后返回对应的信息(类,token 等等)(自动的)