Az előző leckében megismerted, hogyan tudnak async taskok egymással kommunikálni csatornákon (mpsc, oneshot) keresztül. Ez remek alap ahhoz, hogy most egy lépéssel feljebb menjünk: ahelyett, hogy a taskok egymással beszélgetnek egy process-en belül, most azt nézzük meg, hogyan tud a programod a külvilággal – böngészőkkel, más szolgáltatásokkal – beszélgetni HTTP-n keresztül. Ehhez az axum keretrendszert fogjuk használni, amely a Tokio ökoszisztéma egyik legnépszerűbb webszerver-megoldása. Ez a lecke az egész útvonal egyik fordulópontja: innentől kezdve nem csak async kódot írunk, hanem valódi, kívülről elérhető API-t.

Az axum helye a Tokio-ökoszisztémában

Az axum egy async webszerver-keretrendszer, amelyet a Tokio-csapat fejleszt, és amely a Tokio runtime-ra épül. Ez azt jelenti, hogy amit már tudsz a Future-ökről, a .await-ről és a #[tokio::main]-ről, mindet fel fogod tudni használni – az axum nem egy külön világ, hanem egy réteg a Tokio fölött, amely a HTTP-protokoll kezelését és a route-olást (útvonal-illesztést) oldja meg neked.

Ahhoz, hogy axum-mal dolgozhass, két függőségre lesz szükséged a Cargo.toml-ban:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }

A tokio "full" feature-je bekapcsolja azokat a képességeket (pl. hálózati I/O, tokio::net::TcpListener), amelyekre egy webszervernek szüksége van – ezeket korábban esetleg nem mind használtad, de a #[tokio::main] attribútum és a tokio::spawn már ismerős neked az előző leckékből.

Megjegyzés

Az axum verziószámaira mindig érdemes figyelni: a keretrendszer aktívan fejlődik, és a major verziók között API-változások lehetnek. A leckében a 0.8-as vonalat használjuk.

A Router: az útvonalak térképe

Az axum központi fogalma a Router. Ez egy olyan struct, amely leírja, hogy melyik HTTP-metódushoz (GET, POST, stb.) és melyik útvonalhoz melyik handler függvény tartozik. Gondolj rá úgy, mint egy match-re, csak nem egy értéken, hanem a beérkező HTTP-kéréseken – a Router dönti el, hogy egy adott kérést melyik függvénynek adjon oda.

use axum::{routing::get, Router};

async fn hello() -> &'static str {
    "Szia, világ!"
}

async fn health_check() -> &'static str {
    "OK"
}

fn build_router() -> Router {
    Router::new()
        .route("/", get(hello))
        .route("/health", get(health_check))
}

#[tokio::main]
async fn main() {
    let app = build_router();

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .expect("nem sikerült a portra kötni");

    axum::serve(listener, app)
        .await
        .expect("a szerver hibával leállt");
}

A .route(útvonal, metódus(handler)) mintázat a Router API alapköve. A get() függvény az axum::routing modulból egy MethodRouter-t hoz létre, amely azt mondja: "erre az útvonalra GET kéréssel érkező forgalmat ez a handler dolgozza fel". A .route() hívások láncolhatók, így egy Router-en akárhány útvonalat regisztrálhatsz.

Tipp

A Router::new() visszatérési értéke önmagában is egy érvényes, üres router. Ha csak épp elindítod a szervert egy route nélkül, minden kérésre 404-et fog visszaadni – ez segít abban, hogy a route-hiányzást gyorsan felismerd fejlesztés közben.

Handler-ek: async fn-ek mint HTTP-végpontok

A handler egy közönséges async fn, amelyet a Router hív meg, amikor egy kérés illeszkedik az adott útvonalra. A legegyszerűbb handler nem vár be semmilyen paramétert, és egy olyan típust ad vissza, amely implementálja az axum IntoResponse trait-jét – ez a trait mondja meg az axum-nak, hogyan alakítsa a visszatérési értéket valódi HTTP-válasszá.

A &'static str és a String típusok már "dobozból" implementálják az IntoResponse-t, ezért az alábbi két handler minden további nélkül lefordul és működik:

async fn hello() -> &'static str {
    "Szia, világ!"
}

async fn greet_default() -> String {
    format!("Szia, ismeretlen felhasználó!")
}

De nem minden típus alkalmas visszatérési értéknek. Ha egy handler olyan típust ad vissza, amelyhez az axum nem tud HTTP-választ generálni, a fordító hibát jelez:

async fn bad_handler() -> i32 {
    42
}

fn build_router() -> axum::Router {
    axum::Router::new().route("/bad", axum::routing::get(bad_handler))
}
error[E0277]: the trait bound `i32: IntoResponse` is not satisfied
  --> src/main.rs:6:44
   |
6  |     axum::Router::new().route("/bad", axum::routing::get(bad_handler))
   |                                                        ^^^ the trait `IntoResponse` is not implemented for `i32`
   |
   = help: the following types implement the trait: ...

Ez a hibaüzenet pontosan jelzi a problémát: az i32-nek nincs olyan implementációja, amely megmondaná az axum-nak, hogyan lesz belőle HTTP-válasz. A gyakorlatban ez azt jelenti, hogy a handler visszatérési típusát mindig valamilyen "válaszként értelmezhető" típusra kell alakítanod – például String-re, vagy (ahogy a következő leckében látni fogod) az axum saját típusaira, mint a Json.

Megjegyzés

Előretekintés: a handler-ek paramétereket is fogadhatnak (pl. az URL-ből vagy a kérés törzséből kinyert adatokat) – ezt extractoroknak hívják, és ez lesz a következő lecke fő témája.

Az is fontos, hogy a handler egy async fn, tehát belül szabadon használhatsz .await-et – például ha egy másik async függvényt hívsz meg, vagy (ahogy majd egy későbbi leckében látni fogod) adatbázis-lekérdezést futtatsz.

A szerver elindítása: tokio + axum::serve

Ahhoz, hogy a Router-ből valódi, hálózaton figyelő szerver legyen, két lépés szükséges: egy TcpListener-t kell nyitni egy porton, majd az axum::serve függvénynek átadni ezt a listener-t és a Router-t.

#[tokio::main]
async fn main() {
    let app = axum::Router::new()
        .route("/", axum::routing::get(|| async { "Szia, világ!" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .expect("nem sikerült a portra kötni");

    println!("Szerver fut a 3000-es porton");

    axum::serve(listener, app)
        .await
        .expect("a szerver hibával leállt");
}

Ez a kód pontosan az eddig ismert eszközökkel dolgozik: a #[tokio::main] létrehozza a runtime-ot (ahogy az előző leckékben már megszoktad), a TcpListener::bind(...).await egy async I/O-hívás, amely Result-ot ad vissza (ezért van rajta .expect), és az axum::serve(...).await maga is egy Future, amelyet a runtime futtat – egészen addig, amíg a szervert le nem állítod, vagy hiba nem történik.

Figyeld meg, hogy a Router-be egy closure-t is átadtunk handlerként (|| async { "Szia, világ!" }). Ez ugyanúgy egy async-blokkot visszaadó függvény, mint egy külön definiált async fn, csak helyben, névtelenül írtuk le – kis, egysoros handlerekhez ez praktikus lehet, nagyobb logikánál viszont érdemes külön nevesített async fn-t írni, ahogy az előző szakaszban tettük.

Figyelem

Az axum::serve(...).await sora blokkol (pontosabban: a runtime a Future-t addig futtatja, amíg a szerver le nem áll) – utána írt kód csak leállás után futna le. Ha teszteléshez szeretnéd a szervert egy másik taskban futtatni, a tokio::spawn-t is bevethetnéd, ahogy az korábbi leckében megismertél.

Próbáld ki!

Vegyél egy meglévő (vagy az itt bemutatott) axum-projektet, és adj hozzá egy új route-ot /about útvonalon, GET metódussal, amely egy rövid, saját szöveget ad vissza (pl. a neved és pár szó a projektről). Futtasd a cargo run paranccsal, majd nyisd meg a böngészőben a http://localhost:3000/about címet, és ellenőrizd, hogy a válasz megjelenik-e. Próbálj ki egy nem létező útvonalat is (pl. /nincs-ilyen) – figyeld meg, milyen választ ad az axum alapból.

Önellenőrzés

  1. Mi a különbség a Router és egy handler függvény szerepe között?
Megoldás A `Router` egy térkép, amely megmondja, hogy melyik HTTP-metódushoz és útvonalhoz melyik handler tartozik – ő végzi a kérések "irányítását". A handler maga egy `async fn`, amely a tényleges munkát végzi el egy adott kérésre, és egy `IntoResponse`-t implementáló típust ad vissza, amelyből az axum HTTP-választ épít.
  1. Miért nem fordul le az a handler, amely i32-t ad vissza, miközben egy &'static str-et visszaadó handler rendben van?
Megoldás Az axum minden handler visszatérési típusától megköveteli, hogy implementálja az `IntoResponse` trait-et, mert ez mondja meg, hogyan alakuljon a Rust-érték valódi HTTP-válasszá (státuszkód, törzs, stb.). A `&'static str` és a `String` már rendelkeznek ilyen implementációval, az `i32` viszont nem, ezért a fordító `E0277` hibát jelez trait bound hiánya miatt.
  1. Miért van szükség #[tokio::main]-re, és mi a szerepe a .await-nek az axum::serve hívásban?
Megoldás Az `axum::serve(...)` egy `Future`-t ad vissza, amelyet valakinek futtatnia (poll-oznia) kell – ehhez kell egy executor/runtime. A `#[tokio::main]` attribútum gondoskodik arról, hogy a `main` függvény async legyen, és a Tokio runtime elindítsa és futtassa ezt (és a benne lévő összes `Future`-t, beleértve az `axum::serve`-ét is). A `.await` azt jelzi, hogy a `main` task ezen a ponton "átadja a vezérlést" a runtime-nak, amíg a szerver Future-je készen nem áll (vagyis amíg a szerver fut, vagy amíg hiba nem történik).

Összefoglalás és továbblépés

  • Az axum a Tokio runtime-ra épülő async webszerver-keretrendszer: mindent, amit a Future-ökről és a #[tokio::main]-ről tudsz, itt is felhasználod.
  • A Router::new().route(útvonal, metódus(handler)) mintázattal regisztrálhatsz HTTP-végpontokat; a .route() hívások láncolhatók.
  • A handler egy async fn, amelynek visszatérési típusa implementálnia kell az IntoResponse trait-et (pl. &'static str, String) – különben fordítási hibát kapsz.
  • A szerver elindítása két lépés: TcpListener::bind(...).await, majd axum::serve(listener, router).await.
  • A következő leckében megnézzük, hogyan tudnak a handler-ek paramétereket fogadni extractorokkal (pl. URL-részekből vagy a kérés törzséből), és hogyan lehet megosztott állapotot (state) átadni a handler-eknek.

További olvasmányok: