Middleware — קוד שרץ על כל בקשה

מטרות השיעור
  • להסביר מה זה Middleware ואיזו בעיה הוא פותר
  • לכתוב Middleware עם @app.middleware(“http”) ו-call_next
  • להבין מה עושים async ו-await בתוך Middleware
  • להוסיף ל-API הסטודנטים לוג ומדידת זמן לכל בקשה, בלי לשנות אף Route

מה נלמד?

  • איזו בעיה יש כשרוצים לעשות משהו בכל בקשה.
  • מה זה Middleware, ואיך הוא קשור ל-Decorator שכבר מכירים.
  • @app.middleware("http"), call_next, async ו-await.
  • לוג ומדידת זמן לכל בקשה ב-API הסטודנטים, ו-Header שמתווסף לכל תשובה.

למה צריך את זה?

ה-API הסטודנטים מהשיעור הקודם עובד. עכשיו רוצים לדעת מה קורה בו: איזו בקשה הגיעה, איזה קוד חזר, וכמה זמן היא לקחה. הדרך הראשונה שעולה לראש — להוסיף שורת לוג לכל Route:

@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))
    ...

שלוש בעיות:

  1. אותה שורה מועתקת לכל Route — חמש, עשר, חמישים פעמים.
  2. Route חדש שבו שכחו את השורה — פשוט לא נרשם.
  3. אין דרך לדעת כך את קוד התשובה — 404 ו-400 נוצרים ב-raise HTTPException, אחרי שורת הלוג, ו-422 נוצר עוד לפני שה-Route רץ. וגם לא כמה זמן הבקשה לקחה.

הסבר מהבסיס

הרעיון — Middleware

Middleware (תוכנת ביניים) = קוד שעוטף כל בקשה שמגיעה לשרת: הוא רץ לפני ה-Route, מעביר את הבקשה הלאה, ורץ שוב אחרי — כשהתשובה כבר ביד.

Client --request--> Middleware --------> Route (your function)
                    before: start the timer
Client <-response-- Middleware <-------- Route
                    after: status code, duration, log

כמו Decorator — לכל השרת

בשיעור על דקורטורים כתבנו Decorator שמודד זמן: הוא עוטף פונקציה אחת, רץ לפניה ואחריה, וקורא לה באמצע. Middleware הוא בדיוק אותו רעיון — רק שהוא עוטף את כל הבקשות לשרת, בלי לגעת באף Route.

DecoratorMiddleware
עוטףפונקציה אחתכל בקשה לשרת
הקריאה באמצעfunc(*args, **kwargs)await call_next(request)
איפה מוסיפים@timed מעל כל פונקציהפעם אחת, ב-main.py
אנלוגיה: השומר בכניסה לבניין

כל מי שנכנס לבניין עובר ליד השומר — לא משנה לאיזו קומה הוא הולך. השומר רושם מי נכנס ומתי, ומי יצא ומתי. הוא לא צריך לעמוד ליד כל משרד. Middleware = השומר בכניסה, וה-Routes = המשרדים.

למה צריך לחכות נכון?

ה-Middleware מעביר את הבקשה ל-Route, ואז מחכה לתשובה. אבל שרת מקבל הרבה בקשות בבת אחת. אם הוא “נתקע” ומחכה לכל בקשה עד הסוף, כל השאר עומדות בתור.

אנלוגיה: מלצר ומטבח

מלצר מעביר הזמנה למטבח. אם הוא עומד ליד המטבח עד שהמנה מוכנה — כל השולחנות האחרים מחכים לו. מלצר טוב אומר “תודיעו לי כשהמנה מוכנה”, וממשיך לקבל הזמנות משולחנות אחרים. כשהמנה מוכנה — הוא חוזר, לוקח אותה ומגיש.

async ו-await

בדיוק בשביל זה יש await. בתוך ה-Middleware מופיעה השורה:

response = await call_next(request)
  • call_next(request) — מעביר את הבקשה הלאה: ל-Route המתאים (או ל-404 אם אין כזה), ומחזיר את התשובה. זו ההזמנה למטבח.
  • await — “תודיעו לי כשהמנה מוכנה”: מחכים לתשובה של ה-Route, ובינתיים השרת פנוי לטפל בבקשות אחרות. כשהתשובה מוכנה, הקוד ממשיך מהשורה הבאה.
  • async def — את await מותר לכתוב רק בתוך פונקציה שמוגדרת עם async def. לכן ה-Middleware מוגדר כך.
כמה צריך לדעת על זה בקורס?

רק את הדפוס הזה: async def למעלה, ו-await לפני call_next. ה-Routes שלנו נשארים def רגיל — FastAPI מטפל בהם בעצמו. איך השרת מנהל את ההמתנה מאחורי הקלעים (event loop, asyncio) — נלמד בהמשך המסלול.

request ו-response

אובייקטמה יש בו (מה שנשתמש בו)
request.method"GET", "POST"…
request.url.pathהנתיב, למשל "/students/2"
response.status_codeקוד התשובה: 200, 404…
response.headersה-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 (מהשיעור הקודם) — רק מוסיפים, אף Route לא משתנה:

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). הלוג ב-Terminal:

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(...)הגדרת הלוג, פעם אחת (נושא Python: Logging).
@app.middleware("http")הפונקציה הבאה תרוץ על כל בקשה.
async def log_requests(request: Request, call_next):הבקשה, ו-call_next — הדרך להמשיך ל-Route.
start = time.time()לפני ה-Route: מתחילים למדוד.
response = await call_next(request)מריצים את ה-Route ומחכים לתשובה.
ms = round((time.time() - start) * 1000, 1)אחרי: כמה זמן עבר, במילישניות.
logging.info(...)שורה אחת לכל בקשה: Method, נתיב, קוד וזמן. גם 404, 400 ו-422 — כי ה-Middleware רואה את התשובה הסופית, גם כשה-Route בכלל לא רץ.
return responseמעבירים את התשובה ללקוח.

שימו לב: הזמנים קצרים מאוד — הנתונים ברשימה בזיכרון. כשנחבר את השרת למסד נתונים, נראה את הזמנים עולים — ומדידה כזו היא בדיוק הדרך לגלות איפה השרת “מבזבז” זמן.

דוגמה נוספת

Header לכל תשובה

Middleware יכול גם לשנות את התשובה. כאן מוסיפים לכל תשובה Header בשם 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.7
  • response.headers[...] = ... — כמו הוספת מפתח למילון.
  • X- בתחילת השם — מוסכמה ל-Header שלנו, שאינו חלק מהתקן של HTTP.
  • אפשר כמה Middlewares באותו שרת — כל בקשה עוברת דרך כולם.

מתי לא Middleware?

Middleware מתאים לדברים שכל בקשה צריכה — לוג, מדידת זמן, Headers. כלל של Route אחד — למשל “גיל מינימלי 16” ב-POST /students — שייך ל-Route עצמו, ולא לקוד שרץ על כל השרת.

טעות נפוצה

הטעות 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, לא מחכים ל-Route — ו-response הוא לא תשובה. השרת נכשל בכל בקשה. await לפני call_next, תמיד.

הטעות 3: def במקום async def. בלי async, אי אפשר להשתמש ב-await בתוך הפונקציה: SyntaxError: 'await' outside async function.

הטעות 4: לוגיקה של Route בתוך Middleware. if request.url.path == "/students" and ... — Middleware שבודק נתיבים ספציפיים הוא בדרך כלל סימן שהקוד שייך ל-Route.

תרגול

התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.

תרגיל 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 רץ לפני ה-Route, await call_next(request) מריץ את ה-Route, ואז ממשיכים אחרי — עם התשובה ביד.
  • גם בקשה לנתיב שלא קיים עוברת דרך ה-Middleware: אין Route שירוץ, ו-FastAPI מחזיר 404 — וה-Middleware רואה גם אותו.

תרגיל 2: לוג לכל בקשה

כתבו Middleware בשם log_requests שרושם ללוג, ברמת INFO, שורה לכל בקשה: ה-Method, הנתיב וקוד התשובה — בפורמט שבפלט הצפוי.

השתמשו באפליקציה הקטנה הזו: רשימה עם סטודנט אחד, 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.)

שורת לוג אחת, במקום אחד — ואף Route לא השתנה. גם Route שיתווסף מחר ייכנס ללוג אוטומטית.

תרגיל 3: Header עם זמן הטיפול

כתבו Middleware שמודד כמה זמן לקח לטפל בבקשה, ומוסיף לתשובה Header בשם X-Process-Time עם הזמן במילישניות. באפליקציה יש Route אחד, GET / שמחזיר {"message": "hello"}.

בדקו שה-Header מגיע — שולחים בקשה עם TestClient (כלי ששולח בקשות ישר לאפליקציה מתוך קוד Python) ובודקים את ה-Headers של התשובה:

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: True
  • time.time() לפני ואחרי — בדיוק כמו ב-Decorator שמודד זמן.
  • response.headers עובד כמו מילון. ה-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
  1. מה עושה השורה response = await call_next(request)? הסבירו עם אנלוגיית המלצר והמטבח.
  2. מה השרת עושה בזמן ש-await מחכה?
  3. למה הפונקציה מוגדרת עם async def ולא עם def?
  4. האם גם ה-Routes שלנו (list_students, get_student) צריכים להיות async def?
הצג פתרון
  1. call_next(request) מעביר את הבקשה ל-Route — זו ההזמנה למטבח. await הוא “תודיעו לי כשהמנה מוכנה”: מחכים לתשובה של ה-Route, וכשהיא מוכנה הקוד ממשיך לשורה הבאה — עם התשובה ב-response.
  2. מטפל בבקשות אחרות. כמו מלצר שלא עומד ליד המטבח אלא ממשיך לקבל הזמנות משולחנות אחרים — השרת לא “נתקע” על בקשה אחת.
  3. כי את await מותר לכתוב רק בתוך פונקציה שמוגדרת עם async def. עם def רגיל, Python לא תאפשר את השורה בכלל: SyntaxError: 'await' outside async function.
  4. לא. ה-Routes נשארים 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?

הצג פתרון
  1. def במקום async def — Middleware ב-FastAPI חייב להיות async def.
  2. חסר await לפני call_next(request) — בלעדיו לא מחכים ל-Route, ו-response הוא לא התשובה.
  3. חסר return response — ה-Middleware חייב להחזיר את התשובה הלאה. בלעדיו הלקוח לא מקבל כלום, והשרת נכשל.

מתי לא: Middleware מתאים לדברים שכל בקשה צריכה — לוג, מדידת זמן, Headers. כלל עסקי של Route אחד — למשל “גיל מינימלי 16” ב-POST /students — שייך ל-Route עצמו (או לפונקציית ולידציה), לא ל-Middleware שרץ על כל השרת.

סיכום

  • Middleware = קוד שעוטף כל בקשה: לפני ה-Route, ואחריו. כמו Decorator — לכל השרת.
  • @app.middleware("http") + async def + await call_next(request) + return response.
  • לפני call_next — מה שצריך לפני הבקשה (למשל, להתחיל למדוד). אחרי — יש תשובה ביד: קוד, Headers, זמן.
  • שימושים: לוג לכל בקשה, מדידת זמן, Headers לכל תשובה.
  • כללים של Route אחד — ב-Route, לא ב-Middleware.

דוקומנטציה רשמית