在上面的程式碼中,我們使用了兩個尚未定義的類別:CreateTodoUpdateTodo。這些類別代表了 API 的輸入模型,與資料函式庫分離,這是一個重要的設計決策。

使用專門的輸入類別有幾個關鍵優勢:

  1. 資料驗證:可以在輸入類別上實作驗證邏輯,確保接收的資料符合業務規則。

  2. 類別安全:避免直接使用資料函式庫作為輸入,防止意外暴露或修改敏感欄位。

  3. API 版本控制:輸入模型可以隨 API 版本演進,而不直接影響資料函式庫。

在實際開發中,這些輸入類別可能如下所示:

// 建立待辦事項的輸入模型
#[derive(Deserialize)]
pub struct CreateTodo {
    body: String,
}

impl CreateTodo {
    pub fn body(&self) -> &str {
        &self.body
    }
    
    // 可以加入驗證邏輯
    pub fn validate(&

## Rust後端開發:開發高效能REST API服務

在現代Web開發中,構建穩定高效的REST API已成為後端工程師的基本技能Rust憑藉其高效能和安全性,正逐漸成為後端服務開發的優秀選擇。本文將探討如何使用Rust的Axum框架結合SQLx資料函式庫,開發一個功能完整的待辦事項(Todo)REST API服務

在實際開發過程中,我發現Rust生態系統中的Axum框架提供了優雅而強大的API設計方式,而SQLx則以其類別安全的特性大大提升了資料函式庫的可靠性。這個組合在效能和開發體驗上都相當出色。

### 查詢操作與資料對映的核心機制

在與資料函式庫時,每個查詢操作(除了刪除)都會回傳一或多筆記錄。SQLx的一個強大特性在於它能自動將查詢結果對映到我們定義的Rust結構體上

當我們為`Todo`結構體實作`sqlx::FromRow`特性後,SQLx可以自動處理資料對映,這大幅減少了手動解析結果集的繁瑣工作。值得注意的是,執行資料函式庫時,我們需要傳入資料函式庫池的參照(或者直接傳入連線)。

在使用`bind()`方法繫結值到SQL陳述式時,需要特別注意引數的順序,因為它們是按照指定的順序進行繫結的。雖然某些SQL驅動程式支援使用識別符號來繫結值,但SQLite不支援這種方式,我們必須嚴格按照問號出現的順序繫結引數。

## 資料模型設計:建立與更新操作的結構體

讓我們先來看看用於建立待辦事項的`CreateTodo`結構體:

```rust
#[derive(Deserialize)]
pub struct CreateTodo {
    body: String,
}

impl CreateTodo {
    pub fn body(&self) -> &str {
        self.body.as_ref()
    }
}

這個結構體設計相當簡潔。我們使用了#[derive(Deserialize)]來自動實作反序列化能力,這讓我們可以直接從HTTP請求中的JSON資料反序列化出CreateTodo例項。

結構體內只有一個body欄位,用於儲存待辦事項的內容。我們提供了一個body()方法作為存取器,它回傳對欄位內容的參照,避免了不必要的字元串複製。注意這裡使用as_ref()方法將String轉換為&str,這是Rust中處理字元串參照的常見模式。

在API設計中,我們不需要手動建構CreateTodo,它僅在接收API呼叫時透過反序列化建立。

接下來看看UpdateTodo結構體:

#[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
    }
}

UpdateTodo結構體與CreateTodo類別,但增加了一個completed欄位,用於標記待辦事項是否已完成。同樣地,我們為兩個欄位都提供了存取器方法,遵循良好的封裝原則。

completed()方法直接回傳布林值,因為布林類別實作了Copy特性,複製它的成本極低,無需回傳參照。這種設計在Rust中很常見,對於小型資料類別(如整數、布林值等)直接回傳值,而對於可能較大的類別(如字元串)則回傳參照。

這樣的資料模型設計非常適合REST API,它清晰地定義了建立和更新操作所需的資料結構,同時利用Serde函式庫的自動反序列化功能簡化了處理過程。

API路由宣告:構建服務骨架

有了資料模型後,下一步是定義API路由。使用Axum框架,我們可以優雅地宣告路由和對應的處理函式。如果你曾使用過其他Web框架,這部分程式碼看起來很熟悉,它包含請求路徑(可帶引數)、請求方法、請求處理函式、處理函式所需的狀態以及額外的服務層。

以下是定義服務和路由的程式碼

pub async fn create_router(
    dbpool: sqlx::Pool<sqlx::Sqlite>,
) -> axum::Router {
    use crate::api::{
        ping, todo_create, todo_delete, todo_list, todo_read, todo_update,
    };
    use axum::{routing::get, Router};
    use tower_http::cors::{Any, CorsLayer};
    use tower_http::trace::TraceLayer;
    
    Router::new()
        .route("/alive", get(|| async { "ok" }))
        .route("/ready", get(ping))
        .nest(
            "/v1",
            Router::new()
                .route("/todos", get(todo_list).post(todo_create))
                .route(
                    "/todos/:id",
                    get(todo_read).put(todo_update).delete(todo_delete),
                ),
        )
        .with_state(dbpool)
        .layer(CorsLayer::new().allow_methods(Any).allow_origin(Any))
        .layer(TraceLayer::new_for_http())
}

這段程式碼義了我們的API路由結構,讓我們逐部分分析:

  1. 健康檢查端點

    • /alive:一個簡單的活性檢查,回傳HTTP 200狀態和"ok"文字
    • /ready:就緒檢查,呼叫ping處理函式檢查資料函式庫是否正常
  2. API版本化與路由巢狀

    • 所有API端點都巢狀在/v1路徑下,這種版本化設計允許未來API變更時保持向後相容
  3. 待辦事項API端點

    • /v1/todos:支援GET(列出所有待辦事項)和POST(建立新待辦事項)
    • /v1/todos/:id:支援GET(讀取單個待辦事項)、PUT(更新待辦事項)和DELETE(刪除待辦事項)
  4. 狀態與中介軟體

    • .with_state(dbpool):將資料函式庫池作為狀態傳遞給路由器
    • CorsLayer:增加CORS支援,允許跨源請求
    • TraceLayer:增加HTTP請求追蹤功能

這種宣告式的路由定義方式是Axum框架的一大特色,它使API結構一目瞭然,同時提供了流暢的API來連結多個操作。路由器的設計展現了Rust強大的類別系統和流暢的API設計能力。

在實際開發中,我經常使用這種巢狀路由的方式來組織API,它不僅使程式碼構清晰,還便於按功能模組擴充套件API。

API路由實作:處理請求的核心邏輯

最後,讓我們實作API路由處理函式。首先來看最基本的ping()處理函式,它用於健康檢查:

pub async fn ping(
    State(dbpool): State<SqlitePool>,
) -> Result<String, Error> {
    use sqlx::Connection;
    let mut conn = dbpool.acquire().await?;
    conn.ping()
        .await
        .map(|_| "ok".to_string())
        .map_err(Into::into)
}

這個處理函式檢查資料函式庫是否正常工作。它使用了Axum的State提取器來取得資料函式庫池,然後取得一個連線並呼叫ping()方法。如果連線正常,則回傳"ok"字元串;如果失敗,則錯誤會被轉換為我們自定義的Error類別。

這裡引入了Axum框架的一個重要概念:提取器(Extractors)。提取器是實作了axum::extract::FromRequestaxum::extract::FromRequestParts特性的類別,它們能從HTTP請求中提取資料。

Axum提供了多種內建提取器,包括:

  • State:提取全域應用狀態
  • Path:提取路徑引數
  • Json:提取請求體並將其反序列化為JSON物件

現在,讓我們看看待辦事項API的處理函式:

pub async fn todo_list(
    State(dbpool): State<SqlitePool>,
) -> Result<Json<Vec<Todo>>, Error> {
    Todo::list(dbpool).await.map(Json::from)
}

pub async fn todo_read(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
) -> Result<Json<Todo>, Error> {
    Todo::read(dbpool, id).await.map(Json::from)
}

pub async fn todo_create(
    State(dbpool): State<SqlitePool>,
    Json(new_todo): Json<CreateTodo>,
) -> Result<Json<Todo>, Error> {
    Todo::create(dbpool, new_todo).await.map(Json::from)
}

pub async fn todo_update(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
    Json(updated_todo): Json<UpdateTodo>,
) -> Result<Json<Todo>, Error> {
    Todo::update(dbpool, id, updated_todo).await.map(Json::from)
}

pub async fn todo_delete(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
) -> Result<(), Error> {
    Todo::delete(dbpool, id).await
}

這些處理函式構成了我們API的核心,每個函式對應一個特定的API操作:

  1. todo_list:列出所有待辦事項

    • 使用State提取器取得資料函式庫池
    • 呼叫Todo::list方法取得所有待辦事項
    • 將結果包裝在Json中回傳
  2. todo_read:讀取單個待辦事項

    • 使用Path提取器取得URL路徑中的ID引數
    • 呼叫Todo::read方法取得指定ID的待辦事項
  3. todo_create:建立新待辦事項

    • 使用Json提取器從請求體中取得並反序列化CreateTodo物件
    • 呼叫Todo::create方法建立新待辦事項
  4. todo_update:更新待辦事項

    • 結合使用PathJson提取器取得ID和更新資料
    • 呼叫Todo::update方法更新待辦事項
  5. todo_delete:刪除待辦事項

    • 使用Path提取器取得ID
    • 呼叫Todo::delete方法刪除待辦事項
    • 成功時回傳空元組()

所有函式都遵循相同的模式:從請求中提取必要資料,呼叫對應的Todo方法執行操作,然後將結果包裝在Json中回傳。這種一致的模式使程式碼於理解和維護。

注意回傳類別都是Result<Json<T>, Error>(除了todo_delete),這表示操作可能成功回傳JSON資料,也可能失敗並回傳錯誤。這種回傳類別利用了Rust的強大錯誤處理機制,確保我們能優雅地處理各種錯誤情況。

Axum提取器:優雅處理HTTP請求資料

在實作API時,Axum的提取器系統是一個非常強大的功能。提取器讓我們能以類別安全的方式從HTTP請求中取得資料,而無需手動解析請求。

Path提取器為例,它能自動從URL路徑中提取引數,並將其轉換為指定類別。在todo_read函式中,Path(id): Path<i64>會提取路徑/v1/todos/:id中的:id引數,並將其轉換為i64類別。如果轉換失敗(例如,使用者供了非數字ID),Axum會自動回傳適當的錯誤回應。

同樣,Json提取器會自動從請求體中提取JSON資料,並將其反序列化為指定類別。在todo_create函式中,Json(new_todo): Json<CreateTodo>會解析請求體中的JSON資料,並將其轉換為CreateTodo類別。

這種提取器系統大大簡化了請求處理邏輯,讓我們能專注於業務邏輯而非請求解析細節。更重要的是,它利用

Rust Web開發的類別安全之美:使用Axum構建RESTful API

在現代Web後端開發中,類別安全和高效能是兩個不可或缺的要素。Rust憑藉其強大的類別系統和零成本抽象,為Web服務開發提供了獨特的優勢。本文將探討如何使用Axum框架構建一個功能完整的Todo RESTful API,特別關注請求處理和錯誤管理這兩個核心環節。

請求處理的類別安全

在Axum中,處理請求的程式碼非常精簡,這得益於框架的類別安全特性。以我們的Todo API為例,處理請求的核心在於定義正確的輸入和輸出類別。

// 從請求體中解析建立Todo的資料結構
// 使用了serde的Deserialize實作
#[derive(Deserialize)]
struct CreateTodo {
    body: String,
}

// 從請求體中解析更新Todo的資料結構
#[derive(Deserialize)]
struct UpdateTodo {
    body: String,
    completed: bool,
}

這兩個結構體定義了API接收的資料格式。CreateTodo只需要一個body欄位,而UpdateTodo則需要bodycompleted兩個欄位。Axum使用Json提取器從HTTP請求體中解析這些資料,並透過serde的Deserialize特性自動完成類別轉換。這種方式不僅程式碼簡潔,還確保了類別安全,如果客戶端傳送了格式不正確的資料,系統會自動回傳相應的錯誤。

Axum的類別安全路由機制

Axum的一個顯著優勢在於它的路由比對系統。它只會將請求比對到有效的處理函式上,並且以類別安全的方式進行。這意味著一旦程式碼成功編譯,我們就不必太擔心處理函式是否能正常工作。這正是Rust類別系統的魅力所在。

需要注意的是,我們的API設計相對嚴格。例如,所有端點都要求提供精確的欄位,不支援可選欄位。在大多數情況下這沒有問題,但在實際應用中,特別是對於PATCH或更新請求,允許只更新特定欄位可能更合理。例如,如果只需修改completed欄位,API應該能夠優雅地處理只指定的欄位,而不是要求提供所有欄位。

Axum中的回應處理

在討論錯誤處理之前,讓我們先了解Axum如何處理回應。Axum內建了將基本類別(如()StringJsonStatusCode)轉換為HTTP回應的能力,這是透過為常見回應類別提供axum::response::IntoResponse特性的實作來實作的。

如果需要將自定義類別轉換為回應,有兩種選擇:

  1. 將其轉換為已實作IntoResponse的類別
  2. 為自定義類別實作IntoResponse特性

實用的錯誤處理策略

在我們的API中,錯誤處理採用了簡潔明瞭的方式。首先,在error.rs中定義一個Error列舉:

#[derive(Debug)]
pub enum Error {
    Sqlx(StatusCode, String),
    NotFound,
}

這個錯誤類別設計非常直接。我們定義了兩種錯誤情況:

  • Sqlx:用於處理資料函式庫,包含HTTP狀態碼和錯誤訊息
  • NotFound:專門用於處理404錯誤情況

值得注意的是,我們將404(資源不存在)視為錯誤,但在HTTP協定中,404其實是一種正常的回應,不一定表示錯誤。為了簡化處理,我們將所有非200狀態碼都視為錯誤。

接下來,為sqlx::Error實作From特性,將SQLx錯誤轉換為我們的錯誤類別:

impl From<sqlx::Error> for Error {
    fn from(err: sqlx::Error) -> Error {
        match err {
            sqlx::Error::RowNotFound => Error::NotFound,
            _ => Error::Sqlx(
                StatusCode::INTERNAL_SERVER_ERROR,
                err.to_string(),
            ),
        }
    }
}

這段實作相當簡單,只對RowNotFound錯誤進行特殊處理,將其對映為HTTP 404錯誤。對於其他所有SQLx錯誤,統一回傳HTTP 500錯誤,並附上錯誤訊息。這種方式讓錯誤處理更加明確和有用,而不是對所有錯誤都回傳通用的500錯誤。

最後,為Error實作IntoResponse特性,使Axum能夠將我們的錯誤類別轉換為HTTP回應:

impl IntoResponse for Error {
    fn into_response(self) -> Response {
        match self {
            Error::Sqlx(code, body) => (code, body).into_response(),
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),
        }
    }
}

這個實作的巧妙之處在於,我們並沒有直接構建Response物件,而是利用Axum已有的IntoResponse實作。對於Sqlx錯誤,我們將狀態碼和錯誤訊息作為一個元組傳遞給into_response();對於NotFound錯誤,則直接使用StatusCode::NOT_FOUNDinto_response()實作。這種委託模式非常簡潔高效。

唯一需要考慮不使用這種方式的情況是,當預設實作涉及昂貴的轉換操作,而你有足夠的訊息可以更好地最佳化時。

執行和測試服務

當我們使用cargo run啟動服務時,會看到包含SQLx查詢和遷移執行的日誌輸出。雖然不是必須自動執行遷移,但這對測試非常方便。在生產環境中,通常不會自動執行遷移。

測試API端點

首先,確保健康檢查端點正常工作。使用像HTTPie這樣的工具(當然也可以用curl或其他CLI HTTP客戶端):

http 127.0.0.1:3000/alive
http 127.0.0.1:3000/ready

這兩個請求應該都回傳HTTP 200狀態碼,回應體為ok,並且包含CORS頭訊息。

接下來,建立一個待辦事項:

http post 127.0.0.1:3000/v1/todos body='wash the dishes'

然後,測試讀取和列表端點:

# 讀取單個待辦事項
http 127.0.0.1:3000/v1/todos/1

# 列出所有待辦事項
http 127.0.0.1:3000/v1/todos

第一個請求會回傳單個待辦事項物件,而第二個請求會回傳一個物件列表。

接著,使用PUT方法更新待辦事項,將其標記為已完成:

http put 127.0.0.1:3000/v1/todos/1 body='wash the dishes' completed:=true

注意,我們需要同時指定bodycompleted欄位,這有點煩人。如果API能夠優雅地只處理更新請求中指定的欄位,那會更方便。

最後,測試刪除功能:

http delete 127.0.0.1:3000/v1/todos/1

進階實驗

作為延伸練習,可以嘗試以下操作:

  1. 增加多個待辦事項
  2. 列出多個待辦事項
  3. 實作可選欄位支援(如前所述)
  4. 使用不同的主鍵類別(如UUID)
  5. 修改POST回應,使其包含資源URL或3xx重定向,這是RESTful API中有時使用的模式

Axum框架的優勢與特點

Axum是一個根據Tokio非同步執行時的Web框架,提供了構建Web服務API所需的所有元件。雖然Rust生態系統中還有其他Web框架,但Axum在類別安全和效能方面有著顯著優勢。

在實際開發中,玄貓發現Axum的最大亮點是它的類別驅動設計。這種設計使得API端點的定義和請求處理變得異常清晰和安全。與其他語言的Web框架相比,Rust的類別系統和Axum的設計理念共同確保了許多常見錯誤在編譯時就能被捕捉,而不是在執行時當機。

另一個值得注意的優勢是Axum與Tokio的無縫整合,這使得處理高併發請求變得簡單高效。在構建需要處理大量並發連線的Web服務時,這一點尤為重要。

實際應用中的考量

在將這種API服務佈署到生產環境時,還有一些額外考量:

  1. 資料驗證:除了類別檢查外,還應該增加更詳細的資料驗證邏輯
  2. 認證與授權:實際應用中通常需要使用者證和許可權控制
  3. 日誌與監控:增加結構化日誌和效能監控機制
  4. 資料函式庫池管理:最佳化料函式庫的使用
  5. 錯誤處理的改進:為不同類別的錯誤提供更友好的錯誤訊息
  6. 檔案生成:考慮整合類別OpenAPI的檔案生成工具

在開發過程中,玄貓發現最大的挑戰往往不是實作功能本身,而是設計出既符合RESTful原則又適合特定應用需求的API。良好的API設計需要在嚴格的類別安全和靈活的使用體驗之間找到平衡點。

使用Rust和Axum構建Web服務雖然有一定的學習曲線,但帶來的類別安全和效能優勢絕對值得投入。隨著Rust在Web開發領域的不斷成熟,這種組合將成為構建高可靠性、高效能Web服務的強大選擇。

透過本文的實踐,我們看到了如何使用Axum構建一個功能完整的RESTful API。Axum的設計理念與Rust的類別系統相得益彰,為Web開發提供了一種安全、高效與富有表現力的方式。無論是小型專案還是大型服務,這種組合都能提供卓越的開發體驗和執行效能。

開發HTTP REST API命令列工具

在前一章我們已經建立了一個API服務,現在讓我們開發一個配套的命令列工具來與這個API互動。透過這個CLI工具,我們將展示另一種在Rust中實作非同步操作的方式——向我們之前開發的服務傳送HTTP請求。這個命令列工具將提供一個便捷的方式與待辦事項後端API進行互動,同時展示在客戶端-伺服器關係中,從客戶端角度實作非同步Rust的基礎知識。

選擇適當的工具與函式庫開發命令列工具是解決問題的一種方式,它幫助我們避免重複工作、減少錯誤,以及節省時間。遵循Unix哲學的"做好一件事"原則,我們將開發一個專注與高效的CLI工具。此外,我們會讓工具的輸出容易透過管道傳輸到其他工具,實作命令的串聯使用。

在這個專案中,我們將繼續使用Tokio執行環境,並採用Hyper函式庫理HTTP請求。雖然有像reqwest這樣更高階的HTTP客戶端函式庫別Python的Requests函式庫但我選擇使用較低階的Hyper,因為這能幫助我們更深入理解HTTP客戶端的工作原理。此外,我們還會引入一個新的crate——Clap,它提供結構化與類別安全的命令列引數解析功能。

專案依賴列表

以下是我們需要的主要依賴函式庫

  1. Clap (帶derive特性) - 命令列框架
  2. colored_json - JSON資料美化輸出
  3. Hyper (帶client/http1/tcp/stream特性) - HTTP客戶端/伺服器API
  4. serde - 序列化/反序列化函式庫. serde_json - JSON序列化/反序列化
  5. tokio (帶macros/rt-multi-thread/io-util/io-std特性) - 非同步執行環境
  6. yansi - ANSI彩色輸出支援

可以透過以下命令一次性增加所有依賴:

cargo add clap --features derive
cargo add colored_json
cargo add hyper --features client,http1,tcp,stream
cargo add serde
cargo add serde_json
cargo add tokio --features macros,rt-multi-thread,io-util,io-std
cargo add yansi

增加後,Cargo.toml檔案內容大致如下:

[package]
name = "api-client"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4.3.10", features = ["derive"] }
colored_json = "3.2.0"
hyper = { version = "0.14.27", features = ["client", "http1", "tcp", "stream"] }
serde = "1.0.166"
serde_json = "1.0.100"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "io-util", "io-std"] }
yansi = "0.5.1"

CLI設計:簡潔有力的介面

我們的CLI設計非常直觀,將對映五個CRUD操作(加上列表功能)到相應的命令,每個命令的功能正如其名:

命令操作HTTP方法路徑
create建立待辦事項POST/v1/todos
read讀取特定待辦事項GET/v1/todos/:id
update更新待辦事項PUT/v1/todos/:id
delete刪除待辦事項DELETE/v1/todos/:id
list列出所有待辦事項GET/v1/todos

我們會直接輸出API的回應,對於JSON回應,會進行格式美化以提高可讀性。這樣設計不僅使輸出人類讀,還能方便地將輸出透過管道傳送給其他工具(如jq)進一步處理。

Clap函式庫我們建立根據命令的CLI,支援位置引數或選項引數。Clap會自動生成幫助輸出(透過help命令取得),我們可以為頂層命令或子命令定義引數。Clap負責解析引數並處理錯誤,前提是我們正確定義了引數類別。完成解析後,我們得到一個包含所有命令列引數值的結構體。

定義命令結構

Clap的API使用derive巨集一些過程巨集宣告介面。我們要使用根據命令的介面,可以透過#[command]巨集用:

#[derive(Parser)]
struct Cli {
    /// Base URL of API service
    url: hyper::Uri,
    
    #[command(subcommand)]
    command: Commands,
}

這段程式碼義了CLI的頂層結構。我們使用#[derive(Parser)]讓Clap可以從命令列引數中解析出這個結構體。這裡定義了兩個位置引數:

  1. url:API服務的基礎URL,會直接解析成hyper::Uri結構體(因為它實作了FromStr特性)
  2. command:子命令,使用#[command(subcommand)]標記,具體定義在Commands列舉中

三斜線註解///會被Clap解析為引數的幫助文字。

接下來,我們需要定義子命令:

#[derive(Subcommand, Debug)]
enum Commands {
    /// List all todos
    List,
    
    /// Create a new todo
    Create {
        /// The todo body
        body: String,
    },
    
    /// Read a todo
    Read {
        /// The todo ID
        id: i64,
    },
    
    /// Update a todo
    Update {
        /// The todo ID
        id: i64,
        
        /// The todo body
        body: String,
        
        /// Mark todo as completed
        #[arg(short, long)]
        completed: bool,
    },
    
    /// Delete a todo
    Delete {
        /// The todo ID
        id: i64,
    },
}

這段程式碼義了我們CLI的子命令,使用列舉來表示(因為我們一次只能選擇一個命令)。我們使用#[derive(Subcommand)]來標記這個列舉可以作為子命令使用。

每個命令都有不同的引數需求:

  • List:不需要額外引數
  • Create:需要一個待辦事項內容(body)
  • ReadDelete:需要待辦事項ID
  • Update:需要ID、內容和完成狀態,其中完成狀態是一個可選的布林值,使用#[arg(short, long)]標記,可以透過-c--completed標誌設定

三斜線註解同樣用於生成每個引數的幫助文字。

當我們實作main()函式後,可以執行cargo run -- --help來檢視生成的幫助訊息(注意在使用cargo run時,引數需要放在雙破折號--之後):

Usage: api-client <URL> <COMMAND>

Commands:
  list    List all todos
  create  Create a new todo
  read    Read a todo
  update  Update a todo
  delete  Delete a todo
  help    Print this message or the help of the given subcommand(s)

Arguments:
  <URL>  Base URL of API service

Options:
  -h, --help  Print help

每個子命令也有自己的幫助訊息,例如執行cargo run -- --help createcargo run -- create --help會顯示:

Create a new todo

Usage: api-client <URL> create <BODY>

Arguments:
  <BODY>  The todo body

Options:
  -h, --help  Print help

這種設計讓我們的CLI工具既直觀又功能完整,使用者以輕鬆瞭解每個命令的用途和引數要求。

命令列引數處理的優勢

使用Clap進行命令列引數處理帶來了幾個明顯優勢:

  1. 類別安全:引數會被解析成特定類別,錯誤的輸入格式會在解析階段就被捕捉
  2. 自動生成幫助:不需要手動編寫幫助檔案,Clap會根據我們的定義自動生成
  3. 引數驗證:Clap會處理引數的驗證,包括必要引數的檢查
  4. 結構化資料:解析後的引數會以結構化的形式提供,便於在程式中使用

在下一部分,我們將實作各個命令的具體功能,包括如何傳送HTTP請求和處理回應。這將展示Rust中非同步HTTP客戶端程式設計的基本模式,以及如何將API互動整合到命令列工具中。

實作命令功能

完成命令定義後,接下來需要實作每個命令的具體功能。我們將使用Hyper和Tokio來處理HTTP請求,並將回應格式化後輸出到終端。

首先,讓我們看一下主函式的實作,它會解析命令列引數並根據子命令執行相應的功能:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let cli = Cli::parse();
    
    match cli.command {
        Commands::List => {
            let todos = list_todos(&cli.url).await?;
            println!("{}", todos);
        }
        Commands::Create { body } => {
            let todo = create_todo(&cli.url, &body).await?;
            println!("{}", todo);
        }
        Commands::Read { id } => {
            let todo = read_todo(&cli.url, id).await?;
            println!("{}", todo);
        }
        Commands::Update { id, body, completed } => {
            let todo = update_todo(&cli.url, id, &body, completed).await?;
            println!("{}", todo);
        }
        Commands::Delete { id } => {
            delete_todo(&cli.url, id).await?;
            println!("Todo deleted successfully");
        }
    }
    
    Ok(())
}

這個主函式使用#[tokio::main]巨集建立非同步執行環境。函式的工作流程很直觀:

  1. 使用Cli::parse()解析命令列引數
  2. 根據解析出的子命令類別,呼叫相應的非同步函式處理請求
  3. 將請求結果輸出到標準輸出
  4. 對於刪除操作,成功時顯示確認訊息

每個命令處理函式都是非同步的,回傳一個Result類別,這允許我們使用?運算元進行簡潔的錯誤處理。

實作HTTP請求函式

接下來,我們需要實作每個命令對應的HTTP請求函式。首先,我們需要一個共用的HTTP客戶端:

async fn get_client() -> Client<HttpConnector> {
    Client::new()
}

這個簡單的函式建立並回傳一個Hyper HTTP客戶端例項。在實際應用中,我們可能會想要增加更多設定或重用客戶端例項以提高效率。

現在,讓我們實作各個命令的函式:

列出所有待辦事項

async fn list_todos(base_url: &Uri) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
    let client = get_client().await;
    
    let url = format!("{}/v1/todos", base_url);
    let url = url.parse::<Uri>()?;
    
    let resp = client.get(url).await?;
    
    if resp.status().is_success() {
        let body_bytes = hyper::body::to_bytes(resp.into_body()).await?;
        let body_str = String::from_utf8(body_bytes.to_vec())?;
        let pretty_json = colored_json::to_colored_json_auto(&body_str)?;
        Ok(pretty_json)
    } else {
        Err(format!("Error: {}", resp.status()).into())
    }
}

這個函式傳送GET請求到/v1/todos端點取得所有待辦事項:

  1. 取得HTTP客戶端
  2. 構建完整URL並解析成Uri類別
  3. 傳送GET請求並等待回應
  4. 檢查回應狀態,如果成功:
    • 將回應體轉換為位元組 - 將位元組換為UTF-8字元串
    • 使用colored_json函式庫SON美化並增加顏色
    • 回傳美化後的JSON字元串
  5. 如果失敗,回傳帶有狀態碼的錯誤

建立待

實作 Rust CLI 命令:從設計到執行

在前面我們已經設計好了命令列介面的結構,現在是時候實作這些命令了。Rust 的類別安全特性讓我們能夠輕鬆地處理每個命令及其引數。在這一部分,我將帶你完成命令的實作、HTTP 請求的處理,以及如何讓整個工具優雅地運作。

命令實作:利用 Rust 的類別系統

當使用 clap 提供的類別安全 API 時,處理各種命令和引數變得極其簡單。我們可以對 Commands 列舉中的每個變體進行比對,並相應地處理。在處理每個命令前,我們需要一些樣板程式碼解析 CLI 引數和基礎 URL。

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let cli = Cli::parse();
    let mut uri_builder = Uri::builder();
    
    if let Some(scheme) = cli.url.scheme() {
        uri_builder = uri_builder.scheme(scheme.clone());
    }
    
    if let Some(authority) = cli.url.authority() {
        uri_builder = uri_builder.authority(authority.clone());
    }
    // 稍後在這裡處理命令
}

這段程式碼我們程式的入口點,做了幾件重要的事情:

  1. 使用 #[tokio::main] 巨集我們的 main 函式轉換為非同步執行環境,這是處理 HTTP 請求所必需的
  2. 呼叫 Cli::parse() 自動解析命令列引數並填充到我們的 Cli 結構中
  3. 從基礎 URL 中提取 scheme(如 http 或 https)和 authority(如 localhost 或 127.0.0.1)部分
  4. 值得注意的是,我們選擇忽略基礎 URL 的路徑部分,這是一個設計決定

在實際應用中,有時候我會選擇允許指定一個字首基礎路徑,然後將每個請求 URL 附加到這個字首上。但在這個例子中,我們為了簡化,選擇忽略路徑部分。

處理各種命令

接下來,我們需要處理每個命令的具體邏輯。這裡使用 match 陳述式來處理不同的命令:

match cli.command {
    Commands::List => {
        request(
            uri_builder.path_and_query("/v1/todos").build()?,
            Method::GET,
            None,
        )
        .await
    }
    Commands::Delete { id } => {
        request(
            uri_builder
                .path_and_query(format!("/v1/todos/{}", id))
                .build()?,
            Method::DELETE,
            None,
        )
        .await
    }
    Commands::Read { id } => {
        request(
            uri_builder
                .path_and_query(format!("/v1/todos/{}", id))
                .build()?,
            Method::GET,
            None,
        )
        .await
    }
    Commands::Create { body } => {
        request(
            uri_builder.path_and_query("/v1/todos").build()?,
            Method::POST,
            Some(json!({ "body": body }).to_string()),
        )
        .await
    }
    Commands::Update { id, body, completed } => {
        request(
            uri_builder
                .path_and_query(format!("/v1/todos/{}", id))
                .build()?,
            Method::PUT,
            Some(json!({"body": body, "completed": completed}).to_string()),
        )
        .await
    }
}

這段程式碼過 match 表示式處理了 Commands 列舉中的每個命令變體:

  1. List 命令:傳送 GET 請求到 /v1/todos 端點,取得所有待辦事項
  2. Delete 命令:傳送 DELETE 請求到 /v1/todos/{id},刪除特定 ID 的待辦事項
  3. Read 命令:傳送 GET 請求到 /v1/todos/{id},讀取特定 ID 的待辦事項
  4. Create 命令:傳送 POST 請求到 /v1/todos,並在請求體中包含待辦事項內容
  5. Update 命令:傳送 PUT 請求到 /v1/todos/{id},更新特定 ID 的待辦事項內容和狀態

每個命令都呼叫了一個 request() 函式(稍後會定義),傳遞請求 URI、HTTP 方法和可選的 JSON 請求體。這裡使用了前面定義的 uri_builder 來構建完整的 URI。

Rust 的模式比對要求我們處理列舉的每個變體,這確保了我們不會遺漏任何命令。這是 Rust 類別系統的一個強大特性,它在編譯時就能幫助我們捕捉潛在的錯誤。

實作 HTTP 請求處理

在設計好命令和引數後,執行實際的 API 請求變得相當直接。我們已經有了所需的所有元素(URI、HTTP 方法和可選的請求體),現在只需要實際執行請求。以下是實作這一功能的單個函式:

async fn request(
    url: hyper::Uri,
    method: Method,
    body: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let client = Client::new();
    let mut res = client
        .request(
            Request::builder()
                .uri(url)
                .method(method)
                .header("Content-Type", "application/json")
                .body(body.map(|s| Body::from(s))
                    .unwrap_or_else(|| Body::empty()))?,
        )
        .await?;
        
    let mut buf = Vec::new();
    while let Some(next) = res.data().await {
        let chunk = next?;
        buf.extend_from_slice(&chunk);
    }
    
    let s = String::from_utf8(buf)?;
    eprintln!("Status: {}", Paint::green(res.status()));
    
    if res.headers().contains_key(CONTENT_TYPE) {
        let content_type = res.headers()[CONTENT_TYPE].to_str()?;
        eprintln!("Content-Type: {}", Paint::green(content_type));
        
        if content_type.starts_with("application/json") {
            println!("{}", &s.to_colored_json_auto()?);
        } else {
            println!("{}", &s);
        }
    } else {
        println!("{}", &s);
    }
    
    Ok(())
}

這個函式是我們 CLI 工具的核心,負責處理所有 HTTP 請求:

  1. 建立請求:使用 hyper 的 Client 建立請求,設定適當的 URI、方法和標頭
  2. 處理請求體:如果提供了請求體,將其轉換為 Body;否則使用空 Body
  3. 收集回應:使用一個 Vec 作為緩衝區來處理分塊的回應資料
  4. 格式化輸出
    • 將回應狀態碼輸出到標準錯誤,並使用 ANSI 顏色增強可讀性
    • 檢查回應是否包含 Content-Type 標頭
    • 如果內容類別是 JSON,使用 colored_json 函式庫美化輸出
    • 否則以純文字形式輸出回應內容

將回應主體輸出到標準輸出而將中繼資料狀態碼和內容類別)輸出到標準錯誤的設計很巧妙,這使得我們可以將命令輸出透過管道傳遞給其他工具進行處理,而不會被中繼資料擾。

優雅的錯誤處理

在我們的實作中,廣泛使用了 ? 運算元和 trait 物件(Box<dyn std::error::Error + Send + Sync>)作為錯誤回傳類別。這是一種方便但相對簡單的錯誤處理方式。

// main 函式的回傳類別
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // ...
}

這種錯誤處理方式遵循了 KISS(保持簡單,愚蠢)原則,對於這個特定的案例來說是合理的。但如果我們需要更複雜的錯誤處理邏輯,或者想要自定義錯誤日誌或錯誤訊息處理,我們可能會想要建立自己的錯誤類別並使用 From trait 來轉換錯誤。

由於 main() 函式和 request() 函式回傳相同的 Result 類別,我們可以在整個程式中使用 ? 運算元,它會正確地將錯誤傳播到頂層。

在開發類別工具時,我發現這種簡單的錯誤處理策略通常足夠應付大多數情況。然而,對於更大規模的應用程式,我會建議採用更結構化的錯誤處理方式,可能包括:

  1. 建立自定義錯誤類別,分類別同類別的錯誤
  2. 為各種外部錯誤實作 From trait
  3. 提供更詳細的錯誤上下文略

測試我們的 CLI 工具

完成實作後,讓我們測試一下我們的 CLI 工具,看它如何與前面章節中的 API 服務互動。以下是一系列範例命令及其輸出:

建立新的待辦事項

cargo run -- http://localhost:3000 create "finish writing chapter 10"

這個命令會向 API 傳送 POST 請求,建立一個新的待辦事項。輸出會顯示建立的待辦事項的詳細訊息,包括自動生成的 ID、內容和完成狀態(預設為 false)。

列出所有待辦事項

cargo run -- http://localhost:3000 list

這個命令傳送 GET 請求到 API 的 /v1/todos 端點,取得所有待辦事項的列表。輸出會以 JSON 格式顯示所有待辦事項。

更新待辦事項

cargo run -- http://localhost:3000 update 1 "finish writing chapter 10" --completed

這個命令傳送 PUT 請求,更新 ID 為 1 的待辦事項。我們修改了內容並將完成狀態設定為 true。輸出會顯示更新後的待辦事項。

讀取單個待辦事項

cargo run -- http://localhost:3000 read 1

這個命令傳送 GET 請求到 /v1/todos/1,讀取 ID 為 1 的待辦事項。輸出會顯示該待辦事項的詳細訊息。

結合管道使用

我們也可以將 CLI 輸出透過管道傳遞給其他命令,例如 jq

cargo run -- http://localhost:3000 read 1 | jq '.body'

這個命令會從待辦事項 JSON 輸出中只選擇 body 欄位。請注意,Cargo 會將其輸出列印到標準錯誤,而不是標準輸出,所以我們仍然可以使用管道。

刪除待辦事項

cargo run -- http://localhost:3000 delete 1

這個命令傳送 DELETE 請求,刪除 ID 為 1 的待辦事項。

改進與擴充套件

這個 CLI 工具提供了一個很好的起點,但還有一些可能的改進:

  1. 替代 HTTP 客戶端:考慮使用 reqwest 或其他更高階別的 HTTP 客戶端函式庫 hyper,這可能會簡化程式碼2. 自定義錯誤類別:建立專門的錯誤類別,提供更好的錯誤報告
  2. 設定檔支援:增加從設定檔載入設定的功能,例如預設 URL
  3. 認證支援:增加處理 API 金鑰、OAuth 令牌或其他認證方式的功能
  4. 輸出格式選項:提供更多輸出格式選項,如純文字、表格或 CSV

在開發這類別具時,我發現平衡功能豐富性和簡單性是關鍵。過於複雜的 CLI 工具往往使用率較低,而簡單直觀的工具則更

Rust命令列工具的輸出與處理

在開發Rust的命令列工具時,輸出格式和可讀性是提升使用者經驗的關鍵。透過前面章節我們已經建立了基礎的HTTP REST API CLI,現在讓我們來看看如何進一步改善輸出格式並處理更多命令。

當我們讀取待辦事項時,CLI工具會以結構化的方式呈現資料。而透過管道將輸出傳送給其他工具如jq,可以進一步處理和格式化JSON輸出,這讓我們的CLI工具能夠無縫整合到各種自動化指令碼工作流程中。

刪除功能同樣重要,一個完整的CRUD功能集讓CLI工具更加實用。透過實作這些功能,我們的待辦事項CLI能夠提供完整的資料管理功能。

使用Rust開發命令列工具具有以下優勢:

  • 類別安全的引數解析:透過clap等套件,我們能夠以類別安全的方式解析命令列引數,使工具開發變得快速與穩健。

  • 豐富的生態系統:Rust的套件生態系統讓我們能夠輕鬆增加豐富功能,如yansicolored_json等套件提供了格式良好、人類讀的輸出格式。

  • HTTP函式庫:雖然hyper提供了低階的HTTP實作,但在實際應用中,對於HTTP伺服器我們通常會選擇axum,而對於HTTP客戶端則傾向使用reqwest等高階API。

  • 錯誤處理:使用特性物件如Box<dyn std::error::Error + Send + Sync>作為錯誤類別,可以簡化錯誤處理流程。此外,thiserroranyhow等套件也提供了更進階的錯誤處理功能。

Rust效能最佳化略

在軟體開發過程中,有時僅靠良好的設計、選擇正確的資料結構和演算法是不夠的,我們需要更深入地最佳化能。現代作業系統、CPU和編譯器在處理大部分效能問題上做得相當出色,但偶爾我們還是需要探討效能最佳化巧。

零成本抽象的概念與實踐

Rust的一個重要特性是零成本抽象(Zero-cost abstractions)。簡單來說,Rust的抽象機制讓我們能夠編寫高階程式碼,同時產生最佳化機器碼,與不會帶來額外的執行時間開銷。Rust編譯器負責找出從高階Rust到低階機器碼的最佳轉換路徑,不產生額外負擔。這讓我們能夠安心使用Rust的抽象機制,不必擔心它們會造成效能陷阱。

Rust零成本抽象的代價是,某些高階語言常見的功能在Rust中不存在或以不同形式呈現。這些功能包括虛擬方法、反射、函式多載和可選函式引數等。Rust提供了這些功能的替代方案或模擬方法,但它們並非語言的內建部分。如果想引入這些開銷,我們必須自己實作(這反而使程式更容易推理),例如使用特性物件(trait objects)實作動態分派,類別於虛擬方法。

相比之下,C++的抽象確實會帶來執行時間開銷。在C++中,核心類別抽象可能包含虛擬方法,這需要執行時間查詢表(稱為虛擬表或vtables)。雖然這種開銷通常不明顯,但在某些情況下會變得顯著,例如在迴圈中多次呼叫虛擬方法時。

Rust的特性物件也使用虛擬表進行方法呼叫。特性物件是透過dyn Trait語法啟用的功能。例如,我們可以使用Box<dyn MyTrait>儲存特性物件,其中盒子中的專案必須實作MyTrait,而與可以使用虛擬表查詢來呼叫MyTrait中的方法。

反射是另一種在某些語言中廣泛使用的不透明抽象,用於在執行時分派函式呼叫或執行其他操作。例如,Java常使用反射來處理各種問題,但它也往往會產生大量難以除錯的執行時錯誤。反射為程式設計師提供了一些便利,但代價是程式碼變得更不穩定。

Rust的零成本抽象根據編譯時最佳化在這個框架內,Rust可以根據需要最佳化未使用的程式碼或值。Rust的抽象也可以深度巢狀,編譯器可以(在大多數情況下)沿著抽象鏈執行最佳化當我們談論Rust中的零成本抽象時,我們真正的意思是執行所有最佳化的零成本抽象。

當我們想要建立生產二進位檔或對程式碼進行基準測試時,必須透過在釋出模式下編譯程式碼來啟用編譯器最佳化我們可以透過在cargo中啟用--release標誌來做到這一點,因為預設編譯模式是除錯模式。如果忘記啟用釋出模式,可能會遇到意外的效能損失,下一節將展示其中一個範例。

向量的高效使用

向量(Vectors)是Rust中的核心集合抽象。如前所述,在需要元素集合時,大多數情況下應使用Vec。由於在Rust中會經常使用Vec,瞭解其實作細節以及如何影響程式碼效能非常重要。此外,瞭解何時使用Vec之外的其他集合也很重要。

關於Vec,首先要了解的是記憶體分配方式。雖然在前面章節中已經討論過,但這裡會更探討。Vec以連續區塊分配記憶體,根據容量使用可設定的區塊大小。它透過延遲分配直到必要時才進行,並始終以連續區塊分配記憶體。

向量記憶體分配

關於Vec記憶體分配方式,首先要了解它如何確定容量大小。預設情況下,空的Vec容量為0,因此不分配任何記憶體。直到增加資料時才進行記憶體分配。當達到容量限制時,Vec將容量翻倍(即容量呈指數增長)。

我們可以透過執行一個小測試來觀察Vec如何增加容量:

let mut empty_vec = Vec::<i32>::new();
(0..10).for_each(|v| {
    println!(
        "empty_vec has {} elements with capacity {}",
        empty_vec.len(),
        empty_vec.capacity()
    );
    empty_vec.push(v)
});

需要注意的是,容量是以元素數量而非位元組數量來衡量。向量所需的位元組數是容量乘以每個元素的大小。當我們執行上述程式碼時,會生成以下輸出:

empty_vec has 0 elements with capacity 0
empty_vec has 1 elements with capacity 4
empty_vec has 2 elements with capacity 4
empty_vec has 3 elements with capacity 4
empty_vec has 4 elements with capacity 4
empty_vec has 5 elements with capacity 8
empty_vec has 6 elements with capacity 8
empty_vec has 7 elements with capacity 8
empty_vec has 8 elements with capacity 8
empty_vec has 9 elements with capacity 16

從這個輸出中我們可以看到,Rust的向量容量管理策略是指數增長的。這種策略是為了平衡記憶體使用和重新分配開銷。初始容量為0,當我們增加第一個元素時,容量增加到4。當容量達到上限(增加第5個元素時),容量翻倍至8,依此類別。

這種指數增長策略非常高效,因為它在保持記憶體使用合理的同時,將重新分配操作的次數降到最低。每次重新分配都需要複製所有現有元素,這是相對昂貴的操作。透過指數增長策略,重新分配的頻率隨著向量大小的增加而顯著降低。

當我們預先知道向量大小時,可以使用with_capacity方法預先分配記憶體,這樣可以避免多次重新分配:

// 預先分配容量為10的向量
let mut vec = Vec::with_capacity(10);
for i in 0..10 {
    vec.push(i);
    // 不會發生重新分配
}

SIMD程式設計與平行計算

在需要處理大量資料時,單指令多資料(SIMD)和平行計算是提升效能的關鍵技術。Rust提供了強大的工具來利用這些技術。

SIMD允許CPU同時對多個資料元素執行相同的操作,大幅提高計算密集型任務的效能。Rust透過標準函式庫std::arch模組提供SIMD支援,同時還有如packed_simd等第三方套件簡化SIMD程式設計。

// 使用SIMD加速向量加法的簡化範例
use std::arch::x86_64::{__m256, _mm256_add_ps, _mm256_loadu_ps, _mm256_storeu_ps};

unsafe fn simd_add(a: &[f32], b: &[f32], c: &mut [f32]) {
    let mut i = 0;
    while i <= a.len() - 8 {
        let a_chunk = _mm256_loadu_ps(a[i..].as_ptr());
        let b_chunk = _mm256_loadu_ps(b[i..].as_ptr());
        let sum = _mm256_add_ps(a_chunk, b_chunk);
        _mm256_storeu_ps(c[i..].as_mut_ptr(), sum);
        i += 8;
    }
    // 處理剩餘元素
    for j in i..a.len() {
        c[j] = a[j] + b[j];
    }
}
__CODE_BLOCK_37__rust
use rayon::prelude::*;

fn sum_of_squares(input: &[i32]) -> i32 {
    input.par_iter()  // 平行迭代器
         .map(|&i| i * i)
         .sum()
}

這個簡單的例子展示瞭如何使用Rayon將序列迭代轉換為平行迭代。只需將普通的.iter()替換為.par_iter(),Rayon就會自動處理執行緒立和工作分配。這種簡潔的API使平行計算變得非常容易實作,同時保持了程式碼的可讀性。Rayon使用工作竊取演算法來確保有效的負載平衡,這使其特別適合處理大型資料集和不規則工作負載。