Python и чистая архитектура…

Всем привет! Сегодня я хочу поделиться своими опытом разработки на различных языках программирования и размышлениями касаемо проектирования серверных приложений. Речь пойдет про много обсуждаемую в последнее время чистую архитектуру в рамках языка python. Казалось бы, по заветам Роберта Мартина мы не должны зависеть от инструмента (зачастую под этим понимают фреймворк или библиотеку), однако это порождает множество ошибок и просто небольших неточностей в проектировании сервисов и даже выборе языка программирования.

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

Оригинал: https://crosp.net/wp-content/uploads/2017/07/ArchitectureEvolutionDiagram-1600x900.jpg

На рисунке выше изображено развитие архитектурных паттернов, практик организации кода и сервисов. Заострим наше внимание на вершину эволюции, где располагаются DDD и Clean (architecture).

DDD — Domain-Driven Design (Предметно-ориентированное программирование). По большому счету это набор правил или даже рекомендаций по организации кода относительно области проблематики. Для более лучшего понимания можно почитать тематическую книгу или ознакомиться со статьей по ссылке: https://habr.com/ru/companies/oleg-bunin/articles/551428/

Чистая архитектура — приплетаемая к DDD тема, однако затрагивающая гексагональную архитектуру и принципы SOLID. Сформулировал и описал их Роберт Мартин в своей книге — «Чистая архитектура»

Казалось бы, ну пускай у нас есть множество странноватых определений, прочитаем, поймем и сделаем. Однако на практике многие опускают контекст и они начинают просто брать, писать все на классах, делать интерфейсы (криво выглядящие абстрактные классы в случае python), внедрять объекты друг в друга ииии… получается java, лишенная своей строгости или python, лишенный гибкости.

Немного истории

В те стародавние времена, когда программисты в прошлом скорее инженеры и математики болью и кровью писали сначала скрипты, а потом уже и большие монолиты, потихоньку идя к сервисно-ориентированным архитектурам, появился он — Python. Разработанный Гвидо ван Россумом интерпретируемый зверь понравился программистам и начал набирать популярность. Немногим позже, появилась уйма библиотек и даже фреймворков, которые помогают нашему красавцу решить практически любую задачу и по сей день.

Но не долго музыка играла, ведь когда мы начнем писать что-то серьезное и масштабное, к нам стучится в дверь низкая скорость работы, дорогая, болезненная масштабируемость и спагетти код. В этой связи в начале нашего тысячелетия для более-менее структурированных и требовательных проектов выбирались языки со статической типизацией и более ООП-шной природой, такие как Java и компания. И даже здесь разработчики были вынуждены набить много шишек ведь одной типизации, достаточной скорости работы и разбиения на классы не решишь данные проблемные вопросы. Тогда и появились и DDD, и SOLID и различные архитектуры, позволяющие уменьшить связность, упростить масштабируемость и во многом сказать нет спагетти коду.

А что python? Пока взрослые дяди игрались с разбиением логики на слои и придумывали заумные определения, наш змей был свободен и писался код, как нашей душеньке угодно.

Шло время, мощь python росла, как и мощь его библиотек/фреймворков. Росло и наше желание писать что-то большое и крутое. Наличие ООП никто не отменял, поэтому сообщество начало активно продвигать свои идеи и разработчики python не смогли долго сопротивляться.

Поехали:

И теперь мы живём в мире, где в даже маленький проект разработчики начинают пихать типы, классы называя все это ООП и чистая архитектура…

Python не про чистую архитектуру?

Из описанного выше можно предположить, что я против java-фишек в питоне. Где-то это предположение будет верным. Один из моментов заключается в смешивании понятий ООП и чистая архитектура, а это далеко не одно и то же и так делать нельзя. Посмотрим на схему гексоганальной архитектуры:

Domain-driver hexagon

Domain-driver hexagon

Здесь мы можем увидеть все что угодно, кроме ООП. Как так, спросите вы? Мы на каждый слой создадим класс, будет их объекты внедрять друг в друга, объединим их предметно в компоненты, сделаем внедрение через интерфейс и все по ООП, DDD и всему всему! Но, нет)

В объектно-ориентированном программировании сущности и сценарии использования располагались бы в одном слое, в одном классе. Он содержал бы поведение и состояние. Если похожих объектов много, мы бы реализовали абстрактный класс, чтобы задать общие свойства (также можно реализовать несколько общих методов) и создали бы один или несколько интерфейсов, чтобы задать поведение. Возьмем простой пример с животными:

from abc import ABC
from typing import Protocol


# Тип животного
class TypeAnimal:
    name_type: str

    def __init__(self, name_type):
        self.name_type = name_type


# Не совсем интерфейс, но об этом попозже
class AnimalInterface(Protocol):
    def move(self):
        pass

    def make_sound(self):
        pass


# Свойства животного
class Animal(ABC, AnimalInterface):
    weight: int
    type_animal: TypeAnimal


class Cat(Animal):
    def __init__(self, weight: int, type_animal: TypeAnimal):
        self.weight = weight
        self.type_animal = type_animal

    def move(self):
        print("Cat do step)")

    def make_sound(self):
        print("Meow")


class Dog(Animal):
    def __init__(self, weight: int, type_animal: TypeAnimal):
        self.weight = weight
        self.type_animal = type_animal

    def move(self):
        print("Dog do step)")

    def make_sound(self):
        print("Woof")


if __name__ == "__main__":
    type_animal: TypeAnimal = TypeAnimal("mammalian")
    
    # Пока без адаптеров)
    animals: list[Animal] = [Cat(5, type_animal), Dog(10, type_animal)]
    for animal in animals:
        animal.move()
        animal.make_sound

И да, даже в нашей самой ООП-шной джаве так уже никто не пишет (почти), не то что в других языках! Сейчас мы разделяем поведение и состояние, чтобы добавить абстракции, ведь деловая (бизнес) логика не должна зависеть от реализации. Если код выше мы приблизим к чистой архитектуре, то получим:

from abc import ABC
from typing import Protocol


class TypeAnimal:
    name_type: str

    def __init__(self, name_type):
        self.name_type = name_type


# Не совсем интерфейс, об этом попозже
class AnimalService(Protocol):
    def move(self):
        pass

    def make_sound(self):
        pass


class Animal(ABC):
    weight: int
    type_animal: TypeAnimal


class Cat(Animal):
    def __init__(self, weight: int, type_animal: TypeAnimal):
        self.weight = weight
        self.type_animal = type_animal


class Dog(Animal):
    def __init__(self, weight: int, type_animal: TypeAnimal):
        self.weight = weight
        self.type_animal = type_animal


class CatServiceImpl(AnimalService):
    def __init__(self, cat: Cat):
        self.cat = cat

    def move(self):
        print("Cat do step")

    def make_sound(self):
        print("Meow")


class DogServiceImpl(AnimalService):
    def __init__(self, dog: Dog):
        self.dog = dog

    def move(self):
        print("Dog do step)")

    def make_sound(self):
        print("Woof")


if __name__ == "__main__":
    type_animal: TypeAnimal = TypeAnimal("mammalian")

    # Вместо адаптеров)
    animal_services: list[AnimalService] = [
        CatServiceImpl(Cat(5, type_animal)),
        DogServiceImpl(Dog(10, type_animal)),
    ]
    for animal_serv in animal_services:
        animal_serv.move()
        animal_serv.make_sound()

Мы вынесли поведение в отдельный сервисный класс и получили меньшую зависимость поведения от состояния! Останется лишь разбить сервисный класс на работу с деловой логикой и базовой (сохранение в бд, например), добавить слой адаптеров (контроллеров), разложить наши классы по папочкам и будет все вообще по SOLID и чистой архитектуре, но не по python-style.

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

Динамическая архитектура

Не уверен, что такой термин не был уже кем-то использован) В случае python мы имеем объекты везде: функции — объект, модули — объект и т.д. Также мы имеем встроенные синглтоны, что позволяет нам вообще отказаться от классов для stateless слоев. Об этом хорошо написано здесь.

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

А как же внедрять зависимости? — спросите вы меня. Все очень просто. Наш старый добрый импорт это уже и есть базовое внедрение, которое неявно происходит. Ниже цитата из книги «Чистая архитектура» Роберта Мартина стр. 98:

В языках с динамической системой типов, таких как Ruby или Python, подобные абстрактные компоненты вообще отсутствуют, так же как зависимости, которые можно было бы нацелить на них. Структура зависимостей в этих языках намного проще, потому что для инверсии зависимостей не требуется объявлять или наследовать интерфейсы.

Но если мы коснемся DDD, то там у нас есть типы и как-то с ними нужно работать. Здесь нам помогают протоколы.

Структурная типизация, закрепленная появлением Protocol, в питоне добавила возможность внедрить прослойку между модулями.

Практика

Доверяй, но проверяй, как говорится. Пора показать все на примере.

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

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

В данном случае можно определить четыре основные сущности:

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

from dataclasses import dataclass
from datetime import datetime


@dataclass
class User:
    id: int
    name: str
    contact_phone: str


@dataclass
class Seller:
    id: int
    name: str
    contact_phone: str
    address: str


@dataclass
class Product:
    id: int
    name: str
    type_product: str
    description: str
    guarantee_month: int
    price: float
    isSaled: bool


@dataclass
class SaleHistory:
    id: int
    user_id: int
    seller_id: int
    product_id: int
    price: float
    date_sale: datetime

Для реализации сервиса продаж нам нужно создать crud-интерфейсы только для сущностей продукта и истории продуктов (предполагается, что данный сервис получает данные о пользователе и продавце у другого сервиса)

from typing import Protocol
from typing import Generic

from model import SaleHistory
from util.generics import T


class ReadableRepository(Protocol, Generic[T]):
    def fetch_by_id(self, id: int) -> T:
        pass

    def fetch_all(self) -> list[T]:
        pass


class CrudRepository(ReadableRepository, Generic[T], Protocol):
    def create(self, entity: T) -> T:
        pass

    def update(self, entity: T) -> T:
        pass

    def delete(self, entity: T) -> None:
        pass

    def delete_by_id(self, id: int) -> None:
        pass


class SaleRepository(CrudRepository[SaleHistory], Protocol):
    def fetch_by_key(
        self, user_id: int, seller_id: int, product_id: int
    ) -> SaleHistory:
        pass

Для репозиториев можно задать начальный общий базовый протокол, так как crud-ы поначалу будут стандартные. Разделим crud-ы чтение и редактирования в разные протоколы по принципы разделения интерфейсов.

Для реализации сервиса продаж нам нужно создать crud-интерфейсы только для сущностей продукта и истории продуктов (предполагается, что данный сервис получает данные о пользователе и продавце у другого сервиса)

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

from typing import Protocol, Generic
from model import SaleHistory
from util.generics import T


class ReadableService(Protocol, Generic[T]):
    def get_by_id(self, id: int) -> T:
        pass

    def get_all(self) -> list[T]:
        pass


class CrudService(ReadableService, Generic[T], Protocol):
    def create(self, entity: T) -> T:
        pass

    def update(self, entity: T) -> T:
        pass

    def delete(self, entity: T) -> None:
        pass

    def delete_by_id(self, id: int) -> None:
        pass


class SaleService(Protocol):
    def get_record_sale(self, user_id: int, seller_id: int, product_id: int):
        pass

    # todo: добавить траназакционность
    def sell(self, user_id: int, seller_id: int, product_id: int) -> SaleHistory:
        pass

    def rollback_product(self, sale: SaleHistory) -> None:
        pass

Базовый crud для сервиса также разбиваем на несколько протоколов и добавляем сервис продаж с тремя методами.

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

Что ж, приступим к написанию кода:

from model import SaleHistory, Product
from service.protocol_service import CrudService
from service import product_service
from repository.protocol_repository import SaleRepository
from repository import sale_repository
from datetime import datetime

# Определяем зависимости и проверяем их тип по структуре
_product_serv: CrudService[Product] = product_service
_sale_repo: SaleRepository = sale_repository
# Здесь также можно внедрить сервис оплаты и т.д.


# Получить информацию о продаже
def get_record_sale(user_id: int, seller_id: int, product_id: int):
    return _sale_repo.fetch_by_key(user_id, seller_id, product_id)


# todo: добавить транзакционность
def sell(user_id: int, seller_id: int, product_id: int) -> SaleHistory:
    product: Product = _product_serv.get_by_id(product_id)

    # Запросить баланс пользователя и списать деньги
    # Можно упросить и сразу попытаться списать))

    product.isSaled = True
    _product_serv.update(product)
    return _sale_repo.create(
        SaleHistory(user_id, seller_id, product_id, product.price, datetime.now())
    )


# todo: добавить транзакционность
def rollback_product(sale: SaleHistory) -> None:
    product: Product = _product_serv.get_by_id(sale.product_id)
    product.isSaled = False
    _product_serv.delete(product)

Наш функционал поместился в три метода. Два из них требуют добавления методов (интеграции) списания денег и транзакционности. Первое — скорее всего будет использоваться стороннее апи (или другой наш сервис), второе — решается использованием библиотек, да и в целом выбором хранилища.

Главное, что данный код использует абстракцию при вызове методов из других слоев!

Инверсия зависимости при помощи Protocol

Инверсия зависимости при помощи Protocol

Это достигается благодаря утиной (структурной) типизации. Мы используем протоколы, подобно интерфейсам в других языках без необходимости явно указывать реализацию. Модули — это по сути наши синглтон объекты и уже при импорте у нас производится своего рода инъекция. В строке 8 производится смещение связи на абстракцию, чтобы проверить структуру модуля. Чтобы сделать тоже самое на классах нужно писать конструктор, также писать присвоение и один фиг также где-то можно ошибиться и использовать модуль, вместо переменной класса (и про self можно забыть иногда). А чтобы не ошибиться, нужно отказываться от синглтона и создавать на каждый сервисный объект новые объекты в зависимости или использовать какую-либо библиотеку. Зачем делать такое усложнение, если python уже предоставил нам нужную базу?!

Также для типизированный модулей, я ставлю нижнее подчеркивание. Это позволяет уменьшить путаницу с импортированным сервисов, а также скрывает переменную от from service import *.

А зачем так сложно?

Если разбиение на слои адаптер, сервис, репозиторий, сущность (модель) вопросов может не возникать, однако использование протоколов вместо интерфейсов вызывает вопросы.

Для создания протокола нужно создать класс, получается еще один уровень прослойки и его нужно писать и для адаптеров и для сервисов и репозиториев.

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

Да, я соглашусь, что использования классов для stateless слоев имеет преимущество явного наследования тех же протоколов. Также есть преимущество в переиспользовании готовых классов опять же путем наследования. Но стоит помнить, что наследование часто подается критике и зачастую рекомендуют использовать вместо него ту же инъекцию.

К заключению

Знаю, что многие проекты пишутся полностью на классах, только из-за того, что так пишут в java). Однако выше мы рассмотрели примеры, где ту же инверсию зависимости можно реализовать и в модульной архитектуре при помощи Protocol. Разбивка моделей по папкам позволяет реализовывать модели предметной области. Все принципы Solid можно применить при помощи модульной структуры и протоколов (вместо интерфейсов).

Но я также замечу, что написание stateless классов нам позволяет использовать наследование тех же протоколов, ведь при этом наследуется и документация к методам).

Также хочется заметить, что соблюдать требованиям того же SOLID выходит далеко не всегда просто используя один python. Как минимум нужен статический анализатор типов, аля mypy, тщательная настройка среды разработки и строгие договоренности. В этом плане та же джава выглядит даже сейчас может быть предпочтительнее в ряде случаев.

Надеюсь в будущем помимо доработки дженериков в питоне добавят способ реализации протокола модулем. Однако даже в таком случае пришлось бы использовать переприсваивание для ссылки на абстракцию, если только не типизировать импорт. Но это уже что-то меня понесло.

А как пишите вы и какие принципы чистой архитектуры уже успели внедрить в ваших проектах?

Буду рад почитать комментарии и готов к конструктивной критике, ведь тема на самом деле дискуссионная.

Спасибо всем за внимание.

И если тебе не хватило части примера без использования библиотек и хотелось бы увидеть полную реализацию сервиса, могу тебя обрадовать) В ближайшее время будет выпущена вторая часть статьи, где будет добавлен слой адаптеров, где будет использоваться FastApi, подкрутим ORM и рассмотрим случае, где нам может пригодиться гексагональная архитектура. Подписывайся, коль интересно :-)

© Habrahabr.ru