open atlas
↑ К треку
NestJS с нуля до senior NEST · 01 · 04

Динамические модули и асинхронная конфигурация

У статического @Module набор провайдеров фиксирован; динамический модуль — класс, чей static-метод возвращает дескриптор DynamicModule в рантайме, чтобы импортёр передал конфиг. forRoot один раз, forFeature на срез, register на импорт, forRootAsync — для конфига из провайдера.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Пейджер сработал в 2 ночи: FATAL: sorry, too many clients already. Postgres упёрся в свой max_connections равный 100, отказывал новым логинам, и каждый под в деплое уходил в краш-луп на старте. Схема не менялась, трафик ровный. Диф, выехавший накануне, добавил DatabaseModule.forRoot({ poolSize: 10 }) в фиче-модуль, чтобы фоновая задача «переиспользовала соединение». Но forRoot() уже был вызван один раз в AppModule. Повторный вызов ничего не переиспользовал — он зарегистрировал провайдер соединения второй раз, поднял второй пул из 10, и на пяти подах это тихо удвоило простаивающие соединения кластера с 50 до 100. Тот общий синглтон, который команда думала, что у неё есть, всё это время был двумя инстансами. Этот урок — про машинерию под forRoot, forFeature, register и асинхронным конфигом, и про режимы отказа, которые возникают, когда ты их путаешь.

Фиксированный набор против рантайм-дескриптора

У статического @Module({ providers, exports }) набор провайдеров фиксирован, решён в момент определения класса. Он не может принять конфигурацию — imports: [CacheModule] даёт тебе то, что CacheModule решил за себя сам.

Динамический модуль — это класс со static-методом, который возвращает дескриптор DynamicModule в рантайме: { module, providers, imports?, exports?, global? }. Импортёр вызывает этот метод и передаёт конфиг, а Nest вклеивает возвращённые провайдеры в граф. Именно так работают ConfigModule.forRoot({ ... }), JwtModule.register({ ... }) и TypeOrmModule.forFeature([ ... ]) — каждый вызов есть вызов метода, собирающий заточенный модуль прямо на месте.

import { DynamicModule, Module } from '@nestjs/common';

export const STORAGE_OPTIONS = 'STORAGE_OPTIONS';
export interface StorageOptions { bucket: string; region: string; }

@Module({})
export class StorageModule {
  // hand-rolled dynamic module: the importer passes config, we turn it into a provider
  static forRoot(options: StorageOptions): DynamicModule {
    return {
      module: StorageModule,
      providers: [{ provide: STORAGE_OPTIONS, useValue: options }],
      exports: [STORAGE_OPTIONS], // so consumers can inject the options token
      global: true,               // exports visible app-wide without re-importing
    };
  }
}

StorageModule.forRoot({ bucket: 'uploads', region: 'eu-central-1' }) возвращает полностью сформированный модуль, единственный провайдер которого — объект опций, экспортированный под токеном, который инжектят нижестоящие сервисы. Оболочка @Module({}) пуста намеренно — настоящий конструктор тут метод.

Конвенция имён: forRoot, forFeature, register

Три имени метода — это конвенция, не навязанная Nest, — но каждый Nest-разработчик читает их как контракт, так что нарушение её есть выстрел себе в ногу.

  • forRoot() — настроить один раз, в корне приложения. Обычно возвращает общую инфраструктуру (соединение, хранилище конфига) и часто global. Вызов более одного раза — это и есть баг из хука.
  • forFeature() — зарегистрировать срез, многократно, на фиче-модуль. TypeOrmModule.forFeature([User]) в одном модуле, forFeature([Order]) в другом — каждый вызов скоупит репозитории в этот модуль, не пересоздавая соединение, которое уже сделал forRoot.
  • register() — конфигурация на импорт, без root/global-семантики. Каждый HttpModule.register({ baseURL }) независим; два импорта означают два отдельно сконфигурированных инстанса, и это задумано.
// forRoot ONCE, at the root — stands up the shared connection (global)
imports: [DatabaseModule.forRoot({ url: process.env.DB_URL, poolSize: 10 })]

// forFeature PER module — scopes entities/repos, reuses the forRoot connection
imports: [DatabaseModule.forFeature([User])]   // in UsersModule
imports: [DatabaseModule.forFeature([Order])]  // in OrdersModule

// register PER import — each gets its own independent config, no sharing implied
imports: [HttpModule.register({ baseURL: 'https://api.billing.internal' })]

Вместе три имени кодируют намерение по шарингу: forRoot — «один общий пул на всё приложение», forFeature — «срез этого пула, на модуль», register — «независимый инстанс, без шаринга». Когда видишь, что коллега вызывает forRoot в фиче-модуле, — это инцидент в 2 ночи, ждущий своего часа: само имя несёт контракт.

Почему это работает

Почему вызов forRoot() дважды удваивает пул вместо переиспользования соединения? Потому что forRoot() — это обычная функция, которая возвращает свежий дескриптор провайдера каждый раз. Первый вызов регистрирует { provide: DB_POOL, useFactory: makePool } в AppModule; второй вызов возвращает тот же объект-дескриптор снова, и Nest регистрирует его второй раз в инжекторе фиче-модуля — useFactory отрабатывает заново, открывая второй физический пул. Нет никакой проверки идентичности, которая сказала бы «этот провайдер уже есть, пропусти». @Global тебя тоже не спасает: global меняет только видимость экспортов, а не кардинальность провайдеров. Два вызова forRoot = два прогона фабрики = два пула, точка. Ментальная модель общего синглтона — это ложь, которую второй forRoot тихо ломает; фикс — вызывать forRoot ровно один раз, а всем ниже по графу делать forFeature или инжектить экспортированный токен.

Асинхронный конфиг: forRootAsync и провайдер-фабрика

Самописный forRoot выше принимает литеральный объект опций. Но настоящий конфиг — URL базы, JWT-секрет — лежит в env и читается через ConfigService. Ты не можешь передать литерал в момент импорта; тебе нужно значение, разрезолвленное из другого провайдера. Это forRootAsync:

import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';

JwtModule.registerAsync({
  imports: [ConfigModule],            // make ConfigService resolvable IN this context
  inject: [ConfigService],            // the factory's dependencies, in order
  useFactory: (cfg: ConfigService) => ({
    secret: cfg.getOrThrow('JWT_SECRET'),
    signOptions: { expiresIn: cfg.get('JWT_TTL') ?? '15m' },
  }),
});

Три части работают вместе. useFactory сам является провайдером — функцией, которую Nest вызывает, чтобы произвести опции. inject перечисляет зависимости этой функции, позиционно, чтобы Nest знал, что нужно разрезолвить ConfigService и передать его первым аргументом. imports делает эти зависимости резолвимыми внутри собственного контекста инъекции динамического модуля — у динамического модуля свой скоуп, так что, если ConfigModule не глобальный, фабрика не увидит ConfigService без импорта его здесь.

Забудь imports: [ConfigModule]ConfigModule не глобальный) — и получишь падение на старте, а не в рантайме: Nest can't resolve dependencies of the <factory> (?). Please make sure that the argument ConfigService at index [0] is available in the <module> context. (?) — это неразрезолвленный ConfigService: фабрика объявила его через inject, но ничего в скоупе его не провайдит.

ConfigurableModuleBuilder: хватит писать руками

Писать forRoot и forRootAsync руками — литеральный путь, путь через фабрику, async-варианты useExisting/useClass, токен опций — это ~40 строк бойлерплейта на модуль, повторяемых идентично в каждом конфигурируемом модуле, которым ты владеешь. ConfigurableModuleBuilder генерирует всё это:

import { ConfigurableModuleBuilder } from '@nestjs/common';
import { StorageOptions } from './storage-options.interface';

// generates the class + token + sync AND async entry points for you
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<StorageOptions>().build();
import { Module } from '@nestjs/common';
import { ConfigurableModuleClass } from './storage.module-definition';

@Module({ providers: [StorageService], exports: [StorageService] })
export class StorageModule extends ConfigurableModuleClass {}
// now StorageModule.register({...}) AND StorageModule.registerAsync({ useFactory, inject, imports })
// both exist, and StorageService can inject MODULE_OPTIONS_TOKEN

Билдер отдаёт обратно ConfigurableModuleClass (наследуешь его) и MODULE_OPTIONS_TOKEN (инжектишь, чтобы прочитать опции). Ты получаешь и register, и registerAsync бесплатно, с уже разведённой async-обвязкой imports/inject/useFactory. Хочешь именование forRoot вместо register? .setClassMethodName('forRoot'). Хочешь глобальный? .setExtras({ isGlobal: true }, ...). Те ~40 строк на модуль схлопываются до двух.

Выбери лучший вариант

Твоему AuthModule нужен секрет подписи JWT из окружения, читаемый через ConfigService. Как сконфигурировать JwtModule?

Викторина

В JwtModule.registerAsync({ imports, inject, useFactory }) что делают `inject` и `imports` каждый — и почему ты получишь 'Nest can't resolve dependencies of the useFactory (?)' на старте?

Викторина

Команда помечает DatabaseModule @Global() и вызывает DatabaseModule.forRoot() и в AppModule, и в фиче-модуле, ожидая, что @Global дедуплицирует его в одно общее соединение. Что происходит на самом деле?

Вспомните перед уходом
  1. 01
    Что такое динамический модуль против статического, и что сигналят имена forRoot / forFeature / register каждое? Почему вызов forRoot дважды вызывает инцидент с пулом соединений?
  2. 02
    Как forRootAsync превращает инжектированный провайдер в конфиг, и что генерирует ConfigurableModuleBuilder? Почему 'Nest can't resolve dependencies of the useFactory' — ошибка времени старта?
Итог

У статического @Module набор провайдеров фиксирован, решён в момент определения, и он не может быть сконфигурирован импортёром. Динамический модуль это чинит: это класс, чей статический метод возвращает дескриптор DynamicModule — { module, providers, imports?, exports?, global? } — в рантайме, так что вызывающий передаёт конфиг, а Nest вклеивает провайдеры. Это машинерия за ConfigModule.forRoot, JwtModule.register и TypeOrmModule.forFeature. Имена — конвенция, не навязанная: forRoot настраивает один раз в корне и возвращает общую инфру (часто global); forFeature регистрирует срез многократно на фичу, переиспользуя соединение forRoot; register — конфиг на импорт без global-семантики, так что каждый импорт независим. Первый режим отказа: вызов forRoot дважды ничего не переиспользует — это функция, возвращающая свежий дескриптор провайдера каждый раз, так что фабрика отрабатывает заново и открывается второй пул соединений, что на нескольких подах может удвоить простаивающие соединения и исчерпать Postgres max_connections (по умолчанию 100); @Global тебя не спасёт, потому что он меняет видимость экспортов, не кардинальность провайдеров. Для env-зависимого конфига используй forRootAsync({ imports, inject, useFactory }): useFactory — провайдер, производящий опции, inject перечисляет его позиционные зависимости, а imports делает эти зависимости резолвимыми в собственном скоупе динамического модуля — забудь imports: [ConfigModule], и получишь на старте ‘Nest can’t resolve dependencies of the useFactory (?)’. ConfigurableModuleBuilder генерирует ConfigurableModuleClass и MODULE_OPTIONS_TOKEN плюс и register, и registerAsync, схлопывая ~40 строк обвязки async-провайдеров на модуль до двух. Сеньорская позиция по @Global: береги его для по-настоящему сквозной инфры (Config, Logger, DB) — злоупотребление прячет зависимости и превращает DI-граф в клубок, где модули молча используют провайдеры, которые никогда не импортируют. Теперь, когда увидишь инцидент с исчерпанием пула соединений, — пройдись по дереву модулей в поисках forRoot, вызванного больше одного раза: именно второй вызов рожает второй пул.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.