Middleware: код, который выполняется при каждом запросе
- Объяснить, что такое Middleware и какую проблему оно решает
- Писать Middleware с @app.middleware(“http”) и call_next
- Понимать, что делают async и await внутри Middleware
- Добавить в API студентов лог и измерение времени для каждого запроса, не меняя ни одного маршрута
Что мы изучим?
- Какая возникает проблема, когда нужно что-то делать при каждом запросе.
- Что такое Middleware и как оно связано с уже знакомым нам декоратором.
@app.middleware("http"),call_next,asyncиawait.- Лог и измерение времени для каждого запроса в API студентов, а также заголовок, который добавляется к каждому ответу.
Зачем это нужно?
API студентов из предыдущего урока работает. Теперь мы хотим знать, что в нём происходит: какой запрос пришёл, какой код вернулся и сколько это заняло. Первый способ, который приходит в голову, — добавить строку лога в каждый маршрут:
@app.get("/students")
def list_students():
logging.info("GET /students")
return students
@app.get("/students/{student_id}")
def get_student(student_id: int):
logging.info("GET /students/" + str(student_id))
...Три проблемы:
- Одна и та же строка копируется в каждый маршрут — пять, десять, пятьдесят раз.
- Новый маршрут, в котором строку забыли, просто не логируется.
- Так нельзя узнать код ответа — 404 и 400 создаются в
raise HTTPException, после строки лога, а 422 создаётся ещё до выполнения маршрута. И нельзя узнать, сколько длился запрос.
Объяснение с основ
Идея — Middleware
Middleware (промежуточный слой) = код, который оборачивает каждый запрос, приходящий на сервер: он выполняется до маршрута, передаёт запрос дальше и выполняется снова после — когда ответ уже на руках.
Client --request--> Middleware --------> Route (your function)
before: start the timer
Client <-response-- Middleware <-------- Route
after: status code, duration, logКак декоратор — для всего сервера
В уроке о декораторах мы написали декоратор, который измеряет время: он оборачивает одну функцию, выполняется до и после неё и вызывает её посередине. Middleware — ровно та же идея, только оно оборачивает все запросы к серверу, не трогая ни одного маршрута.
| Декоратор | Middleware | |
|---|---|---|
| Оборачивает | Одну функцию | Каждый запрос к серверу |
| Вызов посередине | func(*args, **kwargs) | await call_next(request) |
| Где добавляют | @timed над каждой функцией | Один раз, в main.py |
Каждый, кто входит в здание, проходит мимо охранника — неважно, на какой этаж он идёт. Охранник записывает, кто и когда вошёл и кто и когда вышел. Ему не нужно стоять у каждого офиса. Middleware = охранник на входе, а маршруты = офисы.
Почему нужно правильно ждать?
Middleware передаёт запрос маршруту, а затем ждёт ответа. Но сервер получает много запросов одновременно. Если он «застрянет» и будет ждать каждый запрос до конца, все остальные будут стоять в очереди.
Официант передаёт заказ на кухню. Если он стоит у кухни, пока блюдо не будет готово, все остальные столики его ждут. Хороший официант говорит «сообщите мне, когда блюдо будет готово» и продолжает принимать заказы у других столиков. Когда блюдо готово — он возвращается, забирает его и подаёт.
async и await
Именно для этого и нужен await. Внутри Middleware есть строка:
response = await call_next(request)call_next(request)— передаёт запрос дальше: в подходящий маршрут (или в 404, если такого нет) и возвращает ответ. Это заказ на кухню.await— «сообщите мне, когда блюдо будет готово»: подождать ответа маршрута, а тем временем сервер свободен обрабатывать другие запросы. Когда ответ готов, код продолжается со следующей строки.async def—awaitможно писать только внутри функции, определённой черезasync def. Поэтому Middleware определяется так.
Только этот шаблон: async def вверху и await перед call_next. Наши маршруты остаются обычным def — FastAPI сам их обрабатывает. Как сервер управляет ожиданием за кулисами (цикл событий, event loop, asyncio), мы изучим позже в треке.
request и response
| Объект | Что в нём (что будем использовать) |
|---|---|
request.method | "GET", "POST"… |
request.url.path | Путь, например "/students/2" |
response.status_code | Код ответа: 200, 404… |
response.headers | Заголовки ответа — работают как словарь |
Синтаксис
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def my_middleware(request: Request, call_next):
# ... before the route
response = await call_next(request) # wait for the route's response
# ... after the route
return response # always pass the response on@app.middleware("http")— регистрирует функцию как Middleware для каждого HTTP-запроса.request: Request—Requestимпортируется изfastapi.return response— обязательно. Без этого клиент не получит ответа.
Первый пример кода
Лог и измерение времени для каждого запроса в API студентов. В начале students_memory.py (из предыдущего урока) — мы только добавляем, маршруты не меняются:
import logging
import time
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
app = FastAPI(title="Students API")
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
logging.info(request.method + " " + request.url.path + " -> "
+ str(response.status_code) + " (" + str(ms) + " ms)")
return response
# ... students, StudentIn and all the routes stay exactly the sameЗапустите (uvicorn students_memory:app --reload) и отправьте запросы из Swagger (/docs). Лог в терминале:
INFO GET /students -> 200 (0.7 ms)
INFO GET /students/99 -> 404 (0.5 ms)
INFO POST /students -> 201 (1.3 ms)
INFO POST /students -> 400 (0.6 ms)
INFO GET /students/abc -> 422 (0.5 ms)Разбор кода построчно
| Код | Объяснение |
|---|---|
logging.basicConfig(...) | Настройка лога, один раз (уроки логирования). |
@app.middleware("http") | Следующая функция будет выполняться при каждом запросе. |
async def log_requests(request: Request, call_next): | Запрос и call_next — способ продолжить к маршруту. |
start = time.time() | До маршрута: начинаем измерение. |
response = await call_next(request) | Выполнить маршрут и дождаться ответа. |
ms = round((time.time() - start) * 1000, 1) | После: сколько прошло времени, в миллисекундах. |
logging.info(...) | Одна строка на каждый запрос: метод, путь, код и время. Также 404, 400 и 422 — потому что Middleware видит окончательный ответ, даже когда маршрут вообще не выполнялся. |
return response | Передать ответ клиенту. |
Обратите внимание: время очень короткое — данные лежат в списке в памяти. Когда мы подключим сервер к базе данных, увидим, как время растёт, — и такое измерение — именно способ обнаружить, где сервер «теряет» время.
Ещё один пример
Заголовок для каждого ответа
Middleware может и менять ответ. Здесь мы добавляем к каждому ответу заголовок с именем X-Process-Time со временем, которое заняла обработка:
@app.middleware("http")
async def add_process_time(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
response.headers["X-Process-Time"] = str(ms)
return responseВ Swagger в разделе Response headers теперь появляется:
x-process-time: 0.7response.headers[...] = ...— как добавление ключа в словарь.X-в начале имени — соглашение для собственного заголовка, который не входит в стандарт HTTP.- На одном сервере может быть несколько Middleware — каждый запрос проходит через все.
Когда не Middleware?
Middleware подходит для того, что нужно каждому запросу, — лог, измерение времени, заголовки. Правило одного маршрута — например, «минимальный возраст 16» в POST /students — принадлежит самому маршруту, а не коду, который выполняется на всём сервере.
Типичная ошибка
Ошибка 1: забыть return response.
@app.middleware("http")
async def log_requests(request: Request, call_next):
response = await call_next(request)
logging.info(request.url.path)Middleware возвращает None, и клиент получает 500 — при каждом запросе, потому что Middleware выполняется на всех.
Ошибка 2: забыть await.
response = call_next(request)Без await вы не ждёте маршрута — и response не является ответом. Сервер падает на каждом запросе. await перед call_next — всегда.
Ошибка 3: def вместо async def. Без async внутри функции нельзя использовать await: SyntaxError: 'await' outside async function.
Ошибка 4: логика маршрута внутри Middleware. if request.url.path == "/students" and ... — Middleware, которое проверяет конкретные пути, обычно признак того, что код принадлежит маршруту.
Практика
Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.
Упражнение 1: Что выполняется первым?
@app.middleware("http")
async def trace(request: Request, call_next):
print("before", request.method, request.url.path)
response = await call_next(request)
print("after", response.status_code)
return response
@app.get("/hello")
def hello():
print("inside the route")
return {"message": "hi"}Вы отправляете GET /hello. Что будет напечатано и в каком порядке? А что будет напечатано для GET /nothing — несуществующего пути?
Показать решение
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def trace(request: Request, call_next):
print("before", request.method, request.url.path)
response = await call_next(request)
print("after", response.status_code)
return response
@app.get("/hello")
def hello():
print("inside the route")
return {"message": "hi"}
client = TestClient(app)
client.get("/hello")
client.get("/nothing")before GET /hello
inside the route
after 200
before GET /nothing
after 404- Middleware выполняется до маршрута,
await call_next(request)запускает маршрут, а затем мы продолжаем после — с ответом на руках. - Запрос к несуществующему пути тоже проходит через Middleware: маршрута для выполнения нет, FastAPI возвращает 404 — и Middleware это тоже видит.
Упражнение 2: Лог для каждого запроса
Напишите Middleware с именем log_requests, которое пишет в лог на уровне INFO одну строку на каждый запрос: метод, путь и код ответа — в формате ожидаемого вывода.
Используйте это маленькое приложение: список с одним студентом, GET /students и GET /students/{student_id} (который возвращает 404 для несуществующего студента). Настройте лог так, чтобы строки печатались на экран:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
logging.getLogger("httpx").setLevel(logging.WARNING) # hide the test client's own log lines
students = [{"id": 1, "name": "Dana"}]Чтобы проверить без запуска сервера, отправьте три запроса с помощью TestClient из FastAPI — инструмента, который отправляет запросы прямо в приложение из кода Python:
from fastapi.testclient import TestClient
client = TestClient(app)
client.get("/students")
client.get("/students/1")
client.get("/students/99")Ожидаемый вывод:
INFO GET /students -> 200
INFO GET /students/1 -> 200
INFO GET /students/99 -> 404Показать решение
import logging
import sys
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
logging.getLogger("httpx").setLevel(logging.WARNING) # hide the test client's own log lines
app = FastAPI()
students = [{"id": 1, "name": "Dana"}]
@app.middleware("http")
async def log_requests(request: Request, call_next):
response = await call_next(request)
logging.info(request.method + " " + request.url.path + " -> " + str(response.status_code))
return response
@app.get("/students")
def list_students():
return students
@app.get("/students/{student_id}")
def get_student(student_id: int):
for student in students:
if student["id"] == student_id:
return student
raise HTTPException(status_code=404, detail="student not found")
client = TestClient(app)
client.get("/students")
client.get("/students/1")
client.get("/students/99")INFO GET /students -> 200
INFO GET /students/1 -> 200
INFO GET /students/99 -> 404(stream=sys.stdout и строка с httpx нужны только для проверки через TestClient. На настоящем сервере достаточно basicConfig с level и format.)
Одна строка лога в одном месте — и ни один маршрут не изменился. Маршрут, добавленный завтра, тоже автоматически попадёт в лог.
Упражнение 3: Заголовок со временем обработки
Напишите Middleware, которое измеряет, сколько заняла обработка запроса, и добавляет к ответу заголовок с именем X-Process-Time со временем в миллисекундах. В приложении один маршрут — GET /, который возвращает {"message": "hello"}.
Проверьте, что заголовок приходит, — отправьте запрос через TestClient (инструмент, который отправляет запросы прямо в приложение из кода Python) и проверьте заголовки ответа:
from fastapi.testclient import TestClient
client = TestClient(app)
response = client.get("/")
print(response.status_code)
print("Has X-Process-Time:", "x-process-time" in response.headers)Ожидаемый вывод:
200
Has X-Process-Time: TrueПоказать решение
import time
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def add_process_time(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
response.headers["X-Process-Time"] = str(ms)
return response
@app.get("/")
def home():
return {"message": "hello"}
client = TestClient(app)
response = client.get("/")
print(response.status_code)
print("Has X-Process-Time:", "x-process-time" in response.headers)200
Has X-Process-Time: Truetime.time()до и после — точно как в декораторе, который измеряет время.response.headersработает как словарь. Заголовки не зависят от регистра букв, поэтомуx-process-timeнаходитX-Process-Time.
Упражнение 4: async и await
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
logging.info(request.url.path + " (" + str(ms) + " ms)")
return response- Что делает строка
response = await call_next(request)? Объясните с помощью аналогии с официантом и кухней. - Что делает сервер, пока
awaitждёт? - Почему функция определена через
async def, а не черезdef? - Должны ли наши маршруты (
list_students,get_student) тоже бытьasync def?
Показать решение
call_next(request)передаёт запрос маршруту — это заказ на кухню.await— «сообщите мне, когда блюдо будет готово»: подождать ответа маршрута, и когда он готов, код продолжается на следующей строке — с ответом вresponse.- Обрабатывает другие запросы. Как официант, который не стоит у кухни, а продолжает принимать заказы у других столиков, — сервер не «застревает» на одном запросе.
- Потому что
awaitможно писать только внутри функции, определённой черезasync def. С обычнымdefPython вообще не допустит эту строку:SyntaxError: 'await' outside async function. - Нет. Маршруты остаются обычным
def— FastAPI сам их обрабатывает. На этом курсеasyncиawaitвстречаются только в Middleware, в этом шаблоне.
Упражнение 5: Что не так?
@app.middleware("http")
def log_requests(request: Request, call_next):
response = call_next(request)
logging.info(request.url.path)Найдите три проблемы. И когда использовать Middleware неправильно?
Показать решение
defвместоasync def— Middleware в FastAPI должно бытьasync def.- Не хватает
awaitпередcall_next(request)— без него вы не ждёте маршрута, иresponse— не ответ. - Не хватает
return response— Middleware должно вернуть ответ дальше. Без этого клиент ничего не получит, и сервер падает.
Когда не нужно: Middleware подходит для того, что нужно каждому запросу, — лог, измерение времени, заголовки. Бизнес-правило одного маршрута — например, «минимальный возраст 16» в POST /students — принадлежит самому маршруту (или функции проверки), а не Middleware, которое выполняется на всём сервере.
Итоги
- Middleware = код, который оборачивает каждый запрос: до маршрута и после него. Как декоратор — для всего сервера.
@app.middleware("http")+async def+await call_next(request)+return response.- До
call_next— то, что нужно перед запросом (например, начать измерение). После — ответ на руках: код, заголовки, время. - Применения: лог для каждого запроса, измерение времени, заголовки для каждого ответа.
- Правила одного маршрута — в маршруте, а не в Middleware.