Rust web-фреймворки: Axum vs Actix и выбор под задачу

Сравнение Axum и Actix-web: архитектура, производительность, ergonomics. Как выбрать фреймворк под конкретный backend-сценарий. Практические примеры: REST API, middleware, error handling

Третья статья серии про Rust в backend. В предыдущей разбирали async-runtime и Tokio — фундамент, на котором стоят все веб-фреймворки Rust. Теперь — этаж выше: чем писать HTTP-сервис. Сравним два основных фреймворка, Axum и Actix-web, на одном и том же REST API, разберём роль Tower и дадим матрицу выбора.

Два фреймворка — один маршрут: Axum и Actix обслуживают один и тот же REST-эндпоинт

В статье

Кто есть кто в экосистеме

Веб-фреймворков на Rust много, но в production-разговоре их два:

  • Axum — от команды Tokio, построен на tower и hyper. Типобезопасный роутинг, экстракторы, прямая интеграция с экосистемой Tower. Сегодня — де-факто выбор сообщества по умолчанию.
  • Actix-web — один из самых зрелых и быстрых, со своей системой middleware и экстракторов, исторический лидер бенчмарков.

Остальные занимают ниши: Warp (тоже от Tokio, на «фильтрах» — мощно, но эргономика на любителя, импульс спал), Poem (моложе, из коробки OpenAPI), Rocket (очень эргономичный, теперь async). Для нового сервиса в 9 случаях из 10 выбор стоит между Axum и Actix-web — на них и сосредоточимся.

Axum: фреймворк поверх Tower

Идея Axum — минимум своего, максимум переиспользования. Это, по сути, удобный роутер поверх hyper, где каждый сервис — это Tower Service, а middleware — Tower Layer. Handler’ы получают данные через экстракторы (Json, State, Path, Query), а возвращают всё, что реализует IntoResponse.

use axum::{routing::post, Router, Json, extract::State, http::StatusCode};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Clone)]
struct AppState { /* db: sqlx::PgPool, ... */ }

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

async fn create_user(
    State(_state): State<Arc<AppState>>,
    Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), StatusCode> {
    if payload.name.is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    Ok((StatusCode::CREATED, Json(User { id: 1, name: payload.name })))
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", post(create_user))
        .with_state(Arc::new(AppState {}));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app)
        .with_graceful_shutdown(async { tokio::signal::ctrl_c().await.ok(); })
        .await
        .unwrap();
}

Сильная сторона — типобезопасность: несоответствие экстрактора и handler’а не скомпилируется. И graceful shutdown здесь штатный — with_graceful_shutdown.

Actix-web: зрелость и скорость

Actix-web старше, очень обкатан в production и стабильно держится в топе TechEmpower. У него своя система роутинга (в т.ч. через атрибуты-макросы), свои экстракторы и middleware. Тот же эндпоинт:

use actix_web::{web, App, HttpServer, HttpResponse, post};
use serde::{Deserialize, Serialize};

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

#[post("/users")]
async fn create_user(payload: web::Json<CreateUser>) -> HttpResponse {
    if payload.name.is_empty() {
        return HttpResponse::BadRequest().finish();
    }
    HttpResponse::Created().json(User { id: 1, name: payload.name.clone() })
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(create_user))
        .bind(("0.0.0.0", 8080))?
        .run()
        .await
}

Про производительность важно сказать честно: на синтетике Actix-web обычно чуть впереди, Axum — рядом. Но реальный сервис почти всегда упирается в базу, сеть и сериализацию, а не во фреймворк — на практике разница между ними теряется на фоне запроса к PostgreSQL. Выбирать по строчке в TechEmpower не стоит.

Один REST API на двух фреймворках

Концептуально код одинаков: разобрать JSON → провалидировать → сходить в БД → отдать ответ. Различия — в деталях API:

Axum Actix-web
Роутинг Router::new().route(...) App::service(...) / макросы #[get]
JSON-вход экстрактор Json<T> web::Json<T>
Состояние State<T>with_state) web::Data<T> (app_data)
Ответ любой IntoResponse HttpResponse / impl Responder
БД sqlx/sea-orm (любой async) то же

Слой БД для обоих одинаков — async-драйвер вроде sqlx (запросы с проверкой типов на этапе компиляции) или ORM sea-orm. Фреймворк здесь не диктует ничего: пул соединений кладётся в состояние и достаётся экстрактором.

Оба варианта — один и тот же POST /users с валидацией, middleware и обработкой ошибок — собраны и покрыты тестами в digital-cookbook, rust/web-frameworks/: крейты axum-api и actix-api, cargo test гоняет оба handler’а без поднятия порта.

Middleware и error handling

Middleware. В Axum это Tower Layer — и сразу доступна готовая экосистема tower-http: логирование/трейсинг (TraceLayer), CORS, сжатие, таймауты, лимиты тела:

use tower_http::{trace::TraceLayer, cors::CorsLayer};

let app = Router::new()
    .route("/users", post(create_user))
    .layer(TraceLayer::new_for_http())
    .layer(CorsLayer::permissive())
    .with_state(state);

В Actix-web — свой механизм .wrap(...) и свои крейты (actix-web::middleware::Logger, actix-cors). Тоже зрело и полно, просто не Tower.

Error handling. В Axum ошибка — это тип, реализующий IntoResponse: handler возвращает Result<T, AppError>, а AppError сам решает, в какой HTTP-ответ превратиться. В Actix-web аналогично через трейт ResponseError. В обоих случаях идиома одна: доменная ошибка → её отображение в HTTP, без россыпи unwrap().

Tower как фундамент

Понимать Tower стоит, даже если берёте Actix. Tower — это абстракция «асинхронного сервиса»: трейт Service отображает запрос в future ответа, а Layer оборачивает один сервис в другой. Звучит абстрактно, но даёт конкретное: middleware (таймауты, ретраи, rate-limiting, трейсинг, load-balancing) пишется один раз и переиспользуется в любом Tower-сервисе — не только в вебе.

Поэтому выбор Axum — это не только про HTTP: тот же стек Tower лежит под gRPC-фреймворком tonic, под клиентами и фоновыми сервисами. Один набор middleware на весь сервис, а не отдельный для каждого слоя. Actix-web живёт в своей экосистеме — прекрасной, но отдельной.

Что выбирать под задачу

Оба фреймворка production-ready; «неправильного» выбора тут нет. Ориентир:

  • Axum — если хотите Tokio-нативность, экосистему Tower (общие middleware для HTTP, gRPC, фоновых задач), современную эргономику и максимальный импульс сообщества. Разумный выбор по умолчанию для нового сервиса.
  • Actix-web — если нужна выжатая до предела пропускная способность на синтетике, у вас уже есть actix-кодовая база, либо команда привыкла к его модели.
  • Для типового REST/CRUD берите тот, что приятнее команде: на реальной нагрузке (БД, сеть) разница между ними практически не видна.

Практическое правило: начинаете с нуля и нет сильного аргумента в пользу Actix — берите Axum, чтобы переиспользовать Tower на всём сервисе.

Дальше в серии — минимальные Docker-образы для Rust: как упаковать такой сервис в образ на 5–15 MB.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии