Чому я обираю FastAPI: основні можливості та переваги фреймворку

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт! Мене звуть Ярослав Мартиненко, я Python Developer в NIX.

Раніше я займався Embedded-розробкою, пізніше пішов у сторону вебу. Вже більше року я розробляю бекенд на Python. Намагаюся постійно вивчати щось нове і прагну створювати те, що спростить життя оточуючим.

Рік тому я дізнався про FastAPI. Він є «спадкоємцем» філософії Flask, але вже «з коробки» надає цікаві фічі, про які я розповім у цій статті. FastAPI не пропонує більше, ніж необхідний мінімум, тому розробник вільно може використовувати разом з цим фреймворком будь-які інструменти.

Що ж це за FastAPI

Це відносно новий асинхронний веб-фреймворк для Python. По суті, це гібрид Starlett та Pydantic. Starlett  асинхронний веб-фреймворк, Pydantic  бібліотека для валідації даних, серіалізації тощо. У документації FastAPI написано, що він може наблизитися за швидкістю до Node.js та Golang. Я цього не перевіряв, тому й вірити в це не буду. Для мене він швидкий з іншої причини. FastAPI дозволяє просто та оперативно написати невеликий REST AРІ, не витративши на це багато зусиль.

Давайте поглянемо, як легко (це лише моя суб’єктивна думка) можна почати роботу з FastAPI.

Початок роботи

Насамперед варто встановити потрібні нам залежності, а це — сам фреймворк та ASGI сервер, оскільки FastAPI не має вбудованого сервера, як у Flask або Django. У документації пропонується використовувати uvicorn як ASGI сервер.

pip install fastapi
pip install uvicorn

У FastAPI використовується подібна до Flask система оголошення ендпоінтів  за допомогою декораторів. Тому тим, хто працював із Flask, буде досить легко пристосуватися до FastAPI. Тепер створимо об’єкт нашої програми та додамо HelloWorld роут:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

Ми оголосили, що при GET запиті на / ми повернемо json {«message»: «Hello World»}, особливо відмінностей від Flask тут немає. Важлива ремарка: ендпоінт також можна оголосити в синхронному стилі, використовуючи просто def, якщо ви хочете використовувати await. FastAPI розрулить все за вас. За що я полюбляю FastAPI — так це за його лаконічність. Давайте оголосимо роут, який буде очікувати якийсь параметр як частину шляху:

@app.get("/item/{id}")
async def get_item(id):
    return id

Тепер, якщо ми перейдемо за адресою /item/2, то отримаємо 2 у відповідь. А що робити, якщо хтось захоче нам надіслати замість цифри рядок, наприклад, dva? Хотілося б якось захистити себе від таких конфузів. І тут нам приходить на допомогу Python 3.6+ і type_hints. Тайп хінтинг (оголошення типів) загалом допомагає зробити код більш зрозумілим і дає можливість використовувати інструменти для статичного аналізу (такі, як mypy). FastAPI змушує вас використовувати тайп хінтинг, тим самим покращуючи якість коду і зменшуючи ймовірність того, що ви припустилися десь помилки через неуважність.

Тепер визначимо, що наш id повинен бути типу int:

@app.get("/item/{id}")
async def get_item(id: int):
    return id

Ми досить просто додали валідацію, і тепер можна спробувати передати dva та подивитися, що ж вийде. У відповідь отримаємо те, що ми робимо щось не так. Сервер поверне нам 422 статус код і наступний json:

{
    "detail": [
        {
            "loc": [
                "path",{
    "detail": [
        {
            "loc": [
                "path",
                "item_id"
            ],
            "msg": "value is not a valid integer",
            "type": "type_error.integer"
        }
    ]
}
                "item_id"
            ],
            "msg": "value is not a valid integer",
            "type": "type_error.integer"
        }
    ]
}

На цьому етапі настав час Pydantic. Він згенерує дані про те, де виявлена помилка, і підкаже, що ми зробили не так. Знову ж таки, не всім припаде до душі статус код 422 і дані про помилку, які нам генерує Pydantic. Але це все можна кастомізувати, якщо дуже хочеться.

А як оголосити, що ми хочемо якийсь квері параметр та ще й щоб він був необов’язковий? Усе просто: якщо аргумент функції не оголошений як частина шляху, то FastAPI буде вважати, що він повинен бути отриманий як квері параметр. Для того, щоб зробити його необов’язковим, надамо йому дефолтне значення.

Ще однією прекрасною фічею FastAPI є те, що ми можемо оголосити, наприклад, enum — щоб задати певні значення, які очікуємо на вхід:

class Framework(str, Enum):
    flask = "flask"
    django = "django"
    fastapi = "fastapi"


@app.get("/framework")
def framework(framework: Framework = Framework.flask):
    return {"framework": framework}

Наступна цікава фіча — перетворення типів. Якщо ми хочемо отримати булеве значення як квері параметр, нам все одно доведеться його передати як число або як рядок. Рydantic пропонує перетворити логічно правильне значення на булевий тип ось так:

@app.get("/items")
async def read_item(short: bool = False):
    if short:
        return "Short items description"
    else:
        return "Full items description"

Для ендпоінту, вказаного вище, наступні значення будуть валідні та перетворені на булеве значення True:

Інколи нам потрібно гнучкіше налаштовувати той момент, де шукати і звідки діставати параметри. Наприклад, ми хочемо дістати значення з хедера. Для цього FastAPI надає нам такі інструменти: Query, Body, Path, Header, Cookie, які імпортуються з FastAPI. Вони допомагають не лише явно визначити, де шукати параметр, але й дозволяють оголосити додаткову валідацію. Давайте розглянемо це на прикладі:

from typing import Optional
from fastapi import FastAPI, Query, Header

app = FastAPI()


@app.get("/")
async def test(number: Optional[int] = Query(None, alias="num", gt=0, le=10), owner: str = Header(...)):
    return {"number": number, "owner": owner}

Ми визначили ендпоінт, який очікує, що ми передамо йому число від 0 до 10 включно як квері параметр. Причому квері параметр ми повинні передавати як /?num=3, оскільки визначили alias для цього параметра і тепер очікуємо, що він прийде нам під ім’ям num, і що у нас буде хедер Owner.

Pydantic моделі

Найчастіше, коли ми будуємо REST API, то хочемо передавати якісь складніші структури у вигляді json у тілі запиту. Ці структури можна описати за допомогою Рydantic моделей. Наприклад, ми хочемо приймати об’єкт item, який має ім’я, ціну та опціональний опис:

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float

Також ми хочемо додати ендпоінт, який прийматиме POST запити, десеріалізувати та валідувати json і десь зберігати його. Наша модель Item — це клас, тому ми можемо успадковуватися від неї і створити модель, яка також буде містити id. Адже нам хочеться зберегти десь наш item. Йому присвоюється id, і вже разом із цим id ми можемо повернути клієнту відповідь із кодом 201.

Для початку створимо модель із новим полем id:

class ItemOut(Item):
    id: int

Далі — ендпоінт з аргументом item типу Item. Оскільки Item — це Pydantic модель, FastAPI передбачає, що нам потрібно дістати item з тіла запиту і content-type = application/json. Pydantic десеріалізує ці дані та провалідує їх. Потім створимо об’єкт типу ItemOut, який матиме ще й поле id, і повернемо все це користувачеві:

@app.post("/item/", response_model=ItemOut, status_code=201)
async def create_item(item: Item):
    item_with_id = ItemOut(**item.dict(), id=1)
    return item_with_id

Як ви можете побачити в декораторі, ми визначили, що дані, що повертаються будуть типу ItemOut, а статус код — 201. Вказівка ​​ response_model необхідна для того, щоб правильно згенерувати документацію (про це розповім далі), а також щоб серіалізувати та провалідувати дані. Ми могли б передати словник замість об’єкта ItemOut. Тоді FastAPI спробував би перетворити цей словник на ItemOut об’єкт і провалідував дані.

Якщо хочемо створити більш складні структури з вкладеністю, то тут також не виникає особливих труднощів. Ми просто визначаємо нашу модель Pydantic, яка містить об’єкти з типом іншої Pydantic моделі:

class OrderOut(BaseModel):
    id: int
    items: list[Item]

Ще одна перевага FastAPI — автогенерація OpenApi документації. Нічого не потрібно підключати, не потрібно танцювати з бубном — просто бери і користуйся. За замовчуванням документація перебуває на шляху /docs.

Відкладені задачі

Іноді буває, що ми хочемо швиденько повернути респонс клієнту, а витратні задачі виконати потім у фоні. Зазвичай для цього використовується щось на зразок Celery або RQ. Щоб не морочитися з чергою та воркерами, у FastAPI є така фіча, як background tasks. Ми можемо оголосити, що наша функція набуває аргументу типу BackgroundTasks, і цей об’єкт буде інтерфейсом для створення бекграунд тасків:

def write_notification(email: str, message=""):
    with open("log.txt", mode="w") as email_file:
        content = f"notification for {email}: {message}"
        email_file.write(content)


@app.post("/send-notification/{email}")
async def send_notification(email: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_notification, email, message="some notification")
    return {"message": "Notification sent in the background"}

На прикладі вище зображено функцію, яка щось записує у файл. Нам необхідно обробити її після того, як повернемо користувачеві респонс. Для цього оголосимо аргумент background_tasks з типом BackgroundTasks і за допомогою нього зможемо додавати функції, які нам потрібно виконати після того, як відпрацює наша view. Тут варто розуміти, що це не заміна Celery і подібних інструментів для виконання асинхронних завдань. У даному випадку у нас є процес з Python, в якому обробляються наші запити, і в ньому ж буде запущена відкладена функція, на відміну від тієї ж Celery, де є черга й окремі процеси-воркери, які обробляють нашу задачу.

Ін’єкція залежностей

FastAPI надає систему для ін’єкції залежностей у наші view. Для цього є Depends. Залежністю може бути callable об’єкт, у якому буде реалізована певна логіка. Об’єкт, який ми інжектуємо, матиме доступ до контексту реквеста. Це означає, що ми зможемо винести певну загальну логіку з наших view та перевикористати її.

Пропоную розглянути цей процес на прикладі:

from typing import Optional
from fastapi import Depends, FastAPI

app = FastAPI()


async def common_parameters(q: Optional[str] = None, skip: int = 0, limit: int = 100):
    return {"q": q, "skip": skip, "limit": limit}


@app.get("/items/")
def read_items(commons: dict = Depends(common_parameters)):
    return commons


@app.get("/users/")
async def read_users(commons: dict = Depends(common_parameters)):
    return commons

Ми створили функцію, яка дістає нам параметр для фільтрації і повертає його як словник. Потім підключили цю функцію як залежність у наші view функції read_items та read_users. Оголосили аргумент common типу dictі та надали йому Depends (common_parameters). Depends приймає аргументом callable об’єкт, який буде викликаний перед обробкою нашого view. У цьому випадку він поверне словник із параметрами для фільтрації. Цікавим тут є те, що нам байдуже, чи є функція синхронною. Ми можемо оголосити common_parameters як синхронну і як асинхронну. FastAPI усе розрулить за нас.

Оскільки залежностями можуть бути callable об’єкти, ми можемо замінити нашу функцію, яка повертає словник з параметрами, на щось більш елегантне:

class CommonQueryParams:
    def __init__(self, q: Optional[str] = None, skip: int = 0, limit: int = 100):
        self.q = q
        self.skip = skip
        self.limit = limit


@app.get("/items/")
def read_items(commons: CommonQueryParams = Depends(CommonQueryParams)):
    return commons

Як бачите, ми замінили функцію на клас і тепер передаємо його в Depends. У результаті нам повертається об’єкт класу CommonQueryParams. Тепер ми можемо отримати доступ до його атрибутів через точку, наприклад, commons.q. Ось так виглядають наші залежності:

По суті, це граф. Ми можемо зробити його більш складним, де у поточні залежності додамо інші та зробимо їх більш специфічними. Припустимо, у нас буде залежність, яка перевіряє, чи авторизований користувач, і дістає його з бази. Інша — пов’язана з першою залежність — перевіряє, чи є користувач активним, а третя — котра залежить від другої — визначає, чи є він адміном:

Оскільки це граф, виникає питання ромбовидної залежності: скільки разів буде виконуватись найперша батьківська залежність? Відповідь проста — всього раз. Зазвичай нам потрібно лише один раз виконати дію над реквестом, а потім закешувати дані, які нам поверне залежність. Це можна перевизначити, передавши в Depends use_cache=False.

def dep_a():
    logger.warning("A")


def dep_b(a = Depends(dep_a)):
    logger.warning("B")


def dep_c(a = Depends(dep_a, use_cache=False)):
    logger.warning("C")


@app.get("/test")
def test(dep_a = Depends(dep_a), dep_b = Depends(dep_b), dep_c = Depends(dep_c)):
    return "Hello world"

Залежність dep_a йде першою в аргументах і не має інших залежностей, тому вона виконається та кешує її. Залежність dep_b йде слідом і має залежність від dep_a, але виклик dep_a було зроблено, і відповідь закешована, тому dep_a не буде викликатися. Далі йде dep_c, яка залежить від dep_a та визначає use_cache=False для залежності dep_a. Незважаючи на те що dep_a була закешована, вона все одно буде викликатися, і відповідь також закешується. Потім викличеться dep_c. І тільки наприкінці виконається наша функція test.

І це ще не все. Ми можемо використовувати наші залежності разом з yield. Це буде щось на зразок контекстного менеджера. Ми зможемо виконати якусь ініціалізацію до yield, потім виконається наша view, далі бекграунд таски, а також відпрацює код після yield. Це можна використовувати для ініціалізації ресурсів, наприклад, для налаштування підключення до бази даних:

async def get_db():
    logger.warning("Open connection")
    yield "database"
    logger.warning("Close connection")


async def task(database):
    logger.warning("Some task")
    logger.warning(f"DB: {database}")


@app.get("/test")
async def test(background_tasks: BackgroundTasks, database = Depends(get_db)):
    background_tasks.add_task(task, database)
    return database

Dependency Injector необхідний для того, щоб легко підмінити нашу залежність на mock. Припустимо, ця залежність — і є клієнт, який звертається до стороннього API за http. Робиться це просто: підмінюємо залежність, яка повертає клієнта, на залежність, яка повертає mock із таким самим публічним API.

Якщо у нас є сервіс для надсилання повідомлень, то при спробі запустити тести з цим сервісом, вони впадуть з помилкою. Однак ми можемо визначити pytest фікстуру, в якій наша залежність буде підмінятися. Як це зробити? Додамо функцію яка поверне mock у app.dependency_overrides, і після того, як тест спрацює, очистимо наші перевизначення залежностей app.dependency_overrides = {}:

import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def send_msg():
    raise ValueError("Error")


@app.get("/api")
def some_api(msg = Depends(send_msg)):
    return msg


@pytest.fixture
def mock_dependencies():
    def get_msg_mocked():
        return "Test pass"
    app.dependency_overrides[send_msg] = get_msg_mocked
    yield
    app.dependency_overrides = {}


@pytest.mark.usefixtures("mock_dependencies")
def test_my_api():
    res = client.get("/api")
    assert res.status_code == 200

Висновок

Я спробував коротко описати основні можливості FastAPI і показати, чим мені подобається цей фреймворк. Вам варто спробувати його хоча б для невеликого pet-проєкту. Довкола FastAPI досить швидко розростається спільнота його шанувальників, мало не щодня ​​з’являються нові бібліотеки. Тому деякі проєкти вже поступово переходять із Flask на FastAPI.

👍ПодобаєтьсяСподобалось23
До обраногоВ обраному13
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Классный фрейморк, который навязывает разработчикам хорошие практики в плане Dependency Injection. А если с этой интеграцией, то вообще сказка:)
python-dependency-injector.ets-labs.org/examples/fastapi.html

Все частіше бачу згадку FastAPI в описі вакансій та обговореннях на форумах. Чимось нагадує ранній express.js, який добре вистрілив, і став гідною замінною гігантам. Можливо і з FastAPI відбудеться те саме і він буде використовуватися повсюдно і замінить джангу. Як на мене FastAPI це ковток свіжого повітря, про який багато хто мріє

docs.djangoproject.com/en/4.0/topics/async
Якщо треба віддавати json по http то Django і FastAPI взаємозамінні, у чому проблема то?

якщо твоя бізнес логіка залежить від веб фреймворку — то у тебе проблеми набагато більші ніж асинхронність фреймворку.

Ніхто на фастапі нічого серйозного писати не буде, окрім мікросервісів, що примазані до основного джанго-проекту. Вічно видумувати свої велосипеди вже всі проходили на фласку.

На FastAPI можно и синхронные роуты писать

Колись розробляв на Django, потім для одного проекту з’явилася потреба в чомусь швидшому, без фронтів, чисто АРІ...вирішив спробувати FastAPI. І тепер не можу з нього злізти) Не такий великий маю в ньому досвід, але декілька мікросервісів написав. Можу сказати що приємно здивувала присутність під капотом аналога Celery, періодичні таски і т.д. Хоча все ж таки в Celery є свої переваги, там все запускається окремо і без навантаження на основний бекенд, а в FastAPI здається все воно крутиться в тих же воркерах. В даний момент перевагу в Джанго бачу хіба тільки в своїй ORM, хоча на приклад TortoiseORM для FastAPI нічим не гірша. Та і адмінка для FastAPI класна і не складніша в налаштуванні. Напевно прийшов той час коли Джанго вже не так актуальна. Якщо рахувати що переважно всі фронти зараз SPA, то тамплейти в Джанго зараз вже не потрібні. А якщо треба тільки REST то чи варто для цього брати ціле Джанго. Якщо я помиляюся то поправте мене.

А стосовно швидкодії, то знаходив такі порівняння... Django ~ 750 RPS / FastAPI ~ 1500 RPS...в той час як Go ~ 40000 RPS

Джанга це про основний бізнес-бекенд і швидкість розробки без болі з пайдентіком, а все що літає на орбіті — можна і на фастапі.

Таке відчуття що python щось недобре з тобою зробив.

Какие посоветуете фреймворки в питоне?

Це дуже смішно, враховуючи що інстаграм, венмо і купу інших світових продуктів на джанзі. Джанга йде уже з батарейками, а на фастапі хоч і всякі вебсокети із коробки, все рівно все через одне місце, як з пайдентіком, котрий відсмоктує у маршмеллова по зручності і можливостям (не швидкодії). Ніхто не буде писати основну логіку продукту на фастапі, окрім якраз всяких мікросервісів.

«fast» и «python» — это слова антонимы.

Или здесь «fast» — о скорости «тяп-ляп и в продакшн»?

Дякую за статтю та популяризацію якісних технологій)

Я не дуже багато працював з фреймворками на python, але ця штука дійсно виглядає як досить пристойний мікрофреймворк з усім що треба — тобто простий та зрозумілий роутінг та простий та зрозумілий DI.

Це ви як уявляєте? От сидять декілька Python розробників розробляють backend, а тут заходить начальник та каже: «А ну швидко вивчить нову мову програмування заради API.». Приблизно так?

Я не читав дуже уважно цю статтю, але думаю, що головна думка автора — це те, що FastAPI є нормальним вибором серед Python фреймворків.

Звичайно ж, якщо в вас є така розкіш як розробники Python + розробники на іншій мові програмування, то тоді звичайно можна дивитися на архітектуру під іншим кутом і писати все на Go.

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

Проблема в тому, що зараз дуже мало задач де не можна застосовувати більшість популярних мов.
А для задача побудови веб-апі навіть асинхронних — це типова задача і не потребує окремого інструменту.

А ще за красивими словами про «сильні команди», «не джуни», «інструмент під задачу» ви забуваєте важливі якісні атрибути, наприклад:
— швидкість реалізації. Щоб вона була однаковою розробникі мають однаково добре знати обидва стеки, таке буває дуже рідко.
— ментейнабіліті. Сюди входить не лише можливість посадити джуна на фікс багу колись потім, а і забрати на підтримку та оновлення середовища (банально треба слідкувати за дірками в 2-х платвормах)

Та з наведеного вище, можемо бачити, що для команди з досвідом та інфраструктурою під пітон, го не буде «правильним інструментом».

В такие языки как Go, Rust с нуля не вкатываются после курсов

Ліл. Го якраз рекламують як «просту мову». І його в часто використовують як «простий Сі» для системних утиліт.

Им не нужно объяснять базовые вещи. А значит проблема знания стека обычно не стоит. При найме на проект обычно указыавается стек технологий.

1) З першого речення не слідує друге. Який би досвід у вас не був, якщо ви не знаєте назву фремворка або утіліти, їх апі і тд, воно в вас не з’явиться само собою.
2) Речення 3 протирічить вашому ж посилу про «інструмент під задачу»
3) Чим ширше стек технологій тим складніше знайти людей, що мають досвід з усім стеком

Щось я не розумію що ви хочете зараз довести і як це пов’язано з вашим твердженням:

Fast API хороший фреймворк, но не вижу смысла использовать подобные python фреймворки когда есть golang.

Щодо «доменних областей», то у го тут перевага лише для системних утіліт. Для всього іншого він конкурує принаймні з 1 (а по факту 2-3) платформами.

по сравнению с теми ассинхронными фреймворками в питоне которые были до него; но по сравнению в частности с го

До чого тоді всі ті висловлюванн про доменну область?

высокопроизводительных API

Перворманс АПІ практично не залежить від мови. Там 2 складових:
— інфраструктура (умовний клауд, балансери і тд)
— перформанс коду за АПІ. Якщо у вас «числодробилка», то скоріше за все її ніхто не буде писати на пітоні, але й не на го, це при наявності джави/дотнет і ЦПП/Раст.

Бізнесу все рівно, головне аби працювало.

обычно хороший разработчик знает пару-тройку языков

Хороший розробник той, хто добре заробляє. А оці понти про про декілька мов програмування лишіть для хабра, там цим топіки про пайтон завалені роками.

Це ви як уявляєте? От сидять декілька Python розробників розробляють backend, а тут заходить начальник та каже: «А ну швидко вивчить нову мову програмування заради API.». Приблизно так?

Таке буває у великих компаніях рівня ФААНГ або около того. Особисто таке бачив, коли прийшов новий архітект чи хто там, і сказав — переписуємо все на гоу та скала.

Сенс є, адже є купа нюансів. Та хоча б те, що пітонщика легше знайти і ліб під нього більше.

Я б сказав навіть «найти гарного інженера» бо не пітоністами єдиними обідніли )))))

Так само можна підмінити у вашому реченні {python} та {golang} на будьщо. Наприклад "не вижу смысла использовать подобные golang фреймворки когда есть dotnet.

Що до того що мова то інструмент воно так і є. Але будь який інструмент потребує застосування. І розмахувати голангом або наприклад нодою і казати рубістам та пітоністам що мовляв що ви на своїх тормозяках пишете, коли 90% проектів не потребують ніякої гіпершвидкості, то є мабудь не досить раціонально.

Люди досі пишуть на рубі та пхп і отримують задоволення.

Перформанс приложухи це дійсно важливо, але не треба також забувати про перформанс розобки. І люди можуть обирати інструменти в тій ниші яка їм ближча. Якщо вам зручно і подобається писати на голанг, так то чудово, але як я вже казав не всі приложухи потребують того перформансу який дає голанг.

Але ж треба розуміти і зворотню сторону — якщо команда розробників баче, що приложуха потребує високого перформанса, та буде молотити шо дурна, але все одно беруть пітончик, бо воно їм ближче, то вже питання до інженерів.

на самом деле fastapi берут потому что он становится стандартом и вытесняет фласк

fastapi тоже можно как синхронный использовать

Не бачу сенсу використовувати надповільний go, якщо набагато простішне і головне продуктивніше можна писати на расті. А так да, Fiber непоганий для пет-проектів.

Рекмендую також звернути увагу на github.com/vitalik/django-ninja — FastAPI пiдхiд + батарейки Django (made in Ukraine 🇺🇦 )

Підписатись на коментарі