Ha webalkalmazást írsz Rust-ban, előbb-utóbb szembe kerülsz a kérdéssel: hogyan kommunikáljon a szerver az adatbázissal úgy, hogy az élvezze a Rust type system előnyeit? Ma ezt nézzük meg gyakorlati oldalról: Axum + sqlx + PostgreSQL kombóval építünk egy egyszerű CRUD API-t.

Miért a sqlx?

A sqlx egy async, tiszta Rust adatbázis driver és query builder, amely nem használ ORM-et a hagyományos értelemben, hanem közel viszi a nyers SQL-t az alkalmazásodhoz — de type-safe módon. A legnagyobb erénye a compile-time query ellenőrzés: a query! és query_as! makrók fordítási időben csatlakoznak egy valódi adatbázishoz (vagy egy offline metadata cache-hez), és ellenőrzik, hogy a lekérdezésed szintaktikailag helyes-e, illetve a visszaadott mezők típusai egyeznek-e azzal, amit a Rust struktúrádban vársz.

Tipp

Ha CI-ban buildelsz és nincs élő adatbázis-kapcsolat, generálj offline query cache-t a cargo sqlx prepare paranccsal. Ez egy .sqlx mappát hoz létre, amit be lehet commitolni a repóba.

A sqlx támogatja a PostgreSQL, MySQL és SQLite backendeket is, mi most a PostgreSQL-re fókuszálunk, mert ez a legnépszerűbb választás production Rust webalkalmazásokhoz.

Projekt felépítése és függőségek

Hozzunk létre egy új projektet, és adjuk hozzá a szükséges függőségeket:

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono", "uuid"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"

A runtime-tokio-rustls feature azt jelzi a sqlx-nek, hogy Tokio runtime-ot és rustls TLS-t szeretnénk. Ha inkább natívabb TLS-t akarsz, a runtime-tokio-native-tls is elérhető opció.

Connection pool konfigurálása

A valós alkalmazásokban minden request nem nyithat új adatbázis-kapcsolatot — ez drasztikusan lelassítaná a szervert. Ehelyett egy connection pool-t hozunk létre, amit az PgPoolOptions segítségével finomhangolhatunk.

use sqlx::postgres::{PgPool, PgPoolOptions};
use std::time::Duration;

async fn init_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
    PgPoolOptions::new()
        .max_connections(10)
        .acquire_timeout(Duration::from_secs(5))
        .connect(database_url)
        .await
}

A pool-t egyszer hozzuk létre az alkalmazás startupjakor, és megosztott állapotként (shared state) adjuk át az Axum routernek. Az Axum with_state metódusa pont ehhez van kitalálva.

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

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenvy::dotenv().ok();
    let database_url = env::var("DATABASE_URL")?;
    let pool = init_pool(&database_url).await?;

    let app = Router::new()
        .route("/health", get(|| async { "OK" }))
        .with_state(pool);

    let addr = "0.0.0.0:3000".parse()?;
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}
Megjegyzés

A .env fájlból a dotenvy crate segítségével olvashatjuk be a DATABASE_URL-t, ami tipikusan így néz ki: postgres://user:password@localhost/mydb.

Migrációk kezelése sqlx-cli-vel

A sémaváltozásokat sose kézzel, hanem migrációs fájlokkal kezeljük. A sqlx-cli telepítése:

cargo install sqlx-cli --no-default-features --features rustls,postgres

Ezután létrehozhatunk egy új migrációt:

sqlx migrate add create_todos_table

Ez egy időbélyeggel ellátott SQL fájlt generál a migrations/ mappában, amit szabadon szerkeszthetünk:

CREATE TABLE todos (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title TEXT NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

A migrációk futtatása:

sqlx migrate run
Jó tudni

A sqlx migrate run parancshoz élő adatbázis-kapcsolat szükséges, és a DATABASE_URL környezeti változónak beállítva kell lennie. Production deploy előtt győződj meg róla, hogy a migrációk automatikusan futnak-e (pl. egy induló szkriptben), különben a séma és a kód elcsúszhat egymástól.

Az alkalmazás indulásakor is futtathatjuk a migrációkat programozottan, ha beágyazzuk a sqlx::migrate!() makróval:

async fn run_migrations(pool: &sqlx::PgPool) -> Result<(), sqlx::migrate::MigrateError> {
    sqlx::migrate!("./migrations").run(pool).await
}

CRUD végpontok Axum handlerekben

Definiáljunk egy Todo struktúrát, amely megfelel az adatbázis táblának, és amelyet a query_as! makró tud kitölteni:

use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Debug, Serialize, sqlx::FromRow)]
struct Todo {
    id: Uuid,
    title: String,
    completed: bool,
    created_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
struct CreateTodo {
    title: String,
}

A listázó és létrehozó handlerek így néznek ki:

use axum::{extract::State, http::StatusCode, Json};
use sqlx::PgPool;

async fn list_todos(
    State(pool): State<PgPool>,
) -> Result<Json<Vec<Todo>>, (StatusCode, String)> {
    let todos = sqlx::query_as::<_, Todo>(
        "SELECT id, title, completed, created_at FROM todos ORDER BY created_at DESC",
    )
    .fetch_all(&pool)
    .await
    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(todos))
}

async fn create_todo(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), (StatusCode, String)> {
    let todo = sqlx::query_as::<_, Todo>(
        "INSERT INTO todos (title) VALUES ($1) RETURNING id, title, completed, created_at",
    )
    .bind(&payload.title)
    .fetch_one(&pool)
    .await
    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok((StatusCode::CREATED, Json(todo)))
}

Figyeld meg, hogy itt a runtime query_as függvényt használtam bind paraméterekkel, nem a query_as! makrót — ez azért van, mert dinamikus SQL-nél (pl. változó számú feltétel) rugalmasabb, viszont elveszíted a compile-time ellenőrzést. Ha rögzített lekérdezésről van szó, mindig érdemesebb a makró verziót használni:

async fn get_todo(
    State(pool): State<PgPool>,
    axum::extract::Path(id): axum::extract::Path<Uuid>,
) -> Result<Json<Todo>, StatusCode> {
    let todo = sqlx::query_as!(
        Todo,
        "SELECT id, title, completed, created_at FROM todos WHERE id = $1",
        id
    )
    .fetch_optional(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let Some(todo) = todo else {
        return Err(StatusCode::NOT_FOUND);
    };

    Ok(Json(todo))
}

Itt egy let-else szerkezetet is bevetettünk, ami kifejezetten jól passzol az "early return, ha nincs érték" mintázathoz — sokkal olvashatóbb, mint egy sima match vagy if let blokk.

A routokat végül így kötjük össze:

let app = Router::new()
    .route("/todos", get(list_todos).post(create_todo))
    .route("/todos/:id", get(get_todo))
    .with_state(pool);

Tranzakciók és hibakezelés

Amikor egy handler-ben több adatbázis-műveletet kell atomikusan végrehajtani, tranzakciót kell nyitnunk. A sqlx Pool::begin() metódusa egy Transaction objektumot ad, amelyet aztán commit()-tel zárunk le — ha ez nem történik meg, a Drop implementáció automatikusan rollback-el.

async fn complete_and_log(
    State(pool): State<PgPool>,
    axum::extract::Path(id): axum::extract::Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    let mut tx = pool
        .begin()
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    sqlx::query!("UPDATE todos SET completed = TRUE WHERE id = $1", id)
        .execute(&mut *tx)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    sqlx::query!(
        "INSERT INTO todo_events (todo_id, event) VALUES ($1, 'completed')",
        id
    )
    .execute(&mut *tx)
    .await
    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    tx.commit()
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(StatusCode::NO_CONTENT)
}
Figyelem

Ha elfelejted &mut *tx-et használni a lekérdezésekben, és véletlenül a pool-t kötöd be helyette, a két query! hívás nem lesz egy tranzakción belül, és a rollback logika sem fog rájuk vonatkozni. A sqlx Executor trait-je miatt könnyű ezt elsőre elrontani.

A fenti kódban (StatusCode, String)-et használunk error type-ként, ami egyszerű, de production kódban jobb egy dedikált error enum-ot bevezetni a thiserror crate-tel, és rá IntoResponse-t implementálni:

use axum::response::{IntoResponse, Response};
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("adatbázis hiba: {0}")]
    Database(#[from] sqlx::Error),
    #[error("nem található")]
    NotFound,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match self {
            AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            AppError::NotFound => StatusCode::NOT_FOUND,
        };
        (status, self.to_string()).into_response()
    }
}

Ezzel a handlerek visszatérési típusa lecsupaszodik Result<Json<T>, AppError>-re, és a ? operátorral kényelmesen tovább tudjuk dobni a sqlx::Error-t a #[from] attribútum miatt.

Összefoglalás

A sqlx + Axum páros erős alapot ad egy production-ready webalkalmazáshoz: a compile-time query ellenőrzés korán elkapja a hibákat, a connection pool gondoskodik a skálázható kapcsolatkezelésről, a sqlx-cli migrációk pedig verziózható, reprodukálható sémaváltozásokat biztosítanak. A tranzakciók helyes kezelése és a dedikált error type-ok bevezetése pedig azt garantálja, hogy az alkalmazásod ne csak gyors, hanem megbízható is legyen. Ha eddig ORM-hez szoktál, érdemes egy kisebb projekten kipróbálni ezt a megközelítést — a nyers SQL feletti type-safe réteg meglepően jó fejlesztői élményt ad.