Deep Engineering

MEASUREMENT

bench/di/graph.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/system-design/dependency-injection/di-containers
Run on
3.13.7, dishka 1.10.1, dependency-injector 4.49.1
How to run it
pip install "dishka==1.10.1" "dependency-injector==4.49.1"

python3.13 bench/di/graph.py
python3.13 bench/di/cost.py

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для статьи «DI-контейнер в Python: dishka против dependency-injector»

Скрипт Что делает
graph.py наблюдения без времени: одна ли сессия на запрос, когда обнаруживается нехватка зависимости, что будет, если забыть настроить, и что делает несовпадающий маркер
cost.py цена: один «запрос», обращение к общему объекту, второе обращение внутри запроса, создание контейнера
pip install "dishka==1.10.1" "dependency-injector==4.49.1"

python3.13 bench/di/graph.py
python3.13 bench/di/cost.py

Версии закреплены намеренно: сравниваются не «библиотеки вообще», а две конкретные сборки, и через полгода числа нужно снимать заново. dependency_injector.providers — скомпилированный модуль (providers.abi3.so, Cython), dishka — чистый Python; это проверяется одной строкой print(providers.__file__) и объясняет половину второго блока.

Что здесь важно прочитать правильно

Первый блок cost.py нельзя читать без graph.py. Его строки делают РАЗНУЮ работу, и в этом всё дело. dishka входит в scope, собирает граф и на выходе закрывает сессию. Factory у dependency-injector собирает граф и не закрывает ничего — закрывать некому, понятия границы запроса у него нет. Он же выдаёт ДВЕ разные сессии двум репозиториям одного запроса. «Быстрее» здесь означает «сделал меньше».

Единственное честное сравнение реализаций — второй блок. Обе библиотеки делают там одно и то же: отдают уже созданный объект. Там dependency-injector быстрее в 6 раз, и это прямое следствие компиляции.

Одинаковую работу выполняют dishka_request() и context_request() — одна сессия на запрос, граница запроса проведена. На них порядок обратный.

Что получилось (3.13.7, dishka 1.10.1, dependency-injector 4.49.1)

Один «запрос» — собрать UnitOfWork из двух репозиториев:

что нс что при этом делается
руками 231 одна сессия, закрывается в finally
dishka, with container() 2462 scope, граф, finalizer закрывает сессию
dishka, только вход и выход из scope 785 без единого resolve
dependency-injector, Factory 1840 граф; две сессии; не закрывается
dependency-injector, ContextLocalSingleton + reset() 5583 одна сессия, граница вручную

Остальное:

что dishka dependency-injector
обращение к общему на приложение объекту 238 нс 40 нс
второе обращение за тем же объектом в одном запросе 175 нс 2162 нс
создание контейнера 969 мкс 170 мкс

От запуска к запуску: dishka_request() 2462–2595, factory_request() 1840–2278, context_request() 5583–5875, FACTORY.pool() 40–42, DISHKA.get(Pool) 224–238.

Наблюдения graph.py

  • Одна сессия на запрос. dishka в Scope.REQUEST — одна на оба репозитория, закрывается на выходе. Factory — две разные, не закрывается ни одна. ContextLocalSingleton — одна, но следующий «запрос» получает ту же самую, пока кто-нибудь не вызовет reset().
  • Нехватка зависимости. dishka — GraphMissingFactoryError при СОЗДАНИИ контейнера. dependency-injector — контейнер создаётся молча, при первом вызове приходит TypeError из __init__ вашего класса. Есть check_dependencies(), но вызывать её нужно самому, и покрывает она только явные providers.Dependency.
  • Забыли настроить. У dependency-injector не вызванный wire() не даёт ошибки вовсе: в аргумент приходит сам объект-маркер Provide. У dishka необёрнутый обработчик падает сразу: TypeError: missing 1 required positional argument.
  • Маркер, не совпадающий с аннотацией. Provide[Container.config] в параметре, объявленном как Service, подставляет Config. Ни контейнер, ни mypy об этом не сообщают.
  • Обращение не в том scope. dishka — NoFactoryError с указанием scope. У dependency-injector такой ошибки быть не может: понятия scope в нём нет.

Проверка типов

Отдельно, mypy 2.3.1:

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

Что этими числами доказать НЕЛЬЗЯ

Что одна библиотека быстрее другой. Быстрее — на разной работе разные: dependency-injector на выдаче готового объекта (в 6 раз), dishka на всём, что связано с границей запроса (в 2,3 раза на одинаковой задаче и в 12 раз на повторном обращении). Выбор между ними определяется не этими числами, а тем, нужна ли вам граница запроса как понятие.

Script

305 lines
"""
Наблюдения для статьи «DI-контейнер в Python: dishka против dependency-injector».

ЗАПУСК (снято на 3.13.7, dishka 1.10.1, dependency-injector 4.49.1):

    pip install "dishka==1.10.1" "dependency-injector==4.49.1"
    python3.13 bench/di/graph.py

ЗДЕСЬ НЕТ ЗАМЕРОВ ВРЕМЕНИ. Скрипт отвечает на вопросы «один это объект или
два» и «на каком этапе сломалось» — то есть на `is` и на имя исключения.
Время меряется отдельно, в `cost.py`.

ПОЧЕМУ ЭТО ВАЖНЕЕ ВРЕМЕНИ. Библиотеки сравнивают по скорости, а расходятся
они в другом: в том, ЧТО именно они делают за запрос и КОГДА сообщают об
ошибке. Пока это не измерено, сравнение времени вводит в заблуждение —
быстрее оказывается тот, кто делает меньше.

ЧЕСТНОСТЬ СРАВНЕНИЯ. Каждая проверка ставится обеим библиотекам, включая те,
где неудобно оказывается той, что понравилась автору. Раздел 5 — специально
симметричный: «что будет, если забыть настроить» спрашивается у обеих.
"""

import sys
from collections.abc import Iterable


def header(title: str) -> None:
    print(f"\n--- {title} ---")


# --------------------------------------------------------------------------
# Общий доменный граф. Одинаковый для обеих библиотек — иначе сравнивать
# нечего: разница должна быть в контейнере, а не в примере.
# --------------------------------------------------------------------------
class Config:
    pass


class Pool:
    def __init__(self, config: Config) -> None:
        self.config = config


class Session:
    """Считает сама себя: по номеру видно, одна она на запрос или несколько."""

    created = 0

    def __init__(self, pool: Pool) -> None:
        Session.created += 1
        self.number = Session.created
        self.closed = False

    def close(self) -> None:
        self.closed = True


class UserRepo:
    def __init__(self, session: Session) -> None:
        self.session = session


class OrderRepo:
    def __init__(self, session: Session) -> None:
        self.session = session


class UnitOfWork:
    """Две репозитория в одном запросе — место, где всё и ломается."""

    def __init__(self, users: UserRepo, orders: OrderRepo) -> None:
        self.users = users
        self.orders = orders


def observe_shared_session() -> None:
    header("1. Одна сессия на запрос — или по одной на каждого, кто её попросил")

    from dishka import Provider, Scope, make_container, provide

    class DishkaProvider(Provider):
        config = provide(Config, scope=Scope.APP)
        pool = provide(Pool, scope=Scope.APP)
        users = provide(UserRepo, scope=Scope.REQUEST)
        orders = provide(OrderRepo, scope=Scope.REQUEST)
        uow = provide(UnitOfWork, scope=Scope.REQUEST)

        @provide(scope=Scope.REQUEST)
        def session(self, pool: Pool) -> Iterable[Session]:
            session = Session(pool)
            yield session
            session.close()

    container = make_container(DishkaProvider())
    with container() as request:
        unit = request.get(UnitOfWork)
        same = unit.users.session is unit.orders.session
        numbers = (unit.users.session.number, unit.orders.session.number)
    print(f"dishka, Scope.REQUEST:            одна сессия на оба репозитория: {same}, номера {numbers}")
    print(f"dishka, закрыта при выходе из scope: {unit.users.session.closed}")

    from dependency_injector import containers, providers

    class FactoryContainer(containers.DeclarativeContainer):
        config = providers.Singleton(Config)
        pool = providers.Singleton(Pool, config=config)
        session = providers.Factory(Session, pool=pool)
        users = providers.Factory(UserRepo, session=session)
        orders = providers.Factory(OrderRepo, session=session)
        uow = providers.Factory(UnitOfWork, users=users, orders=orders)

    unit2 = FactoryContainer().uow()
    same2 = unit2.users.session is unit2.orders.session
    numbers2 = (unit2.users.session.number, unit2.orders.session.number)
    print(f"dependency-injector, Factory:     одна сессия на оба репозитория: {same2}, номера {numbers2}")
    print(f"dependency-injector, Factory, закрыта: {unit2.users.session.closed} (закрывать некому)")

    class ContextContainer(containers.DeclarativeContainer):
        config = providers.Singleton(Config)
        pool = providers.Singleton(Pool, config=config)
        session = providers.ContextLocalSingleton(Session, pool=pool)
        users = providers.Factory(UserRepo, session=session)
        orders = providers.Factory(OrderRepo, session=session)
        uow = providers.Factory(UnitOfWork, users=users, orders=orders)

    ctx = ContextContainer()
    first = ctx.uow()
    print(
        "dependency-injector, ContextLocalSingleton: одна сессия на оба репозитория: "
        f"{first.users.session is first.orders.session}, номера "
        f"{(first.users.session.number, first.orders.session.number)}"
    )
    second = ctx.uow()
    print(f"  следующий «запрос» БЕЗ reset(): та же сессия: {second.users.session is first.users.session}")
    ctx.session.reset()
    third = ctx.uow()
    print(f"  после ctx.session.reset(): новая сессия: {third.users.session is not first.users.session}")

    print()
    print("Читать так: у dishka границу запроса рисует контекстный менеджер, и")
    print("finalizer закрывает сессию на выходе. У dependency-injector Factory")
    print("выдаёт НОВУЮ сессию каждому, кто её попросил, а ContextLocalSingleton")
    print("выдаёт одну — но границу «запроса» проводит reset(), который вызываете")
    print("вы сами и в каждой ветке кода.")


def observe_error_timing() -> None:
    header("2. На каком этапе становится известно, что зависимости не хватает")

    class Missing:
        pass

    class Service:
        def __init__(self, config: Config, missing: Missing) -> None:
            pass

    from dishka import Provider, Scope, make_container, provide

    class Incomplete(Provider):
        config = provide(Config, scope=Scope.APP)
        service = provide(Service, scope=Scope.REQUEST)

    stage = "создание контейнера"
    try:
        container = make_container(Incomplete())
        stage = "первый resolve"
        with container() as request:
            request.get(Service)
        stage = "не упало вовсе"
    except Exception as exc:
        print(f"dishka:               {type(exc).__name__} на этапе «{stage}»")

    from dependency_injector import containers, providers

    class Forgot(containers.DeclarativeContainer):
        config = providers.Singleton(Config)
        service = providers.Factory(Service, config=config)  # missing не передан

    forgot = Forgot()
    print("dependency-injector:  контейнер создан без ошибки")
    try:
        forgot.service()
    except Exception as exc:
        print(f"dependency-injector:  {type(exc).__name__} при первом вызове: {exc}")

    class Declared(containers.DeclarativeContainer):
        missing = providers.Dependency(instance_of=Missing)
        config = providers.Singleton(Config)
        service = providers.Factory(Service, config=config, missing=missing)

    declared = Declared()
    try:
        declared.check_dependencies()
    except Exception as exc:
        print(f"dependency-injector:  check_dependencies() находит это явно: {exc}")
    print("  но вызвать её нужно самому, и покрывает она только providers.Dependency,")
    print("  а не забытый аргумент конструктора из предыдущего примера.")


def observe_forgotten_setup() -> None:
    header("3. Симметричная проверка: что будет, если забыть настроить")

    from dependency_injector import containers, providers
    from dependency_injector.wiring import Provide, inject

    class Service:
        def __init__(self, config: Config) -> None:
            self.config = config

    class Wired(containers.DeclarativeContainer):
        config = providers.Singleton(Config)
        service = providers.Singleton(Service, config=config)

    @inject
    def handler(service: Service = Provide[Wired.service]) -> str:
        return type(service).__name__

    print(f"dependency-injector, контейнер НЕ подключён (wire не вызван):")
    print(f"  handler() вернул объект типа {handler()!r} — исключения нет вовсе")

    Wired().wire(modules=[sys.modules[__name__]])
    print(f"  после wire(): {handler()!r}")

    from dishka import FromDishka
    from dishka.integrations.base import is_dishka_injected

    def dishka_handler(service: FromDishka[Service]) -> str:
        return type(service).__name__

    print("dishka, обработчик НЕ обёрнут:")
    print(f"  помечен как инжектируемый: {is_dishka_injected(dishka_handler)}")
    try:
        dishka_handler()  # type: ignore[call-arg]
    except TypeError as exc:
        print(f"  вызов: {type(exc).__name__}{exc}")


def observe_wrong_marker() -> None:
    header("4. Маркер, не совпадающий с аннотацией")

    from dependency_injector import containers, providers
    from dependency_injector.wiring import Provide, inject

    class Service:
        def __init__(self, config: Config) -> None:
            self.config = config

    class Mixed(containers.DeclarativeContainer):
        config = providers.Singleton(Config)
        service = providers.Singleton(Service, config=config)

    @inject
    def handler(service: Service = Provide[Mixed.config]) -> str:
        """Аннотация обещает Service, маркер указывает на config."""
        return type(service).__name__

    Mixed().wire(modules=[sys.modules[__name__]])
    print(f"объявлено Service, маркер указывает на config — пришёл: {handler()}")
    print()
    print("Ни контейнер, ни mypy об этом не сообщают: маркер и аннотация не")
    print("сверяются между собой. Проверка типов из README этой статьи:")
    print("  mypy находит несовпадение у ВОЗВРАЩАЕМОГО значения обеих библиотек,")
    print("  но подмену внутри Provide[...] не находит.")


def observe_scope_error() -> None:
    header("5. Симметричная проверка: обращение не в том scope")

    from dishka import Provider, Scope, make_container, provide

    class Service:
        def __init__(self, config: Config) -> None:
            self.config = config

    class P(Provider):
        config = provide(Config, scope=Scope.APP)
        service = provide(Service, scope=Scope.REQUEST)

    container = make_container(P())
    try:
        container.get(Service)
    except Exception as exc:
        print(f"dishka, запрос REQUEST-зависимости на APP-уровне: {type(exc).__name__}")
        print(f"  {str(exc).splitlines()[0]}")
    print()
    print("У dependency-injector такой ошибки быть не может: понятия scope в нём")
    print("нет вовсе, и обратиться «не в том scope» не к чему. Это не достоинство")
    print("и не недостаток — это и есть разница в модели.")


def main() -> None:
    import importlib.metadata as metadata

    print(f"Python {sys.version.split()[0]}")
    print(f"dishka {metadata.version('dishka')}, dependency-injector {metadata.version('dependency-injector')}")
    observe_shared_session()
    observe_error_timing()
    observe_forgotten_setup()
    observe_wrong_marker()
    observe_scope_error()


if __name__ == "__main__":
    main()