Trang chủ

Hình dạng của một dịch vụ axum còn bảo trì được

axum là một lớp mỏng đặt trên tower và hyper, và nó cố tình không áp đặt quan điểm nào về cách bạn cấu trúc một ứng dụng. Điều đó tốt và nó có nghĩa là phiên bản đầu tiên bạn viết sẽ sai.

Đây là hình dạng tôi đi đến.

Handler là hàm của các đầu vào của nó

Một handler axum là một hàm async bình thường mà các tham số là các extractor — mỗi cái rút một thứ ra khỏi request:

async fn create_note(
    State(state): State<AppState>,
    Path(user_id): Path<Uuid>,
    Query(params): Query<ListParams>,
    headers: HeaderMap,
    Json(payload): Json<CreateNote>,
) -> Result<Json<Note>, AppError> {
    let note = state.notes.create(user_id, payload).await?;
    Ok(Json(note))
}

Quy tắc thứ tự bẫy được tất cả mọi người: Json phải nằm cuối cùng. Các extractor tiêu thụ phần thân request cài đặt FromRequest chứ không phải FromRequestParts, và chỉ một cái được nằm cuối. Lỗi khi bạn làm sai chỗ này là một lỗi ràng buộc trait về Handler chẳng nhắc gì đến chuyện rút phần thân cả.

Một kiểu lỗi duy nhất, chuyển đổi ở ranh giới

Cải tiến lớn nhất so với phiên bản đầu của tôi:

pub enum AppError {
    NotFound,
    Unauthorized,
    Validation(String),
    Internal(anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
            AppError::Validation(message) => (StatusCode::BAD_REQUEST, message),
            AppError::Internal(error) => {
                tracing::error!(%error, "internal error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

impl<E: Into<anyhow::Error>> From<E> for AppError {
    fn from(error: E) -> Self {
        AppError::Internal(error.into())
    }
}

Phần cài đặt From đó là thứ khiến ? chạy được với mọi lỗi bên trong một handler. Mọi thứ chưa được xử lý đều thành 500, và — quan trọng là — phần chi tiết được ghi log chứ không trả về. Để lọt một thông điệp lỗi cơ sở dữ liệu tới client là một vấn đề bảo mật thật và là giá trị mặc định dễ mắc phải.

State theo lối ghép, không phải một struct khổng lồ

Phiên bản đầu của tôi có một AppState với tám trường, và mọi handler đều nhận cả cái đó.

#[derive(Clone)]
struct AppState {
    db: PgPool,
    cache: Arc<Cache>,
    config: Arc<Config>,
}

FromRef cho phép một handler chỉ rút ra đúng phần nó cần:

impl FromRef<AppState> for PgPool {
    fn from_ref(state: &AppState) -> Self {
        state.db.clone()
    }
}

async fn list_notes(State(db): State<PgPool>) -> Result<Json<Vec<Note>>, AppError> { … }

Chữ ký của handler giờ ghi rõ các phụ thuộc của nó, và nó kiểm thử được bằng một pool thay vì bằng cả một trạng thái ứng dụng.

Mẹo

State phải Clone, và nó được clone cho mỗi request. Hãy bọc mọi thứ tốn kém trong Arc — clone một PgPool thì rẻ theo thiết kế vì bên trong nó vốn là một Arc, còn một struct cấu hình chứa các String sở hữu thì không.

Middleware là các layer của tower

let app = Router::new()
    .route("/notes", get(list_notes).post(create_note))
    .route("/notes/{id}", get(get_note).delete(delete_note))
    .layer(
        ServiceBuilder::new()
            .layer(TraceLayer::new_for_http())
            .layer(TimeoutLayer::new(Duration::from_secs(30)))
            .layer(CompressionLayer::new())
            .layer(CorsLayer::permissive()),
    )
    .with_state(state);

Trong ServiceBuilder, các layer áp dụng từ dưới lên, nên đặt TraceLayer đầu tiên nghĩa là nó bọc tất cả và thấy được phản hồi cuối cùng — đúng thứ bạn muốn cho việc ghi log.

Middleware riêng cho một số route thì đặt trên một router lồng:

let protected = Router::new()
    .route("/me", get(current_user))
    .layer(middleware::from_fn_with_state(state.clone(), require_auth));

let app = Router::new()
    .merge(public_routes)
    .nest("/api", protected);

Cách bố trí file

src/
  main.rs            — khởi động, cấu hình, migration, chạy server
  routes/
    mod.rs           — cái router
    notes.rs         — chỉ chứa handler
  domain/
    notes.rs         — logic nghiệp vụ, không có kiểu nào của axum
  error.rs
  state.rs

Quy tắc quan trọng: domain/ không được import axum. Logic nghiệp vụ trả về Json<T> hay nhận State<_> thì không kiểm thử được nếu không có một request, và không tái sử dụng được từ một CLI hay một tiến trình chạy nền. Handler là một tầng dịch mỏng — rút ra, gọi domain, bọc kết quả lại.

Sự tách bạch đó là thứ tôi làm sai đầu tiên và là thứ khiến phiên bản thứ hai bảo trì được.

Tắt máy một cách duyên dáng

Dễ bỏ qua và nó quan trọng trong môi trường thật:

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())
    .await?;

async fn shutdown_signal() {
    let ctrl_c = async { signal::ctrl_c().await.unwrap() };
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .unwrap().recv().await;
    };
    tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
}

Không có nó, một lần triển khai sẽ giết các request đang bay. Có nó, máy chủ thôi nhận kết nối mới và hoàn tất những gì đang làm — đó là khác biệt giữa một lần triển khai cuốn chiếu mà chẳng ai để ý và một lần đẻ ra một loạt lỗi 502.