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.

Tipp

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.

Jó tudni

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.

Megjegyzés

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.

Figyelem

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 `Json` extractor?

Mert 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 a Query a kérés fejrészéből dolgozik (FromRequestParts), a Json a body-ból (FromRequest) – ezért a body-t elfogyasztó extractor mindig az utolsó paraméter kell legyen.
  • A Query és a Json extractorok a serde crate Deserialize trait-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 a Router::with_state segítségével oszthatsz meg alkalmazásállapotot a handler-ek között; a state típusának Clone-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: