Vấn đề với những API “Đủ Nhanh”
Tôi đã duy trì các API Node.js và Python trong môi trường production đủ lâu để biết đến cái khoảnh khắc bạn bắt đầu ngán ngẩm mỗi lần deploy. Runtime panic từ undefined is not a function, lỗi truy vấn database chỉ xuất hiện khi tải cao, Docker image phình to vượt quá 800MB. Riêng lẻ từng cái, không cái nào là chết người. Gộp lại, chúng tạo thành một hệ thống mà ngay cả ngày tốt đẹp nhất bạn cũng chỉ tin tưởng được một nửa.
Đó là điều khiến tôi chuyển sang Rust cho các backend service. Không phải vì con số hiệu năng — dù những con số đó là thật — mà vì sự đảm bảo rằng nếu code biên dịch được, cả một lớp bug nhất định đơn giản là không tồn tại. Axum, xây dựng trên tokio và hyper, mang sự đảm bảo đó vào các HTTP service với một API thực sự phù hợp với cách bạn nghĩ về web handler.
Đây là những gì tôi học được khi xây dựng REST API với Axum và SQLx từ đầu — điều gì thực sự quan trọng, điều gì khiến tôi vấp ngã, và điều tôi sẽ nói với một đồng nghiệp bắt đầu hôm nay.
Các Khái niệm Cốt lõi Cần Hiểu Trước
Mô hình Extractor của Axum
Thiết kế của Axum xoay quanh extractor — các kiểu dữ liệu dùng để lấy thông tin từ HTTP request. Path parameter, JSON body, query string, header — tất cả được extract qua đối số hàm, tất cả được xác thực tại thời điểm biên dịch. Khai báo một handler nhận JSON body kiểu CreateUser, Axum sẽ xác minh kiểu đó trước khi server khởi động. Kiểu không khớp là lỗi biên dịch, không phải response 500.
SQLx: Xác minh SQL tại Thời điểm Biên dịch
SQLx làm điều đáng kinh ngạc: nó kiểm tra các câu truy vấn SQL của bạn với database thực tế tại thời điểm biên dịch. Macro sqlx::query_as! kết nối với database dev trong quá trình cargo build và xác thực tên cột, kiểu dữ liệu và tính đúng đắn của câu truy vấn. Đổi tên một cột trong schema mà quên cập nhật câu truy vấn — build sẽ thất bại. Loại phản hồi nhanh đó đã cứu tôi khỏi không ít cảnh báo production bất ngờ.
Tower Middleware Stack
Axum được xây dựng trên Tower, một thư viện middleware có thể kết hợp linh hoạt. CORS, rate limiting, tracing, nén dữ liệu — tất cả đều là Tower layer. Biết rằng Axum handler chỉ là Tower service, bạn có thể kết hợp hành vi một cách rõ ràng, không có phép thuật framework nào mà bạn không thể kiểm tra hay test.
Thực hành: Xây dựng User API từ Đầu
Thiết lập Dự án
cargo new rust-api && cd rust-api
Thêm các dependency vào Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
Cấu trúc Database
-- migrations/0001_create_users.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Chạy migration với: sqlx migrate run
Application State và Models
// src/main.rs
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[derive(Serialize, sqlx::FromRow)]
struct User {
id: Uuid,
username: String,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
struct CreateUser {
username: String,
email: String,
}
Triển khai Handler
async fn list_users(
State(state): State<AppState>,
) -> impl IntoResponse {
match sqlx::query_as!(
User,
"SELECT id, username, email, created_at FROM users ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await
{
Ok(users) => (StatusCode::OK, Json(users)).into_response(),
Err(e) => {
tracing::error!("Không thể lấy danh sách users: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUser>,
) -> impl IntoResponse {
match sqlx::query_as!(
User,
"INSERT INTO users (username, email) VALUES ($1, $2)
RETURNING id, username, email, created_at",
payload.username,
payload.email
)
.fetch_one(&state.db)
.await
{
Ok(user) => (StatusCode::CREATED, Json(user)).into_response(),
Err(sqlx::Error::Database(e)) if e.constraint() == Some("users_username_key") => {
(StatusCode::CONFLICT, "Username đã được sử dụng").into_response()
}
Err(e) => {
tracing::error!("Không thể tạo user: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match sqlx::query_as!(
User,
"SELECT id, username, email, created_at FROM users WHERE id = $1",
id
)
.fetch_optional(&state.db)
.await
{
Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("Không thể lấy thông tin user {}: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
Hàm Main với Connection Pool
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL phải được thiết lập");
let pool = PgPool::connect(&database_url)
.await
.expect("Không thể kết nối đến database");
sqlx::migrate!().run(&pool).await
.expect("Không thể chạy migrations");
let state = AppState { db: pool };
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user))
.layer(
tower_http::cors::CorsLayer::permissive()
)
.layer(
tower_http::trace::TraceLayer::new_for_http()
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("Server đang chạy trên cổng 3000");
axum::serve(listener, app).await.unwrap();
}
Triển khai với Docker
Một điều tôi ước mình biết sớm hơn: quá trình build Docker cho Rust mặc định rất chậm. Multi-stage build với cargo-chef cache lớp biên dịch dependency, giảm thời gian build lại từ hơn 10 phút xuống dưới 60 giây cho các lần build tăng dần.
# Giai đoạn 1: Cache dependency
FROM rust:1.78-slim as chef
RUN cargo install cargo-chef
WORKDIR /app
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Giai đoạn 2: Build
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release
# Giai đoạn 3: Image runtime tối giản
FROM debian:bookworm-slim as runtime
RUN apt-get update && apt-get install -y \
libssl3 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/rust-api .
COPY migrations ./migrations
EXPOSE 3000
CMD ["./rust-api"]
Image cuối cùng chỉ khoảng 80-100MB — nhỏ hơn nhiều so với 400-700MB từ image node:18 vốn đóng gói toàn bộ V8 runtime.
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://postgres:password@db:5432/rustapi
- RUST_LOG=info
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: rustapi
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
docker compose up --build
Kiểm tra API
# Tạo một user
curl -X POST http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{"username": "alice", "email": "[email protected]"}'
# Lấy danh sách tất cả users
curl http://localhost:3000/users
# Lấy theo ID
curl http://localhost:3000/users/<uuid>
Bài học Từ Thực tế
- Dùng macro
sqlx::query_as!thay vì chuỗi query thô. Kiểm tra tại thời điểm biên dịch tự hoàn vốn ngay lập tức. ĐặtDATABASE_URLtrong file.envtrước khi chạycargo build. - Giữ handler gọn nhẹ. Logic nghiệp vụ đặt trong handler rất khó test. Hãy tách ra thành các service function nhận pool làm tham số — những hàm này dễ unit test mà không cần khởi động server.
- Xử lý lỗi ràng buộc database một cách tường minh. Match trên
e.constraint()cho phép trả về response 409 Conflict có ý nghĩa thay vì 500 chung chung khi người dùng đăng ký email trùng lặp. - Log với context có cấu trúc. Các span của
tracingcho phép gắn request ID và user ID vào mọi dòng log trong vòng đời một request — không thể thiếu khi debug các request production chạy đồng thời. - Chạy migration khi khởi động trong môi trường dev, nhưng chạy riêng trong production. Gọi
sqlx::migrate!()trongmain()rất tiện khi phát triển cục bộ, nhưng trong production bạn muốn kiểm soát migration độc lập với việc restart app. - Thay thế
CorsLayer::permissive()trước khi ra mắt. Nó cho phép request từ bất kỳ origin, method và header nào. Thu hẹp phạm vi bằngCorsLayer::new().allow_origin([...])trước khi triển khai thực tế.
Hướng Đi Tiếp Theo
Với nền tảng này đã chạy được, các phần mở rộng hiển nhiên là xác thực JWT (crate jsonwebtoken tích hợp gọn gàng với Axum extractor như middleware), phân trang cursor-based trên các endpoint danh sách, và OpenAPI spec qua utoipa — thứ tự động tạo tài liệu Swagger UI tương tác trực tiếp từ kiểu handler của bạn.
Những đảm bảo tại thời điểm biên dịch vốn cảm thấy hạn chế khi tôi mới học Rust giờ lại là lý do chính tôi chọn nó cho các service mới. Vòng phản hồi thay đổi — bạn phát hiện kiểu không khớp và trường thiếu trong giai đoạn phát triển, không phải qua cảnh báo lúc 3 giờ sáng. Với một nhóm duy trì cùng một API trong hai hoặc ba năm, đó là sự đánh đổi đáng thực hiện sớm.

