Python и чистая архитектура в 2021 году

Kate

Administrator
Команда форума
Прошло уже почти 3 года с тех пор, как я впервые воспользовался чистой архитектурой на практике. С тех пор я побывал на многочисленных конференциях, где выступал с докладами на эту тему (вот, например, доклад Clean Architecture in Python с конференции PyGotham 2018). Кроме того, я написал статью о чистой архитектуре на Python, которая попала в рассылку RealPython.com … но сейчас заканчивается 2021 год, и мы ушли далеко вперед. Давайте рассмотрим, как развился Python, изучим разные крутые библиотеки, благодаря которым реализовывать чистую архитектуру на Python сегодня стало гораздо проще.

Сначала вспомним о том, зачем она нужна.

Допущения – старые и проверенные​

Независимость от фреймворка​

В данном случае мы стремимся избежать просачивания фреймворк-специфичного содержимого в бизнес-логику. Если логика сложна сама по себе, то примесь из фреймворка это только усугубит. Никогда не ставил цель абстрагировать фреймворк просто из любви к искусству – нет, делал это осознанно, чтобы оставить код однонаправленным. Замена одного фреймворка на другой ПОЧТИ никогда не делается. Просто не дайте фреймворку слишком сильно перепутаться с тонкостями бизнес-логики.

Тестируемость​

Идея была в том, чтобы обеспечить тестирование самых ценных участков кода без особых усилий. Можно добиться, чтобы тесты стало быстрее писать, быстрее и дешевле поддерживать.

Независимость от UI/API​

Самое важное, что здесь нужно отметить: ваша программа – это не просто какое-нибудь приложение на Django/Flask/FastAPI/Starlette, т.д. Это нематериальная сущность, предоставляющая определенный набор услуг для группы (или нескольких групп) пользователь. Поверх программы может быть реализован API. Чем сложнее набор сервисов, лежащих под ним, тем целесообразнее такой API.

Независимость от сторонних сервисов​

Мы используем абстрактные классы (порты), чтобы писать бизнес-логику под стабильные интерфейсы, обертывающие сторонний код. Не только потому, что вполне вероятен такой сценарий, как смена платежного шлюза, но и для того, чтобы держать бизнес-логику простой и чистой. Также так удобнее писать заглушки для последующего подключения внешних элементов, которые мы не контролируем на этапе тестирования системы.

Несколько новых допущений​

Модуляризация важнее Чистой Архитектуры​

Чистая Архитектура – весьма капитальное вложение. И иногда овчинка выделки не стоит, особенно, если речь идет о такой части системы как:

  • Тривиальный прокси для соединения с каким-то сторонним сервисом
  • Простое CRUD-приложение
Использование чрезмерно упрощенного или переусложненного подхода сразу во всей системе – не лучший подход. Нужно научиться выявлять различные компоненты приложения, а затем аккуратно их разрабатывать. Разумеется, я не призываю выстраивать Изначально Полный Проект (Big Design Up Front). Будьте готовы к постепенному рефакторингу элементов вашего приложения, чтобы членить их на отдельные компоненты по мере того, как будете все лучше понимать систему.

Вот хорошая статья о создании модульного монолита на Python. Изучите и не кладите все яйца в одну корзину.

Независимость от баз данных​

Хотя, чистую архитектуру/гексагональную архитектуру/луковую архитектуру часто комбинируют с паттерном «Репозиторий», не следует рассчитывать, что переключение на другую базу данных будет тривиальным. Цель – убедиться, что ваша бизнес-логика задает очертания персистентности, а не наоборот. В MongoDB это без труда достигается при помощи Pydantic (или встроенных датаклассов + marshmallow-dataclasses). А в реляционных базах данных все не так просто. Придется немало поработать, чтобы объекты нашей предметной области не оказались представлены в моделях базы данных. На мой взгляд, достаточно мощным инструментом для достижения этой цели по-прежнему остается SQLAlchemy.

Учитывайте, что все становится гораздо сложнее, когда в дело вовлечено несколько баз данных. Кроме того, управление транзакциями – это сквозная функциональность, абстрагировать которую не так просто. Этой теме я посвятил немало материала в моей книге.

Прямой доступ к базе данных бывает безвредным, но обременительным​

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

Поэтому при считывании конечных точек совершенно нормально обращаться напрямую к базе данных. Но в долгосрочной перспективе прямое раскрытие доступа к вашей базе данных – это, пожалуй, не лучшее решение в долгосрочной перспективе. Попробуйте наложить представление поверх ваших таблиц или обеспечьте себе выход другим способом (есть, например, виды в РСУБД, прокси-модели в Django, т.д.).

Чистая архитектура – это не подход, в котором «все включено»​

Будем честны – системы строятся не так, как об этом пишут в книгах или рассказывают на конференциях. Это тернистый путь, на котором придется совершить немало ошибок и не раз заняться рефакторингом. Кроме того, в книгах тактично умалчивается, какого труда стоит изучить правила игры в той отрасли, с которой нам придется работать.

Какие проблемы с внедрением чистой архитектуры в Python существовали ранее, а теперь в основном решены?​

Валидация​

Лично у меня больше всего проблем возникало с валидацией. Теперь существует вышеупомянутый подход с Pydantic / dataclasses+marshmallow-dataclasses + mypy, благодаря которому многое упростилось. Кроме того, возьмите на вооружение паттерн «Value Object» - и можете считать, что хорошо подготовились на будущее.

Переусложненная инженерия​

Когда откроешь для себя возможность модуляризации и начнешь постепенно внедрять чистую архитектуру – риски значительно снижаются. Но все равно делайте домашнюю работу – рекомендую изучить стратегию предметно-ориентированного проектирования и такие техники, как ивент-сторминг (событийный штурм) – чтобы точнее оценивать, во что стоит дополнительно вкладываться.

Неидиоматичное использование фреймворка​

Хотя, философия Django не сильно изменилась с 2018 года, теперь в тренде новые (микро)фреймворки, например, Starlette или FastAPI. По гибкости они не уступают Flask, но при этом более мощные и современные. Все-таки, будьте осторожны при их использовании!

Например, наш контейнер для внедрения зависимостей должен быть достаточно универсален, чтобы его можно было использовать в любом контексте (консольное приложение, AND API для очереди задач)

Хватит слов – покажите мне код!​

Случаи / интеракторы​

Первым делом давайте идентифицируем и введем случаи (Use Сases) (AKA Интеракторы). У нас их будет по одному на каждое отдельное действие/команду актора. Актор – это лицо или другая система, взаимодействующие с нашим приложением. Как правило, актор – это просто пользователь.

Если мы попробуем изобразить клон meetup.com, то нам поднадобится:

  • Подтвердить, что участник будет на мероприятии
  • Отменить участие человека в мероприятии
  • Наметить новое собрание, будучи его организатором
В случае с клоном Trello это может быть:

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

  • Размещение предложения в качестве заявителя
  • Отзыв предложения в качестве администратора
  • Снятие товара с аукциона в качестве владельца товара
Итак, теперь мы в целом представляем, что пользователи могут делать в нашей системе, и можем выразить эти действия в коде как сущности первого класса:

class PlacingBid:
def __call__(self) -> None:
...

class WithdrawingBid:
def __call__(self) -> None:
...

class CancellingAuction:
def __call__(self) -> None:
...
Случаи / Интеракторы по определению будут обладать семантикой функции – их потребуется вызывать в представлении api / команде cli / фоновой задаче. Поэтому, почему бы не использовать здесь функции? Ведь внедрение зависимостей с функциями особенно не реализуешь. Кроме того, при работе с классами легко контролировать время жизни объектов.

Входные DTO (аргументы вариантов использования)​

В большинстве вариантов использования потребуется набор аргументов – например, идентифицировать пользователя, по чьей инициативе произошло действие, узнать, какой ресурс он хотел изменить, т.д. Их мы упаковываем в неизменяемые структуры данных при помощи классов. Неизменяемого @dataclass(frozen=True) вполне хватит, но вот как красиво это получится в Pydantic:

from decimal import Decimal
from pydantic import BaseModel, validator


class PlacingBidInputDto(BaseModel):
auction_id: int
bidder_id: int
amount: Decimal

@validator("amount")
def amount_must_be_positive(cls, v) -> None:
if v <= Decimal("0"):
raise ValueError("Amount must be positive")

class Config:
allow_mutation = False


class PlacingBid:
def __call__(self, dto: PlacingBidInputDto) -> None:
...
Входные объекты передачи данных (InputDTO) должны валидироваться. Либо это будет делаться с ними как таковыми (pydantic) или при помощи другого объекта (marshmallow-dataclasses), но в рамках варианта использования мы вправе не сомневаться в том, что у аргументов правильный тип. Никогда не знаешь, существует ли аукцион с заданным ID, пока не вызовешь Use Case, но необходимо гарантировать, что ID хотя бы внешне выглядят так, что могут принадлежать существующему объекту.

Объекты-значения​

Как вы заметили, в предыдущем примере я использовал встроенные типы для таких вещей как auction_id или bidder_id и amount. Это запах кода, и называется он одержимость примитивами. Особенно это касается поля amount – лучше было бы не играть здесь с десятичными числами, а создать (или использовать) выделенный тип Money, который гарантированно был бы валидным и неизменяемым. Этот паттерн называется Объект-Значение, он присутствует в стандартной библиотеке Python. Примеры использования — datetime.datetime, uuid.UUID или decimal.Decimal.

Интерфейсы / Порты​

Всякий раз, когда нам потребуется синхронно вызвать сторонний API (или другой компонент нашего модульного монолита), окажется, что хорошо бы его обернуть – по двум причинам:

  • Предотвратить загрязнение бизнес-логики второстепенными деталями и именами извне
  • Улучшить тестируемость – когда у вас есть абстракция, проще делать имитационные объекты/заглушки. Если мы используем TDD, то тоже гораздо красивее организуется совместная работа, с упором на имитационные объекты
Под капотом могут драконы водиться. Но, если рассмотреть случай и как в нем используется интерфейс/порт, то все должно показаться простым и приятным!

В 2021 году, как правило, было принято абстрагировать базовый класс (abc.ABC):

import abc


class Payments(abc.ABC):
@abc.abstractmethod
def init_payment(
self, bidder_id: int, amount: Money
) -> None:
pass


class ClosingAuction:
def __init__(self, payments: Payments) -> None:
self._payments = payments

def __call__(self, dto: ClosingAuctionInputDto) -> None:
self._payments.init_payment(
bidder_id=auction.winner_id,
amount=auction.price,
)
Помните, что ваш Порт – это не только список применяемых публичных методов. Если есть какие-либо исключения, которые вы хотите явно обрабатывать при данном варианте использования, то они должны быть определены вместе с Портом и при необходимости переупакованы в следующем строительном блоке.

Адаптер интерфейса / адаптер​

У каждого абстрактного класса будет как минимум одна реализация +заглушки/имитационные объекты, следующие за ней. Адаптер – это всего лишь реализация Порта:

class MadeUpCompanyPayments(Payments):
def init_payment(
self, bidder_id: int, amount: Money
) -> None:
# подтягиваем id соответствующего пользователя под заданный id предлагающего
# customer id – это идентификатор во внешней среде
# мы не хотим, чтобы он протек на аукционную платформу
...
# затем мы можем взять у клиента запрошенную сумму ИЛИ
# попросить его, чтобы он выполнил онлайн-платеж


closing_auction = ClosingAuction(MadeUpCompanyPayments())
Помните, что класс Use Case обязан знать, какую реализацию Payments он использует – поэтому для абстракции аннотируется тип.

Внедрение зависимости​

Надеюсь, взгляд у вас зацепился за последнюю строку – ClosingAuction(MadeUpCompanyPayments()) – сборка объектов вручную - то еще удовольствие. Чтобы упростить здесь себе жизнь, можно воспользоваться injector – приятной библиотекой, смоделированной по образцу Guice от Java.

import injector


class Auctions(injector.Module):
@injector.provider
def closing_auction(self, payments: Payments) -> ClosingAuction:
return ClosingAuction(payments)


class AuctionsInfrastructure(injector.Module):
@injector.provider
def payments(self) -> Payments:
return MadeUpCompanyPayments()


container = injector.Injector([Auctions(), AuctionsInfrastructure()])
# в представлении / команде CLI / фоновой задаче
closing_auction = container.get(ClosingAuction)
Все, что от вас требуется – определить рецепты для всех зависимостей, и injector соберет их за вас.

Раньше я пользовался библиотекой inject. Самый серьезный ее недостаток – в том, что она полагается на глобальное состояние, а из-за этого во время тестирования иногда хочется плакать.

Помните, что лучше не стоит использовать container напрямую. Это может привести к антипаттерну, который называется «локатор сервисов». В pypi есть пакеты, в которые интегрирован flask_injector. Если в вашем фреймворке ничего такого не предоставляется, то попробуйте написать интеграцию сами и не стесняйтесь поделиться с сообществом тем, что у вас получится.

Еще одна внятная альтернатива для injector называется dependencies. Я ею не пользовался, но выглядит основательно. Кстати, вся система «fixtures» в pytest – это один большой контейнер для внедрения зависимостей.

Сущности​

Тогда как Случаи отвечают за оркестрацию контроля управления, работая вместе с Интерфейсами (Портами), нам все равно еще нужны объекты предметной области – представляющие концепции, о которых мы можем говорить с заказчиками или пользователями. Так, на аукционной платформе у нас будут классы Auction и Bid.

Теперь подход “все включено” предполагает, что мы будем писать классы на чистом или почти чистом Python:

@dataclass
class Bid:
_id: int
_bidder_id: int
_amount: Money


@dataclass
class Auction:
_id: int
_initial_price: Money
_bids: List[Bid]

def place_bid(self, bidder_id: int, amount: Money) -> None:
...
Обратите внимание, что все поля являются приватными. Сфокусируемся на поведении Сущностей (методы!), когда будем их моделировать, а не на данных, которые будут выводиться пользователю. Если инкапсуляция – нечто незнакомое для вас, почитайте мою статью о ней.

Теперь нам нужен способ хранить и загружать Сущности из долговременного хранилища (какого именно – PostgreSQL/MongoDB – не важно). Популярный способ (которым пользовался и я) – применить для этого паттерн «персистентно-ориентированный репозиторий»:

class AuctionsRepository(abc.ABC):
@abc.abstractmethod
def get(self, auction_id: int) -> Auction:
...

@abc.abstractmethod
def save(self, auction: Auction) -> None:
...
С ним работаем точно как с портами – используем совместно с Вариантами Использования. Конкретные репозитории должны переупаковывать данные из определенных Сущностей в:

  • Модели с SQLAlchemy и сбрасывать изменения
  • Словари, напр., pymongo с последующим сохранением
Можете себе представить, насколько утомительной может быть реализация конкретных репозиториев. В данном случае целесообразнее использовать тактические паттерны предметно-ориентированного проектирования и моделировать Агрегаты в виде небольших самодостаточных элементов вокруг инвариантов бизнес-логики, которые всегда должны быть согласованными с чем-то. Таков случай с Auction – он обязан согласовываться со своими Bids. Также можно отметить, что в некоторых Случаях может потребоваться загрузить все Bids (отзыв предложения) тогда как в других случаях (размещение предложения) – лишь подмножество (т.e. выигравшие) Предложения нужны, чтобы выполнилась логика. Паттерн Репозиторий помогает справляться с этими тонкостями и управлять ими.

НО, что, если объекты нашей предметной области окажутся проще? Почему бы в таком случае не использовать в качестве объектов предметной области модели базы данных? Раньше я был против такого непотребства. Но в одном из моих проектов Сущности оказались просты. Хотя, там было совершенно оправданно применить Случаи и Порты / Адаптеры, такие вложения в Сущности и Репозитории особенно не окупались. Я счел, что здесь будет вполне достаточно реализации паттерна Единичная Задача из SQLAlchemy (это Сеанс), без скрывания каких-либо технических деталей.

Здесь я не призываю вас отбросить Правило Зависимостей и писать SQL в ваших Случаях, но задумайтесь – а не решит ли вашу задачу сравнительно легковесный подход. Чтобы понять, что именно вам нужно, лучше всего почитать об Агрегатах и Тактическом предметно-ориентированном проектировании. В качестве краткого и основательного введения в эти темы рекомендую книгу Вона Вернона «Предметно-ориентированное проектирование».

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

Заключение​

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

Просто здорово, что в сообществе Python создается такое множество классных инструментов, помогающих писать на Python программы, которые отвечают требованиям большого предприятия.

 
Сверху