Если вы пытались сделать 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)


  1. 4umak
    01.06.2023 20:41
    +4

    При каждом запросе создается новое подключение к базе данных (как и было)

    Как было: одно подключение на клиентский запрос в эндпоит

    Как стало: одно подключение на каждый вызов метода круда

    Не совсем одно и то же


  1. Murtagy
    01.06.2023 20:41
    +5

    Интересная вещь, функционал которой понял не полностью

    Как по мне стоит разобраться...

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


    1. masai
      01.06.2023 20:41
      +1

      Тестировать тоже сложнее, так как Depends можно подменить, а тут придётся манкипатчить декоратор.


      1. 4umak
        01.06.2023 20:41

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


        1. Murtagy
          01.06.2023 20:41

          Можно, но не нужно. Если вы свои тесты не можете запустить параллельно - скорее всего что-то не так


          1. 4umak
            01.06.2023 20:41

            а можете чуть шире раскрыть ваш комментарий про "не можете запустить параллельно"? Я сам то фастапи не так давно начал использовать, может не знаю какого-то подводного камня тут


            1. Murtagy
              01.06.2023 20:41

              Что тестируем и как?
              Отдельную функцию / весь эндпоинт?

              Я не использую фастапи, просто это очевидный минус структуры - каждая функция имеет глобальные зависимости:
              То есть если нужно протестировать 2 функции - если они запустятся одновременно и им нужны разные настройки - один из тестов переедет второй. Это называется состояние гонки.
              Чтобы этого избежать стоит передавать контекст явно либо посредством инкапсуляции - например создать класс который будет при инициации хранить настройки БД, а не читать их с глобальных сеттингов. Так можно для тестов инциировать 2 отдельных класса и они вряд ли смогут друг другу помешать.

              Я бы передавал соединение с базой явно, ничего плохого и "некрасивого" здесь не вижу.


              1. 4umak
                01.06.2023 20:41

                А, ну это прямо совсем сильно не общий случай, кмк. Много ли веб-штук используют больше одной бд одновременно? А если и используют, то их параметры подключения наверняка лежат в разных переменных, поэтому касательно именно случая с бд не вижу тут потенциала для конфликтов.

                Что, конечно же, не относится к другим возможным настройкам приложения.


                1. Murtagy
                  01.06.2023 20:41

                  https://en.wikipedia.org/wiki/Dependency_injection

                  Разные базы здесь не причем.
                  Если один круд дергает другой круд, например раз 10, будет интересно. Сделайте такой кейс и направьте на него апаче бенчмарк, посмотрите что будет.
                  Если 2 теста одновременно ожидают чистой БД - будет интересно.

                  Хотите читайте глобалы и делайте новые коннекты в декораторах. Хотите - в случайных местах приложения читайте os.environ.
                  В общем - дерзайте


                  1. 4umak
                    01.06.2023 20:41

                    Мы видимо всё же о разном говорим) Мой изначальный поинт был исключительно в ответе @masai - что в случае необходимости заменить параметры подключения, можно использовать не только подмену зависимости. Буквально это, всё.

                    А вот с тем архитектурным решением, что предлагает автор поста, я не согласен, о чём и написал ниже)


                    1. Murtagy
                      01.06.2023 20:41

                      Это все еще ГЛОБАЛЬНАЯ подмена зависимости


  1. 4umak
    01.06.2023 20:41
    +4

    При каждом запросе создается новое подключение к базе данных (как и было)

    Как было: одно подключение на клиентский запрос в эндпоит

    Как стало: одно подключение на каждый вызов метода круда

    Не совсем одно и то же


    1. Tishka17
      01.06.2023 20:41

      Полагаю, про транзакции автор не слышал


  1. Tishka17
    01.06.2023 20:41
    +6

    Было: попытка в Dependency Injection от автора фреймворка. Могла быть доработана до нормального DI.

    Стало: глобальная переменна со всем вытекающими.

    Не надо так


    1. Roman_Pokrovsky Автор
      01.06.2023 20:41

      Добрый день. Спасибо за комментарий!)

      Стало интересно, а как вы предлагаете доработать до нормального DI? И в чем плюсы DI, если нет модульности? А если потребуется сменить фреймворк?

      А почему db глобальная переменная, если каждый раз создается в декораторе?)

      И может вы подскажете. Если оставить Depends, можно ли его перенести в query менеджер или crud?


      1. 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 контейнеру


      1. Tishka17
        01.06.2023 20:41
        +1

        db_session - глобальная переменная. В декораторе вы создаете из нее конкретную сессию. В результате будут проблемы с конфигурированием какую базу использовать. Мой пост про глобалы https://t.me/advice17/5

        Depends указывает зависимость, но ещё надо настроить как её создавать. Если вы напрямую юзаете реализованную функцию создания зависимости в Depends - у вас нет DI, есть просто хитрый способ вызвать функцию. Вам нужен dependency_overrides. Мой пост как это сделать в fastapi https://t.me/advice17/14