[Из песочницы] Управление зависимостями в Python: сравнение подходов
Я пишу на питоне лет пять, из них последние три года — развиваю собственный проект. Большую часть этого пути мне помогает в этом моя команда. И с каждым релизом, с каждой новой фичей у нас все больше усилий уходит на то, чтобы проект не превращался в месиво из неподдерживаемого кода; мы боремся с циклическими импортами, взаимными зависимостями, выделяем переиспользуемые модули, перестраиваем структуру.
К сожалению, в Python-сообществе нет универсального понятия «хорошей архитектуры», есть только понятие «питоничности», поэтому архитектуру приходится придумывать самим. Под катом — лонгрид с размышлениями об архитектуре и в первую очередь — об управлении зависимостями применимо к Python.
Начну с вопроса джангистам. Часто ли вы пишете вот эти две строчки?
import django
django.setup()
С этого нужно начать файл, если вы хотите поработать с объектами django, не запуская сам вебсервер django. Это касается и моделей, и инструментов работы со временем (django.utils.timezone
), и урлов (django.urls.reverse
), и многого другого. Если этого не сделать, то вы получите ошибку:
django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet.
Я постоянно пишу эти две строчки. Я большой любитель кода «на выброс»; мне нравится создать отдельный .py
-файл, покрутить в нем какие-то вещи, разобраться в них —, а потом встроить в проект.
И меня очень раздражает этот постоянный django.setup()
. Во-первых, устаешь это везде повторять; а, во-вторых, инициализация django занимает несколько секунд (у нас большой монолит), и, когда перезапускаешь один и тот же файл 10, 20, 100 раз — это просто замедляет разработку.
Как избавиться от django.setup()
? Нужно писать код, который по минимуму зависит от django.
Например, если мы пишем некий клиент внешнего API, то можно сделать его зависимым от django:
from django.conf import settings
class APIClient:
def __init__(self):
self.api_key = settings.SOME_API_KEY
# использование:
client = APIClient()
а можно — независимым от django:
class APIClient:
def __init__(self, api_key):
self.api_key = api_key
# использование:
client = APIClient(api_key='abc')
Во втором случае конструктор более громоздкий, зато любые манипуляции с этим классом можно делать, не загружая всю джанговскую машинерию.
Тесты тоже становятся проще. Как тестировать компонент, который зависит от настроек django.conf.settings
? Только замокав их декоратором @override_settings
. А если компонент ни от чего не зависит, то и мокать будет нечего: передал параметры в конструктор — и погнали.
История с зависимостью от django
— это наиболее яркий пример проблемы, с которой я сталкиваюсь каждый день: проблемы управления зависимостями в python — и в целом выстраивания архитектуры python-приложений.
Отношение к управлению зависимостями в Python-сообществе неоднозначное. Можно выделить три основных лагеря:
- Питон — гибкий язык. Пишем как хотим, зависим от чего хотим. Не стесняемся циклических зависимостей, подмены атрибутов у классов в рантайме и т.д.
- Питон — особенный язык. Здесь есть свои идиоматичные способы выстраивать архитектуру и зависимости. Передача данных вверх и вниз по стеку вызовов выполняется за счет итераторов, корутин и контекстных менеджеров.Классный доклад на эту тему и примерBrandon Rhodes, Dropbox: Hoist your IO.
Пример из доклада:
def main(): """ На внешнем уровне есть доступ к данным с диска """ with open("/etc/hosts") as file: for line in parse_hosts(file): print(line) def parse_hosts(lines): """ на внутреннем уровне - логика обработки """ for line in lines: if line.startswith("#"): continue yield line
- Гибкость питона — это лишний способ выстрелить себе в ногу. Нужен жесткий набор правил для управления зависимостями. Хороший пример — русские ребята dry-python. Еще есть менее хардкорный подход — Django structure for scale and longevity, Но идея здесь та же.
Есть несколько статей на тему управления зависимостями в python (пример 1, пример 2), но все они сводятся к рекламе чьих-то Dependency Injection фреймворков. Эта статья — новый заход на ту же тему, но на сей раз это чистый мысленный эксперимент без рекламы. Это попытка найти баланс между тремя подходами выше, обойтись без лишнего фреймворка и сделать «питонично».
Недавно я прочел Clean Architecture — и, кажется, понял, в чем ценность внедрения зависимостей в питоне и как его можно реализовать. Я увидел это на примере своего собственного проекта. Вкратце — это защита кода от поломок при изменениях другого кода.
Есть API-клиент, который выполняет HTTP-запросы на сервис-укорачиватель:
# shortener_client.py
import requests
class ShortenerClient:
def __init__(self, api_key):
self.api_key = api_key
def shorten_link(self, url):
response = requests.post(
url='https://fstrk.cc/short',
headers={'Authorization': self.api_key},
json={'url': url}
)
return response.json()['url']
И есть модуль, который укорачивает все ссылки в тексте. Для этого он использует API-клиент укорачивателя:
# text_processor.py
import re
from shortener_client import ShortenerClient
class TextProcessor:
def __init__(self, text):
self.text = text
def process(self):
changed_text = self.text
links = re.findall(
r'https?://[^\r\n\t") ]*',
self.text,
flags=re.MULTILINE
)
api_client = ShortenerClient('abc')
for link in links:
shortened = api_client.shorten_link(link)
changed_text = changed_text.replace(link, shortened)
return changed_text
Логика выполнения кода живет в отдельном управляющем файле (назовем его контроллером):
# controller.py
from text_processor import TextProcessor
processor = TextProcessor("""
Ссылка 1: https://ya.ru Ссылка 2: https://google.com
""")
print(processor.process())
Всё работает. Процессор парсит текст, укорачивает ссылки с помощью укорачивателя, возвращает результат. Зависимости выглядят вот так:
Проблема вот какая: класс TextProcessor
зависит от класса ShortenerClient
— и сломается при изменении интерфейсаShortenerClient
.
Как это может произойти?
Допустим, в нашем проекте мы решили отслеживать переходы по ссылкам и добавили в метод shorten_link
аргумент callback_url
. Этот аргумент означает адрес, на который должны приходить уведомления при переходе по той или иной ссылке.
Метод ShortenerClient.shorten_link
стал выглядеть вот так:
def shorten_link(self, url, callback_url):
response = requests.post(
url='https://fstrk.cc/short',
headers={'Authorization': self.api_key},
json={'url': url,
'callback_on_click': callback_url}
)
return response.json()['url']
И что получается? А получается то, что при попытке запуска мы получим ошибку:
TypeError: shorten_link() missing 1 required positional argument: 'callback_url'
То есть мы изменили укорачиватель, но сломался не он, а его клиент:
Ну и что такого? Ну сломался вызывающий файл, мы пошли и поправили его. В чем проблема-то?
Если это решается за минуту — пошли и поправили — то это, конечно, и не проблема вовсе. Если в классах мало кода и если вы поддерживаете их самостоятельно (это ваш сайд-проект, это два небольших класса одной подсистемы и тд) — то на этом можно остановиться.
Проблемы начинаются, когда:
- в вызывающем и вызываемом модулях много кода;
- поддержкой разных модулей занимаются разные люди/команды.
Если вы пишете класс ShortenerClient
, а ваш коллега пишет TextProcessor
, то получается обидная ситуация: код изменили вы, а сломалось у него. Причем сломалось в том месте, которое вы в жизни не видели, и вам теперь нужно садиться и разбираться в чужом коде.
Еще интереснее — когда ваш модуль используется в нескольких местах, а не в одном; и ваша правка поломает код в куче файлов.
Поэтому задачу можно сформулировать так: как организовать код так, чтобы при изменении интерфейса ShortenerClient
ломался сам ShortenerClient
, а не его потребители (которых может быть много)?
Решение здесь такое:
- Потребители класса и сам класс должны договориться об общем интерфейсе. Этот интерфейс должен стать законом.
- Если класс перестанет соответствовать своему интерфейсу — это будут уже его проблемы, а не проблемы потребителей.
Как в питоне выглядит фиксация интерфейса? Это абстрактный класс:
from abc import ABC, abstractmethod
class AbstractClient(ABC):
@abstractmethod
def __init__(self, api_key):
pass
@abstractmethod
def shorten_link(self, link):
pass
Если теперь мы унаследуемся от этого класса и забудем реализовать какой-то метод — мы получим ошибку:
class ShortenerClient(AbstractClient):
def __ini__(self, api_key):
self.api_key = api_key
client = ShortenerClient('123')
>>> TypeError: Can't instantiate abstract class ShortenerClient with abstract methods __init__, shorten_link
Но этого недостаточно. Абстрактный класс фиксирует только названия методов, но не их сигнатуру.
Нужен второй инструмент для проверки сигнатуры Этот второй инструмент — mypy
. Он поможет проверить сигнатуры унаследованных методов. Для этого мы должны добавить в интерфейс аннотации:
# shortener_client.py
from abc import ABC, abstractmethod
class AbstractClient(ABC):
@abstractmethod
def __init__(self, api_key: str) -> None:
pass
@abstractmethod
def shorten_link(self, link: str) -> str:
pass
class ShortenerClient(AbstractClient):
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def shorten_link(self, link: str, callback_url: str) -> str:
return 'xxx'
Если теперь проверить этот код при помощи mypy
, мы получим ошибку из-за лишнего аргумента callback_url
:
mypy shortener_client.py
>>> error: Signature of "shorten_link" incompatible with supertype "AbstractClient"
Теперь у нас есть надежный способ зафиксировать интерфейс класса.
Отладив интерфейс, мы должны переместить его в другое место, чтобы окончательно устранить зависимость потребителя от файла shortener_client.py
. Например, можно перетащить интерфейс прямо в потребителя — в файл с процессором TextProcessor
:
# text_processor.py
import re
from abc import ABC, abstractmethod
class AbstractClient(ABC):
@abstractmethod
def __init__(self, api_key: str) -> None:
pass
@abstractmethod
def shorten_link(self, link: str) -> str:
pass
class TextProcessor:
def __init__(self, text, shortener_client: AbstractClient) -> None:
self.text = text
self.shortener_client = shortener_client
def process(self) -> str:
changed_text = self.text
links = re.findall(
r'https?://[^\r\n\t") ]*',
self.text,
flags=re.MULTILINE
)
for link in links:
shortened = self.shortener_client.shorten_link(link)
changed_text = changed_text.replace(link, shortened)
return changed_text
И это изменит направление зависимости! Теперь интерфейсом взаимодействия владеет TextProcessor
, и в результате ShortenerClient
зависит от него, а не наоборот.
В простых словах можно описать суть нашего преобразования так:
TextProcessor
говорит: я процессор, и я занимаюсь преобразованием текста. Я не хочу ничего знать о механизме укорачивания: это не моё дело. Я хочу дернуть методshorten_link
, чтоб он мне всё укоротил. Поэтому будьте добры, передайте мне объект, который играет по моим правилам. Решения о способе взаимодействия принимаю я, а не он.ShortenerClient
говорит: похоже, я не могу существовать в вакууме, и от меня требуют определенного поведения. Пойду спрошу уTextProcessor
, чему мне нужно соответствовать, чтобы не ломаться.
Если же укорачиванием ссылок пользуются несколько модулей, то интерфейс нужно положить не в одного из них, а в какой-то отдельный файл, который находится «над» остальными файлами, выше по иерархии:
Если потребители не импортируют ShortenerClient
, то кто все-таки его импортирует и создает объект класса? Это должен быть управляющий компонент — в нашем случае это controller.py
.
Самый простой подход — это прямолинейное внедрение зависимостей, Dependency Injection «в лоб». Создаём объекты в вызывающем коде, передаем один объект в другой. Профит.
# controller.py
import TextProcessor
import ShortenerClient
processor = TextProcessor(
text='Ссылка 1: https://ya.ru Ссылка 2: https://google.com',
shortener_client=ShortenerClient(api_key='123')
)
print(processor.process())
Считается, что более «питоничный» подход — это Dependency Injection через наследование.
Чтобы адаптировать код под этот стиль, нужно немного поменять TextProcessor
, сделав его наследуемым:
# text_processor.py
class TextProcessor:
def __init__(self, text: str) -> None:
self.text = text
self.shortener_client: AbstractClient = self.get_shortener_client()
def get_shortener_client(self) -> AbstractClient:
""" Метод нужно переопределить в наследниках """
raise NotImplementedError
И затем, в вызывающем коде, унаследовать его:
# controller.py
import TextProcessor
import ShortenerClient
class ProcessorWithClient(TextProcessor):
""" Расширяем базовый класс, инджектим получение укорачивателя """
def get_shortener_client(self) -> ShortenerClient:
return ShortenerClient(api_key='abc')
processor = ProcessorWithClient(
text='Ссылка 1: https://ya.ru Ссылка 2: https://google.com'
)
print(processor.process())
Второй пример повсеместно встречается в популярных фреймворках:
- В Django мы постоянно наследуемся. Мы переопределяем методы Class-based вьюх, моделей, форм; иначе говоря, инджектим свои зависимости в уже отлаженную работу фреймворка.
- В DRF — то же самое. Мы расширяем вьюсеты, сериализаторы, пермишены.
- И так далее. Примеров масса.
Второй пример выглядит красивее и знакомее, не правда ли? Давайте разовьем его и посмотрим, сохранится ли эта красота.
В бизнес-логике обычно больше двух компонентов. Предположим, что наш TextProcessor
, — это не самостоятельный класс, а лишь один из элементов пайплайна TextPipeline
, который обрабатывает текст и шлет его на почту:
class TextPipeline:
def __init__(self, text, email):
self.text_processor = TextProcessor(text)
self.mailer = Mailer(email)
def process_and_mail(self) -> None:
processed_text = self.text_processor.process()
self.mailer.send_text(text=processed_text)
Если мы хотим изолировать TextPipeline
от используемых классов, мы должны проделать такую же процедуру, что и раньше:
- класс
TextPipeline
будет декларировать интерфейсы для используемых компонентов; - используемые компоненты будут вынуждены соответствовать этим интерфейсам;
- некий внешний код будет собирать все воедино и запускать.
Схема зависимостей будет выглядеть так:
Но как теперь будет выглядеть код сборки этих зависимостей?
import TextProcessor
import ShortenerClient
import Mailer
import TextPipeline
class ProcessorWithClient(TextProcessor):
def get_shortener_client(self) -> ShortenerClient:
return ShortenerClient(api_key='123')
class PipelineWithDependencies(TextPipeline):
def get_text_processor(self, text: str) -> ProcessorWithClient:
return ProcessorWithClient(text)
def get_mailer(self, email: str) -> Mailer:
return Mailer(email)
pipeline = PipelineWithDependencies(
email='abc@def.com',
text='Ссылка 1: https://ya.ru Ссылка 2: https://google.com'
)
pipeline.process_and_mail()
Заметили? Мы сначала наследуем класс TextProcessor
, чтобы вставить в него ShortenerClient
, а потом наследуем TextPipeline
, чтобы вставить в него наш переопределенный TextProcessor
(а также Mailer
). У нас появляется несколько уровней последовательного переопределения. Уже сложновато.
Почему же все фреймворки организованы именно таким образом? Да потому, что это подходит только для фреймворков.
- Все уровни фреймворка четко определены, и их количество ограничено. Например, в Django можно переопределить
FormField
, чтобы вставить его в переопределение формыForm
, чтобы вставить форму в переопределениеView
. Всё. Три уровня. - Каждый фреймворк служит одной задаче. Эта задача четко определена.
- У каждого фреймворка есть подробная документация, в которой описано, как и что наследовать; что и с чем комбинировать.
Можете ли вы так же четко и однозначно определить и задокументировать вашу бизнес-логику? Особенно — архитектуру уровней, на которых она работает? Я — нет. К сожалению, подход Раймонда Хеттингера не масштабируется на бизнес-логику.
На нескольких уровнях сложности выигрывает простой подход. Он выглядит проще — и его легче менять, когда меняется логика.
import TextProcessor
import ShortenerClient
import Mailer
import TextPipeline
pipeline = TextPipeline(
text_processor=TextProcessor(
text='Ссылка 1: https://ya.ru Ссылка 2: https://google.com',
shortener_client=ShortenerClient(api_key='abc')
),
mailer=Mailer('abc@def.com')
)
pipeline.process_and_mail()
Но, когда количество уровней логики возрастает, даже такой подход становится неудобным. Нам приходится в императивном ключе инициировать кучу классов, передавая их друг в друга. Хочется избежать множества уровней вложенности.
Попробуем еще один заход.
Попробуем создать некий глобальный словарь, в котором будут лежать инстансы нужных нам компонентов. И пусть эти компоненты достают друг друга через обращение к этому словарю.
Назовем его INSTANCE_DICT
:
# text_processor.py
import INSTANCE_DICT
class TextProcessor(AbstractTextProcessor):
def __init__(self, text) -> None:
self.text = text
def process(self) -> str:
shortener_client: AbstractClient = INSTANCE_DICT['Shortener']
# ... прежний код
# text_pipeline.py
import INSTANCE_DICT
class TextPipeline:
def __init__(self) -> None:
self.text_processor: AbstractTextProcessor = INSTANCE_DICT[
'TextProcessor']
self.mailer: AbstractMailer = INSTANCE_DICT['Mailer']
def process_and_mail(self) -> None:
processed_text = self.text_processor.process()
self.mailer.send_text(text=processed_text)
Трюк — в том, чтобы подложить в этот словарь наши объекты до того, как к ним обратятся. Это мы и сделаем в controller.py
:
# controller.py
import INSTANCE_DICT
import TextProcessor
import ShortenerClient
import Mailer
import TextPipeline
INSTANCE_DICT['Shortener'] = ShortenerClient('123')
INSTANCE_DICT['Mailer'] = Mailer('abc@def.com')
INSTANCE_DICT['TextProcessor'] = TextProcessor(text='Вот ссылка: https://ya.ru')
pipeline = TextPipeline()
pipeline.process_and_mail()
Плюсы работы через глобальный словарь:
- никакой подкапотной магии и лишних DI-фреймворков;
- плоский список зависимостей, в котором не нужно управлять вложенностью;
- все бонусы DI: простое тестирование, независимость, защита компонентов от поломок при изменениях других компонентов.
Конечно, вместо того чтобы создавать самостоятельно INSTANCE_DICT
, можно воспользоваться каким-нибудь DI-фреймворком;, но суть от этого не изменится. Фреймворк даст более гибкое управление инстансами; он позволит создавать их в виде синглтонов или пачками, как фабрика;, но идея останется такой же.
Возможно, в какой-то момент мне станет этого мало, и я все-таки выберу какой-нибудь фреймворк.
А, возможно, всё это лишнее, и проще обойтись без этого: писать прямые импорты и не создавать лишних абстрактных интерфейсов.
А какой у вас опыт с управлением зависимостями в питоне? И вообще — нужно ли это, или я изобретаю проблему из воздуха?