Rust 程式語言憑藉其卓越的執行效能與記憶體安全保證,已成為現代後端服務開發的重要選擇。在眾多 Rust Web 框架當中,Axum 以其簡潔的 API 設計、強大的型別系統整合,以及與 Tokio 非同步執行環境的無縫搭配,逐漸成為開發者建構 RESTful API 服務時的首選工具。本文將以實作待辦事項管理 API 為例,完整說明如何運用 Rust 與 Axum 框架,搭配 SQLx 資料庫操作套件,從零開始建構一個具備完整 CRUD 功能的生產等級 REST API 服務。

現代 Web 應用程式的後端服務需要同時處理大量並發請求,傳統的執行緒模型在面對高併發場景時往往會遇到資源耗盡的問題。Rust 的非同步程式設計模型透過 async/await 語法糖與 Future 抽象,讓開發者能夠以接近同步程式碼的方式撰寫非同步邏輯,同時獲得極低的執行時期開銷。Axum 框架建構於 Tokio 非同步執行環境之上,充分發揮 Rust 非同步生態系統的優勢,使得單一伺服器實體就能輕鬆處理數萬甚至數十萬的並發連線。

在開始實作之前,有必要先理解 Axum 框架的核心設計理念。Axum 採用組合式架構,將請求處理流程拆解為多個獨立的元件,包含路由、中介層、抽取器與處理函式等。這種設計方式讓程式碼具備高度的可測試性與可維護性,開發者可以針對每個元件進行單元測試,而不需要啟動完整的 HTTP 伺服器。此外,Axum 充分運用 Rust 的型別系統,在編譯時期就能捕捉許多常見的程式錯誤,大幅降低執行時期發生問題的機率。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 14
skinparam minClassWidth 100

|Client|
start
:發送 HTTP 請求;

|Router|
:路由比對;
note right
  定義 API 端點
  與 HTTP 方法對應
end note
:轉發至對應處理器;

|Middleware|
:執行前置處理;
note right
  CORS 驗證
  請求日誌記錄
  身份驗證檢查
end note

|Extractor|
:提取請求資料;
note right
  State: 應用程式狀態
  Path: URL 路徑參數
  Json: 請求主體
  Query: 查詢參數
end note

|Handler|
:執行業務邏輯;
:呼叫資料模型方法;

|SQLx|
:執行資料庫操作;
:管理連線池;

|SQLite|
:處理 SQL 查詢;
:回傳查詢結果;

|Handler|
:組裝回應資料;

|Middleware|
:執行後置處理;

|Client|
:接收 HTTP 回應;
stop

@enduml

上方的架構圖清楚呈現了 Axum REST API 服務的各個組成層級。當一個 HTTP 請求進入系統時,首先會經過 Router 路由層進行端點比對,接著通過 Middleware 中介層處理跨域請求、日誌記錄等橫切關注點,然後由 Extractor 抽取器從請求中提取所需資料,最後交由 Handler 處理函式執行業務邏輯並與資料庫互動。這種分層架構讓每個元件都能專注於單一職責,符合軟體工程的關注點分離原則。

資料模型設計與定義

在任何資料驅動的應用程式中,資料模型的設計都是整個系統架構的基石。一個設計良好的資料模型不僅能夠準確反映業務需求,還能夠簡化後續的資料庫操作與 API 實作。在 Rust 中,我們使用 struct 來定義資料模型,並透過 derive 巨集自動實作各種 trait,讓資料模型具備序列化、反序列化與資料庫對映等能力。

待辦事項的資料模型需要包含三個核心欄位:用於唯一識別的 id、儲存事項內容的 body,以及標記完成狀態的 completed。id 欄位採用 64 位元整數型別,這個選擇考量了與 SQLite 資料庫的相容性,因為 SQLite 的 INTEGER PRIMARY KEY 實際上就是 64 位元整數。body 欄位使用 String 型別來儲存待辦事項的文字描述,而 completed 欄位則使用布林值來表示該事項是否已經完成。

// 引入 SQLx 的 FromRow derive 巨集
// 這個巨集會自動實作將資料庫查詢結果轉換為 Rust struct 的邏輯
use sqlx::FromRow;

// Todo 結構體定義
// 代表資料庫中 todos 表格的一筆記錄
// #[derive(FromRow)] 讓 SQLx 能夠自動將查詢結果對映到此結構
#[derive(sqlx::FromRow)]
pub struct Todo {
    // 主鍵欄位,使用 i64 與 SQLite 的 INTEGER PRIMARY KEY 對應
    id: i64,
    // 待辦事項的內容描述
    body: String,
    // 完成狀態標記,true 表示已完成,false 表示未完成
    completed: bool,
}

// 為 Todo 結構體實作 getter 方法
// 這些方法提供對私有欄位的唯讀存取
// 採用此模式可以確保資料的封裝性,防止外部程式碼直接修改內部狀態
impl Todo {
    // 取得待辦事項的唯一識別碼
    // 回傳值為 i64 型別的副本,因為 i64 實作了 Copy trait
    pub fn id(&self) -> i64 {
        self.id
    }

    // 取得待辦事項的內容描述
    // 回傳字串切片的參考,避免不必要的記憶體配置
    // 使用 as_ref() 將 String 轉換為 &str
    pub fn body(&self) -> &str {
        self.body.as_ref()
    }

    // 取得待辦事項的完成狀態
    // 回傳布林值的副本
    pub fn completed(&self) -> bool {
        self.completed
    }
}

上述程式碼展示了 Rust 中定義資料模型的慣用模式。將欄位宣告為私有(沒有 pub 修飾詞),然後透過公開的 getter 方法提供存取介面,這種封裝方式能夠有效控制資料的存取方式,並在未來需要修改內部實作時提供彈性。例如,若日後需要在取得 body 時加入快取機制或格式轉換,只需要修改 getter 方法的實作,而不會影響到使用該方法的外部程式碼。

除了代表完整資料記錄的 Todo 結構體之外,我們還需要定義用於接收 API 請求的輸入結構體。建立新待辦事項時,客戶端只需要提供事項內容,id 會由資料庫自動產生,而 completed 狀態預設為 false。更新待辦事項時,客戶端則需要同時提供新的內容與完成狀態。將這兩種不同的使用情境分別定義為獨立的結構體,可以讓型別系統幫助我們在編譯時期就捕捉到不正確的資料傳遞。

// 引入 serde 的 Deserialize derive 巨集
// 用於將 JSON 請求主體反序列化為 Rust struct
use serde::Deserialize;

// CreateTodo 結構體定義
// 用於接收建立新待辦事項的 API 請求
// 只包含 body 欄位,id 由資料庫自動產生,completed 預設為 false
#[derive(Deserialize)]
pub struct CreateTodo {
    // 新待辦事項的內容描述
    body: String,
}

impl CreateTodo {
    // 取得待辦事項的內容描述
    // 提供唯讀存取,確保資料不會被意外修改
    pub fn body(&self) -> &str {
        self.body.as_ref()
    }
}

// UpdateTodo 結構體定義
// 用於接收更新待辦事項的 API 請求
// 包含 body 與 completed 兩個欄位,允許同時更新內容與狀態
#[derive(Deserialize)]
pub struct UpdateTodo {
    // 更新後的待辦事項內容
    body: String,
    // 更新後的完成狀態
    completed: bool,
}

impl UpdateTodo {
    // 取得更新後的內容描述
    pub fn body(&self) -> &str {
        self.body.as_ref()
    }

    // 取得更新後的完成狀態
    pub fn completed(&self) -> bool {
        self.completed
    }
}

Deserialize derive 巨集來自 serde 套件,這是 Rust 生態系統中最廣泛使用的序列化框架。當 Axum 的 Json 抽取器收到 HTTP 請求時,會自動呼叫 serde_json 將 JSON 格式的請求主體轉換為對應的 Rust 結構體。這個過程完全在編譯時期產生程式碼,沒有執行時期的反射開銷,因此效能極佳。如果 JSON 資料的格式與結構體定義不符,反序列化過程會失敗並回傳錯誤,Axum 會自動將此錯誤轉換為 400 Bad Request 回應。

資料庫操作實作

有了資料模型的定義之後,接下來要實作與資料庫互動的操作方法。SQLx 是 Rust 生態系統中功能強大的非同步資料庫操作套件,它支援 PostgreSQL、MySQL、SQLite 等多種資料庫,並提供編譯時期的 SQL 查詢驗證功能。在本文的範例中,我們使用 SQLite 作為資料庫,這是一個輕量級的嵌入式資料庫,非常適合用於開發測試與小型應用程式。

資料庫操作的核心是連線池管理。每次執行資料庫查詢都建立新連線會造成顯著的效能開銷,因此實務上會使用連線池來重複利用已建立的連線。SQLx 內建連線池支援,開發者只需要在應用程式啟動時建立 SqlitePool 實體,然後將其注入到需要存取資料庫的處理函式中即可。Axum 的 State 抽取器正是用來處理這種全域共享狀態的傳遞。

// 引入 SQLx 相關型別
use sqlx::{query_as as query, SqlitePool, Error as SqlxError};

impl Todo {
    // 列出所有待辦事項
    // 執行不帶條件的 SELECT 查詢,回傳所有記錄
    // dbpool 參數是資料庫連線池的參考
    pub async fn list(dbpool: &SqlitePool) -> Result<Vec<Todo>, Error> {
        // 使用 query_as 巨集執行 SQL 查詢
        // select * from todos 會取得 todos 表格的所有記錄
        // fetch_all 方法會等待查詢完成並回傳所有結果列
        query("select * from todos")
            .fetch_all(dbpool)
            .await
            // 將 sqlx::Error 轉換為自定義的 Error 型別
            .map_err(Into::into)
    }

    // 讀取單一待辦事項
    // 根據 id 查詢特定記錄,可能存在也可能不存在
    // 回傳 Option<Todo> 來表示查詢結果可能為空的情況
    pub async fn read(dbpool: &SqlitePool, id: i64) -> Result<Option<Todo>, Error> {
        // 使用參數化查詢防止 SQL 注入攻擊
        // ? 是 SQLite 的參數佔位符號
        query("select * from todos where id = ?")
            // bind 方法將 id 值繫結到第一個參數佔位符
            .bind(id)
            // fetch_optional 在找不到記錄時回傳 None 而非錯誤
            .fetch_optional(dbpool)
            .await
            .map_err(Into::into)
    }

    // 建立新待辦事項
    // 插入一筆新記錄到資料庫,並回傳包含自動產生 id 的完整記錄
    pub async fn create(dbpool: &SqlitePool, create_todo: CreateTodo) -> Result<Todo, Error> {
        // INSERT 語句搭配 RETURNING 子句
        // SQLite 3.35.0 版本開始支援 RETURNING 語法
        // 這讓我們能在單一查詢中完成插入並取得結果
        // completed 預設值設為 0(false)
        query("insert into todos (body, completed) values (?, 0) returning *")
            // 將 create_todo 的 body 欄位值繫結到參數
            .bind(create_todo.body())
            // fetch_one 預期查詢會回傳正好一筆記錄
            .fetch_one(dbpool)
            .await
            .map_err(Into::into)
    }

    // 更新待辦事項
    // 根據 id 找到記錄並更新其 body 與 completed 欄位
    pub async fn update(
        dbpool: &SqlitePool,
        id: i64,
        updated_todo: UpdateTodo
    ) -> Result<Todo, Error> {
        // UPDATE 語句搭配 RETURNING 子句
        // 同時更新 body 與 completed 兩個欄位
        // 注意參數繫結的順序必須與 ? 佔位符的順序一致
        query("update todos set body = ?, completed = ? where id = ? returning *")
            // 第一個參數:新的 body 值
            .bind(updated_todo.body())
            // 第二個參數:新的 completed 值
            .bind(updated_todo.completed())
            // 第三個參數:要更新的記錄 id
            .bind(id)
            .fetch_one(dbpool)
            .await
            .map_err(Into::into)
    }

    // 刪除待辦事項
    // 根據 id 從資料庫中移除記錄
    // 刪除操作不需要回傳資料,因此回傳型別為 Result<(), Error>
    pub async fn delete(dbpool: &SqlitePool, id: i64) -> Result<(), Error> {
        // DELETE 語句不需要 RETURNING 子句
        query("delete from todos where id = ?")
            .bind(id)
            // execute 方法用於不需要回傳資料的查詢
            // 會回傳 QueryResult 包含受影響的列數等資訊
            .execute(dbpool)
            .await?;
        // 刪除成功後回傳空的 Ok
        Ok(())
    }
}

這段程式碼實作了完整的 CRUD 操作。每個方法都是非同步的,使用 async 關鍵字宣告,在方法內部使用 await 等待資料庫操作完成。這種非同步設計讓應用程式在等待資料庫回應時可以處理其他請求,大幅提升整體吞吐量。

值得注意的是參數化查詢的使用方式。我們使用 ? 作為參數佔位符,然後透過 bind 方法依序繫結實際值。這種做法不僅能防止 SQL 注入攻擊,還能讓資料庫對查詢計畫進行快取優化。SQLx 的 bind 方法採用鏈式呼叫設計,多個參數可以連續繫結,但必須注意繫結順序要與 SQL 語句中佔位符出現的順序一致。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 14
skinparam minClassWidth 100

participant "Client" as client
participant "Axum Handler" as handler
participant "Todo Model" as model
participant "SQLx Pool" as pool
participant "SQLite" as db

client -> handler: HTTP POST /v1/todos
activate handler

handler -> model: Todo::create(pool, create_todo)
activate model

model -> pool: 取得連線
activate pool

pool -> db: INSERT INTO todos...
activate db

db --> pool: 回傳新記錄
deactivate db

pool --> model: Todo 物件
deactivate pool

model --> handler: Result<Todo, Error>
deactivate model

handler --> client: HTTP 200 JSON Response
deactivate handler

@enduml

上圖以時序圖方式呈現了建立待辦事項的完整流程。從客戶端發送 HTTP POST 請求開始,經過 Axum Handler 處理,呼叫 Todo Model 的 create 方法,再透過 SQLx Pool 取得資料庫連線並執行 INSERT 語句,最後將新建立的記錄轉換為 JSON 格式回傳給客戶端。整個流程採用非同步處理,在等待資料庫操作期間,執行緒可以處理其他請求。

API 路由架構設計

Axum 的路由系統建立在 Tower 生態系統之上,採用組合式設計,讓開發者能夠靈活地組織 API 端點。Router 是路由系統的核心型別,它可以將 HTTP 方法與路徑模式對應到處理函式,並支援路由巢狀、中介層套用等進階功能。設計良好的路由架構不僅能提升程式碼的可讀性,還能為未來的 API 版本演進預留空間。

在我們的待辦事項 API 中,採用 RESTful 風格的路由設計。根層級提供服務健康檢查端點,包含存活探測(/alive)與就緒探測(/ready)兩個端點,這些端點主要用於容器編排平台(如 Kubernetes)的健康檢查機制。業務相關的 API 端點則放在 /v1 前綴下,方便未來進行 API 版本管理。

// 引入 Axum 相關型別與函式
use axum::{
    routing::{get, post, put, delete},
    Router,
};
// 引入 Tower HTTP 中介層
use tower_http::cors::{CorsLayer, Any};
use tower_http::trace::TraceLayer;
// 引入 SQLx 連線池型別
use sqlx::{Pool, Sqlite};

// 建立並設定 API Router
// 這是應用程式的核心路由配置函式
// 接收資料庫連線池作為參數,並將其注入到應用程式狀態中
pub async fn create_router(dbpool: Pool<Sqlite>) -> Router {
    Router::new()
        // 存活探測端點
        // 僅檢查應用程式本身是否正常運作
        // 回傳簡單的字串 "ok" 即可
        .route("/alive", get(|| async { "ok" }))
        // 就緒探測端點
        // 檢查應用程式是否已準備好接收流量
        // 包含資料庫連線檢查
        .route("/ready", get(ping))
        // 使用 nest 方法建立路由前綴
        // 所有巢狀路由都會加上 /v1 前綴
        // 這種設計便於未來進行 API 版本管理
        .nest(
            "/v1",
            Router::new()
                // /v1/todos 端點
                // GET 方法對應 todo_list 處理函式(列出所有待辦事項)
                // POST 方法對應 todo_create 處理函式(建立新待辦事項)
                .route("/todos", get(todo_list).post(todo_create))
                // /v1/todos/:id 端點
                // :id 是路徑參數,可以透過 Path 抽取器取得
                // GET 方法對應 todo_read(讀取單一待辦事項)
                // PUT 方法對應 todo_update(更新待辦事項)
                // DELETE 方法對應 todo_delete(刪除待辦事項)
                .route(
                    "/todos/:id",
                    get(todo_read).put(todo_update).delete(todo_delete)
                ),
        )
        // 將資料庫連線池注入為應用程式狀態
        // 處理函式可以透過 State 抽取器取得此狀態
        .with_state(dbpool)
        // 套用 CORS 中介層
        // 允許任意來源與任意 HTTP 方法
        // 生產環境應該根據實際需求設定更嚴格的規則
        .layer(CorsLayer::new().allow_methods(Any).allow_origin(Any))
        // 套用追蹤中介層
        // 自動記錄每個請求的處理資訊
        .layer(TraceLayer::new_for_http())
}

這段程式碼展示了 Axum Router 的核心配置方式。route 方法用於定義單一路由,接受路徑模式與處理函式作為參數。處理函式可以透過鏈式呼叫(如 get(handler).post(handler))來為同一路徑註冊多個 HTTP 方法。nest 方法則用於建立路由前綴,讓相關的端點可以組織在一起。

with_state 方法是 Axum 狀態管理的關鍵。它將資料庫連線池注入為應用程式的全域狀態,讓所有處理函式都能透過 State 抽取器取得連線池的複製(實際上是 Arc 智慧指標,因此複製成本極低)。這種設計讓資源的生命週期管理變得簡單,連線池在應用程式啟動時建立一次,然後被所有請求共享使用。

layer 方法用於套用中介層,這些中介層會在請求處理流程中的特定時機被呼叫。CorsLayer 處理跨來源資源共享,讓瀏覽器中執行的 JavaScript 應用程式能夠存取我們的 API。TraceLayer 則提供請求追蹤功能,自動記錄每個請求的處理時間、狀態碼等資訊,對於除錯與效能分析非常有幫助。

抽取器與處理函式實作

Axum 的抽取器系統是其最具特色的功能之一。抽取器負責從 HTTP 請求中提取各種資料,包括路徑參數、查詢字串、請求標頭、JSON 主體等,並將這些資料以強型別的方式傳遞給處理函式。這種設計讓處理函式的簽章就能清楚表達其需要的輸入資料,大幅提升程式碼的可讀性與自我文件化程度。

最常用的抽取器包含三種:State 用於提取應用程式狀態(如資料庫連線池),Path 用於提取路徑參數(如 /todos/:id 中的 id),Json 則用於將請求主體反序列化為 Rust 結構體。這些抽取器可以在處理函式的參數列表中任意組合使用,Axum 會自動從請求中提取對應的資料。

// 引入 Axum 抽取器與回應型別
use axum::{
    extract::{State, Path, Json},
};
// 引入 SQLx 連線池型別
use sqlx::SqlitePool;

// 就緒探測處理函式
// 檢查資料庫連線是否正常
// State(dbpool) 語法使用解構來同時提取狀態並繫結到變數
pub async fn ping(State(dbpool): State<SqlitePool>) -> Result<String, Error> {
    // 從連線池取得一個連線
    // acquire 方法會從池中取得可用連線,若池已滿則等待
    let mut conn = dbpool.acquire().await?;
    // 執行 ping 操作測試連線是否有效
    // ping 會發送簡單的查詢來確認資料庫回應正常
    conn.ping()
        .await
        // ping 成功則回傳 "ok" 字串
        .map(|_| "ok".to_string())
        // 將可能的錯誤轉換為自定義 Error 型別
        .map_err(Into::into)
}

// 列出所有待辦事項
// 不需要任何額外輸入,僅需要資料庫連線池
pub async fn todo_list(
    State(dbpool): State<SqlitePool>,
) -> Result<Json<Vec<Todo>>, Error> {
    // 呼叫 Todo::list 取得所有待辦事項
    Todo::list(&dbpool)
        .await
        // 將 Vec<Todo> 包裝成 Json 型別
        // Axum 會自動將其序列化為 JSON 格式並設定適當的 Content-Type
        .map(Json::from)
}

// 讀取單一待辦事項
// 需要從路徑中提取 id 參數
pub async fn todo_read(
    State(dbpool): State<SqlitePool>,
    // Path(id) 會從 /todos/:id 中提取 id 值
    // 自動將字串轉換為 i64 型別
    Path(id): Path<i64>,
) -> Result<Json<Todo>, Error> {
    // 呼叫 Todo::read 查詢指定 id 的待辦事項
    Todo::read(&dbpool, id)
        .await?
        // read 回傳 Option<Todo>,需要處理找不到記錄的情況
        // ok_or 將 None 轉換為 Error::NotFound
        .ok_or(Error::NotFound)
        // 將 Todo 包裝成 Json 型別
        .map(Json::from)
}

// 建立新待辦事項
// 需要從請求主體中提取 JSON 資料
pub async fn todo_create(
    State(dbpool): State<SqlitePool>,
    // Json(new_todo) 會將請求主體反序列化為 CreateTodo 結構體
    Json(new_todo): Json<CreateTodo>,
) -> Result<Json<Todo>, Error> {
    // 呼叫 Todo::create 建立新記錄
    Todo::create(&dbpool, new_todo)
        .await
        .map(Json::from)
}

// 更新待辦事項
// 同時需要路徑參數(id)與請求主體(更新內容)
pub async fn todo_update(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
    Json(updated_todo): Json<UpdateTodo>,
) -> Result<Json<Todo>, Error> {
    // 呼叫 Todo::update 更新指定記錄
    Todo::update(&dbpool, id, updated_todo)
        .await
        .map(Json::from)
}

// 刪除待辦事項
// 僅需要路徑參數中的 id
pub async fn todo_delete(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
) -> Result<(), Error> {
    // 呼叫 Todo::delete 刪除指定記錄
    // 刪除成功回傳空的 Ok,Axum 會回傳 200 OK 無內容
    Todo::delete(&dbpool, id).await
}

每個處理函式都遵循相似的模式:使用抽取器取得所需資料,呼叫資料模型的對應方法執行業務邏輯,然後將結果包裝為適當的回應型別。這種一致的程式碼結構讓維護者能夠快速理解每個端點的行為,也讓新功能的開發變得更有效率。

處理函式的回傳型別 Result<T, Error> 是 Rust 錯誤處理的標準模式。當操作成功時回傳 Ok(T),當操作失敗時回傳 Err(Error)。Axum 會根據回傳值自動產生適當的 HTTP 回應:成功時將 T 轉換為回應主體,失敗時則使用 Error 的 IntoResponse 實作來產生錯誤回應。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 14
skinparam minClassWidth 100

|HTTP Request|
start
:接收 HTTP 請求;

|State Extractor|
:提取應用程式狀態;
note right
  從 with_state 注入的狀態
  多個請求共用同一實體
  常用於資料庫連線池
end note

|Path Extractor|
:提取 URL 路徑參數;
note right
  /todos/:id 中的 id
  支援單一與多個參數
  自動進行型別轉換
end note

|Json Extractor|
:提取請求主體 JSON;
note right
  自動反序列化為結構體
  需要 Deserialize trait
  失敗時回傳 400 錯誤
end note

|Query Extractor|
:提取查詢字串參數;
note right
  ?key=value 格式
  需要 Deserialize trait
end note

|HeaderMap Extractor|
:提取請求標頭;
note right
  存取所有 HTTP 標頭
  可查詢特定標頭值
end note

|Handler|
:接收所有提取的資料;
:執行業務邏輯處理;
stop

@enduml

上圖整理了 Axum 中常用的抽取器類型。除了本文範例中使用的 State、Path、Json 之外,還有 Query 用於提取查詢字串參數,HeaderMap 用於存取請求標頭等。這些抽取器可以根據 API 的需求自由組合,Axum 的型別系統會確保所有抽取操作在編譯時期就被正確處理。

錯誤處理機制

完善的錯誤處理是生產等級 API 服務不可或缺的一環。好的錯誤處理機制不僅能讓服務在遇到問題時優雅地回應,還能提供有意義的錯誤訊息幫助客戶端理解發生了什麼問題。在 Rust 中,我們透過定義自訂錯誤型別並實作相關 trait 來建立錯誤處理機制。

我們的 API 服務主要會遇到兩類錯誤:資料庫操作錯誤(如連線失敗、查詢語法錯誤等)與資源不存在錯誤(如查詢的待辦事項 id 不存在)。這兩類錯誤需要對映到不同的 HTTP 狀態碼:資料庫錯誤通常對應 500 Internal Server Error,而資源不存在則對應 404 Not Found。

// 引入 Axum HTTP 狀態碼型別
use axum::http::StatusCode;
// 引入 Axum 回應相關型別
use axum::response::{IntoResponse, Response};

// 自定義錯誤列舉
// 使用 Debug derive 讓錯誤可以被格式化輸出用於除錯
#[derive(Debug)]
pub enum Error {
    // 資料庫相關錯誤
    // 包含 HTTP 狀態碼與錯誤訊息
    // 狀態碼讓我們能夠針對不同的資料庫錯誤回傳適當的 HTTP 狀態
    Sqlx(StatusCode, String),
    // 資源不存在錯誤
    // 用於查詢不到指定 id 的記錄時
    NotFound,
}

// 實作從 sqlx::Error 到自定義 Error 的轉換
// 這讓我們可以在程式碼中使用 ? 運算子自動轉換錯誤型別
impl From<sqlx::Error> for Error {
    fn from(err: sqlx::Error) -> Error {
        // 使用 match 針對不同的 sqlx 錯誤類型進行處理
        match err {
            // RowNotFound 錯誤表示查詢沒有回傳任何記錄
            // 這在使用 fetch_one 時會發生,對映到 NotFound 錯誤
            sqlx::Error::RowNotFound => Error::NotFound,
            // 其他所有資料庫錯誤都對映為內部伺服器錯誤
            // 錯誤訊息會被轉換為字串供除錯使用
            _ => Error::Sqlx(
                StatusCode::INTERNAL_SERVER_ERROR,
                err.to_string(),
            ),
        }
    }
}

// 實作 IntoResponse trait
// 這讓 Error 型別可以被 Axum 轉換為 HTTP 回應
impl IntoResponse for Error {
    fn into_response(self) -> Response {
        match self {
            // Sqlx 錯誤回傳對應的狀態碼與錯誤訊息
            // (StatusCode, String) 元組會被 Axum 轉換為
            // 具有該狀態碼與純文字主體的回應
            Error::Sqlx(code, body) => (code, body).into_response(),
            // NotFound 錯誤僅回傳 404 狀態碼
            // 不包含額外的錯誤訊息
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),
        }
    }
}

這段程式碼建立了完整的錯誤處理管線。From trait 的實作讓我們可以在處理函式中使用 ? 運算子來簡化錯誤處理,當 sqlx 操作回傳 Err 時,? 運算子會自動將 sqlx::Error 轉換為我們的自定義 Error 型別並提早回傳。IntoResponse trait 的實作則讓 Axum 知道如何將我們的 Error 型別轉換為 HTTP 回應。

在實務應用中,錯誤處理機制還可以進一步擴充。例如,可以加入日誌記錄功能,在回傳錯誤回應之前先將錯誤詳情記錄到日誌系統。也可以實作更細緻的錯誤分類,將不同類型的資料庫錯誤對映到不同的 HTTP 狀態碼,讓客戶端能夠根據狀態碼判斷錯誤的性質並採取適當的處理措施。

服務啟動與測試驗證

完成所有元件的實作之後,接下來要將這些元件組合起來並啟動 HTTP 伺服器。Axum 建構於 Tokio 非同步執行環境之上,因此需要使用 Tokio 的執行時期來運行伺服器。伺服器啟動前需要先建立資料庫連線池,然後將連線池傳遞給 Router 建立函式。

// 引入 Tokio 網路相關功能
use tokio::net::TcpListener;
// 引入 SQLx SQLite 連線池
use sqlx::sqlite::SqlitePoolOptions;

// 使用 tokio::main 巨集將 main 函式轉換為非同步入口點
// 這個巨集會自動建立 Tokio 執行時期並執行非同步程式碼
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 初始化追蹤訂閱器
    // 這會啟用 TraceLayer 的日誌輸出功能
    tracing_subscriber::fmt::init();

    // 建立 SQLite 連線池
    // max_connections 設定池中最大連線數
    // 連線字串指定資料庫檔案路徑,?mode=rwc 表示讀寫模式,不存在時自動建立
    let dbpool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect("sqlite:todos.db?mode=rwc")
        .await?;

    // 執行資料庫遷移
    // 確保 todos 表格存在
    // 實務上應該使用 sqlx-cli 工具管理遷移
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            body TEXT NOT NULL,
            completed BOOLEAN NOT NULL DEFAULT 0
        )"
    )
    .execute(&dbpool)
    .await?;

    // 建立 Router 並注入資料庫連線池
    let app = create_router(dbpool).await;

    // 建立 TCP 監聽器
    // 綁定到 127.0.0.1:3000 位址
    let listener = TcpListener::bind("127.0.0.1:3000").await?;

    // 輸出啟動訊息
    println!("伺服器正在監聽 http://127.0.0.1:3000");

    // 啟動 Axum 伺服器
    // serve 方法接受監聽器與 Router,並開始處理請求
    axum::serve(listener, app).await?;

    Ok(())
}

伺服器啟動後,可以使用 HTTPie 或 curl 等命令列工具進行測試。HTTPie 提供了更友善的語法與格式化輸出,適合用於 API 開發與除錯。以下是測試各個 API 端點的命令範例與預期結果說明。

健康檢查端點的測試相當直接。存活探測僅檢查應用程式本身是否正在執行,不涉及任何外部相依性。就緒探測則會實際執行資料庫連線測試,確保應用程式已準備好處理業務請求。這兩個端點在容器化部署環境中特別重要,Kubernetes 等編排平台會定期呼叫這些端點來判斷容器的健康狀態。

# 測試存活探測端點
# 預期回應:HTTP 200 OK,主體內容為 "ok"
# 此端點僅確認應用程式正在執行
http GET 127.0.0.1:3000/alive

# 測試就緒探測端點
# 預期回應:HTTP 200 OK,主體內容為 "ok"
# 此端點會實際測試資料庫連線
http GET 127.0.0.1:3000/ready

待辦事項 CRUD 操作的測試需要按照邏輯順序進行。首先建立一個新的待辦事項,記下回傳的 id 值。然後使用該 id 測試讀取與更新功能。最後測試刪除功能,並確認刪除後再次讀取會回傳 404 錯誤。

# 建立新待辦事項
# HTTP POST 方法,JSON 主體包含 body 欄位
# 預期回應:HTTP 200 OK,JSON 主體包含完整的 Todo 物件(含自動產生的 id)
http POST 127.0.0.1:3000/v1/todos body='完成 Axum 教學文章'

# 列出所有待辦事項
# HTTP GET 方法,無需任何參數
# 預期回應:HTTP 200 OK,JSON 陣列包含所有待辦事項
http GET 127.0.0.1:3000/v1/todos

# 讀取特定待辦事項
# 將 1 替換為實際的待辦事項 id
# 預期回應:HTTP 200 OK,JSON 主體包含該待辦事項
http GET 127.0.0.1:3000/v1/todos/1

# 更新待辦事項
# HTTP PUT 方法,JSON 主體包含 body 與 completed 欄位
# completed:=true 使用 := 語法傳送布林值而非字串
# 預期回應:HTTP 200 OK,JSON 主體包含更新後的待辦事項
http PUT 127.0.0.1:3000/v1/todos/1 body='完成 Axum 教學文章' completed:=true

# 刪除待辦事項
# HTTP DELETE 方法,無需請求主體
# 預期回應:HTTP 200 OK,無回應主體
http DELETE 127.0.0.1:3000/v1/todos/1

# 確認刪除成功
# 嘗試讀取已刪除的待辦事項
# 預期回應:HTTP 404 Not Found
http GET 127.0.0.1:3000/v1/todos/1

透過這些測試命令,開發者可以快速驗證 API 服務的各項功能是否正常運作。在實際開發流程中,這些手動測試通常會在初期開發階段使用。隨著專案規模成長,應該將這些測試自動化,使用 Rust 的整合測試功能或專門的 API 測試框架來確保服務品質。

進階主題與最佳實務

在建構生產等級的 API 服務時,除了基本的 CRUD 功能之外,還有許多進階主題值得關注。身份驗證與授權是其中最重要的一環,確保只有經過授權的使用者才能存取特定資源。Axum 可以透過中介層來實作 JWT 驗證、API 金鑰驗證等機制。tower-http 套件提供了許多實用的中介層,包含速率限制、請求大小限制、回應壓縮等功能。

資料驗證是另一個重要主題。雖然 Rust 的型別系統已經提供了基本的型別檢查,但業務邏輯層面的驗證(如字串長度限制、數值範圍檢查等)需要額外的處理。validator 套件提供了宣告式的驗證規則定義,可以與 serde 整合使用,在反序列化時自動執行驗證。

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 14
skinparam minClassWidth 100

|用戶端|
start
:發送 HTTPS 請求;

|負載平衡器|
:TLS 終止處理;
note right
  處理 SSL/TLS 加密
  卸載加密運算負擔
end note
:健康檢查與流量分配;
note right
  輪詢或加權演算法
  分配至健康實體
end note

|Axum 服務叢集|
fork
  :Axum 實體 1 處理;
fork again
  :Axum 實體 2 處理;
fork again
  :Axum 實體 3 處理;
end fork

|Redis 快取層|
:檢查快取命中;
if (快取存在?) then (是)
  :回傳快取資料;
else (否)
  |PostgreSQL 叢集|
  :主節點處理寫入;
  fork
    :副本 1 同步資料;
  fork again
    :副本 2 同步資料;
  end fork
  :回傳查詢結果;
  |Redis 快取層|
  :更新快取;
endif

|Axum 服務叢集|
:組裝回應資料;

|負載平衡器|
:回傳至用戶端;

|用戶端|
:接收回應;
stop

@enduml

上圖呈現了生產環境中常見的 API 服務部署架構。多個 Axum 實體部署在負載平衡器後方,實現水平擴展與高可用性。資料庫採用主從複製架構,主節點處理寫入操作,副本節點分擔讀取負載。Redis 快取層用於儲存 Session 資料與快取頻繁查詢的結果,減少資料庫壓力並提升回應速度。

日誌記錄與監控對於維運生產服務至關重要。tracing 套件是 Rust 生態系統中的標準追蹤框架,它提供了結構化日誌記錄功能,可以記錄請求處理過程中的各種事件與指標。搭配 tracing-subscriber 套件,可以將追蹤資料輸出到控制台、檔案或外部監控系統。OpenTelemetry 整合則讓追蹤資料可以發送到 Jaeger、Zipkin 等分散式追蹤系統,實現跨服務的請求追蹤。

效能優化是持續進行的工作。Axum 本身已經具備優異的效能,但應用層面的優化同樣重要。資料庫查詢優化包含適當的索引設計、避免 N+1 查詢問題、使用連線池等。回應壓縮可以顯著減少網路傳輸量,特別是對於大型 JSON 回應。適當的快取策略可以避免重複查詢相同的資料,Redis 是常見的快取解決方案。

本文從資料模型設計開始,逐步介紹了使用 Rust 與 Axum 框架建構 REST API 服務的完整流程。透過實作待辦事項管理 API,我們涵蓋了資料庫操作、路由設計、抽取器運用、錯誤處理等核心主題。Rust 的型別系統與所有權機制為我們的 API 服務提供了堅實的安全保障,而 Axum 框架則讓這些安全保障能夠以簡潔優雅的方式表達。

對於想要進一步深入學習的開發者,建議閱讀 Axum 官方文件與範例程式碼,了解更多進階功能如 WebSocket 支援、Server-Sent Events、多部分表單處理等。同時也推薦研究 Tower 生態系統,了解如何撰寫自訂中介層來實作特定的橫切關注點。透過持續的學習與實作,相信每位開發者都能夠掌握使用 Rust 建構高效能 Web 服務的核心技能。