Rust 的命令列程式開發中,檔案讀取和錯誤處理是兩個關鍵環節。本文將示範如何讀取外部檔案,並使用 match 語法判斷檔案是否存在,接著利用 std::fs::read_to_string 讀取檔案內容,並使用 ANSI 色彩程式碼強化輸出顯示。錯誤處理方面,我們將使用 ? 運算元簡化 Result 型別的處理流程,避免程式當機。更進一步,我們會引入 anyhow 函式庫,提供更友善的錯誤訊息,並示範如何實作管道機制,提升程式的互動性和靈活性。最後,我們將探討如何使用 assert_cmdpredicates 套件撰寫整合測試,驗證程式功能的正確性,並說明如何封裝和發布 Rust 程式到 crates.io 或編譯成二進位制檔案進行分發。

使用 Rust 建構命令列程式:讀取檔案與錯誤處理

本章節將繼續探討如何使用 Rust 開發命令列程式,重點在於讀取外部檔案以及處理可能發生的錯誤。

讀取貓咪圖片檔案

為了增強程式的靈活性,我們將加入一個可選的引數,讓使用者可以指定自定義的貓咪圖片檔案。為此,我們需要修改 Options 結構體,加入一個新的欄位 catfile

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

重點解析:

  1. #[clap(short = 'f', long = "file")] 定義了 -f--file 兩個選項,用於指設定檔案路徑。
  2. 使用 Option<std::path::PathBuf> 來表示 catfile 是可選的,並且利用 PathBuf 來處理不同作業系統中的路徑差異。
  3. 如果未提供 catfile,則該欄位為 None

讀取並顯示自定義貓咪圖片

main 函式中,我們使用 match 陳述式來檢查 options.catfile 是否存在。如果存在,則讀取檔案內容並替換其中的 {eye} 佔位符:

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. 使用 std::fs::read_to_string(path)? 讀取檔案內容,如果失敗則傳回錯誤。
  2. {eye} 佔位符替換為格式化後的眼睛字元。
  3. 使用 ANSI 色彩程式碼為訊息上色並顯示貓咪圖片。

錯誤處理

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

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

重點解析:

  1. ? 運算元會對 Result 進行匹配,如果是 Ok(value) 則傳回 value,如果是 Err(e) 則提前傳回錯誤。
  2. 需要將 main 函式的簽名改為 fn main() -> Result<(), Box<dyn std::error::Error>> 以支援錯誤傳回。

命令列程式的錯誤處理與管道機制實作

在開發命令列程式時,錯誤處理與管道機制是兩個重要的議題。本篇文章將探討如何在 Rust 語言中使用 anyhow 函式庫來改進錯誤處理,並實作管道機制使程式更加靈活。

使用 anyhow 改善錯誤處理

當程式遇到錯誤時,提供使用者友善的錯誤訊息是非常重要的。Rust 的標準函式庫提供了基本的錯誤處理機制,但使用 anyhow 函式庫可以讓錯誤處理變得更加方便。

首先,需要在 Cargo.toml 中加入 anyhow 函式庫:

[dependencies]
anyhow = "1.0.70"

接著,在程式碼中使用 anyhow::Resultwith_context 方法來包裝原始錯誤並提供使用者友善的錯誤訊息:

use anyhow::{Context, Result};

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

這樣,當程式遇到錯誤時,會輸出如下的錯誤訊息:

Error: 無法讀取檔案 "no/such/file.txt"
Caused by:
No such file or directory (os error 2)

內容解密:

  1. anyhow::Result 取代了標準的 Result:使用 anyhow::Result 可以簡化錯誤處理的程式碼。
  2. with_context 方法提供了額外的錯誤資訊:當錯誤發生時,with_context 方法可以提供更詳細的錯誤資訊。
  3. anyhow 函式庫支援錯誤鏈的處理:當有多層函式呼叫時,anyhow 可以提供完整的錯誤鏈資訊。

管道機制的實作

管道機制是 Unix-like 系統中的一個強大功能,可以讓多個命令列程式協同工作。要使 catsay 程式支援管道機制,需要進行兩項修改:輸出到 STDOUT 時不包含顏色碼,以及接受來自 STDIN 的輸入。

輸出到 STDOUT 時不包含顏色碼

當輸出被管道到其他程式時,顏色碼可能會造成問題。可以使用 NO_COLOR 環境變數來控制是否輸出顏色碼:

NO_COLOR=1 cargo run

這樣,當輸出被管道到其他程式時,就不會包含顏色碼。

接受來自 STDIN 的輸入

可以新增一個 --stdin 引數,讓 catsay 程式從 STDIN 讀取輸入:

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

fn main() -> Result<()> {
    let options = Options::parse();
    let mut message = String::new();
    if options.stdin {
        io::stdin().read_to_string(&mut message)?;
        // ...
    }
    // ...
}

這樣,就可以使用以下命令將 echo 的輸出管道到 catsay 程式:

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

內容解密:

  1. --stdin 引數讓程式從 STDIN 讀取輸入:這樣可以讓 catsay 程式支援管道機制。
  2. io::stdin().read_to_string 方法讀取 STDIN 的內容:這樣可以將來自管道的輸入讀取到程式中。

命令列程式的整合測試

撰寫命令列程式的測試通常涉及執行命令,然後驗證其傳回碼和標準輸出/錯誤輸出。雖然可以透過撰寫 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::* 模組。前者提供了一個可以在新衍生程式中執行程式的 Command 結構,後者引入了一些有用的 trait,使得 Command 更適合整合測試。

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

  3. 執行測試:在終端機中執行 cargo test 來執行測試。應該會看到類別似以下的輸出:

$ cargo test
Compiling catsay v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.04s
Running target/debug/deps/catsay-bf24a9cbada6cbf2
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/integration_test-cce770f212f0b7be
running 1 test
test run_with_defaults ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

驗證標準輸出

目前的測試並不十分有趣,也沒有測試太多內容。下一步是檢查標準輸出,看看是否包含預期的輸出。當在沒有任何引數的情況下執行 catsay 程式時,它會列印出一隻貓說「Meow!」,因此可以驗證標準輸出中是否有字串「Meow!」。為此,可以使用 assert_cmd 中的 .stdout() 函式取得標準輸出。然後,使用 predicates 套件提供的工具來驗證標準輸出字串是否包含要尋找的內容。

新增 predicates 套件

執行以下命令新增 predicates 套件:

cargo add predicates

Cargo.toml 檔案現在應該如下所示:

[package]
// ...
[dependencies]
// ...
predicates = "3.0.2"

修改 integration_test.rs

修改 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!"));
}

#### 程式碼變更解析

  • 引入 predicates:使用 use predicates::prelude::*; 引入必要的模組。
  • 修改測試:在 .assert() 後新增 .stdout(predicate::str::contains("Meow!")),用於檢查標準輸出是否包含「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()
        // 在這裡新增斷言以檢查預期的錯誤處理行為
}

發布與分發程式

一旦你對自己的程式感到滿意,你可能會想要將它封裝,以便任何人都能輕鬆地安裝並在他們的shell中使用它作為一個命令。有幾種方法可以做到這一點。每種方法在使用者端的易用性和開發者端的發布工作量之間都有一些權衡。

從原始碼安裝

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

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

你可以將程式碼釋出到任何公共程式碼倉函式庫服務,如GitHub,甚至將其作為tarball釋出在你管理的網頁上,然後要求使用者下載原始碼並執行cargo install --path ./。但是這種方法有一些缺點:

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

發布到crates.io

現在,大多數Rust程式設計師都在crates.io上找到套件。因此,為了讓你的程式更容易被找到,你可以將它釋出到crates.io。在crates.io上釋出你的程式非常容易,使用者可以輕鬆地執行cargo install <crate name>來下載和安裝它。

要在crates.io上釋出,你需要有一個帳戶並獲得一個存取令牌。以下是取得令牌的步驟:

  1. 在瀏覽器中開啟https://crates.io。
  2. 點選“Log in with GitHub”連結(你需要一個GitHub帳戶。如果你沒有,請先在https://github.com/signup註冊。)
  3. 新增一個電子郵件地址並驗證它。
  4. 一旦登入到crates.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 = "A catsay cli"
# ...

一旦套件準備就緒,只需執行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。