bff-python — Průvodce kódem
Teorie: Event loop a GIL
Python má GIL (Global Interpreter Lock) — mutex který zabraňuje skutečnému paralelnímu vykonávání Python bytekódu ve více vláknech zároveň. Dvě Python vlákna nemohou vykonávat Python kód současně.
Důsledek: Python thready jsou zbytečné pro CPU-bound paralelismus (třídění, výpočty). Pro I/O-bound práci (síťová volání, disk) GIL není problém — vlákno stejně čeká na OS a GIL mezitím uvolní.
asyncio obchází GIL jinak: jeden thread, jeden event loop, žádné context switching:
Thread 1 (event loop):
→ request přijde
→ await httpx.get(contacts_url) ← uvolní řízení event loopu
→ event loop obslouží jiný request
→ event loop obslouží jiný request
→ odpověď přišla, pokračuj
→ vrátí odpověď
Tisíce souběžných HTTP requestů = tisíce coroutine objektů v paměti (kilobajty), ne tisíce OS vláken (megabajty).
Teorie: asyncio.gather a fan-out pattern
/api/stats potřebuje statistiky ze tří backendů. Sekvenční přístup:
r1 = await client.get(contacts_url) # čekáme ~5ms
r2 = await client.get(search_url) # čekáme ~5ms
r3 = await client.get(gateway_url) # čekáme ~5ms
# celkem: ~15ms
S asyncio.gather (fan-out):
r1, r2, r3 = await asyncio.gather(
client.get(contacts_url),
client.get(search_url),
client.get(gateway_url),
)
# celkem: max(5ms, 5ms, 5ms) = ~5ms
gather spustí všechny coroutiny "najednou" — event loop střídá mezi nimi při každém await. Výsledná latence je max(t1, t2, t3) místo t1 + t2 + t3. Při třech backendech je to 3× zrychlení.
return_exceptions=True zabrání tomu, aby selhání jednoho backendu zrušilo celý gather — ostatní výsledky se vrátí normálně, chyba se vrátí jako exception objekt v poli výsledků.
Teorie: httpx.AsyncClient a connection reuse
requests (synchronní knihovna) otevírá nové TCP spojení na každý request (pokud se explicitně nepoužívá Session). Pro BFF, která dělá stovky requestů za sekundu na stejné backendy, je to zbytečné.
httpx.AsyncClient udržuje connection pool — sdružené TCP spojení na každý backend. HTTP/1.1 keep-alive nechá spojení otevřené po dokončení requestu pro další použití.
# Špatně — nové spojení na každý request
async def bad():
async with httpx.AsyncClient() as c:
return await c.get(url)
# Správně — sdílené spojení po celou dobu životnosti aplikace
_client: httpx.AsyncClient # globální, inicializován v lifespan()
async def good():
return await _client.get(url)
Sdílený klient se vytvoří jednou v lifespan() a zůstane otevřený po celou dobu běhu aplikace. FastAPI lifespan je context manager — yield odděluje startup (před ním) od shutdown (po něm).
Přehled a BFF pattern
Backend-for-Frontend (BFF) je architektonický vzor: server, který sedí mezi prohlížečem a interní sítí backendových služeb. Prohlížeč nezná nic o kontejnerech, interních adresách ani o tom, kolik backendů existuje — komunikuje jen s jednou adresou (http://localhost:8989).
Bez BFF by prohlížeč musel:
- znát adresy všech tří backendů (
contacts-cpp:8080,search-rust:8081,gateway-go:9000) - řešit CORS hlavičky pro každý z nich zvlášť
- sám paralelizovat volání při sestavování souhrnné stránky (např.
/api/stats)
BFF tyto problémy řeší na straně serveru. Prohlížeč dostane jednu konzistentní API bez závislosti na topologii interní sítě.
bff-python je napsán ve FastAPI a komunikuje s backendy přes sdíleného httpx.AsyncClient. Zároveň obsluhuje statické soubory frontendu — prohlížeč stáhne HTML/JS/CSS ze stejné adresy jako volá API.
Konfigurace a env proměnné
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://localhost:9000")
CONTACTS_URL = os.getenv("CONTACTS_URL", "http://localhost:8080")
SEARCH_URL = os.getenv("SEARCH_URL", "http://localhost:8081")
PORT = int(os.getenv("PORT", "8989"))
STATIC_DIR = Path(__file__).parent / "static"
Každá proměnná používá os.getenv(název, výchozí_hodnota) — dvouhodnotová varianta, která vrátí výchozí hodnotu pokud proměnná prostředí není nastavena. Tento vzor zajistí:
- Lokální vývoj —
python main.pyfunguje bez nastavení čehokoliv, výchozí adresy míří nalocalhost. - Docker / Kubernetes — orchestrátor nastaví proměnné prostředí (
CONTACTS_URL=http://contacts-cpp:8080) a aplikace je automaticky použije.
STATIC_DIR je konstruován relativně vůči souboru main.py pomocí Path(__file__).parent — funguje správně bez ohledu na to, odkud je aplikace spuštěna (cwd se nemění).
lifespan context manager
@asynccontextmanager
async def lifespan(app: FastAPI):
global _client
_client = httpx.AsyncClient(timeout=10.0)
try:
await _client.post(
f"{GATEWAY_URL}/services",
json={
"name": "bff-python",
"url": "http://bff-python:8989",
"health_path": "/health",
},
)
except Exception:
pass
yield
await _client.aclose()
app = FastAPI(title="bff-python", lifespan=lifespan)
@asynccontextmanager — starý i nový způsob
FastAPI původně používal on_startup / on_shutdown event handlery. Od verze 0.95+ je preferovaný způsob lifespan — jeden kontextový manažer, který zahrnuje obě fáze životního cyklu. @asynccontextmanager z contextlib umožňuje zapsat startup a shutdown kód do jedné funkce s yield jako dělítkem.
Proč sdílený httpx.AsyncClient
httpx.AsyncClient interně spravuje connection pool — udržuje otevřená TCP spojení na každý backend a znovu je používá pro další požadavky. Vytvoření nového klienta per požadavek by znamenalo:
- Nové TCP spojení (3-way handshake) pro každý požadavek — latence desítek ms navíc.
- TLS handshake pokud backend používá HTTPS.
- Zbytečnou alokaci a garbage collection.
Sdílený klient tyto náklady amortizuje přes všechny příchozí požadavky.
Registrace u gateway — proč try/except pass
try:
await _client.post(f"{GATEWAY_URL}/services", json={...})
except Exception:
pass # gateway may not be up yet; ignore
Pořadí startu Docker kontejnerů není deterministické. BFF se může spustit dříve než gateway, síť nemusí být ještě připravená, nebo gateway může být v restart loopě. Selhání registrace není fatální — gateway má vlastní health checker, který BFF časem objeví. Ignorování výjimky (pass) je zde správné rozhodnutí: chceme, aby BFF nastartoval i bez gateway.
Proč ne except Exception as e: log.warning(...)
V produkčním systému bychom výjimku logovali pro snadnější debugging. Zde je pass přijatelný zkratkovitý zápis pro vývojové prostředí.
yield — startup vs shutdown
async def lifespan(app: FastAPI):
# === STARTUP ===
_client = httpx.AsyncClient(...)
try:
await _client.post(...) # registrace u gateway
except Exception:
pass
yield # <-- zde FastAPI spustí server a začne přijímat požadavky
# === SHUTDOWN ===
await _client.aclose() # korektní uzavření spojení
Kód před yield se spustí jednou při startu — inicializace sdílených zdrojů. Kód po yield se spustí jednou při vypnutí — úklid. _client.aclose() korektně uzavře všechna otevřená TCP spojení a uvolní porty, místo aby čekal na garbage collector.
_proxy() helper
async def _proxy(request: Request, url: str, **kwargs) -> Response:
body = await request.body()
upstream = await _client.request(
method=request.method,
url=url,
content=body,
headers={
k: v
for k, v in request.headers.items()
if k.lower() not in ("host", "content-length")
},
**kwargs,
)
return Response(
content=upstream.content,
status_code=upstream.status_code,
media_type=upstream.headers.get("content-type", "application/json"),
)
Řádek po řádku:
body = await request.body()
FastAPI tělo požadavku nenačítá automaticky — request.body() je coroutine, která ho přečte z síťového streamu. Explicitní await je nutné: bez něj bychom předali prázdné tělo. Pro GET požadavky vrátí prázdný bytes, což je správné.
method=request.method
Přeposílá HTTP metodu beze změny — GET, POST, PUT, DELETE vše putuje k upstream službě.
content=body
httpx rozlišuje content= (raw bytes) a json= (automatická serializace). Používáme content= protože tělo jsme přečetli jako raw bytes a nechceme ho znovu parsovat ani enkódovat.
Filtrování headers:
host— HTTP headerHostříká serveru, na jakou doménu požadavek míří. BFF pošleHost: localhost:8989(svou vlastní adresu). Upstream backend (contacts-cpp:8080) by tento header odmítl nebo špatně interpretoval, protože se neshoduje s jeho konfigurací.httpxautomaticky nastaví správnýHostpro cílovou URL.content-length— délka těla v bytech.httpxji přepočítá automaticky z předanéhocontent=body. Pokud bychom přeposlali původnícontent-lengtha tělo bylo přeformátováno, došlo by k neshodě délky a upstream by vrátil400 Bad Request.
**kwargs
Volitelné klíčové argumenty předané přímo do httpx.AsyncClient.request(). Endpointy je používají pro params=:
params={"threshold": 0.85} se přemění na query string ?threshold=0.85 v cílové URL — upstream vidí parametr přesně tak, jak ho klient poslal na BFF.
Response(...)
Vrátíme FastAPI Response objekt s raw content z upstream odpovědi. Nepoužíváme JSONResponse záměrně — neznáme a nechceme modifikovat obsah odpovědi, jen ho přeposlat. media_type bereme z upstream Content-Type headeru.
Endpointy — projdi každý
/health
Jednoduchý liveness endpoint. Gateway ho volá každých 10 sekund, aby věděla, zda je tato instance nahoře. Neověřuje dostupnost backendů — jen potvrzuje, že BFF sám běží.
/ (index)
Vrátí hlavní HTML soubor SPA (Single-Page Application). Veškerá interakce s API probíhá přes JavaScript uvnitř stránky. Explicitní route musí existovat, protože StaticFiles mount (mountovaný jako poslední) by pro / vrátil directory listing nebo 403, ne index.html.
/api/contacts — CRUD
@app.get("/api/contacts")
async def get_contacts(request: Request, q: str = ""):
return await _proxy(request, f"{CONTACTS_URL}/contacts", params={"q": q})
@app.post("/api/contacts")
async def create_contact(request: Request):
return await _proxy(request, f"{CONTACTS_URL}/contacts")
@app.put("/api/contacts/{contact_id:path}")
async def update_contact(request: Request, contact_id: str):
return await _proxy(request, f"{CONTACTS_URL}/contacts/{contact_id}")
@app.delete("/api/contacts/{contact_id:path}")
async def delete_contact(request: Request, contact_id: str):
return await _proxy(request, f"{CONTACTS_URL}/contacts/{contact_id}")
Přímé přesměrování na contacts-cpp. Parametr :path v {contact_id:path} umožňuje ID obsahovat lomítka — bez něj by FastAPI vrátil 404 pro ID jako user/123.
/api/search
@app.get("/api/search")
async def search(request: Request, q: str = ""):
return await _proxy(request, f"{SEARCH_URL}/search", params={"q": q})
Přesměruje fulltext dotaz do search-rust, který udržuje invertovaný index nad kontakty.
/api/db
@app.get("/api/db")
async def db_tables(request: Request):
return await _proxy(request, f"{CONTACTS_URL}/db/tables")
Diagnostický endpoint — vrátí raw obsah PostgreSQL tabulek pro vývojářský pohled v UI.
/api/dedup
@app.get("/api/dedup")
async def dedup(request: Request, threshold: float = 0.85):
return await _proxy(request, f"{CONTACTS_URL}/dedup", params={"threshold": threshold})
Najde potenciální duplicity; threshold (0–1) určuje minimální míru podobnosti. Výchozí hodnota 0.85 je praktický kompromis — nízká hodnota způsobí příliš mnoho false positives, vysoká přehlédne reálné duplicity.
/api/analytics
@app.get("/api/analytics")
async def analytics(request: Request):
return await _proxy(request, f"{CONTACTS_URL}/analytics")
Vrátí agregované metriky (rozdělení kategorií, počty kontaktů podle příjmení atd.) z contacts-cpp.
/api/export/vcard a /api/import/vcard
@app.get("/api/export/vcard")
async def export_vcard(request: Request):
return await _proxy(request, f"{CONTACTS_URL}/export/vcard")
@app.post("/api/import/vcard")
async def import_vcard(request: Request):
return await _proxy(request, f"{CONTACTS_URL}/import/vcard")
Export vrátí .vcf soubor se všemi kontakty. Import přijme multipart nebo raw vCard tělo a dávkově naimportuje kontakty do contacts-cpp.
/api/services a /api/topology
@app.get("/api/services")
async def services(request: Request):
return await _proxy(request, f"{GATEWAY_URL}/services")
@app.get("/api/topology")
async def topology(request: Request):
return await _proxy(request, f"{GATEWAY_URL}/topology")
Přesměrují dotaz přímo na gateway-go — registry registrovaných služeb a graf závislostí pro vizualizaci v UI.
/api/stats — paralelní fan-out
Toto je jediný endpoint BFF, který agreguje data z více zdrojů namísto prostého přesměrování.
async def _fetch_stats(name: str, base_url: str) -> dict:
try:
r = await _client.get(f"{base_url}/stats", timeout=5.0)
data = r.json() if r.status_code == 200 else {}
return {"service": name, **data}
except Exception as exc:
return {"service": name, "error": str(exc)}
@app.get("/api/stats")
async def stats():
targets = [
("contacts-cpp", CONTACTS_URL),
("search-rust", SEARCH_URL),
("gateway-go", GATEWAY_URL),
]
results = await asyncio.gather(
*[_fetch_stats(name, url) for name, url in targets]
)
return JSONResponse(content=list(results))
_fetch_stats — degradovaný záznam místo výjimky:
Pokud contacts-cpp neodpovídá, _fetch_stats vrátí {"service": "contacts-cpp", "error": "Connection refused"} místo vyvolání výjimky. To je degraded response vzor — jeden nedostupný backend nezabránil zobrazení statistik ostatních dvou. Caller (stats()) dostane výsledky ze všech tří volání bez ohledu na to, zda selhaly.
asyncio.gather — proč celková latence = max(3 backends):
results = await asyncio.gather(
_fetch_stats("contacts-cpp", CONTACTS_URL), # spustí se okamžitě
_fetch_stats("search-rust", SEARCH_URL), # spustí se okamžitě
_fetch_stats("gateway-go", GATEWAY_URL), # spustí se okamžitě
)
# čeká, dokud všechny tři nedokončí — celková latence = max(latence A, B, C)
asyncio.gather spustí všechny coroutines souběžně v rámci jednoho event loop threadu — ne paralelně v OS smyslu, ale přepíná mezi nimi v každém await bodě. Každé _fetch_stats čeká na síťovou odpověď (await _client.get(...)) — v době čekání event loop obslouží ostatní coroutines. Výsledkem je, že všechna tři volání probíhají překrytě:
Sekvenčně: |--A(3s)--|--B(2s)--|--C(5s)--| = 10s celkem
asyncio.gather: |--A(3s)--|
|--B(2s)--|
|------C(5s)------| = 5s celkem (max)
asyncio není threading
asyncio.gather nevytváří vlákna ani procesy. Vše běží v jednom Python threadu. Paralelismus vzniká tím, že I/O operace (síťová volání) uvolní event loop pro ostatní coroutines. CPU-bound operace by se takto nezrychlily.
StaticFiles mount
StaticFiles mount musí být poslední — FastAPI prochází routes v pořadí registrace a vrátí první shodu. Pokud by StaticFiles byl mountován dříve, zachytil by všechny požadavky na / (včetně /api/contacts, /health atd.) a vrátil by statický soubor nebo 404.
Parametr html=True aktivuje fallback chování: pokud soubor static/api/contacts neexistuje, StaticFiles vrátí 404 a FastAPI pokračuje hledáním další shody — takže explicitní route výše zachytí požadavek správně. Bez html=True by StaticFiles vrátil vlastní 404 okamžitě.
Proč mount na / a ne na /static
Frontend je SPA — HTML, JS a CSS jsou servovány ze stejné základní adresy jako API. Prohlížeč tak nemusí řešit cross-origin požadavky a celá aplikace funguje na jednom portu.
Jak testovat
Spustí unit testy BFF. uv run zajistí správné virtuální prostředí bez nutnosti venv activate.
Vrátí agregované metriky z contacts-cpp — rozdělení kontaktů podle kategorií, počty atd.
Najde potenciální duplicity s mírou podobnosti ≥ 80 %. Nižší threshold vrátí více výsledků (více false positives), vyšší méně (přísnější shoda).
Agregované statistiky ze všech tří backendů souběžně — výsledek by měl přijít do ~5 sekund bez ohledu na to, kolik backendů odpovídá.
curl -X POST http://localhost:8989/api/contacts \
-H "Content-Type: application/json" \
-d '{"name": "Jana Nováková", "email": "jana@example.com"}'
Vytvoří nový kontakt — BFF předá tělo beze změny do contacts-cpp.
Klíčové prvky jazyka Python
@asynccontextmanager — dekorátor + generátor
@asynccontextmanager
async def lifespan(app: FastAPI):
_client = httpx.AsyncClient(timeout=10.0)
yield # ← FastAPI spustí aplikaci zde
await _client.aclose()
@asynccontextmanager kombinuje dva koncepty:
Dekorátor (@) — funkce která obalí jinou funkci. asynccontextmanager přemění generátorovou funkci na context manager použitelný s async with.
Generátor — funkce s yield. Při prvním volání běží do yield (startup). Při ukončení (nebo výjimce) pokračuje za yield (shutdown). Stav mezi yield je zachován v closure.
Výsledek: startup kód → yield → aplikace běží → shutdown kód. Přirozené a čitelné bez nutnosti psát třídu s __aenter__ a __aexit__.
async def a await — coroutiny
async def get_contacts(request: Request, q: str = "") -> Response:
return await _proxy(request, f"{CONTACTS_URL}/contacts", params={"q": q})
async def definuje coroutinu — funkci která může být pozastavena. await je bod pozastavení: předá řízení event loopu, který může obsluhovat jiné coroutiny, a obnoví tuto funkci až výsledek bude dostupný.
Klíčový rozdíl od synchronního kódu: await httpx.get(url) nepozastaví celé vlákno (jako requests.get(url)), jen tuto coroutinu. Event loop mezitím obslouží stovky dalších requestů.
FastAPI dekorátory @app.get — routing jako metadata
@app.get("/api/contacts") je dekorátor který zaregistruje funkci jako handler pro GET /api/contacts. FastAPI při startu projde všechny dekorované funkce a sestaví routing tabulku.
Parametry funkce FastAPI automaticky mapuje:
- request: Request → celý HTTP request objekt
- q: str = "" → query parametr ?q= s výchozí hodnotou ""
- contact_id: str v path /{contact_id:path} → část URL
:path v {contact_id:path} říká FastAPI: tento segment může obsahovat lomítka (pro UUID je to sice zbytečné, ale robustní).
asyncio.gather — paralelní fan-out
results = await asyncio.gather(
fetch_stats(CONTACTS_URL, "contacts"),
fetch_stats(SEARCH_URL, "search"),
fetch_stats(GATEWAY_URL, "gateway"),
return_exceptions=True,
)
gather přijme N coroutin a spustí je konkurentně v jednom event loopu. Vrátí se až všechny skončí (nebo selžou).
return_exceptions=True — kritická volba: bez toho by první výjimka zrušila celý gather a ostatní výsledky by se ztratily. S tímto parametrem se výjimka vrátí jako hodnota v poli výsledků — volající může ošetřit každý výsledek zvlášť.
Typové anotace a str = ""
Python typové anotace (:str, -> Response) jsou za běhu informativní — interpret je ignoruje. FastAPI je ale čte přes inspect modul a používá je pro:
- automatické parsování a validaci parametrů
- generování OpenAPI dokumentace (/docs)
- srozumitelné chybové hlášky při špatném typu
Výchozí hodnota q: str = "" říká: parametr je volitelný, pokud chybí, použij "". FastAPI nepošle ?q= do backendu pokud je prázdné — params se filtrují.