Skip to main content

NestJS

WIP

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

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

Установка

yarn add global @nestjs/cli
# or
npm i -g @nestjs/cli

Создание проекта

# project-name - название создаваемого проекта
nest new [project-name]

Первые шаги

Выполнение команды nest new приводит к генерации следующих файлов:

src
app.controller.spec.ts
app.controller.ts
app.module.ts
app.service.ts
main.ts
  • app.controller.ts - базовый (basic) контроллер с одним роутом (обработчиком маршрута или пути);
  • app.controller.spec.ts - юнит-тесты для контроллера;
  • app.module.ts - корневой (root) модуль приложения;
  • app.service.ts - базовый сервис с одним методом;
  • main.ts - входной (entry) файл приложения, в котором используется класс NestFactory для создания экземпляра приложения Nest.

main.ts содержит асинхронную функцию, которая инициализирует (выполняет начальную загрузку, bootstrap) приложения:

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

async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
bootstrap()

Класс NestFactory предоставляет несколько статических методов (static methods), позволяющих создавать экземпляры приложения. Метод create возвращает объект приложения, соответствующий интерфейсу INestApplication. Этот объект предоставляет набор методов, которые будут рассмотрены в следующих разделах.

Структура проекта, создаваемого с помощью Nest CLI, предполагает размещение каждого модуля приложения в отдельной директории.

Команды для запуска приложения

Запуск приложения в производственном режиме:

yarn start
# or
npm run start

Запуск приложения в режиме для разработки:

yarn start:dev
# or
npm run start:dev

Контроллеры / Controllers

Контроллеры отвечают за обработку входящих запросов (requests) и формирование ответов (responses).


Для создания базового контроллера используются классы и декораторы (decorators). Декораторы связывают классы с необходимыми им метаданными и позволяют Nest создавать схему маршрутизации (карту роутинга, routing map) - привязывать запросы к соответствующим контроллерам.

Маршрутизация

В приведенном ниже примере мы используем декоратор @Controller для создания базового контроллера. Мы определяем опциональный префикс пути (path prefix) posts. Использование префикса пути в @Controller позволяет группировать набор связанных роутов и минимизировать повторяющийся код. Например, мы можем сгруппировать набор роутов для аутентификации и авторизации с помощью префикса auth. Использование префикса пути избавляет от необходимости дублировать его в каждом роуте контроллера.

// posts.controller.ts
import { Controller, Get } from '@nestjs/common'

@Controller('posts')
export class PostController {
@Get()
getAllPosts(): string {
return 'Все посты'
}
}

Для создания контроллера с помощью Nest CLI можно выполнить команду nest g controller posts (g - generate).

Декоратор @Get перед методом getAllPosts указывает Nest создать обработчик для указанной конечной точки (endpoint) для HTTP-запросов. Конечная точка соответствует методу HTTP-запроса (в данном случае GET) и пути роута. Путь роута для обработчика определяется посредством объединения опционального префикса пути контроллера и пути, указанного в декораторе метода. Поскольку мы определили префикс для каждого роута (posts) и не добавляли информацию о пути в декоратор, Nest привяжет к этому роуту запросы GET /posts. Префикс пути auth в сочетании с декоратором @GET('user') приведет к привязке к роуту запросов GET /auth/user.

В приведенном примере при выполнении GET-запросов к указанной конечной точке Nest перенаправляет запрос в метод getAllPosts. Название метода может быть любым.

Данный метод возвращает статус-код 200 и ответ в виде строки. Nest предоставляет 2 способа формирования ответов:

  • стандартный (рекомендуемый) - когда обработчик запроса возвращает объект или массив, этот объект или массив автоматически сериализуются (serialized) в JSON. Примитивные типы (строка, число, логическое значение и т.д.) возвращаются без сериализации. По умолчанию для GET-запросов статус-кодом ответа является 200, а для POST-запросов - 201: это можно изменить с помощью декоратора @HttpCode на уровне обработчика;
  • специфичный для библиотеки - мы можем использовать специфичный для библиотеки объект ответа, который может быть внедрен (встроен, injected) с помощью декоратора @Res или @Response в сигнатуре обработчика метода (например, getAllPosts(@Res res)). В данном случае мы можем использовать методы обработки ответа, предоставляемые библиотекой, например, res.status(200).send('Все посты').

Обратите внимание: использование в обработчике декораторов @Res или @Next отключает стандартный подход к обработке ответов, предоставляемый Nest. Для того, чтобы иметь возможность использовать оба подхода одновременно (например, для внедрения объекта ответа только для установки куки или заголовков) необходимо установить настройку passthrough в значение true в соответствующем декораторе, например: @Res({ passthrough: true }).

Объект запроса

Обработчикам часто требуется доступ к деталям запроса. Nest предоставляет доступ к объекту запроса используемой платформы (фреймворка). Мы можем получить доступ к объекту запроса, указав Nest внедрить его с помощью декоратора @Req в обработчике:

import { Controller, Get, Req } from '@nestjs/common'
import { Request } from 'express'

@Controller('posts')
export class PostController {
@Get()
getAllPosts(@Req req: Request): string {
return 'Все посты'
}
}

Объект запроса представляет HTTP-запрос и содержит свойства для строки запроса, параметров, HTTP-заголовков и тела запроса. В большинстве случаев нам не требуется извлекать их вручную. Вместо этого, мы можем применить специальные декораторы, такие как @Body или @Query. Вот полный список декораторов и соответствующих им объектов:

  • @Request, @Req - req;
  • @Response, @Res - res;
  • @Next - next;
  • @Session - req.session;
  • @Param(key?: string) - req.params / req.params[key];
  • @Body(key?: string) - req.body / req.body[key];
  • @Query(key?: string) - req.query / req.query[key];
  • @Headers(name?: string) - req.headers / req.headers[name];
  • @Ip - req.ip;
  • @HostParam - req.hosts.

Ресурсы

Ранее мы определили конечную точку для получения всех постов (роут GET). Как правило, мы также хотим предоставить конечную точку для создания новых постов. Определим обработчик POST-запросов:

import { Controller, Get, Post } from '@nestjs/common'

@Controller('posts')
export class PostController {
@Post()
create(): string {
return 'Новый пост'
}

@Get()
getAllPosts(): string {
return 'Все посты'
}
}

Nest предоставляет декораторы для всех стандартных HTTP-методов: @Get, @Post, @Put, @Delete, @Patch, @Options и @Head. Декоратор @All определяет конечную точку для обработки всех методов.

Группировка путей на уровне роута / Route wildcards

Nest поддерживает роутинг на основе паттернов (регулярных выражений). Например, символ * совпадает с любой комбинацией символов:

@Get('ab*cd')
getAll(): string {
return '*'
}

Путь ab*cd будет совпадать с abcd, ab_cd, abecd и т.д. В пути роута также могут использоваться символы ?, + и (). Символы - и . интерпретируются буквально.

Статус-код ответа

Декоратор @HttpCode позволяет определять статус-код ответа:

// немного забегая вперед
import { Delete, Param, HttpCode } from 'nestjs/common'
import { GetUser } from 'src/auth/decorator'

@Delete()
@HttpCode(204)
remove(
@Param('id', ParseIntPipe) postId: number,
@GetUser('id') userId: number
) {
return this.postService.remove({ postId, userId })
}

Вместо явного определения статус-кода можно использовать значение из перечисления (enum) HttpStatus:

import { Delete, HttpCode, HttpStatus } from 'nestjs/common'

@Delete()
@HttpCode(HttpStatus.NO_CONTENT)

Заголовки ответа

Декоратор @Header позволяет определять заголовки ответа:

@Post()
@Header('Cache-Control', 'no-cache, no-store, must-revalidate')
create(): string {
return 'Новый пост'
}

Перенаправление запроса

Для перенаправления запроса предназначен декоратор @Redirect. Он принимает 2 опциональных аргумента: url и statusCode. Последний по умолчанию имеет значение 302.

@Get()
@Redirect('https://redirected.com', 301)

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

{
url: string,
statusCode: number
}

Возвращаемые значения перезаписывают аргументы, переданные в @Redirect:

@Get()
@Redirect('https://redirected.com')
get(@Query('version') version: string) {
if (version) {
return {
url: `https://redirected.com/v${version}`
}
}
}

Параметры запроса

Роуты со статическими путями не будут работать в случаях, когда путь включает динамические данные, являющиеся частью запроса (например, GET posts/1 для получения поста с идентификатором 1). Для решения этой задачи мы можем добавить токены (tokens) параметров в путь роута для перехвата динамического значения на указанной позиции в URL запроса. Доступ к параметрам роута можно получить с помощью декоратора @Param, который должен быть добавлен в сигнатуру метода:

@Get(':id')
getPostById(@Param() params: Record<string, string>): string {
console.log(params.id)
return `Пост с идентификатором ${params.id}`
}

В @Param мы можем явно указать интересующий нас параметр:

@Get(':id')
getPostById(@Param('id') id: string): string {
return `Пост с идентификатором ${id}`
}

Тело запроса

Для получения доступа к телу запроса предназначен декоратор @Body.

Для определения контракта (contract), которому должен соответствовать объект тела запроса в Nest используется схема объекта передачи данных (Data Transfer Object, DTO), реализуемая в виде класса. Почему не в виде интерфейса TypeScript? Потому что классы являются частью ECMAScript и остаются в скомпилированном JavaScript (в отличие от интерфейсов, которые удаляются при преобразовании TypeScript в JavaScript). В ряде случаев (например, при использовании Pipes, о которых мы поговорим в одном из следующих разделов), Nest требуется доступ к метаданным переменной во время выполнения кода.

Определим класс CreatePostDto:

// create-post.dto
export class CreatePostDto {
title: string
content: string
authorId: number
}

И добавим его в PostController:

// post.controller.ts
@Post()
async create(@Body() createPostDto: CreatePostDto) {
return this.postService.create(createPostDto)
}

Расширенный пример контроллера

import { Controller, Get, Query, Post, Body, Put, Param, Delete, HttpCode, HttpStatus } from '@nestjs/common'
import { CreatePostDto, UpdatePostDto, ListAllEntities } from './dto'
import { PostService } from './post.service'

@Controller('posts')
export class PostController {
constructor(private postService: BookmarkService) {}

@Post()
create(@Body() createPostDto: CreatePostDto) {
return 'Новый пост'
}

@Get()
getAllPosts(@Query() query: ListAllEntities) {
return `Посты в количестве ${query.limit} штук`
}

@Get(':id')
getPostById(@Param('id') id: string) {
return `Пост с идентификатором ${id}`
}

@Put(':id')
update(
@Param('id') id: string,
@Body() updatePostDto: UpdatePostDto
) {
return `Обновленный пост с идентификатором ${id}`
}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
return this.postService.remove(id)
}
}

Подключение контроллера к модулю

Для того, чтобы сообщить Nest о существовании контроллера PostController, его необходимо передать в массив контроллеров модуля:

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

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

Провайдеры / Providers

Провайдеры - фундаментальная концепция Nest. В роли провайдеров могут выступать многие базовые классы Nest - сервисы (services), репозитории (repositories), фабрики (factories), помощники (helpers) и др. Суть провайдера состоит в том, что он может быть внедрен (injected) в качестве зависимости. Это означает, что объекты могут выстраивать различные отношения с другими объектами. Функции по созданию экземпляров объектов во многом могут быть делегированы системе выполнения (runtime system) Nest.


Провайдеры - это обычные JS-классы, которые определяются как providers в модуле.

Сервисы / Services

Создадим простой сервис PostService. Данный сервис будет отвечать за хранение и извлечение данных. Он спроектирован для использования в PostController, что делает его отличным кандидатом на статус провайдера.

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

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

create(post: CreatePostDto) {
this.posts.push(post)
}

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

Для создания сервиса с помощью Nest CLI можно использовать команду nest g service post.

Наш PostService - обычный класс с одним свойством и двумя методами. Новым является декоратор @Injectable. Этот декоратор добавляет метаданные, которые передают управление PostService контейнеру инверсии управления (Inversion of Control, IoC) Nest. В примере также используется интерфейс PostDto, который может выглядеть так:

// dto/post.dto.ts
export class PostDto {
id: number
title: string
content: string
authorId: number
createdAt: Date
}

Вот как можно использовать созданный нами сервис в контроллере:

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

@Controller('post')
export class PostController {
constructor(private postService: PostService) {}

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

@Get()
async getAllPosts(): Promise<PostDto[]> {
return this.postService.getAllPosts()
}
}

PostService внедряется в контроллер через конструктор класса. Обратите внимание на использование ключевого слова private. Такое сокращение позволяет одновременно определить и инициализировать поле postService в одном месте.

Внедрение зависимостей

В основе Nest лежит мощный паттерн, известный под названием "внедрение зависимостей" (Dependency Injection).

В Nest благодаря возможностям, предоставляемым TypeScript, управлять зависимостями очень легко, поскольку они разрешаются (resolved) по типу. В приведенном примере Nest разрешает postService посредством создания и возврата экземпляра PostService (обычно, возвращается существующий экземпляр класса - паттерн "Одиночка" / Singleton). Данная зависимость разрешается и передается конструктору контроллера (или присваивается указанному свойству):

constructor(private postService: PostService) {}

Регистрация провайдеров

У нас имеется провайдер (PostService) и его потребитель (PostController). Теперь нам необходимо зарегистрировать сервис для того, чтобы Nest мог осуществить его внедрение. Это делается путем добавления сервиса в массив, передаваемый полю providers декоратора @Module:

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

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

После этого Nest будет иметь возможность разрешать зависимости PostController.

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

src
post
dto
post.dto.ts
create-post.dto.ts
post.controller.ts
post.service.ts
app.module.ts
main.ts

Модули / Modules

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


В каждом приложении имеется по крайней мере один модуль - корневой (root). Корневой модуль - это начальная точка для построения графа приложения (application graph) - внутренней структуры данных, используемой Nest для разрешения модулей и построения отношений и зависимостей. В большинстве приложений будет использоваться несколько модулей, инкапсулирующих близкий набор возможностей.

Декоратор @Module принимает объект со следующими свойствами:

  • providers - провайдеры, которые инстанцируются Nest и могут использоваться любыми частями, по крайней мере, данного модуля;
  • controllers - набор контроллеров, определенных в данном модуле для инстанцирования;
  • imports - список импортируемых модулей, которые экспортируют провайдеры, необходимые данному модулю;
  • exports - часть провайдеров, принадлежащих данному модулю, которые должны быть доступны другим модулям. Мы можем использовать сам провайдер или только его токен (значение provide).

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

Модули частей приложения

PostController и PostService принадлежат одному и тому же домену (domain) приложения. Поскольку они тесно связаны между собой, имеет смысл вынести их в отдельный модуль. Частичный модуль просто организует код, отвечающий за определенную часть (возможность) приложения. Это помогает управлять сложностью приложения и разрабатывать, придерживаясь принципов SOLID, что становится особенно актуальным с ростом приложения или команды разработчиков.

Создадим PostModule:

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

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

Для создания модуля с помощью Nest CLI можно выполнить команду nest g module post.

Импортируем этот модуль в корневой (AppModule, определенный в app.module.ts):

import { Module } from '@nestjs/common'
import { PostModule } from './post/post.module'

@Module({
imports: [PostModule]
})
export class AppModule {}

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

src
post
dto
post.dto.ts
create-post.dto.ts
post.controller.ts
post.module.ts
post.service.ts
app.module.ts
main.ts

Распределенные модули

По умолчанию модули в Nest являются "одиночками". Поэтому мы можем распределять один и тот же экземпляр любого провайдера между несколькими модулями.


Каждый модуль по умолчанию является распределенным (shared). Предположим, что мы хотим поделиться экземпляром PostService с другими модулями. Для этого нужно экспортировать PostService из модуля:

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

@Module({
controller: [PostController],
providers: [PostService],
exports: [PostService]
})
export class PostModule {}

Теперь любой модуль, импортирующий PostModule, будет иметь доступ к PostService и будет делиться этим экземпляром с модулями, импортирующими его самого.

Повторный экспорт модулей

Модуль может экспортировать не только внутренние провайдеры, но и импортируемые модули:

@Module({
imports: [CommonModule],
exports: [CommonModule]
})
export class CoreModule {}

Внедрение зависимостей

Модуль также может внедрять провайдеры:

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

@Module({
controllers: [PostController],
providers: [PostService]
})
export class PostModule {
constructor(private postService: PostService) {}
}

Тем не менее, сами модули не могут внедряться как провайдеры по причине циклической зависимости (circular dependency).

Глобальные модули

Для создания глобального модуля (помощники, ORM и т.д.) предназначен декоратор @Global:

import { Module, Global } from '@nestjs/common'
import { PostController } from './post.controller'
import { PostService } from './post.service'

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

Декоратор @Global делает модуль глобальным. Такие модули регистрируются только один раз, обычно, в корневом модуле. В приведенном примере провайдер PostService будет доступен любому модулю без необходимости импорта PostModule.

Посредники / Middleware

Посредник - это функция, которая вызывается перед обработчиком маршрута. Посредники имеют доступ к объектам запроса и ответа, а также к посреднику next в цикле запрос-ответ.


По умолчанию посредники Nest аналогичны посредникам Express.

Посредники предоставляют следующие возможности:

  • выполнение любого кода;
  • модификация объектов запроса и ответа;
  • завершение цикла запрос-ответ;
  • вызов следующего посредника в стеке;
  • если текущий посредник не завершает цикл запрос-ответ, он должен вызвать next() для передачи управления следующему посреднику. В противном случае, запрос зависнет (hanging).

Кастомный посредник может быть реализован как в виде функции, так и в виде класса с помощью декоратора @Injectable. Класс должен реализовывать интерфейс NestMiddleware, а к функции особых требований не предъявляется. Начнем с реализации посредника в виде класса:

import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('Запрос...')
next()
}
}

Внедрение зависимостей

Посредники Nest поддерживают внедрение зависимостей. Как и провайдеры или контроллеры, они могут внедрять зависимости, доступные в родительском модуле. Это делается через constructor.

Регистрация посредников

В декораторе @Module нет места для посредников. Поэтому мы применяем их в с помощью метода configure класса модуля. Модули, включающие посредников, должны реализовывать интерфейс NestModule. Применим LoggerMiddleware на уровне AppModule:

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'
import { LoggerMiddleware } from './middlewares/logger.middleware'
import { PostModule } from './post/post.module'

@Module({
imports: [PostModule]
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('post')
}
}

В приведенном примере мы применяем LoggerMiddleware к обработчикам маршрутов /post, определенным в PostController. Ограничить обработчики, к которым применяется посредник, можно следующим образом (обратите внимание на использование перечисления RequestMethod):

import { Module, NestModule, RequestMethod, MiddlewareConsumer } from '@nestjs/common'
import { LoggerMiddleware } from './middlewares/logger.middleware'
import { PostModule } from './post/post.module'

@Module({
imports: [PostModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes({ path: 'post', method: RequestMethod.GET })
}
}

Обратите внимание: метод configure может быть асинхронным (async/await).

Перехват роутов / Route wildcards

Метод forRoutes поддерживает перехват роутов с помощью паттернов:

forRoutes({ path: 'ab*cd', method: RequestMethod.ALL })

Потребитель посредника / Middleware consumer

MiddlewareConsumer - это вспомогательный класс. Он предоставляет несколько встроенных методов для управления посредником. Данные методы могут вызываться по цепочке. Метод forRoutes принимает строку, несколько строк или объект RouteInfo, класс контроллера или несколько таких классов. В большинстве случаев мы передаем этому методу контроллеры, разделенные запятыми. Пример передачи одного контроллера:

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'
import { LoggerMiddleware } from './middlewares/logger.middleware'
import { PostModule } from './post/post.module'
import { PostController } from './post/post.controller'

@Module({
imports: [PostModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes(PostController)
}
}

Обратите внимание: метод apply может принимать несколько посредников.

Исключение роутов

Иногда мы не хотим, чтобы посредник применялся к определенным роутам. Исключить такие роуты можно с помощью метода exclude, который принимает строку, несколько строк или объект RouteInfo:

consumer
.apply(LoggerMiddleware)
.exclude(
{ path: 'post', method: RequestMethod.GET },
{ path: 'post', method: RequestMethod.POST },
'post/(.*)',
)
.forRoutes(CatsController)

Обратите внимание: метод exclude поддерживает перехват роутов с помощью паттернов.

Посредники в виде функций

Реализованный нами посредник LoggerMiddleware является очень простым. У него нет членов, дополнительных методов и зависимостей. Поэтому мы вполне может реализовать его в виде функции:

import { Request, Response, NextFunction } from 'express'

export function logger(req: Request, res: Response, next: NextFunction) {
console.log('Запрос...')
next()
}

Применяем его в AppModule:

consumer
.apply(logger)
.forRoutes(PostController)

Несколько посредников

Для применения нескольких посредников достаточно передать их методу apply через запятую:

consumer.apply(cors(), helmet(), logger).forRoutes(PostController)

Глобальные посредники

Для применения посредника ко всем роутам приложения можно использовать метод use, предоставляемый экземпляром INestApplication:

const app = await NestFactory.create(AppModule)
app.use(logger)
await app.listen(3000)

Фильтры исключений / Exception filters

Nest имеет встроенный слой для обработки исключений (exceptions layer), которые по какой-то причине не были обработаны приложением (unhandled exceptions - необработанные исключения).


По умолчанию используется глобальный фильтр исключений (global exception filter), который обрабатывает исключения типа HttpException (и его подклассов). В случае, когда исключение не удается распознать (когда оно не является ни HttpException, ни классом, наследующим от него), генерируется такой ответ в формате JSON:

{
"statusCode": 500,
"message": "Internal server error"
}

Стандартные исключения

Nest предоставляет встроенный класс HttpException. В PostController у нас имеется метод getAllPosts. Предположим, что по какой-то причине обработчик этого роута выбрасывает исключение:

import { HttpException, HttpStatus } from '@nestjs/common'

@Get()
async getAllPosts() {
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN)
}

В ответ на запрос к данной конечной точке возвращается такой ответ:

{
"statusCode": 403,
"message": "Forbidden"
}

Конструктор HttpException принимает 2 обязательных параметра, определяющих ответ:

  • параметр response определяет тело ответа. Он может быть строкой или объектом;
  • параметр status определяет статус-код.

По умолчанию тело ответа содержит 2 свойства:

  • statusCode: статус-код, указанный в аргументе status;
  • message: краткое описание ошибки на основе status.

Для перезаписи message достаточно передать строку в качестве response. Для перезаписи всего тела ответа в качестве response передается такой объект:

@Get()
async getAllPosts() {
throw new HttpException({
status: HttpStatus.FORBIDDEN,
error: 'Доступ запрещен'
}, HttpStatus.FORBIDDEN)
}

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

{
"status": 403,
"error": "Доступ запрещен"
}

Встроенные исключения

Nest предоставляет набор исключений, наследующих от HttpException. Они экспортируются из @nestjs/common и представляют наиболее распространенные исключения:

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • HttpVersionNotSupportedException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableEntityException
  • InternalServerErrorException
  • NotImplementedException
  • ImATeapotException
  • MethodNotAllowedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException
  • PreconditionFailedException

Фильтры исключений

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

Определим фильтр исключений, отвечающий за перехват исключений, которые являются экземплярами класса HttpException, и реализующий кастомную логику формирования ответа. Для этого нам потребуется доступ к объектам Request и Response. Объект Request будет использоваться для извлечения URL и его включения в ответ. Объект Response будет использоваться для отправки ответа с помощью метода json:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'
import { Request, Response } from 'express'

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const res = ctx.getResponse<Response>()
const req = ctx.getRequest<Request>()
const status = exception.getStatus()

response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: req.url
})
}
}

Обратите внимание: все фильтры исключений должны реализовывать общий интерфейс ExceptionFilter<T>. Сигнатура определяется с помощью catch(exception: T, host: ArgumentsHost), где T - это тип исключения.

Декоратор @Catch привязывает необходимые метаданные к фильтру исключений. Это сообщает Nest, что данный фильтр обрабатывает только исключения типа HttpException. Декоратор @Catch может принимать несколько аргументов, разделенных через запятую, для обработки нескольких типов исключений.

Аргументы хоста / Arguments host

В приведенном примере мы используем объект ArgumentsHost для получения доступа к объектам Request и Response. Вспомогательные функции, предоставляемые ArgumentsHost, позволяют получать доступ к любому контексту выполнения, будь то HTTP-сервер, микросервисы или веб-сокеты.

Применение фильтров исключений

Привяжем HttpExceptionFilter к методу create в PostController:

import { Post, UseFilters, Body, ForbiddenException } from '@nestjs/common'

@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body createPostDto: CreatePostDto) {
throw new ForbiddenException()
}

Декоратор @UseFilters может принимать несколько фильтров через запятую.

Фильтры исключений могут применяться не только на уровне методов, но также на уровне контроллеров и даже глобально. Пример использования фильтра на уровне контроллера:

@UseFilters(HttpExceptionFilter)
export class PostController {}

Пример использования фильтра на уровне всего приложения (глобально):

async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.useGlobalFilters(HttpExceptionFilter)
await app.listen(3000)
}
bootstrap()

Перехват всех исключений

Для того, чтобы перехватывать все необработанные исключения (независимо от типа исключения), достаточно применить декоратор @Catch без аргументов:

import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common'
import { HttpAdapterHost } from '@nestjs/core`'

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

catch(exception: unknown, host: ArgumentsHost): void {
// В некоторых случаях `httpAdapter` может быть недоступен в конструкторе,
// поэтому нам следует получать (разрешать) его здесь
const { httpAdapter } = this.httpAdapterHost

const ctx = host.switchToHttp()

const httpStatus =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR

const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
}

httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus)
}
}

HttpAdapterHost позволяет коду быть платформонезависимым (platform-agnostic), поскольку мы не используем зависящие от платформы объекты Request и Response напрямую.

Конвейеры / Pipes

Конвейер - это класс, аннотированный с помощью декоратора @Injectable и реализующий интерфейс PipeTransform.


Конвейеры используются для:

  • трансформации: преобразование входных данных в ожидаемый формат, например, преобразование строки в число;
  • валидации: проверка корректности входных данных.

Nest запускает конвейер перед вызовом обработчика маршрута, и конвейер получает аргументы, переданные последнему.

Nest предоставляет несколько встроенных конвейеров. Разумеется, можно создавать собственные конвейеры.

Встроенные конвейеры

Nest предоставляет 8 встроенных конвейеров:

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe

Встроенные конвейеры экспортируются из пакета @nestjs/common.

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

Регистрация конвейеров

Для применения конвейера нам необходимо привязать его экземпляр к соответствующему контексту. На уровне метода это можно сделать следующим образом:

@Get(':id')
async getPostById(@Param('id', ParseIntPipe) id: number) {
return this.postService.getPostById(id)
}

После этого если мы отправим GET-запрос к конечной точке http://localhost:3000/abc, Nest выбросит такое исключение:

{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}

В этом случае код метода getPostById не выполняется.

Для кастомизации поведения встроенного конвейера вместо класса передается его экземпляр:

@Get(':id')
async getPostById(
@Param('id', new ParseIntPipe({
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE
}))
id: number
) {
return this.postService.getPostById(id)
}

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

Реализуем простой конвейер ValidationPipe, принимающий значение и просто возвращающий его:

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common'

@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
return value
}
}

Обратите внимание: PipeTransform<T, R> - это общий интерфейс, который должен быть реализован любым конвейером. T - это тип входного значения (value), а R - тип значения, возвращаемого методом transform.

transform() принимает 2 параметра:

  • value - аргумент, переданный обработчику;
  • metadata - объект со следующими свойствами:
    • type - тип аргумента: 'body' | 'query' | 'param' | 'custom';
    • metatype - тип данных аргумента, например, String;
    • data - строка, переданная декоратору, например, @Body('string').

Валидация входных данных на основе схемы

Сделаем наш конвейер для валидации более полезным. Предположим, что мы хотим валидировать объект тела запроса, передаваемого методу create. Как нам это сделать?

Существует несколько способов валидации объекта. Одним из наиболее распространенных является валидация на основе схемы (schema-based validation). Одной из наиболее популярных библиотек для такой валидации является Joi.

Устанавливаем необходимые зависимости:

yarn add joi
yarn add -D @types/joi
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'
import { ObjectSchema } from 'joi'

@Injectable()
export class JoiValidationPipe implements PipeTransform {
constructor(private schema: ObjectSchema) {}

transform(value: any, metadata: ArgumentMetadata) {
const { error } = this.schema.validate(value)

if (error) {
throw new BadRequestException('Валидация провалилась')
}

return value
}
}

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

  1. Создать экземпляр JoiValidationPipe.
  2. Передать схему в конструктор класса конвейера.
  3. Привязать конвейер к методу.
@Post()
@UsePipes(new JoiValidationPipe(createPostSchema))
async create(@Body() createPostDto: CreatePostDto) {
this.postService.create(createPostDto)
}

Существует другой, более простой способ: библиотека class-validator позволяет выполнять валидацию с помощью декораторов.

Устанавливаем необходимые зависимости:

yarn add class-validator class-transformer

Добавляем несколько декораторов-валидаторов в класс CreatePostDto

// create-post.dto
import { IsString, IsInt } from 'class-validator'

export class CreatePostDto {
@IsString()
title: string,

@IsString()
content: string,

@IsInt()
authorId: number
}

Обновляем код ValidationPipe:

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'
import { validate } from 'class-validator'
import { plainToClass } from 'class-transformer'

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || this.toValidate(metatype)) {
return value
}

const obj = plainToClass(metatype, value)
const errors = await validate(object)

if (errors.length > 0) {
throw new BadRequestException('Валидация провалилась')
}

return value
}

private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object]
return !types.includes(metatype)
}
}

Обратите внимание: библиотека class-transformer разработана тем же человеком, что и class-validator, поэтому они очень хорошо работают вместе.

Вспомогательная функция toValidate предназначена для пропуска валидации, когда обрабатываемый аргумент имеет нативный тип JavaScript (к такому аргументу нельзя добавить декораторы для валидации, поэтому выполнять ее бессмысленно).

Функция plainToClass предназначена для преобразования обычного объекта JavaScript в типизированный объект для применения валидации. Причина необходимости такого преобразования состоит в том, что входной объект тела запроса не содержит информации о типах, а class-validator нуждается в них для применения декораторов для валидации, определенных нами в CreatePostDto.

Привяжем ValidationPipe к декоратору @Body:

@Post()
async create(
@Body(ValidationPipe) createPostDto: CreatePostDto
) {
this.postService.create(createPostDto)
}

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

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

// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.useGlobalPipes(ValidationPipe)
await app.listen(3000)
}

Встроенный конвейер для валидации

На самом деле у нас нет необходимости создавать собственные конвейеры для валидации, поскольку ValidationPipe предоставляется Nest из коробки.

Кастомный конвейер для трансформации

Как отмечалось ранее, конвейеры могут использоваться не только для валидации, но и для трансформации. Рассмотрим пример кастомной реализации ParseIntPipe:

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const val = parseInt(value, 10)

if (isNaN(val)) {
throw new BadRequestException('Переданный аргумент не может быть преобразован в число')
}

return val
}
}

Применяем этот конвейер:

@Get(':id')
async getPostById(@Param('id', ParseIntPipe) id) {
return this.postService.getPostById(id)
}

Конвейер для передача параметров по умолчанию

Конвейер DefaultValuePipe позволяет определять дефолтные параметры:

@Get()
async getAllPosts(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('published', new DefaultValuePipe(true), ParseBoolPipe) published: boolean
) {
return this.postService.getAllPosts({ page, published })
}

Защитники/предохранители / Guards

Защитник - это класс, аннотированный с помощью декоратора @Injectable и реализующий интерфейс CanActivate.


Защитники предназначены для авторизации - определения того, передавать ли запрос обработчику маршрута для дальнейшей обработки, в зависимости от определенных условий (разрешения, роли пользователя и т.д.). Защитники, в отличие от посредников, имеют доступ к экземпляру ExecutionContext и по этой причине точно знают, какой код будет выполняться следующим.

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

Защитник для авторизации

Реализуем простой защитник для авторизации:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Observable } from 'rxjs'

@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
const req = context.switchToHttp().getRequest()

return validateRequest(req)
}
}

Логика внутри validateRequest может быть настолько простой или сложной, насколько необходимо.

Функция canActivate должна возвращать логическое значение - индикатор валидности текущего запроса:

  • если она возвращает true, запрос будет передан обработчику;
  • если она возвращает false, запрос будет отклонен с ошибкой.

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

canActivate() принимает один параметр - экземпляр ExecutionContext. ExecutionContext наследует от ArgumentsHost. ExecutionContext расширяет ArgumentsHost несколькими вспомогательными методами, предоставляющими дополнительную информацию о текущем процессе выполнения (обработке запроса).

Аутентификация на основе роли пользователя / Role-based authentication

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

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Observable } from 'rxjs'

@Injectable()
export class RolesGuard implements CanActivate {
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
return true
}
}

Регистрация защитников

Защитники, также как и исключающие фильтры или конвейеры могут привязываться к методу, контроллеру или быть глобальными. Для привязки защитника используется декоратор @UseGuards из пакета @nestjs/common, в качестве параметра принимающий одного или нескольких защитников, разделенных запятыми:

@Controller('posts')
@UseGuards(RolesGuard)
export class PostController {}

Для установки глобального защитника используется метод useGlobalGuard экземпляра приложения Nest:

const app = await NestFactory.create(AppModule)
app.useGlobalGuard(RolesGuard)

Определение ролей для обработчика

Наш RolesGuard работает, но пока он бесполезен. postController может иметь разные схемы разрешений: одни роуты могут быть доступны всем, другие - только администраторам.

Вот где нам пригодятся кастомные метаданные. Для их добавления предназначен декоратор @SetMetadata из пакета @nestjs/common:

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

Мы добавляем метаданные roles к методу create (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 { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Reflector } from '@nestjs/core'

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

canActivate(ctx: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', ctx.getHandler())

if (!roles) {
return true
}

const req = ctx.switchToHttp().getRequest()

return matchRoles(roles, req.user.role)
}
}

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

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

{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}

По умолчанию Nest выбрасывает исключение ForbiddenException. Однако в защитнике можно выбрасывать любые другие исключения:

throw new UnauthorizedException()

Исключения, выбрасываемые защитниками, обрабатываются соответствующим слоем (exception layer).

Перехватчики / Interceptors

Перехватчик - это класс, аннотированный с помощью декоратора @Injectable и реализующий интерфейс NestInterceptor.


Возможности перехватчиков:

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

Основы

Каждый перехватчик реализует метод intercept, принимающий 2 параметра. Первым параметром является экземпляр контекста выполнения (ExecutionContext), о котором рассказывалось в разделе, посвященном защитникам. Второй параметр - обработчик вызова (CallHandler).

CallHandler

Интерфейс CallHandler реализует метод handle, который используется для вызова метода обработчика маршрута для передачи ему управления. Если не вызвать handle в intercept, метод обработчика не будет выполнен.

Перехватчики позволяют выполнять кастомную логику как до, так и после вызова метода обработчика. С "до" все ясно, но что позволяет перехватчику выполнять код "после"? Дело в том, что handle возвращает Observable. Это позволяет использовать мощные операторы RxJS для дальнейшей манипуляции ответом. В терминологии аспектно-ориентированного программирования это называется точечным разрезом (pointcut) - мы вставляем в эту точку дополнительную логику.

Перехват аспекта / Aspect interception

Первым случаем использования перехватчиков является фиксирование взаимодействия пользователя со страницей (например, сохранение действий пользователя, асинхронный вызов событий или вычисление отметки времени (timestamp)). Реализуем простой перехватчик LoggingInterceptor:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: , next: ): Observable<any> {
console.log('Перед...')

const now = Date.now()

return next
.handle()
.pipe(
tap(() => console.log(`После... ${Date.now() - now} мс`))
)
}
}

NestInterceptor<T, R> - общий интерфейс, в котором T означает тип Observable<T> (поддерживающего поток ответа - response stream), а R - тип значения, обернутого Observable<R>.

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

Поскольку handle() возвращает Observable, в нашем распоряжении имеется большое количество операторов для работы с потоком.

Регистрация перехватчиков

Для установки перехватчика используется декоратор @UseInterceptor из пакета @nestjs/common. Подобно конвейерам или защитникам, перехватчики могут устанавливаться на уровне контроллеров, методов или глобально.

@UseInterceptors(LoggingInterceptor)
export class PostController {}

Для установки глобального перехватчика используется метод useGlobalInterceptor экземпляра приложения Nest:

const app = await NestFactory.create(AppModule)
app.useGlobalInterceptor(LoggingInterceptor)

Обработка ответов

Как мы знаем, метод handle возвращает Observable. Поток содержит значение из обработчика маршрута, которое можно мутировать с помощью оператора map из библиотеки RxJS.

Создадим TransformInterceptor, в котором map используется для присвоения объекта ответа свойству data нового объекта, возвращаемого клиенту:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'

export interface Response<T> {
data: T
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(map((data) => ({ data })))
}
}

Перехватчики отлично подходят для создания повторно используемых решений. Предположим, что мы хотим заменять все null на пустую строку (''). Для этого достаточно изменить одну строку нашего TransformInterceptor и зарегистрировать его глобально:

return next
.handle()
.pipe(map((value) => value === null ? '' : value ))

Обработка исключений

Еще одним интересным случаем использования перехватчиков является обработка исключений с помощью оператора catchError из библиотеки RxJS:

import {
Injectable,
NestInterceptor,
ExecutionContext,
BadGatewayException,
CallHandler
} from '@nestjs/common'
import { Observable, throwError } from 'rxjs'
import { catchError } from 'rxjs/operators'

@Injectable()
export class ErrorInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
catchError((err) => throwError(() => new BadGatewayException()))
)
}
}

Перезапись потока ответа

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

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable, of } from 'rxjs'

@Injectable()
export class CacheInterceptor implements ... {
intercept(ctx: ..., next: ...): Observable<any> {
const isCached = true

if (isCached) {
return of([])
}

return next.handle()
}
}

У нас имеется жестко заданная переменная isCached и жестко заданный ответ []. В данном случае мы возвращаем новый поток, генерируемый оператором of, поэтому обработчик маршрута не вызывается.

Другие операторы RxJS

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

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common'
import { Observable, throwError, TimeoutError } from 'rxjs'
import { catchError, timeout } from 'rxjs/operators'

@Injectable()
export class TimeoutInterceptor implements ... {
intercept(ctx: ..., next: ...): Observable<any> {
return next.handle().pipe(
timeout(5000),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException())
}
return throwError(() => err)
})
)
}
}

В приведенном примере запрос, который обрабатывается дольше 5 секунд, отменяется.

Кастомные декораторы маршрутов / Custom route decorators

Nest позволяет легко создавать кастомные декораторы. Когда это может пригодиться?

В Node.js распространенной практикой является добавление новых свойств в объект запроса. Затем эти свойства извлекаются в обработчиках запросов.

const { user } = req

Для того, чтобы сделать код более читаемым и прозрачным, можно создать такой декоратор @User:

import { createParamDecorator, ExecutionContext } from '@nestjs/common'

export const User = createParamDecorator(
(data: unknown, ctx: ...) => {
const req = ctx.switchToHttp().getRequest()

return req.user ? req.user : null
}
)

Затем мы просто применяем этот декоратор по необходимости:

@Get()
async getPostById(@User() user: User) {
console.log(user)
}

Передача данных

Когда поведение декоратора зависит от некоторых условий, мы можем использовать параметр data для передачи аргументов фабричной функции декоратора. Одним из таких случаев является извлечение свойств из объекта запроса по ключу. Предположим, что наш слой для аутентификации валидирует запрос и добавляет пользователя в объект запроса. Сущность пользователя может выглядеть так:

{
"id": 1,
"firstName": "Алан",
"lastName": "Тьюринг",
"email": "alan@mail.com",
"roles": ["admin"]
}

Определим декоратор, принимающий название свойства в качестве ключа и возвращающего соответствующее значение при его наличии:

import { createParamDecorator, ExecutionContext } from '@nestjs/common'

export const User = createParamDecorator(
(data: string, ctx: ...) => {
const req = ctx.switchToHttp().getRequest()
const { user } = req

if (!user) return null

return data ? user[data] : user
}
)

Пример использования этого декоратора:

@Get()
async getPostById(@User('firstName') firstName: string) {
console.log(`Привет, ${firstName}!`)
}

Мы можем использовать этот декоратор с разными ключами для доступа к разным свойствам.

Работа с конвейерами

Конвейеры могут применяться к кастомным декораторам напрямую:

@Get()
async getPostById(
@User(new ValidationPipe({ validateCustomDecorators: true }))
user: User
) {
console.log(user)
}

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

Композиция декораторов

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

import { applyDecorators } from '@nestjs/common'

export function Auth(...roles: Role[]) {
return applyDecorators(
SetMetadata('roles', roles),
UseGuards(AuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized' })
)
}

Пример использования этого декоратора:

@Get('users')
@Auth('admin')
getAllUsers()

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

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

Внедрение зависимостей (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) завершать работу приложения при получении соответствующего сигнала.

img


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

События жизненного цикла возникают в процессе запуска и завершения работы приложения. 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), длительные фоновые задачи и т.п., процесс не будет завершен автоматически.