awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Скоупы и время жизни DI: singleton, request, transient

Суть У связанного объекта есть время жизни: создан один раз, по разу на запрос или на внедрение. Неверный выбор — два противоположных бага: синглтон, держащий состояние запроса, утекает данные между пользователями, а request-скоуп тихо пересоздаёт половину графа на каждом вызове.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 15 min

Баг-репорт говорит: пользователь A иногда видит имя аккаунта пользователя B. Код auth корректен. Утечка — в поле currentUser на сервисе, зарегистрированном как синглтон — один экземпляр, разделяемый каждым параллельным запросом. Запрос A ставит поле, запрос B перезаписывает миллисекундой позже, и запрос A читает значение B. Никто не писал баг безопасности. Кто-то выбрал неверное время жизни.

Три времени жизни

DI-контейнер может создавать экземпляр с тремя ритмами:

  • Singleton — один экземпляр на весь процесс, создан один раз и разделяем каждым запросом. Дефолт в NestJS, Spring и большинстве контейнеров. Быстро и легко по памяти, ведь ничего не пересоздаётся.
  • Request-scoped — один экземпляр на входящий запрос, разделяем внутри этого запроса, выброшен после. Полезен для контекста на запрос (текущий пользователь, request ID, транзакция на запрос).
  • Transient — свежий экземпляр на каждое внедрение. Полезен для stateful-помощников, которыми вообще нельзя делиться.

Ловушка «синглтон с состоянием»

Баг из Hook — каноническая ошибка синглтона: синглтон должен быть stateless относительно запроса, ведь он разделяем всеми параллельными запросами. Хранение this.currentUser на синглтоне — утечка данных, ждущая случиться: параллельные запросы гонятся за общим полем. Лечение — либо пустить данные на запрос через аргументы метода (передавать user, а не хранить), либо сделать сервис request-scoped, чтобы каждый запрос получал свой экземпляр.

Противоположная ловушка: всплытие скоупа

Request-скоуп выглядит безопасным дефолтом, и команды тянутся к нему — и платят скрытую цену. Скоуп всплывает вверх по цепочке внедрения: если request-scoped провайдер внедрён в контроллер, тот контроллер тоже становится request-scoped, и всё, что зависит от него. Один request-scoped логгер у самого низа может «заразить» весь граф выше, так что контейнер переинстанцирует большой срез ваших сервисов на каждом запросе вместо переиспользования синглтонов. Это лишние аллокации, лишнее давление на GC и измеримая задержка — ради того, что часто лишь удобство.

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

Почему request-скоуп всплывает, а transient — нет? Всплытие скоупа — про корректность разделения. Если сервис A request-scoped (один экземпляр на запрос) и контроллер C внедряет A, то C не может быть синглтоном — единственный общий C должен был бы держать ссылку на какой-то A, но A разный на каждый запрос. Значит C тоже должен создаваться на запрос; время жизни запроса распространяется вверх к каждому потребителю. Transient иначе: синглтон может безопасно держать transient-зависимость, ведь transient создан свежим в момент внедрения, и синглтон просто хранит этот один экземпляр. Transient меняет, сколько экземпляров существует, а не как долго должен жить потребитель, поэтому не навязывает потребителям более короткое время жизни.

Выбор времени жизни

Правило, избегающее обеих ловушек: по умолчанию singleton и держи синглтоны stateless; тянись к request-скоупу лишь когда тебе вправду нужна идентичность на запрос, которую нельзя передать аргументом; используй transient экономно для намеренно неразделяемых stateful-помощников. Большинство случаев «мне нужен текущий пользователь здесь» лучше решаются передачей его через вызов, чем превращением сервиса в request-scoped и утягиванием всего графа вниз.

СкоупЭкземпляровЛучше дляЛовушка
SingletonОдин на процессStateless-сервисы, клиенты, пулыДержит состояние запроса → утечка данных
RequestОдин на запросТекущий пользователь, транзакция запросаВсплывает, пересоздаёт граф на запрос
TransientОдин на внедрениеНеразделяемые stateful-помощникиНеожиданное число экземпляров
Викторина

Синглтон-сервис хранит `this.currentUser`, и иногда один пользователь видит данные другого. Почему?

Викторина

Почему превращение одного низкоуровневого провайдера в request-scoped может ударить по производительности множества сервисов?

Викторина

Синглтону нужен пользователь текущего запроса. Какое чистейшее решение избегает обеих ловушек?

Вспомните перед уходом
  1. 01
    Каковы три времени жизни DI и для чего лучше каждое?
  2. 02
    Почему хранение состояния на запрос в синглтоне — баг утечки данных и как это чинят?
  3. 03
    Объясните всплытие скоупа и почему request-скоуп может быть дорогим, а transient — нет.
Итог

У каждого объекта, который связывает контейнер, есть время жизни, и выбор — решение про корректность и цену. Singleton — один экземпляр на процесс — это дефолт и верный дом для stateless-сервисов, клиентов и пулов, но хранение на нём состояния на запрос утекает данные между параллельными пользователями, канонический баг синглтона. Request-скоуп даёт каждому запросу свой экземпляр и честно чинит настоящую идентичность на запрос, но всплывает вверх по цепочке внедрения: один request-scoped провайдер может навязать пересоздание своих потребителей, и их потребителей, на каждом запросе, меняя переиспользование на аллокации и задержку. Transient создаёт свежий экземпляр на внедрение, не навязывая потребителям более короткое время жизни. Дисциплина — по умолчанию stateless-синглтоны и передача значений на запрос аргументами, повышая скоуп лишь когда нет чище варианта. Времена жизни готовят следующую заботу: с графом, связанным и заскоупленным, как DI становится швом, делающим систему тестируемой.

Связанные уроки
встречается в185
Продолжить восхождение ↑DI как шов для тестов: фейки, моки и граница, которая важна
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.