В этой статье разберем как заложить «чистую архитектуру» в fast api проект. Несмотря на то, что мы используем fast api, данный подход можно применять при использовании любого другого фреймворка.

Что такое «чистая архитектура»

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

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

  • Возможность изолированного тестирования различных слоев приложения.

  • Независимость от реализации пользовательского интерфейса.

  • Изолированность бизнес‑логики от других слоев приложения.

Для достижения разделения ответственности используется правило Зависимостей.

Данную концепцию предложил Роберт Мартин в 2012 г в статье «The Clean Architecture». Ниже приведена оригинальная схема из данной статьи:

Основное правило при таком архитектурном подходе гласит: Зависимости могут быть направлены только внутрь. Таким образом каждый внутренний круг системы ничего не знает о внешнем. Круг состоит из 4 слоев. Внешний круг — это БД и фреймворки. Данный слой, как правило не содержит много кода, и никаким образом не должен влиять на имплементацию бизнес‑логики. Далее идет слой интерфейсов‑адаптеров, он отвечает за преобразование данных в более удобный формат для исполнения сценариев. Он выступает «мостом» между внешним слоем и слоем бизнес‑логики приложения. Следующий слой Сценарии предназначен для имплементации бизнес‑логики. Он инкапсулирует все случаи использования системы. Изменения во внешних слоях не должны затрагивать слой сценариев. Мы также ожидаем, что изменения в в этом слое не отразиться на слое Сущностей. Слой сущности это минимальная единица с чем работает приложение. Она определяется бизнес‑правилами предприятия. Это может быть модель данных, объекты с методами, набор структур или отдельные функции.

Внедрение зависимостей в fast api приложение

Перейдем от теории к практики и заложим данную архитектуру в новый проект на fast api.

Для достижения концепции «чистой архитектуры» разделим приложение на следующие компоненты:

В данном случае пакет routing будет отвечать за работу с http, пакет service имплементировать в себе бизнес‑логику, а repository будет отвечать за работу с бд.

Для этого создадим следующую иерархию в проекте:

Предполагается, что в файле config находятся настройки для приложения, файл app — точка запуска приложения, в файле depends реализуется паттерн Dependency Injection. Стоит отметить, что это не конечный вариант структуры проекта и в него могут добавляться различные пакеты или бизнес‑сущности, например пакет для работы с моделями и проведения миграций.

Для примера реализуем небольшой модуль для работы книжного магазина.

Первое, что мы сделаем объявим точку запуска приложения в файле app:

from fastapi import FastAPI
from routing.books import router as books_routing

app = FastAPI(openapi_url="/core/openapi.json", docs_url="/core/docs")

app.include_router(books_routing)

Далее опишем DTO — один из шаблонов проектирования для передачи данных между слоями приложения. Для решения этой задачи существует множество решений, начиная с простых dataclass и заканчивая различными библиотеками marmeslow, pydantic и др. Сейчас наиболее популярна связка Pydantic/Fast API. Помимо этого Pydantic обеспечивает мощный механизм валидации данных, который использует аннотации типов. Если необходимо реализовать специфичные валидаторы для полей, то следует использовать декоратор @validate.

Создадим в пакете schemas файл books и добавим туда следующий код:

from datetime import datetime
from pydantic import BaseModel


class Author(BaseModel):
   first_name: str
   last_name: str
   date_birth: datetime
   biography: str


class Book(BaseModel):
   title: str
   annotation: str
   date_publishing: datetime
   author: Author

Далее реализуем класс для работы с БД, для этого в пакете repositories также создадим файл books и наберем следующий код:

from typing import List

from schemas.books import Book


class BookRepository:

   def get_books(self) -> List[Book]:
       ...

   def create_book(self) -> Book:
       ...

В данном классе у нас происходит работа с БД. При этом неважно какой инструмент для этого будет использоваться, это лишь детали реализации. Это могут быть различные ORM или простой SQL. Наибольшей популярностью на данный момент пользуется ORM SQLAlchemy.

После этого реализуем слой, отвечающий за бизнес‑логику приложения. На этом этапе начинается внедрение зависимостей, т.к. данный слой (UseCase) должен зависеть от внешнего слоя repository. В пакете services создадим файл books и добавим следующий код:

from typing import List

from repositories.books import BookRepository
from schemas.books import Book


class BookBookService:

   def __init__(self, repository: BookRepository) -> None:
       self.repository = repository

   def get_books(self) -> List[Book]:
       result = self.repository.get_books()
       return result
  
   def create_book(self) -> Book:
       result = self.repository.create_book()
       return result

Как мы видим в методе init вводится зависимость для класса с работой в БД. В данном классе должна быть заключена вся бизнес‑логика приложения. Т.к. мы используем искусственный пример, то здесь идет простой вызов методов репозитория. В реальном кейсе при создании книги, может происходить отправка данных в CRM, а при получении листинга применены различные фильтры.

Теперь поднимемся на уровень фреймворка. Он будет отвечать за обработку http запросов и роутинг приложения. Но перед этим реализуем паттерн Dependency Injection, где будет находится конструктор для нашего сервиса, в файле depends наберем:

from repositories.books import BookRepository
from services.books import  BookService

"""
Файл внедрения зависимостей
"""
# repository - работа с БД
book_repository = BookRepository()

# service - слой UseCase
book_service = BookService(book_repository)


def get_book_service() -> BookService:
   return book_service

В папке routing, создадим привычный нам файл books и реализуем обработчики запросов:

from typing import List

from fastapi import APIRouter, Depends

from depends import get_book_service
from schemas.books import Book
from services.books import BookService

router = APIRouter(prefix="/books", tags=["books"])


@router.get(
   "",
   responses={400: {"description": "Bad request"}},
   response_model=List[Book],
   description="Получение листинга всех книг",
)
async def get_all_books(
       book_service: BookService = Depends(get_book_service),
) -> List[Book]:
   books = book_service.get_books()
   return books


@router.post(
   "",
   responses={400: {"description": "Bad request"}},
   response_model=Book,
   description="Создание книги",
)
async def get_all_books(
       book_service: BookService = Depends(get_book_service),
) -> Book:
   book = book_service.create_book()
   return book

Стоит отметить что FastAPI из коробки поддерживает внедрение зависимостей с помощью функции Depends.

Заключение

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

Ссылка на проект в гитхабе

Комментарии (6)


  1. axelmaker
    01.12.2023 11:47
    +1

    Уважаемый автор, прошу, добавьте подсветку кода в примерах


    1. PurplePlane Автор
      01.12.2023 11:47

      Спасибо, добавили)


  1. AshBlade
    01.12.2023 11:47
    +4

    Как по мне этот пример больше про 3-слойное приложение:

    1. Все модели наследуются от BaseModel, который нужен для правильной сериализации и работы с БД - и только для этого

    2. Модели анемичные. По факту, выполняют роль DTO

    3. На диаграмме бизнес-логика зависит от БД

    4. В данном классе должна быть заключена вся бизнес-логика приложения (BookBookService) - я увидел лишь простое делегирование вызовов, без какой либо бизнес-логики

    Вывод - это 3 слойное приложение, которое называют чистой архитектурой


  1. outlingo
    01.12.2023 11:47
    +2

    Синглетоны и глобальные объекты? Ну да, конечно, как же иначе - в копипасте по-другому никак.


  1. Didntread
    01.12.2023 11:47

    а можете пояснить, в чем именно выигрыш сервиса обьявленного классом, по сравнению с банальным импортом метода из питон файла с именем book_service.py? Импорт, кстати, в этом примере также присутствует. Я так понимаю, изначальный смысл всего этого инжектинга в возможности подменить реализацию. Тут ее можно подменить?


  1. soltyy00
    01.12.2023 11:47

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