17.1 實戰專案二:高效能 Web API 服務——使用 Axum 建立 Web 伺服器#
為什麼 Rust 適合寫 Web 服務#
在討論框架之前,先回答一個更根本的問題:為什麼在 Go、Java、Node.js 這些語言已經能寫出高效能 Web 服務的今天,還要考慮用 Rust?
第一個理由是沒有垃圾收集器所帶來的延遲可預測性。GC 的運作時機由實作決定,雖然現代的 GC 已經把停頓時間壓得很短,但「某個請求可能剛好撞上一次回收」這件事永遠存在。Rust 沒有這個問題:記憶體在離開作用域時就立刻被釋放,沒有任何不確定的停頓。這對延遲敏感的服務(交易系統、串流媒體、期貨行情)特別重要。
第二個理由是零成本抽象帶來的開發效率。Rust 的型別系統允許你寫出高階的抽象,卻在編譯期把它們降解成與手寫低階程式碼等價的機器碼。第 9 章談過的泛型與特徵在 Web 框架裡扮演著關鍵角色:axum 的 handler 與 extractor 建立在特徵之上,但執行期沒有任何動態派發的開銷。
第三個理由是對 async 的原生支援。Rust 的 async/await 是語言層級的 constructs,Future 是標準函式庫的核心型別,而 tokio 這類執行環境只是驅動 Future 的一個函式庫。這種設計讓整個生態系都建立在同一套基礎上。
第四個理由是成熟的 crate 生態。tokio、hyper、tower、serde、axum 這些 crate 涵蓋了非同步執行、HTTP 協定解析、服務抽象與序列化,把它們組合起來就是一套完整的生產級網路堆疊。而且它們全都由大型社群維護,有詳細的文件與長期的相容性承諾。
最後一個理由是安全。Web 服務是攻擊面最廣的應用之一,記憶體安全缺陷在網路上被利用的案例比比皆是。Rust 把這類缺陷從「靠 fuzzing 去找」變成「編譯器保證不存在」。
Axum 的設計#
axum 是 Tokio 團隊維護的網路框架,它的設計哲學可以用一句話概括:以型別表達 HTTP 的結構。
axum 建立在兩個基礎之上。底層是 hyper,一個專注於 HTTP/1 與 HTTP/2 協定實作的函式庫,負責解析位元組流、處理連線與協定細節。hyper 之上是 tower,一個「服務」的抽象與組合框架。tower::Service 特徵只有一個核心方法:
async fn call(&mut self, req: Request) -> Response;
axum 把「路由匹配」與「型別化的請求/回應轉換」這兩件事實作為 Service 實作來組合。這個分層的好處是,tower 生態裡大量的中介軟體(限流、逾時、重試、負載平衡)都可以直接套用在 axum 上,不需要任何轉接。
axum 最有特色的是 extractor 與 handler 的組合。extractor 是某個 FromRequest 特徵的實作,它負責從 HTTP 請求中抽取某種型別;handler 則是一個以 extractor 為參數、回傳某個 IntoResponse 實作的函式。
use axum::extract::FromRequest;
trait FromRequest<S>: Sized {
type Rejection: IntoResponse;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection>;
}
這個設計的精妙之處在於型別安全:handler 的簽章本身就是它的規格書。Path<u64> 表示路徑參數必須是整數,Json<T> 表示請求本體必須是能反序列化成 T 的 JSON,State<AppState> 表示需要存取應用狀態。編譯器會檢查這些組合是否合法,例如同一個請求不能有兩個 Body extractor。
最小的 Hello 伺服器#
第一個例子應該盡可能簡單,讓我們先確認整個鏈路能跑起來:
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
use axum::routing::get;
use axum::Router;
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("無法綁定連接埠");
println!("伺服器正在 0.0.0.0:3000 監聽");
axum::serve(listener, app)
.await
.expect("伺服器執行失敗");
}
#[tokio::main] 是程序化巨集,它把 async fn main 轉換成建立 tokio 執行環境並阻塞等待的同步函式。Router::new() 建立一個空的路由表,route("/", get(handler)) 把 HTTP GET 請求的路徑 / 對應到某個 handler。
axum::serve 接受一個 TcpListener 與一個服務,並在 Future 尚未完成時持續 poll。TcpListener::bind 回傳 Result,因為綁定連接埠可能失敗——連接埠被占用、權限不足或位址無效。
Router 的型別是 Router<S>,其中 S 是應用狀態的型別。當沒有狀態時,型別參數會是 (),這讓「不需要狀態的路由」與「需要狀態的路由」在型別上區分開來。
Handler 的簽章與 Extractor#
handler 就是一個 async fn,它的參數必須實作 FromRequest,回傳值必須實作 IntoResponse。常見的 extractor 有五種。
| Extractor | 來源 | 用途 | 失敗時 |
|---|---|---|---|
Path<T> | 路徑參數 | /users/{id} 中的 id | 404 或 400 |
Query<T> | 查詢字串 | ?page=2&size=10 | 400 |
Json<T> | 請求本體 | 解析 JSON 並反序列化 | 400 或 422 |
State<S> | 應用狀態 | 存取共用的 Arc 資料 | 500 |
Extension<T> | 擴充資料 | 存取請求範圍外的共用值 | 500 |
use axum::extract::{Path, Query};
use axum::Json;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
size: Option<u32>,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
async fn list_users(Query(pagination): Query<Pagination>) -> Json<Vec<User>> {
let page = pagination.page.unwrap_or(1);
let size = pagination.size.unwrap_or(20);
let users = vec![User {
id: 1,
name: format!("使用者第 {} 頁", page),
}];
Json(users)
}
async fn get_user(Path(id): Path<u64>) -> Json<User> {
Json(User {
id,
name: "Alice".to_string(),
})
}
Query<Pagination> 用了 serde 的 Deserialize 衍生,Json<User> 的回傳則需要 Serialize。這兩個衍生宏正是 axum 能把「HTTP 層面」與「應用型別」無縫接起來的原因:查詢字串 ?page=2 會被自動映射到 pagination.page,型別不匹配時回傳 400。
Path<u64> 同理,路徑中的 {id} 會被解析成 u64。若使用者傳入 /users/abc,解析失敗,此時 extractor 的 rejection 會被自動轉成 400 回應。
回應型別#
回應端同樣由特徵抽象。IntoResponse 讓 handler 可以回傳多種型別:
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect};
async fn variants() -> impl IntoResponse {
(
StatusCode::OK,
[("content-type", "text/html; charset=utf-8")],
"<h1>你好</h1>",
)
}
axum 提供了 Json<T>、Html<T>、Redirect、StatusCode、String、&'static str 與 (StatusCode, T) 等常見的實作。(StatusCode, T) 這個元組實作特別實用,因為它讓設定狀態碼不需要額外的型別包裝。
若要自訂回應型別,只需實作 IntoResponse:
use axum::response::{IntoResponse, Response};
struct ApiError {
status: StatusCode,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
}
}
不過在真實專案裡,直接用 thiserror 定義一個錯誤列舉會更好,這正是下一節的內容。
中介軟體:Layer 與 Middleware#
中介軟體是包在路由外的處理層,用於日誌、認證、跨域、限流這類橫切關注點。axum 提供兩種中介軟體介面。
middleware::from_fn 讓你用一個普通函式寫中介軟體,最為直覺:
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
async fn request_id(mut req: Request, next: Next) -> Response {
let id = uuid::Uuid::new_v4().to_string();
req.extensions_mut().insert(id.clone());
let mut res = next.run(req).await;
res.headers_mut()
.insert("x-request-id", id.parse().unwrap());
res
}
Next 代表管線的下一段,next.run(req).await 會呼叫後續的處理。Request::extensions_mut() 提供一個型別化的擴充儲存區(Extensions),可以在中介軟體之間傳遞自訂資料而不用改動請求本身的結構。
Layer 則是 tower 的組合抽象,適合撰寫可重用的中介軟體套件:
use tower::Layer;
use tower_http::{
cors::{CorsLayer, CorsPolicy},
trace::TraceLayer,
};
let cors = CorsLayer::new()
.allow_origin(CorsPolicy::any())
.allow_methods([axum::http::Method::GET, axum::http::Method::POST]);
let app = Router::new()
.route("/", get(|| async { "hi" }))
.layer(TraceLayer::new_for_http())
.layer(cors)
.layer(middleware::from_fn(request_id));
Layer 的關鍵特性是型別化的狀態傳遞:.layer() 會把先前的路由型別與目前的型別組合,若某個 Layer 需要改變請求型別(例如認證中介軟體要插入使用者身分),編譯器會確保後續的 handler 能接受那個新狀態。tower-http 提供了 CORS、追蹤、壓力限流、超時等現成的 Layer。
狀態管理:State 與 Arc#
需要共用資料時,使用 State<S> extractor。S 必須實作 Clone + Send + Sync + 'static,最常見的型別是 Arc<T>。
use axum::extract::State;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Clone)]
struct AppState {
counter: Arc<AtomicU64>,
}
async fn count(State(state): State<AppState>) -> String {
let n = state.counter.fetch_add(1, Ordering::Relaxed) + 1;
format!("第 {} 次請求", n)
}
let state = AppState {
counter: Arc::new(AtomicU64::new(0)),
};
let app = Router::new()
.route("/count", get(count))
.with_state(state);
with_state 把狀態注入路由,之後 State<AppState> extractor 就能取出它。Arc 的選擇是必然的:多個請求可能同時需要這份狀態,而 Arc 的原子計數保證它在所有持有者都釋放之前不會被回收。這正是第 12.3 節談過的共享所有權模式。
AppState 本身實作 Clone 是必要的,因為 State<S> 每次都會複製一份 S——但因為 S 內部是 Arc,這個複製只是計數加一,實際資料仍然只有一份。
錯誤處理#
Web 服務的錯誤處理有兩個層次:應用錯誤(例如找不到資源)與內部錯誤(例如資料庫連線失敗)。前者應該轉成適當的 HTTP 狀態碼,後者應該被記錄但不洩漏細節。
thiserror 讓錯誤列舉的撰寫變得非常簡潔:
[dependencies]
thiserror = "2"
anyhow = "1"
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("找不到使用者 {0}")]
NotFound(String),
#[error("資料庫錯誤")]
Database(#[from] sqlx::Error),
#[error("內部錯誤:{0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
if status.is_server_error() {
tracing::error!("伺服器錯誤:{}", self);
}
(status, self.to_string()).into_response()
}
}
anyhow 與 thiserror 的分工是明確的:thiserror 用於有明確結構、需要被上層辨識的錯誤(本例的 AppError),它產生的列舉可以輕易地做 match;anyhow 用於不透明的錯誤,例如應用程式啟動階段的設定載入失敗,那裡不需要區分具體種類,只需要把訊息與來源鏈保留下來。這是 Rust 社群廣泛認可的慣例。
#[from] 屬性自動生成 From 實作,讓 ? 能在 handler 中直接轉換錯誤型別。
背景任務#
有些工作不該阻塞請求——寄送通知、寫入佇列、清理暫存檔。這類任務可以用 tokio::spawn 放到背景執行。
fn spawn_background_task(state: AppState) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
tracing::info!("定期檢查執行中");
let _ = &state;
}
});
}
tokio::spawn 需要 'static 的未來,因此區塊中的資料必須被 move 進去。這裡的 AppState 內部是 Arc,複製成本極低。
不過要特別注意:tokio::spawn 產生的任務是detached 的,主程式結束時它不會被等待。若背景任務很重要(例如必須把待處理的訊息寫完),應當保存 JoinHandle 並在關閉流程中 await 它,否則程序退出可能遺失資料。
優雅關閉#
生產環境的伺服器不應該在收到訊號時立刻中止,否則進行中的請求會被硬生生切斷。axum::serve 提供了 with_graceful_shutdown 來處理這件事。
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("無法安裝 Ctrl+C 處理器");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("無法安裝 SIGTERM 處理器")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("收到關閉訊號,停止接受新請求");
}
tokio::select! 讓多個 future 同時競爭,誰先完成就採納誰。這裡的 terminate future 在非 Unix 平台上永遠不會完成(pending),因此編譯器仍要求它回傳 () 型別的結果,型別得以統一。
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
在收到訊號之後,axum::serve 會停止接受新連線,但會等待進行中的請求完成後才返回。搭配容器化的部署環境時,務必確認 orchestrator 給足夠的時間讓關閉流程跑完,否則同樣會造成請求被切斷。
效能#
Rust Web 服務的效能祕訣其實相當樸素:用正確的建置模式。
cargo build --release
除錯建置(debug build)沒有開啟最佳化,程式碼可能慢上十倍甚至更多。--release 啟用 LLVM 的最佳化流程,並且會把 #[inline] 與泛型的單型化代碼真正最佳化。
第二個重點是不要在 handler 中做阻塞操作。檔案讀取、阻塞的資料庫驅動、std::thread::sleep 這些操作會佔住執行緒,讓整個執行環境的並行度下降。正確做法是使用非同步版本,或把阻塞操作丟到 tokio::task::spawn_blocking 中。
第三個重點是使用 tracing 而不是 println!。tracing 是結構化的日誌框架,它產生的紀錄帶有欄位而非只是字串,因此可以被收集、篩選與關聯。搭配 tracing-subscriber 就能輸出結構化的 JSON 日誌,供日誌系統索引。
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.with(tracing_subscriber::fmt::layer().json())
.init();
框架取捨#
Rust 生態中還有其他 Web 框架,取捨各有不同。
| 框架 | 抽象層次 | 非同步 | 特色 | 代價 |
|---|---|---|---|---|
axum | 較低,直接建於 tower/hyper | 是 | 與 Tokio 生態緊密整合,型別安全 | 功能需自行組合 crate |
actix-web | 中等 | 是 | 功能完整、文件豐富、生態成熟 | 歷史包袱較重,執行模型非 Tokio |
rocket | 較高 | 是 | 高度約定的「零設定」開發體驗 | 客製化受限,抽象成本較高 |
warp | 中等 | 是 | Filter 風格,組合性強 | 學習曲線較陡,錯誤訊息較難讀 |
actix-web 與 axum 的最大差異在執行模型。actix-web 使用自有的 actor 式執行模型,歷史包袱較重(例如早期版本中 actor 建立在 unsafe 之上引發的爭議)。相較之下 axum 直接建在 Tokio 的 hyper 之上,整個堆疊一致,學習資源也集中在 Tokio 生態。
rocket 的抽象層次最高,它把「路由、資料驗證、錯誤處理」都做成約定好的形式,開發體驗非常順暢,但當需求超出約定時能做的調整有限。axum 則相反,它提供的是零件,組合方式由你決定。
對本書的專案而言,axum 是合適的選擇:它的分層足夠清晰,能讓本章的每個概念都有明確的對應位置。
完整範例#
綜合以上,一個可執行的完整 Web 服務如下:
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[derive(Clone)]
struct AppState {
hits: Arc<AtomicU64>,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
#[derive(Debug)]
struct ApiError {
status: StatusCode,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
}
}
async fn index() -> Html<&'static str> {
Html("<h1>minigrep API</h1>")
}
async fn list_users(State(state): State<AppState>) -> Json<Vec<User>> {
let id = state.hits.fetch_add(1, Ordering::Relaxed) + 1;
Json(vec![User {
id,
name: "Alice".to_string(),
}])
}
async fn create_user(Json(payload): Json<CreateUser>) -> Result<(StatusCode, Json<User>), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError {
status: StatusCode::BAD_REQUEST,
message: "姓名不可為空".to_string(),
});
}
Ok((
StatusCode::CREATED,
Json(User {
id: 1,
name: payload.name,
}),
))
}
async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, ApiError> {
if id == 0 {
return Err(ApiError {
status: StatusCode::NOT_FOUND,
message: "找不到使用者".to_string(),
});
}
Ok(Json(User {
id,
name: "Alice".to_string(),
}))
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("無法安裝 Ctrl+C 處理器");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("無法安裝 SIGTERM 處理器")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("收到關閉訊號,停止接受新請求");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let state = AppState {
hits: Arc::new(AtomicU64::new(0)),
};
let app = Router::new()
.route("/", get(index))
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user))
.with_state(state);
let listener = TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("伺服器正在 0.0.0.0:3000 監聽");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
這個範例把本章的每個概念都串在一起:Router 定義路由,State 提供共用狀態,Json 處理序列化,Path 解析路徑參數,ApiError 把應用錯誤轉成 HTTP 回應,tracing 負責觀測,with_graceful_shutdown 負責生命週期管理。
flowchart TD
A["TCP 連線建立"] --> B["hyper 解析 HTTP 請求"]
B --> C["Router 依路徑比對路由表"]
C --> D["Middleware 層 最外層先執行"]
D --> E["Middleware 層 由外而內"]
E --> F["Extractor 從請求抽取 Path Query Json State"]
F --> G["Handler 處理業務邏輯"]
G --> H["IntoResponse 轉換回應型別"]
H --> I["Middleware 回程 由內而外"]
I --> J["Middleware 回程 最內層先執行"]
J --> K["hyper 序列化 HTTP 回應"]
K --> L["寫回 TCP 連線"]
F -.->|"解析失敗"| M["Rejection 轉為錯誤回應"]
M --> I
G -.->|"Err"| N["IntoResponse 產生錯誤狀態碼"]
N --> I下一步要處理的是這個服務最關鍵的依賴:資料的儲存。目前的 list_users 只是回傳硬編碼的資料,任何真實的 API 都必須把資料放在資料庫裡,而資料庫存取又帶來全新的問題:連線成本、交易邊界、查詢效能,以及輸入驗證的安全性。下一節就以 sqlx 為例,把這些問題一一處理。