Архитектура FastAPI приложений. Внедрение зависимостей.
В этой статье разберем как заложить “чистую архитектуру” в FastAPI проект. Несмотря на то, что мы используем FastAPI, данный подход можно применять при использовании любого другого фреймворка. Например заложить данную архитектуру при разработке Django проекта.

Читать на Habr

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

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

  • Независимость от фреймворков;
  • Возможность изолированного тестирования различных слоев приложения;
  • Независимость от реализации пользовательского интерфейса;
  • Изолированность бизнес-логики от других слоев приложения.

Для достижения разделения ответственности используется правило зависимостей - данную концепцию предложил Роберт Мартин в 2012 г. в статье “The Clean Architecture”. Ниже приведена оригинальная схема из данной статьи:
Основное правило при таком архитектурном подходе гласит: Зависимости могут быть направлены только внутрь. Таким образом каждый внутренний круг системы ничего не знает о внешнем. Круг состоит из 4 слоев:

1) Внешний круг - это БД и фреймворки. Данный слой, как правило не содержит много кода, и никаким образом не должен влиять на имплементацию бизнес-логики;
2) Далее идет слой интерфейсов-адаптеров, он отвечает за преобразование данных в более удобный формат для исполнения сценариев. Он выступает “мостом” между внешним слоем и слоем бизнес-логики приложения;
3) Следующий слой Сценарии предназначен для имплементации бизнес-логики. Он инкапсулирует все случаи использования системы. Изменения во внешних слоях не должны затрагивать слой сценариев. Мы также ожидаем, что изменения в в этом слое не отразиться на слое Сущностей;
4) Слой сущности это минимальная единица с чем работает приложение. Она определяется бизнес-правилами предприятия. Это может быть модель данных, объекты с методами, набор структур или отдельные функции.

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

Перейдем от теории к практики и заложим данную архитектуру в новый проект на FastAPI. Для достижения концепции “чистой архитектуры” разделим приложение на следующие компоненты:
В данном случае пакет 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/FastAPI. Помимо этого 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

Заключение

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

Ссылка на проект в гитхабе: https://github.com/aarbatskov/clean_architecture.git