Руководство по NestJS. Часть 2
- пятница, 20 мая 2022 г. в 00:41:38
Привет, друзья!
Данная серия статей представляет собой мои заметки о 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 происходит следующее:
constructor(private postService: PostService) {}
При инстанцировании 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
позволяет создавать провайдеры динамически. В данном случае провайдер — это значение, возвращаемое фабричной функцией:
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 следующим образом:
Динамический модуль позволяет импортировать один модуль в другой и кастомизировать свойства и поведение импортируемого модуля во время импорта.
Предположим, что мы хотим, чтобы 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 {}
Что здесь происходит?
На самом деле метод register возвращает DynamicModule. Динамический модуль — это модуль, создаваемый во время выполнения с такими же свойствами, что и статический модуль, и одним дополнительным свойством module. Значением этого свойства должно быть название модуля, которое должно совпадать с названием класса модуля.
Интерфейс динамического модуля возвращает модуль, но вместо того, чтобы "фиксить" свойства этого модуля в декораторе 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]
}
}
}
Наш конфигурационный модуль пока бесполезен. Давайте это исправим.
Рассмотрим пример использования объекта 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
При создании модуля с помощью:
Провайдер может иметь одну их следующих областей видимости:
Обратите внимание: в большинстве случаев рекомендуется использовать дефолтную область видимости. Распределение провайдеров между потребителями и запросами означает, что экземпляр может быть кеширован и инициализируются только один раз при запуске приложения.
Область видимости провайдера определяется в настройке 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) {}
}
Циклическая (круговая) зависимость возникает, когда 2 класса зависят друг от друга. Например, класс А зависит от класса Б, а класс Б зависит от класса А. Циклическая зависимость в NestJS может возникнуть между модулями и провайдерами.
NestJS предоставляет 2 способа для разрешения циклических зависимостей:
Передача ссылки позволяет 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 {}
Класс 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) запускают разные сервисы (разную логику) в зависимости от входных аргументов (путь/дата/параметры строки запроса и т.д.).
NestJS предоставляет несколько вспомогательных классов, помогающих создавать приложения, работающие в разных контекстах (HTTP-сервер, микросервисы и веб-сокеты). Эти утилиты предоставляют информацию о текущем контексте выполнения, которая может использоваться для создания общих (generic) защитников, фильтров и перехватчиков.
Класс 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 расширяет 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.
Декоратор @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'].
Приложение NestJS, как и любой элемент приложения, обладают жизненным циклом, управляемым NestJS. NestJS предоставляет хуки жизненного цикла (lifecycle hooks), которые позволяют фиксировать ключевые события жизненного цикла и определенным образом реагировать (запускать код) при возникновении этих событий.
На диаграмме ниже представлена последовательность ключевых событий жизненного цикла приложения, от его запуска до завершения процесса Node.js. Жизненный цикл можно разделить на 3 стадии: инициализация, выполнение (запуск) и завершение. Жизненный цикл позволяет планировать инициализацию модулей и сервисов, управлять активными подключениями и плавно (graceful) завершать работу приложения при получении соответствующего сигнала.
События жизненного цикла возникают в процессе запуска и завершения работы приложения. NestJS вызывает методы хуков, зарегистрированные на modules, injectables и controllers для каждого события.
В приведенном ниже списке методы onModuleDestroy, beforeApplicationShutdown и onApplicationShutdown вызываются только при явном вызове app.close() или при получении процессом специального системного сигнала (такого как SIGTERM), а также при корректном вызове enableShutdownHooks на уровне приложения.
NestJS предоставляет следующие методы хуков:
Обратите внимание: хуки жизненного цикла не вызываются для классов, область видимости которых ограничена запросом.
Каждый хук представлен соответствующим интерфейсом. Реализация такого интерфейса означает регистрацию хука. Например, для регистрации хука, вызываемого после инициализации модуля на определенном классе, следует реализовать интерфейс 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!