Собираем контейнер: разрешение вглубь, один экземпляр, токены
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Реестр метаданных научил каркас оставлять сведения о маршрутах и ролях и находить их. Но прежде чем маршрут сработает, нужен объект контроллера, контроллеру нужен сервис, сервису — хранилище. В Nest их никто не создаёт руками: конструктор перечисляет, что ему нужно, и объект появляется уже со всем нужным внутри. Это работа контейнера. Соберём его сами и разберём, на чём он держится: кто вызывает new, в каком порядке появляются объекты, почему сервис один на всех и почему токен — это ключ Map, а не тип.
Возьмём три слоя, из которых состоит почти любая ручка API. Контроллер принимает запрос и зовёт сервис. Сервис решает, что делать, и сохраняет через репозиторий. Репозиторий знает, где лежат строки: пока это массив в памяти, потом будет Postgres. Второй контроллер, статистика, считает заметки через такой же сервис. У каждого класса один и тот же вопрос: откуда взять объект, от которого он зависит. Самый прямой ответ — создать самому, полем с new. Прогноз: что напечатает последняя строка?
NotesController и StatsController создали по своему NotesService, а каждый сервис — по своему NotesRepo. Заметки легли в одно хранилище, а считались в другом. С массивом в памяти ошибку видно сразу. Когда репозиторий будет держать пул соединений с базой, данные сойдутся, зато пулов станет столько, сколько копий репозитория наделали классы. И второе: NotesController не проверить без настоящего хранилища. new NotesRepo() зашит в сервис, и тесту нечем его подменить.Один объект на всех в Node уже встречался: модуль выполняется один раз, и пул, созданный в модуле, делят все файлы, которые его подключили. Но и там платил тест. Чтобы получить свежий объект, он удалял модуль из кеша require, а файлы, подключившие модуль раньше, продолжали работать со старым.
Выход — развернуть зависимость. Класс не создаёт то, что ему нужно, а получает готовым в конструкторе: constructor(private readonly repo: NotesRepo) {}. Это параметр-свойство, и так написан конструктор почти любого сервиса Nest. Объекты создаёт кто-то снаружи, один раз и в одном месте: const repo = new NotesRepo(), потом new NotesService(repo), потом оба контроллера с этим сервисом. Такой приём называют внедрением зависимостей. Порядок строк не выбирают: сервис нельзя создать раньше репозитория, его нечем передать в конструктор. Для четырёх объектов это четыре строки, а в приложении классов десятки, и держать порядок руками — та работа, которую отдают контейнеру.
Класс перечисляет, что ему нужно. Кто и когда это создаст, его не касается.
Контейнеру нужно знать, что передать в конструктор, а из самого класса этого не узнать. Типы стираются: от constructor(private readonly repo: NotesRepo) в выполняемом коде остаются constructor(repo) и присваивание this.repo = repo, а слова NotesRepo там нет. Nest получает типы параметров от компилятора, но так умеют только старые декораторы, стандартные — нет. Поэтому зависимости объявляются списком: @Injectable({ deps: [NotesRepo] }) кладёт список в WeakMap по самому классу, как реестр прошлой статьи. Метод get берёт класс, получает каждую его зависимость тем же get, только потом вызывает new и запоминает готовый объект в Map. Чтобы видеть его работу, get печатает, кого у него попросили и кого он создал. Прогноз: в каком порядке выйдут строки «нужен» и «создан» и напечатает ли что-нибудь второй get?
Просили сверху вниз: контроллер, сервис, репозиторий. Создавали снизу вверх: репозиторий, сервис, контроллер. Сортировки в контейнере нет, порядок получается из рекурсии. new NotesController(…) не вызвать, пока не вернулся get(NotesService), а тот ждёт get(NotesRepo). Первым возвращается самый глубокий вызов, и первым создаётся объект без зависимостей. Это и называют разрешением вглубь: контейнер спускается по зависимостям до дна и собирает объекты на обратном пути. Второй get не напечатал ничего: объект нашёлся в Map, и вернулся тот же контроллер с тем же сервисом внутри.
У списка есть цена. Порядок в deps обязан совпадать с порядком параметров конструктора, и сверить их некому: типы стёрты, а контейнер видит только функцию и массив. Переставьте в списке два разных сервиса — каждый параметр получит чужой объект, и в лучшем случае ошибка всплывёт на первом вызове метода, которого у чужого объекта нет. Когда список пишет компилятор, как в Nest, перестановки не бывает. А класс, у которого записи в реестре нет вовсе, контейнер пока молча создаёт без аргументов. К чему это приводит, разберёт статья о «Nest can't resolve dependencies».
Порядок создания нигде не записан: он получается из того, что new ждёт свои аргументы.
Второй get вернул тот же объект, и так контейнер обходится с каждым классом: создаёт один раз и отдаёт всем, кто попросит. Ниже два сервиса зависят от счётчика номеров. Контейнер из прошлого примера — в подложке, печать из него убрана. В последних двух строках появляются второй контейнер и сервис, созданный через new в обход контейнера. Прогноз: какие номера получат заметки и метка?
Метка получила номер 2: у TagsService и NotesService один счётчик на двоих, потому что под ключом IdCounter в контейнере одна запись. Свежий контейнер начал с единицы: у него своя Map и свой счётчик. А сервис, созданный руками, выдал 3. Сам он новый — класс не мешает вызывать new сколько угодно раз, — но счётчик ему передали из app. Одиночкой объект делает не класс, а запись в контейнере: один объект на ключ, пока жив контейнер.
Для большинства сервисов так и нужно. Данных отдельного вызова они не хранят, а держат общее: пул соединений, настройки, кеш. Создавать их заново на каждый запрос значило бы заново соединяться с базой. Отсюда же удобство для тестов: вместо того чтобы вычищать кеш модулей, тест создаёт новый контейнер, и прежние объекты ему не мешают. Как на этом строится подмена репозитория в тестах, разберёт статья о тестах против настоящей базы.
Обратная сторона видна на метке. Общее состояние делят все, кто получил объект, в том числе те, кому оно не предназначалось. На сервере «все» — это ещё и все запросы сразу: контроллер и его сервисы создаются один раз и обслуживают каждый запрос, пока жив процесс. Поле такого сервиса, куда один запрос положил «текущего пользователя», увидит и другой. Для данных запроса у провайдеров Nest есть область запроса. Чего она стоит и как общее поле ломается на двух одновременных запросах, разберёт статья о модулях и областях. А счётчику, который заметкам и меткам нужен разный, поможет не область, а второй ключ.
Один экземпляр — на ключ в одном контейнере. Новый контейнер или new руками дают новый объект.
До сих пор ключом в Map контейнера был сам класс: попросили IdCounter — получили экземпляр IdCounter. Эти роли пора развести. Ключ, по которому объект просят, называют токеном, а правило, как этот объект получить, — провайдером. Токеном годится любое значение, ведь ключом Map бывает что угодно, но на деле это класс, строка или символ. Провайдеры у контейнера ниже трёх видов, и все три есть в Nest под теми же именами: useClass создаёт экземпляр класса с его зависимостями, useValue отдаёт готовое значение, useFactory вызывает функцию с зависимостями из inject. В deps теперь тоже токены. Счётчик зарегистрирован трижды: под своим классом и под двумя символами. Прогноз: что напечатают три последние строки?
2, 1 и false. У меток счётчик свой: он начал с единицы, хотя счётчик заметок уже выдал два номера. И объект под IdCounter — не тот, что под NOTE_IDS. Контейнер создал три счётчика, потому что кеш #instances ведётся по токену, а не по классу: для каждого токена useClass отработал отдельно. Класс в провайдере — рецепт, по которому объект делают, а не имя объекта. Nest ведёт себя так же: два токена с useClass одного класса дают два разных объекта.Второе, ради чего заводят токены, — зависеть не от конкретного класса. Сервису заметок нужно хранилище: в тесте в памяти, в работе в Postgres. Хочется написать deps: [NotesStore], где NotesStore — интерфейс. Компилятор ответит «"NotesStore" относится только к типу, но используется здесь как значение», в терминале это TS2693, а запуск упадёт с ReferenceError. Интерфейс исчезает при компиляции бесследно, и контейнеру нечего положить в Map. Ключ должен дожить до выполнения. Строка доживёт, но две части приложения, выбравшие одинаковую строку, в нашем контейнере затрут записи друг друга. Символ равен только себе, и два Symbol("notes") не совпадут никогда. В Nest токеном можно сделать и абстрактный класс: abstract при сборке стирается, а сам класс остаётся, и одно имя служит и типом параметра, и ключом.
Токен — адрес ячейки, провайдер — что в неё положить. Тип параметра ни то ни другое.
Ниже NotesService зависит от символа NOTES, а параметр описан интерфейсом. Контейнеров два, и собраны они по-разному. В рабочем под NOTES фабрика: она берёт адрес базы из настроек под CONFIG и соединяется — connect здесь только печатает адрес и считает строки. В тестовом под тем же токеном готовая подделка, чей add всегда отвечает 42. Контейнер из прошлого примера — в подложке. Прогноз: сколько раз напечатается «соединяюсь» и какой номер выдаст тестовый контейнер?
app. Фабрика — такой же провайдер: её результат лёг в кеш под NOTES, и на втором проходе сервис был уже готов, отсюда «заметка 2». Тестовый контейнер отдал подделку, и сервис этого не заметил: он знает токен и зовёт у объекта add. Ни одна строка NotesService не поменялась. Фабрика нужна там, где объект не получить через new с зависимостями: библиотека отдаёт соединение функцией, конструктору нужна строка из настроек, реализация выбирается по окружению. inject для фабрики — то же, что deps для класса.Цена этой свободы стоит в строке container.get(NotesService) as NotesService. get возвращает unknown: по символу компилятор не знает, что под ним лежит, а as ничего не проверяет. То же с параметром store: NotesStore — это обещание, а не проверка. Подделку { add: () => "42" } со строкой вместо числа контейнер принял бы без единой диагностики. В Nest та же дыра: @Inject(NOTES) принимает любой токен, и тип параметра рядом с ним компилятор с провайдером не сверяет. Как привязать тип к самому токену, — во второй задаче.
В главном модель совпадает с Nest: токен — ключ, провайдер — рецепт, зависимость создаётся раньше того, кто её получает, и по умолчанию под токеном один объект. Провайдеры в Nest перечисляют в модуле: это класс с декоратором @Module, у которого в массиве providers лежат провайдеры, а в controllers — контроллеры. Модуль передают в NestFactory.create, и тот возвращает готовое приложение. Расходится модель с нашей там, где разницу видно только запуском. Сверено с Nest 12.0.3: проект собран tsc с experimentalDecorators и emitDecoratorMetadata и запущен node.
| Свой контейнер | Nest 12 | |
|---|---|---|
| откуда список зависимостей | deps в @Injectable, пишется руками | типы параметров конструктора, их сохраняет компилятор |
| символ или строка в зависимостях | в том же deps | @Inject(токен) перед параметром |
| когда создаются объекты | при первом get | все сразу внутри NestFactory.create, даже те, что никто не просит |
| порядок создания | по рекурсии: зависимость раньше зависимого | зависимость раньше зависимого, остальные — как лягут промисы |
| фабрика | синхронная | может быть async, Nest дождётся значения |
| один объект | на токен в контейнере | на токен в модуле |
| псевдоним токена | нет, это третья задача | useExisting |
Две строки таблицы стоит развернуть. Первая — когда. Nest не ждёт первого get: внутри NestFactory.create он создаёт все провайдеры и контроллеры, в том числе провайдер, который никто не просит. Исключение — провайдеры с областью запроса: их создают на каждый запрос. Ошибка в фабрике или конструкторе роняет запуск, а не первый запрос — тот же принцип, ради которого статья о старте приложения проверяет настройки раньше всего остального. Вторая — порядок. Если перечислить провайдеры как [NotesService, Unused, NotesRepo], где Unused ни от кого не зависит и никому не нужен, конструкторы вызовутся в порядке Unused, NotesRepo, NotesService, и только потом контроллеры. Провайдеры модуля Nest создаёт через Promise.all: фабрика бывает асинхронной, поэтому весь контейнер работает на промисах. Пока NotesService дожидался репозитория, Unused успел появиться. Гарантия остаётся одна, та же, что у рекурсии: зависимость готова раньше того, кто её получает. На порядок остальных полагаться нельзя.
Список зависимостей в Nest пишет компилятор. С emitDecoratorMetadata он сохраняет в метаданных класса типы параметров конструктора, у NotesService это [NotesRepo], и контейнер читает их вместо нашего deps. Интерфейс там превращается в Object, по которому искать нечего, поэтому для токена-символа или строки перед параметром ставят @Inject(NOTES) — декоратор параметра, которого нет в стандарте. Откуда берутся эти метаданные и чем такой проект собирать, разберёт статья «Почему Nest живёт на старых декораторах».
Остаётся граница. Наш контейнер плоский: любой класс достаёт любой токен. В Nest провайдеры живут в модулях, и «один объект на токен» считается внутри модуля: класс, записанный в providers двух модулей, даёт два объекта. Модули и то, почему провайдер одного из них не виден другому без экспорта, контейнер получит в следующей статье.
- Класс не создаёт свои зависимости, а получает их в конструкторе, объекты же создаёт контейнер. Если каждый класс сам вызывает
new, у каждого своя копия хранилища, и подменить её в тесте нечем. - Типы параметров стираются, поэтому в своём каркасе зависимости объявляются списком:
@Injectable({ deps })кладёт его вWeakMap. Контейнер разрешает вглубь:getспускается к зависимостям и вызываетnewна обратном пути, так что зависимость всегда создана раньше зависимого. - Один экземпляр — запись в
Mapконтейнера, а не свойство класса:newпо-прежнему даёт новый объект, новый контейнер — новый набор объектов. Общее состояние одиночки делят все, кто его получил, и все запросы сервера. - Токен — ключ
Map: класс, строка или символ. Кеш ведётся по токену, поэтому один класс под двумя токенами даёт два объекта. Интерфейс токеном быть не может, он стирается; берут символ или абстрактный класс. - Провайдер — рецепт:
useClass,useValue,useFactoryсinject. Сервис знает только токен, и тест подменяет хранилище, не трогая сервис. Тип параметра при этом только обещание:getпо символу возвращаетunknown. - В Nest список зависимостей пишет компилятор, а для символов и строк нужен
@Inject. Провайдеры с областью по умолчанию создаются все сразу внутриNestFactory.create, несвязанные — в порядке промисов, фабрики бываютasync, а один объект приходится на токен в модуле.
1/4Контроллер зависит от сервиса, сервис — от репозитория, контейнер пуст. Вызван container.get(NotesController). В каком порядке сработают конструкторы?
2/4В контейнере provide("a", { useClass: IdCounter }) и provide("b", { useClass: IdCounter }). Что вернёт get("a") === get("b")?
3/4Два сервиса получают IdCounter из одного контейнера. Какое утверждение верно?
4/4Почему deps: [NotesStore] не работает, если NotesStore — интерфейс, и работает, если это абстрактный класс?
Упасть при старте, а не на первом запросе
Контейнер создаёт объекты при первом get. Поэтому сервер с пустым DATABASE_URL сообщает, что слушает порт, отвечает на проверку здоровья и падает только на первом запросе к заметкам. Допишите контейнеру метод init(), который создаёт все зарегистрированные провайдеры, и вызовите его на месте комментария. Нужно, чтобы ошибка стала первой и единственной строкой, а с адресом postgres://localhost/app печатались все три строки, как раньше.
Метод обходит все токены и просит каждый через get. Зависимости разрешатся сами, а готовые объекты повторно не создаются.
И вызов на месте комментария, до сообщения о порте:
Вывод:
С адресом postgres://localhost/app:
Порядок обхода не важен. Если первым попадётся токен, чья зависимость ещё не создана, get создаст её сам, а когда очередь дойдёт до неё, вернёт готовую. Так поступает и Nest: всё создаётся внутри NestFactory.create, до того как приложение начнёт слушать порт. Цена — создаются и провайдеры, которые в этом запуске никому не понадобятся. Зато ошибка, которую бросает фабрика или конструктор, ломает выкат сразу, а не через неделю на редком запросе. Поймать так можно только то, что проверяется при создании: пул драйвера Postgres pg при создании в базу не ходит, и неверный адрес заметит лишь на первом запросе.
Токен, который помнит тип
В тестовом контейнере две ошибки: подделка хранилища отвечает строкой, а порт записан строкой. Пример печатает 42: купить хлеб1 30001 без единой диагностики: get отдаёт unknown, а as приняли всё на слово. Сделайте класс Token с параметром типа, чтобы токены создавались как new Token<NotesStore>("notes"), get возвращал этот тип без as, а provide не пропускал значение другого типа. Цель — диагностики на обеих ошибочных строках, а после их исправления вывод 43 3001.
Тип живёт в самом токене: вместо псевдонима type Token — класс с параметром, а provide и get достают тип оттуда.
И чтение без приведений:
Компилятор подчеркнёт обе строки теста: у подделки «Тип "(text: string) => string" не может быть назначен для типа "(text: string) => number"», у порта «Тип "string" не может быть назначен для типа "number"». T для provide он берёт из токена: параметр класса назван при new, явно в угловых скобках. После исправления на (text: string) => 42 и 3000 вывод:
as T остался один — внутри get, где Map хранит значения всех типов вперемешку. Это обещание держит provide: положить под токен значение другого типа он не даст. Строка declare readonly type: T в выполняемый код не попадает, declare только описывает поле для компилятора. Без неё Token<string> и Token<number> одинаковы по форме, оба — объект с полем name, а форма важнее имени: компилятор молча пропустит const PORT: Token<number> = new Token<string>("port"). Поле с типом T делает токены разных типов разными по форме. В Nest такого токена нет: @Inject принимает строку, символ или класс, и тип параметра рядом с ним остаётся обещанием.
Псевдоним для старого имени
Счётчик номеров заметок раньше был зарегистрирован под строкой "note-ids", и старый код до сих пор просит его так, а новый — по классу IdCounter. Нужен псевдоним: провайдер { useExisting: IdCounter } отдаёт тот же объект, что лежит под IdCounter. Сейчас компилятор такого провайдера не знает, а запуск падает с TypeError. Нужно 1 2 true.
Псевдоним не создаёт объект, а просит контейнер о другом токене. Проверять его нужно раньше useClass, иначе провайдер без useValue уйдёт в ветку с new.
Вывод:
Под "note-ids" кеш запомнил тот же объект, что лежит под IdCounter, поэтому второй номер — 2. Порядок запросов значения не имеет: если первым попросят псевдоним, get(IdCounter) создаст счётчик и положит его под своим токеном, а псевдоним получит его же. Соблазнительная замена — { useClass: IdCounter } под старой строкой — печатает 1 1 false: это второй счётчик, и номера заметок начнут повторяться. В Nest псевдоним так и называется, useExisting, и под двумя токенами там тоже один объект.