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.