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 — один из самых быстрых веб-фреймворков (по бенчмаркам 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
}
| Критерий | axum | actix-web |
|---|---|---|
| Модель | функции + extractors, tower middleware | сервисы/акторы, свой рантайм-обвес |
| Экосистема | официально от tokio-команды, tight tower integration | зрелая, независимая, обширные бенчмарки |
| Производительность | очень высокая | исторически лидер TechEmpower |
| Порог входа | ниже, идиоматичнее для tokio-стека | выше из-за собственных абстракций |
| Когда выбрать | новые проекты, tower-совместимость важна | максимальный RPS, устоявшийся стек |
Экстракторы 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 })
}
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-статусами напрямую. Реализуйте 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, клиенту отдавайте общий код и сообщение.
/api/v1/users, а не /api/getUser/v1/) или заголовок Acceptvalidator crate поверх serde-структур запроса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 }))
}
Корректное завершение сервера — дождаться обработки текущих запросов, но перестать принимать новые. В 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();
}
preStop-хуком (sleep 5-10с), чтобы под успел выйти из Endpoints до того, как процесс начнёт отклонять новые соединения.
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; // клиент отключился
}
}
}
}