← Назад к списку тем

21. Веб-разработка

axum/actix-web, маршрутизация, middleware, extractors, REST API, graceful shutdown, tower.

axum — веб-фреймворк на tokio и tower

axum — фреймворк от команды tokio, построенный поверх hyper и tower. Роутинг типобезопасен: обработчики — обычные async-функции, а извлечение данных из запроса происходит через типы-экстракторы, проверяемые компилятором.

use axum::{
    routing::{get, post},
    Router, Json,
    extract::{State, Path},
};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    db: Arc<sqlx::PgPool>,
}

#[tokio::main]
async fn main() {
    let state = AppState { db: Arc::new(pool) };

    let app = Router::new()
        .route("/api/users", get(list_users).post(create_user))
        .route("/api/users/{id}", get(get_user))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> impl axum::response::IntoResponse {
    // извлечение состояния и параметра пути типобезопасно —
    // ошибка компиляции, а не паника в рантайме
    Json(serde_json::json!({"id": id}))
}

Хендлер в axum — это функция, реализующая трейт Handler, который автоматически выводится для async-функций с параметрами-экстракторами (реализующими FromRequest/FromRequestParts) и возвращаемым типом, реализующим IntoResponse.

actix-web — актор-ориентированный фреймворк

actix-web — один из самых быстрых веб-фреймворков (по бенчмаркам TechEmpower), исторически построен на модели акторов (сейчас actor-система опциональна). Использует свой рантайм поверх tokio с #[actix_web::main].

use actix_web::{web, App, HttpServer, HttpResponse, get, post};

#[get("/api/users/{id}")]
async fn get_user(path: web::Path<i64>, db: web::Data<sqlx::PgPool>) -> HttpResponse {
    let id = path.into_inner();
    HttpResponse::Ok().json(serde_json::json!({"id": id}))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .service(get_user)
    })
    .workers(4)
    .bind("0.0.0.0:8080")?
    .run()
    .await
}
Критерийaxumactix-web
Модельфункции + extractors, tower middlewareсервисы/акторы, свой рантайм-обвес
Экосистемаофициально от tokio-команды, tight tower integrationзрелая, независимая, обширные бенчмарки
Производительностьочень высокаяисторически лидер TechEmpower
Порог входаниже, идиоматичнее для tokio-стекавыше из-за собственных абстракций
Когда выбратьновые проекты, tower-совместимость важнамаксимальный RPS, устоявшийся стек

Extractors — извлечение данных из запроса

Экстракторы axum позволяют декларативно описать, что нужно хендлеру: JSON-тело, query-параметры, заголовки, состояние приложения. Порядок параметров имеет значение — экстрактор, потребляющий тело запроса (Json<T>, Bytes), должен идти последним.

use axum::extract::{Query, Json};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Pagination {
    page: Option<u32>,
    limit: Option<u32>,
}

#[derive(Deserialize)]
struct CreateUserRequest {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: i64,
    name: String,
}

async fn list_users(Query(pg): Query<Pagination>) -> Json<Vec<UserResponse>> {
    let page = pg.page.unwrap_or(1);
    let limit = pg.limit.unwrap_or(20).min(100); // защита от чрезмерного limit
    // ...
    Json(vec![])
}

async fn create_user(Json(body): Json<CreateUserRequest>) -> Json<UserResponse> {
    // Json<T> автоматически возвращает 400 Bad Request при невалидном теле
    Json(UserResponse { id: 1, name: body.name })
}

Middleware и tower — переиспользуемая логика

tower — экосистема абстракций Service/Layer, общая для axum, tonic и hyper. Middleware реализуется один раз и переиспользуется между HTTP и gRPC слоями.

use tower_http::{trace::TraceLayer, cors::CorsLayer, timeout::TimeoutLayer};
use std::time::Duration;
use axum::{middleware::{self, Next}, extract::Request, response::Response};

async fn auth_middleware(req: Request, next: Next) -> Result<Response, axum::http::StatusCode> {
    let token = req.headers()
        .get("Authorization")
        .and_then(|h| h.to_str().ok());

    match token {
        Some(t) if is_valid(t) => Ok(next.run(req).await),
        _ => Err(axum::http::StatusCode::UNAUTHORIZED),
    }
}

let app = Router::new()
    .route("/api/users", get(list_users))
    .layer(middleware::from_fn(auth_middleware))
    .layer(TraceLayer::new_for_http())   // структурированные логи запросов
    .layer(TimeoutLayer::new(Duration::from_secs(30)))
    .layer(CorsLayer::permissive());

Слои применяются снизу вверх: запрос сначала проходит через внешние layer, затем через внутренние, ответ идёт в обратном порядке — как матрёшка.

🔑 Ключевое: tower::Service — единый интерфейс async fn call(&mut self, req) -> Result<Resp, Err>. Middleware, написанный для одного tower-совместимого фреймворка (например, rate limiting или retry), работает и в другом без изменений.

Обработка ошибок на границе HTTP

Хорошая практика — не смешивать доменные ошибки с HTTP-статусами напрямую. Реализуйте IntoResponse для своего типа ошибки, преобразуя её в единый JSON-формат.

use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
use serde_json::json;

#[derive(thiserror::Error, Debug)]
enum ApiError {
    #[error("not found")]
    NotFound,
    #[error("validation failed: {0}")]
    Validation(String),
    #[error("database error")]
    Database(#[from] sqlx::Error),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            ApiError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
            // внутренние детали БД не отдаём наружу — только логируем
            ApiError::Database(e) => {
                tracing::error!(error = %e, "database error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}
🚫 Опасно: Никогда не возвращайте наружу Display внутренних ошибок БД или файловой системы — они могут содержать имена таблиц, строки подключения или пути. Логируйте детали через tracing, клиенту отдавайте общий код и сообщение.

REST API — проектирование и версионирование

  • Ресурсы во множественном числе/api/v1/users, а не /api/getUser
  • Статусы — 200/201/204 для успеха, 400/404/409/422 для клиентских ошибок, 500 для серверных
  • Версионирование — через префикс URL (/v1/) или заголовок Accept
  • Валидация — на границе, через validator crate поверх serde-структур запроса
  • Идемпотентность — PUT/DELETE идемпотентны по контракту, POST — нет (используйте Idempotency-Key)
use validator::Validate;

#[derive(Deserialize, Validate)]
struct CreateUserRequest {
    #[validate(length(min = 1, max = 100))]
    name: String,
    #[validate(email)]
    email: String,
}

async fn create_user(Json(body): Json<CreateUserRequest>) -> Result<Json<UserResponse>, ApiError> {
    body.validate().map_err(|e| ApiError::Validation(e.to_string()))?;
    // ...
    Ok(Json(UserResponse { id: 1, name: body.name }))
}

Graceful shutdown

Корректное завершение сервера — дождаться обработки текущих запросов, но перестать принимать новые. В axum это делается через with_graceful_shutdown, слушая сигналы ОС.

use tokio::signal;

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
    tracing::info!("shutdown signal received, draining connections");
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}
✅ Рекомендация: В Kubernetes всегда сочетайте graceful shutdown с preStop-хуком (sleep 5-10с), чтобы под успел выйти из Endpoints до того, как процесс начнёт отклонять новые соединения.

WebSocket и потоковые ответы

axum поддерживает WebSocket через встроенный экстрактор WebSocketUpgrade, а потоковые HTTP-ответы (SSE, chunked) — через Sse и произвольные Stream.

use axum::extract::ws::{WebSocketUpgrade, WebSocket, Message};

async fn ws_handler(ws: WebSocketUpgrade) -> impl axum::response::IntoResponse {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    while let Some(Ok(msg)) = socket.recv().await {
        if let Message::Text(text) = msg {
            if socket.send(Message::Text(text)).await.is_err() {
                break; // клиент отключился
            }
        }
    }
}