深入了解Nest的模块Module

8,277 阅读12分钟

上文Provider中提到,将某一类特定功能最终包装到一个模块(Module)中,每个Nest都至少要有一个Module。每个模块类必须用@Module装饰器加上原数据(Metadata)来组织应用程序模块。

@Module装饰器

@Module装饰器接受一个对象参数,在Nest源码中是ModuleMetadata接口,它有四个字段,且均是数组类型,分别是:

  • imports :导入其他模块中导出的Providers,以实现共享
  • providers :模块中所有用到的功能类,模块内共享实用;
  • controllers:控制器
  • exports:导出其他模块需要共享的Providers

通过以上四种类型的设定,Nest的IoC才能够准确识别需要组装(注入和被注入)各种依赖关系,同时实现一定程度的共享。

SOLID原则

SOLID指面向对象编程中的五项基本原则。应用这些原则,可以提升程序的可维护行和扩展性,同时也让大规模协作开发变得可能。表格源自WiKi:

字母指代概念
S单一功能原则认为对象应该仅具有一种单一功能的概念。
O开闭原则认为“软件体应该是对于扩展开放的,但是对于修改封闭的”的概念。
L里氏替换原则认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念。参考契约式设计
I接口隔离原则认为“多个特定客户端接口要好于一个宽泛用途的接口”的概念。
D依赖反转原则认为一个方法应该遵从“依赖于抽象而不是一个实例” 的概念。 依赖注入是该原则的一种实现方式。

模块的设计遵循SOLID原则。另外,在Nest中:一组功能也会被归类到一个文件夹中,同时体现在程序文件名的前缀上。

模块共享

默认情况下,Nest使用的都是单例模式,一个Serivce也可以被多个Module导入使用。有心的同学可能已经发现了,在聊Provider时,即便是没有UserModule,照样也可以在UserController中使用AppService,那么将这个套路稍加推广,一定也可以在需要使用某个Provider的Module直接引入相关文件。我们在项目中追加一个UserModule

nest g mo user

内容如下:

@Module({
  controllers: [UserController],
  providers: [AppService],
})
export class UserModule {}

还需要修改app.module,引入user.module

@Module({
  imports: [UserModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

运行项目,然后依次执行下面的代码,查看输出:

npm run start:dev
curl --location --request GET 'localhost:3000/set?str=Hi~' # done
curl --location --request GET 'localhost:3000/get' # Hi~
curl --location --request GET 'localhost:3000/user/get' # 
curl --location --request GET 'localhost:3000/user/set?str=Hi User~' # done
curl --location --request GET 'localhost:3000/user/get' # Hi User~
curl --location --request GET 'localhost:3000/get' # Hi~

根据这个结果,不难推断出,AppService在AppModule和UserModule中是两个独立存在的实例。也许这并不符合我们的开发本意,而是需要使用一个实例。这就是Nest的模块共享的实质意义。

我们只需要将UserModule添加一行export [AppServie],将AppModule中的providers: [AppService]删去(反之亦然),即可实现功能的共享。然后再次执行上面的检查代码,看看输出结果。

动态模块

动态模块是Nest的强大的功能之一,在对这个功能研究的时候,发现不论是国内还是国外,对这方面的具体解释和应用案例比较少。官方的文档只不过非常简单的介绍了一下在不同的环境(开发、测试或生产)下,加载不同配置的案例(原理是利用了值类型的Provider)。但实际上这个功能的强大之处远不止此。个人感觉是官方(和大多数文章)拿这把杀牛刀演示怎么宰只鸡。在充分了解动态模块之前,如果对Provider的种类还不太清楚的同学,可以先复习一下先前Provider的内容。

Provider 种类

先前在Provider章节中提到,Provider是一个比较宽泛的概念,不仅局限于Service类型,实际上任何一个类、值乃至一个接口,都可以视作一个Provider。在调用时也可以使调用时也可以用参数装饰器@Inject()来修饰后面的参数需要什么样的依赖。例如:

export class AppService {
  constructor(@Inject('MethodOptions') private option: MethodOptions) {}
}

在应用Provider时,我们说一下Provider的几种类型,查看源代码:

export type Provider<T = any> =
  | Type<any> // 类型
  | ClassProvider<T> // 类
  | ValueProvider<T> // 值
  | FactoryProvider<T> // 工厂
  | ExistingProvider<T>; // 别名
  • Type 类型
  • ClassProvider 类类型的Provider,有三个字段组成:
    • provide:被注入对象参数,可以是字符串,symbol,类型,抽象类和Function
    • useClass:类型名称
    • scope:作用域(参考Provider作用域),可选参数,默认scope.DEFAULT,即Application
  • ValueProvider 值类型Provider,有两个字段组成:
    • provide:被注入对象参数,可以是字符串,symbol,类型,抽象类和Function
    • userValue:值的实例
  • FactoryProvider 工厂类Provider,有四个字段:
    • provide:被注入对象参数,可以是字符串,symbol,类型,抽象类和Function
    • useFactory:工厂的参数
    • inject:被注入的工厂中上依赖项(可选)
    • scope:作用域,(可选)
  • ExistingProvider 已经存在的(别名)类Provider,两个字段:
    • provide:被注入对象参数,可以是字符串,symbol,类型,抽象类和Function
    • useExisting:别名

DynamicModule

动态模块的核心接口,它基于ModuleMetadata接口(@Module装饰器的参数接口)扩展出了必选的module和默认为false的可选global字段。其中最为值得讨论的是Providers字段,每一个Provider就是上文提到的接口。

在上级模块引入模块时,可以调用模块的静态方法,以获得一个DynamicModule对象,例如:

@Module({
  module: Type<any>,
  imports: [CommonModule.specifyCommon('user')],
  controllers: [UserController],
  providers: [AppService],
})
export class UserModule {}

类动态模块Provider

假设,有一个数据库(或其他服务)的抽象类CommonService,提供标准化的CRUD功能。在具体的对象中,应用具体的业务,都是基于这个CommonService扩展(实现)的子类。特殊的地方是:*在使用功能方面(Controller),不需要知道具体是谁(实现了抽象类的类)实现或者扩展了抽象类。*此时的系统结构如下:

为了让源代码够简单,在最小的项目基础上只增加了User,两者在层级上有上下级关系,但两者关于调用Provider是一样的。有兴趣的同学可以在增加一个类型(例如Customer)装配在App的下面。用最简单的内容来举例,抽象类和两个实现(一共三个文件):

// /src/common/common.abstract.ts
export abstract class AbstractDBCommonService {
  set Prefix(value: string) {}
  get Prefix(): string {
    return 'abstract';
  }
}

// /src/common/user.db.service.ts
import { AbstractDBCommonService } from './common.abstract';

export class UserDBService implements AbstractDBCommonService {
  private _prefix: string = 'user';
  set Prefix(value: string) {
    this._prefix = value;
  }
  get Prefix(): string {
    return this._prefix;
  }
}

// /src/common/app.db.service.ts
import { AbstractDBCommonService } from './common.abstract';

export class AppDBService implements AbstractDBCommonService {
  private _prefix: string = 'app';
  set Prefix(value: string) {
    this._prefix = value;
  }
  get Prefix(): string {
    return this._prefix;
  }
}

添加User和App对DB.Service的依赖,UserController的代码与AppController代码较为相似,两者不需要关心具体实现了什么:

// /src/user/user.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { AppService } from 'src/app.service';
import { AbstractDBCommonService } from 'src/common/common.abstract';

@Controller('user')
export class UserController {
  constructor(
    private readonly appService: AppService,
    private commonService: AbstractDBCommonService,
  ) {}

  @Get('/')
  hello(): string {
    return this.commonService.getPrefix() + this.appService.getHello();
  }
}

上面的代码中,构造函数需要有两个注入,AppSerivceAbstractDBCommonService,我们可以将这两个Provider视作为某一类服务所共同需要的支持,在hello方法中,输出是这两个Service功能的整合。AppController文件与之基本保持一致。动态模块CommonModule的代码如下:

// /src/common/common.module.ts
import { DynamicModule, Inject, Module } from '@nestjs/common';
import { AppDBService } from './app.db.service';
import { AbstractDBCommonService } from './common.abstract';
import { UserDBService } from './user.db.service';
const Warehouse = { AppDBService, UserDBService };

@Module({})
export class CommonModule {
  static LoadByClass(kind: string): DynamicModule {
    const provider = {
      provide: AbstractDBCommonService,
      useClass: Warehouse[kind],
    };
    return {
      module: CommonModule,
      providers: [provider],
      exports: [provider],
    };
  }
}

然后是UserModuleAppModule

// /src/user/user.module.ts
import { Module } from '@nestjs/common';
import { AppService } from '../app.service';
import { UserController } from './user.controller';
import { CommonModule } from 'src/common/common.module';

@Module({
  imports: [CommonModule.LoadByClass('UserDBService')],
  controllers: [UserController],
  providers: [AppService],
})
export class UserModule {}
// /src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { CommonModule } from './common/common.module';

@Module({
  imports: [UserModule, CommonModule.LoadByClass('AppDBService')],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

运行代码得到结果:

npm run start:dev
curl -X GET 'http://localhost:3000/user' # UserHello World!
curl -X GET 'http://localhost:3000/' #AppHello World!

实际上,标准的Provider的用法Providers:[SomeSevice],与下面的代码等效:

providers:[{provider:SomeService,useClass:SomeService}]

值型的Provider

官方的案例以及网上大多数的案例都是以值类型的Provider来演示的。基本原理是:不同的环境以确定不同的“值”,这个值是加载不同的配置文件(名)。

假设CommonServiceUserControllerAppController提供服务,另外,为了方便,需要安装一个dotenv组件(npm install --save dotenv)。

// /src/common/common.service.ts
import { Inject, Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';

@Injectable()
export class CommonService {
  private readonly env;
  constructor(@Inject('file') file: string) {
    this.env = dotenv.parse(fs.readFileSync(file));
  }

  getPrefix() {
    return this.env['PREFIX'];
  }
}

接着定义两个配置文件/config/app.env/config/user.env注意,配置文件不能放在src目录下,因为ts文件会被编译为js文件,并且放在dist目录中执行,如果放在源代码目录中又没有利用webpack进行特定的打包时,就会发生无法找到文件的异常。文件内容及其简单:

PREFIX=APP

我们将CommonModule文件稍加修改,另外用一个静态的方法LoadByValue:

// /src/common/common.module.ts
import { DynamicModule, Inject, Module } from '@nestjs/common';
import * as path from 'path';
import { CommonService } from './common.service';

@Module({})
export class CommonModule {
  static LoadByValue(file: string): DynamicModule {
    const envFile = path.resolve(__dirname, '../../config', file);
    return {
      module: CommonModule,
      providers: [{ provide: 'file', useValue: envFile }, CommonService],
      exports: [CommonService],
    };
  }
}

接着将UserModule和AddModule的引用方式改为imports: [CommonModule.LoadENV('user.env')],以及相应的AppControllerUserController的构造函数依赖:private readonly commonService: CommonService。 运行代码的结果:

npm run start:dev
curl -X GET 'http://localhost:3000/user' # USERHello World!
curl -X GET 'http://localhost:3000/' #APPHello World!

工厂模式Provider

工厂模式是一种开发模式也是一种方法,有兴趣的同学可以参考WiKi,与上面两者不同的是useFactory是一个方法,返回一个实例化的依赖对象。这有点类似上面提到的抽象类的分配方式和值类的组合并用。其他引用方式相同。

// /src/common/common.module.ts
import { DynamicModule, Inject, Module } from '@nestjs/common';
import { AppDBService } from './app.db.service';
import { UserDBService } from './user.db.service';
const Warehouse = { UserDBService, AppDBService };

@Module({})
export class CommonModule {
  static LoadByFactory(kind: string): DynamicModule {
    const provide = {
      provide: kind,
      useFactory: () => {
        return new Warehouse[kind]();
      },
    };
    return {
      module: CommonModule,
      providers: [provide],
      exports: [provide],
    };
  }
}

UserModuleAppModule改动

// /src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { CommonModule } from './common/common.module';

@Module({
  imports: [UserModule, CommonModule.LoadByFactory('AppDBService')],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

UserControllerAppController的改动。注意,第9和10行是等效的。

// /src/app.controller.ts
import { Controller, Get, Inject, Put, Query } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService,
    @Inject('AppDBService') private readonly commonService,
     // 等效 private readonly commonService: AppDBService,
  ) {}
  @Get()
  getHello(): string {
    return this.commonService.Prefix + this.appService.getHello();
  }
}

被依赖的类型如果是同一个(将上文中App和User调整为同一个依赖项),你会发现他们共享了同一个实例。

npm run start:dev
curl -X GET "http://localhost:3000/user" # userHello World!
curl -X PUT "http://localhost:3000/?str=C" # done
curl -X GET "http://localhost:3000/user" # CHello World!

工厂模式中的生产函数useFactory:(),其本身是被调用时,是可以被带上参数的,这就需要了解工厂模式中的一个可选参数inject?。它是一组被注入到执行生产函数中的参数,且与之一一对应。

@Module({})
export class CommonModule {
  static LoadByFactory(kind: string): DynamicModule {
    const provide = {
      provide: kind,
      useFactory: (a:A, b:B) => {
        console.log(a,b); // -> A {} B {}
        return new Warehouse[kind]();
      },
      inject: [A, B],
    };
    return {
      module: CommonModule,
      // imports: [TestModule],
      providers: [A,B, provide],
      exports: [provide],
    };
  }
}

注意,被依赖的A和B必须是作为Provider来看待,也就是说,要么在providers中,也可以是注册在其他module中的共享providers并导入(imports)在当前模块中。Nest会自动将对象实力化,并送入useFactory()中。

别名Provider

当使用useClass的动态模块组装模块时,每个模块都会实例化类并且私有使用。如果这个类需要被共享使用(单例),例如一个配置文件,那么就可以使用这个方式。需要注意的是:因为是一个别名,实际上并不存在于实际的代码中,所以在依赖方的代码中不能依靠类型限制来自动完成注入,而是利用@Inject()装饰器。

// /src/app.controller.ts
import { Controller, Get, Inject, Put, Query } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService,
    @Inject('AppDBService') private readonly commonService,
  ) {}
}

同理UserControllerCommonModule改为:

import { DynamicModule, Module } from '@nestjs/common';
import { AbstractDBCommonService } from './common.abstract';

@Module({})
export class CommonModule {
  static loadByExisting(): DynamicModule {
    const userAliasConfigFile = {
      provide: 'UserDBService',
      useExisting: AbstractDBCommonService,
    };
    const appAliasConfigFile = {
      provide: 'AppDBService',
      useExisting: AbstractDBCommonService,
    };
    return {
      module: CommonModule,
      providers: [
        AbstractDBCommonService,
        userAliasConfigFile,
        appAliasConfigFile,
      ],
      exports: [
        AbstractDBCommonService,
        userAliasConfigFile,
        appAliasConfigFile,
      ],
    };
  }
}

执行测试程序可以发现,虽然是不同的注入项目,但最终依赖的AbstractDBCommonService是同一个实例。如果将*AliasConfigFile直接放在app.module.tsuser.module.ts的providers中,其实也是可以完成别名的依赖配置,但是两者使用的不同的实例。

小结

以上动态模块根据接口主要分为四个不同的类别:

  • useClass:对应ClassProvider接口,基于类的变换(或者不变),做动态模块;
  • useFactory:基于工厂模式下的动态模块;工厂返回对象可以是一个值也可以是一个实例化的类;
  • useExisting:重用一个已经存在的Provider,用一个别名方式做动态模块;
  • useValue:被依赖的Provider构造时传入不同的值;不是基于类的Provider;

动态类同样是可以被输出共享(exports),导出需要使用于provider的provide字段关联。例如工厂模式中,定义一个{provide:'xxx',userFactory:()=>{...}},将其输出就是exports:['xxx'],其他同理。

全局模块

在上文模块共享中提到过,Nest的策略遵循SOLID中的O,开闭原则:有限度的开放,所以一个模块除非是定义为输出共享,它才能被其他依赖方程序使用。假如某个模块几乎所有的地方都会被用到,那么可以利用@Global()装饰器,将你的模块定义为全局范围。如果是动态模块,那么在返回的DynamicModule中加入globa:true即可。

不论是@Global()静态模块还是global:true的动态模块 ,只需要且也必须注册一次,一般情况下可以放在根模块app.module.ts或者核心模块中即可。

@Global()
@Module({
  controllers: [UserController],
  providers: [AppService],
})
export class UserModule {}
@Module({})
export class CommonModule {
  static LoadByClass(kind: string): DynamicModule {
    const provider = {
      provide: AbstractDBCommonService,
      useClass: Warehouse[kind],
    };
    return {
      global:true,
      module: CommonModule,
      providers: [provider],
      exports: [provider],
    };
  }
}