Привет, друзья! Данная серия статей представляет собой мои заметки о NestJS — фреймворке для разработки эффективных и масштабируемых серверных приложений на Node.js. NestJS использует прогрессивный (что означает текущую версию ECMAScript) JavaScript с полной поддержкой TypeScript (использование TypeScript является опциональным) и сочетает в себе элементы объектно-ориентированного, функционального и реактивного функционального программирования. Под капотом Nest по умолчанию использует Express, но позволяет переключиться Fastify. Первая статья представляет собой обзор основных возможностей, предоставляемых NestJS, во второй рассматриваются основы работы с этим фреймворком, в третьей — техники и рецепты по интеграции NestJS с некоторыми популярными библиотеками, используемыми при разработке приложений на Node.js, наконец, четвертая статья представляет собой туториал по разработке относительно полноценного React/Nest/TypeScript-приложения. При рассказе о Nest я буду придерживаться структуры и содержания официальной документации. Это вторая часть руководства. Вот ссылка на первую часть. Содержание: Внедрение зависимостей (Dependency Injection, DI) — это способ инверсии управления (Inversion of Control, IoC), когда инстанцирование (создание экземпляров) зависимостей делегируется контейнеру IoC (системе выполнения — runtime system) NestJS. Все начинается с определения провайдера. В следующем примере декоратор @Injectable помечает (mark) класс PostService как провайдер: Затем этот провайдер внедряется в контроллер: Наконец, провайдер регистрируется с помощью контейнера IoC: В процессе DI происходит следующее: При инстанцировании PostController контейнер IoC определяет зависимости. При обнаружении зависимости PostService, он изучает токен PostService, который возвращает класс PostService. Учитывая, что по умолчанию применяется паттерн проектирования SINGLETON (Одиночка), NestJS создает экземпляр PostService, кеширует его и возвращает либо сразу доставляет экземпляр PostService из кеша. Присмотримся к декоратору Module. В app.module.ts определяется следующее: Свойство providers принимает массив провайдеров. В действительности, синтаксис providers: [PostService] является сокращением для: Теперь процесс регистрации провайдеров стал более понятным, не так ли? Здесь мы явно ассоциируем токен PostService с одноименным классом. Токен используется для получения экземпляра одноименного класса. Случаи использования кастомных провайдеров: NestJS предоставляет несколько способов создания кастомных провайдеров. useValue используется для внедрения константных значений, сторонних библиотек в контейнер IoC, а также для замены настоящей реализации объектом с фиктивными данными. Рассмотрим пример использования фиктивного PostService в целях тестирования: В приведенном примере токен PostService разрешится фиктивным объектом mockPostService. Благодаря структурной типизации TypeScript, в качестве значения useValue может передаваться любой объект с совместимым интерфейсом, включая литерал объекта или экземпляр класса, инстанцированный с помощью new. В качестве токенов провайдеров могут использоваться не только классы, но и, например, строки или символы: В приведенном примере строковый токен CONNECTION ассоциируется с объектом connection. Провайдеры со строковыми токенами внедряются с помощью декоратора Inject: Разумеется, в реальном приложении строковые токены лучше выносить в константы (constants.ts). useClass позволяет динамически определять класс, которым должен разрешаться токен. Предположим, что у нас имеется абстрактный (или дефолтный) класс ConfigService, и мы хотим, чтобы NestJS предоставлял ту или иную реализацию данного сервиса на основе среды выполнения кода: useFactory позволяет создавать провайдеры динамически. В данном случае провайдер — это значение, возвращаемое фабричной функцией: useExisting позволяет создавать псевдонимы для существующих провайдеров. В приведенном ниже примере строковый токен AliasedLoggerService является псевдонимом "классового токена" LoggerService: Провайдеры могут предоставлять не только сервисы, но и другие значения, например, массив объектов с настройками в зависимости от текущей среды выполнения кода: Областью видимости любого провайдера, включая кастомные, является модуль, в котором он определяется. Для обеспечения доступа к нему других модулей, провайдер должен быть экспортирован из модуля. Кастомный провайдер может экспортироваться с помощью токена или полного объекта. Пример экспорта кастомного провайдера с помощью токена: Пример экспорта кастомного провайдера с помощью объекта: Что если мы не хотим обрабатывать запросы до установки соединения с базой данных? Для решения задач, связанных с отложенным запуском приложения, используются асинхронные провайдеры: В данном случае NestJS будет ждать разрешения промиса перед инстанцированием любого класса, от которого зависит провайдер. Асинхронные провайдеры внедряются в другие компоненты с помощью токенов. В приведенном примере следует использовать конструкцию @Inject('ASYNC_CONNECTION'). В большинстве случаев используются регулярные или статические (static) модули. Модули определяют группы компонентов (провайдеров или контроллеров), представляющих определенную часть приложения. Они предоставляют контекст выполнения (execution context) или область видимости (scope) для компонентов. Например, провайдеры, определенные в модуле, являются доступными (видимыми) другим членам модуля без необходимости их экспорта/импорта. Когда провайдер должен быть видимым за пределами модуля, он сначала экспортируется из хостового (host) модуля и затем импортируется в потребляющий (consuming) модуль. Вспомним, как это выглядит. Сначала определяется UsersModule для предоставления и экспорта UsersService. UsersModule — это хостовый модуль для UsersService: Затем определяется AuthModule, который импортирует UsersModule, что делает экспортируемые из UsersModule провайдеры доступными внутри AuthModule: Такая конструкция позволяет внедрить UsersService в AuthService, который находится (hosted) в AuthModule: NestJS делает UsersService доступным внутри AuthModule следующим образом: Динамический модуль позволяет импортировать один модуль в другой и кастомизировать свойства и поведение импортируемого модуля во время импорта. Предположим, что мы хотим, чтобы ConfigModule принимал объект options, позволяющий настраивать его поведение: мы хотим иметь возможность определять директорию, в которой находится файл .env. Динамические модули позволяют передавать параметры в импортируемые модули. Рассмотрим пример импорта статического ConfigModule (внимание на массив imports в декораторе Module): Теперь рассмотрим пример импорта динамического модуля: Что здесь происходит? На самом деле метод register возвращает DynamicModule. Динамический модуль — это модуль, создаваемый во время выполнения с такими же свойствами, что и статический модуль, и одним дополнительным свойством module. Значением этого свойства должно быть название модуля, которое должно совпадать с названием класса модуля. Интерфейс динамического модуля возвращает модуль, но вместо того, чтобы "фиксить" свойства этого модуля в декораторе Module, они определяются программно. Что еще можно здесь сказать? Вот как может выглядеть ConfigModule: Наш конфигурационный модуль пока бесполезен. Давайте это исправим. Рассмотрим пример использования объекта options для настройки сервиса ConfigService: Нам нужно каким-то образом внедрить объект options через метод register из предыдущего шага. Разумеется, для этого используется внедрение зависимостей. ConfigModule предоставляет ConfigService. В свою очередь, ConfigService зависит от объекта options, который передается во время выполнения. Поэтому во время выполнения options должен быть привязан (bind) к IoC контейнеру — это позволит NestJS внедрить его в ConfigService. Как вы помните из раздела, посвященного провайдерам, провайдеры могут предоставлять любые значения, а не только сервисы. Вернемся к статическому методу register. Помните, что мы конструируем модуль динамически и одним из свойств модуля является список провайдеров. Поэтому необходимо определить объект с настройками в качестве провайдера. Это сделает его внедряемым (injectable) в ConfigService: Теперь провайдер CONFIG_OPTIONS может быть внедрен в ConfigService: Опять же вместо строкового токена CONFIG_OPTIONS в реальных приложениях лучше использовать константы. При создании модуля с помощью: Провайдер может иметь одну их следующих областей видимости: Обратите внимание: в большинстве случаев рекомендуется использовать дефолтную область видимости. Распределение провайдеров между потребителями и запросами означает, что экземпляр может быть кеширован и инициализируются только один раз при запуске приложения. Область видимости провайдера определяется в настройке scope декоратора @Injectable: Пример определения области видимости кастомного провайдера: Обратите внимание: по умолчанию используется область видимости DEFAULT. Контроллеры также могут иметь область видимости, которая применяется ко всем определенным в них обработчикам. Область видимости контроллера определяется с помощью настройки scope декоратора Controller: Область видимости REQUEST поднимается (всплывает) по цепочке внедрения зависимостей. Это означает, что контроллер, который основан на провайдере с областью видимости REQUEST, будет иметь такую же область видимости. Предположим, что у нас имеется такой граф зависимостей: PostController <- PostService <- PostRepository. Если область видимости PostService ограничена запросом (а другие зависимости имеют дефолтную область видимости), область видимости PostController будет ограничена запросом, поскольку он зависит от внедренного сервиса. PostRepository, который не зависит от PostService, будет иметь дефолтную область видимости. Зависимости с временной областью видимости не следуют данному паттерну. Если PostService с дефолтной областью видимости внедряет LoggerService с временной областью видимости, он получит новый экземпляр сервиса. Однако область видимости PostService останется дефолтной, поэтому его внедрение не приведет к созданию нового экземпляра PostService. Доступ к объекту запроса в ограниченном запросом провайдере можно получить через объект REQUEST: В приложениях, использующих GraphQL, вместо REQUEST следует использовать CONTEXT: Циклическая (круговая) зависимость возникает, когда 2 класса зависят друг от друга. Например, класс А зависит от класса Б, а класс Б зависит от класса А. Циклическая зависимость в NestJS может возникнуть между модулями и провайдерами. NestJS предоставляет 2 способа для разрешения циклических зависимостей: Передача ссылки позволяет NestJS ссылаться на классы, которые еще не были определены, с помощью вспомогательно функции forwardRef. Например, если PostService и CommonService зависят друг от друга, обе стороны отношений могут использовать декоратор Inject и утилиту forwardRef для разрешения циклической зависимости: Для разрешения циклической зависимости между модулями также используется утилита forwardRef: Класс ModuleRef предоставляет доступ к внутреннему списку провайдеров и позволяет получать ссылку на любого провайдера с помощью токена внедрения (injection token) как ключа для поиска (lookup key). Данный класс также позволяет динамически инстанцировать статические провайдеры и провайдеры с ограниченной областью видимости. ModuleRef внедряется в класс обычным способом: Метод get экземпляра ModuleRef позволяет извлекать провайдеры, контроллеры, защитники, перехватчики и т.п., которые существуют (были инстанцированы) в данном модуле с помощью токена внедрения/названия класса: Обратите внимание: метод get не позволяет извлекать провайдеры с ограниченной областью видимости. Для извлечения провайдера из глобального контекста (например, когда провайдер был внедрен в другой модуль) используется настройка strict со значением false: Для динамического разрешения провайдеров с ограниченной областью видимости используется метод resolve, в качестве аргумента принимающий токен внедрения провайдера: Метод resolve возвращает уникальный экземпляр провайдера из собственного поддерева контейнера внедрения зависимостей (DI container sub-tree). Каждое поддерево имеет уникальный идентификатор контекста (context identifier): Для генерации одного экземпляра для нескольких вызовов resolve() и обеспечения распределения одного поддерева в resolve() можно передать идентификатор контекста. Для генерации такого идентификатора используется класс ContextIdFactory (метод create): Для регистрации кастомного объекта REQUEST для созданного вручную поддерева используется метод registerRequestByContextId экземпляра ModuleRef: Иногда может потребоваться разрешить экземпляр ограниченного запросом провайдера в пределах контекста запроса (request context). Предположим, что PostService — это ограниченный запросом провайдер, и мы хотим разрешить PostRepository, который также является провайдером с областью видимости REQUEST. Для распределения одного и того же поддерева следует получить текущий идентификатор контекста вместо создания нового. Сначала объект запроса внедряется с помощью декоратора Inject: Затем на основе объекта запроса с помощью метода getByRequest класса ContextIdFactory создается идентификатор контекста, который передается в метод resolve: Для динамического инстанцирования класса, который не был зарегистрирован в качестве провайдера, используется метод create экземпляра ModuleRef: Данная техника позволяет условно (conditional) инстанцировать классы за пределами контейнера IoC. По умолчанию все модули загружаются при запуске приложения. В большинстве случаев это нормально. Однако, это может стать проблемой для приложений/воркеров, запущенных в бессерверной среде (serverless environment), где критичной является задержка запуска приложения ("холодный старт"). Ленивая загрузка может ускорить время запуска посредством загрузки только тех модулей, которые требуются для определенного вызова бессерверной функции. Для еще больше ускорения запуска другие модули могут загружаться асинхронно после "разогрева" такой функции. Обратите внимание: в лениво загружаемых модулях и функциях не вызываются методы хуков жизненного цикла (lifecycle hooks methods). NestJS предоставляет класс LazyModuleLoader для ленивой загрузки модулей, который внедряется в класс обычным способом: В качестве альтернативы ссылку на провайдер LazyModuleLoader можно получить через экземпляр приложения NestJS: Далее модули загружаются с помощью такой конструкции: Обратите внимание: лениво загружаемые модули кешируются после первого вызова метода load. Это означает, что последующие загрузки LazyModule будут очень быстрыми и будут возвращать кешированный экземпляр вместо повторной загрузки модуля. Метод load возвращает ссылку на модуль (LazyModule), которая позволяет исследовать внутренний список провайдеров и получать ссылку на провайдер с помощью токена внедрения в качестве ключа для поиска. Предположим, что у нас имеется такой LazyModule: Обратите внимание: ленивые модули не могут быть зарегистрированы в качестве глобальных модулей. Пример получения ссылки на провайдер LazyService: Обратите внимание: при использовании Webpack файл tsconfig.json должен быть обновлен следующим образом: Поскольку контроллеры (или резолверы в GraphQL) в NestJS представляют собой наборы роутов/путей/темы (или запросы/мутации), мы не можем загружать их лениво с помощью класса LazyModuleLoader. Лениво загружаемые модули требуются в ситуациях, когда воркер/крон-задача (cron job)/лямда (lambda) или бессерверная функция/веб-хук (webhook) запускают разные сервисы (разную логику) в зависимости от входных аргументов (путь/дата/параметры строки запроса и т.д.). NestJS предоставляет несколько вспомогательных классов, помогающих создавать приложения, работающие в разных контекстах (HTTP-сервер, микросервисы и веб-сокеты). Эти утилиты предоставляют информацию о текущем контексте выполнения, которая может использоваться для создания общих (generic) защитников, фильтров и перехватчиков. Класс ArgumentsHost предоставляет методы для извлечения аргументов, переданных в обработчик. Он позволяет выбрать соответствующий контекст (HTTP, RPC (микросервисы) или веб-сокеты) для извлечения из него аргументов. Ссылка на экземпляр ArgumentsHost обычно представлена в виде параметра host. Например, с таким параметром вызывается метод catch фильтра исключений. По сути, ArgumentsHost — это абстракция над аргументами, переданными в обработчик. Например, для HTTP-сервера (при использовании @nestjs/platform-express) объект host инкапсулирует массив , где request — это объект запроса, response — объект ответа и next — функция, управляющая циклом запрос-ответ приложения. Для GraphQL-приложений объект host содержит массив . Тип контекста, в котором запущено приложение, можно определить с помощью метода getType: Извлечь массив аргументов, переданных в обработчик, можно с помощью метода getArgs: Метод getArgByIndex позволяет извлекать аргументы по индексу: Перед извлечением аргументов рекомендуется переключаться (switch) на соответствующий контекст. Это можно сделать с помощью следующих методов: Перепишем предыдущий пример с помощью метода switchToHttp. Данный метод возвращает объект HttpArgumentsHost, соответствующий контексту HTTP-сервера. Этот объект предоставляет 2 метода для извлечения объектов запроса и ответа: Аналогичные методы имеют объекты RpcArgumentsHost и WsArgumentsHost: ExecutionContext расширяет ArgumentsHost, предоставляя дополнительную информацию о текущем процессе выполнения. Экземпляр ExecutionContext передается, например, в метод canActivate защитника и метод intercept перехватчика. ExecutionContext предоставляет следующие методы: Если в контексте HTTP текущим запросом является POST-запрос, привязанный к методу create контроллера PostController, getHandler вернет ссылку на create, а getClass — тип PostController: Возможность получать доступ к текущему классу и методу обработчика позволяет, в частности, извлекать метаданные, установленные с помощью декоратора @SetMetadata. Декоратор @SetMetadata позволяет добавлять кастомные метаданные в обработчик: В приведенном примере мы добавляем в метод create метаданные roles (roles — это ключ, а  — значение). @SetMetadata не рекомендуется использовать напрямую. Лучше вынести его в кастомный декоратор: Перепишем предыдущий пример: Для доступа к кастомным метаданным в обработчике используется вспомогательный класс Reflector: Читаем метаданные: В качестве альтернативы метаданные можно добавлять на уровне контроллера, т.е. применять их ко всем обработчикам сразу: В этом случае для извлечения метаданных в качестве второго аргумента метода get следует передавать ctx.getClass: Класс Reflector предоставляет 2 метода для одновременного извлечения метаданных, добавленных на уровне контроллера и метода, и их комбинации. Рассмотрим такой случай: Метод getAllAndOverride перезаписывает метаданные: В данном случае переменная roles будет содержать массив . Метод getAllAndMerge объединяет метаданные: В этом случае переменная roles будет содержать массив . Приложение NestJS, как и любой элемент приложения, обладают жизненным циклом, управляемым NestJS. NestJS предоставляет хуки жизненного цикла (lifecycle hooks), которые позволяют фиксировать ключевые события жизненного цикла и определенным образом реагировать (запускать код) при возникновении этих событий. На диаграмме ниже представлена последовательность ключевых событий жизненного цикла приложения, от его запуска до завершения процесса Node.js. Жизненный цикл можно разделить на 3 стадии: инициализация, выполнение (запуск) и завершение. Жизненный цикл позволяет планировать инициализацию модулей и сервисов, управлять активными подключениями и плавно (graceful) завершать работу приложения при получении соответствующего сигнала. События жизненного цикла возникают в процессе запуска и завершения работы приложения. NestJS вызывает методы хуков, зарегистрированные на modules, injectables и controllers для каждого события. В приведенном ниже списке методы onModuleDestroy, beforeApplicationShutdown и onApplicationShutdown вызываются только при явном вызове app.close() или при получении процессом специального системного сигнала (такого как SIGTERM), а также при корректном вызове enableShutdownHooks на уровне приложения. NestJS предоставляет следующие методы хуков: Обратите внимание: хуки жизненного цикла не вызываются для классов, область видимости которых ограничена запросом. Каждый хук представлен соответствующим интерфейсом. Реализация такого интерфейса означает регистрацию хука. Например, для регистрации хука, вызываемого после инициализации модуля на определенном классе, следует реализовать интерфейс OnModuleInit посредством определения метода onModuleInit: Хуки OnModuleInit и OnApplicationBootstrap позволяют отложить процесс инициализации модуля: Хуки, связанные с завершением работы приложения, по умолчанию отключены, поскольку они потребляют системные ресурсы. Для их включения следует вызвать метод enableShutdownHooks на уровне приложения: Когда приложение получает сигнал о завершении работы, оно вызывает зарегистрированные методы onModuleDestroy, beforeApplicationShutdown и onApplicationShutdown с соответствующим сигналом в качестве первого параметра. Если зарегистрированная функция является асинхронной (ожидает разрешения промиса), NestJS будет ждать разрешения промиса: Обратите внимание: вызов app.close() не завершает процесс Node.js, а только запускает хуки OnModuleDestroy и OnApplicationShutdown, поэтому если у нас имеются счетчики (timers), длительные фоновые задачи и т.п., процесс не будет завершен автоматически. На этом вторая часть руководства завершена. Благодарю за внимание и happy coding!  JavaScript Developer