Разработка бэкенда сайта на Rust (Axum)
Axum — HTTP-фреймворк из экосистемы Tokio, созданный командой Tokio. Его отличие от Actix Web — архитектурная близость к Tower middleware stack и более идиоматичный async Rust. Извлечение из запроса типизировано на уровне системы типов: если компилятор пропустил — запрос валиден. Если не пропустил — ошибка в коде, а не в рантайме.
Архитектурные отличия от Actix
Actix работает на собственном акторном рантайме (исторически). Axum — поверх Tokio напрямую, что упрощает интеграцию с остальными crates экосистемы: tower, tower-http, tracing. Нет отдельного треда для каждого воркера — всё в одном Tokio-рантайме. Это удобно при написании тестов и при совместном использовании с gRPC через tonic.
Базовая структура
// main.rs
use axum::{routing::{get, post}, Router};
use sqlx::PgPool;
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer, compression::CompressionLayer};
mod config;
mod errors;
mod handlers;
mod models;
mod middleware;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: Arc<config::Config>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
.init();
let cfg = Arc::new(config::Config::from_env());
let pool = PgPool::connect(&cfg.database_url).await.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let state = AppState { db: pool, config: cfg };
let app = Router::new()
.nest("/api/v1", api_routes())
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
fn api_routes() -> Router<AppState> {
Router::new()
.nest("/users", handlers::users::router())
.nest("/products", handlers::products::router())
}
Экстракторы — ключевая концепция Axum
// handlers/users.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{errors::AppError, models::User, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_users).post(create_user))
.route("/:id", get(get_user).put(update_user).delete(delete_user))
}
#[derive(Deserialize)]
pub struct ListParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub search: Option<String>,
}
async fn list_users(
State(state): State<AppState>,
Query(params): Query<ListParams>,
) -> Result<impl IntoResponse, AppError> {
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(25).min(100);
let offset = (page - 1) * per_page;
let users = sqlx::query_as!(
User,
r#"
SELECT * FROM users
WHERE ($1::text IS NULL OR email ILIKE '%' || $1 || '%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
params.search,
per_page as i64,
offset as i64
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::not_found("user not found"))?;
Ok(Json(user))
}
#[derive(Deserialize)]
pub struct CreateUserPayload {
pub email: String,
pub name: String,
pub password: String,
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserPayload>,
) -> Result<impl IntoResponse, AppError> {
// валидация
if payload.email.is_empty() || !payload.email.contains('@') {
return Err(AppError::validation("invalid email"));
}
let hash = tokio::task::spawn_blocking(move || {
bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST)
})
.await
.unwrap()
.map_err(|_| AppError::internal("hash failed"))?;
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, name, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
Uuid::new_v4(),
payload.email,
payload.name,
hash
)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}
Tower middleware
// middleware/auth.rs
use axum::{
extract::Request,
http::header::AUTHORIZATION,
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use crate::{errors::AppError, models::Claims};
pub async fn require_auth(
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::unauthorized())?;
let secret = std::env::var("JWT_SECRET").unwrap();
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| AppError::unauthorized())?
.claims;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
Подключение middleware к отдельным роутам:
use axum::middleware;
fn api_routes() -> Router<AppState> {
let protected = Router::new()
.nest("/orders", handlers::orders::router())
.route_layer(middleware::from_fn(middleware::auth::require_auth));
Router::new()
.nest("/auth", handlers::auth::router())
.merge(protected)
}
Стриминг ответов
use axum::response::sse::{Event, Sse};
use futures_util::stream;
use tokio_stream::StreamExt;
async fn stream_events(
State(state): State<AppState>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, axum::Error>>> {
let stream = stream::iter(0..)
.throttle(std::time::Duration::from_secs(1))
.map(|i| {
Ok(Event::default()
.data(format!("event #{i}"))
.event("tick"))
});
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(std::time::Duration::from_secs(15))
)
}
Тестирование без запуска сервера
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn test_get_user_not_found() {
let app = create_test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/users/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
Сроки
Axum немного быстрее в разработке, чем Actix Web, за счёт более простой модели middleware. REST API средней сложности (8–12 ресурсов, JWT, PostgreSQL, базовые тесты): 2–3 недели. Добавление WebSocket, SSE, gRPC (через tonic) и нагрузочного тестирования: ещё 1–2 недели. Первый проект на Rust в команде без опыта с языком потребует закладывать на 30–50% больше времени.







