Перейти к содержимому

Практика 14. Практическая работа 14. Dependency Injection

Цель

  • Научиться распознавать и устранять жёсткие зависимости (классы, создающие свои зависимости внутри).
  • Освоить внедрение через конструктор (constructor injection) и зависимость от абстракций, а не от конкретных классов.
  • Применить принцип инверсии зависимостей (SOLID-D) на практике, вынеся сборку объектов в composition root.
  • Реализовать простой DI-контейнер с операциями регистрации и разрешения зависимостей.
  • Убедиться, что слабая связанность даёт тестируемость: подмену реальных объектов моками без правки бизнес-логики.

Краткая теория

  • Жёсткая зависимость. Класс, пишущий self.repo = DatabaseRepo() внутри __init__, намертво привязан к реализации: её нельзя заменить и нельзя подставить мок в тесте.
  • Внедрение через конструктор. Зависимость передают снаружи: def __init__(self, repo: Repository). Класс зависит от абстракции, а кто создаёт конкретный объект — забота вызывающего кода.
  • SOLID-D (инверсия зависимостей). Модули верхнего уровня не зависят от нижнего; оба зависят от абстракций. DI — практический механизм: стрелка зависимости «переворачивается» через интерфейс.
  • Composition root. Единственная точка приложения, где конкретные реализации соединяются друг с другом; классы остаются «чистыми».
  • DI-контейнер (IoC). Объект, которому регистрируют соответствие «абстракция → реализация», а затем просят разрешить (resolve) готовый объект со всеми вложенными зависимостями. Примеры — в lecture_14.md.

Задания

Задание 1. Устранение жёсткой зависимости

Дан «плохой» связанный код. Сервис сам создаёт свои зависимости и потому непригоден ни для замены реализации, ни для тестирования.

Исходный код для рефакторинга:

class ConsoleLogger:
def log(self, msg: str) -> None:
print(f"[LOG] {msg}")
class EmailSender:
def send(self, to: str, text: str) -> None:
print(f"[SMTP] -> {to}: {text}")
class NotificationService:
def __init__(self):
self.logger = ConsoleLogger() # жёстко зашито
self.sender = EmailSender() # жёстко зашито
def notify(self, user_email: str, text: str) -> None:
self.sender.send(user_email, text)
self.logger.log(f"sent to {user_email}")

Требования:

  1. Выделите абстракции Logger(ABC) и Sender(ABC) с методами log / send.
  2. Перепишите NotificationService так, чтобы logger и sender внедрялись через конструктор и были аннотированы абстрактными типами.
  3. Сделайте две реализации Sender: EmailSender и SmsSender.
  4. Соберите сервис в коде вызова (вне класса), подставив нужные реализации.

Проверьте: в NotificationService нет ни одного ()-создания зависимости; смена EmailSender на SmsSender не требует правок внутри сервиса.


Задание 2. Зависимость от абстракции и подмена мока в тесте

Покажите главную выгоду DI — тестируемость без реальных побочных эффектов.

Требования:

  1. Реализуйте FakeSender(Sender), который ничего не отправляет, а запоминает вызовы в список self.sent: list[tuple[str, str]].
  2. Реализуйте FakeLogger(Logger), накапливающий сообщения в self.records.
  3. Напишите проверку (без поднятия реального SMTP): создайте NotificationService(FakeLogger(), FakeSender()), вызовите notify(...) и убедитесь по атрибутам мока, что отправка и запись в лог произошли.

Скелет:

class FakeSender(Sender):
def __init__(self):
self.sent: list[tuple[str, str]] = []
def send(self, to: str, text: str) -> None:
self.sent.append((to, text))
sender = FakeSender()
service = NotificationService(FakeLogger(), sender)
service.notify("a@b.c", "привет")
assert sender.sent == [("a@b.c", "привет")]

Проверьте: тест не выводит ничего по сети/в консоль; проверка идёт через состояние моков. Поясните в комментарии, почему это было невозможно в задании 1.


Задание 3. Инверсия зависимостей и composition root

Соберите многослойное приложение, в котором верхний слой не знает о конкретных реализациях нижнего.

Исходный код для рефакторинга:

class OrderService:
def __init__(self):
self.repo = InMemoryOrderRepo() # высокий уровень -> низкий
self.notifier = NotificationService()
def place(self, order):
self.repo.save(order)
self.notifier.notify(order.email, "Заказ принят")

Требования:

  1. Опишите абстракцию OrderRepository(ABC) с методами save / find_by_id; сделайте реализацию InMemoryOrderRepo.
  2. Перепишите OrderService так, чтобы он зависел только от абстракций (OrderRepository, NotificationService с внедрёнными зависимостями), получая их через конструктор.
  3. Вынесите всю сборку (логгер, сендер, репозиторий, сервисы) в одну функцию build_app() — это composition root. Нигде, кроме неё, не должно быть создания конкретных реализаций.
  4. В комментарии покажите направление зависимостей «до» и «после» и поясните, почему стрелка инвертировалась.

Проверьте: OrderService импортирует/использует только абстракции; чтобы переключить хранилище на другое, правится одна строка в build_app().


Задание 4. Простой DI-контейнер: регистрация и разрешение

Автоматизируйте сборку зависимостей вместо ручных вызовов конструкторов.

Требования:

  1. Реализуйте класс Container с методами:
    • register_singleton(service_type, instance) — сохранить готовый объект;
    • register(service_type, implementation) — связать абстракцию с классом;
    • resolve(service_type) — вернуть объект, рекурсивно разрешив параметры его конструктора по аннотациям типов.
  2. В resolve сначала проверяйте singletons, затем зарегистрированные реализации, иначе создавайте сам запрошенный тип.
  3. _create должен прочитать inspect.signature(cls.__init__), для каждого параметра (кроме self) разрешить его по аннотации; если аннотации нет, но есть значение по умолчанию — взять его.

Скелет:

import inspect
class Container:
def __init__(self):
self._singletons: dict[type, object] = {}
self._impls: dict[type, type] = {}
def register_singleton(self, service_type, instance):
self._singletons[service_type] = instance
def register(self, service_type, implementation):
self._impls[service_type] = implementation
def resolve(self, service_type):
if service_type in self._singletons:
return self._singletons[service_type]
cls = self._impls.get(service_type, service_type)
return self._create(cls)
def _create(self, cls):
... # разобрать сигнатуру __init__ и разрешить параметры

Проверьте: зарегистрировав Logger, Sender, OrderRepository и сервисы, container.resolve(OrderService) возвращает полностью собранный объект; singleton при двух resolve возвращается один и тот же (сравните по is).


Задание 5*. Управление временем жизни и фабрики (повышенной сложности)

  1. Добавьте в контейнер register_factory(service_type, factory) — разрешение через переданную функцию-фабрику (например, чтобы создать объект с параметром из конфигурации).
  2. Реализуйте режим transient (новый объект на каждый resolve) и сравните его поведение с singleton.
  3. Добавьте обнаружение циклической зависимости: если при разрешении тип уже находится в процессе создания — выбросьте понятную ошибку CircularDependencyError вместо бесконечной рекурсии.

Критерии оценки

КритерийВес
Задание 1: выделены абстракции, зависимости внедрены через конструктор20%
Задание 2: моки Fake*, проверка через состояние без побочных эффектов20%
Задание 3: инверсия зависимостей, единый composition root (build_app)20%
Задание 4: контейнер с register/resolve, рекурсивное разрешение, singleton20%
Задание 5*: фабрики, transient/singleton, обнаружение цикла10%
Качество кода: имена, docstrings, аннотации типов, отсутствие () внутри классов10%

Штрафы: создание зависимости внутри __init__ (self.x = Concrete()), зависимость сервиса от конкретного класса вместо абстракции, сборка объектов вне composition root, «глобальный» доступ к контейнеру из бизнес-логики.


Вопросы для самопроверки

  1. Какие три проблемы возникают у класса, который сам создаёт свои зависимости в конструкторе?
  2. Что значит «зависеть от абстракции, а не от реализации» и как это выглядит в сигнатуре __init__?
  3. Как Dependency Injection реализует принцип инверсии зависимостей (SOLID-D)?
  4. Что такое composition root и почему сборку объектов выносят в одно место?
  5. Почему внедрение через конструктор обычно предпочтительнее внедрения через сеттер?
  6. Как DI делает код тестируемым? Приведите пример с моком из задания 2.
  7. Что означают этапы «регистрация» и «разрешение» в работе DI-контейнера?
  8. Как контейнер из задания 4 определяет, какие объекты подставить в конструктор?
  9. Чем отличаются режимы времени жизни singleton и transient?
  10. Что такое циклическая зависимость и как контейнер может её обнаружить?