В этой статье разберем как заложить «чистую архитектуру» в 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)
AshBlade
01.12.2023 11:47+4Как по мне этот пример больше про 3-слойное приложение:
Все модели наследуются от BaseModel, который нужен для правильной сериализации и работы с БД - и только для этого
Модели анемичные. По факту, выполняют роль DTO
На диаграмме бизнес-логика зависит от БД
В данном классе должна быть заключена вся бизнес-логика приложения
(BookBookService) - я увидел лишь простое делегирование вызовов, без какой либо бизнес-логики
Вывод - это 3 слойное приложение, которое называют чистой архитектурой
outlingo
01.12.2023 11:47+2Синглетоны и глобальные объекты? Ну да, конечно, как же иначе - в копипасте по-другому никак.
Didntread
01.12.2023 11:47а можете пояснить, в чем именно выигрыш сервиса обьявленного классом, по сравнению с банальным импортом метода из питон файла с именем book_service.py? Импорт, кстати, в этом примере также присутствует. Я так понимаю, изначальный смысл всего этого инжектинга в возможности подменить реализацию. Тут ее можно подменить?
soltyy00
01.12.2023 11:47Фактически, такой подход в разработке показался неудобным, мы соединили в одном файле модель, роуты и сервисы к роутам, стало удобно.
axelmaker
Уважаемый автор, прошу, добавьте подсветку кода в примерах
PurplePlane Автор
Спасибо, добавили)