Модули определяют группы компонентов, таких как провайдеры и контроллеры, которые подходят друг другу как модульная часть общего приложения. Они обеспечивают контекст выполнения, или область действия, для этих компонентов. Например, провайдеры, определенные в модуле, видны другим членам модуля без необходимости их экспорта. Когда провайдер должен быть виден за пределами модуля, он сначала экспортируется из своего главного модуля, а затем импортируется в потребляющий модуль.

Давайте рассмотрим знакомый пример.

Сначала мы определим UsersModule для предоставления и экспорта UsersService. UsersModule - это хост модуль для UsersService.

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Далее мы определим AuthModule, который импортирует UsersModule, делая экспортированные провайдеры UsersModule доступными внутри AuthModule:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
@Module({
  imports: [UsersModule],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

Эти конструкции позволяют нам внедрить UsersService, например, в AuthService, который размещен в AuthModule:

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}
  /*
    Implementation that makes use of this.usersService
  */
}

Мы будем называть это статическим связыванием модулей. Вся информация, необходимая Nest для соединения модулей, уже объявлена в принимающем и потребляющем модулях. Давайте разберем, что происходит во время этого процесса. Nest делает UsersService доступным внутри AuthModule путем:

  1. Инстанцирует UsersModule, включая импорт других модулей, которые потребляет сам UsersModule, и разрешение любых зависимостей.
  2. Инстанцирует AuthModule и предоставляет экспортируемые провайдеры UsersModule компонентам AuthModule (так же, как если бы они были объявлены в AuthModule).
  3. Инжектирует экземпляр UsersService в AuthService.

Пример использования динамического модуля

При статическом связывании модулей у потребляющего модуля нет возможности влиять на конфигурацию провайдеров модуля "хоста". Почему это важно? Рассмотрим случай, когда у нас есть модуль общего назначения, который должен вести себя по-разному в различных случаях использования. Это аналогично концепции "плагина" во многих системах, где общий модуль требует определенной конфигурации, прежде чем он может быть использован потребителем.

Хороший пример с Nest - это конфигурационный модуль. Многие приложения находят полезным внешнее представление деталей конфигурации с помощью модуля конфигурации. Это позволяет легко динамически изменять настройки приложения в различных средах развертывания: например, база данных разработки для разработчиков, база данных для среды тестирования и т. д. Делегируя управление параметрами конфигурации модулю конфигурации, исходный код приложения остается независимым от параметров конфигурации.

Проблема заключается в том, что сам модуль конфигурации, поскольку он является общим (подобно "плагину"), должен быть настроен потребляющим модулем. Именно здесь в игру вступают динамические модули. Используя возможности динамических модулей, мы можем сделать наш модуль конфигурации динамическим, чтобы потребляющий модуль мог использовать API для управления настройкой модуля конфигурации в момент его импорта.

Другими словами, динамические модули предоставляют API для импорта одного модуля в другой и настройки свойств и поведения этого модуля при импорте, в отличие от использования статических привязок, которые мы рассматривали до сих пор.

Пример модуля Config

Наша задача состоит в том, чтобы ConfigModule принимал объект options для его настройки. Вот функционал, который мы хотим создать. Базовый пример жестко кодирует расположение файла .env в корневой папке проекта. Предположим, мы хотим сделать это настраиваемым, чтобы вы могли управлять файлами .env в любой папке по вашему выбору. Например, представьте, что вы хотите хранить различные файлы .env в папке под корнем проекта под названием config (т.е. в папке, родственной папке src). Вы хотели бы иметь возможность выбирать разные папки при использовании ConfigModule в разных проектах.

Динамические модули дают нам возможность передавать параметры в импортируемый модуль, чтобы мы могли изменять его поведение. Давайте посмотрим, как это работает. Будет полезно, если мы начнем с конечной цели - как это может выглядеть с точки зрения потребляющего модуля, а затем будем работать в обратном направлении. Во-первых, давайте быстро рассмотрим пример статического импорта ConfigModule (т.е. подход, который не имеет возможности влиять на поведение импортируемого модуля). Обратите пристальное внимание на массив imports в декораторе @Module():

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';
@Module({
  imports: [ConfigModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Давайте рассмотрим, как может выглядеть импорт динамического модуля, в котором мы передаем объект конфигурации. Сравните разницу в массиве imports между этими двумя примерами:

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

Давайте посмотрим, что происходит в приведенном выше динамическом примере. Каковы движущиеся части?

  1. ConfigModule - это обычный класс, поэтому мы можем сделать вывод, что у него должен быть статический метод под названием register(). Мы знаем, что он статический, потому что мы вызываем его на классе ConfigModule, а не на экземпляре класса. Примечание: этот метод, который мы вскоре создадим, может иметь любое произвольное имя, но по соглашению мы должны называть его либо forRoot(), либо register().
  2. Метод register() определен нами, поэтому мы можем принимать любые входные аргументы. В данном случае мы будем принимать простой объект options с подходящими свойствами, что является типичным случаем.
  3. Мы можем сделать вывод, что метод register() должен возвращать что-то вроде модуля, поскольку его возвращаемое значение появляется в знакомом списке imports, который, как мы уже видели, включает список модулей.

На самом деле, метод register() вернет нам DynamicModule. Динамический модуль - это не что иное, как модуль, созданный во время выполнения, с теми же свойствами, что и статический модуль, плюс одно дополнительное свойство под названием module. Давайте быстро рассмотрим пример объявления статического модуля, обращая пристальное внимание на параметры модуля, передаваемые в декоратор:

@Module({
  imports: [DogsModule],
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService]
})

Динамические модули должны возвращать объект с точно таким же интерфейсом, плюс одно дополнительное свойство module. Свойство module служит именем модуля и должно быть таким же, как имя класса модуля, как показано в примере ниже.

Для динамического модуля все свойства объекта module options являются необязательными за исключением module.

А как насчет статического метода register()? Теперь мы видим, что его задача - вернуть объект, который имеет интерфейс DynamicModule. Когда мы вызываем его, мы предоставляем модуль в список imports, подобно тому, как мы сделали бы это в статическом случае, указав имя класса модуля. Другими словами, API динамического модуля просто возвращает модуль, но вместо того, чтобы фиксировать свойства в декораторе @Module, мы задаем их программно.

Осталось еще несколько деталей, которые помогут сделать картину полной:

  1. Теперь мы можем утверждать, что свойство @Module() декоратора imports может принимать не только имя класса модуля (например, imports: [UsersModule]), но и функцию возвращающую динамический модуль (например, imports: [ConfigModule.register(...)]).
  2. Динамический модуль может сам импортировать другие модули. Мы не будем делать этого в данном примере, но если динамический модуль зависит от провайдеров из других модулей, вы будете импортировать их с помощью необязательного свойства imports. Опять же, это в точности аналогично тому, как вы объявляете метаданные для статического модуля с помощью декоратора @Module().

Вооруженные этим знанием, мы можем теперь посмотреть, как должно выглядеть наше динамическое объявление ConfigModule. Давайте попробуем это сделать.

import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
@Module({})
export class ConfigModule {
  static register(): DynamicModule {
    return {
      module: ConfigModule,
      providers: [ConfigService],
      exports: [ConfigService],
    };
  }
}

Теперь должно быть понятно, как эти части связаны друг с другом. Вызов ConfigModule.register(...) возвращает объект DynamicModule со свойствами, которые по сути те же самые, что до сих пор мы предоставляли в качестве метаданных через декоратор @Module().

Импортируйте DynamicModule из @nestjs/common.

Однако наш динамический модуль пока не очень интересен, поскольку мы не представили никакой возможности конфигурировать его, как мы собирались сделать. Давайте займемся этим дальше.

Конфигурация модуля

Очевидным решением для настройки поведения ConfigModule является передача ему объекта options в статическом методе register(), как мы догадались выше. Давайте еще раз посмотрим на свойство imports нашего потребляющего модуля:

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

Этот вариант прекрасно справляется с передачей объекта options нашему динамическому модулю. Как мы затем используем этот объект options в ConfigModule? Давайте рассмотрим это. Мы знаем, что наш ConfigModule - это, по сути, хост для предоставления и экспорта инжектируемого сервиса - ConfigService - для использования другими провайдерами. На самом деле именно нашему ConfigService необходимо читать объект options для настройки своего поведения. Давайте пока предположим, что мы знаем, как каким-то образом получить options из метода register() в ConfigService. Исходя из этого предположения, мы можем внести несколько изменений в сервис, чтобы настроить его поведение на основе свойств объекта options. (Примечание: на данный момент, поскольку мы не определили, как его передавать, мы просто жестко закодируем options. Мы исправим это через минуту).

import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import { EnvConfig } from './interfaces';
@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;
  constructor() {
    const options = { folder: './config' };
    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }
  get(key: string): string {
    return this.envConfig[key];
  }
}

Теперь наш ConfigService знает, как найти файл .env в папке, которую мы указали в options.

Наша оставшаяся задача - каким-то образом внедрить объект options из шага register() в наш ConfigService. И конечно же, для этого мы будем использовать инъекцию зависимостей. Это ключевой момент, поэтому убедитесь, что вы его понимаете. Наш ConfigModule предоставляет ConfigService. ConfigService в свою очередь зависит от объекта options, который предоставляется только во время выполнения. Поэтому во время выполнения нам нужно сначала связать объект options с IoC-контейнером Nest, а затем заставить Nest внедрить его в наш ConfigService. Помните из главы Custom providers, что провайдеры могут [включать любое значение] (/guide/fundamentals/custom-providers.html#проваидеры-без-сервисов), а не только сервисы, поэтому мы вполне можем использовать инъекцию зависимостей для работы с простым объектом options.

Давайте сначала займемся привязкой объекта options к IoC-контейнеру. Мы сделаем это в нашем статическом методе register(). Помните, что мы динамически конструируем модуль, а одним из свойств модуля является список провайдеров. Поэтому нам нужно определить наш объект options как провайдер. Это сделает его инжектируемым в ConfigService, чем мы воспользуемся в следующем шаге. В приведенном ниже коде обратите внимание на массив providers:

import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
@Module({})
export class ConfigModule {
  static register(options): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        {
          provide: 'CONFIG_OPTIONS',
          useValue: options,
        },
        ConfigService,
      ],
      exports: [ConfigService],
    };
  }
}

Теперь мы можем завершить процесс инъекцией провайдера 'CONFIG_OPTIONS' в ConfigService. Напомним, что когда мы определяем провайдера с помощью неклассового токена, нам нужно использовать декоратор @Inject() [как описано здесь] (/guide/fundamentals/custom-providers.html#проваидеры-не-основанные-на-классах).

import * as dotenv from 'dotenv';
import * as fs from 'fs';
import { Injectable, Inject } from '@nestjs/common';
import { EnvConfig } from './interfaces';
@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;
  constructor(@Inject('CONFIG_OPTIONS') private options) {
    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }
  get(key: string): string {
    return this.envConfig[key];
  }
}

И последнее замечание: для простоты мы использовали строковый маркер ('CONFIG_OPTIONS'), но лучше всего определить его как константу (или Symbol) в отдельном файле и импортировать этот файл. Например:

export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';