2020/05/04

rust 適合開發 command line tool,這邊要開發一個 grep: Globally search a Regular Expression and Print。這邊會展示如何讓我們的命令行工具提中很多命令行工具中用到的終端功能。讀取環境變數來配置工具的行為。列印到標準錯誤控制流(stderr) 而不是標準輸出(stdout),這樣可以選擇將成功輸出重定向到文件中的同時仍然在 console 上顯示錯誤信息。

接受 command line 參數

建立新的 minigrep project

cargo new minigrep

Rust 標準庫提供函數 std::env::args,這個函數回傳一個傳遞給程序的命令行參數的 iterator。

use std::env;

fn main() {
    // 將 command line 參數收集到一個 vector 中並列印
    // Rust 中我們很少會需要註明類別,但 collect 是一個需要註明類別的函數,因為 Rust 不能推斷出你想要什麼類別的集合
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

執行,第一個參數是程式的名稱

$ cargo run 1234 55
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/minigrep 1234 55`
["target/debug/minigrep", "1234", "55"]

注意:args 函數和無效的 Unicode

std::env::args 在其任何參數包含無效 Unicode 字元時會 panic。如果你需要接受包含無效 Unicode 字元的參數,要用 std::env::args_os 代替。這個函數會傳回 OsString 而不是 StringOsString 在每個平台都不一樣而且比 String 值處理起來更為複雜。

將參數儲存到變數

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    // 程式的名稱 是 &args[0]
    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);
}

執行

$ cargo run test sample.txt
   Compiling minigrep v0.1.0 (/Users/charley/project/idea/rust/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.67s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

讀取檔案

要增加讀取由 filename 命令行參數指定檔案的功能

std::fs::read_to_string 處理 filename 參數,打開檔案,回傳包含其內容的 Result<String>

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

執行結果

$ cargo run the poem.txt
   Compiling minigrep v0.1.0 (/Users/charley/project/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.72s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

重構以改進 module 及錯誤處理

  • main 解析了參數並打開檔案,最好是分離這兩個功能。

  • search 跟 filename 是程式中的設定變數, f, contents 則是程式邏輯,最好將設定變數儲存到 struct,有更結構化的變數資訊。

  • 如果打開檔案失敗會用 expect 列印錯誤訊息,但只是 "file not found",沒有其他類型的錯誤訊息 ex: 權限錯誤

  • 不停地使用 expect 處理不同的錯誤,例如參數不夠,會得到 index out of bounds error,但問題不夠明確。 將所有錯誤訊息代碼集中,也能確保列印有意義的錯誤訊息。

分離 binary project 的程式邏輯

在 binary project 中,main 函數負責多個任務的組織問題。rust 社群提供分離 binary project 程式邏輯的 guideline。

  1. 將城市分成 main.rs, lib.rs,將邏輯放入 lib.rs
  2. 當 command line 解析邏輯較小時,可保留在 main.rs 中
  3. 當 command line 解析開始複雜時,同樣將其從 main.rs 提取到 lib.rs
  4. 保留在 main 函數中的責任應被限制為
    • 使用參數值呼叫 command line 解析邏輯
    • 設定
    • 呼叫 lib.rs 中的 run 函數
    • 如果 run 回傳錯誤,就處理錯誤

main.rs 負責程式運作,lib.rs 負責任務邏輯,因不能直接測試 main,將程式邏輯移動到 lib.rs 中,就可以進行測試。

提取參數解析器

改為呼叫新函數 parse_config

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, filename) = parse_config(&args);

    println!("Searching for {}", query);
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

組合設定值

將 query, filename 兩個值放入一個 struct 並給一個有意義的名字

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config { query, filename }
}

main 中的 args 變量是參數值的owner並只允許 parse_config 函數借用他們,如果 Config 嘗試獲取 args 中值的所有權將違反 Rust 的借用規則。以 clone String 的方式,產生 Config instance

建立 Config 的 constructor

parse_config 從一個普通函數變為一個叫做 new 且與結構體關聯的函數。未來可以像 std library 中的 String 呼叫 String::new 一樣來產生 Config,將 parse_config 變為一個與 Config 關聯的 new 函數。

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

處理錯誤

直接執行時,會發生錯誤

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:25:21
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

在 new 裡面,進行參數數量檢查,並以 panic! 產生有意義的錯誤訊息

// --snip--
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("not enough arguments");
    }
    // --snip--

new 回傳 Result 而不是呼叫 panic!

可以改為回傳一個 Result 值,它在成功時會包含一個 Config 的實例,而在錯誤時會描述問題。

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    // 呼叫 new,失敗時以 std::process::exit 直接停掉程式
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

impl Config {
    //  &'static str 是 string literal 的類別
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            // 異常時,會傳 Err
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

執行結果

$ cargo run
   Compiling minigrep v0.1.0 (/Users/charley/project/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.74s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

提取程式邏輯到 run 函數

fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.filename)
        .expect("something went wrong reading the file");

    println!("With text:\n{}", contents);
}

// --snip--

在 run 回傳錯誤

use std::env;
use std::fs;
use std::process;
use std::error::Error;

fn main() {
    let args: Vec<String> = env::args().collect();

    // 呼叫 new,失敗時以 std::process::exit 直接停掉程式
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

// 錯誤類別,使用了 trait 對象 Box<dyn Error>(在開頭使用了 use 語句將 std::error::Error 引入作用域)
fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    println!("With text:\n{}", contents);

    Ok(())
}

struct Config {
    query: String,
    filename: String,
}

impl Config {
    //  &'static str 是 string literal 的類別
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            // 異常時,會傳 Err
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

將程式碼拆分到 crate

src/lib.rs

use std::error::Error;
use std::fs;

// 錯誤類別,使用了 trait 對象 Box<dyn Error>(在開頭使用了 use 語句將 std::error::Error 引入作用域)
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    println!("With text:\n{}", contents);

    Ok(())
}

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    //  &'static str 是 string literal 的類別
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            // 異常時,會傳 Err
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

src/main.rs

use std::env;
use std::process;
use minigrep;
use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    // 呼叫 new,失敗時以 std::process::exit 直接停掉程式
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = minigrep::run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

測試

去掉 lib.rs, main.rs 的 println!

search 要 iterate 每一行資料,並檢查是否有包含 query

增加 search 的測試程式

use std::error::Error;
use std::fs;

// 錯誤類別,使用了 trait 對象 Box<dyn Error>(在開頭使用了 use 語句將 std::error::Error 引入作用域)
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    // println!("With text:\n{}", contents);
    // 呼叫 search
    for line in search(&config.query, &contents) {
        println!("{}", line);
    }

    Ok(())
}

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    //  &'static str 是 string literal 的類別
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            // 異常時,會傳 Err
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

// 需要在 search 的簽名中定義一個顯式生命週期 'a 並用於 contents 參數和返回值。
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    // 用 lines 處理每一行資料,包含 query 時,就放到 results
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

// 使用 "duct" 作為這個測試中需要搜索的字符串。用來搜索的文本有三行,其中只有一行包含 "duct"。我們斷言 search 函數的返回值只包含期望的那一行。
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(
            vec!["safe, fast, productive."],
            search(query, contents)
        );
    }
}

處理環境變數

增加 search_case_insensitive 的功能,並將會在設置了環境變量後呼叫它。

use std::error::Error;
use std::fs;
use std::env;

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

impl Config {
    //  &'static str 是 string literal 的類別
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            // 異常時,會傳 Err
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        // 使用 env 環境變數
        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config { query, filename, case_sensitive })
    }
}

// 需要在 search 的簽名中定義一個顯式生命週期 'a 並用於 contents 參數和返回值。
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    // 用 lines 處理每一行資料,包含 query 時,就放到 results
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

// 在比較查詢和每一行之前將他們都轉換為小寫
fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

// 使用 "duct" 作為這個測試中需要搜索的字符串。用來搜索的文本有三行,其中只有一行包含 "duct"。我們斷言 search 函數的返回值只包含期望的那一行。
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(
            vec!["safe, fast, productive."],
            search(query, contents)
        );
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

執行結果

$ export CASE_INSENSITIVE=1
$ cargo run the poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/minigrep the poem.txt`
Then there's a pair of us - don't tell!
They'd banish us, you know.
To tell your name the livelong day

將錯誤訊息轉到 stderr

目前都是用 println! 將輸出列印到 console,錯誤訊息可改為輸出到 stderr,這樣就能讓標準輸出 stdout 轉到一個檔案,而錯誤訊息還是列印到 console

檢查錯誤要寫到哪裡

執行程式時,可用 > 將 stdout 轉輸出到檔案中

$ cargo run > output.txt

將錯誤列印到 stderr

列印錯誤要改用 eprintln!

use std::env;
use std::process;
use minigrep;
use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {}", e);

        process::exit(1);
    }
}

References

The Rust Programming Language

中文版

中文版 2

沒有留言:

張貼留言