Собираем контейнер: разрешение вглубь, один экземпляр, токены

каркас≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Реестр метаданных научил каркас оставлять сведения о маршрутах и ролях и находить их. Но прежде чем маршрут сработает, нужен объект контроллера, контроллеру нужен сервис, сервису — хранилище. В Nest их никто не создаёт руками: конструктор перечисляет, что ему нужно, и объект появляется уже со всем нужным внутри. Это работа контейнера. Соберём его сами и разберём, на чём он держится: кто вызывает new, в каком порядке появляются объекты, почему сервис один на всех и почему токен — это ключ Map, а не тип.

Разделы темы:·

Возьмём три слоя, из которых состоит почти любая ручка API. Контроллер принимает запрос и зовёт сервис. Сервис решает, что делать, и сохраняет через репозиторий. Репозиторий знает, где лежат строки: пока это массив в памяти, потом будет Postgres. Второй контроллер, статистика, считает заметки через такой же сервис. У каждого класса один и тот же вопрос: откуда взять объект, от которого он зависит. Самый прямой ответ — создать самому, полем с new. Прогноз: что напечатает последняя строка?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Всего заметок: 0», хотя две заметки только что созданы. По отдельности каждый класс написан верно и честно создал то, что ему нужно. Но 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?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Просили сверху вниз: контроллер, сервис, репозиторий. Создавали снизу вверх: репозиторий, сервис, контроллер. Сортировки в контейнере нет, порядок получается из рекурсии. new NotesController(…) не вызвать, пока не вернулся get(NotesService), а тот ждёт get(NotesRepo). Первым возвращается самый глубокий вызов, и первым создаётся объект без зависимостей. Это и называют разрешением вглубь: контейнер спускается по зависимостям до дна и собирает объекты на обратном пути. Второй get не напечатал ничего: объект нашёлся в Map, и вернулся тот же контроллер с тем же сервисом внутри.

У списка есть цена. Порядок в deps обязан совпадать с порядком параметров конструктора, и сверить их некому: типы стёрты, а контейнер видит только функцию и массив. Переставьте в списке два разных сервиса — каждый параметр получит чужой объект, и в лучшем случае ошибка всплывёт на первом вызове метода, которого у чужого объекта нет. Когда список пишет компилятор, как в Nest, перестановки не бывает. А класс, у которого записи в реестре нет вовсе, контейнер пока молча создаёт без аргументов. К чему это приводит, разберёт статья о «Nest can't resolve dependencies».

Порядок создания нигде не записан: он получается из того, что new ждёт свои аргументы.

Второй get вернул тот же объект, и так контейнер обходится с каждым классом: создаёт один раз и отдаёт всем, кто попросит. Ниже два сервиса зависят от счётчика номеров. Контейнер из прошлого примера — в подложке, печать из него убрана. В последних двух строках появляются второй контейнер и сервис, созданный через new в обход контейнера. Прогноз: какие номера получат заметки и метка?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Метка получила номер 2: у TagsService и NotesService один счётчик на двоих, потому что под ключом IdCounter в контейнере одна запись. Свежий контейнер начал с единицы: у него своя Map и свой счётчик. А сервис, созданный руками, выдал 3. Сам он новый — класс не мешает вызывать new сколько угодно раз, — но счётчик ему передали из app. Одиночкой объект делает не класс, а запись в контейнере: один объект на ключ, пока жив контейнер.

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

Обратная сторона видна на метке. Общее состояние делят все, кто получил объект, в том числе те, кому оно не предназначалось. На сервере «все» — это ещё и все запросы сразу: контроллер и его сервисы создаются один раз и обслуживают каждый запрос, пока жив процесс. Поле такого сервиса, куда один запрос положил «текущего пользователя», увидит и другой. Для данных запроса у провайдеров Nest есть область запроса. Чего она стоит и как общее поле ломается на двух одновременных запросах, разберёт статья о модулях и областях. А счётчику, который заметкам и меткам нужен разный, поможет не область, а второй ключ.

Один экземпляр — на ключ в одном контейнере. Новый контейнер или new руками дают новый объект.

До сих пор ключом в Map контейнера был сам класс: попросили IdCounter — получили экземпляр IdCounter. Эти роли пора развести. Ключ, по которому объект просят, называют токеном, а правило, как этот объект получить, — провайдером. Токеном годится любое значение, ведь ключом Map бывает что угодно, но на деле это класс, строка или символ. Провайдеры у контейнера ниже трёх видов, и все три есть в Nest под теми же именами: useClass создаёт экземпляр класса с его зависимостями, useValue отдаёт готовое значение, useFactory вызывает функцию с зависимостями из inject. В deps теперь тоже токены. Счётчик зарегистрирован трижды: под своим классом и под двумя символами. Прогноз: что напечатают три последние строки?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
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. Контейнер из прошлого примера — в подложке. Прогноз: сколько раз напечатается «соединяюсь» и какой номер выдаст тестовый контейнер?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Соединяюсь» — один раз на два обращения к 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 печатались все три строки, как раньше.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Метод обходит все токены и просит каждый через get. Зависимости разрешатся сами, а готовые объекты повторно не создаются.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

И вызов на месте комментария, до сообщения о порте:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

С адресом postgres://localhost/app:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок обхода не важен. Если первым попадётся токен, чья зависимость ещё не создана, get создаст её сам, а когда очередь дойдёт до неё, вернёт готовую. Так поступает и Nest: всё создаётся внутри NestFactory.create, до того как приложение начнёт слушать порт. Цена — создаются и провайдеры, которые в этом запуске никому не понадобятся. Зато ошибка, которую бросает фабрика или конструктор, ломает выкат сразу, а не через неделю на редком запросе. Поймать так можно только то, что проверяется при создании: пул драйвера Postgres pg при создании в базу не ходит, и неверный адрес заметит лишь на первом запросе.

Токен, который помнит тип

В тестовом контейнере две ошибки: подделка хранилища отвечает строкой, а порт записан строкой. Пример печатает 42: купить хлеб1 30001 без единой диагностики: get отдаёт unknown, а as приняли всё на слово. Сделайте класс Token с параметром типа, чтобы токены создавались как new Token<NotesStore>("notes"), get возвращал этот тип без as, а provide не пропускал значение другого типа. Цель — диагностики на обеих ошибочных строках, а после их исправления вывод 43 3001.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип живёт в самом токене: вместо псевдонима type Token — класс с параметром, а provide и get достают тип оттуда.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

И чтение без приведений:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Компилятор подчеркнёт обе строки теста: у подделки «Тип "(text: string) => string" не может быть назначен для типа "(text: string) => number"», у порта «Тип "string" не может быть назначен для типа "number"». T для provide он берёт из токена: параметр класса назван при new, явно в угловых скобках. После исправления на (text: string) => 42 и 3000 вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Псевдоним не создаёт объект, а просит контейнер о другом токене. Проверять его нужно раньше useClass, иначе провайдер без useValue уйдёт в ветку с new.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Под "note-ids" кеш запомнил тот же объект, что лежит под IdCounter, поэтому второй номер — 2. Порядок запросов значения не имеет: если первым попросят псевдоним, get(IdCounter) создаст счётчик и положит его под своим токеном, а псевдоним получит его же. Соблазнительная замена — { useClass: IdCounter } под старой строкой — печатает 1 1 false: это второй счётчик, и номера заметок начнут повторяться. В Nest псевдоним так и называется, useExisting, и под двумя токенами там тоже один объект.