•10 min read

Axum vs Actix-web: Xây dựng REST API sản xuất bằng Rust

Axum vs Actix-web: Xây dựng REST API sản xuất bằng Rust

Các framework web bằng Rust đã trưởng thành đến mức việc lựa chọn giữa chúng là một quyết định kiến trúc thực sự, chứ không chỉ là sự tò mò. Hướng dẫn này bao gồm những gì bạn thực sự cần để xây dựng một REST API sẵn sàng cho sản xuất: lựa chọn framework, truy cập cơ sở dữ liệu bất đồng bộ, lan truyền lỗi và trạng thái ứng dụng dùng chung — với mã hoạt động xuyên suốt.

Audio Briefing
0:00 / 0:00

Axum vs Actix-web: Chọn cái nào?

Cả hai đều đã sẵn sàng cho sản xuất. Điểm khác biệt thực sự nằm ở triết lý kiến trúc.

Actix-web ra đời sớm hơn (2017), đã được thử nghiệm trong thực tế và luôn đứng đầu bảng xếp hạng TechEmpower. Nó chạy trên runtime riêng của mình (actix-rt) và sử dụng mô hình actor bên trong. API đã trưởng thành và ổn định — bạn sẽ không gặp phải các thay đổi gây lỗi.

Axum (2021, từ nhóm Tokio) được thiết kế để kết hợp với hệ sinh thái Tokio rộng lớn hơn. Nó sử dụng middleware tower, nghĩa là bất kỳ tower::Service nào — bộ giới hạn tốc độ, lớp tracing, nén — đều có thể cắm trực tiếp vào. API tiện dụng hơn và không dùng macro.

Tính năngAxum 0.7Actix-web 4
RuntimeTokioactix-rt (dựa trên Tokio)
MiddlewareHệ sinh thái toweractix-web native
Định tuyếnKhông macro, an toàn kiểuDựa trên macro, tiện dụng
WebSocketsQua axum::extract::wsNative, trưởng thành
Thông lượng thô†~450k req/s~490k req/s
Hệ sinh tháiPhát triển nhanhLớn hơn, cũ hơn
Đường cong học tậpTrung bình (traits)Trung bình (actors)

†TechEmpower Round 22, benchmark plaintext, đơn luồng, bare metal. Các tải công việc thực tế với truy cập DB sẽ tương tự.

Khuyến nghị của tôi: Sử dụng Axum cho các dự án mới. Hệ sinh thái middleware tower là vô song và thiết kế native Tokio giúp việc tracing/quan sát dễ dàng hơn.

Advertisement

Thiết lập dự án

cargo new rust-api && cd rust-api
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
anyhow = "1"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenv = "0.15"

Trạng thái ứng dụng dùng chung

Quyết định kiến trúc quan trọng nhất trong Axum là cách bạn chia sẻ trạng thái giữa các handler. Mô hình là Arc<AppState> được truyền qua .with_state():

// src/state.rs
use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub config: AppConfig,
}

#[derive(Clone)]
pub struct AppConfig {
    pub jwt_secret: String,
    pub allowed_origins: Vec<String>,
}
// src/main.rs
use axum::{Router, middleware};
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv::dotenv().ok();
    tracing_subscriber::fmt()
        .with_env_filter("rust_api=debug,tower_http=debug")
        .init();

    let database_url = std::env::var("DATABASE_URL")?;
    let pool = PgPoolOptions::new()
        .max_connections(20)
        .connect(&database_url)
        .await?;

    // Run migrations at startup
    sqlx::migrate!("./migrations").run(&pool).await?;

    let state = Arc::new(AppState {
        db: pool,
        config: AppConfig {
            jwt_secret: std::env::var("JWT_SECRET")?,
            allowed_origins: vec!["https://yourdomain.com".to_string()],
        },
    });

    let app = Router::new()
        .nest("/api/v1", api_router())
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive()) // tighten in production
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    tracing::info!("Listening on {}", listener.local_addr()?);
    axum::serve(listener, app).await?;
    Ok(())
}

Lưu ý: Arc<AppState> triển khai Clone, vì vậy Axum có thể clone nó cho mỗi yêu cầu mà không cần deep-copy pool (bản thân nó là một pool các kết nối, không phải một kết nối duy nhất).

Truy cập cơ sở dữ liệu bất đồng bộ với SQLx

SQLx cung cấp cho bạn SQL được kiểm tra tại thời điểm biên dịch mà không cần ORM. Macro sqlx::query_as! xác minh truy vấn của bạn với schema cơ sở dữ liệu thực tế tại thời điểm biên dịch.

// src/models/post.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Post {
    pub id: Uuid,
    pub title: String,
    pub content: String,
    pub author_id: Uuid,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreatePost {
    pub title: String,
    pub content: String,
}
// src/db/posts.rs
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::post::{Post, CreatePost};
use crate::error::AppError;

pub async fn get_posts(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<Post>, AppError> {
    let posts = sqlx::query_as!(
        Post,
        r#"
        SELECT id, title, content, author_id, created_at, updated_at
        FROM posts
        ORDER BY created_at DESC
        LIMIT $1 OFFSET $2
        "#,
        limit,
        offset
    )
    .fetch_all(pool)
    .await?;

    Ok(posts)
}

pub async fn create_post(
    pool: &PgPool,
    author_id: Uuid,
    payload: CreatePost,
) -> Result<Post, AppError> {
    let post = sqlx::query_as!(
        Post,
        r#"
        INSERT INTO posts (id, title, content, author_id, created_at, updated_at)
        VALUES ($1, $2, $3, $4, NOW(), NOW())
        RETURNING *
        "#,
        Uuid::new_v4(),
        payload.title,
        payload.content,
        author_id,
    )
    .fetch_one(pool)
    .await?;

    Ok(post)
}

Hậu tố macro ! (query_as!) yêu cầu một cơ sở dữ liệu đang chạy trong quá trình cargo build hoặc một thư mục .sqlx/ đã được ghi lại (được tạo bởi cargo sqlx prepare) cho các bản build offline trong CI.

Advertisement

Xử lý lỗi với thiserror

Cách tiếp cận chuẩn của Rust là định nghĩa enum lỗi của riêng bạn và triển khai IntoResponse cho Axum:

// src/error.rs
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Unauthorized")]
    Unauthorized,

    #[error("Validation error: {0}")]
    Validation(String),

    #[error("Internal error")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
            AppError::Database(e) => {
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
            }
            AppError::Internal(e) => {
                tracing::error!("Internal error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
        };

        (status, Json(json!({ "error": message }))).into_response()
    }
}

Mô hình này có nghĩa là mọi handler có thể trả về Result<impl IntoResponse, AppError> và lỗi sẽ tự động ánh xạ tới phản hồi HTTP phù hợp.

Route Handlers

// src/handlers/posts.rs
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;

use crate::{db, error::AppError, models::post::CreatePost, state::AppState};

#[derive(Deserialize)]
pub struct Pagination {
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

pub async fn list_posts(
    State(state): State<Arc<AppState>>,
    Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<crate::models::post::Post>>, AppError> {
    let limit = pagination.limit.unwrap_or(20).min(100);
    let offset = pagination.offset.unwrap_or(0);

    let posts = db::posts::get_posts(&state.db, limit, offset).await?;
    Ok(Json(posts))
}

pub async fn create_post(
    State(state): State<Arc<AppState>>,
    // In production, extract author_id from JWT claims
    Json(payload): Json<CreatePost>,
) -> Result<(StatusCode, Json<crate::models::post::Post>), AppError> {
    if payload.title.trim().is_empty() {
        return Err(AppError::Validation("Title cannot be empty".to_string()));
    }
    if payload.title.len() > 200 {
        return Err(AppError::Validation("Title too long (max 200 chars)".to_string()));
    }

    let author_id = Uuid::new_v4(); // Replace with JWT extractor
    let post = db::posts::create_post(&state.db, author_id, payload).await?;
    Ok((StatusCode::CREATED, Json(post)))
}

pub fn api_router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/posts", axum::routing::get(list_posts).post(create_post))
        .route("/posts/:id", axum::routing::get(get_post_by_id))
}

Số liệu thông lượng thực tế

Đây là các phép đo từ một VM thử nghiệm 4 lõi (8 vCPU, 16 GB RAM) chạy wrk -t4 -c100 -d30s chống lại một endpoint JSON đơn giản được hỗ trợ bởi Postgres:

FrameworkNgôn ngữReq/s (JSON)Độ trễ p99Bộ nhớ
Axum 0.7Rust~180k1.2ms18 MB
Actix-web 4Rust~195k1.1ms20 MB
Hyper (thô)Rust~230k0.9ms12 MB
FastAPI + uvicornPython~22k4.8ms95 MB
Express 4Node.js~38k3.1ms75 MB
Axum + SQLxRust~42k2.8ms22 MB

Hàng "Với SQLx" đại diện cho một vòng truy cập DB thực tế — Postgres là nút thắt cổ chai, không phải Rust. Mặc dù vậy, 42k req/s với các truy vấn Postgres thực tế trên một VM khiêm tốn là đặc biệt so với 22k của FastAPI trên cùng phần cứng.

Xử lý đường cong học tập

Hệ thống sở hữu gây khó khăn cho hầu hết mọi người ở hai điểm:

1. Mượn qua .await: Bạn không thể giữ một tham chiếu qua một điểm await trong nhiều ngữ cảnh bất đồng bộ. Giải pháp: clone những gì bạn cần trước khi await, hoặc cấu trúc lại để có phạm vi mượn ngắn hơn.

2. Chia sẻ trạng thái: Sự cám dỗ là sử dụng Mutex<SomeState> ở khắp mọi nơi. Ưu tiên:

  • Arc<T> cho dữ liệu dùng chung đọc nhiều (AppState của bạn)
  • DashMap (từ crate dashmap) cho các hash map đồng thời so với Mutex<HashMap>
  • Channels (tokio::sync::mpsc) cho các mô hình truyền tin nhắn

Thời gian biên dịch: Sử dụng cargo check để có phản hồi nhanh trong quá trình phát triển. cargo build chỉ khi bạn cần binary. Lần build sạch đầu tiên chậm; các lần rebuild tăng dần nhanh.

Triển khai

Đối với Docker, một bản build đa giai đoạn sẽ cho bạn một image ~50 MB:

# Build stage
FROM rust:1.78-slim as builder
WORKDIR /app
COPY . .
RUN cargo build --release

# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rust-api /usr/local/bin/rust-api
RUN useradd -m appuser
USER appuser
EXPOSE 3000
CMD ["rust-api"]

Đối với triển khai được quản lý, Shuttle cho phép bạn triển khai các ứng dụng Axum với cargo shuttle deploy — không cần Dockerfile, Postgres được cấp phát tự động.

Rust có đáng giá cho Web không?

Đối với hầu hết các API CRUD: có lẽ là chưa. Hệ sinh thái nhỏ hơn, việc làm quen khó khăn và Python/Node sẽ nhanh hơn để triển khai.

Đối với các API có lưu lượng truy cập cao, worker nền, máy chủ WebSocket hoặc bất kỳ dịch vụ nào mà bạn phải trả tiền cho điện toán ở quy mô lớn — có. Việc giảm 5x–8x bộ nhớ so với Node.js là có thật, và bộ nhớ thường là nút thắt cổ chai trong các pod Kubernetes. Rust cũng loại bỏ toàn bộ một loại lỗi runtime panics và các sự cố sản xuất liên quan đến bộ nhớ.

Điểm khởi đầu tốt nhất là một dịch vụ nội bộ nhạy cảm với độ trễ, nơi nhóm có thời gian để học borrow checker mà không bị áp lực về thời hạn.

Bạn cũng có thể thích

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement