ЗАМЕР
bench/di/cost.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/system-design/dependency-injection/di-containers
- Прогон
- 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 python3.13 bench/di/cost.py
Запись прогона
Замеры для статьи «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 раз на повторном обращении). Выбор между ними определяется не этими числами, а тем, нужна ли вам граница запроса как понятие.
Скрипт
234 строк"""
Цена контейнера для статьи «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()