13g10n
Напишите мне
На главную

Dependency Injector для Flask — сервисы

5 минут

Наконец-то мы добрались до тех самых зависимостей из-за которых это всё и затевалось. Зависимости в том же FastAPI реализованы весьма круто и чем-то отдаленно это всё напоминает даже pytest фикстуры. Сегодня мы изменим наш декоратор и постараемся максимально приблизится к чему-то подобному концептуально.

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

Начать я хочу с превращения нашего декоратора в кастомный роут-декоратор (делать это не обязательно, но почему бы не показать пример). В базовом виде сделать это весьма легко, просто делаем наш декоратор параметризованным, добавляя ещё один уровень, и уже внутри используем @app.route от фласка:

def route(path: str, method: str):
    def wrapper(handler):
        signature = inspect.signature(handler)

        @app.route(path, methods=[method])
        @wraps(handler)
        def wrapped(*args, **kwargs):
            ...

Мы вполне могли бы наследоваться от Flask класса приложения и изменить методы .get, .post, и т.п. обернув их в наш декоратор, но это не относится к теме заметки. Используем наш route декоратор:

@route('/<lang>/pages', method='GET')
async def list_pages(lang: Language, offset: Optional[int] = 0, limit: Optional[int] = 10) -> list:
    return pages.get_list(lang, offset, limit)

@route('/<lang>/pages', method='POST')
def create_page(page: Page):
    return pages.create(page)

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

Изначально я не планировал разбирать и это, но всё же в рамках темы все входящие данные так или иначе являются зависимостями для хэндлеров, а значит давайте добавим пару строк и для request.json! Хоть я и буду использовать Pydantic в примерах (уж очень люблю python аннотации типов), на практике у вас может быть абсолютно любая система валидации (например, marshmallow). Опишем модель страницы:

class PageStatus(str, Enum):
    Published = 'Published'
    Draft = 'Draft'


class Page(BaseModel):
    title: str
    status: PageStatus

Далее, нам нужно определиться с логикой, чтобы знать в каких моментах вызывать наш волшебный метод validate и какие параметры туда передавать. Т.к. мы не пытаемя написать свой фреймворк, а лишь разбираем концепцию, сделаем допущение, что валидируем мы только application/json POST запросы, а разработчики будут использовать pydantic модели в хэндлерах, чтобы обозначить request.json данные:

elif request.method == 'POST' and issubclass(param_signature.annotation, BaseModel):
    params[param] = validate(request.json, param_signature.annotation)

Как и предыдущих случаях, мы имеем отличную возможность воспользоваться перехватом ошибок фласка и выдавать пользователю красивый и информативный текст ошибки от pydantic в любой удобной для нас форме.

Прежде чем перейти к сервисам, я хочу сделать ещё одно небольшое, но очень важное в перспективе изменение — меняем наш главный цикл в декораторе с итерации GET-параметров и параметров роута, на итерацию параметров хэндлера, т.к. нас волнуют только нужные в конкретной функции зависимости (тем более, их вероятнее всего будет меньше):

def route(path: str, method: str):
    def wrapper(handler):
        signature = inspect.signature(handler)

        @app.route(path, methods=[method])
        @wraps(handler)
        def wrapped(*args, **kwargs):
            params = {}

            for param, param_signature in signature.parameters.items():
                if param in request.view_args:
                    params[param] = validate(request.view_args[param], param_signature.annotation)
                elif param in request.args:
                    params[param] = validate(request.args[param], param_signature.annotation)
                elif request.method == 'POST' and issubclass(param_signature.annotation, BaseModel):
                    params[param] = validate(request.json, param_signature.annotation)

            return handler(**params)

        return wrapped
    return wrapper

Примерно таким на данный момент получается наш декоратор, он же dependency injector. Как видите, никакой магии и сложных алгоритмов здесь нет. Если мы захотим развиваться во что-то ещё более приближенное к FastAPI, то нам понадобится всего несколько дополнительных классов-оберток и немного логики для взятия данных и передачу их в наш validate с нужными типами.

Ну и последний пункт в нашей повестке — сервисы. Сервисы, зависимости, хэлперы, ресурсы — называйте как привыкли, по сути же это как правило либо функции, либо классы с набором методов, которые содержат бизнес-логику.

Мы не будем разбирать работу с функциями, но расширить приведенный код по аналогии можно будет и для них. Вы, вероятно, заметили, что в наших эндпоинтах фигурирует некий pages объект странного происхождения (на деле просто инициализированный и импортированный экземпляр класса), методы которого мы вызываем. Давайте воспользуемся нашим инжектором и вставим этот сервис как зависимость в хэндлер!

@route('/<lang>/pages', method='POST')
def create_page(pages: PagesService, page: Page) -> str:
    return pages.create(page)
def route(path: str, method: str):
    def wrapper(handler):
        signature = inspect.signature(handler)

        @app.route(path, methods=[method])
        @wraps(handler)
        def wrapped(*args, **kwargs):
            params = {}

            for param, param_signature in signature.parameters.items():
                if param in request.view_args:
                    params[param] = validate(request.view_args[param], param_signature.annotation)
                elif param in request.args:
                    params[param] = validate(request.args[param], param_signature.annotation)
                elif request.method == 'POST' and issubclass(param_signature.annotation, BaseModel):
                    params[param] = validate(request.json, param_signature.annotation)
                elif is_dependency(param_signature.annotation):
                    params[param] = get_dependency(param_signature.annotation)

            return handler(**params)

        return wrapped
    return wrapper

Всё что нам остаётся — добавить is_dependency и get_dependency методы, которые могут применять абсолютно любую логику. Например, вы можете иметь декоратор @service, который будет помечать классы как зависимости; или наследовать все классы работы от единого класса Service и проверять что аннотация является сабклассом; или используйте Depends() в качестве дефолтного значения, как это делает уже не раз упомянутый FastAPI — варианты ограничены лишь фантазией и структурой вашего приложения.

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

Как всегда, спасибо всем, кто поддерживает меня словами и реакциями. Мне было очень интересно и приятно работать над кодом для этой серии и писать сами заметки. Надеюсь, что вы смогли подчеркнуть для себя какие-то важные детали или идеи.

Как всегда не прощаюсь,
Ваня.

Python
Flask