返回文章列表

Rust 命令列程式開發

本文探討 Rust 命令列程式開發的各個導向,包含檔案讀取、錯誤處理、使用 anyhow 套件、支援管道輸入輸出、整合測試以及釋出到 crates.io 等技巧。文章以 catsay 程式為例,逐步講解如何強化命令列工具的功能,並提升使用者經驗。此外,也涵蓋瞭如何編寫整合測試確保程式碼品質,以及如何將程式釋出到

程式開發 Rust

Rust 提供強大的功能來建構可靠的命令列應用程式。本文以 catsay 程式為例,示範如何讀取檔案、處理錯誤並使用 anyhow 套件提供更友善的錯誤訊息。同時也說明如何讓程式支援管道輸入輸出,增強其在命令列工作流程中的整合性。此外,文章也引導讀者使用 assert_cmdpredicates 套件撰寫整合測試,確保程式碼的品質。最後,文章也介紹瞭如何將程式釋出到 crates.io,讓更多使用者可以輕鬆安裝和使用,以及如何編譯成二進位制檔案,方便在不同平台上散佈。

強化命令列程式:讀取檔案與錯誤處理

在開發命令列應用程式時,讀取檔案和處理錯誤是兩個非常重要的功能。本章節將探討如何在 Rust 中實作這兩個功能,以建立一個更強壯的命令列程式。

讀取貓咪圖片檔案

為了讓我們的 catsay 程式能夠讀取外部的貓咪圖片檔案,我們需要新增一個可選的命令列引數。這個引數將用於指定貓咪圖片檔案的路徑。

新增檔案路徑引數

首先,我們需要在 Options 結構體中新增一個欄位來儲存檔案路徑:

#[derive(Parser)]
struct Options {
    // ...
    #[clap(short = 'f', long = "file")]
    /// 從指定的檔案載入貓咪圖片
    catfile: Option<std::path::PathBuf>,
    // ...
}

這段程式碼使用了 clap 函式庫來定義命令列引數。其中,short = 'f'long = "file" 指定了引數的簡寫和完整名稱,而 catfile 欄位則用於儲存檔案路徑。

讀取檔案內容

接下來,我們需要在 main 函式中讀取檔案內容:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ...
    match &options.catfile {
        Some(path) => {
            let cat_template = std::fs::read_to_string(path)?;
            let eye = format!("{}", eye.red().bold());
            let cat_picture = cat_template.replace("{eye}", &eye);
            println!("{}", message.bright_yellow().underline().on_purple());
            println!("{}", &cat_picture);
        }
        None => {
            // ... 列印預設的貓咪圖片
        }
    }
    Ok(())
}

內容解密:

  1. match &options.catfile:檢查 catfile 欄位是否有值。如果有,則讀取檔案內容;否則,列印預設的貓咪圖片。
  2. std::fs::read_to_string(path)?:讀取指定路徑的檔案內容。如果讀取失敗,則傳回錯誤。
  3. cat_template.replace("{eye}", &eye):將貓咪圖片中的 {eye} 佔位符替換為實際的眼珠字元。

錯誤處理

在前面的範例中,我們使用了 unwrapexpect 方法來處理可能失敗的操作。然而,這些方法會導致程式在發生錯誤時直接當機。為了更好地處理錯誤,我們可以使用 ? 運算元。

使用 ? 運算元處理錯誤

? 運算元可以用於簡化錯誤處理的程式碼。它的作用是:如果 ResultOk,則傳回其中的值;否則,傳回錯誤。

let cat_template = std::fs::read_to_string(path)?;

等價於:

let cat_template = match std::fs::read_to_string(path) {
    Ok(file_content) => file_content,
    Err(e) => return Err(e.into()),
};

修改 main 函式的簽名

為了使用 ? 運算元,我們需要修改 main 函式的簽名,使其傳回 Result

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ...
}

內容解密:

  1. Result<(), Box<dyn std::error::Error>>:表示 main 函式可能傳回錯誤。Box<dyn std::error::Error> 是一個 trait 物件,可以容納任何實作了 std::error::Error trait 的型別。
  2. Ok(()):表示 main 函式執行成功,沒有傳回任何值。

強化命令列工具的錯誤處理與管道功能

在開發命令列工具時,良好的錯誤處理機制和管道(piping)功能的支援是提升使用者經驗的關鍵。本章節將探討如何使用 anyhow 套件來改善錯誤處理,以及如何讓 catsay 工具支援管道輸入和輸出。

使用 anyhow 提升錯誤處理能力

在處理檔案讀取等操作時,可能會遇到諸如檔案不存在等錯誤。預設的錯誤訊息可能過於技術性,不利於使用者理解。為此,可以引入 anyhow 套件來提供更友善的錯誤訊息。

首先,透過 cargo add anyhowanyhow 新增至專案中。接著,在 Cargo.toml 中應可見如下設定: // Cargo.toml

[package]
# ...

[dependencies]
# ...
anyhow = "1.0.70"

接下來,在 src/main.rs 中引入 anyhow::{Context, Result},並修改 main 函式的回傳型別為 Result<()>。當讀取檔案時,使用 with_context 方法為可能的錯誤新增上下文訊息: // src/main.rs

use anyhow::{Context, Result};
// ...

fn main() -> Result<()> {
    // ...
    std::fs::read_to_string(path).with_context(|| format!("無法讀取檔案 {:?}", path))?;
    // ...
    Ok(())
}

如此一來,當發生錯誤時,使用者將看到更為友善的錯誤訊息,例如:

$ cargo run -- -f no/such/file.txt
# ... 一般的 cargo 編譯輸出
Error: 無法讀取檔案 "catfiles.txt"
Caused by:
No such file or directory (os error 2)

內容解密:

  1. 引入 anyhow 套件:提升錯誤處理能力,提供更豐富的錯誤上下文。
  2. with_context 方法:為原始錯誤新增使用者友好的上下文訊息。
  3. Result 型別的變化:使用 anyhow::Result 取代原始的 std::result::Result,簡化錯誤處理。

支援管道輸出

為了讓 catsay 工具的輸出能順暢地傳遞給其他命令列工具,需要處理顏色程式碼的問題。預設情況下,colored 套件會在輸出中加入 ANSI 顏色轉義碼,這可能導致某些工具出現非預期的行為。

停用顏色輸出

可以透過設定 NO_COLOR 環境變數來停用顏色輸出,使輸出更乾淨:

$ NO_COLOR=1 cargo run

如此一來,再將輸出導向檔案時,便不會包含顏色程式碼。

內容解密:

  1. NO_COLOR 環境變數:遵循業界慣例,用於控制是否輸出顏色程式碼。
  2. 停用顏色輸出的好處:避免顏色程式碼幹擾其他命令列工具的正常運作。

接受 STDIN 輸入

為了增強 catsay 的靈活性,可以讓它從 STDIN 讀取訊息。為此,需新增一個 --stdin 旗標,當該旗標存在時,程式便從 STDIN 讀取輸入。

首先,在 Options 結構體中新增 stdin 欄位: // src/main.rs

#[derive(Parser)]
struct Options {
    // ...
    #[clap(short = 'i', long = "stdin")]
    /// 從 STDIN 而非引數讀取訊息
    stdin: bool,
}

接著,在 main 函式中根據 options.stdin 的值決定讀取來源: // src/main.rs

use std::io::{self, Read};
// ...

fn main() -> Result<()> {
    let options = Options::parse();
    let mut message = String::new();
    if options.stdin {
        io::stdin().read_to_string(&mut message)?;
        // 處理從 STDIN 讀取的訊息
    } else {
        // 原有的從引數讀取訊息的邏輯
    }
    // ...
}

如此一來,便可透過管道將其他命令的輸出傳給 catsay

$ echo -n "Hello world" | catsay --stdin

內容解密:

  1. --stdin 旗標:用於啟用從 STDIN 讀取輸入的功能。
  2. 從 STDIN 讀取邏輯:使用 io::stdin().read_to_string() 方法讀取輸入。
  3. 提升工具的靈活性:支援管道輸入,使 catsay 能更好地融入命令列工作流程中。

撰寫命令列程式的整合測試

撰寫命令列程式時,自動化測試是確保程式品質和及早發現錯誤的重要工具。前面我們已經在 src/main.rs 中編寫了所有程式碼,但這種方式不利於測試。為了進行單元測試,最好將業務邏輯拆分到一個獨立的函式庫專案(library crate)中,並讓 main.rs 檔案匯入和使用該函式庫專案。這樣就可以對包含大部分業務邏輯的其他專案進行內部單元測試。

整合測試的挑戰

測試命令列程式通常涉及執行命令,然後驗證其傳回碼和 STDOUT/STDERR 輸出。這可以透過編寫 shell 指令碼來實作,但編寫 shell 指令碼意味著必須自行實作斷言和測試結果匯總與報告,而 Rust 的單元測試框架已經支援這些功能。std::process::Command 結構體和 assert_cmd 套件將有助於測試程式。

新增 assert_cmd 套件

首先,執行以下命令新增 assert_cmd 套件:

cargo add assert_cmd

這將更新 Cargo.toml 檔案:

[package]
name = "catsay"
version = "0.1.0"
edition = "2021"

[dependencies]
# ...
assert_cmd = "2.0.10"

建立整合測試

接下來,在專案根目錄下建立一個名為 tests 的資料夾來存放測試檔案。然後,建立一個名為 integration_test.rs 的檔案,並將以下程式碼貼入其中:

// tests/integration_test.rs
use std::process::Command; // 執行程式
use assert_cmd::prelude::*; // 新增命令方法

#[test]
fn run_with_defaults() {
    Command::cargo_bin("catsay")
        .expect("binary exists")
        .assert()
        .success();
}

程式碼解析

  1. 載入必要的模組:使用 use 命令載入了兩個不同的模組:std::process::Commandassert_cmd::prelude::*std::process::Command 提供了一個 Command 結構體,可以幫助我們在新產生的行程中執行程式。assert_cmd::prelude::* 模組匯入了一些有用的 trait,擴充套件了 Command 以更適合整合測試,例如 cargo_bin()assert()success()

  2. 定義測試函式:在測試函式 run_with_defaults() 中,首先使用 Command::cargo_bin() 初始化命令,該命令接受一個由 cargo 建構的二進位名稱,在本例中為 “catsay”。如果二進位檔案不存在,它將傳回一個 Err(CargoError),因此需要使用 .expect() 解封裝它。然後在命令上呼叫 assert(),產生一個 Assert 結構體。Assert 結構體提供了各種實用函式,用於對執行命令的狀態和輸出進行斷言。在這個例子中,使用了一個非常基本的斷言 success(),檢查命令是否成功執行。

  3. 執行測試:可以透過在終端機中執行 cargo test 來執行這個測試。

擴充套件測試功能

目前的測試只是確保程式能夠執行,但並未進行更有意義的驗證。下一個步驟是檢查 STDOUT 是否包含預期的輸出。當不帶任何引數執行 catsay 程式時,它會印出一個貓說 “Meow!”,因此可以驗證標準輸出中是否有字串 “Meow!”。

新增 predicates 套件

為了實作上述功能,需要新增 predicates 套件:

cargo add predicates

更新後的 Cargo.toml 檔案如下:

[package]
# ...

[dependencies]
# ...
predicates = "3.0.2"

修改測試程式碼

接下來,修改 tests/integration_test.rs 如下:

// tests/integration_test.rs
// ...
use predicates::prelude::*;

#[test]
fn run_with_defaults() {
    Command::cargo_bin("catsay")
        .expect("binary exists")
        .assert()
        .success()
        .stdout(predicate::str::contains("Meow!"));
}

程式碼解析

  1. 使用 predicates:新增了 predicates::prelude::* 的載入,以便使用其提供的實用函式。

  2. 驗證 STDOUT:在斷言鏈中新增了 .stdout(predicate::str::contains("Meow!")),用於檢查 STDOUT 是否包含字串 “Meow!”。

測試負面案例和錯誤處理

除了測試正面案例外,也可以測試負面案例和錯誤處理。例如,下面的例子檢查程式是否正確處理了無效的 -f 引數:

#[test]
fn fail_on_non_existing_file() -> Result<(), Box<dyn std::error::Error>> {
    Command::cargo_bin("catsay")
        .expect("binary exists")
        .args(&["-f", "no/such/file.txt"])
        .assert()
        // 在此新增對錯誤輸出的斷言
        ;
}

程式碼解析

  1. 測試錯誤處理:定義了一個新的測試函式 fail_on_non_existing_file(),用於檢查當傳遞一個不存在的檔案路徑作為 -f 引數時,程式是否正確處理了這個錯誤。

  2. 傳遞引數:使用 .args(&["-f", "no/such/file.txt"])catsay 命令傳遞引數。

  3. 斷言錯誤輸出:可以在 .assert() 之後新增對錯誤輸出的斷言,以確保程式正確地處理了錯誤情況。

發布與散佈程式

當您對自己的程式感到滿意時,您可能會想要將它包裝成任何人都能輕易安裝並在他們的命令列中使用的形式。有幾種方法可以做到這一點。每種方法在使用者使用上的便利性與開發者在發布上的努力之間都有一些取捨。

從原始碼安裝

如果您在專案資料夾中執行 cargo install --path ./,Cargo 將以發行模式編譯程式碼,然後將其安裝到 ~/.cargo/bin 資料夾中。然後,您可以將這個路徑追加到您的 PATH 環境變數中,這樣 catsay 命令就可以在您的命令列中使用了。

提示:Cargo 安裝程式的位置可以透過設定 CARGO_HOME 環境變數來覆寫。預設情況下,它被設定為 $HOME/.cargo

您可以將程式碼發布到任何公共程式碼儲存函式庫服務,如 GitHub,甚至將其封裝成 tarball 並發布在您管理的網頁上,然後要求您的使用者下載原始碼並執行 cargo install --path ./。但是,這種方法有幾個缺點:

  • 使用者很難自行找到這個程式。
  • 使用者需要 Rust 工具鏈和一台強大的電腦來編譯原始碼。
  • 需要了解如何下載原始碼並編譯它。
  • 很難管理程式的不同版本,而且升級很困難。

發布到 crates.io

現在,大多數 Rust 程式設計師都在 crates.io 上尋找套件。因此,為了讓您的程式更容易被找到,您可以將其發布到 crates.io。在 crates.io 上發布程式非常簡單,使用者可以輕易地執行 cargo install <crate 名稱> 來下載並安裝它。

要在 crates.io 上發布,您需要有一個帳戶並取得存取權杖。以下是取得存取權杖的步驟:

  1. 在瀏覽器中開啟 https://crates.io
  2. 點選「Log in with GitHub」連結(您需要一個 GitHub 帳戶。如果沒有,請先在 https://github.com/signup 註冊。)
  3. 新增一個電子郵件地址並驗證它。
  4. 登入 crate.io 後,點選右上角的頭像並選擇「Account Settings」。
  5. 在「API Tokens」部分,您可以使用「New Token」產生一個權杖。複製該權杖並妥善儲存。

一旦您獲得了權杖,您就可以執行 cargo login <token>(將 <token> 替換為您剛才建立的權杖)來允許 Cargo 代表您存取 crates.io。然後,您可以在專案目錄中執行 cargo package,這將把您的程式封裝成 crates.io 可接受的格式。您可以檢查 target/package 資料夾以檢視生成了什麼。

還有一步需要做才能發布到 crates.io;您需要在 Cargo.toml 檔案中新增一個授權條款和描述欄位,並將所有程式碼提交到您的本地 git 儲存函式庫中(如果需要,請使用 sudo apt install git 安裝 git)。

# Cargo.toml
[package]
# ...
license = "MIT OR Apache-2.0"
description = "一個 catsay 命令列工具"
# ...

一旦套件準備就緒,只需執行 cargo publish 即可將其發布到 crates.io。請注意,一旦程式碼上傳到 crates.io,它就會永遠保留在那裡,不能被刪除或覆寫。要更新程式碼,您需要在 Cargo.toml 中增加版本號並發布新版本。如果您不小心發布了一個有問題的版本,您可以使用 cargo yank 命令來「撤回」它。這意味著您的使用者不能再針對該版本建立新的相依性,但現有的相依性仍然有效。即使版本被撤回,程式碼仍然是公開的。因此,絕不能在您的 crates.io 套件中發布任何秘密(例如密碼、存取權杖或任何個人資訊)。

編譯二進位制檔案以進行散佈

Rust 編譯為機器碼,預設情況下會靜態連結,因此它不需要像 Java 虛擬機器或 Python 直譯器那樣龐大的執行環境。如果您執行 cargo build --release,Cargo 將以發行模式編譯您的程式,這意味著更高的最佳化程度和比預設的偵錯模式更少的詳細日誌記錄。您將在 target/release/catsay 中找到編譯好的二進位制檔案。然後,可以與使用相同平台的使用者共用這個二進位制檔案。他們可以直接執行它,而無需安裝任何東西。

請注意,這裡假設使用的是「相同的平台」。這是因為二進位制檔案可能無法在另一種 CPU 架構和作業系統組合上執行。理論上,您可以為不同的目標平台進行交叉編譯。例如,如果您正在使用 x86_64 CPU 的 Linux 機器上執行,我們可以為具有 ARM 處理器的嵌入式裝置編譯我們的程式。Rust 的交叉編譯通常很簡單,而且 Cargo 中內建了很多資源;請參閱 https://rust-lang.org