Привет, друзья!


Данная серия статей представляет собой мои заметки о NestJS — фреймворке для разработки эффективных и масштабируемых серверных приложений на Node.js. NestJS использует прогрессивный (что означает текущую версию ECMAScript) JavaScript с полной поддержкой TypeScript (использование TypeScript является опциональным) и сочетает в себе элементы объектно-ориентированного, функционального и реактивного функционального программирования.


Под капотом Nest по умолчанию использует Express, но позволяет переключиться Fastify.



Первая статья представляет собой обзор основных возможностей, предоставляемых NestJS, во второй рассматриваются основы работы с этим фреймворком, в третьей — техники и рецепты по интеграции NestJS с некоторыми популярными библиотеками, используемыми при разработке приложений на Node.js, наконец, четвертая статья представляет собой туториал по разработке относительно полноценного React/Nest/TypeScript-приложения.


При рассказе о Nest я буду придерживаться структуры и содержания официальной документации.


Это вторая часть руководства.


Вот ссылка на первую часть.


Содержание:



Кастомные провайдеры


Основы внедрения зависимостей


Внедрение зависимостей (Dependency Injection, DI) — это способ инверсии управления (Inversion of Control, IoC), когда инстанцирование (создание экземпляров) зависимостей делегируется контейнеру IoC (системе выполнения — runtime system) NestJS.


Все начинается с определения провайдера.


В следующем примере декоратор @Injectable помечает (mark) класс PostService как провайдер:


// post.service.ts
import { Injectable } from '@nestjs/common'
import { PostDto } from './dto'

@Injectable()
export class PostService {
  private readonly posts: PostDto[] = []

  getAll(): PostDto[] {
    return this.posts
  }
}

Затем этот провайдер внедряется в контроллер:


// post.controller.ts
import { Controller, Get } from '@nestjs/common'
import { PostService } from './post.service'
import { PostDto } from './dto'

@Controller('posts')
export class PostController {
  // внедрение зависимости
  constructor(private postService: PostService) {}

  @Get()
  async getAll(): Promise<PostDto[]> {
    // обращение к провайдеру
    return this.postService.getAll()
  }
}

Наконец, провайдер регистрируется с помощью контейнера IoC:


// app.module.ts
import { Module } from '@nestjs/common'
import { PostController } from './post/post.controller'
import { PostService } from './post/ost.service'

@Module({
  controllers: [PostController],
  providers: [PostService]
})
export class AppModule {}

В процессе DI происходит следующее:


  1. В post.service.ts декоратор @Injectable определяет класс PostService как класс, доступный для управления контейнером IoC.
  2. В post.controller.ts класс PostController определяет зависимость на токене (token) PostService посредством его внедрения через конструктор:

constructor(private postService: PostService) {}

  1. В app.module.ts токен PostService связывается (map) с классом PostService из post.service.ts.

При инстанцировании PostController контейнер IoC определяет зависимости. При обнаружении зависимости PostService, он изучает токен PostService, который возвращает класс PostService. Учитывая, что по умолчанию применяется паттерн проектирования SINGLETON (Одиночка), NestJS создает экземпляр PostService, кеширует его и возвращает либо сразу доставляет экземпляр PostService из кеша.


Стандартные провайдеры


Присмотримся к декоратору Module. В app.module.ts определяется следующее:


@Module({
  controllers: [PostController],
  providers: [PostProvider]
})

Свойство providers принимает массив провайдеров. В действительности, синтаксис providers: [PostService] является сокращением для:


providers: [
  {
    provide: PostService,
    useClass: PostService
  }
]

Теперь процесс регистрации провайдеров стал более понятным, не так ли? Здесь мы явно ассоциируем токен PostService с одноименным классом. Токен используется для получения экземпляра одноименного класса.


Кастомные провайдеры


Случаи использования кастомных провайдеров:


  • создание кастомного экземпляра класса;
  • повторное использование существующего класса в другой зависимости;
  • перезапись (переопределение) класса фиктивной версией в целях тестирования.

NestJS предоставляет несколько способов создания кастомных провайдеров.


Провайдеры значения: useValue


useValue используется для внедрения константных значений, сторонних библиотек в контейнер IoC, а также для замены настоящей реализации объектом с фиктивными данными. Рассмотрим пример использования фиктивного PostService в целях тестирования:


import { PostService } from './post.service'

const mockPostService = {
  // ...
}

@Module({
  providers: [
    {
      provide: PostService,
      useValue: mockPostService
    }
  ]
})

В приведенном примере токен PostService разрешится фиктивным объектом mockPostService. Благодаря структурной типизации TypeScript, в качестве значения useValue может передаваться любой объект с совместимым интерфейсом, включая литерал объекта или экземпляр класса, инстанцированный с помощью new.


Другие токены провайдеров


В качестве токенов провайдеров могут использоваться не только классы, но и, например, строки или символы:


import { connection } from './connection'

@Module({
  providers: [
    {
      provide: 'CONNECTION',
      useValue: connection
    }
  ]
})

В приведенном примере строковый токен CONNECTION ассоциируется с объектом connection.


Провайдеры со строковыми токенами внедряются с помощью декоратора Inject:


@Injectable()
export class PostRepository {
  constructor(@Inject('CONNECTION') connection: Connection) {}
}

Разумеется, в реальном приложении строковые токены лучше выносить в константы (constants.ts).


Провайдеры классов: useClass


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


const configServiceProvider = {
  provide: ConfigService,
  useClass:
    process.env.NODE_ENV === 'development'
      ? DevelopmentConfigService
      : ProductionConfigService
}

@Module({
  providers: [configServiceProvider]
})

Провайдеры фабрик: useFactory


useFactory позволяет создавать провайдеры динамически. В данном случае провайдер — это значение, возвращаемое фабричной функцией:


  1. Фабричная функция принимает опциональные аргументы.
  2. Опциональное свойство inject принимает массив провайдеров, разрешаемых NestJS и передаваемых фабричной функции в качестве аргументов в процессе инстанцирования. Эти провайдеры могут быть помечены как опциональные. Экземпляры из списка inject передаются функции в качестве аргументов в том же порядке.

const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider, optionalProvider?: string) => {
    const options = optionsProvider.get()
    return new DatabaseConnection(options)
  },
  inject: [OptionsProvider /* обязательно */, { token: 'SomeOptionalProvider' /* провайдер с указанным токеном может разрешаться в `undefined` */, optional: true }]
}

@Module({
  providers: [
    connectionFactory,
    OptionsProvider,
    // { provide: 'SomeOptionalProvider', useValue: 'qwerty' }
  ]
})

Провайдеры псевдонимов: useExisting


useExisting позволяет создавать псевдонимы для существующих провайдеров. В приведенном ниже примере строковый токен AliasedLoggerService является псевдонимом "классового токена" LoggerService:


@Injectable()
class LoggerService {
  // ...
}

const loggerAliasProvider = {
  provide: 'AliasedLoggerService',
  useExisting: LoggerService
}

@Module({
  providers: [LoggerService, loggerAliasProvider]
})

Другие провайдеры


Провайдеры могут предоставлять не только сервисы, но и другие значения, например, массив объектов с настройками в зависимости от текущей среды выполнения кода:


const configFactory = {
  provide: 'CONFIG',
  useFactory: () => process.env.NODE_ENV === 'development' ? devConfig : prodConfig
}

@Module({
  providers: [configFactory]
})

Экспорт кастомных провайдеров


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


Пример экспорта кастомного провайдера с помощью токена:


const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get()
    return new DatabaseConnection(options)
  },
  inject: [OptionsProvider]
}

@Module({
  providers: [connectionFactory],
  exports: ['CONNECTION']
})

Пример экспорта кастомного провайдера с помощью объекта:


const connectionFactory = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get()
    return new DatabaseConnection(options)
  },
  inject: [OptionsProvider]
}

@Module({
  providers: [connectionFactory],
  exports: [connectionFactory]
})

Асинхронные провайдеры


Что если мы не хотим обрабатывать запросы до установки соединения с базой данных? Для решения задач, связанных с отложенным запуском приложения, используются асинхронные провайдеры:


{
  provide: 'ASYNC_CONNECTION',
  useFactory: async () => {
    const connection = await createConnection(options)
    return connection
  }
}

В данном случае NestJS будет ждать разрешения промиса перед инстанцированием любого класса, от которого зависит провайдер.


Асинхронные провайдеры внедряются в другие компоненты с помощью токенов. В приведенном примере следует использовать конструкцию @Inject('ASYNC_CONNECTION').


Динамические модули


В большинстве случаев используются регулярные или статические (static) модули. Модули определяют группы компонентов (провайдеров или контроллеров), представляющих определенную часть приложения. Они предоставляют контекст выполнения (execution context) или область видимости (scope) для компонентов. Например, провайдеры, определенные в модуле, являются доступными (видимыми) другим членам модуля без необходимости их экспорта/импорта. Когда провайдер должен быть видимым за пределами модуля, он сначала экспортируется из хостового (host) модуля и затем импортируется в потребляющий (consuming) модуль.


Вспомним, как это выглядит.


Сначала определяется 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, который находится (hosted) в AuthModule:


import { Injectable } from '@nestjs/common'
import { UsersService } from '../users/users.service'

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  // ...
}

NestJS делает UsersService доступным внутри AuthModule следующим образом:


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

Динамические модули


Динамический модуль позволяет импортировать один модуль в другой и кастомизировать свойства и поведение импортируемого модуля во время импорта.


Пример конфигурационного модуля


Предположим, что мы хотим, чтобы ConfigModule принимал объект options, позволяющий настраивать его поведение: мы хотим иметь возможность определять директорию, в которой находится файл .env.


Динамические модули позволяют передавать параметры в импортируемые модули. Рассмотрим пример импорта статического 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 {}

Теперь рассмотрим пример импорта динамического модуля:


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. Этот метод может называться как угодно, но по соглашению его следует именовать register или forRoot.
  2. Метод register определяется нами, поэтому он может принимать любые параметры. Мы хотим, чтобы он принимал объект options.
  3. Можно предположить, что register() возвращает module, поскольку возвращаемое им значение указано в списке imports.

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


Интерфейс динамического модуля возвращает модуль, но вместо того, чтобы "фиксить" свойства этого модуля в декораторе Module, они определяются программно.


Что еще можно здесь сказать?


  1. Свойство imports декоратора Module принимает не только названия классов, но также функции, возвращающие динамические модули.
  2. Динамический модуль может импортировать другие модули.

Вот как может выглядеть 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]
    }
  }
}

Наш конфигурационный модуль пока бесполезен. Давайте это исправим.


Настройка модуля


Рассмотрим пример использования объекта options для настройки сервиса ConfigService:


import { Injectable } from '@nestjs/common'
import dotenv from 'dotenv'
import fs from 'fs'
import { EnvConfig } from './interfaces'

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig

  constructor() {
    const options = { folder: './config' }

    const fileName = `${process.env.NODE_ENV || 'development'}.env`
    const envFile = path.resolve(__dirname, '../../', options.folder, fileName)
    this.envConfig = dotenv.parse(fs.readFileSync(envFile))
  }

  get(key: string): string {
    return this.envConfig[key]
  }
}

Нам нужно каким-то образом внедрить объект options через метод register из предыдущего шага. Разумеется, для этого используется внедрение зависимостей. ConfigModule предоставляет ConfigService. В свою очередь, ConfigService зависит от объекта options, который передается во время выполнения. Поэтому во время выполнения options должен быть привязан (bind) к IoC контейнеру — это позволит NestJS внедрить его в ConfigService. Как вы помните из раздела, посвященного провайдерам, провайдеры могут предоставлять любые значения, а не только сервисы.


Вернемся к статическому методу register. Помните, что мы конструируем модуль динамически и одним из свойств модуля является список провайдеров. Поэтому необходимо определить объект с настройками в качестве провайдера. Это сделает его внедряемым (injectable) в ConfigService:


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:


import { Injectable } from '@nestjs/common'
import dotenv from 'dotenv'
import fs from 'fs'
import { EnvConfig } from './interfaces'

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig

  constructor(@Inject('CONFIG_OPTIONS') private options) {
    const fileName = `${process.env.NODE_ENV || 'development'}.env`
    const envFile = path.resolve(__dirname, '../../', options.folder, fileName)
    this.envConfig = dotenv.parse(fs.readFileSync(envFile))
  }

  get(key: string): string {
    return this.envConfig[key]
  }
}

Опять же вместо строкового токена CONFIG_OPTIONS в реальных приложениях лучше использовать константы.


Разница между методами register, forRoot и forFeature


При создании модуля с помощью:


  • register(), предполагается, что данный динамический модуль будет использоваться только в вызывающем его модуле;
  • forRoot(), предполагается однократная настройка динамического модуля и его повторное использование в нескольких местах;
  • forFeature(), предполагается использование настройки forRoot, но имеется необходимость в модификации некоторых настроек применительно к нуждам вызывающего модуля (например, репозиторий, к которому модуль будет иметь доступ, или контекст, который будет использоваться "логгером").

Области внедрения / Injection scopes


Область видимости провайдера


Провайдер может иметь одну их следующих областей видимости:


  • DEFAULT — в приложении используется единственный экземпляр провайдера. Жизненный цикл (lifecycle) экземпляра совпадает с жизненным циклом приложения. "Провайдеры-одиночки" (singleton providers) инстанцируются при инициализации приложения;
  • REQUEST — для каждого запроса создается новый экземпляр провайдера. Экземпляр уничтожается сборщиком мусора после обработки запроса;
  • TRANSIENT — временные (transient) провайдеры не распределяются между потребителями. Каждый потребитель, внедряющий провайдера, получает новый экземпляр.

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


Определение области видимости провайдера


Область видимости провайдера определяется в настройке scope декоратора @Injectable:


import { Injectable, Scope } from '@nestjs/common'

@Injectable({ scope: Scope.REQUEST })
export class PostService {}

Пример определения области видимости кастомного провайдера:


{
  provide: 'CACHE_MANAGER',
  useClass: CacheManager,
  scope: Scope.TRANSIENT
}

Обратите внимание: по умолчанию используется область видимости DEFAULT.


Область видимости контроллера


Контроллеры также могут иметь область видимости, которая применяется ко всем определенным в них обработчикам.


Область видимости контроллера определяется с помощью настройки scope декоратора Controller:


@Controller({
  path: 'post',
  scope: Scope.REQUEST
})
export class PostController {}

Иерархия областей видимости


Область видимости REQUEST поднимается (всплывает) по цепочке внедрения зависимостей. Это означает, что контроллер, который основан на провайдере с областью видимости REQUEST, будет иметь такую же область видимости.


Предположим, что у нас имеется такой граф зависимостей: PostController <- PostService <- PostRepository. Если область видимости PostService ограничена запросом (а другие зависимости имеют дефолтную область видимости), область видимости PostController будет ограничена запросом, поскольку он зависит от внедренного сервиса. PostRepository, который не зависит от PostService, будет иметь дефолтную область видимости.


Зависимости с временной областью видимости не следуют данному паттерну. Если PostService с дефолтной областью видимости внедряет LoggerService с временной областью видимости, он получит новый экземпляр сервиса. Однако область видимости PostService останется дефолтной, поэтому его внедрение не приведет к созданию нового экземпляра PostService.


Провайдер запроса


Доступ к объекту запроса в ограниченном запросом провайдере можно получить через объект REQUEST:


import { Injectable, Scope, Inject } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'
import { Request } from 'express'

@Injectable({ scope: Scope.REQUEST })
export class PostService {
  constructor(@Inject(REQUEST) private request: Request) {}
}

В приложениях, использующих GraphQL, вместо REQUEST следует использовать CONTEXT:


import { Injectable, Scope, Inject } from '@nestjs/common'
import { CONTEXT } from '@nestjs/graphql'

@Injectable({ scope: Scope.REQUEST })
export class PostService {
  constructor(@Inject(CONTEXT) private context) {}
}

Циклическая зависимость / Circular dependency


Циклическая (круговая) зависимость возникает, когда 2 класса зависят друг от друга. Например, класс А зависит от класса Б, а класс Б зависит от класса А. Циклическая зависимость в NestJS может возникнуть между модулями и провайдерами.


NestJS предоставляет 2 способа для разрешения циклических зависимостей:


  • использование техники передачи (перенаправления) ссылки (forward referencing);
  • использование класса ModuleRef.

Передача ссылки


Передача ссылки позволяет NestJS ссылаться на классы, которые еще не были определены, с помощью вспомогательно функции forwardRef. Например, если PostService и CommonService зависят друг от друга, обе стороны отношений могут использовать декоратор Inject и утилиту forwardRef для разрешения циклической зависимости:


import { Injectable, Inject, forwardRef } from '@nestjs/common'

// post.service.ts
@Injectable()
export class PostService {
  constructor(
    @Inject(forwardRef(() => CommonService))
    private commonService: CommonService
  ) {}
}

// common.service.ts
@Injectable()
export class CommonService {
  constructor(
    @Inject(forwardRef(() => PostService))
    private postService: PostService
  ) {}
}

Для разрешения циклической зависимости между модулями также используется утилита forwardRef:


@Module({
  imports: [forwardRef(() => PostModule)]
})
export class CommonModule {}

Ссылка на модуль / Module reference


Класс ModuleRef предоставляет доступ к внутреннему списку провайдеров и позволяет получать ссылку на любого провайдера с помощью токена внедрения (injection token) как ключа для поиска (lookup key). Данный класс также позволяет динамически инстанцировать статические провайдеры и провайдеры с ограниченной областью видимости. ModuleRef внедряется в класс обычным способом:


import { ModuleRef } from '@nestjs/core'

@Injectable()
export class PostService {
  constructor(private moduleRef: ModuleRef) {}
}

Получение экземпляров компонентов


Метод get экземпляра ModuleRef позволяет извлекать провайдеры, контроллеры, защитники, перехватчики и т.п., которые существуют (были инстанцированы) в данном модуле с помощью токена внедрения/названия класса:


@Injectable()
export class PostService implements OnModuleInit {
  private service: Service
  constructor(private moduleRef: ModuleRef) {}

  onModuleInit() {
    this.service = this.moduleRef.get(Service)
  }
}

Обратите внимание: метод get не позволяет извлекать провайдеры с ограниченной областью видимости.


Для извлечения провайдера из глобального контекста (например, когда провайдер был внедрен в другой модуль) используется настройка strict со значением false:


this.moduleRef.get(Service, { strict: false })

Разрешение провайдеров с ограниченной областью видимости


Для динамического разрешения провайдеров с ограниченной областью видимости используется метод resolve, в качестве аргумента принимающий токен внедрения провайдера:


@Injectable()
export class PostService implements OnModuleInit {
  private transientService: TransientService
  constructor(private moduleRef: ModuleRef) {}

  async onModuleInit() {
    this.transientService = await this.moduleRef.resolve(TransientService)
  }
}

Метод resolve возвращает уникальный экземпляр провайдера из собственного поддерева контейнера внедрения зависимостей (DI container sub-tree). Каждое поддерево имеет уникальный идентификатор контекста (context identifier):


@Injectable()
export class PostService implements OnModuleInit {
  constructor(private moduleRef: ModuleRef) {}

  async onModuleInit() {
    const transientServices = await Promise.all([
      this.moduleRef.resolve(TransientService),
      this.moduleRef.resolve(TransientService)
    ])
    console.log(transientServices[0] === transientServices[1]) // false
  }
}

Для генерации одного экземпляра для нескольких вызовов resolve() и обеспечения распределения одного поддерева в resolve() можно передать идентификатор контекста. Для генерации такого идентификатора используется класс ContextIdFactory (метод create):


import { ContextIdFactory } from '@nestjs/common'

@Injectable()
export class PostService implements OnModuleInit {
  constructor(private moduleRef: ModuleRef) {}

  async onModuleInit() {
    // создаем идентификатор контекста
    const contextId = ContextIdFactory.create()
    const transientServices = await Promise.all([
      // передаем идентификатор контекста
      this.moduleRef.resolve(TransientService, contextId),
      this.moduleRef.resolve(TransientService, contextId)
    ])
    console.log(transientServices[0] === transientServices[1]) // true
  }
}

Регистрация ограниченного запросом провайдера


Для регистрации кастомного объекта REQUEST для созданного вручную поддерева используется метод registerRequestByContextId экземпляра ModuleRef:


const contextId = ContextIdFactory.create()
this.moduleRef.registerRequestByContextId(/* REQUEST_OBJECT */, contextId)

Получение текущего поддерева


Иногда может потребоваться разрешить экземпляр ограниченного запросом провайдера в пределах контекста запроса (request context). Предположим, что PostService — это ограниченный запросом провайдер, и мы хотим разрешить PostRepository, который также является провайдером с областью видимости REQUEST. Для распределения одного и того же поддерева следует получить текущий идентификатор контекста вместо создания нового. Сначала объект запроса внедряется с помощью декоратора Inject:


@Injectable()
export class PostService {
  constructor(
    @Inject(REQUEST) private request: Request
  ) {}
}

Затем на основе объекта запроса с помощью метода getByRequest класса ContextIdFactory создается идентификатор контекста, который передается в метод resolve:


const contextId = ContextIdFactory.getByRequest(this.request)
const postRepository = await this.moduleRef.resolve(PostRepository, contextId)

Динамическое инстанцирование кастомных классов


Для динамического инстанцирования класса, который не был зарегистрирован в качестве провайдера, используется метод create экземпляра ModuleRef:


@Injectable()
export class PostService implements OnModuleInit {
  private postFactory: PostFactory
  constructor(private moduleRef: ModuleRef) {}

  async onModuleInit() {
    this.postFactory = await this.moduleRef.create(PostFactory)
  }
}

Данная техника позволяет условно (conditional) инстанцировать классы за пределами контейнера IoC.


Ленивая загрузка модулей


По умолчанию все модули загружаются при запуске приложения. В большинстве случаев это нормально. Однако, это может стать проблемой для приложений/воркеров, запущенных в бессерверной среде (serverless environment), где критичной является задержка запуска приложения ("холодный старт").


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


Обратите внимание: в лениво загружаемых модулях и функциях не вызываются методы хуков жизненного цикла (lifecycle hooks methods).


Начало работы


NestJS предоставляет класс LazyModuleLoader для ленивой загрузки модулей, который внедряется в класс обычным способом:


import { LazyModuleLoader } from '@nestjs/core'

@Injectable()
export class PostService {
  constructor(private lazyModuleLoader: LazyModuleLoader) {}
}

В качестве альтернативы ссылку на провайдер LazyModuleLoader можно получить через экземпляр приложения NestJS:


const lazyModuleLoader = app.get(LazyModuleLoader)

Далее модули загружаются с помощью такой конструкции:


const { LazyModule } = await import('./lazy.module')
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule)

Обратите внимание: лениво загружаемые модули кешируются после первого вызова метода load. Это означает, что последующие загрузки LazyModule будут очень быстрыми и будут возвращать кешированный экземпляр вместо повторной загрузки модуля.


Метод load возвращает ссылку на модуль (LazyModule), которая позволяет исследовать внутренний список провайдеров и получать ссылку на провайдер с помощью токена внедрения в качестве ключа для поиска.


Предположим, что у нас имеется такой LazyModule:


@Module({
  providers: [LazyService],
  exports: [LazyService]
})
export class LazyModule {}

Обратите внимание: ленивые модули не могут быть зарегистрированы в качестве глобальных модулей.


Пример получения ссылки на провайдер LazyService:


const { LazyModule } = await import('./lazy.module')
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule)

const { LazyService } = await import('./lazy.service')
const lazyService = moduleRef.get(LazyService)

Обратите внимание: при использовании Webpack файл tsconfig.json должен быть обновлен следующим образом:


{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "node"
  }
}

Ленивая загрузка контроллеров, шлюзов и резолверов


Поскольку контроллеры (или резолверы в GraphQL) в NestJS представляют собой наборы роутов/путей/темы (или запросы/мутации), мы не можем загружать их лениво с помощью класса LazyModuleLoader.


Случаи использования лениво загружаемых модулей


Лениво загружаемые модули требуются в ситуациях, когда воркер/крон-задача (cron job)/лямда (lambda) или бессерверная функция/веб-хук (webhook) запускают разные сервисы (разную логику) в зависимости от входных аргументов (путь/дата/параметры строки запроса и т.д.).


Контекст выполнения / Execution context


NestJS предоставляет несколько вспомогательных классов, помогающих создавать приложения, работающие в разных контекстах (HTTP-сервер, микросервисы и веб-сокеты). Эти утилиты предоставляют информацию о текущем контексте выполнения, которая может использоваться для создания общих (generic) защитников, фильтров и перехватчиков.


ArgumentsHost


Класс ArgumentsHost предоставляет методы для извлечения аргументов, переданных в обработчик. Он позволяет выбрать соответствующий контекст (HTTP, RPC (микросервисы) или веб-сокеты) для извлечения из него аргументов. Ссылка на экземпляр ArgumentsHost обычно представлена в виде параметра host. Например, с таким параметром вызывается метод catch фильтра исключений.


По сути, ArgumentsHost — это абстракция над аргументами, переданными в обработчик. Например, для HTTP-сервера (при использовании @nestjs/platform-express) объект host инкапсулирует массив [request, response, next], где request — это объект запроса, response — объект ответа и next — функция, управляющая циклом запрос-ответ приложения. Для GraphQL-приложений объект host содержит массив [root, args, context, info].


Текущий контекст выполнения


Тип контекста, в котором запущено приложение, можно определить с помощью метода getType:


import { GqlContextType } from '@nestjs/graphql'

if (host.getType() === 'http') {
  // ...
} else if (host.getType() === 'rpc') {
  // ...
} else if (host.getType<GqlContextType>() === 'graphql') {
  // ...
}

Аргументы обработчика хоста


Извлечь массив аргументов, переданных в обработчик, можно с помощью метода getArgs:


const [req, res, next] = host.getArgs()

Метод getArgByIndex позволяет извлекать аргументы по индексу:


const req = host.getArgByIndex(0)
const res = host.getArgByIndex(1)

Перед извлечением аргументов рекомендуется переключаться (switch) на соответствующий контекст. Это можно сделать с помощью следующих методов:


switchToHttp(): HttpArgumentsHost
switchToRpc(): RpcArgumentsHost
switchToWs(): WsArgumentsHost

Перепишем предыдущий пример с помощью метода switchToHttp. Данный метод возвращает объект HttpArgumentsHost, соответствующий контексту HTTP-сервера. Этот объект предоставляет 2 метода для извлечения объектов запроса и ответа:


import { Request, Response } from 'express'

const ctx = host.switchToHttp()
const req = ctx.getRequest<Request>()
const res = ctx.getResponse<Response>()

Аналогичные методы имеют объекты RpcArgumentsHost и WsArgumentsHost:


export interface WsArgumentsHost {
  /**
   * Возвращает объект данных.
   */
  getData<T>(): T
  /**
   * Возвращает объект клиента.
   */
  getClient<T>(): T
}

export interface RpcArgumentsHost {
  /**
   * Возвращает объект данных.
   */
  getData<T>(): T

  /**
   * Возвращает объект контекста.
   */
  getContext<T>(): T
}

ExecutionContext


ExecutionContext расширяет ArgumentsHost, предоставляя дополнительную информацию о текущем процессе выполнения. Экземпляр ExecutionContext передается, например, в метод canActivate защитника и метод intercept перехватчика. ExecutionContext предоставляет следующие методы:


export interface ExecutionContext extends ArgumentsHost {
  /**
   * Возвращает тип (не экземпляр) контроллера, которому принадлежит текущий обработчик.
   */
  getClass<T>(): Type<T>
  /**
   * Возвращает ссылку на обработчик (метод),
   * который будет вызван при дальнейшей обработке запроса.
   */
  getHandler(): Function
}

Если в контексте HTTP текущим запросом является POST-запрос, привязанный к методу create контроллера PostController, getHandler вернет ссылку на create, а getClass — тип PostController:


const methodKey = ctx.getHandler().name // create
const className = ctx.getClass().name // PostController

Возможность получать доступ к текущему классу и методу обработчика позволяет, в частности, извлекать метаданные, установленные с помощью декоратора @SetMetadata.


Reflection и метаданные


Декоратор @SetMetadata позволяет добавлять кастомные метаданные в обработчик:


import { SetMetadata } from '@nestjs/common'

@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createPostDto: CreatePostDto) {
  this.postService.create(createPostDto)
}

В приведенном примере мы добавляем в метод create метаданные roles (roles — это ключ, а ['admin'] — значение). @SetMetadata не рекомендуется использовать напрямую. Лучше вынести его в кастомный декоратор:


import { SetMetadata } from '@nestjs/common'

export const Roles = (...roles: string[]) => SetMetadata('roles', roles)

Перепишем предыдущий пример:


@Post()
@Roles('admin')
async create(@Body() createPostDto: CreatePostDto) {
  this.postService.create(createPostDto)
}

Для доступа к кастомным метаданным в обработчике используется вспомогательный класс Reflector:


import { Reflector } from '@nestjs/core'

@Injectable()
export class RolesGuard {
  constructor(private reflector: Reflector) {}
}

Читаем метаданные:


const roles = this.reflector.get<string[]>('roles', ctx.getHandler())

В качестве альтернативы метаданные можно добавлять на уровне контроллера, т.е. применять их ко всем обработчикам сразу:


@Roles('admin')
@Controller('post')
export class PostController {}

В этом случае для извлечения метаданных в качестве второго аргумента метода get следует передавать ctx.getClass:


const roles = this.reflector.get<string[]>('roles', ctx.getClass())

Класс Reflector предоставляет 2 метода для одновременного извлечения метаданных, добавленных на уровне контроллера и метода, и их комбинации.


Рассмотрим такой случай:


@Roles('user')
@Controller('post')
export class PostController {
  @Post()
  @Roles('admin')
  async create(@Body() createPostDto: CreatePostDto) {
    this.postService.create(createPostDto)
  }
}

Метод getAllAndOverride перезаписывает метаданные:


const roles = this.reflector.getAllAndOverride<string[]>('roles', [
  ctx.getHandler(),
  ctx.getClass()
])

В данном случае переменная roles будет содержать массив ['admin'].


Метод getAllAndMerge объединяет метаданные:


const roles = this.reflector.getAllAndMerge<string[]>('roles', [
  ctx.getHandler(),
  ctx.getClass()
])

В этом случае переменная roles будет содержать массив ['user', 'admin'].


События жизненного цикла / Lifecycle events


Приложение NestJS, как и любой элемент приложения, обладают жизненным циклом, управляемым NestJS. NestJS предоставляет хуки жизненного цикла (lifecycle hooks), которые позволяют фиксировать ключевые события жизненного цикла и определенным образом реагировать (запускать код) при возникновении этих событий.


Жизненный цикл


На диаграмме ниже представлена последовательность ключевых событий жизненного цикла приложения, от его запуска до завершения процесса Node.js. Жизненный цикл можно разделить на 3 стадии: инициализация, выполнение (запуск) и завершение. Жизненный цикл позволяет планировать инициализацию модулей и сервисов, управлять активными подключениями и плавно (graceful) завершать работу приложения при получении соответствующего сигнала.


События жизненного цикла


События жизненного цикла возникают в процессе запуска и завершения работы приложения. NestJS вызывает методы хуков, зарегистрированные на modules, injectables и controllers для каждого события.


В приведенном ниже списке методы onModuleDestroy, beforeApplicationShutdown и onApplicationShutdown вызываются только при явном вызове app.close() или при получении процессом специального системного сигнала (такого как SIGTERM), а также при корректном вызове enableShutdownHooks на уровне приложения.


NestJS предоставляет следующие методы хуков:


  • onModuleInit — вызывается один раз после разрешения зависимостей модуля;
  • onApplicationBootstrap — вызывается один раз после инициализации модулей, но до регистрации обработчиков установки соединения;
  • onModuleDestroy — вызывается после получения сигнала о завершении работы (например, SIGTERM);
  • beforeApplicationShutdown — вызывается после onModuleDestroy; после завершения (разрешения или отклонения промисов), все существующие подключения закрываются (вызывается app.close());
  • onApplicationShutdown — вызывается после закрытия всех подключений (разрешения app.close()).

Обратите внимание: хуки жизненного цикла не вызываются для классов, область видимости которых ограничена запросом.


Использование хуков жизненного цикла


Каждый хук представлен соответствующим интерфейсом. Реализация такого интерфейса означает регистрацию хука. Например, для регистрации хука, вызываемого после инициализации модуля на определенном классе, следует реализовать интерфейс OnModuleInit посредством определения метода onModuleInit:


import { Injectable, OnModuleInit } from '@nestjs/common'

@Injectable()
export class UsersService implements OnModuleInit {
  onModuleInit() {
    console.log('Инициализация модуля завершена.')
  }
}

Асинхронная инициализация модуля


Хуки OnModuleInit и OnApplicationBootstrap позволяют отложить процесс инициализации модуля:


async onModuleInit(): Promise<void> {
  const response = await fetch(/* ... */)
}

Завершение работы приложения


Хуки, связанные с завершением работы приложения, по умолчанию отключены, поскольку они потребляют системные ресурсы. Для их включения следует вызвать метод enableShutdownHooks на уровне приложения:


import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'

async function bootstrap() {
  const ap = await NestFactory.create(AppModule)

  // включаем хуки
  app.enableShutdownHooks()

  await app.listen(3000)
}
bootstrap()

Когда приложение получает сигнал о завершении работы, оно вызывает зарегистрированные методы onModuleDestroy, beforeApplicationShutdown и onApplicationShutdown с соответствующим сигналом в качестве первого параметра. Если зарегистрированная функция является асинхронной (ожидает разрешения промиса), NestJS будет ждать разрешения промиса:


@Injectable()
class UserService implements OnApplicationShutdown {
  onApplicationShutdown(signal: string) {
    console.log(signal) // например, `SIGTERM`
  }
}

Обратите внимание: вызов app.close() не завершает процесс Node.js, а только запускает хуки OnModuleDestroy и OnApplicationShutdown, поэтому если у нас имеются счетчики (timers), длительные фоновые задачи и т.п., процесс не будет завершен автоматически.


На этом вторая часть руководства завершена.


Благодарю за внимание и happy coding!




Комментарии (0)