Deep Engineering

MEASUREMENT

bench/di/cost.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

234 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/cost.py

ЧТО МОЖНО СРАВНИВАТЬ. Только строки внутри одного блока одного запуска.
Абсолютные наносекунды с другой машины несравнимы.

ГЛАВНАЯ ОГОВОРКА, БЕЗ КОТОРОЙ ПЕРВЫЙ БЛОК ЧИТАТЬ НЕЛЬЗЯ. Строки первого
блока делают РАЗНУЮ работу, и в этом всё дело. dishka входит в scope,
собирает граф, а на выходе закрывает сессию. `Factory` у
dependency-injector собирает граф и не закрывает ничего — закрывать некому,
понятия границы запроса у него нет. Поэтому «быстрее» здесь означает
«сделал меньше», а не «сделал то же дешевле». Что именно делает каждая
библиотека — печатает `graph.py`, и без него этот блок вводит в
заблуждение.

ПОЧЕМУ ЗАМЕР БЕЗ ЦИКЛА. Тело — двести одинаковых вызовов подряд,
отдельными строками: внутри `for` разница утонула бы в цене итерации.
"""

import sys
import timeit
from collections.abc import Iterable

REPEATS_IN_BODY = 200


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


def ns(stmt: str, env: dict, number: int = 2000) -> float:
    body = (stmt + "\n") * REPEATS_IN_BODY
    best = min(timeit.repeat(body, globals=env, number=number, repeat=7))
    return best / (number * REPEATS_IN_BODY) * 1e9


class Config:
    pass


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


class Session:
    def __init__(self, pool: Pool) -> None:
        self.pool = pool
        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


from dishka import Provider, Scope, make_container, provide  # noqa: E402
from dependency_injector import containers, providers  # noqa: E402


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()


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)


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)


DISHKA = make_container(DishkaProvider())
FACTORY = FactoryContainer()
CONTEXT = ContextContainer()

CONFIG = Config()
POOL = Pool(CONFIG)


def manual_request() -> UnitOfWork:
    """То, что контейнер заменяет собой. Одна сессия на оба репозитория, закрывается."""
    session = Session(POOL)
    try:
        return UnitOfWork(UserRepo(session), OrderRepo(session))
    finally:
        session.close()


def dishka_request() -> UnitOfWork:
    with DISHKA() as request:
        return request.get(UnitOfWork)


def dishka_scope_only() -> None:
    with DISHKA():
        pass


def factory_request() -> UnitOfWork:
    """Без границы запроса и без закрытия сессии — так это и работает."""
    return FACTORY.uow()


def context_request() -> UnitOfWork:
    """Ближайшее к «запросу»: общая сессия плюс сброс вручную."""
    try:
        return CONTEXT.uow()
    finally:
        CONTEXT.session.reset()


def bench_request() -> None:
    header("1. Один «запрос»: собрать UnitOfWork")

    env = {
        "manual_request": manual_request,
        "dishka_request": dishka_request,
        "dishka_scope_only": dishka_scope_only,
        "factory_request": factory_request,
        "context_request": context_request,
    }
    rows = [
        ("manual_request()", "руками, одна сессия, закрывается"),
        ("dishka_request()", "scope + граф + finalizer"),
        ("dishka_scope_only()", "только вход и выход из scope"),
        ("factory_request()", "Factory: граф, ДВЕ сессии, не закрываются"),
        ("context_request()", "ContextLocalSingleton + reset(): одна сессия"),
    ]
    for stmt, note in rows:
        print(f"{stmt:<24} {ns(stmt, env):>8.0f} нс   {note}")
    print()
    print("Строки делают разную работу — см. оговорку в шапке файла и graph.py.")


def bench_singleton() -> None:
    header("2. Обращение к общему на приложение объекту")

    env = {"DISHKA": DISHKA, "FACTORY": FACTORY, "Pool": Pool}
    print(f"{'DISHKA.get(Pool)':<24} {ns('DISHKA.get(Pool)', env):>8.0f} нс")
    print(f"{'FACTORY.pool()':<24} {ns('FACTORY.pool()', env):>8.0f} нс")
    print()
    print("Здесь работа одинаковая: отдать уже созданный объект. Это самое")
    print("честное сравнение двух реализаций во всём файле.")


def bench_second_lookup() -> None:
    header("3. Второе обращение за тем же объектом внутри одного запроса")

    open_scope = DISHKA().__enter__()
    open_scope.get(UnitOfWork)  # первый resolve — прогрев кеша scope
    env = {"open_scope": open_scope, "FACTORY": FACTORY, "UnitOfWork": UnitOfWork}
    print(f"{'dishka, тот же scope':<28} {ns('open_scope.get(UnitOfWork)', env):>8.0f} нс")
    print(f"{'dependency-injector, Factory':<28} {ns('FACTORY.uow()', env):>8.0f} нс")
    print()
    print("dishka внутри scope кеширует: второе обращение — поиск в словаре.")
    print("Factory кеша не имеет и каждый раз собирает граф заново — это не")
    print("недостаток реализации, это его обещанное поведение.")


def bench_startup() -> None:
    header("4. Создание контейнера (цена старта и каждого теста)")

    env = {
        "make_container": make_container,
        "DishkaProvider": DishkaProvider,
        "FactoryContainer": FactoryContainer,
    }

    def us(stmt: str, number: int) -> float:
        """Микросекунды. Тело здесь не размножается: одна операция дорогая сама."""
        return min(timeit.repeat(stmt, globals=env, number=number, repeat=7)) / number * 1e6

    print(f"{'make_container(...)':<24} {us('make_container(DishkaProvider())', 200):>8.1f} мкс")
    print(f"{'FactoryContainer()':<24} {us('FactoryContainer()', 2000):>8.1f} мкс")
    print()
    print("dishka на этом шаге проверяет граф целиком — см. graph.py, раздел 2.")
    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')}")
    bench_request()
    bench_singleton()
    bench_second_lookup()
    bench_startup()


if __name__ == "__main__":
    main()