Rust 開發命令行工具(上)

大家好,我是 「柒八九」

作爲一個前端/Rust/AI知識博主,之前的文章中,大部分篇幅都是關於前端的知識分享,而對RustAI的內容只是做了幾篇內容梳理和介紹。

而,我們今後的重心也會逐漸偏移,勢必能達到 前端/Rust/AI「三足鼎立」 的局面。

這裏也和很多 「精神股東」 做一次簡短的彙報,之前答應大家多出一些Rust相關的文章,由於工作和個人事務侵佔大部分 「學習和總結」 的時間,所以遲遲沒有兌現承諾。也很感謝大部分 「老粉」 能不離不棄,在這裏先叩謝大家了。

你們的支持也是我輸入內容的 「精神支柱」,同時也很感謝有些 「遠在天涯海角」 的朋友,不停的給出建議和改進意見,Last but not least, 由於有些技術能力的有限,在一些表達方式和技術深度方向上,有很多瑕疵。也希望以後大家,互相學習,共同進步。

好了,估計大家不想聽我在這裏一個人聒噪了,那麼就進入我們今天的主題。

前言

在上一篇致所有渴望學習 Rust 的人的信中我們介紹了Rust可以在命令行工具上也大有建樹。

現在就是我們兌現承諾的時候了。

Rust是一種靜態編譯的、快速的語言,具有出色的工具支持和迅速增長的生態系統。這使它非常適合編寫命令行應用程序。

通過編寫具有簡單CLI的程序,對於那些初學者來說是一個很好的練習,也是我們需要 「循序漸進」 的一個過程。畢竟,大家剛開始接觸一個新的語言都是從Hello World的入手的,但是這種Demo級別的程序,可以說是閉門造車,沒有任何的實際價值。並且這種程序是 「難登大雅之堂」 的。

所以,我們今天來通過一個簡單的CLI來鞏固之前的內容,並且寫出的東西也可以在公司應用場景中有用武之地。

所以說選擇很重要,我們不要成爲別人口中說的 「你之所以窮,是因爲你不夠努力」 的人。

我們在講解代碼中,有一些基礎語法會一帶而過,也就是說,已經默認大家已經有Rust基礎了。如果,你是一個Rust初學者,我們也提供了 Rust 學習筆記系列,可以快速掌握基礎語法。當然,裏面的有一些內容也會做一些簡單的梳理和講解。這個就因人而異了,看大家實際情況吧。

由於篇幅的原因,我們打算寫三篇文章(上/中/下),來介紹如何用Rust來編寫屬於自己的命令行工具。 今天是第一篇文章,我們主要的目的是用Rust寫出一個可用的命令行工具。屬於本地應用級別,現在先不要 「嗤之以鼻」,我們後面的 2 篇文章,會逐步優化這個項目,然後達到最後發版供別人使用的級別。

你能所學到的知識點

  1. 前置知識點

  2. 項目設置

  3. 解析命令行參數

  4. 解析文件內容

  5. 更人性化的錯誤報告

  6. 信息輸出處理

  7. 代碼展示 (這個狠重要) 👈 徐志勝語音包

好了,天不早了,乾點正事哇。

1. 前置知識點

「前置知識點」,只是做一個概念的介紹,不會做深度解釋。因爲,這些概念在下面文章中會有出現,爲了讓行文更加的順暢,所以將本該在文內的概念解釋放到前面來。「如果大家對這些概念熟悉,可以直接忽略」
同時,由於閱讀我文章的羣體有很多,所以有些知識點可能 「我視之若珍寶,爾視只如草芥,棄之如敝履」。以下知識點,請 「酌情使用」

grep 簡介

grep 是一個常用的命令行工具,用於在文本文件中搜索指定的文本模式返回匹配的行。其名稱來源於 global regular expression print(全局正則表達式打印),它最初是在UNIX操作系統中開發的,現在已經成爲大多數Unix-like系統(包括Linux)的標準工具之一。grep 的主要功能是查找文件中包含特定文本的行,並將這些行打印到標準輸出(通常是終端)上。

以下是 grep 命令的基本語法:

grep [選項] 模式 [文件...]

一些常見的 grep 用法示例:

  1. 在文件中搜索特定字符串(不區分大小寫):

    grep -i "search_text" file.txt
  2. 在多個文件中遞歸搜索特定字符串並顯示包含匹配項的文件名:

    grep -r -l "search_text" directory/
  3. 使用正則表達式搜索匹配模式:

    grep "pattern.*text" file.txt
  4. 統計匹配的行數:

    grep -c "pattern" file.txt

grep 是一個強大的文本搜索工具,可以在各種情況下用於過濾、查找和處理文本數據。它的靈活性和正則表達式支持使得它在命令行中非常有用。

讓我們編寫一個小型的類似grep的工具。給它起一個霸氣側漏的名稱,那就叫它 - f789吧。

我們可以在我們本地,創建一個文件夾,作爲項目的工作目錄。(這就看個人喜好,自行決斷了)

最終,我們希望能夠像這樣運行我們的工具:

// 創建一個text.txt文件,並向其寫入指定內容
echo "front:789" > text.txt
echo "province:山西" >> text.txt
echo "rust: hello" >> text.txt


$ f789 rust test.txt
rust: hello
$ f789 --help
// 提供一些幫助選項

本文中rustc採用的是1.72.0 (5680fa18f 2023-08-23)的版本。並且在Cargo.toml文件的[package]部分中設置edition = "2021"

如果,「版本不對」 會有一些庫的兼容性問題,所以最好大家在運行代碼前,做一下代碼配置和相關的處理。具體的配置和升級可以參考 Rust 環境配置和入門指南 [1].

在使用對應命令升級之前,這裏有一個小的提示,如果你在Mac中使用brew安裝過Rust,你最好檢測一下對應的版本信息。可以使用rustc --version命令,會返回指定版本信息。例如:rustc 1.68.2 (9eb3afe9e 2023-03-27) (built from a source tarball)
但是,(built from a source tarball)這一部分表示 Rust 編譯器不是通過二進制發佈版安裝的,而是從 Rust 源代碼中編譯生成的。這通常是因爲我們手動構建 Rust 或從源代碼倉庫中獲取 Rust 的最新版本。這種情況的話,在使用rustup update進行版本更新的時候,會有問題。所以我推薦安裝官方的二進制發佈版。(也就是官網的處理方式)

2. 項目設置

如果你尚未安裝Rust,可以參考我們之前的文章 Rust 環境配置和入門指南。然後,打開一個終端並導航到我們想要放置應用程序代碼的目錄。

首先,在存儲編程項目的目錄中運行以下命令:cargo new f789。如果我們查看新創建的f789目錄,我們將會找到一個典型的Rust項目設置:

我們用 erdtree[2] 進行頁面結構展示。當然,我們也可以用tree命令。「一切的理所應當都是命運的暗中撮合」。因爲erdtree也是Rust寫的。

如果我們可以在f789目錄中執行cargo run並獲得一個Hello World,那麼我們已經設置好了。

項目運行

$ cargo new f789
     Created binary (application) `f789` package
$ cd f789/
$ cargo run
   Compiling f789 v0.1.0 (項目存儲路徑)
    Finished dev [unoptimized + debuginfo] target(s) in 0.70s
     Running `target/debug/f789`
Hello, world!

3. 解析命令行參數

一般的CLI都支持參數的輸入:例如tree -a -L 2或者我們之前的erd -i -I -L 2 -y inverted

我們也想讓我們的CLI具有這個功能:

$ f789 front test.txt

我們期望我們的程序查看test.txt並打印出包含front的行。但是我們如何獲取這兩個值呢?

程序名稱後面的文本通常被稱爲命令行參數命令行標誌(特別是當它們看起來像--這樣時)。

在操作系統內部通常將它們表示爲**「字符串列表」** - 簡而言之,它們由空格分隔。

有許多方法可以探查和識別這些參數,以及如何將它們解析成更容易處理的形式。我們還需要告訴使用我們程序的用戶需要提供哪些參數以及它們期望的格式是什麼。

獲得參數

標準庫中包含了函數std::env::args(),它提供了給定參數的迭代器。第一項(「索引爲 0」)是我們程序被調用的名稱(例如,f789),其後的項是用戶在後面寫的內容。

通過這種方式獲取原始參數非常容易(在文件src/main.rs中,在fn main() {之後):

let pattern = std::env::args().nth(1).expect("未提供模式");
let path = std::env::args().nth(2).expect("未提供路徑");

這裏,pattern將包含用戶輸入的第一個參數,path將包含用戶輸入的第二個參數。如果用戶沒有提供這些參數,程序將會報錯並顯示相應的錯誤消息。

將 CLI 參數自定義數據類型

與將CLI參數視爲一堆文本相比,將其視爲表示程序輸入的自定義數據類型通常更有幫助。

看看 f789 front test.txt:有兩個參數,首先是模式(要查找的字符串),然後是路徑(要查找的文件)。

此外還有其它需要注意的點?首先,它們都是必需的。我們還沒有討論默認值,因此我們期望用戶始終提供兩個值。此外,我們還可以談談它們的類型:模式應該是一個字符串,而第二個參數應該是文件的路徑

Rust中,通常以處理的數據爲中心來構建程序,因此以這種方式看待CLI參數非常合適。讓我們做一層數據抽象(在文件src/main.rs中,在fn main() {之前):

struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

這定義了一個新的結構體(struct),它有兩個字段來存儲數據:patternpath

注意:PathBuf類似於String,但用於跨平臺的文件系統路徑。

現在,我們需要將我們的程序接收到的實際參數轉換爲這種形式。一種選項是 「手動解析」 操作系統獲取的字符串列表並自己構建結構。代碼可能如下所示:

let pattern = std::env::args().nth(1).expect("未提供模式");
let path = std::env::args().nth(2).expect("未提供路徑");
let args = Cli {
    pattern: pattern,
    path: std::path::PathBuf::from(path),
};

這種方法是可行的,但不夠方便。上面的方式無法滿足,用戶天馬行空的創造力。例如:遇到類似--pattern="front"--pattern "front"--help 的參數形式上面的代碼就捉襟見肘了。

也就是說,上面的代碼不夠優雅。

使用 Clap 解析 CLI 參數

「站在巨人的肩膀上,你會看的更高」。是不是很熟悉的名言警句,是否勾起你兒時那種貼滿走廊的校園回憶。

我們可以使用別人寫好的工具庫。而用於解析命令行參數的最流行庫稱爲 clap[3]。它具備我們所期望的所有功能,包括支持子命令、Shell 自動完成以及出色的幫助消息。

首先,通過將clap = { version = "4.0", features = ["derive"] }添加到我們的Cargo.toml文件的[dependencies]部分來導入clap

[dependencies]
clap = { version = "4.4.2", features = ["derive"] }

現在,我們可以在代碼中使用use clap::Parser;,並在我們的struct Cli上方添加#[derive(Parser)]。讓我們還順便寫一些文檔註釋。

代碼看起來像這樣(在文件src/main.rs中,在fn main() {之前):

use clap::Parser;

/// 在文件中搜索模式並顯示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要讀取的文件的路徑
    path: std::path::PathBuf,
}

簡單解釋其中的關鍵部分:

  1. use clap::Parser;: 這是導入 clap 庫中的 Parser trait,它用於定義命令行參數和解析命令行輸入。

  2. #[derive(Parser)]: 這是一個自定義屬性(attribute),用於自動實現 Parser trait。通過這個屬性,我們可以在結構體上使用 Parser 的功能,使其成爲一個可以解析命令行參數的類型。

通過使用 clap 庫中的 Parser trait,我們可以輕鬆地爲我們的命令行工具定義參數和解析用戶提供的命令行輸入。這有助於使命令行工具更加靈活和易於使用,同時提供了自動生成幫助文檔和解析命令行參數的功能。

關於trait可以參考我們之前的 Rust 泛型、trait 與生命週期中的內容

注意:我們可以在字段上添加許多自定義屬性。例如,要表示我們希望將此字段用作-o--output之後的參數,我們可以添加#[arg(short = 'o', long = "output")]。有關更多信息,請參閱 clap 文檔。

Cli結構體下方,我們的模板包含了其 「main 函數」。當程序啓動時,將調用此函數。第一行是:

fn main() {
    let args = Cli::parse();
}

這將嘗試將參數解析爲我們的Cli結構。

但如果失敗怎麼辦?這就是這種方法的美妙之處:Clap知道期望哪些字段以及它們的預期格式。它可以自動生成漂亮的--help消息,並提供一些出色的錯誤提示,以建議我們在寫--putput時傳遞--output

代碼實操

我們的代碼現在應該如下所示:

#![allow(unused)]

use clap::Parser;

/// 在文件中搜索模式並顯示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要讀取的文件的路徑
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();
}

在沒有任何參數的情況下運行它:

$ cargo run
   Compiling f789 v0.1.0 (/Users/xxxx/RustWorkSpace/cli/f789)
    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/f789`
error: the following required arguments were not provided:
  <PATTERN>
  <PATH>

Usage: f789 <PATTERN> <PATH>

For more information, try '--help'.

我們可以在使用cargo run時通過在--後面寫參數來傳遞參數:

$ cargo run -- some-pattern some-file
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/f789 some-pattern some-file`

如我們所見,沒有輸出。這是好事:這意味着沒有錯誤,我們的程序已經結束。

4. 解析文件內容

利用Clap進行參數處理後,我們輕而易舉可以獲取到用戶輸入數據。可以實現f789的內部邏輯了。我們的main函數現在只包含以下這行代碼:

let args = Cli::parse();

接下來,我們逐步完善我們的內部邏輯,現在從打開我們得到的文件開始:

let content = std::fs::read_to_string(&args.path).expect("無法讀取文件");

注意:看到這裏的.expect方法了嗎?這是一個快速退出的快捷函數,當值(在這種情況下是輸入文件)無法讀取時,它會立即使程序退出。具體的使用情況,參看 Rust 錯誤處理

然後,讓我們迭代每一行,並打印包含我們模式的每一行:

for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

代碼實操

我們的代碼現在應該如下所示:

#![allow(unused)]

use clap::Parser;

/// 在文件中搜索模式並顯示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要讀取的文件的路徑
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("無法讀取文件");

    for line in content.lines() {
        if line.contains(&args.pattern) {
            println!("{}", line);
        }
    }
}

試一試:cargo run -- main src/main.rs 現在應該可以工作了!

上面的代碼,雖然能滿足我們的業務需求,但是還不夠完美。有一個弊端:它會將整個文件讀入內存 - 無論文件有多大。如果我們想在一個**「龐然大物」**中搜索我們需要的內容,那就有點不爽了。

我們可以使用 BufReader 來優化上面的代碼:

#![allow(unused)]

use clap::Parser;
use std::io::{self, BufRead};
use std::fs::File;

/// 在文件中搜索模式並顯示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要讀取的文件的路徑
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();

    // 打開文件並創建一個 BufReader 來逐行讀取
    let file = File::open(&args.path).expect("無法打開文件");
    let reader = io::BufReader::new(file);

    for line in reader.lines() {
        let line = line.expect("無法讀取行");
        if line.contains(&args.pattern) {
            println!("{}", line);
        }
    }
}

這個版本的代碼使用 BufReader 來逐行讀取文件,而不是一次性讀取整個文件內容,這樣可以更有效地處理大文件。BufReader 在內部緩衝讀取的數據,以提高性能,並且適合用於逐行處理文本文件。

5. 更人性化的錯誤報告

使用其它語言時候,我們時刻會擔心會存在莫名其妙的錯誤,從而使得我們自詡健壯的代碼,變得一文不值。而Rust不一樣,當使用Rust時,我們可以放心的去寫相關邏輯。因爲 「它沒有異常,所有可能的錯誤狀態通常都編碼在函數的返回類型中」

Result

read_to_string這樣的函數不會返回一個字符串。相反,它返回一個Result,其中包含一個String或某種類型的錯誤(在這種情況下是std::io::Error)。

Result是一個 「枚舉」,我們可以使用match來檢查它是哪個變體:

let result = std::fs::read_to_string("test.txt");
match result {
    Ok(content) ={ println!("文件內容: {}", content); }
    Err(error) ={ println!("出錯了: {}", error); }
}

想了解Rust中枚舉和它如何工作的,可以參考 Rust 枚舉和匹配模式

Unwrapping

現在,我們已經能夠訪問文件的內容,但實際上我們無法在match塊之後對其進行任何操作。爲此,我們需要以某種方式處理錯誤情況。挑戰在於match塊的所有分支都需要 「返回相同類型的內容」。但有一個巧妙的技巧可以繞過這個問題:

let result = std::fs::read_to_string("test.txt");
let content = match result {
    Ok(content) ={ content },
    Err(error) ={ panic!("無法處理錯誤:{},在這裏退出", error); }
};
println!("文件內容:{}", content);

match塊之後,我們可以使用content中的String。如果result是一個錯誤,String將不存在。但由於程序在達到使用content的地方之前會退出,所以沒問題。

Rust 將錯誤組合成兩個主要類別:可恢復錯誤 recoverable 和 不可恢復錯誤 unrecoverable。

  • 「可恢復錯誤」 通常代表向用戶報告錯誤和重試操作是合理的情況,比如未找到文件

  • 「不可恢復錯誤」 通常是 bug 的同義詞,比如嘗試訪問超過數組結尾的位置。

  • Rustpanic!宏。當執行這個宏時,程序會打印出一個錯誤信息,展開並清理棧數據,然後接着退出

這可能看起來有點激進,但非常方便。如果我們的程序需要讀取該文件,如果文件不存在無法執行任何操作,那麼退出是一種有效的策略。甚至在Result上還有一個快捷方法,稱爲unwrap

let content = std::fs::read_to_string("test.txt").unwrap();

panic 的替代方案

當然,中止程序並不是處理錯誤的唯一方法。除了使用panic!之外,我們也可以輕鬆地使用return

let result = std::fs::read_to_string("test.txt");
let content = match result {
    Ok(content) ={ content },
    Err(error) ={ return Err(error.into()); }
};

然而,這 「改變了我們的函數需要的返回類型」。所以,我們需要處理一下函數簽名。

以下是完整示例:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = std::fs::read_to_string("test.txt");
    let content = match result {
        Ok(content) ={ content },
        Err(error) ={ return Err(error.into()); }
    };
    println!("文件內容:{}", content);
    Ok(())
}

我們來簡單對每行代碼做一次解釋:

  1. fn main() -> Result<(), Box<dyn std::error::Error>>: 這是程序的入口點 main 函數的簽名。它返回一個 Result 類型,表示程序的執行結果。
  1. let result = std::fs::read_to_string("test.txt");: 這行代碼嘗試打開並讀取文件 "test.txt" 的內容。它使用了標準庫中的 std::fs::read_to_string 函數,該函數返回一個 Result<String, std::io::Error>,表示讀取文件內容的結果。

  2. let content = match result { ... }: 這是一個模式匹配語句,用於處理文件讀取的結果 result

  1. println!("文件內容:{}", content);: 如果成功讀取文件內容,程序將打印文件的內容到標準輸出,使用 {} 佔位符來插入 content 變量的值。

  2. Ok(()): 最後,程序返回一個成功的 Result,表示程序執行成功。

注意:爲什麼這不寫作return Ok(());?它完全可以這樣寫, 這也是完全有效的。在Rust中,「任何塊的最後一個表達式都是它的返回值」,習慣上省略不必要的返回。

? 操作

就像調用.unwrap()是與panic!在錯誤分支中的匹配的快捷方式一樣,我們還有另一個與在錯誤分支返回的匹配的快捷方式:?

你沒有看錯,就是一個 「問號」。我們可以將此操作符附加到Result類型的值上,「Rust 將在內部將其擴展爲與我們剛剛編寫的 match 非常相似的東西」。

可以將對應的代碼部分改成如下格式:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("test.txt")?;
    println!("文件內容:{}", content);
    Ok(())
}

難道這就是傳說中,「從天而降的掌法嘛」。這也太絲滑了。

這裏有一些Rust開發中的**「潛規則」**。例如,我們main函數中的錯誤類型是Box<dyn std::error::Error>。但是我們已經看到read_to_string返回的是std::io::Error。這是因爲?擴展爲轉換錯誤類型的代碼。

同時,Box<dyn std::error::Error>也是一個有趣的類型。它是一個Box,可以包含任何實現標準Error trait的類型。這意味着基本上所有錯誤都可以放入這個Box中,因此我們可以在所有通常返回Result的函數上使用?

有關Box的使用原理和介紹可以參考 Rust 智能指針

爲錯誤提供合適的語境提示

使用?在主函數中時,得到的錯誤是可以接受的,但不是很好。例如:當我們運行std::fs::read_to_string("test.txt")?但文件test.txt不存在時,我們會得到以下輸出:

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

在代碼中不包含文件名的情況下,很難確定哪個文件是NotFound。有多種處理方式。

創建自己的錯誤類型

我們可以創建自己的錯誤類型,然後使用它來構建自定義錯誤消息:

#[derive(Debug)]
struct CustomError(String);

fn main() -> Result<(), CustomError> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .map_err(|err| CustomError(format!("在讀取`{}`時: {}", path, err)))?;
    println!("文件內容:{}", content);
    Ok(())
}

我們來簡單解釋一下上面的代碼

  1. #[derive(Debug)] struct CustomError(String);: 這個代碼定義了一個自定義的錯誤類型 CustomError,它包含一個字符串字段用於存儲錯誤消息。#[derive(Debug)] 屬性宏爲這個結構體自動生成了 Debug trait 的實現,以便在打印錯誤時更容易調試。

  2. fn main() -> Result<(), CustomError> { ... }: 這是程序的入口點 main 函數的簽名。與之前的代碼不同,它返回一個 Result,其中成功值是 (),表示成功執行而沒有返回值,錯誤值是自定義錯誤類型 CustomError

  3. let content = std::fs::read_to_string(path) ... ?;: 與之前的代碼不同,這裏使用了 map_err 方法來處理可能的錯誤情況。

現在,運行這個程序將會得到我們自定義的錯誤消息:

Error: CustomError("在讀取`test.txt`時: No such file or directory (os error 2)")

雖然不太美觀,但我們可以稍後輕鬆調整我們類型的調試輸出。

使用 anyhow 庫

上面的模式非常常見。但它有一個問題:我們沒有存儲 「原始錯誤,只有它的字符串表示」。我們可以使用 anyhow 庫 [4] 對此有一個巧妙的解決方案:與我們的CustomError類型類似,它的Context trait 可以用來添加描述。此外,它還保留了原始錯誤,因此我們得到一個指出根本原因的錯誤消息 “鏈”。

首先,通過在Cargo.toml文件的[dependencies]部分添加anyhow = "1.0.75"來導入anyhow crate

然後,完整的示例將如下所示:

use anyhow::{Context, Result};

fn main() -> Result<(){
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("無法讀取文件 `{}`", path))?;
    println!("文件內容:{}", content);
    Ok(())
}

這將打印一個錯誤:

Error: 無法讀取文件 `test.txt`

Caused by:
    No such file or directory (os error 2)

6. 信息輸出處理

使用 println!

我們幾乎可以使用println!宏打印所有我們喜歡的內容。這個宏具有一些非常驚人的功能,但也有特殊的語法。它希望我們 「將一個字符串字面量作爲第一個參數,該字符串包含佔位符,這些佔位符將由後面的參數的值作爲進一步的參數填充」

例如:

let x = 789;
println!("我的幸運數字是 {}。", x);

將打印:

我的幸運數字是 789。

上述字符串中的**「花括號」**({})是其中的一個**「佔位符」**。這是默認的佔位符類型,它嘗試以人機友好的方式打印給定的值。對於**「數字和字符串」**,這個方法非常有效,但並不是所有類型都可以這樣做。這就是爲什麼還有一種**「調試模式」**(debug representation) --{:?}

例如:

let xs = vec![1, 2, 3];
println!("列表是:{:?}", xs);

將打印:

列表是:[1, 2, 3]

如果希望我們自己的數據類型能夠用於調試和記錄,大多數情況下可以在它們的 「定義之上」 添加#[derive(Debug)]

「用戶友好」(User-friendly) 打印使用Display trait「調試輸出」(面向開發人員的輸出)使用Debug trait。我們可以在 std::fmt 模塊的文檔 [5] 中找到有關可以在println!中使用的語法的更多信息。

打印錯誤信息

通過stderr來打印錯誤,以使用戶和其他工具更容易將其輸出重定向到文件或其他工具。

在大多數操作系統上,程序可以寫入兩個輸出流,stdoutstderr

  • stdout用於程序的實際輸出

  • stderr允許將錯誤和其他消息與stdout分開

這樣,可以將輸出存儲到文件或將其管道傳輸到另一個程序,而錯誤將顯示給用戶。

Rust中,可以通過println!eprintln!來實現這一點,前者打印到stdout,後者打印到stderr

println!("這是正常信息");
eprintln!("這是一個錯誤! :(");

在打印轉義代碼時,會使用戶的終端處於奇怪現象,所以,當處理原始轉義代碼時,應該使用像ansi_term這樣的crate來使我們的輸出更加順暢。

打印優化

向終端打印的速度出奇地慢!如果在循環中調用類似println!的函數,它可能成爲程序運行的瓶頸。爲了加快速度,有兩件事情可以做。

1. 減少寫入次數

首先,我們可能希望減少實際刷新到終端的寫入次數。

println!在每次調用時都會告訴系統刷新到終端,因爲通常會打印每一行。

如果我們不需要這樣做,可以將stdout句柄包裝在默認情況下 「緩衝最多 8 KB」BufWriter中。(當我們想立即打印時,仍然可以在此BufWriter上調用.flush()。)

use std::io::{self, Write};

let stdout = io::stdout(); // 獲取全局stdout實體
let mut handle = io::BufWriter::new(stdout); // 可選:將該句柄包裝在緩衝區中
writeln!(handle, "front: {}", 789); // 如果我們關心此處的錯誤,請添加`?`

2. 使用鎖

其次,可以獲取stdout(或stderr)的鎖,並使用writeln!直接打印到它。這可以防止系統一遍又一遍地鎖定和解鎖stdout

use std::io::{self, Write};

let stdout = io::stdout(); // 獲取全局stdout實體
let mut handle = stdout.lock(); // 獲取它的鎖
writeln!(handle, "front: {}", 789); // 如果我們關心此處的錯誤,請添加`?`

我們還可以結合兩種方法。

具體代碼如下:

use std::io::{self, Write};

fn main() -> io::Result<(){
    let stdout = io::stdout(); // 獲取全局stdout實體
    let stdout_lock = stdout.lock(); // 獲取stdout的鎖
    
    // 將鎖包裝在BufWriter中
    let mut handle = io::BufWriter::new(stdout_lock);
    
    writeln!(handle, "front: {}", 789)?; // 如果我們關心此處的錯誤,請添加`?`

    Ok(())
}

在這個示例中,首先獲取了 stdout 的鎖,然後將鎖傳遞給 io::BufWriter,最後使用 writeln!handle 寫入數據。

顯示一個進度條

某些CLI運行時間不到一秒,而其他一些可能需要幾分鐘或幾小時。如果我們正在編寫後者類型的程序,我們可能希望向用戶顯示正在發生的事情。爲此,我們可以嘗試打印有用的狀態更新,最好以易於消耗的形式呈現。

使用indicatif crate,我們可以向我們的程序添加進度條和小的旋轉器。

在使用之前,我們需要在Cargo.toml中引入對應的庫。

[dependencies]
indicatif = { version = "*", features = ["rayon"] }

下面是使用indicatif的一個小示例。

fn main() {
    let pb = indicatif::ProgressBar::new(100);
    for i in 0..100 {
        do_hard_work();
        pb.println(format!("[+] 完成了第 #{}項", i));
        pb.inc(1);
    }
    pb.finish_with_message("任務完成");
}
fn do_hard_work() {
    use std::thread;
    use std::time::Duration;

    thread::sleep(Duration::from_millis(250));
}

有關更多信息,請參閱 indicatif 文檔 [6] 和示例 [7]。

日誌

爲了更容易理解程序中發生的情況,我們可能想要添加一些日誌語句。通常在編寫應用程序時這很容易。但在半年後再次運行此程序時,日誌將變得非常有幫助。「在某種程度上,日誌記錄與使用 println! 相同,只是你可以指定消息的重要性」

通常可以使用的日誌級別有 errorwarninfodebugtraceerror 優先級最高,trace 優先級最低)。

要嚮應用程序添加日誌記錄,你需要兩樣東西:

  1. log crate(其中包含了根據日誌級別命名的宏)

  2. 一個實際將日誌輸出寫到有用位置的適配器

由於我們現在只關心編寫一個 CLI ,一個易於使用的適配器是 env_logger[8]。它被稱爲env logger,因爲你可以 「使用環境變量來指定你想要記錄的應用程序部分(以及你想要記錄它們的級別)」。它將在日誌消息前加上時間戳和消息來源的模塊。由於庫也可以使用 log,因此我們可以輕鬆配置它們的日誌輸出。

以下是簡單示例:

配置Cargo.toml

[dependencies]
log = "0.4.20"
env_logger = "0.10.0"
use log::{info, warn};

fn main() {
    env_logger::init();
    info!("項目啓動");
    warn!("這是一個警告信息");
}

假設你將此文件保存爲 src/bin/output-log.rs,在 LinuxmacOS 上,你可以這樣運行它:

$ env RUST_LOG=info cargo run --bin output-log

Windows PowerShell 中,你可以這樣運行:

$ $env:RUST_LOG="info"
$ cargo run --bin output-log

Windows CMD 中,你可以這樣運行:

$ set RUST_LOG=info
$ cargo run --bin output-log

上面的代碼是在運行 Rust 項目中的二進制文件(通過指定 --bin 標誌)並設置日誌級別(通過 RUST_LOG 環境變量)。

針對主要的代碼,做一下解釋:

  1. env RUST_LOG=info: 這部分設置了一個環境變量 RUST_LOG,用於控制 Rust 項目中的日誌記錄級別。具體來說,它將日誌級別設置爲 info
  1. --bin output-log: 這部分告訴 cargo 運行項目中名爲 output-log 的二進制文件。Rust 項目通常包含多個二進制文件,這個選項指定要運行的二進制文件的名稱。output-log 應該是你的 Rust 項目中一個二進制文件的名稱。

綜合起來,這行代碼的作用是設置日誌級別爲 info,然後運行 Rust 項目中名爲 output-log 的二進制文件。這有助於控制日誌記錄的詳細程度,並查看項目中的輸出日誌。如果你的 Rust 項目使用了日誌庫,並且在代碼中有相應的日誌記錄語句,那麼設置日誌級別爲 info 會讓你看到 info 級別的日誌消息。

代碼展示

我們上面通過幾節的內容,從項目配置/參數獲取/解析文件內容/處理錯誤信息/信息輸出處理等方面。可以構建出在本地,兼容錯誤提示,並且有很好的輸出形式的本地搜索工具。

讓我們就上面的內容,從代碼上做一次梳理和彙總。

use anyhow::{Context, Result};
use clap::Parser;
use indicatif::ProgressBar;
use std::fs::File;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;

/// 在文件中搜索模式並顯示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要讀取的文件的路徑
    path: PathBuf,
}

fn main() -> Result<(){
    let args = Cli::parse();

    // 打開文件並創建一個 BufReader 來逐行讀取
    let file = File::open(&args.path).with_context(|| format!("無法打開文件 {:?}"&args.path))?;
    let reader = io::BufReader::new(file);

    let stdout = io::stdout();
    let stdout_lock = stdout.lock();
    let mut handle = io::BufWriter::new(stdout_lock);
    let pb = ProgressBar::new(100);
    for line in reader.lines() {
        do_hard_work();
        pb.println(format!("[+] 查找到了 #{:?}項", line));
        pb.inc(1);
        let line = line.with_context(|| "無法讀取行")?;
        if line.contains(&args.pattern) {
            writeln!(handle, "{}", line)?;
        }
    }

    Ok(())
}

fn do_hard_work() {
    thread::sleep(Duration::from_millis(250));
}

對應的Cargo.toml如下

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


[dependencies]
clap = { version = "4.4.2", features = ["derive"] }
anyhow = "1.0.75"
indicatif = { version = "0.17.6", features = ["rayon"] }
log = "0.4.20"
env_logger = "0.10.0"

對應的運行結果如下:

在上文中我們手動創建了一個text.txt文件。我們只是創建了,沒告訴它放置的位置。我們將與src目錄同級。

使用erd -L 1 -y inverted命令查看目錄信息

Cargo會默認把 「所有的源代碼文件」 保存到src目錄下,而 「項目根目錄」 只被用來存儲諸如README文檔/ 許可聲明 / 配置文件等與源代碼 「無關」 的文件。

如果,我們想看針對大文件的處理方式,我們可以新建一個更大的項目。用於做代碼實驗。

後記

「分享是一種態度」

「全文完,既然看到這裏了,如果覺得不錯,隨手點個贊和 “在看” 吧。」

Reference

[1] Rust 環境配置和入門指南: https://mp.weixin.qq.com/s/qW92zNhvDMjszagxczmrqA

[2] erdtree: https://github.com/solidiquis/erdtree#usage

[3] clap: https://docs.rs/clap/latest/clap/

[4] anyhow 庫: https://docs.rs/anyhow/latest/anyhow/

[5] std::fmt 模塊的文檔: https://doc.rust-lang.org/1.39.0/std/fmt/index.html

[6] 參閱 indicatif 文檔: https://docs.rs/indicatif/latest/indicatif/

[7] 示例: https://github.com/console-rs/indicatif/tree/main/examples

[8] env_logger: https://crates.io/crates/env_logger

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/XK5QanlZBjyf1TOrz4UVUg