Привет! Меня зовут Евгений, я Python-разработчик. Последние полтора года наша команда стала активно применять принципы Clean Architecture, уходя от классической модели MVC. И сегодня я расскажу о том, как мы к этому пришли, что нам это дает, и почему прямой перенос подходов из других ЯП не всегда является хорошим решением.



Python является моим основным инструментом разработки уже более семи лет. Когда у меня спрашивают, что мне больше всего в нем нравится, я отвечаю, что это его великолепная читаемость. Первое знакомство началось с прочтения книги «Программируем коллективный разум». Меня интересовали алгоритмы, изложенные в ней, но все примеры были на еще не знакомом мне тогда языке. Это было не обычно (Python тогда еще не был мейнстримом в машинном обучении), листинги чаще писались на псевдокоде или с использованием диаграмм. Но после быстрого введения в язык, я по достоинству оценил его лаконичность: все было легко и понятно, ничего лишнего и отвлекающего внимание, только самая суть описываемого процесса. Основная заслуга этого — удивительный дизайн языка, тот самый интуитивно понятный синтаксический сахар. Эта выразительность всегда ценилась в сообществе. Чего только стоит «import this», обязательно присутствующий на первых страницах любого учебника: он как невидимый надзирающий смотрит, постоянно оценивает твои действия. На форумах стоило новичку использовать как-нибудь CamelCase в названии переменной в листинге, так сразу ракурс обсуждения смещался в сторону идиоматичности предложенного кода с отсылками на PEP8.
Стремление к изящности плюс мощная динамичность языка позволили создать множество библиотек с по-настоящему восхитительным API. 

Тем не менее, Python, хоть и мощный, но всего лишь инструмент, который позволяет писать выразительный самодокументируемый код, но не гарантирует этого, как не гарантирует этого и соблюдение PEP8. Когда наш, казалось бы, простой интернет-магазин на Django начинает приносить деньги и, как следствие, накачиваться фичами, в один прекрасный момент мы понимаем, что он не такой уж и простой, а внесение даже элементарных изменений требует все больших и больших усилий, а главное, что эта тенденция все нарастает. Что случилось, и когда все пошло не так?

Плохой код


Плохой код это не тот, который не следует PEP8 или не отвечает требованиям цикломатической сложности. Плохой код это, в первую очередь, неконтролируемые зависимости, которые приводят к тому, что изменение в одном месте программы приводит к непредсказуемым изменениям в других частях. Мы теряем контроль над кодом, расширение функционала требует детального изучения проекта. Такой код теряет гибкость и как бы сопротивляется внесению изменений, сама же программа становится «хрупкой». 

Чистая архитектура


Избежать данной проблемы должна выбранная архитектура приложения, и мы не первые, кто с этим столкнулся: в сообществе Java давно идет дискуссия о создании оптимальной конструкции приложения.

Еще в 2000-ом году Роберт Мартин (так же известный как Дядюшка Боб) в статье «Принципы дизайна и проектирования» собрал воедино пять принципов проектирования ООП приложений под запоминающейся аббревиатурой SOLID. Данные принципы были хорошо приняты сообществом и вышли далеко за рамки экосистемы Java. Тем не менее они носят весьма абстрактный характер. Позже было несколько попыток выработать общий дизайн приложения, базирующийся на SOLID-принципах. К ним относится: «Гексагональная архитектура», «Порты и адаптеры», «Луковичная архитектура» и у всех у них много общего хоть и разные детали реализации. А в 2012 году вышла статья того же Роберта Мартина, где он предложил свой вариант под названием «Чистая архитектура».



По версии Дядюшки Боба, архитектура — это в первую очередь «границы и барьеры», необходимо четко понимать потребности и ограничивать программные интерфейсы для того, чтобы не потерять контроль за приложением. Для этого программа делится на слои. Обращаясь из одного слоя к другому, можно передавать только данные (в качестве данных могут выступать простые структуры и DTO объекты) — это правило границ. Еще одна наиболее часто цитируемая фраза, о том, что «приложение должно кричать» — означает, что главным в приложении является не используемый фреймворк или технология хранения данных, а то, что собственно это приложение делает, какую функцию оно выполняет — бизнес-логика приложения. Поэтому слои имеют не линейную структуру, а обладают иерархией. Отсюда еще два правила:

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

Последнее правило достаточно нетипично в мире Python. Для применения сколько-нибудь сложного сценария бизнес-логики всегда нужно обращаться к внешним сервисам (например, БД), но, чтобы избежать этой зависимости, слой бизнес-логики должен сам объявить интерфейс, по которому он будет взаимодействовать с внешним миром. Этот прием называется «инверсией зависимостей» (буква D в SOLID) и широко распространен в языках со статической типизацией. По мнению Роберта Мартина, это основное преимущество, появившееся благодаря ООП.

Эти три правила и есть суть Clean Architecture:

  • Правило пересечения границ;
  • Правило зависимостей;
  • Правило приоритета внутреннего слоя.

К преимуществам данного подхода относится:

  • Простота тестирования — слои изолированы, соответственно, их можно тестировать без monkey-patching, можно гранулярно устанавливать покрытие для разных слоев, в зависимости от степени их важности;
  • Простота изменения бизнес-правил, так как все они собраны в одном месте, не размазаны по проекту и не перемешаны с низкоуровневым кодом;
  • Независимость от внешних агентов: наличие абстракций между бизнес-логикой и  внешним миром в определенных случаях позволяет менять внешние источники, не затрагивая внутренние слои. Работает, если вы не завязали бизнес-логику на специфические особенности внешних агентов, например, транзакции БД;
  • Улучшение восприятия, несмотря на то, что код размазывается на слои, высокоуровневый код не перемешивается с низкоуровневым.

Роберт Мартин рассматривает предложенную им схему, состоящую из четырех слоев. В рамках данной статьи я не стану еще раз ее разбирать. Переадресую лишь заинтересовавшихся к оригинальной статье, а также к разбору на хабре. Также рекомендую отличную статью «Заблуждения Clean Architecture».

Реализация на Python


Это теория, примеры же практического применения можно найти в оригинальной статье, докладах и книге Роберта Мартина. Они опираются на несколько распространенных шаблонов проектирования из мира Java: Adapter, Gateway, Interactor, Fasade, Repository, DTO и др.

Ну а что же Python? Как я уже говорил, в Python-сообществе ценится лаконичность.То, что прижилось у других, далеко не факт, что приживется у нас. Первый раз я обратился к  данной теме три года назад, тогда материалов по теме использования Clean Architecture в Python встречалось не много, но первой же ссылкой в Гугле был проект Леонардо Джордани: автор подробно расписывает процесс создания API для сайта по поиску недвижимости по методике TDD, применяя Clean Architecture.
К сожалению, несмотря на скрупулезное объяснение и следование всем канонам Дядюшки Боба, данный пример скорее отпугивает

API проекта состоит из одного метода — получения списка с доступным фильтром. Думаю, даже для начинающего разработчика код такого проекта займет не более 15 строк. Но в данном случае он занял шесть пакетов. Можно сослаться на не совсем удачную компоновку, и это действительно так, но в любом случае, сложно кому-то объяснить эффективность данного подхода, ссылаясь на этот проект.

Есть и более серьезная проблема, если не читать статью, а сразу начать изучать проект, то в нем достаточно сложно разобраться. Рассмотрим реализацию бизнес-логики:

from rentomatic.response_objects import response_objects as res

class RoomListUseCase(object):
   def __init__(self, repo):
       self.repo = repo
   def execute(self, request_object):
       if not request_object:
           return res.ResponseFailure.build_from_invalid_request_object(
               request_object)
       try:
           rooms = self.repo.list(filters=request_object.filters)
           return res.ResponseSuccess(rooms)
       except Exception as exc:
           return res.ResponseFailure.build_system_error(
               "{}: {}".format(exc.__class__.__name__, "{}".format(exc)))

Класс RoomListUseCase, реализующий бизнес-логику (не очень похоже на бизнес-логику, правда?) проекта, инициализируется объектом repo. Но что такое repo? Конечно, из контекста мы можем понять, что repo реализует шаблон Repository для доступа к данным, если посмотрим тело RoomListUseCase, то поймем, что он должен иметь один метод list, на вход которого подается список фильтров, что на выходе — не понятно, нужно смотреть в ResponseSuccess. А если сценарий будет более сложный, с множественным обращением к источнику данных? Получается, чтобы понять, что такое repo, можно только обратившись к реализации. Но где она находится? Она лежит в отдельном модуле, который никак не связан с RoomListUseCase. Таким образом, чтобы понять, что происходит, нужно подняться на верхний уровень (уровень фреймворка) и посмотреть, что же подается на вход класса при создании объекта.

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

В данном примере интерфейс repo является неотъемлемой частью интерфейса RoomListUseCase, как и метод execute — так работает инверсия зависимостей. Фактически мы можем распространять пакет с бизнес-логикой отдельно от самого приложения, так как он, не имеет зависимостей внутри проекта. Когда работаем со слоем бизнес-логики мы не обязаны помнить о существовании других слоев. Но, чтобы его использовать, необходимо реализовать нужные интерфейсы, а repo один из них.

В общем, в тот раз я отказался от Clean Architecture в новом проекте, опять применив  классический MVC. Но, набив очередную порцию шишек, вернулся к этой идее через год, когда, мы наконец, стали запускать сервисы на Python 3.5+. Как известно, он принес аннотации типов и дата-классы: два мощных инструмента описания интерфейсов. Опираясь на них, я набросал прототип сервиса, и результат уже был намного лучше: слои перестали рассыпаться, не смотря на то, что кода все еще было много, особенно при интеграции с фреймворком. Но этого было достаточно, чтобы начать применять данный подход в небольших проектах. Постепенно начали появляться фреймворки, ориентированные на максимальное использование аннотации типов: apistar (сейчас starlette), moltenframework. Сейчас распространена связка pydantic/FastAPI, и интеграция с такими фреймворками стала намного проще. Вот так бы выглядел вышеуказанный пример restomatic/services.py:

from typing import Optional, List
from pydantic import BaseModel

class Room(BaseModel):
   code: str
   size: int
   price: int
   latitude: float
   longitude: float

class RoomFilter(BaseModel):
   code: Optional[str] = None
   price_min: Optional[int] = None
   price_max: Optional[int] = None

class RoomStorage:
   def get_rooms(self, filters: RoomFilter) -> List[Room]: ...

class RoomListUseCase:
   def __init__(self, repo: RoomStorage):
       self.repo = repo
   def show_rooms(self, filters: RoomFilter) -> List[Room]:
       rooms = self.repo.get_rooms(filters=filters)
       return rooms

RoomListUseCase — класс, реализующий бизнес-логику проекта. Не стоит обращать внимание на то, что все, что делает метод show_rooms это обращение к RoomStorage (данный пример придумал не я). В реальной жизни здесь также может быть расчет скидки, ранжирование списка на основе рекламных объявлений и т.д. Тем не менее модуль является самодостаточным. Если мы захотим воспользоваться данным сценарием в другом проекте, нам придется реализовать RoomStorage. И что для этого нужно, здесь отлично видно прямо из модуля. В отличие от прошлого примера, такой слой является самодостаточным, и при изменении не обязательно держать в голове весь контекст. Из несистемных зависимостей только pydantic, почему, станет понятно в модуле подключения фреймворка. Отсутствие зависимостей, еще один способ повышения читаемости кода, не дополнительного контекста, даже начинающий разработчик сможет понять, что делает данный модуль.

Сценарий бизнес-логики не обязательно должен быть классом, ниже пример аналогичного сценария в виде функции:

def rool_list_use_case(filters: RoomFilter, repo: RoomStorage) -> List[Room]:
   rooms = repo.get_rooms(filters=filters)
   return rooms


А вот как выглядит подключение к фреймворку:

from typing import List
from fastapi import FastAPI, Depends
from rentomatic import services, adapters
app = FastAPI()

def get_use_case() -> services.RoomListUseCase:
   return services.RoomListUseCase(adapters.MemoryStorage())

@app.post("/rooms", response_model=List[services.Room])
def rooms(filters: services.RoomFilter, use_case=Depends(get_use_case)):
   return use_case.show_rooms(filters)

С помощью функции get_use_case в FastAPI реализуется паттерн Dependency Injection. Нам не нужно заботиться о сериализации данных, всю работу выполняет FastAPI в связке с pydantic. К сожалению, не всегда формат данных бизнес-логики подходит для прямой трансляции в рест< и, наоборот, бизнес-логика не должна знать, откуда пришли данные — с урла, тела запроса, кук и т.д. В этом случае в теле функции room будет некое преобразование входных и выходных данных, но в большинстве случаев, если мы работаем с API, достаточно такой легкой прокси-функции. 

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

Я намеренно не стал разделять слой бизнес-логики, как предполагает каноническая модель Clean Architecture. Класс Room должен был оказаться в слое доменной области Entity представляющем доменную область, но для данного примера в этом нет никакой необходимости. От объединения слоев Entity и UseCase проект не перестает быть реализацией Clean Architecture. Сам Роберт Мартин не раз говорил, что количество слоев может меняться как в большую, так и меньшую сторону. При этом проект отвечает основным критериям Clean Architecture:

  • Правило пересечения границ: границы пересекают pydantic модели, являющиеся по сути DTO;
  • Правило зависимостей: слой бизнес-логики не зависит от других слоев;
  • Правило приоритета внутреннего слоя: именно слой бизнес-логики определяет интерфейс (RoomStorage), по которому осуществляется взаимодействие бизнес-логики с внешним миром.

Сегодня уже несколько проектов нашей команды, реализованных с применением описываемого подхода, работают на проде. Я стараюсь организовывать таким образом даже самые маленькие сервисы. Это неплохо тренирует — всплывают вопросы, о которых раньше не думал. Например, что тут является бизнес-логикой? Это далеко не всегда очевидно, например, если вы пишите какую-нибудь проксю. Другой важный момент — это научиться мыслить по-другому. Получая задачу, мы, как правило, начинаем думать о фреймворках, используемых сервисах, о том, понадобится ли тут очередь, где лучше хранить эти данные, что можно закешировать. В подходе, диктующим Clean Architecture, мы должны в первую очередь реализовать бизнес-логику и только потом переходить к реализации взаимодействия с инфраструктурой, так как, по мнению Роберта Мартина, основная задача архитектуры — как можно дальше отсрочить момент, когда связь с каким-либо инфраструктурным слоем будет неотъемлемой частью вашего приложения.

В целом, мне видится благоприятной перспектива применения Clean Architecture в Python. Но форма, скорее всего, будет существенно отличаться от того, как это принято в других ЯП. Последние несколько лет я наблюдаю существенный рост интереса к теме архитектуры в сообществе. Так, на последнем PyCon было несколько докладов о применении DDD, и отдельно стоит отметить ребят из dry-labs. В нашей компании уже многие команды реализуют в той или иной степени описанный подход. Мы все занимаемся одним и тем же делом, мы выросли, выросли и наши проекты, сообществу Python предстоит с этим работать, определить общий стиль и язык которым, например, когда-то стал для всех Django.