Deep Engineering
Продвинутый·Опубликовано·3.13·25 МИН

DI-контейнер: что он делает вместо вас — и чем dishka отличается от dependency-injector

Контейнер решает не «уменьшить связанность», а две конкретные задачи: собрать граф объектов и провести границу времени их жизни. Главное расхождение двух библиотек — на второй. Из этого следует и то, что два репозитория одного запроса окажутся в разных транзакциях, и то, кто быстрее — ответ на второй вопрос зависит от первого.

Полное техническое изложение

TL;DR

Контейнер решает две задачи, и вторую обычно не называют:

  • собрать граф — построить объект вместе со всем, что ему нужно, не перечисляя это в каждом вызове;
  • провести границу времени жизни — сказать, что вот эта сессия живёт ровно один запрос, общая для всех, кто её в этом запросе попросил, и закрывается на выходе.

dishka строит модель вокруг второй задачи: Scope is a lifespan of a dependency (Область — это время жизни зависимости), границу рисует контекстный менеджер, зависимость ищется по аннотации типа.

dependency-injector строит её вокруг первой: провайдеры собираются в контейнер, а границу запроса вы проводите сами. Его Factory документирован прямо — Factory provider creates new objects (Провайдер Factory создаёт новые объекты) — и потому двум репозиториям одного запроса выдаёт две разные сессии, то есть две транзакции.

Что из этого следует по замерам (3.13.7, dishka 1.10.1, dependency-injector 4.49.1):

  • на одинаковой работе порядок противоположный: отдать готовый объект — dependency-injector быстрее в 6 раз (40 нс против 238); сделать «одна сессия на запрос» — dishka быстрее в 2,3 раза (2462 нс против 5583);
  • ошибка в объявлении зависимостей у dishka видна при создании контейнера, у dependency-injector — при первом вызове, а забытый wire() не даёт ошибки вовсе: в аргумент приходит объект-маркер;
  • расхожее «dependency-injector не типизирован» на проверке не подтверждается: mypy одинаково ловит несовпадение у обеих. Не ловит он другое — подмену внутри Provide[...].

Что именно делает контейнер

Термину двадцать два года, и появился он в тексте с точной датой.

As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.

перевод

В итоге, после долгого обсуждения с разными сторонниками IoC, мы остановились на названии Dependency Injection.

Мартин Фаулер, 23 января 2004

Сама идея у Фаулера сформулирована через отдельный объект-сборщик:

The basic idea of the Dependency Injection is to have a separate object, an assembler, that populates a field in the lister class with an appropriate implementation for the finder interface.

перевод

Основная идея внедрения зависимостей — завести отдельный объект, сборщик, который заполняет поле в классе-потребителе подходящей реализацией нужного интерфейса.

Там же

Обратите внимание, чего в этом определении нет. Нет слов «уменьшить связанность», «упростить тестирование» и «инверсия управления» — всё это следствия, а не механизм. Механизм — сборщик, который знает, что чем заполнять.

Первая задача контейнера — собрать граф. Без него код выглядит так:

PYTHON
def handler(request):
    session = Session(pool)
    users = UserRepo(session)
    orders = OrderRepo(session)
    unit = UnitOfWork(users, orders)
    try:
        return unit.run(request)
    finally:
        session.close()

Почти всё тело обработчика — сборка: четыре строки из восьми только создают объекты. Добавьте ещё одну зависимость в UnitOfWork, и придётся пройти по всем обработчикам.

Вторая задача — и вот она обычно не названа. Посмотрите на session в примере: она одна на оба репозитория и закрывается в finally. Оба этих свойства — не про сборку, а про время жизни: сколько живёт объект и кто его на этом отрезке делит. Именно здесь две библиотеки расходятся, и разница дороже любой разницы в скорости.

Одна сессия на запрос — или по одной каждому

Проверка простая. Два репозитория в одном запросе, обоим нужна сессия.

dishka отвечает на этот вопрос понятием области жизни.

Scope is a lifespan of a dependency. Standard scopes are (with some skipped): APPREQUESTACTIONSTEP

перевод

Область — это время жизни зависимости. Стандартные области таковы (часть пропущена): APP → REQUEST → ACTION → STEP.

dishka, Key concepts
PYTHON
class MyProvider(Provider):
    pool = provide(Pool, scope=Scope.APP)
    users = provide(UserRepo, scope=Scope.REQUEST)
    orders = provide(OrderRepo, scope=Scope.REQUEST)
 
    @provide(scope=Scope.REQUEST)
    def session(self, pool: Pool) -> Iterable[Session]:
        session = Session(pool)
        yield session
        session.close()

Внутри одного REQUEST сессия одна, и это проверено сравнением по is. На выходе из области выполняется всё, что стоит после yield, — The finalization of dependencies runs in reverse creation order (Завершение зависимостей идёт в порядке, обратном созданию). Границу рисует контекстный менеджер: To enter a nested scope, you call it and use it as a context manager (Чтобы войти во вложенную область, вы вызываете контейнер и используете результат как контекстный менеджер).

dependency-injector встроенной иерархии областей не имеет — и не скрывает этого. Его основной провайдер документирован так:

Factory provider creates new objects. Factory injects the dependencies every time when creates a new object.

перевод

Провайдер Factory создаёт новые объекты. Factory внедряет зависимости каждый раз, когда создаёт новый объект.

dependency-injector, Factory provider

Каждый раз — значит каждый раз. UserRepo попросил сессию и получил новую, OrderRepo попросил и получил ещё одну. Измерено: два разных номера сессии, users.session is orders.session даёт False. Отсюда следствие, которого замер уже не показывает: две записи одного запроса уйдут в разные транзакции, и заметно это станет на первом же откате.

Если вам нужна одна сессия на запрос, у библиотеки есть для этого ContextLocalSingleton: сессия становится одной на контекст выполнения. Но границу запроса вы теперь проводите сами:

PYTHON
try:
    return container.uow()
finally:
    container.session.reset()      # без этой строки сессия переживёт запрос

Провайдер с завершением у dependency-injector всё-таки есть — Resource: Resource provider provides a component with initialization and shutdown (Провайдер Resource предоставляет компонент с инициализацией и завершением). Но его инициализация happens only once (происходит только один раз), то есть это уровень приложения, а не запроса; для запроса документация отсылает к отдельному ContextLocalResource, и границу ему по-прежнему проводите вы.

И вот здесь важное: без reset() всё выглядит правильно ровно один запрос. Сессия общая, транзакция одна, тесты зелёные. Второй запрос получает ту же самую сессию — и это видно только тогда, когда запросов становится два.

Как зависимость находят

Второе расхождение — по способу указать, что именно внедрять.

dishka ищет по аннотации типа. container.get(Service) — и всё; фабрика находится по типу, который вы объявили. То же в обработчике: параметр FromDishka[Service].

dependency-injector ищет по маркеру, указывающему на конкретный атрибут конкретного контейнера:

Wiring marker specifies what dependency to inject, e.g. Provide[Container.bar]. When wiring is done functions and methods with the markers are patched to provide injections when called.

перевод

Маркер подключения указывает, какую зависимость внедрять, например Provide[Container.bar]. Когда подключение выполнено, функции и методы с маркерами патчатся, чтобы предоставлять внедрения при вызове.

dependency-injector, Wiring

Разница выглядит стилистической, а даёт три практических следствия, и все три проверяются.

Первое: аннотация и маркер не сверяются. Вот код, который проходит и контейнер, и mypy:

PYTHON
@inject
def handler(service: Service = Provide[Container.config]) -> str:
    return service.run()

В параметр, объявленный как Service, приходит Config. Падает это позже — AttributeError: 'Config' object has no attribute 'run' — и в другом месте.

Второе: забытый wire() не даёт ошибки. Патчить нечего, значение аргумента по умолчанию остаётся прежним, и обработчик получает сам объект-маркер:

PYTHON
print(type(handler()).__name__)   # 'Provide'

Ни исключения, ни предупреждения. У dishka симметричный случай — необёрнутый обработчик — падает сразу: TypeError: missing 1 required positional argument.

Третье: зависимость по типу проверяема заранее. Раз фабрика ищется по аннотации, весь граф можно обойти в момент сборки контейнера — что dishka и делает.

Сколько кода придётся переписать при смене библиотеки

Из предыдущего раздела следует вопрос, который стоит задать до выбора: если через год библиотеку меняют, что именно переписывают.

Ответ у обеих начинается одинаково, и это стоит сказать прямо, потому что спор обычно ведут не о том. Прикладной слой не трогает ни одна. dishka формулирует это требованием к самой себе:

  1. Usage of container must not require modification of objects we are creating.
  2. Container must not require being a global variable.
  3. Container can require code changes on the borders of scopes (e.g. application start, middlewares, request handlers).
перевод

1. Использование контейнера не должно требовать изменения объектов, которые мы создаём. 2. Контейнер не должен требовать быть глобальной переменной. 3. Контейнер может требовать изменений кода на границах областей (например, старт приложения, middleware, обработчики запросов).

dishka — Technical requirements

Третий пункт — не оговорка, а честно названная зона присутствия: границы областей. dependency-injector обещает то же про структуру приложения и адресует маркеры туда же:

With wiring you do not need to change the traditional application structure of your framework. […] Place wiring markers in the functions and methods where you want the providers to be injected (Flask or Django views, Aiohttp or Sanic handlers, etc).

перевод

При использовании подключения вам не нужно менять традиционную структуру приложения вашего фреймворка. […] Разместите маркеры подключения в функциях и методах, куда вы хотите внедрять провайдеры (view-функции Flask или Django, обработчики Aiohttp или Sanic и т. п.).

dependency-injector — Wiring

Значит, вопрос не «сколько кода заражено», а «что именно называет маркер в адаптерном слое».

И вот тут расхождение принципиальное. Форма у маркеров одинаковая — оба Annotated: FromDishka[Service] есть Annotated[Service, FromComponent()], а у dependency-injector поддержан Annotated[Service, Provide[Container.service]]. Разное — содержимое. Первый называет тип. Второй — путь к атрибуту конкретного контейнера.

Отсюда цена переезда:

  • маркер, называющий тип, при смене библиотеки меняется на другой маркер того же типа — правка механическая, и её можно проверить: тип в аннотации остался тот же;
  • маркер, называющий Container.service, несёт в прикладной файл имя класса контейнера и имя его атрибута. Переезд — это не замена импорта, а переименование по всему адаптерному слою, и опечатка в нём не ловится проверяющим типов (это ровно тот дефект, который разобран выше).

Есть и вещь, которой у dishka нет вовсе: список файлов с маркерами. У dependency-injector он существует явно, потому что подключение делается вызовом:

PYTHON
container.wire(modules=["yourapp.module1", "yourapp.module2"])

Это одновременно и неудобство (список надо вести), и то, чего не хватает второй библиотеке: границу заражения видно в одном месте, и её можно посчитать. Считать при выборе стоит именно это — число файлов в wire() против числа файлов, где стоит FromDishka[].

Способы ослабить связь у обеих документированы, но не бесплатны. У dependency-injector есть строковые идентификаторы: With string identifiers you don't need to use a container to specify an injection (Со строковыми идентификаторами вам не нужен контейнер, чтобы указать внедрение). Импорт контейнера из прикладного модуля уходит — а проверка типа слабеет ещё сильнее, чем в разобранном выше случае.

У dishka есть DishkaRoute, снимающий @inject, но не FromDishka[]: автоматическое внедрение работает only for HTTP, not for websockets (только для HTTP, не для веб-сокетов), а сам маркер параметра остаётся обязательным. Убрать и его можно — в исходнике default_parse_dependency голая аннотация типа возвращает None, то есть по умолчанию не внедряется, но параметр parse_dependency у wrap_injection публичный, и своя интеграция вправе внедрять по типу. Ценой своей интеграции.

Итог, который стоит унести: отчуждаем у обеих не прикладной слой — он отчуждаем изначально, — а адаптерный. И вопрос при выборе один: маркер называет ваш тип или их контейнер.

Когда становится известно, что всё собрано неправильно

Это свойство замечают последним и платят за него больше всего.

Разница в одну строку кода:

PYTHON
container = make_container(MyProvider())
# dishka: GraphMissingFactoryError — прямо здесь, на старте приложения
 
container = MyContainer()
# dependency-injector: контейнер создан; TypeError придёт при первом вызове

У dependency-injector есть check_dependencies(), и она хорошая: находит незаданную зависимость с понятным текстом. Но вызвать её нужно самому, и покрывает она только объявленные providers.Dependency — забытый аргумент конструктора в неё не попадает.

Про типизацию — вопреки ожиданию. Утверждение «dishka типобезопасна, а dependency-injector нет» проверялось прогоном mypy и не подтвердилось: container.get(Service) и container.service() оба возвращают Service, и присваивание результата переменной другого типа mypy ловит в обоих случаях. Не ловит он ровно одно — подмену внутри Provide[...], потому что маркер с аннотацией не сверяется ничем.

Симметричный случай у dishka тоже есть: запрос REQUEST-зависимости на APP-уровне даёт NoFactoryError с указанием области — понятно, но уже во время выполнения, а не в make_container. У dependency-injector такой ошибки быть не может: объявленной области, которую можно перепутать, в нём нет — границу вы проводите сами.

Что это стоит

Сравнивать эти библиотеки по скорости можно только там, где они делают одно и то же, — и таких мест два.

Отдать уже созданный объект. Здесь dependency-injector быстрее в шесть раз: 40 нс против 238. Причина видна одной строкой:

PYTHON
from dependency_injector import providers
print(providers.__file__)     # .../providers.abi3.so

Это скомпилированный модуль. dishka — чистый Python.

Сделать «одна сессия на запрос». А здесь порядок обратный: 2462 нс у dishka против 5583 нс у ContextLocalSingleton со сбросом. В 2,3 раза — в пользу нескомпилированной библиотеки, потому что скомпилированной приходится делать эту работу окольным путём. Оговорка та же, что абзацем ниже, только в другую сторону: в свои 2462 нс dishka ещё и закрывает сессию, а reset() только отпускает ссылку. То есть медленнее здесь тот, кто делает меньше.

Всё остальное в замере сравнивать между собой нельзя. Factory собирает граф за 1840 нс против 2462 у dishka — и не открывает границу запроса, не закрывает сессию и выдаёт две вместо одной. «Быстрее» здесь означает «сделал меньше».

Отдельно стоит цена самой границы: вход и выход из области без единого resolve стоят 785 нс из 2462. То есть почти треть цены запроса — это не сборка графа, а его время жизни.

И цена старта: make_container — 969 мкс против 170 мкс у DeclarativeContainer. На запуске приложения это не значит ничего; в наборе тестов, где контейнер создаётся на каждый тест, значит. Покупается на неё проверка графа целиком.

Как выбирать

Вопрос не в скорости — числа выше показывают, что ответ на него зависит от того, что ваше приложение делает чаще. Вопрос в том, нужна ли вам граница запроса как понятие языка.

Она нужна, если в одном запросе больше одного потребителя общего ресурса: двух репозиториев с одной сессией достаточно, чтобы Factory стал источником тихих ошибок, а ContextLocalSingleton — источником обязанности не забыть reset() в каждой ветке. Тогда dishka закрывает вопрос механизмом, а не дисциплиной.

Она не нужна, если контейнер у вас — реестр синглтонов: конфигурация, пул, клиенты внешних служб, всё живёт столько же, сколько процесс. Тогда dependency-injector делает ровно то, что нужно, и делает это быстрее — плюс у него есть провайдер Configuration, читающий YAML, INI и переменные окружения.

Чего не стоит делать в обоих случаях — выбирать по бенчмаркам из README. Разница в 200 наносекунд на одном обращении за зависимостью не видна ни в одном веб-приложении: на фоне одного запроса к базе это ноль. Видна другая разница — та, где две записи оказались в разных транзакциях.

История версий

ВерсияИзменениеЧто это значит для кода
2004Мартин Фаулер публикует текст, в котором у приёма появляется имя: «мы остановились на названии Dependency Injection». Там же названы три формы внедрения — через конструктор, через сеттер и через интерфейс — и проведена граница с локатором служб: «каждый потребитель службы зависит от локатора».
dependency-injector 4.xБиблиотека компилируется в расширение (providers.abi3.so), и это видно по цене операции, ради которой она сделана: выдача готового объекта — 40 нс. Модель остаётся прежней: провайдеры, маркеры Provide[...] и подключение через wire(); встроенной иерархии областей жизни в ней нет.
dishka 1.xМодель строится вокруг области жизни: Scope.APP → REQUEST → ACTION → STEP, вход через контекстный менеджер, завершение в обратном порядке создания. Зависимость ищется по аннотации типа, и потому весь граф проверяется в make_container — ошибка проводки видна на старте, а не в проде.
проверено наPython 3.13.7, dishka 1.10.1, dependency-injector 4.49.1, mypy 2.3.1. Числа относятся к этим сборкам, а не к «библиотекам вообще»: через полгода их надо снимать заново.

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

Частые заблуждения

Утверждение

DI-контейнер нужен, чтобы уменьшить связанность

На самом деле

Связанность уменьшает внедрение зависимости — то есть то, что объект получает готовые части, а не создаёт их. Для этого контейнер не нужен вовсе: достаточно передать их аргументами конструктора. Контейнер решает две другие задачи: собирает граф, чтобы не перечислять зависимости в каждом обработчике, и проводит границу времени жизни. У Фаулера это названо прямо: сборщик, «который заполняет поле в классе-потребителе подходящей реализацией».

Утверждение

Скомпилированная библиотека быстрее, значит dependency-injector быстрее

На самом деле

Быстрее — на той операции, ради которой сделана: отдать уже созданный объект, 40 нс против 238 у dishka, в 6 раз. На задаче «одна сессия на запрос» порядок обратный: 2462 нс у чистого Python против 5583 у скомпилированной библиотеки, потому что делать эту работу ей приходится окольным путём — через ContextLocalSingleton и ручной reset().

Утверждение

Factory у dependency-injector — это аналог request-scope

На самом деле

Наоборот. Измерено: два репозитория одного запроса получают ДВЕ разные сессии, users.session is orders.session даёт False. Документация обещает ровно это: Factory injects the dependencies every time when creates a new object (Factory внедряет зависимости каждый раз, когда создаёт новый объект). Для одной сессии на запрос нужен ContextLocalSingleton — и reset(), который вызываете вы.

Утверждение

dependency-injector не типизирован, поэтому ошибки видны только в рантайме

На самом деле

Проверено прогоном mypy 2.3.1: container.service() возвращает Service, и присваивание результата переменной другого типа mypy ловит — ровно так же, как у container.get(Service) из dishka. Расхождение в другом месте: подмену внутри Provide[...] не видит ни mypy, ни контейнер. Параметр, объявленный Service и получающий Config, проходит обе проверки.

Утверждение

Если контейнер собран неправильно, приложение просто не запустится

На самом деле

Зависит от библиотеки, и разброс велик. dishka падает в make_container — на старте. dependency-injector создаёт контейнер молча, и TypeError приходит при первом вызове. А забытый wire() не даёт ошибки ВОВСЕ: обработчик получает в аргумент служебный объект Provide, и падение случится позже, в другом месте и с текстом, не имеющим отношения к проводке.

Утверждение

Разница между контейнерами — вопрос вкуса, механика у них одна

На самом деле

Механика разная в самом основании: dishka ищет фабрику ПО АННОТАЦИИ ТИПА и потому может обойти весь граф на старте; dependency-injector ищет по МАРКЕРУ, указывающему на атрибут конкретного контейнера, и потому патчит ваши функции при wire(). Отсюда и разное поведение при ошибке, и разная проверяемость, и разная цена границы запроса. Вкус тут ни при чём.

Проверьте себя

Вопрос 1 из 4

В одном запросе два репозитория, обоим нужна сессия. Провайдер объявлен как providers.Factory(Session, pool=pool). Сколько сессий получится?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Martin Fowler — Inversion of Control Containers and the Dependency Injection patternИсточник. Текст от 23 января 2004 года, в котором термин и появился: «As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection» (В итоге, после долгого обсуждения с разными сторонниками IoC, мы остановились на названии Dependency Injection). Оттуда же формы внедрения: «There are three main styles of dependency injection. The names I'm using for them are Constructor Injection, Setter Injection, and Interface Injection» (Есть три основных вида внедрения зависимостей. Я называю их внедрением через конструктор, через сеттер и через интерфейс), и определение того, чем занимается контейнер: «The basic idea of the Dependency Injection is to have a separate object, an assembler, that populates a field in the lister class with an appropriate implementation for the finder interface» (Основная идея внедрения зависимостей — завести отдельный объект, сборщик, который заполняет поле в классе-потребителе подходящей реализацией нужного интерфейса). Там же критерий, отделяющий внедрение от локатора служб: «The key difference is that with a Service Locator every user of a service has a dependency to the locator» (Ключевое отличие в том, что при использовании локатора служб каждый потребитель службы зависит от локатора).https://martinfowler.com/articles/injection.html
  2. dishka — Key conceptsОфициальная документация. Определение области жизни, вокруг которого построена вся библиотека: «Scope is a lifespan of a dependency» (Область — это время жизни зависимости), и стандартная лестница: «Standard scopes are (with some skipped): APP → REQUEST → ACTION → STEP» (Стандартные области таковы (часть пропущена): APP → REQUEST → ACTION → STEP). Оттуда же про контейнер — «Container is an object you use to get your dependencies» (Контейнер — это объект, у которого вы получаете свои зависимости) — и про вложенную область: «To enter a nested scope, you call it and use it as a context manager» (Чтобы войти во вложенную область, вы вызываете контейнер и используете результат как контекстный менеджер). Порядок завершения тоже назван прямо: «The finalization of dependencies runs in reverse creation order» (Завершение зависимостей идёт в порядке, обратном созданию).https://dishka.readthedocs.io/en/stable/concepts.html
  3. dependency-injector — Factory providerОфициальная документация. Обещанное поведение основного провайдера, из которого растёт весь раздел про две сессии: «Factory provider creates new objects» (Провайдер Factory создаёт новые объекты) и «Factory injects the dependencies every time when creates a new object» (Factory внедряет зависимости каждый раз, когда создаёт новый объект). Это не недосмотр библиотеки, а её документированный контракт.https://python-dependency-injector.ets-labs.org/providers/factory.html
  4. dependency-injector — Resource providerОфициальная документация. Единственный провайдер с завершением: «Resource provider provides a component with initialization and shutdown» (Провайдер Resource предоставляет компонент с инициализацией и завершением). Там же его область действия: «Resource initialization happens only once» (Инициализация ресурса происходит только один раз) — то есть это уровень приложения, а не запроса; для контекста документация отсылает к отдельному Context Local Resource.https://python-dependency-injector.ets-labs.org/providers/resource.html
  5. dependency-injector — WiringОфициальная документация. Механизм подключения, отличающий эту библиотеку от dishka по способу поиска зависимости: «Wiring feature provides a way to inject container providers into the functions and methods» (Механизм подключения позволяет внедрять провайдеры контейнера в функции и методы), «Wiring marker specifies what dependency to inject, e.g. Provide[Container.bar]» (Маркер подключения указывает, какую зависимость внедрять, например Provide[Container.bar]). И то, что происходит при подключении: «When wiring is done functions and methods with the markers are patched to provide injections when called» (Когда подключение выполнено, функции и методы с маркерами патчатся, чтобы предоставлять внедрения при вызове). Отсюда и поведение при забытом wire(): патчить нечего, и в аргумент приходит сам маркер.https://python-dependency-injector.ets-labs.org/wiring.html
  6. dishka — QuickstartОфициальная документация. Способ обращения к контейнеру, из которого следует вся разница в проверяемости: в примерах документации стоит `container.get(APIClient)` и `request_container.get(Service)` — зависимость запрашивается по типу, а не по имени провайдера. Оттуда же объявление фабрики с областью: `@provide(scope=Scope.REQUEST)`.https://dishka.readthedocs.io/en/stable/quickstart.html