Хабрахабр

[Из песочницы] Управление зависимостями в Python: сравнение подходов

image

Большую часть этого пути мне помогает в этом моя команда. Я пишу на питоне лет пять, из них последние три года — развиваю собственный проект. И с каждым релизом, с каждой новой фичей у нас все больше усилий уходит на то, чтобы проект не превращался в месиво из неподдерживаемого кода; мы боремся с циклическими импортами, взаимными зависимостями, выделяем переиспользуемые модули, перестраиваем структуру.

Под катом — лонгрид с размышлениями об архитектуре и в первую очередь — об управлении зависимостями применимо к 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 занимает несколько секунд (у нас большой монолит), и, когда перезапускаешь один и тот же файл 10, 20, 100 раз — это просто замедляет разработку. И меня очень раздражает этот постоянный django.setup().

Нужно писать код, который по минимуму зависит от django. Как избавиться от django.setup()?

Например, если мы пишем некий клиент внешнего 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=, 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())

Всё работает. Процессор парсит текст, укорачивает ссылки с помощью укорачивателя, возвращает результат. Зависимости выглядят вот так:

image

Проблема вот какая: класс 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'

То есть мы изменили укорачиватель, но сломался не он, а его клиент:

image

Ну сломался вызывающий файл, мы пошли и поправили его. Ну и что такого? В чем проблема-то?

Если в классах мало кода и если вы поддерживаете их самостоятельно (это ваш сайд-проект, это два небольших класса одной подсистемы и тд) — то на этом можно остановиться. Если это решается за минуту — пошли и поправили — то это, конечно, и не проблема вовсе.

Проблемы начинаются, когда:

  • в вызывающем и вызываемом модулях много кода;
  • поддержкой разных модулей занимаются разные люди/команды.

Если вы пишете класс ShortenerClient, а ваш коллега пишет TextProcessor, то получается обидная ситуация: код изменили вы, а сломалось у него. Причем сломалось в том месте, которое вы в жизни не видели, и вам теперь нужно садиться и разбираться в чужом коде.

Еще интереснее — когда ваш модуль используется в нескольких местах, а не в одном; и ваша правка поломает код в куче файлов.

Поэтому задачу можно сформулировать так: как организовать код так, чтобы при изменении интерфейса ShortenerClient ломался сам ShortenerClient, а не его потребители (которых может быть много)?

Решение здесь такое:

  • Потребители класса и сам класс должны договориться об общем интерфейсе. Этот интерфейс должен стать законом.
  • Если класс перестанет соответствовать своему интерфейсу — это будут уже его проблемы, а не проблемы потребителей.

image
Как в питоне выглядит фиксация интерфейса? Это абстрактный класс:

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 зависит от него, а не наоборот.

image

В простых словах можно описать суть нашего преобразования так:

  • TextProcessor говорит: я процессор, и я занимаюсь преобразованием текста. Я не хочу ничего знать о механизме укорачивания: это не моё дело. Я хочу дернуть метод shorten_link, чтоб он мне всё укоротил. Поэтому будьте добры, передайте мне объект, который играет по моим правилам. Решения о способе взаимодействия принимаю я, а не он.
  • ShortenerClient говорит: похоже, я не могу существовать в вакууме, и от меня требуют определенного поведения. Пойду спрошу у TextProcessor, чему мне нужно соответствовать, чтобы не ломаться.

Если же укорачиванием ссылок пользуются несколько модулей, то интерфейс нужно положить не в одного из них, а в какой-то отдельный файл, который находится «над» остальными файлами, выше по иерархии:

image

Если потребители не импортируют 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 через наследование.

Про это очень подробно рассказывает Raymond Hettinger в своем докладе Super considered Super

Чтобы адаптировать код под этот стиль, нужно немного поменять 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 будет декларировать интерфейсы для используемых компонентов;
  • используемые компоненты будут вынуждены соответствовать этим интерфейсам;
  • некий внешний код будет собирать все воедино и запускать.

Схема зависимостей будет выглядеть так:

image

Но как теперь будет выглядеть код сборки этих зависимостей?

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-фреймворком; но суть от этого не изменится. Фреймворк даст более гибкое управление инстансами; он позволит создавать их в виде синглтонов или пачками, как фабрика; но идея останется такой же.

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

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

И вообще — нужно ли это, или я изобретаю проблему из воздуха? А какой у вас опыт с управлением зависимостями в питоне?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть