Привет, Хабр! Сегодня я бы хотел вместе с вами погрузится в увлекательный мир зависимостей, а точнее их внедрение.
И так, давайте сначала разберемся что же такое зависимость?
Зависимость - это объект (или функция, в Python все - это объект), который нужен другому объекту или функции для их нормальной работы. Почти в каждого объекта есть одна или несколько зависимостей. Существует 2 основных метода их получение: создание зависимости непосредственно внутри функции либо же инъекция (внедрение).
Создание зависимостей внутри функции и почему это плохо?
Давайте разберемся с классическим способом получения зависимости, ее созданием внутри функции.
class Calculator:
def add(self, a: int, b: int) -> int:
return a + b
def sub(self, a: int, b: int) -> int:
return a - b
def mult(self, a: int, b: int) -> int:
return a * b
def divide(self, a: int, b: int) -> float:
return a / b
def add_number_to_five(number: int) -> int:
calculator = Calculator()
return calculator.add(number, 5)
Думаю всем понятно что здесь происходит, но лучше объясню. У нас есть функция add_number_to_five
которая создает экземпляр Calculator
и возвращает значение его функции add
с собственным аргументом и числом 5. Понимаю, пример притянут за уши, но это сделано для простоты понимание.
"Ну и что? - спросите вы - нормальный код". А вот и нет. Во первых он противоречит принципу единой ответственности, а во вторых его сложно тестировать. Так еще к тому же он зависит от конкретной реализации. Давайте разберем на примере:
import unittest
class CalculatorTest(unittest.TestCase):
def test_add(self) -> None:
self.assertEqual(Calculator().add(2, 3), 5)
def test_sub(self) -> None:
self.assertEqual(Calculator().sub(2, 3), -1)
def test_mult(self) -> None:
self.assertEqual(Calculator().mult(2, 3), 6)
def test_divide(self) -> None:
self.assertEqual(Calculator().add(8, 4), 2)
Хорошо, а как нам протестировать нашу функцию add_number_to_five
? Вроде все просто:
class ExampleTest(unittest.TestCase):
def test_func(self) -> None:
self.assertEqual(add_number_to_five(2), 7)
Но стойте! Вы же не забыли что наша функция внутри себя вызывает еще Calculator().add
? То есть мы тестируем нашу целевую функцию И ее зависимость, что не есть правильным. Думаю вы уже знаете почему. Потому что если наш тест упадет, мы понятия не имеем что конкретно упало, где искать ошибку, в нашей функции или в зависимостях? А если б там был не простой калькулятор, а какое-то сложное обращение к API или БД?
Также, еще одним очевидным недостатком такого подхода можно выделить что мы для каждого вызова функции создаем отдельные экземпляры зависимостей что как минимум нагружает сборщик мусора и требует постоянной инициализации, а как максимум постоянно переподключаемся к БД или сервису.
Инъекция зависимостей
И тут на сцену выходит внедрения зависимостей! Что же это за штука?
Инъекция (внедрение) зависимостей - это паттерн проектирования согласно которому зависимости для функции, передаются снаружи. Другими словами мы собираем зависимости функции до ее вызова и передаем их, например с аргументами. Перепишем нашу функцию:
def add_number_to_five(calculator: Calculator, number: int) -> int:
return calculator.add(number, 5)
Здесь функция add_number_to_five
опять зависит от Калькулятора, НО эта зависимость передается снаружи - уже лучше! Во первых, в таком состоянии ее легче тестировать, не нужно ничего патчить и т.д. Во вторых, при изменении типа Calculator
на какой-нибудь Protocol или абстрактный класс, он станет соответствовать принципу инверсии зависимостей, но подождите радоваться. Давайте посмотрим как мы вызывали функцию до:
add_number_to_five(2)
И как мы ее вызываем сейчас:
calculator = Calculator()
add_number_to_five(calculator, 2)
Не очень удобно, не так ли? В первом случае нам нужно было просто вызвать функцию, а сейчас приходится еще и зависимости создавать. И если нам ее нужно вызывать во многих местах, значит мы много раз создаем зависимости и не избавились от проблемы многократного создания. Что же делать?
Easy-DI: та инъекция зависимостей которая нам нужна
Встречайте на сцене мое творение easy-di. Этот инструмент как раз исправляет все вышеперечисленные проблемы. Во первых не нужно постоянного создание зависимости, а во вторых вызывающий вообще не знает какие зависимости в данной функции. Меньше слов, переходим к практике.
Для начала устанавливаем:
uv add di-easy
либо через pip:
pip install di-easy
Для начала работы импортируем инжектор, их пока что только два, начнем с базового:
from easy_di import BaseInjector
Далее нам нужно немного изменить функцию, сказав инжектору что ей нужно:
@BaseInjector('calculator')
def add_number_to_five(deps: dict[str, Any], number: int) -> int:
calculator = deps['calculator']
return calculator.add(number, 5)
Давайте разберем данный код. @BaseInjector('calculator')
говорит, что эта функция зависит от калькулятора. И инжектор передает его словарем в первый аргумент, я рекомендую его называть deps
. Хорошо, но как инжектор поймет кто такой этот калькулятор? Для этого его нужно зарегистрировать, обычно это делается в начале программы:
def main():
BaseInjector.register('calculator', Calculator())
# что-то делаем
print(add_number_to_five(21)) # выведет 26
if __name__ == '__main__':
main()
Что здесь происходит? Сначала мы в базовом инжекторе регистрируем calculator
, заметьте мы передаем в метод register
именно экземпляр, а не сам класс. Далее мы можем что-то делать. И когда мы вызываем функцию, мы уже не переживаем об ее зависимостях. Инжектор внедрит туда именно то что мы в нем зарегистрировали и что она запросила. Круто, не правда ли? Но есть нюанс:
ЗАВИСИМОСТЬ МОЖНО РЕГИСТРИРОВАТЬ ДО И ПОСЛЕ ОБЪЯВЛЕНИЯ ФУНКЦИИ, НО ОБЯЗАТЕЛЬНО ПЕРЕД ЕЕ ПЕРВЫМ ВЫЗОВОМ.
Вы же помните что у нас зависимость передается словарем? А это означает что у функции может быть сколько угодно зависимостей. Чтобы их добавить необходимо просто дописать их через запятую в @BaseInjector
. Например @BaseInjector('calculator', 'user')
.
Также хочу добавить, что названия зависимостей могут быть ТОЛЬКО строкой, сама же зависимость напротив - может быть любым объектом.
Давайте посмотрим как же такое тестировать. А все очень просто:
class ExampleTest(unittest.TestCase):
def test_func(self) -> None:
mock = unittest.Mock()
mock.add.side_effect = lambda a, b: a + b
BaseInjector.register('calculator', mock)
self.assertEqual(add_number_to_five(2), 7)
Согласен, выглядит страшно и если б мы использовали чистую инъекцию без либ, мы б ничего не регистрировали, а сразу передавали мок в функцию. НО если б у нас было 10+ тестов то мы б каждый раз создавали мок, настраивали его. А тут мы можем вынести его настройку и регистрацию в setUpClass, он один раз создастся и все. Также в tearDown
можно удалять все зависимости с помощью BaseInjector.unregister('*')
.
Давайте рассмотрим еще один пример. У нас есть два файла, main.py:
import uuid
from easy_di import BaseInjector
from handlers import router
from plugs import Bot
bot = Bot(uuid.uuid4())
def main():
BaseInjector.register('bot', bot)
bot.register(router)
bot.start()
if __name__ == '__main__':
main()
и handlers.py:
from easy_di import BaseInjector
from plugs import Router
router = Router()
@BaseInjector('bot')
def hi(deps):
bot = deps['bot']
bot.send(f"Hi I'm {bot.me()}")
router.register(hi)
Давайте разбираться! В plugs
у нас просто классы-заглушки. hi
- это обработчик команды и для правильной его работы нам требуется объект бота. Но если мы просто сделаем from main import bot
, то программа упадет из-за цикла импорта. Чтоб это исправить нам пришлось бы переносить импорт обработчиков в функцию main
или городить еще что-то. А так мы используем просто наш инжектор.
А что если мы работаем с каким-нибудь аиограммом? Мы просто декорируем инжектором перед декорацией роутером:
@router.message(Command('start'))
@BaseInjector('bot')
def hi(deps, msg): ...
Вот и все, и не нужно городить костыли!
Группировка зависимостей
В Easy-DI также есть группировка зависимостей в группы. Это осуществляется с помощью GroupInjector
. Разберем на примере:
from easy_di import GroupInjector
# Register a dependency group with multiple dependencies
GroupInjector.register_dependency_group("services", logger=lambda msg: f"Log: {msg}", config={"debug": True})
# Define a function with grouped injection
@GroupInjector("services.logger", "services.config")
def log_message(deps, message):
return f"{deps['services.logger'](message)} | Debug: {deps['services.config']['debug']}"
print(log_message("An event occurred")) # Output: "Log: An event occurred | Debug: True"
Пример взят из документации. Сначала мы регистрируем группу зависимостей уже с зарегистрированными в ней зависимостями логгер и конфиг. Мы также могли зарегистрировать пустую группу, а потом в нее зарегистрировать зависимости с помощью GroupInjector.register_dependency(dependency_id: str, dependency: Any, group_id: Optional[str] = None) -> None
. Первый параметр - это название зависимости, второй - собственно зависимость, третий - название группы в которую попадет зависимость. Наверное, вы заметили что группа - опциональный параметр, это не спроста. Мы можем указать id группы прямо в dependency_id, как? Мы просто пишем название группы, а потом через точку название зависимости. Важное замечание:
В НАЗВАНИИ ГРУПЫ НЕ МОЖЕТ БЫТЬ ТОЧКИ!
Почему так, думаю, объяснять не надо. Также, хочу сказать про регистрацию, также как и в базовом инжекторе, можно регистрировать до и после объявление функции, но обязательно перед ее первым вызовом, при этом регистрировать зависимость нужно после регистрации группы. Вроде все особенности рассказал. Но вы спросите, зачем нам групповой инжектор, если есть базовый в которого нет доп. ограничений. У группового инжектора есть возможность получения всех зависимостей с группы:
from easy_di import GroupInjector
# Register a dependency group with multiple dependencies
GroupInjector.register_dependency_group("config", host="localhost", port=8080, debug=True)
# Inject all elements of the group as separate entries in `deps`
@GroupInjector("config.*")
def app_settings(deps):
return f"Host: {deps['config.host']}, Port: {deps['config.port']}, Debug: {deps['config.debug']}"
print(app_settings()) # Output: "Host: localhost, Port: 8080, Debug: True"
Как видите, мы для этого должны поставить название группы, точку, звездочку и инжектор просто передаст нам все зависимости с группы. Это главное преимущество данного инжектора.
Давайте рассмотрим как групповой инжектор может помочь в написании телеграмм ботах. Допустим у нас есть main.py и пакет handlers. В обработчиках у нас n-ое количество отдельных модулей с их роутерами. Как же нам их всех зарегистрировать? Через групповой инжектор! Давайте разберем на примере:
import uuid
from easy_di import BaseInjector, GroupInjector
from handlers import router
from plugs import Bot
bot = Bot(uuid.uuid4())
@GroupInjector('routers.*')
def register_handlers(deps):
for dep in deps.values():
bot.register(dep)
def main():
BaseInjector.register('bot', bot)
register_handlers()
bot.start()
if __name__ == '__main__':
main()
один из файлов пакета handlers:
from easy_di import BaseInjector, GroupInjector
from plugs import Router
router = Router()
@BaseInjector('bot')
def hi(deps): ...
def process(): ...
router.register(hi)
router.register(process)
GroupInjector.register_dependency('routers.base', router, if_group_not_exists='create')
Так как мы точно не знаем в каком проекте будут импортироваться модули, можно указать параметр if_group_not_exists='create'
в первом вызове регистрации в модуле и если группы нет, она создастся. Ну а принципы остального кода мы разбирали.
Также когда указываем група.*
(та и не только в этом случае) удобно чтобы зависимости группировались и при передаче в функции. То есть чтоб нам приходило не {'group_id.dep1':dep1, 'group_id.dep2':dep2}
, а {'group_id': {'dep1': dep1, 'dep2': dep2}
. В основном это полезно когда у нас несколько групп, но может быть и полезно если мы получаем все из группы и нам нужно получить имя - зависимость без лишних костылей. Easy-DI
позволяет очень легко такое сделать, указавши group_deps=True
при декорировании. Давайте посмотрим как теперь будет выглядит наш код:
@GroupInjector('routers.*', group_deps=True)
def register_handlers(deps):
for dep in deps['routers'].values():
bot.register(dep)
Здесь код стал немного сложнее, именно из-за этого это не используется по умолчанию. Но в то же время он стал более конкретным. Мы конкретно говорим по чему мы итерируемся.
Заключение
Давайте подытожим. Инъекция зависимостей - это очень мощная технология, которая может использоваться в средних и больших проектах. В маленьких проектах тоже может пригодится, но может только усложнить код. Она является незаменимым паттерном во время тестов. И Easy-DI очень помогает реализовывать этот паттерн.
Планирую сделать еще динамической инжектор который будет динамически создавать зависимости. Как конкретно он будет реализован пока не знаю. Если есть предложения, смело делитесь.
Вот и конец моего рассказа. Это была моя первая статья, надеюсь получилось хорошо. Если есть замечания, пишите в комментарии. Есть вопросы или предложения к Easy-DI, также пишите в комментарии или открывайте Issue на GitHub.