Request/Response vs. Publish/Subscribe
Studijní materiál pro začínajícího Rust programátora. Vše je ukázáno na reálném kódu z projektu nexial-ventures/site a jeho PR #4 — Stream AI agentic-loop events over a per-user WebSocket.
1. O co jde
Když spolu dvě části systému komunikují (prohlížeč ↔ server, služba ↔ služba), skoro vždy použiješ jeden ze dvou základních komunikačních vzorů:
- Request/Response (požadavek/odpověď) — jedna strana se zeptá, druhá odpoví. Konverzace tím končí.
- Publish/Subscribe (pub/sub, publikuj/odebírej) — jedna strana se přihlásí k odběru a pak dostává zprávy kdykoli se něco stane, aniž by se musela znovu ptát.
Klíčová otázka, kterou si u každého vzoru polož: kdo iniciuje přenos dat?
| Request/Response | Publish/Subscribe | |
|---|---|---|
| Kdo iniciuje přenos dat | Klient — každý přenos začíná jeho požadavkem | Publisher — data „tlačí" (push), jakmile vzniknou |
| Kardinalita | 1 : 1 (jeden klient, jedna odpověď) | 1 : N (jedna událost, N odběratelů) |
| Vazba (coupling) | Těsná — klient musí vědět koho a na co se ptát | Volná — publisher neví, kdo (a jestli vůbec někdo) poslouchá |
| Čas | Synchronní z pohledu klienta: pošli → čekej → dostaň | Asynchronní: událost přijde, až nastane |
| Typický nosič | HTTP (REST API), gRPC unary, funkce vracející hodnotu | WebSocket, MQTT, Kafka, Redis pub/sub, tokio::sync::broadcast |
| Když se nikdo neptá / neposlouchá | Nic se neděje — data „leží" na serveru | Událost zanikne (pokud ji broker nepersistuje) |
2. Request/Response
Klient pošle požadavek a blokuje se (logicky, ne nutně vláknově), dokud nedostane odpověď. HTTP a nad ním postavené REST API je přesně tento vzor: každý GET /api/pages, POST /api/assistant/sessions/1/messages je jedna dvojice požadavek→odpověď a spojení (logicky) končí.
Silné stránky: jednoduchost, přirozené mapování na „zavolej funkci, dostaň výsledek", snadné kešování, jasné chybové stavy (HTTP status kódy).
Slabina, kterou řeší PR #4: dokud server odpověď nedokončí, klient nevidí nic. V projektu site běží uvnitř send_message celá agentická smyčka AI asistenta — LLM navrhne tool cally, ty se schvalují a spouští, upravují se stránky… a to celé bylo pro klienta neviditelné, dokud tah neskončil (issue #1 v repu):
"The assistant loop ran fully synchronously and silently — send_message only returned once a turn finished or paused for approval, so nothing about tool calls, approval prompts, or page edits was observable while a turn was in flight."
Jediná možnost, jak v čistém request/response světě dostat průběžné novinky, je polling — ptát se pořád dokola („už je něco nového? a teď? a teď?"). To plýtvá požadavky, zatěžuje server a stejně má latenci v délce polling intervalu.
3. Publish/Subscribe
V pub/sub se role obrací. Odběratel (subscriber) se jednou přihlásí k odběru a pak už jen pasivně přijímá. Vydavatel (publisher) posílá události do kanálu/topicu a vůbec neřeší, kdo poslouchá — o rozeslání všem odběratelům se stará prostředník (bus, broker).
Všimni si tří věcí:
- Publisher neblokuje a nečeká na odpověď. Odpálí událost a jede dál.
- Jedna událost, N příjemců. Dva otevřené taby = dva odběratelé, oba dostanou totéž.
- Volná vazba.
loop_driverv PR #4 vůbec neví, jestli je nějaký WebSocket otevřený. Když ne, událost prostě zanikne — a to je tam záměr (best-effort mirror).
4. Jak s tím souvisí REST API a WebSocket
Pozor na častou juniorskou záměnu: REST a WebSocket jsou transporty/protokoly, request/response a pub/sub jsou vzory. Souvislost je „typicky nese", ne „rovná se":
- REST API = request/response nad HTTP. Jinak to ani nejde — HTTP samo je požadavek→odpověď a server nemůže klientovi nic poslat sám od sebe.
- WebSocket = trvalé obousměrné spojení. Vznikne z HTTP požadavku (
Upgrade: websockethandshake — spojení tedy pořád iniciuje klient!), ale pak zůstane otevřené a obě strany mohou kdykoli poslat zprávu. To z něj dělá ideální nosič pro pub/sub směrem k prohlížeči — server může pushovat. - WebSocket sám o sobě není pub/sub — je to jen roura. Pub/sub z něj dělá až logika okolo (kanály, odběry, fan-out). V PR #4 tu logiku dodává
EventBus. - Alternativy pro server→klient push: SSE (Server-Sent Events) — jednosměrný stream nad HTTP, jednodušší než WS; long-polling — nouzovka, emuluje push pomocí dlouho visících požadavků.
Kdo kdy iniciuje — shrnutí
| Fáze | REST | WebSocket |
|---|---|---|
| Navázání spojení | klient (každý požadavek) | klient (jednou, HTTP Upgrade) |
| Přenos dat klient→server | klient | kdokoli, kdykoli |
| Přenos dat server→klient | jen jako odpověď na požadavek | server kdykoli (push) |
5. Význam pro real-time aplikace
Real-time UI (průběh AI agenta, chat, notifikace, ceny, telemetrie z IoT) má společné to, že události vznikají na serveru v čase, který klient nezná. S čistým request/response máš jen dvě špatné možnosti — ptát se často (drahé, stejně opožděné), nebo zřídka (ještě opožděnější). Pub/sub přes push kanál dává:
- Latenci ~0 — událost letí klientovi hned, jak vznikne, žádné čekání na další poll.
- Škálovatelnost — žádná bouře prázdných pollovacích požadavků; jedno spojení na klienta.
- Fan-out zdarma — N tabů/zařízení jednoho uživatele dostane totéž bez práce navíc.
A druhá strana mince, kterou PR #4 řeší vědomě: pub/sub kanál je nespolehlivý ze své podstaty (odběratel nemusí existovat, může být pomalý, spojení může spadnout). Proto v PR platí: „send_message stays synchronous — the socket is a best-effort mirror, not a replacement for the HTTP response." Zdroj pravdy zůstává request/response + databáze; WebSocket je jen živé zrcadlo. Tohle je obecně zdravý návrhový princip.
6. Case study: PR #4 krok za krokem
Celé řešení má tři nové kusy: události (events.rs), bus (event_bus.rs) a WS endpoint (routes/api/ws.rs); do stávající smyčky (loop_driver.rs) se jen vložily publish(...) cally na každém přechodu.
6.1 Události — src/ai/events.rs
Každý „pozorovatelný okamžik" smyčky je varianta enumu. Serde atribut tag = "type" vyrobí JSON, kde klient pozná typ události z pole "type":
#[derive(Clone, Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LoopEvent { AssistantText { session_id: i32, text: Option<String> }, ToolProposed { session_id: i32, tool_call_id: String, name: String, args: Value }, ToolAwaitingApproval { session_id: i32, tool_call_id: String, name: String }, ToolRunning { session_id: i32, tool_call_id: String, name: String }, ToolResult { session_id: i32, tool_call_id: String, name: String, is_error: bool, output: Value }, PageChanged { session_id: i32, kind: PageChangedKind, action: PageChangedAction, path: Option<String>, id: Option<i32> }, TurnDone { session_id: i32 }, TurnPaused { session_id: i32 }, TurnError { session_id: i32, message: String }, }
Na drátě pak lítá např. {"type":"tool_running","session_id":7,"tool_call_id":"x","name":"edit_page"}. Všimni si, že každá varianta nese session_id — kanál je per-user, takže klient s více otevřenými sezeními filtruje na své straně.
Proč
Clone? Broadcast kanál doručuje každému odběrateli kopii zprávy — bezCloneby to nešlo.
6.2 Bus — src/ai/event_bus.rs
Celý pub/sub prostředník má ~45 řádků. Jeden tokio::sync::broadcast kanál na uživatele, uložený v DashMap (konkurentní HashMap — nepotřebuješ Mutex<HashMap>):
const CHANNEL_CAPACITY: usize = 256; pub struct EventBus { channels: DashMap<i32, broadcast::Sender<LoopEvent>>, } impl EventBus { fn sender_for(&self, user_id: i32) -> broadcast::Sender<LoopEvent> { self.channels .entry(user_id) .or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0) .clone() } /// Best-effort publish. `send` vrací Err jen když právě není žádný /// odběratel — což je běžný stav, když není otevřený WebSocket. pub fn publish(&self, user_id: i32, event: LoopEvent) { let _ = self.sender_for(user_id).send(event); } pub fn subscribe(&self, user_id: i32) -> broadcast::Receiver<LoopEvent> { self.sender_for(user_id).subscribe() } }
Poznámky pro juniora:
let _ = ...send(...)— vědomé zahození chyby.broadcast::Sender::sendselže jen tehdy, když nikdo neposlouchá, a to je tady OK (události se nepersistují ani nepřehrávají). Kdyby to byl kritický kanál, tohle by byl bug; tady je to dokumentované rozhodnutí.- Kanál se vytváří lazy — až první
publishnebosubscribepro daného uživatele. EventBusžije vAppStatejakoArc<EventBus>— sdílený stav mezi všemi handlery a tasky, klasický Axum vzor.
6.3 Publisher — src/ai/loop_driver.rs
Do existující smyčky se na každém přechodu jen přidal publish. Ukázka (spuštění povoleného nástroje):
Effect::Allow => { event_bus.publish(user_id, LoopEvent::ToolRunning { session_id, tool_call_id: tc.id.clone(), name: tc.name.clone(), }); match tool_registry.dispatch(&tc.name, tc.args.clone(), &dispatch_ctx).await { Ok(v) => (v, false), Err(e) => (json!({ "text": e.to_string() }), true), } }
Smyčka se nijak nezpomalila ani nezkomplikovala — publish je neblokující a nemůže selhat. Přesně takhle má vypadat volná vazba: publisher o odběratelích nic neví.
6.4 Subscriber — src/routes/api/ws.rs
WebSocket handler v Axum: HTTP požadavek se upgraduje na WS a pak běží smyčka, která přeposílá události jako JSON text frames:
async fn ws_handler( ws: WebSocketUpgrade, State(state): State<AppState>, Extension(user_id): Extension<i32>, ) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, state, user_id)) } async fn handle_socket(mut socket: WebSocket, state: AppState, user_id: i32) { let mut rx = state.event_bus.subscribe(user_id); loop { tokio::select! { recv = rx.recv() => match recv { Ok(event) => { let Ok(text) = serde_json::to_string(&event) else { continue }; if socket.send(Message::Text(text.into())).await.is_err() { break; } } Err(broadcast::error::RecvError::Lagged(n)) => { tracing::warn!(user_id, skipped = n, "ws receiver lagged"); continue; } Err(broadcast::error::RecvError::Closed) => break, }, msg = socket.recv() => match msg { Some(Ok(Message::Close(_))) | None => break, Some(Ok(_)) => continue, Some(Err(_)) => break, }, } } }
Tři důležité detaily:
tokio::select!čeká na dvě věci najednou: novou událost z busu nebo zprávu/zavření od klienta. Kterákoli přijde dřív, ta se obslouží. Bez toho bys nepoznal, že klient odešel, dokud bys mu nezkusil něco poslat.RecvError::Lagged(n)— broadcast kanál má kapacitu 256; když odběratel nestíhá, nejstarší zprávy se zahodí a receiver dostaneLagged. Tady se to jen zaloguje a jede dál — další ukázka best-effort návrhu. (Srovnej: Kafka by zprávy držela,mpsckanál by brzdil odesílatele — každý nástroj dělá jiný trade-off.)- Endpoint je za
require_login_apimiddlewarem a odebírá jen kanál přihlášeného uživatele — izolace dat mezi uživateli je vyřešená už volbou klíče vDashMap.
6.5 Co si z návrhu odnést
- Oba vzory se doplňují, nenahrazují.
send_messagezůstal synchronní request/response (spolehlivý, zdroj pravdy v DB); WebSocket je doplňkový pub/sub mirror pro UX. - Vrstvení: vzor (pub/sub) ↔ mechanismus uvnitř procesu (
broadcastkanál) ↔ transport ke klientovi (WebSocket). Tři nezávislé volby. - Vědomé trade-offy jsou v komentářích — proč je publish best-effort, proč se Lagged jen loguje. Piš to tak taky.
7. Cvičení
- Otevři si PR #4 a najdi všechna místa, kde se volá
event_bus.publish. Ke každému přiřaď variantuLoopEventa rozmysli, co v UI klient uvidí. - Co by se stalo, kdyby
LoopEventneimplementovalClone? Zkus si to představit na úrovni typůbroadcast::Sender. - Proč je kanál per-user a ne jeden globální, nebo per-session? Jaké výhody/nevýhody by měly druhé dvě varianty?
- Napiš si minimální verzi sám: Axum server s
GET /ws,tokio::sync::broadcastkanálem a endpointemPOST /publish, který přijme text a rozešle ho všem připojeným WS klientům. (~60 řádků, viz axum examples níže.) - Rozšíření: jak bys řešil, že klient po výpadku spojení přišel o události? (Nápověda: sekvenční čísla + dohrání z DB přes request/response — přesně proto je dobře, že DB zůstala zdrojem pravdy.)
8. Reference
Vzory
- Hohpe, Woolf: Publish-Subscribe Channel a Request-Reply — Enterprise Integration Patterns (kanonický zdroj názvosloví)
- Publisher-Subscriber pattern — Azure Architecture Center
- Fielding: Architectural Styles and the Design of Network-based Software Architectures — kap. 5, původní definice REST
Protokoly
- RFC 6455 — The WebSocket Protocol (handshake/Upgrade v §1.3 a §4)
- MDN — WebSockets API
- MDN — Server-Sent Events — jednodušší alternativa pro čistě server→klient push
Rust / Tokio / Axum
- Tokio tutorial — Channels — přehled mpsc/oneshot/broadcast/watch a kdy který
- tokio::sync::broadcast — docs.rs — sémantika Lagged, kapacita, klonování zpráv
- axum::extract::ws — docs.rs
- axum examples/websockets a examples/chat — chat = broadcast + WS, téměř stejný vzor jako PR #4
- DashMap — docs.rs
- Serde — enum representations — internally tagged enum použitý u
LoopEvent
Studovaný kód
- nexial-ventures/site — Rust/Axum osobní web s AI asistentem
- PR #4 — Stream AI agentic-loop events over a per-user WebSocket —
src/ai/events.rs,src/ai/event_bus.rs,src/routes/api/ws.rs,src/ai/loop_driver.rs