DI-контейнер: что он делает вместо вас — и чем dishka отличается от dependency-injector
Контейнер решает не «уменьшить связанность», а две конкретные задачи: собрать граф объектов и провести границу времени их жизни. Главное расхождение двух библиотек — на второй. Из этого следует и то, что два репозитория одного запроса окажутся в разных транзакциях, и то, кто быстрее — ответ на второй вопрос зависит от первого.
Полное техническое изложение
TL;DR
- Контейнер делает две вещи: собирает граф объектов и проводит границу их времени жизни. Про первую все помнят, про вторую — нет, а расходятся библиотеки именно на ней.
- dishka построен вокруг области жизни:
Scope.APP → REQUEST, границу рисуетwith container(), зависимость ищется по аннотации типа. - dependency-injector построен вокруг сборки: провайдеры, маркеры
Provide[...], подключение черезwire(). Готовой области «запрос» в нём нет — границу проводите сами. - Из этого следует главное:
Factoryвыдаёт двум репозиториям одного запроса две разные сессии, то есть две транзакции. Это не ошибка библиотеки — так написано в её документации. - По скорости однозначного победителя нет: отдать готовый объект dependency-injector умеет в 6 раз быстрее, а «одна сессия на запрос» dishka делает в 2,3 раза быстрее.
Зачем вообще контейнер
Без него сборка объекта расползается по обработчикам:
def handler(request):
session = Session(pool)
users = UserRepo(session)
orders = OrderRepo(session)
try:
return UnitOfWork(users, orders).run(request)
finally:
session.close()Три строки из семи только создают объекты. Добавьте зависимость в
UnitOfWork — и править придётся каждый обработчик.
Но посмотрите на session внимательнее. Она одна на оба репозитория и
закрывается на выходе. Оба свойства — не про сборку, а про время жизни. С
этого места библиотеки и начинают отвечать по-разному.
Главное различие
dishka знает, что такое запрос. Вы объявляете, что сессия живёт в
Scope.REQUEST, и внутри одного запроса она одна на всех, кто её попросил, а
на выходе закрывается сама.
dependency-injector этого понятия не имеет. Его Factory честно
предупреждает в документации, что создаёт новый объект на каждый запрос
зависимости, — и создаёт: UserRepo получил одну сессию, OrderRepo вторую.
Если нужна одна сессия на запрос — есть ContextLocalSingleton. Но границу
запроса тогда проводите вы:
try:
return container.uow()
finally:
container.session.reset() # забудете — сессия переживёт запросИ самое неприятное: без этой строки один запрос выглядит правильным. Ошибка видна только со второго.
Когда узнаёшь, что всё собрано неправильно
- dishka проверяет весь граф при создании контейнера. Нет фабрики для зависимости — падение на старте приложения, где его увидит и CI.
- dependency-injector создаёт контейнер молча. Ошибка приходит при первом
вызове, и приходит в виде
TypeErrorиз вашего же класса. - А если забыть вызвать
wire(), ошибки не будет вообще: в аргумент придёт служебный объект-маркер, и падение случится позже и в другом месте.
Что при этом не подтвердилось: расхожее «dependency-injector не типизирован». mypy одинаково хорошо ловит несовпадение типа результата у обеих библиотек.
Что это стоит
Сравнивать по скорости можно только одинаковую работу, и таких мест два — с противоположным результатом:
- отдать уже созданный объект: 40 нс у dependency-injector против 238 у
dishka. Причина простая — его провайдеры скомпилированы (
providers.abi3.so); - сделать «одна сессия на запрос»: 2462 нс у dishka против 5583 у
ContextLocalSingletonсо сбросом — и в эти 2462 нс dishka ещё и закрывает сессию, аreset()только отпускает ссылку.
Всё остальное несравнимо. Factory дешевле полного запроса dishka ровно
потому, что не открывает границу запроса, не закрывает сессию и выдаёт две
вместо одной.
Как выбрать
Вопрос не «что быстрее», а нужна ли вам граница запроса как понятие.
Нужна, если в одном запросе больше одного потребителя общего ресурса — двух репозиториев с одной сессией уже достаточно. Тогда dishka закрывает вопрос механизмом, а не дисциплиной.
Не нужна, если контейнер у вас — реестр объектов, живущих столько же, сколько
процесс: конфигурация, пул, клиенты. Тогда dependency-injector делает ровно
то, что нужно, быстрее, и у него есть провайдер Configuration, читающий
YAML, INI и переменные окружения.
Чего точно не стоит — выбирать по числам из README. Двести наносекунд на одном обращении за зависимостью не видны ни в одном веб-приложении. Видна другая разница: та, где две записи оказались в разных транзакциях.
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.
Сама идея у Фаулера сформулирована через отдельный объект-сборщик:
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.
Основная идея внедрения зависимостей — завести отдельный объект, сборщик, который заполняет поле в классе-потребителе подходящей реализацией нужного интерфейса.
Обратите внимание, чего в этом определении нет. Нет слов «уменьшить связанность», «упростить тестирование» и «инверсия управления» — всё это следствия, а не механизм. Механизм — сборщик, который знает, что чем заполнять.
Первая задача контейнера — собрать граф. Без него код выглядит так:
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): APP → REQUEST → ACTION → STEP
Область — это время жизни зависимости. Стандартные области таковы (часть пропущена): APP → REQUEST → ACTION → STEP.
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 внедряет зависимости каждый раз, когда создаёт новый объект.
Каждый раз — значит каждый раз. UserRepo попросил сессию и получил новую,
OrderRepo попросил и получил ещё одну. Измерено: два разных номера
сессии, users.session is orders.session даёт False. Отсюда следствие,
которого замер уже не показывает: две записи одного запроса уйдут в разные
транзакции, и заметно это станет на первом же откате.
Если вам нужна одна сессия на запрос, у библиотеки есть для этого
ContextLocalSingleton: сессия становится одной на контекст выполнения. Но границу запроса вы теперь
проводите сами:
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]. Когда подключение выполнено, функции и методы с маркерами патчатся, чтобы предоставлять внедрения при вызове.
Разница выглядит стилистической, а даёт три практических следствия, и все три проверяются.
Первое: аннотация и маркер не сверяются. Вот код, который проходит и контейнер, и mypy:
@inject
def handler(service: Service = Provide[Container.config]) -> str:
return service.run()В параметр, объявленный как Service, приходит Config. Падает это позже —
AttributeError: 'Config' object has no attribute 'run' — и в другом месте.
Второе: забытый wire() не даёт ошибки. Патчить нечего, значение
аргумента по умолчанию остаётся прежним, и обработчик получает сам
объект-маркер:
print(type(handler()).__name__) # 'Provide'Ни исключения, ни предупреждения. У dishka симметричный случай — необёрнутый
обработчик — падает сразу: TypeError: missing 1 required positional argument.
Третье: зависимость по типу проверяема заранее. Раз фабрика ищется по аннотации, весь граф можно обойти в момент сборки контейнера — что dishka и делает.
Сколько кода придётся переписать при смене библиотеки
Из предыдущего раздела следует вопрос, который стоит задать до выбора: если через год библиотеку меняют, что именно переписывают.
Ответ у обеих начинается одинаково, и это стоит сказать прямо, потому что спор обычно ведут не о том. Прикладной слой не трогает ни одна. dishka формулирует это требованием к самой себе:
- Usage of container must not require modification of objects we are creating.
- Container must not require being a global variable.
- Container can require code changes on the borders of scopes (e.g. application
start, middlewares, request handlers).
1. Использование контейнера не должно требовать изменения объектов, которые мы создаём. 2. Контейнер не должен требовать быть глобальной переменной. 3. Контейнер может требовать изменений кода на границах областей (например, старт приложения, middleware, обработчики запросов).
Третий пункт — не оговорка, а честно названная зона присутствия: границы областей. 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 и т. п.).
Значит, вопрос не «сколько кода заражено», а «что именно называет маркер в адаптерном слое».
И вот тут расхождение принципиальное. Форма у маркеров одинаковая — оба
Annotated: FromDishka[Service] есть Annotated[Service, FromComponent()], а
у dependency-injector поддержан Annotated[Service, Provide[Container.service]].
Разное — содержимое. Первый называет тип. Второй — путь к атрибуту
конкретного контейнера.
Отсюда цена переезда:
- маркер, называющий тип, при смене библиотеки меняется на другой маркер того же типа — правка механическая, и её можно проверить: тип в аннотации остался тот же;
- маркер, называющий
Container.service, несёт в прикладной файл имя класса контейнера и имя его атрибута. Переезд — это не замена импорта, а переименование по всему адаптерному слою, и опечатка в нём не ловится проверяющим типов (это ровно тот дефект, который разобран выше).
Есть и вещь, которой у dishka нет вовсе: список файлов с маркерами. У dependency-injector он существует явно, потому что подключение делается вызовом:
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
публичный, и своя интеграция вправе внедрять по типу. Ценой своей интеграции.
Итог, который стоит унести: отчуждаем у обеих не прикладной слой — он отчуждаем изначально, — а адаптерный. И вопрос при выборе один: маркер называет ваш тип или их контейнер.
Когда становится известно, что всё собрано неправильно
Это свойство замечают последним и платят за него больше всего.
Разница в одну строку кода:
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. Причина видна одной строкой:
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. Числа относятся к этим сборкам, а не к «библиотекам вообще»: через полгода их надо снимать заново. |
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Контейнер решает две задачи, и вторую обычно не называют:
- собрать граф — построить объект вместе со всем, что ему нужно, не перечисляя это в каждом вызове;
- провести границу времени жизни — сказать, что вот эта сессия живёт ровно один запрос, общая для всех, кто её в этом запросе попросил, и закрывается на выходе.
- dishka строит модель вокруг второй задачи: Scope is a lifespan of a dependency, границу рисует контекстный менеджер, зависимость ищется по аннотации типа.
- dependency-injector строит её вокруг первой: провайдеры собираются в контейнер, а границу запроса вы проводите сами. Его
Factoryдокументирован прямо — Factory provider creates new objects — и потому двум репозиториям одного запроса выдаёт две разные сессии, то есть две транзакции. - Что из этого следует по замерам (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[...].
На самом деле
- Связанность уменьшает внедрение зависимости — то есть то, что объект получает готовые части, а не создаёт их. Для этого контейнер не нужен вовсе: достаточно передать их аргументами конструктора. Контейнер решает две другие задачи: собирает граф, чтобы не перечислять зависимости в каждом обработчике, и проводит границу времени жизни. У Фаулера это названо прямо: сборщик, «который заполняет поле в классе-потребителе подходящей реализацией».
- Быстрее — на той операции, ради которой сделана: отдать уже созданный объект, 40 нс против 238 у dishka, в 6 раз. На задаче «одна сессия на запрос» порядок обратный: 2462 нс у чистого Python против 5583 у скомпилированной библиотеки, потому что делать эту работу ей приходится окольным путём — через
ContextLocalSingletonи ручнойreset(). - Наоборот. Измерено: два репозитория одного запроса получают ДВЕ разные сессии,
users.session is orders.sessionдаёт False. Документация обещает ровно это: Factory injects the dependencies every time when creates a new object. Для одной сессии на запрос нуженContextLocalSingleton— иreset(), который вызываете вы. - Проверено прогоном 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(). Отсюда и разное поведение при ошибке, и разная проверяемость, и разная цена границы запроса. Вкус тут ни при чём.
По версиям
- 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(). Отсюда и разное поведение при ошибке, и разная проверяемость, и разная цена границы запроса. Вкус тут ни при чём.
Проверьте себя
В одном запросе два репозитория, обоим нужна сессия. Провайдер объявлен как providers.Factory(Session, pool=pool). Сколько сессий получится?
Источники и что читать дальше
6 ИСТОЧНИКОВ
- 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
- 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
- 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
- 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
- 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
- dishka — QuickstartОфициальная документация. Способ обращения к контейнеру, из которого следует вся разница в проверяемости: в примерах документации стоит `container.get(APIClient)` и `request_container.get(Service)` — зависимость запрашивается по типу, а не по имени провайдера. Оттуда же объявление фабрики с областью: `@provide(scope=Scope.REQUEST)`.https://dishka.readthedocs.io/en/stable/quickstart.html