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 服務相依性
| 名稱 | 功能 | 描述 |
|---|---|---|
| Clap | derive | 命令列框架 |
| colored_json | Default | 美化列印 JSON 資料 |
| Hyper | client,http1,tcp,stream | HTTP 客戶端/伺服器 API |
| serde | Default | 序列化/反序列化函式庫 |
| serde_json | Default | serde 的 JSON 序列化/反序列化 |
| tokio | macros,rt-multi-thread,io-util,io-std | 非同步執行環境,與 hyper 一起使用 |
| yansi | Default | ANSI 色彩輸出 |
為了方便起見,您可以使用以下一鍵式複製貼上命令安裝表格 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 | 讀取一個待辦事項透過 ID | GET | /v1/todos/:id |
| update | 更新一個待辦事項透過 ID | PUT | /v1/todos/:id |
| delete | 刪除一個待辦事項透過 ID | DELETE | /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 結構體,它包含兩個欄位:url 和 command。url 是 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 中最常用的資料結構之一。有效地使用向量對於程式的效能至關重要。以下是一些最佳化向量操作的方法:
- 預先分配容量:當預先知道向量的大小時,使用
Vec::with_capacity可以避免多次重新分配記憶體,從而提高效能。 - 避免不必要的複製:使用參考或移動語義來避免對大型資料結構的不必要複製。
- 利用 SIMD 指令:Rust 的某些函式庫,如
simd或packed_simd,允許開發者直接使用 SIMD(單指令多資料)指令來加速向量運算。
範例程式碼:預先分配容量的向量
fn main() {
let mut vec = Vec::with_capacity(100);
for i in 0..100 {
vec.push(i);
}
println!("{:?}", vec);
}
內容解密:
- 我們使用
Vec::with_capacity(100)預先分配了一個容量為 100 的向量,避免了動態擴充的開銷。 - 迴圈中將數字 0 到 99 推入向量中。
- 最後列印出向量中的所有元素。
使用 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);
}
內容解密:
- 我們使用了
simd函式庫中的f32x4型別來建立兩個包含四個浮點數的 SIMD 向量a和b。 - 對
a和b進行加法運算,結果儲存在c中。這個操作是透過 SIMD 指令一次性完成的。 - 列印出結果向量
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 到 99 的向量
nums。 - 使用 Rayon 的
par_iter方法將向量轉換為平行迭代器,並計算其總和。 - 將結果列印出來。
使用 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 模式,可能會遇到意外的效能損失。