Внедрение зависимостей и инверсия управления

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

Внедрение зависимостей (Dependency Injection, DI) — приём, при котором объект получает свои зависимости извне (через конструктор/параметры), а не создаёт их сам. Инверсия управления (Inversion of Control, IoC) — более общий принцип: решение о том, какие конкретно объекты использовать и как их связать, выносится из самого класса наружу — в точку сборки или контейнер.

Зачем это нужно на практике

Когда класс сам создаёт свои зависимости (self.mailer = SmtpMailer()), он жёстко привязан к конкретной реализации. Заменить почту на заглушку в тесте нельзя — внутри намертво вшит SMTP. Подменить отправку на другой провайдер можно только правкой кода самого класса. DI разрывает эту жёсткость: зависимость передаётся снаружи, и класс работает с любой реализацией нужного интерфейса. Это напрямую даёт тестируемость (подставил фейк), гибкость (сменил реализацию без правки класса) и слабую связанность.

DI вручную: просто передать зависимость

Никакой магии не нужно. DI в простейшем виде — это передать зависимость в конструктор. Сравните «плохо» и «хорошо». Сначала жёсткая привязка (этот фрагмент — для чтения, он показывает антипаттерн):

class Notifier:
    def __init__(self):
        self._mailer = SmtpMailer()   # жёстко вшито: не подменить, не протестировать

А теперь — через внедрение:

class SmtpMailer:
    def send(self, to, text):
        print(f"[SMTP] -> {to}: {text}")

class FakeMailer:                 # подмена для теста, тот же интерфейс
    def __init__(self):
        self.sent = []
    def send(self, to, text):
        self.sent.append((to, text))

class Notifier:
    def __init__(self, mailer):   # зависимость ВНЕДРЯЕТСЯ, а не создаётся внутри
        self._mailer = mailer
    def welcome(self, user):
        self._mailer.send(user, "Добро пожаловать!")

Notifier(SmtpMailer()).welcome("[email protected]")   # боевой код

fake = FakeMailer()                                   # тест без реальной почты
Notifier(fake).welcome("[email protected]")
print("Письмо перехвачено тестом:", fake.sent)

Вывод:

[SMTP] -> [email protected]: Добро пожаловать!
Письмо перехвачено тестом: [('[email protected]', 'Добро пожаловать!')]

Один и тот же Notifier в бою шлёт реальную почту, а в тесте — пишет в список, который мы проверяем. Класс не изменился ни на строку. Вот вся суть DI: зависимость снаружи, поведение подменяемо.

IoC-контейнер: автоматическая сборка графа

В большой системе зависимостей десятки, и собирать их вручную в одной длинной функции утомительно. IoC-контейнер автоматизирует это: вы регистрируете «как создать» каждый сервис, а контейнер по запросу сам собирает весь граф, подставляя зависимости друг в друга. Заодно он управляет временем жизни — например, держит один общий экземпляр (singleton) для конфигурации.

class Container:
    def __init__(self):
        self._factories = {}
        self._singletons = {}
    def register(self, key, factory, singleton=False):
        self._factories[key] = (factory, singleton)
    def resolve(self, key):
        factory, singleton = self._factories[key]
        if singleton:
            if key not in self._singletons:
                self._singletons[key] = factory(self)
            return self._singletons[key]
        return factory(self)

class Config:
    dsn = "postgres://localhost/app"

class Repo:
    def __init__(self, config):
        self.dsn = config.dsn

c = Container()
c.register("config", lambda c: Config(), singleton=True)
c.register("repo", lambda c: Repo(c.resolve("config")))   # контейнер сам собирает граф

repo1 = c.resolve("repo")
repo2 = c.resolve("repo")
print("Repo подключён к:", repo1.dsn)
print("Config - синглтон (один на всех):", repo1.dsn == repo2.dsn)
print("Repo каждый раз новый:", repo1 is not repo2)

Вывод:

Repo подключён к: postgres://localhost/app
Config - синглтон (один на всех): True
Repo каждый раз новый: True

Контейнер сам подставил Config внутрь Repo при создании. Конфиг помечен синглтоном — он один на всё приложение; Repo же создаётся заново на каждый запрос. Прикладной код просто просит resolve("repo"), не зная, как именно собирается граф.

DIP: инверсия зависимостей из SOLID на практике

DI — это инструмент, а принцип за ним — Dependency Inversion Principle (буква D в SOLID): «модули верхнего уровня не должны зависеть от модулей нижнего уровня; и те и другие зависят от абстракций». На практике это значит: Notifier зависит не от конкретного SmtpMailer, а от контракта «умеет send(to, text)». В Python контракт часто неявный (duck typing) или оформлен через typing.Protocol/абстрактный базовый класс; в типизированных языках — через interface.

Связь между понятиями такая: DIP — принцип («зависим от абстракций»), IoC — общая идея («сборку решает кто-то снаружи»), DI — конкретная техника реализации («передаём зависимость в конструктор»), а IoC-контейнер — инструмент, автоматизирующий DI. Не путайте их: можно делать DI вообще без контейнера (и часто так и нужно), а контейнер без понимания DIP лишь усложнит код.

Как это работает под капотом

Промышленные контейнеры устроены сложнее нашего, но идейно так же. Spring (Java) сканирует аннотации, строит граф бинов и внедряет их по типу; ASP.NET Core имеет встроенный контейнер с регистрацией сервисов и временами жизни transient/scoped/singleton — ровно та же ось, что наши singleton=True/False. В Python популярны библиотеки dependency-injector и встроенная в FastAPI система Depends, которая внедряет зависимости в обработчики по сигнатуре функции. Под капотом любой из них — реестр фабрик, разрешение графа и управление временем жизни.

Частые ошибки

  • Service Locator вместо DI. Когда класс сам дёргает глобальный container.get("mailer") внутри методов, зависимости снова прячутся, а контейнер становится скрытой глобальной переменной. Лучше внедрять явно через конструктор.
  • Контейнер ради контейнера. На маленьком проекте IoC-контейнер — оверинжиниринг; ручного DI достаточно. Контейнер окупается на десятках сервисов.
  • Зависимость от конкретики, а не от абстракции. Если внедрять конкретный SmtpMailer и завязывать на его специфичные методы, гибкость теряется. Завязывайтесь на контракт (Protocol/interface).
  • Слишком много зависимостей в конструкторе. Семь параметров — сигнал, что класс делает слишком много. Это не вина DI: DI лишь обнажил проблему дизайна, которую стоит разбить на части.

Итоги

  • DI — передавать зависимости снаружи (в конструктор), а не создавать их внутри класса.
  • Это сразу даёт тестируемость (подмена на фейк) и гибкость (смена реализации без правки класса).
  • IoC-контейнер автоматизирует сборку графа зависимостей и управляет временем их жизни (singleton/transient).
  • DIP (SOLID) — принцип «зависим от абстракций», IoC — идея инверсии сборки, DI — техника, контейнер — инструмент; не путайте уровни.
Проверьте себя
1. В чём суть внедрения зависимостей (DI)?
AКласс сам создаёт нужные ему объекты внутри конструктора
BЗависимости передаются классу извне, а не создаются им самим
CВсе зависимости хранятся в глобальных переменных
DОбъекты внедряются в базу данных
2. Как соотносятся DIP, IoC, DI и IoC-контейнер?
AЭто четыре названия одного и того же
BDIP — принцип (зависеть от абстракций), IoC — идея инверсии сборки, DI — техника передачи зависимостей, контейнер — инструмент, автоматизирующий DI
CКонтейнер обязателен для любого DI
DDI работает только в языках с интерфейсами
3. Почему обращение класса к глобальному container.get("mailer") внутри методов считается антипаттерном (Service Locator)?
AЭто работает медленнее конструктора
BЗависимости снова прячутся внутри класса, а контейнер превращается в скрытую глобальную переменную
CКонтейнеры не умеют отдавать объекты по ключу
DТак нельзя протестировать вообще ничего