Если вы пытались сделать CRUD для FastAPI или вынести бизнес логику из контроллеров, то возможно вы видели или делали такую страшную конструкцию:
@router.get("/resources", response_model=List[ResourceResponse])
def read_resource_list(db: Session = Depends(get_db)) -> Any:
return resource.get_multi(db)
def get_multi(self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
Аналогичный код написан в официальной документации по FastAPI.
Страшной я называю конструкцию db: Session = Depends(get_db)
. Передавать объект сессии в контроллер, что бы передать его в CRUD. Это кажется странным и некрасивым решением.
Сейчас приложение работает по такой схеме:
Так выглядит get_db()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Если у нас одна база данных, почему бы не создавать объект сессии в CRUD? Решил так и сделать и я столкнулся c проблемой:
Depends(get_db)
Интересная вещь, функционал которой понял не полностью. Раньше использовал этот класс только для проверки заголовков запроса.
В данном случае Depends нужен что бы получить объект сессии из генератора. Но так как объект сессии выносится из контроллера, то Depends использовать не получится.
Свой Depends декоратор
Заверну get_db в контекстный менеджер и сделаю декоратор для передачи объекта сессии в функцию.
db_session = contextmanager(get_db)
def with_db_session(func):
def wrapper(*args, **kwargs):
with db_session() as db:
self, *args = args
return func(self, db, *args, **kwargs)
return wrapper
Почти готово
Осталось убрать db: Session = Depends(get_db)
из контроллеров и обернуть CRUD в декораторы.
@router.get("/resources", response_model=List[ResourceResponse])
def read_resource_list() -> Any:
return resource.get_multi()
@with_db_session
def get_multi(self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
Теперь так работает приложение:
Результат
При каждом запросе создается новое подключение к базе данных (как и было). При этом не какие данные не пересылаются через FastAPI. Это позволяет поменять фреймворк без переделки CRUD-a.
И снова переделываем
После публикации статьи в комментарии мне указали на неточность. На каждый вызов метода crud-а создается сессия. Поэтому в одном запросе может быть множество сессий.
На это будет тратиться больше времени, при каждом создании и удалении сессии. Поэтому перенесу сессию в класс CRUD-а. А with_db_session
удалю.
Было
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
self.model = model
@with_db_session
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
Стало
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType], db: Session):
self.model = model
self.db = db
def get_multi(
self, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return self.db.query(self.model).offset(skip).limit(limit).all()
Теперь необходимо что бы объект CRUD-а создавался на каждую сессию. Переделываем.
Было
class DBResource(CRUDBase[Resource, None, None]):
pass
resource = DBResource(Resource)
Стало
class DBResource(CRUDBase[Resource, None, None]):
pass
def resource_manager(db: Session):
return DBResource(Resource, db)
В контроллеры возвращаем Depends, но сессию передаем в CRUD, а не в методы CRUD-а.
@router.get("/resources", response_model=List[ResourceResponse])
def read_resource_list(db: Session = Depends(get_db)) -> Any:
return resource_manager(db).get_multi()
Вот ещё пример, который более наглядно отображает, зачем передаем сессию в объект класса, а не в метод.
@router.post("/resources", response_model=List[ResourceResponse])
def create_resource(body: ResourceRequest,
db: Session = Depends(get_db)) -> Any:
resource = resource_manager(db)
parent = None
if body.parent_id is not None:
parent = resource.get(body.parent_id)
if parent is None:
raise HTTPException(400, 'Данный parent отсутствует')
resource.create(body)
return resource.get_multi()
Итого
Получили красивый CRUD
Сессия создается на каждый запрос
Если у вас есть ещё идеи, как ещё можно улучшить этот CRUD. Буду рад услышать ваше мнение в комментариях.
Комментарии (16)
Murtagy
01.06.2023 20:41+5Интересная вещь, функционал которой понял не полностью
Как по мне стоит разобраться...
В чем достижение не понятно, на каждую фукнцию получать новый конекшн это сомнительно по перформансу.
Так же часто делается что весь запрос сделан как одна транзакция - падение запроса не вносит изменений в базу. Тут это также не достижимоmasai
01.06.2023 20:41+1Тестировать тоже сложнее, так как Depends можно подменить, а тут придётся манкипатчить декоратор.
4umak
01.06.2023 20:41Тестировать, кстати, можно не только подменяя депсы (что не поможет, если где-то мимо вьюх дб используется), но и подменяя энвы, например, из которых формируется конекшон стринг
Murtagy
01.06.2023 20:41Можно, но не нужно. Если вы свои тесты не можете запустить параллельно - скорее всего что-то не так
4umak
01.06.2023 20:41а можете чуть шире раскрыть ваш комментарий про "не можете запустить параллельно"? Я сам то фастапи не так давно начал использовать, может не знаю какого-то подводного камня тут
Murtagy
01.06.2023 20:41Что тестируем и как?
Отдельную функцию / весь эндпоинт?
Я не использую фастапи, просто это очевидный минус структуры - каждая функция имеет глобальные зависимости:
То есть если нужно протестировать 2 функции - если они запустятся одновременно и им нужны разные настройки - один из тестов переедет второй. Это называется состояние гонки.
Чтобы этого избежать стоит передавать контекст явно либо посредством инкапсуляции - например создать класс который будет при инициации хранить настройки БД, а не читать их с глобальных сеттингов. Так можно для тестов инциировать 2 отдельных класса и они вряд ли смогут друг другу помешать.
Я бы передавал соединение с базой явно, ничего плохого и "некрасивого" здесь не вижу.4umak
01.06.2023 20:41А, ну это прямо совсем сильно не общий случай, кмк. Много ли веб-штук используют больше одной бд одновременно? А если и используют, то их параметры подключения наверняка лежат в разных переменных, поэтому касательно именно случая с бд не вижу тут потенциала для конфликтов.
Что, конечно же, не относится к другим возможным настройкам приложения.
Murtagy
01.06.2023 20:41https://en.wikipedia.org/wiki/Dependency_injection
Разные базы здесь не причем.
Если один круд дергает другой круд, например раз 10, будет интересно. Сделайте такой кейс и направьте на него апаче бенчмарк, посмотрите что будет.
Если 2 теста одновременно ожидают чистой БД - будет интересно.
Хотите читайте глобалы и делайте новые коннекты в декораторах. Хотите - в случайных местах приложения читайте os.environ.
В общем - дерзайте4umak
01.06.2023 20:41Мы видимо всё же о разном говорим) Мой изначальный поинт был исключительно в ответе @masai - что в случае необходимости заменить параметры подключения, можно использовать не только подмену зависимости. Буквально это, всё.
А вот с тем архитектурным решением, что предлагает автор поста, я не согласен, о чём и написал ниже)
Tishka17
01.06.2023 20:41+6Было: попытка в Dependency Injection от автора фреймворка. Могла быть доработана до нормального DI.
Стало: глобальная переменна со всем вытекающими.
Не надо так
Roman_Pokrovsky Автор
01.06.2023 20:41Добрый день. Спасибо за комментарий!)
Стало интересно, а как вы предлагаете доработать до нормального DI? И в чем плюсы DI, если нет модульности? А если потребуется сменить фреймворк?
А почему db глобальная переменная, если каждый раз создается в декораторе?)
И может вы подскажете. Если оставить Depends, можно ли его перенести в query менеджер или crud?
outlingo
01.06.2023 20:41+2Вот такой пример
import fastapi import uvicorn class MyApplication: def __init__(self) -> None: self.app_name = "AppName" self.database = "database" def something(self) -> str: return "something from " + self.database + " of " + self.app_name def other(self) -> str: return "other from " + self.database + " of " + self.app_name def hello(self, name: str) -> str: return "Hello from %s, %s!" % (self.app_name, name) def setup_routes(logic: MyApplication, router: fastapi.FastAPI) -> None: @router.get("/something") def _something() -> str: return logic.something() @router.get("/other") def _other() -> str: return logic.other() @router.get("/hello") def _hello(user: str = fastapi.Query(title='User name')) -> str: return logic.hello(user) router = fastapi.FastAPI() applicattion = MyApplication() setup_routes(applicattion, router) engine = uvicorn.run(router, port=1080)
Все зависимости на фреймворк собраны в одном месте - setuo_routes. Вы можете сетапить сколько угодно инстансов приложения, никаких глобальных переменных, подключать его к любому фреймворку / раутеру / RPC контейнеру
Tishka17
01.06.2023 20:41+1db_session
- глобальная переменная. В декораторе вы создаете из нее конкретную сессию. В результате будут проблемы с конфигурированием какую базу использовать. Мой пост про глобалы https://t.me/advice17/5
Depends указывает зависимость, но ещё надо настроить как её создавать. Если вы напрямую юзаете реализованную функцию создания зависимости в Depends - у вас нет DI, есть просто хитрый способ вызвать функцию. Вам нуженdependency_overrides
. Мой пост как это сделать в fastapi https://t.me/advice17/14
4umak
Как было: одно подключение на клиентский запрос в эндпоит
Как стало: одно подключение на каждый вызов метода круда
Не совсем одно и то же