Miért van szükség extractorokra?
Az előző leckében, a "Router és handler-ek"-ben megismerted, hogyan regisztrálsz útvonalakat egy Axum Router-en, és hogyan néz ki egy minimális handler függvény. Az a handler eddig legfeljebb egy fix választ adott vissza – de egy valódi API-nak szüksége van magára a bejövő kérés adataira is: melyik erőforrásra hivatkozik az URL, milyen szűrőparamétereket küldött a kliens, és mit tartalmaz a kérés törzse (body). Az Axum ezt a feladatot az extractorokra bízza: olyan típusokra, amelyeket a handler paramétereként felveszel, és amelyeket a keretrendszer automatikusan feltölt a kérésből kinyert adatokkal, mielőtt a handler-ed lefutna.
Ez a lecke három konkrét extractort mutat be – Path, Query, Json –, majd megnézzük, hogyan érhetsz el megosztott állapotot (State) a handler-ek között, és miért van szükség az Arc-ra ehhez.
Path extractor: adat az URL útvonalából
A Path<T> extractor az URL-ben szereplő dinamikus szegmenseket olvassa ki. Az útvonal-mintában a {...} jelöli a dinamikus részt (axum 0.8 ezt a szintaxist használja), a handler paraméterlistájában pedig a Path mintaillesztéssel bontod ki az értéket:
use axum::{extract::Path, routing::get, Router};
async fn get_user(Path(user_id): Path<u32>) -> String {
format!("A keresett felhasználó azonosítója: {user_id}")
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users/{user_id}", get(get_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Ha az URL-ben több dinamikus szegmens is van, tuple-t is kiolvashatsz belőle, pl. Path((group_id, user_id)): Path<(u32, u32)>. Az extractor a háttérben megpróbálja a szöveges szegmenst a megadott típusra (itt u32-re) konvertálni; ha ez sikertelen, az Axum automatikusan egy 400 Bad Request választ küld a kliensnek – neked ezzel nem kell foglalkoznod.
A Path és a hozzá hasonló extractorok a FromRequestParts trait-et implementálják: csak a kérés fejrészéből (útvonal, header-ek) dolgoznak, a body-hoz nem férnek hozzá. Ez azért fontos, mert a body csak egyszer olvasható ki – erre hamarosan visszatérünk.
Query extractor és a serde szerepe
A lekérdezési paraméterek (a ? utáni rész, pl. ?q=rust&limit=10) kiolvasásához a Query<T> extractort használod. A T típusnak ilyenkor implementálnia kell a serde crate Deserialize trait-jét – ez egy önálló, rendkívül elterjedt crate, amelynek a feladata, hogy különböző formátumok (JSON, query string stb.) és Rust típusok között alakítson át adatokat. A #[derive(Deserialize)] attribútum automatikusan legenerálja azt a kódot, amely egy struct-ot fel tud építeni a bejövő adatokból, neked nem kell kézzel megírnod a konverziót.
use axum::{extract::Query, routing::get, Router};
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchParams {
q: String,
limit: Option<u32>,
}
async fn search(Query(params): Query<SearchParams>) -> String {
format!("Keresés: '{}', limit: {:?}", params.q, params.limit)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/search", get(search));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Figyeld meg, hogy a limit mezőt Option<u32>-ként vettük fel: így a paraméter elhagyható a kérésből, és None lesz az értéke. Ehhez a Cargo.toml-ban a serde crate-et derive feature-rel kell felvenned:
[dependencies]
serde = { version = "1", features = ["derive"] }
Json extractor: a kérés törzsének deszerializálása
A Json<T> extractor a kérés body-ját JSON-ként dolgozza fel, és a serde_json crate segítségével a T típusra deszerializálja. Ehhez is a Deserialize trait-et kell implementálnia a T típusnak.
use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
// Egyszerűsítés kedvéért fix azonosítót adunk vissza.
Json(User {
id: 1,
name: payload.name,
})
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
A visszatérési érték is Json<User> – az Axum a Serialize trait-et megkövetelve automatikusan JSON választ épít belőle, és beállítja a megfelelő Content-Type header-t is.
A Json<T> a FromRequest trait-et implementálja (nem FromRequestParts-ot), mert a kérés body-jához fér hozzá, és a body csak egyszer olvasható ki. Ebből következik egy fontos szabály: a body-t elfogyasztó extractor csak a handler utolsó paramétere lehet, minden más extractor (Path, Query, State) előtte kell szerepeljen.
Ha felcseréled a sorrendet, a kód nem fordul le. Az alábbi részlet (nem önállóan futtatható, csak a hiba szemléltetésére szolgál) mutatja a problémát:
// Részlet – a sorrend hibás, ez önmagában nem fordítható program.
async fn create_user_bad(
Json(payload): Json<CreateUser>,
Path(group_id): Path<u32>,
) -> Json<User> {
todo!()
}
A pontos hibaüzenet szövege verziónként eltérhet, de a lényege mindig ehhez hasonló:
error[E0277]: the trait bound `Json<CreateUser>: FromRequestParts<_>` is not satisfied
--> src/main.rs:3:5
|
3 | Json(payload): Json<CreateUser>,
| ^^^^ `Json<CreateUser>` implementálja a `FromRequest`-et, nem a
| `FromRequestParts`-ot, ezért csak az utolsó paraméter lehet
State és Arc: megosztott alkalmazásállapot
A legtöbb valódi szervernek szüksége van közös állapotra, amelyet minden kérés elér – pl. egy adatbázis-kapcsolat, egy konfiguráció, vagy most egy egyszerű memóriabeli lista. Ezt a State<T> extractorral és a Router::with_state metódussal éred el.
Az Axum minden bejövő kéréshez lekloonozza a state-et, mielőtt átadná a handlernek, ezért a state típusának Clone-nak kell lennie. Ha a state nagy vagy megosztott adatot tartalmaz, nem szeretnéd, hogy minden kéréshez teljes másolat készüljön belőle – itt jön be az Arc<T> (Atomically Reference Counted): a klónozása csak egy referenciaszámlálót növel, a valódi adat egyszer létezik a memóriában, és biztonságosan megosztható több, egymástól függetlenül futó task között is.
Ha az állapotot módosítani is szeretnéd, az Arc mellé egy zárolás is szükséges, mert az Arc<T> önmagában csak megosztott, nem kizárólagos hozzáférést ad. Erre a std::sync::Mutex<T> a legegyszerűbb választás: a .lock() metódussal kizárólagos hozzáférést kapsz az adathoz, amíg a visszaadott érték életben van.
use axum::{
extract::{Path, Query, State},
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct AppState {
users: Arc<Mutex<Vec<User>>>,
}
#[derive(Clone, Serialize)]
struct User {
id: u32,
name: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Deserialize)]
struct ListOptions {
limit: Option<usize>,
}
async fn create_user(
Path(group_id): Path<u32>,
Query(options): Query<ListOptions>,
State(state): State<AppState>,
Json(payload): Json<CreateUser>,
) -> Json<User> {
let mut users = state.users.lock().unwrap();
let new_id = users.len() as u32 + 1;
let user = User {
id: new_id,
name: payload.name,
};
users.push(user.clone());
println!("Csoport: {group_id}, limit: {:?}", options.limit);
Json(user)
}
#[tokio::main]
async fn main() {
let state = AppState {
users: Arc::new(Mutex::new(Vec::new())),
};
let app = Router::new()
.route("/groups/{group_id}/users", post(create_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Figyeld meg az extractorok sorrendjét a create_user szignatúrájában: Path, Query és State (mind FromRequestParts) megelőzi a body-t elfogyasztó Json-t. Az AppState maga Clone, de a belső Vec<User> sosem másolódik – csak az Arc referenciaszámlálója nő minden klónozásnál.
A std::sync::Mutex zárolása rövid, szinkron kódrészletekhez ideális – amíg tartod a zárat, ne hívj .await-et, mert blokkolná az executor szálat. Ha a zárolt szakaszban aszinkron munka kellene, a tokio::sync::Mutex a jó választás; ezzel adatbázis-hozzáférés kapcsán még találkozol.
Ha elfelejted meghívni a .with_state(state)-et, vagy a State<T> típusa nem egyezik a Router-hez rendelt state típusával, fordítási hibát kapsz – ezt az Axum típusszinten ellenőrzi, tehát ez sosem futásidejű meglepetés.
Próbáld ki!
Vedd az utolsó, teljes create_user példát, és bővítsd ki egy új GET /groups/{group_id}/users handlerrel, amely State<AppState>-et és Path<u32>-t fogad, majd JSON-ban visszaadja az összes eddig felvett felhasználót (Json<Vec<User>>). Ügyelj arra, hogy a Mutex zárolását (.lock().unwrap()) csak addig tartsd, ameddig szükséges.
Önellenőrzés
Megoldás
Miért nem szerepelhet egy handlerben két `JsonMert a Json<T> a kérés body-ját fogyasztja el (FromRequest), a HTTP body pedig csak egyszer olvasható ki – a második Json extractornak már nem lenne mit beolvasnia. Ezért kell a body-t elfogyasztó extractornak mindig az utolsó paraméternek lennie.
Megoldás
Mi a különbség a `Path` és a `Query` extractor között?A Path az URL útvonal-szegmenseiből (pl. /users/{id} mintában az id helyére kerülő értékből) olvas ki adatot, pozíció szerint. A Query a ? utáni lekérdezési paraméterekből (pl. ?limit=10) dolgozik, kulcs–érték párok alapján, jellemzően egy Deserialize-t implementáló struct-ra képezve le őket.
Megoldás
Miért kell `Arc`-ba csomagolni a `State`-ben tárolt megosztott adatot, ha az Axum egyébként is klónozza a state-et minden kéréshez?Mert az Axum a Clone bound miatt minden kéréshez lekloonozza a state értékét. Ha a belső adat nincs Arc-ba csomagolva, minden klónozás egy teljes, független másolatot készítene – a handlerek nem látnák egymás módosításait. Az Arc klónozása ezzel szemben csak egy referenciaszámlálót növel, a mögötte lévő adat egyetlen helyen létezik a memóriában, és minden klón ugyanarra a helyre mutat, biztonságosan megosztva a taskok között.
Összefoglalás és mi jön legközelebb
- Az extractorok (
Path,Query,Json) automatikusan feltöltik a handler paramétereit a kérésből kinyert adatokkal, mielőtt a handler kódja lefutna. - A
Pathés aQuerya kérés fejrészéből dolgozik (FromRequestParts), aJsona body-ból (FromRequest) – ezért a body-t elfogyasztó extractor mindig az utolsó paraméter kell legyen. - A
Queryés aJsonextractorok aserdecrateDeserializetrait-jére épülnek; a#[derive(Deserialize)]és#[derive(Serialize)]attribútumok generálják a szükséges konverziós kódot. - A
State<T>és aRouter::with_statesegítségével oszthatsz meg alkalmazásállapotot a handler-ek között; a state típusánakClone-nak kell lennie. - Az
Arc<T>biztosítja, hogy a megosztott adat egyszer létezzen a memóriában, miközben biztonságosan elérhető több párhuzamosan futó task-ból is; módosítható állapothoz emellett egy zárolásra (pl.Mutex<T>) is szükség van.
A következő leckében ("Middleware és tracing") megnézzük, hogyan lehet közös logikát (naplózás, kérés-időzítés, hitelesítés előkészítése) minden útvonalra egységesen rárakni anélkül, hogy minden handlerbe bele kellene írni, és bevezetjük a strukturált naplózást a tracing crate segítségével.
További olvasmány: