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.
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(())
}
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
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)
}
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.