Модули, области и контекст запроса
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Границы у вашего приложения уже есть: модуль с providers, exports и imports решает, кто кого видит. В настоящем проекте к этим трём спискам добавляется папка, а рядом с ними — два механизма, которых у вас не было: модуль, видный всем без единого импорта, и модуль, который собирают на ходу. Но главное в этой статье — третье, чего в вашем каркасе нет вовсе: область. До сих пор провайдер жил в одном экземпляре, а значит, обслуживал все запросы сразу; Nest умеет создавать его на каждый запрос — и берёт за это плату, о которой в документации сказано одной строкой. Сегодня вы увидите, как общее поле отдаёт Ане заметку Бори, почему область заразна снизу вверх и что вместо неё делает хранилище, привязанное к цепочке вызовов.
У вас модуль — это три списка в декораторе. В живом проекте к ним прирастает соглашение о файлах: модуль — папка раздела, и в ней лежит всё, что к разделу относится. Вот корень боевого API учебника — того самого, чей контроллер загрузки вы разбирали в первой статье главы.
Первое, что стоит заметить, — корень не делает ничего сам: ни одного провайдера, ни одного контроллера, только список. Два первых пункта в нём — динамические модули, до них дойдём в третьем разделе; остальные шесть — обычные классы с @Module, и каждому соответствует папка в src: auth, notes, upload, health и configs, где живёт общее — база и транспорт. Раздел, папка и модуль здесь одно и то же, и по imports корня читается состав приложения.
Внутри раздела списки короткие. Три модуля из боевого API целиком:
Заметки не экспортируют ничего: снаружи в них никто не ходит, а три класса, которые им нужны внутри, друг друга видят и так. Почта экспортирует MailService, и AuthModule, которому надо слать письма о сбросе пароля, импортирует MailModule — то самое лицо модуля из вашей модели. А вот AuthModule экспортирует AuthService, которого снаружи сейчас не просит никто: AuthRouter лежит в том же модуле и достаёт его как своего. Лишний экспорт ничего не ломает, но и не значит ничего — кроме обещания соседям, что этот класс можно брать. Правило простое: в exports попадает то, чем уже пользуется сосед, а не то, чем он мог бы воспользоваться.
Репозиторий не экспортирован нигде, и это та же граница, которую вы проводили руками. Соблазн вписать чужой класс себе в providers здесь ровно так же наказуем: это даёт второй объект, а не доступ к первому. С сервисом без состояния второй объект незаметен, с пулом соединений — это второй пул.
Модуль — не папка. Но в живом проекте они совпадают, и когда перестают совпадать, это первый признак, что раздел пора делить.
Цену нетранзитивных импортов вы знаете: модуль видит экспорты только тех, кого импортировал сам. Для домена это правильно — список imports читается как полный перечень того, на что раздел опирается. Для базы и настроек это навязчиво: строка imports: [DrizzleModule] появляется в каждом модуле приложения и ничего о нём не сообщает.
На этот случай есть пометка @Global(). Так подключена база в боевом API:
В этом коротком классе три разные вещи, и их стоит развести. @Global() — то самое ослабление границы: Nest дописывает такой модуль в imports каждого модуля приложения. exports при этом остаются лицом: наружу открыт один токен DrizzleAsyncProvider, а не всё, что внутри, и токен этот — символ, потому что интерфейс ключом контейнера быть не может. А onApplicationShutdown — хук жизненного цикла: закрыть пул, когда приложение гасят. По сигналу его, впрочем, не позовут, пока в main.ts нет строки app.enableShutdownHooks(): сигналы Nest слушает только после неё. Об этой строке и обо всей остановке речь впереди.
Проверим границу глобального модуля на трёх корнях сразу. ClockModule помечен @Global() и экспортирует Clock, но не PoolSecret. NotesModule и MailModule не импортируют вообще ничего, PeekModule просит внутренний PoolSecret. Прогноз: какие из трёх приложений соберутся и одни ли часы достанутся заметкам и почте?
import 'reflect-metadata'; import { Global, Injectable, Module, type Type } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; @Injectable() class Clock {} @Injectable() class PoolSecret {} // exports по-прежнему лицо модуля: PoolSecret в них не попал @Global() @Module({ providers: [Clock, PoolSecret], exports: [Clock] }) class ClockModule {} @Injectable() class NotesService { constructor(readonly clock: Clock) {} } @Injectable() class MailService { constructor(readonly clock: Clock) {} } @Injectable() class PeekService { constructor(readonly secret: PoolSecret) {} } // ни один из трёх ClockModule не импортирует @Module({ providers: [NotesService], exports: [NotesService] }) class NotesModule {} @Module({ providers: [MailService], exports: [MailService] }) class MailModule {} @Module({ providers: [PeekService] }) class PeekModule {} // каждый корень ниже — своё приложение и свой набор связей @Module({ imports: [ClockModule, NotesModule, MailModule] }) class RootImportsClock {} @Module({ imports: [NotesModule, MailModule] }) class RootForgotClock {} @Module({ imports: [ClockModule, PeekModule] }) class RootPeeksInside {} async function собрать(root: Type) { try { // abortOnError: false — иначе Nest при ошибке сборки гасит процесс, а не бросает исключение const app = await NestFactory.createApplicationContext(root, { logger: false, abortOnError: false }); const notes = app.get(NotesService); const mail = app.get(MailService); console.log(`${root.name}: создано, часы у заметок и почты одни: ${notes.clock === mail.clock}`); await app.close(); } catch (err) { console.log(`${root.name}: ${(err as Error).constructor.name}`); } } await собрать(RootImportsClock); await собрать(RootForgotClock); await собрать(RootPeeksInside);
RootImportsClock: создано, часы у заметок и почты одни: true RootForgotClock: UnknownDependenciesException RootPeeksInside: UnknownDependenciesException
Первое приложение собралось, и Clock у заметок и почты один — в этом весь эффект пометки: экспорты модуля видны тем, кто о нём не просил, а объект по-прежнему один, потому что владелец у токена один. Второе приложение упало UnknownDependenciesException, хотя ClockModule помечен глобальным: пометка расширяет видимость, но не заводит модуль. Модуль, до которого не дойти по imports от корня, в приложении не существует — это уже говорилось там, где вы проводили границы руками, и пометка этого правила не отменяет. Поэтому глобальный модуль всё равно импортируют один раз, обычно в корневом. Третье упало по той же причине, по какой падало бы и без глобальности: PoolSecret есть в providers, но не в exports, и наружу его не вынесла никакая пометка.
Платят за это невидимой зависимостью. В @Module раздела, который работает с базой, о базе не сказано ни слова: чтобы узнать, откуда у репозитория соединение, придётся открыть корень. Пока глобальных модулей два-три и все они инфраструктурные — настройки, база, логгер, — это терпимо, в боевом API учебника глобальных ровно два. Как только глобальным становится доменный модуль, приложение перестаёт разбираться на части: собрать раздел в тесте отдельно или вынести его в другой сервис уже нельзя — он тянет за собой корень.
Глобальность — это про видимость, а не про существование. Импортировать модуль хотя бы раз всё равно надо.
Списки в @Module посчитаны один раз — тогда, когда объявлен класс. А модулю базы нужен адрес, модулю почты — хост SMTP, и приходят они в рантайме. Отсюда второй способ ослабить статичность: метод, который возвращает описание модуля, — объект DynamicModule с полями module, providers, exports, imports.
Имена у таких методов по всей экосистеме одни и те же, и договор за ними стоит простой. forRoot — настройка один раз на приложение (так подключены ConfigModule и ThrottlerModule в корне боевого API), register — то же самое там, где слово «корень» ни при чём, forFeature — добавка для одного раздела, а вариант с Async в имени — когда настройки сами приезжают из провайдера. Ничего волшебного в этих именах нет: это обычные статические методы, и Nest о них не знает — он видит только объект, который они вернули.
В прогоне ниже SettingsModule.forRoot(адрес) возвращает такое описание с провайдером Settings. Три корня собирают одно и то же тремя способами: в первом заметки и почта зовут forRoot каждый у себя, с одинаковым адресом; во втором forRoot позвали один раз, и оба модуля ввозят один и тот же объект; в третьем — один раз в корне и с global: true. Прогноз: сколько объектов Settings окажется в каждом приложении?
import 'reflect-metadata'; import { Inject, Injectable, Module, type DynamicModule, type Type } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; const SETTINGS_URL = 'SETTINGS_URL'; let объектовSettings = 0; @Injectable() class Settings { constructor(@Inject(SETTINGS_URL) readonly url: string) { объектовSettings++; } } @Module({}) // пусто: провайдеры и экспорт приедут из forRoot class SettingsModule { // обычный статический метод: он возвращает описание модуля, а не модуль static forRoot(url: string, глобальный = false): DynamicModule { return { module: SettingsModule, global: глобальный, providers: [{ provide: SETTINGS_URL, useValue: url }, Settings], exports: [Settings], }; } } @Injectable() class NotesService { constructor(readonly settings: Settings) {} } @Injectable() class MailService { constructor(readonly settings: Settings) {} } // вариант 1: каждый модуль зовёт forRoot сам, адрес у обоих одинаковый @Module({ imports: [SettingsModule.forRoot('postgres://app')], providers: [NotesService], exports: [NotesService] }) class NotesModuleOwn {} @Module({ imports: [SettingsModule.forRoot('postgres://app')], providers: [MailService], exports: [MailService] }) class MailModuleOwn {} // вариант 2: forRoot позвали один раз, и оба модуля ввозят один и тот же объект const настройки = SettingsModule.forRoot('postgres://app'); @Module({ imports: [настройки], providers: [NotesService], exports: [NotesService] }) class NotesModuleShared {} @Module({ imports: [настройки], providers: [MailService], exports: [MailService] }) class MailModuleShared {} // вариант 3: forRoot один раз в корне и global: true — модулям ввозить нечего @Module({ providers: [NotesService], exports: [NotesService] }) class NotesModuleBare {} @Module({ providers: [MailService], exports: [MailService] }) class MailModuleBare {} @Module({ imports: [NotesModuleOwn, MailModuleOwn] }) class RootOwnCalls {} @Module({ imports: [NotesModuleShared, MailModuleShared] }) class RootSharedObject {} @Module({ imports: [SettingsModule.forRoot('postgres://app', true), NotesModuleBare, MailModuleBare] }) class RootGlobalSettings {} async function собрать(root: Type) { объектовSettings = 0; try { const app = await NestFactory.createApplicationContext(root, { logger: false, abortOnError: false }); const notes = app.get(NotesService); const mail = app.get(MailService); console.log(`${root.name}: объектов Settings — ${объектовSettings}, у заметок и почты он один: ${notes.settings === mail.settings}`); await app.close(); } catch (err) { console.log(`${root.name}: ${(err as Error).constructor.name}`); } } await собрать(RootOwnCalls); await собрать(RootSharedObject); await собрать(RootGlobalSettings);
RootOwnCalls: объектов Settings — 2, у заметок и почты он один: false RootSharedObject: объектов Settings — 1, у заметок и почты он один: true RootGlobalSettings: объектов Settings — 1, у заметок и почты он один: true
Два вызова с одинаковым адресом дали два модуля и два объекта Settings. Это главная ловушка динамических модулей: Nest 12 различает описания по ссылке на объект, а не по его содержимому, и дословно совпавшие аргументы не делают два описания одним модулем. Для настроек лишний объект безобиден. Для пула соединений это два пула вместо одного, то есть вдвое больше сессий в базе; для ограничителя частоты со счётчиком в памяти — два счётчика, каждый из которых видит половину запросов.
Оба лечения — в том же прогоне. Позвать forRoot один раз и ввезти полученный объект туда, где он нужен: описание становится общим, и модуль один. Или позвать его один раз в корне и пометить global: true — в описании это то же, что @Global() над классом, и остальным модулям ввозить вообще ничего не надо. Второй путь выбран у настроек боевого API: ConfigModule.forRoot({ isGlobal: true }) в корне, и дальше ConfigService внедряют где угодно.
Настройки базы сами зависят от настроек приложения: адрес лежит в ConfigService. В боевом API это решено на уровне провайдера — фабрикой со списком того, что ей внедрить:
inject — те же токены, что и у конструктора, только перечисленные руками, а useFactory может вернуть обещание: Nest дождётся его, прежде чем отдавать значение потребителям. Методы forRootAsync и registerAsync чужих пакетов устроены так же, только фабрику вы передаёте им аргументом, а провайдер они описывают у себя внутри.
forRoot — статический метод. Позовёте дважды — получите два модуля, даже если аргументы совпали до буквы.
Всё, что вы собирали до сих пор, живёт в одном экземпляре: объект на токен, и этот объект обслуживает все запросы сразу — на сервере их тысяча на один поток. Пока сервис держит только зависимости и методы, это правильно и дёшево. Беда начинается, когда в него кладут поле.
Ниже «текущая заявка» в поле одиночки: прослойка записывает туда имя пользователя, репозиторий читает. Две заявки приходят одна за другой, а база отвечает им в обратном порядке — так и бывает, когда одному запросу ответил кеш, а другой ждёт диска. Никаких потоков здесь нет: обе заявки живут в одном, и между ними стоит обычный await. Прогноз: что получит каждая и что останется в поле?
Аня получила чужую заметку, и не абы какую. Разбор дословный: Аня записала себя в поле и ушла ждать базу; пока она ждала, Боря переписал поле собой, дождался своего ответа и ушёл; когда база ответила Ане, в поле стоял уже Боря. await — это пауза внутри функции, а не пауза процесса: пока одна заявка ждёт, вторая успевает выполниться целиком.
Утечка эта тем противнее, что на машине разработчика её не видно: пока запрос в системе один, поле всегда «правильное». Ловят её на боевом сервере, по жалобе «мне показали чужое», и по логам она не читается — в них не видно, чей await чей.
Nest отвечает на это областью провайдера. Область — ответ на вопрос «сколько объектов», и вариантов три:
| Область | Сколько объектов | Когда нужна |
|---|---|---|
DEFAULT, по умолчанию | один на приложение | почти всегда: сервис без состояния, репозиторий, настройки |
REQUEST | по одному на каждый входящий запрос | в объекте лежат данные заявки: пользователь, язык, открытая транзакция |
TRANSIENT | свой каждому, кто его внедряет | состояние привязано к потребителю: логгер, который знает имя своего класса |
Объявляется она аргументом декоратора: @Injectable({ scope: Scope.REQUEST }). Поле такого провайдера принадлежит одному запросу, и переписать его некому. Заодно Nest даёт и саму заявку: по-запросный провайдер может внедрить токен REQUEST из @nestjs/core и получить объект запроса платформы — у Fastify это FastifyRequest с заголовками и телом. Выглядит это как решение всех проблем сразу. Следующий раздел — о том, во что оно обходится.
Поле в одиночке — это поле, общее всем запросам. Прежде чем его завести, спросите, чьё оно.
Пусть NotesService внедряет по-запросный CurrentUser. Сколько объектов NotesService должно быть в приложении? Одним он быть не может: в его поле лежала бы ссылка на пользователя одного-единственного запроса — ровно та утечка, от которой область и спасает. Значит, сервис тоже по-запросный. А контроллер, который внедряет сервис, — тоже. Это и называется заразностью: область поднимается от зависимости к потребителю, и никто из них её не объявлял.
Добавим правило в модель контейнера: оно короче, чем кажется. Область класса своя, если объявлена, иначе самая узкая из областей его зависимостей; по-запросные объекты кешируются не в контейнере, а в карте заявки, которая живёт ровно один запрос. Прогноз: какие классы окажутся по-запросными и что создаст второй запрос?
Правило уместилось в две строки функции, а последствия — во всей колонке. Db и NotesRepo остались одиночками: они ни от кого по-запросного не зависят, и второй запрос их не трогал. HealthController тоже одиночка — он просит только репозиторий. А NotesService и NotesController стали по-запросными, хотя в их объявлениях об области нет ни слова, и второй запрос построил их заново вместе с CurrentUser.
Теперь те же классы в настоящем Nest, и с двумя добавками. Logger объявлен Scope.TRANSIENT и внедрён в репозиторий и в контроллер здоровья. Metrics — обычный одиночка, RequestLog — по-запросный, и не внедрён никуда ни тот ни другой. Счётчик в конструкторе каждого класса печатает, сколько раз его создавали. Прогноз: что будет создано к концу старта, что добавят два запроса к /notes и два к /health и на каких токенах упадёт app.get?
import 'reflect-metadata'; import { Controller, Get, Inject, Injectable, Module, Scope, type Type } from '@nestjs/common'; import { NestFactory, REQUEST } from '@nestjs/core'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; // счётчик вызовов конструктора: он и показывает, что пересобирается const создано: Record<string, number> = {}; const новый = (имя: string) => (создано[имя] = (создано[имя] ?? 0) + 1); @Injectable({ scope: Scope.TRANSIENT }) // свой объект каждому, кто внедряет class Logger { constructor() { новый('Logger'); } } @Injectable() class NotesRepo { constructor(private readonly log: Logger) { новый('NotesRepo'); } readonly rows = ['аня: купить хлеб', 'боря: пароль от почты']; } @Injectable() // одиночка, которого никто не внедряет class Metrics { constructor() { новый('Metrics'); } } @Injectable({ scope: Scope.REQUEST }) // по-запросный, которого никто не внедряет class RequestLog { constructor() { новый('RequestLog'); } } @Injectable({ scope: Scope.REQUEST }) class CurrentUser { constructor(@Inject(REQUEST) private readonly request: { headers?: Record<string, string | undefined> }) { новый('CurrentUser'); } get name() { return this.request.headers?.['x-user'] ?? 'гость'; } } @Injectable() // область не объявлена class NotesService { constructor(readonly repo: NotesRepo, readonly user: CurrentUser) { новый('NotesService'); } mine() { return this.repo.rows.filter((row) => row.startsWith(`${this.user.name}:`)); } } @Controller('notes') // и здесь не объявлена class NotesController { constructor(private readonly notes: NotesService) { новый('NotesController'); } @Get() list() { return this.notes.mine(); } } @Controller('health') class HealthController { constructor(private readonly repo: NotesRepo, private readonly log: Logger) { новый('HealthController'); } @Get() ok() { return { notes: this.repo.rows.length }; } } @Module({ controllers: [NotesController, HealthController], providers: [NotesRepo, NotesService, CurrentUser, Metrics, RequestLog, Logger], }) class AppModule {} // abortOnError: false — иначе исключение из app.get уходит в ExceptionsZone и процесс гибнет молча const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: false, abortOnError: false, }); await app.init(); console.log('после старта: ', JSON.stringify(создано)); for (const user of ['аня', 'боря']) { const res = await app.inject({ method: 'GET', url: '/notes', headers: { 'x-user': user } }); console.log(`GET /notes от ${user} → ${res.statusCode} ${res.body}`); } for (const n of [1, 2]) { const res = await app.inject({ method: 'GET', url: '/health' }); console.log(`GET /health #${n} → ${res.statusCode} ${res.body}`); } console.log('после запросов:', JSON.stringify(создано)); const токены: Type[] = [CurrentUser, NotesService, NotesController, NotesRepo, Logger, HealthController]; for (const токен of токены) { try { console.log(`app.get(${токен.name}) → ${app.get(токен).constructor.name}`); } catch (err) { console.log(`app.get(${токен.name}) → ${(err as Error).constructor.name}`); } } const первый = await app.resolve(NotesService); const второй = await app.resolve(NotesService); console.log('resolve(NotesService) дважды — один объект:', первый === второй); console.log('после resolve: ', JSON.stringify(создано)); await app.close();
после старта: {"Metrics":1,"Logger":2,"NotesRepo":1,"HealthController":1}
GET /notes от аня → 200 ["аня: купить хлеб"]
GET /notes от боря → 200 ["боря: пароль от почты"]
GET /health #1 → 200 {"notes":2}
GET /health #2 → 200 {"notes":2}
после запросов: {"Metrics":1,"Logger":2,"NotesRepo":1,"HealthController":1,"CurrentUser":2,"NotesService":2,"NotesController":2}
app.get(CurrentUser) → InvalidClassScopeException
app.get(NotesService) → InvalidClassScopeException
app.get(NotesController) → InvalidClassScopeException
app.get(NotesRepo) → NotesRepo
app.get(Logger) → InvalidClassScopeException
app.get(HealthController) → HealthController
resolve(NotesService) дважды — один объект: false
после resolve: {"Metrics":1,"Logger":2,"NotesRepo":1,"HealthController":1,"CurrentUser":4,"NotesService":4,"NotesController":2}Модель угадала Nest по всем пунктам. К концу старта созданы только одиночки — Metrics, NotesRepo, HealthController — и два Logger, по одному каждому потребителю. RequestLog не создан ни разу: по-запросный провайдер, которого никто не внедряет, при старте не создаётся вовсе, а вот одиночка Metrics создаётся, даже если он не нужен никому. Два запроса к /notes добавили по CurrentUser, NotesService и NotesController: контроллер маршрута собирается заново на каждый запрос. Два запроса к /health не добавили ничего — там вся цепочка из одиночек.
Дальше начинается плата. app.get(NotesService) больше не работает — InvalidClassScopeException, и то же самое на контроллере, который области не объявлял. Отказ честный: у контейнера нет заявки, а значит, нет и ответа на вопрос, какой из объектов отдавать. Вместо get остаётся resolve: он асинхронный и на каждый вызов создаёт новый экземпляр вместе со всей его по-запросной цепочкой — после двух resolve счётчик CurrentUser вырос с двух до четырёх. Это не кеш и не утечка, это новый контекст, и держать такие объекты в переменной модуля нельзя.
Отсюда список того, что ломает область запроса, если поставить её не туда. Строка const config = app.get(ConfigService) в main.ts боевого API держится ровно на том, что настройки — одиночка; стань они по-запросными, приложение упало бы на старте, не дойдя до первого запроса. Кеш или счётчик в поле сервиса теперь заводится на каждый запрос и умирает вместе с ним — то есть не работает. Подписка или таймер в конструкторе становятся подпиской на запрос. И сборка перестаёт быть бесплатной: на каждый запрос строится не один объект, а вся заражённая цепочка снизу доверху.
TRANSIENT, в отличие от REQUEST, не заразен, и прогон это показывает. NotesRepo и HealthController внедрили Logger и остались одиночками: их счётчики после четырёх запросов те же, что после старта, а app.get(HealthController) отвечает как ни в чём не бывало. Зато app.get(Logger) падает тем же InvalidClassScopeException: у самого транзитивного провайдера «одного» объекта нет, а есть по объекту на каждого потребителя — их в прогоне два.
У области запроса есть родственник для приложений, где клиентов много, а разделять их надо не по запросу, а по арендатору: @Injectable({ scope: Scope.REQUEST, durable: true }). Такой провайдер живёт не в контексте одного запроса, а в общем поддереве, которое вы назначаете сами: стратегия, поставленная один раз через ContextIdFactory.apply, смотрит на заявку и привязывает её к родительскому контексту — обычно по идентификатору арендатора. Запросов тысяча, арендаторов десять — объектов будет десять, и отдельный запрос они переживут.
Для одного приложения с одной базой это лишняя сложность, и в боевом API учебника ничего подобного нет. Но знать о такой возможности стоит: без неё «область запроса на арендатора» выглядит как выбор между утечкой и тысячей лишних объектов.
Область не объявляют — её получают. Посмотрите, кого внедряет ваш сервис, прежде чем обещать, что он одиночка.
Между «протащить пользователя параметром через все слои» и «пересобирать половину приложения на каждый запрос» есть третий путь: положить контекст рядом с цепочкой асинхронных вызовов. В Node для этого есть AsyncLocalStorage из node:async_hooks — хранилище, которое видит только та цепочка, внутри которой оно заведено, и видит даже после await.
Прогон ниже повторяет утечку из песочницы настоящим node, только рядом с полем-одиночкой стоит хранилище. Репозиторий читает обоих и фильтрует заметки дважды: один раз по полю, другой — по хранилищу. Порядок задан таймерами с разными задержками, и он не случайный: база отвечает Боре раньше, чем Ане. Прогноз: что напечатает каждая заявка и что останется после них?
import { AsyncLocalStorage } from 'node:async_hooks'; const ВСЕ = ['аня: купить хлеб', 'боря: пароль от почты']; const хранилище = new AsyncLocalStorage(); let текущая = null; // «текущая заявка» полем одиночки: одно поле на весь процесс // вместо запроса в базу: ответ приходит через заданное время const запрос = (мс) => new Promise((готово) => setTimeout(готово, мс, ВСЕ)); // репозиторий на три уровня вглубь: кто спрашивает, ему никто не передавал async function своиЗаметки(мс) { const все = await запрос(мс); return { поПолю: все.filter((строка) => строка.startsWith(`${текущая}:`)), изХранилища: все.filter((строка) => строка.startsWith(`${хранилище.getStore()}:`)), }; } async function обработать(имя, мс) { текущая = имя; // так прослойка кладёт пользователя в общий контекст const { поПолю, изХранилища } = await своиЗаметки(мс); console.log(`${имя}: по полю одиночки — ${JSON.stringify(поПолю)}`); console.log(`${имя}: из хранилища — ${JSON.stringify(изХранилища)}`); } // две заявки пришли одна за другой, а база ответила в обратном порядке await Promise.all([ хранилище.run('аня', обработать, 'аня', 300), хранилище.run('боря', обработать, 'боря', 40), ]); console.log(`вне заявки: поле одиночки — ${текущая}, хранилище — ${хранилище.getStore()}`);
боря: по полю одиночки — ["боря: пароль от почты"] боря: из хранилища — ["боря: пароль от почты"] аня: по полю одиночки — ["боря: пароль от почты"] аня: из хранилища — ["аня: купить хлеб"] вне заявки: поле одиночки — боря, хранилище — undefined
Поле повторило утечку слово в слово: Аня получила заметку Бори. Хранилище отдало каждой заявке своё, хотя читает его функция, до которой от границы три вызова, и никто ей ничего не передавал: run() заводит значение на всю цепочку вызовов, включая то, что случится после await. Последняя строка — о границе: вне run() хранилище пусто, а в поле так и осталось чужое имя.
В Nest такое хранилище ставят на входе — прослойкой или перехватчиком, — а читают откуда угодно: из логгера, из репозитория, из фабрики соединений. Классы при этом остаются одиночками: ни один объект не пересобирается, а контекст всё равно свой на каждый запрос. Сквозной номер запроса в логах — ровно этот случай.
Плату стоит назвать честно. Хранилище — неявная зависимость: по подписи метода не видно, что он читает пользователя, и в юнит-тесте такой метод надо не подменить, а обернуть в run(). Забытый run() даёт undefined там, где ждали объект, и падение случается в глубине, а не на границе. И это всё-таки глобальное состояние: удержаться и класть в него только контекст запроса, а не «всё, что неудобно передавать», придётся самим.
| Способ | Что пересобирается на запрос | Чем платите |
|---|---|---|
| Параметром через все слои | ничего | подписи всех методов по пути; зато зависимость видна в коде |
Провайдер Scope.REQUEST | вся заражённая цепочка снизу вверх | app.get, кеши в полях, стоимость сборки; зато контейнер делает всё сам |
AsyncLocalStorage | ничего | неявная зависимость и обязательный run() на входе и в тестах |
Выбор из трёх строк чаще всего такой. Один-два слоя — передавайте параметром, это самый честный код. Нужен контекст на всей глубине и он именно контекст запроса — берите хранилище. Область запроса оставьте для случаев, когда объект и правда обязан быть своим у каждого запроса: открытая транзакция, соединение с правами пользователя. И держите в голове, докуда эта область поднимется. В tRPC, кстати, вопрос решён иначе: контекст там создаётся на каждый запрос и приезжает в процедуру аргументом — об этом следующая глава.
Контекст запроса — это не «данные приложения». Всё, что не умрёт вместе с ответом, в нём не место.
- Модуль в живом проекте — это папка раздела и три списка: корень приложения не делает ничего сам, а в
exportsпопадает то, чем уже пользуется сосед, а не то, чем он мог бы воспользоваться. @Global()расширяет видимость модуля на всё приложение, но не заводит его: импортировать хотя бы раз, обычно в корне, всё равно надо, а наружу по-прежнему открыты толькоexports. Цена — зависимость, которой не видно в@Module.- Динамический модуль — статический метод, возвращающий описание. Nest различает описания по ссылке: два вызова
forRootс одинаковыми настройками дают два модуля, а значит, два пула и два счётчика. - Область — это ответ на вопрос «сколько объектов»:
DEFAULT— один на приложение,REQUEST— один на запрос,TRANSIENT— свой каждому потребителю. - Область запроса заразна снизу вверх: сервис и контроллер, ничего не объявлявшие, пересобираются на каждый запрос,
app.getна них отвечаетInvalidClassScopeException, аresolveсоздаёт новый объект на каждый вызов.TRANSIENTтак не делает. - Тот же контекст без пересборки даёт
AsyncLocalStorage:run()на входе, чтение на любой глубине и послеawait. Платят неявной зависимостью и обязательной обёрткой в тестах.
1/4CurrentUser объявлен со Scope.REQUEST. NotesService внедряет его, NotesController внедряет сервис, и ни тот ни другой области не объявляли. Сколько объектов NotesController создаст Nest на сто запросов к его маршруту?
2/4ClockModule помечен @Global() и экспортирует Clock, но в корневом модуле его не импортировали. Что будет при старте?
3/4Два модуля вызвали SettingsModule.forRoot("postgres://app") каждый у себя, аргументы совпали дословно. Сколько объектов Settings будет в приложении?
4/4Сервис не объявлял области и внедряет только логгер со Scope.TRANSIENT. Что вернёт app.get на самом сервисе?
Уберите общее поле
Перед вами та же утечка, но лечить её будем без Nest и без хранилища: пусть имя пользователя едет параметром. Уберите класс RequestContext и поле context.user — имя должно приезжать в mine() и дальше в byOwner(). Нужен вывод «боря получил: боря: пароль от почты» и «аня получил: аня: купить хлеб»: каждому своё. И посчитайте заодно, сколько подписей пришлось поменять ради двух слоёв.
Имя становится первым параметром у обоих методов, а общее поле и класс вокруг него исчезают:
В заявка строка с присваиванием уходит, а вызов становится notes.mine(имя, ступенек). Вывод:
Подписей пришлось поменять две, и это всего на два слоя — в этом весь сюжет статьи. В боевом коде слоёв не два, а четыре-пять: контроллер, сервис, репозиторий, фабрика соединения, логгер. Параметр, который нужен только логгеру в самом низу, проезжает через всех, и первая же функция, куда его забыли добавить, обрывает цепочку. Из этой усталости и выросли оба способа из статьи — область запроса и хранилище на цепочку вызовов.
Два пула на ровном месте
Приложение стартует и работает, но в базе вдвое больше сессий, чем ожидали, а счётчик соединений в двух разделах показывает разное. DbModule.forRoot(url) возвращает динамический модуль с пулом. Найдите причину и предложите две починки: одну без глобального модуля, другую с ним.
Причина — два вызова forRoot. Каждый возвращает свой объект-описание, Nest считает их разными модулями, и провайдер POOL создаётся в каждом: два модуля — два пула, хотя адрес один.
Починка без глобальности — позвать метод один раз и ввезти полученное описание в оба модуля:
Починка с глобальностью — вызвать forRoot один раз в корне и пометить описание:
Тогда из NotesModule и AuthModule строка imports уходит совсем. Именно так подключена база в боевом API учебника, только пометка там стоит над классом — @Global() над DrizzleModule. И то и другое лечение оставляет один пул; выбор между ними — выбор между явным списком зависимостей и коротким модулем.
Кого пересоберёт Nest
Раздел поиска многоарендный: Tenant достаёт арендатора из заголовка, TenantCache держит найденное в Map. Ответьте по листингу, не запуская: какие классы Nest создаст к концу старта; сколько объектов TenantCache будет на сто запросов к /search; что вернёт app.get(SearchService) из main.ts; и чем в этой сборке стал кеш.
К концу старта создан только Metrics: он одиночка, и его создают, даже если он никому не нужен. Tenant по-запросный, а TenantCache, SearchService и SearchController заражены им снизу вверх — при старте не создаётся ни один из них.
На сто запросов — сто объектов TenantCache, и столько же сервисов и контроллеров. app.get(SearchService) бросит InvalidClassScopeException: без заявки контейнеру нечего отдать, и в main.ts остаётся только асинхронный resolve, который создаст ещё один экземпляр.
Кеш стал бесполезным: Map заводится на каждый запрос и умирает с ним, так и не дождавшись второго обращения. Причём приложение работает, тесты проходят, и заметить это можно только по нагрузке на базу. Лечение — вынуть Tenant из конструктора кеша и передавать идентификатор арендатора аргументом метода: byQuery(tenantId, query). Тогда кеш снова одиночка и живёт столько же, сколько процесс, а по-запросным остаётся один Tenant — его и внедряют там, где заявка уже есть, в контроллере.