返回文章列表

Rust 命令列工具建構與效能最佳化

本文闡述如何使用 Rust 建構 HTTP REST API 命令列工具,涵蓋 Tokio 執行環境、hyper 函式庫與 clap 引數解析的應用,同時探討 Rust 的零成本抽象特性、向量操作、SIMD 指令、Rayon 平行計算框架等效能最佳化策略,並以程式碼範例輔助說明如何提升 CLI 工具的執行效率

Web 開發 系統設計

Rust 的 Tokio 執行環境和 hyper 函式庫為建構高效能網路應用程式提供了堅實的基礎。本文將逐步示範如何結合 clap 函式庫,開發功能完善的 HTTP REST API 命令列工具。我們將探討如何利用 Rust 的零成本抽象特性,結合向量操作最佳化、SIMD 指令應用和 Rayon 平行計算框架,最大程度地提升 CLI 工具的效能。此外,我們還將探討如何透過預先分配向量容量、避免不必要的資料複製等技巧,進一步提升程式碼的執行效率,並以實際程式碼片段佐證這些最佳化策略的效益。

建構 HTTP REST API 命令列工具(CLI)

撰寫 CLI 工具是利用軟體解決問題的一種方式,而建立工具可以幫助我們避免重複、錯誤和浪費時間在電腦更擅長處理的任務上。大多數版本的 Unix 哲學都包含「做好一件事」的基本原則,我們將把這個原則應用到我們的 CLI 工具上。同時,我們也會讓我們的 CLI 輸出可以輕易地傳遞給其他工具(另一個 Unix 哲學的要點),使得命令可以串聯起來。

選擇適當的工具和函式庫

我們將繼續使用 Tokio 執行環境,並且為了傳送 HTTP 請求,我們會再次使用 hyper 函式庫,它提供了 HTTP 的實作(同時支援伺服器和客戶端)。此外,我們還會介紹一個新的 crate,叫做 clap(https://crates.io/crates/clap),它提供了結構化和型別安全的命令列引數解析功能。

值得注意的是,有一個更高層級的 HTTP 客戶端函式庫叫做 reqwest(https://crates.io/crates/reqwest),它與 Python 的 Requests 函式庫類別似,但它是為 Rust 設計的。然而,我們將堅持使用 hyper,因為它更底層;因此,透過直接使用它,我們可以學習更多關於事情如何運作的知識,而不是使用包裝了 hyper 函式庫的 reqwest。在實際應用中,你可能會更好地使用 reqwest(它提供了更方便和使用者友好的 API)。表格 10.1 列出了 API 服務的相依性。

表格 10.1 API 服務相依性

名稱功能描述
Clapderive命令列框架
colored_jsonDefault美化列印 JSON 資料
Hyperclient,http1,tcp,streamHTTP 客戶端/伺服器 API
serdeDefault序列化/反序列化函式庫
serde_jsonDefaultserde 的 JSON 序列化/反序列化
tokiomacros,rt-multi-thread,io-util,io-std非同步執行環境,與 hyper 一起使用
yansiDefaultANSI 色彩輸出

為了方便起見,您可以使用以下一鍵式複製貼上命令安裝表格 10.1 中的所有內容:

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

我們的 CLI 將非常簡單;我們將把五個 CRUD 加上列表命令對映到 CLI 命令,這些命令將完全按照您的預期執行,如表格 10.2 所示。

表格 10.2 CLI 命令

命令操作方法路徑
create建立一個待辦事項POST/v1/todos
read讀取一個待辦事項透過 IDGET/v1/todos/:id
update更新一個待辦事項透過 IDPUT/v1/todos/:id
delete刪除一個待辦事項透過 IDDELETE/v1/todos/:id
list列出所有待辦事項GET/v1/todos

我們將直接從 API 傳回回應,並透過列印到標準輸出;對於 JSON 回應,我們將美化列印它們以提高可讀性。這將使命令可以傳遞給其他工具(例如 jq),同時也使輸出對人類可讀。

Clap 函式庫讓我們可以建立根據命令的 CLI,具有位置引數或可選引數。Clap 將自動生成幫助輸出(我們可以使用 help 命令獲得),並且我們可以擁有適用於頂級命令或其中一個子命令的引數。Clap 將負責解析引數和處理錯誤,如果引數不正確或無效,只要我們正確定義型別即可。一旦 Clap 的解析完成,我們將得到一個結構體(由我們定義),其中包含從命令列引數解析的所有值。

定義命令

Clap 的 API 使用 derive巨集以及一些程式式巨集來宣告介面。我們想要使用根據命令的介面,可以使用 #[command]巨集啟用,如下所示:

#[derive(Parser)]
struct Cli {
    /// API 服務的基本 URL
    url: hyper::Uri,
    #[command(subcommand)]
    command: Commands,
}

對於頂級 CLI,我們定義了兩個位置引數:API 服務的基本 URL 和子命令(create、read、update、delete 或 list 之一)。我們還沒有定義命令,所以我們將在下一個列表中定義它們。

#[derive(Subcommand, Debug)]
enum Commands {
    /// 列出所有待辦事項
    List,
    /// 建立一個新的待辦事項
    Create {
        /// 待辦事項的內容
        body: String,
    },
    /// 讀取一個待辦事項
    Read {
        // ...

程式碼解析:

上述程式碼使用 Clap 函式庫定義了一個 CLI 工具。首先,我們定義了一個 Cli 結構體,它包含兩個欄位:urlcommandurl 是 API 服務的基本 URL,而 command 是一個子命令,可以是 create、read、update、delete 或 list 之一。

我們使用 #[derive(Parser)]巨集來派生 Cli 結構體的解析功能,這使得 Clap 可以自動解析命令列引數並將其填充到 Cli 結構體中。

Commands 列舉定義了可用的子命令,每個子命令都有自己的引數。例如,Create 子命令有一個 body 引數,用於指定待辦事項的內容。

Clap 的強大之處在於它可以自動生成幫助輸出,並且可以處理錯誤和無效的引數。這使得開發者可以專注於實作 CLI 工具的邏輯,而不需要擔心引數解析和錯誤處理的細節。

透過使用 Clap,我們可以建立一個使用者友好的 CLI 工具,它可以輕易地與其他工具整合,並且具有良好的可擴充套件性。

建構HTTP REST API 命令列工具(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());
    }
    // ...
}

內容解密:

  • 使用Cli::parse()解析命令列引數到Cli結構體中。
  • 將基礎URL分解為其組成部分,並將其新增到新的hyper::Uri建構器中。
  • 提取基礎URL的scheme(例如http或https)和authority(例如localhost或127.0.0.1)。

接下來,我們將根據解析出的命令列引數,比對不同的命令並執行相應的操作。

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
    }
    // ...
}

內容解密:

  • 使用match陳述式比對不同的命令,並呼叫request函式執行相應的HTTP請求。
  • request函式接受請求URI、HTTP方法和可選的JSON請求體作為引數。
  • 對於每個命令,我們使用uri_builder建構請求URI。

實作HTTP請求

我們已經定義了命令和引數的處理邏輯,現在我們將實作實際的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?;
    // ...
}

內容解密:

  • 使用Client::new()建立一個新的HTTP客戶端。
  • 使用Request::builder()建構一個HTTP請求,並設定請求URI、HTTP方法和請求頭。
  • 如果提供了請求體,則將其包含在請求中;否則,傳送一個空的請求體。
  • 使用await?等待請求的回應。

處理錯誤

在前面的程式碼中,我們使用了Result?運算元來處理錯誤。這是一種方便但有些懶惰的錯誤處理方式。

let s = String::from_utf8(buf)?;
eprintln!("Status: {}", Paint::green(res.status()));
// ...

內容解密:

  • 使用?運算元來傳播錯誤。
  • 使用eprintln!將回應狀態列印到標準錯誤輸出,並使用yansi套件來列印帶有ANSI顏色的文字。
  • 如果回應包含JSON內容,則使用colored_json套件來美化列印JSON內容。

圖表說明

@startuml
skinparam backgroundColor #FEFEFE
skinparam sequenceArrowThickness 2

title Rust 命令列工具建構與效能最佳化

actor "客戶端" as client
participant "API Gateway" as gateway
participant "認證服務" as auth
participant "業務服務" as service
database "資料庫" as db
queue "訊息佇列" as mq

client -> gateway : HTTP 請求
gateway -> auth : 驗證 Token
auth --> gateway : 認證結果

alt 認證成功
    gateway -> service : 轉發請求
    service -> db : 查詢/更新資料
    db --> service : 回傳結果
    service -> mq : 發送事件
    service --> gateway : 回應資料
    gateway --> client : HTTP 200 OK
else 認證失敗
    gateway --> client : HTTP 401 Unauthorized
end

@enduml

此圖示展示了命令列工具的處理流程,從解析命令列引數到傳送HTTP請求和處理回應。

圖表內容解密:

  • 圖表展示了命令列工具的主要處理流程。
  • 從開始到解析命令列引數,如果成功,則建構URI;如果失敗,則進行錯誤處理。
  • 根據解析出的命令,比對不同的命令並執行相應的操作。
  • 傳送HTTP請求並處理回應,最後列印回應內容。

使用 Rust 建構 HTTP REST API 命令列工具的最佳化策略

在前一章中,我們成功地使用 Rust 建構了一個功能完整的 HTTP REST API 命令列工具(CLI)。本章將重點討論如何最佳化這類別工具的效能,並介紹 Rust 在最佳化方面的強大功能。

深入理解 Rust 的零成本抽象

Rust 的零成本抽象是其效能優越性的關鍵之一。這意味著在 Rust 中使用抽象並不會帶來額外的執行成本。舉例來說,使用 Iterator trait 來遍歷集合與手寫迴圈在效能上是等價的。這種特性使得開發者能夠寫出既安全又高效的程式碼。

向量操作的最佳化

向量(Vec)是 Rust 中最常用的資料結構之一。有效地使用向量對於程式的效能至關重要。以下是一些最佳化向量操作的方法:

  1. 預先分配容量:當預先知道向量的大小時,使用 Vec::with_capacity 可以避免多次重新分配記憶體,從而提高效能。
  2. 避免不必要的複製:使用參考或移動語義來避免對大型資料結構的不必要複製。
  3. 利用 SIMD 指令:Rust 的某些函式庫,如 simdpacked_simd,允許開發者直接使用 SIMD(單指令多資料)指令來加速向量運算。

範例程式碼:預先分配容量的向量

fn main() {
    let mut vec = Vec::with_capacity(100);
    for i in 0..100 {
        vec.push(i);
    }
    println!("{:?}", vec);
}

內容解密:

  1. 我們使用 Vec::with_capacity(100) 預先分配了一個容量為 100 的向量,避免了動態擴充的開銷。
  2. 迴圈中將數字 0 到 99 推入向量中。
  3. 最後列印出向量中的所有元素。

使用 SIMD 加速運算

SIMD 是一種特殊的硬體指令,可以對多個資料元素同時進行相同的操作。Rust 透過一些函式庫支援 SIMD 程式設計。

範例程式碼:使用 SIMD 加速陣列加法

use simd::{f32x4, Simd};

fn main() {
    let a = f32x4::new(1.0, 2.0, 3.0, 4.0);
    let b = f32x4::new(5.0, 6.0, 7.0, 8.0);
    let c = a + b;
    println!("{:?}", c);
}

內容解密:

  1. 我們使用了 simd 函式庫中的 f32x4 型別來建立兩個包含四個浮點數的 SIMD 向量 ab
  2. ab 進行加法運算,結果儲存在 c 中。這個操作是透過 SIMD 指令一次性完成的。
  3. 列印出結果向量 c

Rayon 平行計算框架

Rayon 是 Rust 生態系統中一個流行的平行計算框架,它使得在 Rust 中進行資料平行處理變得簡單高效。

範例程式碼:使用 Rayon 平行計算陣列總和

use rayon::prelude::*;

fn main() {
    let nums: Vec<i32> = (1..100).collect();
    let sum: i32 = nums.par_iter().sum();
    println!("Sum: {}", sum);
}

內容解密:

  1. 我們首先建立了一個包含數字 1 到 99 的向量 nums
  2. 使用 Rayon 的 par_iter 方法將向量轉換為平行迭代器,並計算其總和。
  3. 將結果列印出來。

使用 Rust 加速其他語言

Rust 的效能優勢使其成為最佳化現有程式碼的理想選擇。透過將效能關鍵的部分用 Rust 重寫,可以在不重構整個專案的情況下提升整體效能。

Rust 的零成本抽象與效能最佳化

Rust 語言的一大特色是其零成本抽象(zero-cost abstractions),這使得開發者能夠撰寫高階程式碼,而編譯器能夠將其最佳化為高效的機器碼,且不會產生額外的執行時期負擔。Rust 的編譯器會負責找出從高階 Rust 程式碼到低階機器碼的最佳轉換路徑,無需開發者擔心效能陷阱。

零成本抽象的取捨

Rust 的零成本抽象帶來了一個取捨:某些在高階語言中常見的功能在 Rust 中可能不存在或以不同的形式存在,例如虛擬方法(virtual methods)、反射(reflection)、函式多載(function overloading)和可選函式引數。Rust 提供了替代方案或模擬這些功能的方法,但它們並非語言內建的。如果需要引入額外的執行時期負擔,開發者必須自行實作,例如使用 trait objects 來進行動態分派(dynamic dispatch)。

與 C++ 的比較

我們可以將 Rust 的抽象與 C++ 進行比較。C++ 的核心類別抽象可能包含虛擬方法,這些方法需要執行時期的查詢表(稱為 vtable)。雖然這種負擔通常並不顯著,但在某些情況下,例如在緊密迴圈中呼叫虛擬方法時,可能會變得重要。值得注意的是,Rust 的 trait objects 也使用 vtable 進行方法呼叫。

編譯時期最佳化

Rust 的零成本抽象根據編譯時期的最佳化。在此框架下,Rust 可以根據需要最佳化掉未使用的程式碼或值。Rust 的抽象也可以是深層巢狀的,編譯器可以在大多數情況下沿著抽象鏈進行最佳化。當我們談論 Rust 中的零成本抽象時,其實是指在所有最佳化完成後的零成本抽象。

建置生產環境二進位檔

當我們想要建置生產環境二進位檔或對程式碼進行基準測試時,必須透過使用 --release 旗標啟用編譯器最佳化。預設的編譯模式是 debug 模式,如果忘記啟用 release 模式,可能會遇到意外的效能損失。