Dependency Injector для Flask — сервисы
Наконец-то мы добрались до тех самых зависимостей из-за которых это всё и затевалось. Зависимости в том же 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. Моей же целью было показать простые инструменты и техники которыми можно не просто упростить себе жизнь и очистить код от ненужных аргументов, но и стандартизировать валидацию и даже организовать архитектуру зависимостей.
Как всегда, спасибо всем, кто поддерживает меня словами и реакциями. Мне было очень интересно и приятно работать над кодом для этой серии и писать сами заметки. Надеюсь, что вы смогли подчеркнуть для себя какие-то важные детали или идеи.
Как всегда не прощаюсь,
Ваня.