Собственный DI-контейнер на python
Александр Лебедев
Введение
У моей мотивации реализовать собственный механизм внедрения зависимостей было два основных источника: желание глубже погрузиться в архитектуру на практике и потребность в подобном инструменте в личных и рабочих проектах. Мысль о нем зрела у меня довольно давно, а впервые серьезно о нем я задумался после ознакомления с питоновским inspect'ом, который представляет безумно широкие возможности инспектирования объектов, аннотаций, сигнатур функций и т.д.
Сразу стоит отметить, что я не имел раннее опыта работы с DI-контейнерами, понятия не имею, как устроены нормальные контейнеры, слабо представляю, какой набор функций они предоставляют и на каких принципах работают. Свою реализацию я задумал скорее как удовлетворение собственных потребностей. Я писал его таким, каким лично мне будет удобно его использовать.
Код моей реализации можно найти здесь: https://github.com/Sanch0pansa/MicroDI
Зачем это нужно?
Впервые потребность в этом инструменте возникла у меня, когда я начал на практике применять некоторые архитектурные паттерны. Сравнительно недавно я познакомился с концепцией чистой архитектуры и воспылал к ней страстной любовью с первого взгляда. Идея разделять инфраструктуру и бизнес-логику а также делить программу на слои показалась мне гениальной. Концепция, как я могу судить из своего небольшого опыта ее практического применения, действительно очень хорошая, и сильно улучшает качество кода. Но при работе с ней стоит учитывать некоторые нюансы. Одним из таких является частое появление длинных цепочек зависимостей. Посмотрим на примере.
Представим, что мы пишем сервер для списка задач. У нас есть класс приложения, который является точкой входа. При получении запроса приложение дергает соответствующий метод контроллера. Значит, контроллер нужен приложению, и оно от него зависит. Поскольку мы не хотим жесткой связи, мы не будем создавать экземпляры прямо в конструкторе. Вместо этого в конструкторе мы будем получать зависимости.

Контроллер получает входные данные, валидирует их и вызывает соответствующую бизнес-логику. Реализует бизнес-логику, в свою очередь, класс сервиса. Сделаем контроллер зависимым от сервиса.

В классе сервиса мы должны как-то сохранять данные, добавим для этого класс репозитория. Поскольку сервис относится к бизнес-логике, он не должен знать, как именно репозиторий реализует свои методы, поэтому сделаем его зависимым от интерфейса репозитория.

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

Добавим имплементацию этого репозитория, работающую с БД. Пускай для работы ей нужен URL базы данных.

Теперь соберем всю эту матрешку вместе.

Пока это выглядит нормально, и код остается читаемым. Но что, если контроллеров в приложении будет много? А что, если контроллеры будут зависеть не только от сервисов, но и от менеджеров авторизации, куки-менеджеров, если сервисы будут зависеть от нескольких репозиториев, а еще и от паблишера событий, конвертеров, менеджеров доступа, и прочего? Кроме того, что у нас раздуваются конструкторы без особой необходимости, так еще и сборка этого бутерброда становится неприятной. В сравнительно неплохом случае это может выглядеть как-то так:

Я напомню, что этот код, по сути, нужен только для создания экземпляра приложения, но он уже выглядит неприятно.
Необходимо соблюдать порядок, собирать приложение в правильной последовательности. Необходимо инициализировать множество промежуточных объектов, нужных только для инициализации следующих объектов в цепи.
Инициализацию объектов, относящихся к инфраструктуре, можно понять. Все же нам нужно прокидывать туда какие-то настройки, нам нужно выбирать конкретную имплементацию (например, для тестов мы можем использовать моковые имплементации).
Но инициализация объектов промежуточных слоев (контроллеров, сервисов) нам совершенно не нужна, она необходима лишь для прокидывания зависимостей. Это мусор в коде, который очень неудобно писать, сложно читать, и который будет очень неприятно менять, если вдруг потребуется добавить какую-то новую зависимость.
Очень хотелось бы иметь аккуратный вид точки входа в приложение, где мы бы объявляли в явном виде только то, что нам действительно необходимо.
На сцену выходит DI-контейнер
Как раз DI-контейнер призван решить эту проблему (и, на самом деле, еще кучу других). Он должен определить, от чего зависит каждый класс, и самостоятельно прокинуть зависимости. Также он должен уметь работать с цепочками зависимостей (как в нашем случае: App -> Controller -> Service -> Repo)
В нашем примере нам бы хотелось, чтобы он самостоятельно обнаружил, что приложение зависит от контроллера, что контроллер зависит от сервиса, что сервис зависит от репозитория. Получив на вход нижний уровень (репозиторий), он должен самостоятельно последовательно создать сервис, контроллер и приложение.
Кроме того, он должен позволять нам указывать, какую именно имплементацию интерфейсов нам необходимо использовать. То есть, в нашем случае нам необходимо указать, что для ITaskRepo мы будем использовать конкретную имплементацию: DBTaskRepo.
Как я хочу этим пользоваться
Контейнер каким-то образом должен понять, от чего зависит тот или иной класс. Среди рассматриваемых мною вариантов были:
- Получения зависимостей через исследование аргументов конструктора (смотрим, какие типы принимает __init__, и прокидываем их)
- Исследование атрибутов класса (смотрим, какие атрибуты есть у класса, и устанавливаем их)
Я остановился на втором варианте по нескольким причинам. Во-первых, я не хочу иметь огромный конструктор вроде такого:

Перечислять зависимости два раза (в аргументах и при присвоении) я не хочу. Атрибуты выглядят приятнее:

Во-вторых, я бы хотел сохранить предназначения конструктора - инициализировать объект, позволив прокидывать в него какие-то дополнительные параметры, которые не надо внедрять через контейнер.
У этого метода, безусловно, есть недостатки. Контейнер по отношению к классу - внешний, следовательно, ему недоступна прямая работа с приватными полями. Да, в python нет приватных полей, но есть name mangling, который усложняет дело. Однако сделать поля защищенными вместо приватных мне совесть не мешает.
С методом определились. Будем внедрять атрибуты. Однако у класса кроме внедряемых атрибутов, могут быть атрибуты, значения которым он присваивает сам. Я решил, что удобно будет как-то помечать те из них, которые представляют собой зависимость. Для этого можно воспользоваться Annotated из typing. Он позволяет указать кроме типа еще и дополнительную информацию, в которой мы как раз и укажем, что объект является внедряемым.

Стало выглядеть чуть побольше, но это все еще лучше конструктора. Как минимум, при добавлении новой зависимости, нам придется указать ее лишь один раз.
Что касается магического "Injectable". В принципе, маркером зависимости может быть что угодно, хоть строка, хоть объект, хоть что-то еще. Я выбрал создать объект (буквально Injectable = object()), это экономит место в памяти, и позволяет не ошибиться в написании строки. Там, где нужно, достаточно просто импортировать этот Injectable.
Теперь пару слов об интерфейсе самого контейнера. Я определил несколько функций, которые я хочу в нем видеть:
- регистрация конкретной имплементации для абстрактного класса
- регистрация экземпляра объекта (Чтобы регистрировать независимые объекты)
- регистрация фабрики (Чтобы можно было прокинуть свои аргументы в создаваемый во время разрешения зависимостей объект)
- рекурсивное разрешение зависимостей для конкретного класса (построение дерева зависимостей, создание объектов снизу вверх, создание экземпляра требуемого класса)
То есть, в нашем изначальном примере я хочу код вроде:

И во втором примере

Причем в случае добавления или изменения промежуточных слоев этот код не будет затронут.
А если определять наследование от абстрактных классов и автоматически решать, имплементация какого именно интерфейса указана, то получается еще приятнее:

Конкретно этот аспект, правда, я пока не реализовал, но планирую реализовать в дальнейшем.
Реализуем
Принцип
Принцип работы довольно простой. Рассмотрим на изначальном примере. Пытаемся разрешить TaskApp. При разрешении зависимостей мы смотрим атрибуты класса, выбираем те из них, что содержат маркер Injectable. Для каждого такого атрибута смотрим указанный тип. Например, это TaskController. Смотрим, есть ли у нас зарегистрированные экземпляры для этого типа. Если есть, внедряем в этот атрибут зарегистрированный экземпляр.
Если же зарегистрированного экземпляра нет, начинаем исследовать сам этот тип, смотрим его зависимости. Например, нам попалась зависимость от TaskService. Повторяем процедуру. Экземпляра для TaskService у нас нет, начинаем изучать сам TaskService. Находим зависимость от IRepo. Смотрим, есть ли у нас экземпляр для IRepo. Ага, есть, мы его зарегистрировали через register_instance. Создаем TaskService, внедряем в его атрибут наш экземпляр, зарегистрированный для IRepo. Возвращаемся к TaskController, создаем его, внедряем в его атрибут только что созданный TaskService. Возвращаемся к TaskApp, создаем его, внедряем в его атрибут только что созданный TaskController. Готово.
Из того, что нам необходимо для работы кроме описанных ранее методов:
- список всех регистраций (в виде словаря "Тип -> Регистрация")
- список зарегистрированных фабрик
- метод для получения атрибутов класса с их типами
В принципе, все. По ходу реализации создадим несколько дополнительных методов.
Регистрации
Как я уже писал, нам необходим список всех регистраций. Он будет иметь вид "Тип (класс) -> Регистрация". Вот эта самая регистрация - это будет отдельный объект. Он будет содержать информацию о том, что привязано к этому типу. Если для этого типа прокинут инстанс, это должно быть отражено в регистрации. Если же его тоже надо разрешить, то для него нужны зависимости. Пускай у него будет информация о зависимостях, о конкретном типе и об экземпляре при его наличии. Выглядить он будет так:

Теперь поговорим о фабриках. Я решил хранить их в отдельном словаре. В принципе, можно было добавить их к регистрациям, но я выбрал отдельное хранение.
Фабрика будет представлена довольно простой структурой. Она будет хранить функцию, позиционные и непозиционные аргументы этой функции.

Ну и наконец создадим класс контейнера.

Методы контейнера
В целом, мы уже можем написать методы register, register_factory, register_instance. Все они будут создавать объект регистрации и записывать его в словарь. Для удобства добавим промежуточным метод __create_registration, который как раз будет это делать. Теперь код нашего контейнера выглядит так:

В случае с функцией register все очевидно, мы просто создаем регистрацию, привязывая абстрактный класс к конкретному, но с register_instance и register_factory лучше чуть прояснить ситуацию.
В случае регистрации экземпляра нам не важно, что именно регистрировать в качестве конкретного класса. Т.к. мы все равно опираемся на готовый экземпляр, нам не нужно определять зависимости. Поэтому при регистрации экземпляра мы спокойно можем привязать в качестве конкретного типа что угодно. Можно было бы привязать None, но я выбрал просто сохранить туда сам абстрактный класс.
В случае с фабрикой конкретным типом, по сути, будет являться конструктор. Мы ожидаем, что в качестве конструктора будет передан конкретный класс. Также уточню насчет типизации register_factory. Он принимает на вход конструктор с некоторыми аргументами. После конструктора он принимает arg'и и kwarg'и, совпадающие с аргументами прокинутого конструктора. Таким образом у нас метод register_factory будет принимать те же самые аргументы, что и переданный конструктор. Да, из-за работы с аргументами я принимаю конструктор как Callable, а потом интерпретирую его как тип через cast. Я знаю, что cast - это от слова castыль. Но как одновременно и показать, что на вход мы принимаем именно класс, а не просто callable, и обеспечить совпадение аргументов метода и конструктора - я не придумал.
Разрешение зависимостей
Теперь самая интересная часть: разрешение зависимостей. Магический метод resolve. Он будет получать на вход класс, экземпляр которого нужно создать, разрешив все зависимости. Если получен класс, для которого у нас зарегистрирован экземпляр, то мы просто вернем его. Если экземпляра у нас нет, то мы определяем зависимости этого класса. Далее для каждой зависимости вызываем метод resolve, пытаясь создать экземпляр этой зависимости.
На этапе разрешения мы можем получить сразу несколько проблем. Во-первых, создавая экземпляр для некоторого класса, мы можем наткнуться на непустой конструктор. Но мы же не знаем, какие аргументы ему надо подавать (если не зарегистрирована фабрика), и мы отлетим с ошибкой TypeError.
Во-вторых, мы можем наткнуться на зависимость от абстрактного класса, и у нас при этом может не оказаться для него зарегистрированного конкретного класса.
В обоих описанных случаях разрешить зависимости не удастся, и мы вынуждены будем выбросить исключение. Для этого создадим базовые исключения DIError и DIResolutionError, и унаследуем от DIResolutionError исключения UnresolvableDependencyError и NoRegisteredImplementationsError.

Перейдем к методу resolve. В начале зарегистрируем класс, который мы пытаемся разрешить. Это позволит в дальнейшем опираться на уже созданный инстанс этого класса и не повторять работу по его разрешению.
Далее получим регистрацию, проверим на наличие экземпляра. Если он есть, вернем его.
Если экземпляра нет, определим зависимости для класса, сохраним их в регистрацию. Далее создадим экземпляр по этой регистрации. Затем разрешим все зависимости текущей регистрации и привяжем их к атрибутам созданного экземпляра. И, наконец, вернем инстанс. Код метода выглядит так:

Здесь используются пока не описанные методы. Сейчас разберем и их. __define_registration_dependencies получит регистрацию, получит по ее конкретному классу внедряемые атрибуты и сохранит их как зависимости этой регистрации. Здесь получение внедряемых атрибутов вынесено в метод get_injectable_attributes. Он получает аннотации класса, пробегает по ним. Если аннотация атрибута представлена буквально объектом Annotated, причем у него два аргумента (Annotated[тип, маркер]), мы проверяем маркер. Если он является объектом Injectable, то сохраняем этот атрибут.
Код этих методов выглядит так:

Теперь разберем __create_instance. Тут все сравнительно просто. Если по переданному абстрактному типу есть фабрика, то создаем объект через эту фабрику. Если нет, то в начале проверяем, не является ли переданный конкретный класс абстрактным (через inspect.isabstract). Если он является таковым, выбросим исключение. Если класс не абстрактный, то попробуем создать его экземпляр. Поместим создание экземпляра в try-except, и будем ловить TypeError на случай, если создать экземпляр не получится из-за непустого конструктора. Если мы поймали ошибку, выбросим исключение.
Код метода:

Наконец разберем __resolve_dependencies_for_registration. Он получает на вход регистрацию и созданный экземпляр. Проходится по всем зависимостям регистрации, каждую пытается зарезолвить. Разрешенную зависимость сохраняет в соответствующий атрибут переданного экземпляра.

В общем-то, все. Осталось покрыть тестами.
Результат
Итоговый код можно найти здесь: https://github.com/Sanch0pansa/MicroDI
Получилось неидеально, где-то можно улучшить. Так, например, заявленный механизм определения абстрактных классов по имплементациям я пока не реализовал. К тому же нет некоторых механизмов, которые должны быть в DI-контейнерах, например, области видимости, жизненного цикла и т.д. Но оно получилось рабочим и решающим поставленную задачу.
Мне понравился этот опыт, но есть, над чем еще подумать. Возможно, выпущу как-нибудь продолжение) Мне, к примеру, хотелось бы добавить работу с сигнатурами функций, чтобы получилось что-то вроде Depends в FastAPI. Но это уже в другой раз.
Спасибо за внимание.