與機器溝通

當你能够組合指令行工具時,指令行工具的強大功能便會展現出來。這並不是一個新想法,事實上,這句摘錄自 Unix 哲學

預期每一個程式輸出的結果會成為另一個尚未得知的程式輸入。

如果我們的程式符合這個預期,我們的使用者就會很滿意。為了確保它能順利運作,我們不只能提供漂亮的人類可讀輸出,也需要提供一個其他程式所需的版本。讓我們來看看我們怎麼做。

誰在讀取這個?

首先要問的問題是:我們的輸出對象是在彩色終端機前的人類,還是另一個程式?為了回答這個問題,我們可以使用類似 is-terminal 的箱子

use is_terminal::IsTerminal as _;

if std::io::stdout().is_terminal() {
    println!("I'm a terminal");
} else {
    println!("I'm not");
}

根據誰會讀取我們的輸出,我們可以新增額外的資訊。例如,人類比較喜歡彩色,如果你在任一 Rust 專案中執行 ls ,你可能會看到類似的內容

$ ls
CODE_OF_CONDUCT.md   LICENSE-APACHE       examples
CONTRIBUTING.md      LICENSE-MIT          proptest-regressions
Cargo.lock           README.md            src
Cargo.toml           convey_derive        target

由於這個樣式是為人類設計的,在大部分的設定中,它甚至會用彩色列印部分名稱(像是 src)以顯示它們是目錄。如果你將它導向檔案或類似 cat 的程式, ls 將會調整它的輸出。它不會使用符合我的終端機視窗的欄位,而是會將每個項目印在它自己的行。它也不會顯示任何顏色。

$ ls | cat
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Cargo.lock
Cargo.toml
LICENSE-APACHE
LICENSE-MIT
README.md
convey_derive
examples
proptest-regressions
src
target

機器友善的簡易輸出格式

從歷史的觀點來看,唯一會產生命令列工具輸出的類型為字串。這對在終端機處理文字及理解文字意義的人來說通常很方便。然而,其他程式通常沒有這種能力:讓這些程式了解類似於 ls 工具輸出資料的唯一途徑,就是讓程式開發人員加入一個解析器,而這個解析器必須剛好能處理 ls 產生的輸出資料。

這常常意指輸出會受到容易解析的條件限制。例如,每筆記錄都放在一行的 TSV (tab-separated values) 格式,且每一行都包含以 tab 分隔的內容,都非常普遍。這些以文字行基礎的簡單格式可以讓類似於 grep 的工具用於類似於 ls 的工具所產生的輸出。| grep Cargo 不會在意你的行是否來自於 ls 或檔案,它只會逐行過濾。

此作法的缺點是你不能用一個簡單的 grep 呼叫函數來過濾 ls 給你的所有目錄。要過濾目錄,每個目錄項目都需要攜帶額外的資料。

提供給機器用的 JSON 輸出

以 tab 分隔的值是一種輸出結構化資料的簡單方式,但它需要其他程式知道哪些欄位是預期的資料 (以及這些欄位的順序),而且要輸出不同類型的訊息很困難。例如,假設我們的程式想要傳送訊息告知使用者目前正在等待下載,然後再輸出訊息說明取得的資料。這些是兩種非常不同的訊息類型,且若要將這些訊息統一成為 TSV 輸出,我們必須發明一個方法來區分這些訊息。 當我們想要列印其中包含兩個具有不同長度的項目清單的訊息時,做法也相同。

儘管如此,選擇一個在大多數程式語言/環境中容易解析的格式仍然是一個好主意。因此,在過去幾年來,許多應用程式都具備以 JSON 輸出資料的能力。它夠簡單所以幾乎所有語言都存在解析器,而且它夠強大,因此在許多情況下都能派上用場。儘管它是一種人類可以讀取的文字格式,但也因為許多人都研發出可以快速解析 JSON 資料及將資料序列化為 JSON 的實作,所以它成為一種廣受歡迎的格式。

在以上的說明中,我們已經說明我們的程式會撰寫「訊息」。這是一種思考輸出結果的好方法:你的程式不一定要只輸出一個資料區塊,而可能會在執行時發出許多不同的資訊。輸出 JSON 時,一個支援這種方法的簡單方法,就是每個訊息寫成一個 JSON 文件,並將每個 JSON 文件放在新行上 (有時稱為 Line-delimited JSON)。這可以讓實作就像使用一個常規的 println! 一樣簡單。

以下是個使用 json! 巨集的簡單範例,來自 serde_json,可以在你的 Rust 原始程式碼中快速寫出有效的 JSON

use clap::Parser;
use serde_json::json;

/// Search for a pattern in a file and display the lines that contain it.
#[derive(Parser)]
struct Cli {
    /// Output JSON instead of human readable messages
    #[arg(long = "json")]
    json: bool,
}

fn main() {
    let args = Cli::parse();
    if args.json {
        println!(
            "{}",
            json!({
                "type": "message",
                "content": "Hello world",
            })
        );
    } else {
        println!("Hello world");
    }
}

以下是輸出

$ cargo run -q
Hello world
$ cargo run -q -- --json
{"content":"Hello world","type":"message"}

(使用 -q 執行 cargo 會抑制其一般輸出的顯示。-- 之後的引數會傳遞給我們的程式。)

實際範例:ripgrep

ripgrep 是一個用 Rust 撰寫的 grepag 的替代品,預設會產生如下這類的輸出

$ rg default
src/lib.rs
37:    Output::default()

src/components/span.rs
6:    Span::default()

但是給予 --json 時,它會列印

$ rg default --json
{"type":"begin","data":{"path":{"text":"src/lib.rs"}}}
{"type":"match","data":{"path":{"text":"src/lib.rs"},"lines":{"text":"    Output::default()\n"},"line_number":37,"absolute_offset":761,"submatches":[{"match":{"text":"default"},"start":12,"end":19}]}}
{"type":"end","data":{"path":{"text":"src/lib.rs"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":137622,"human":"0.000138s"},"searches":1,"searches_with_match":1,"bytes_searched":6064,"bytes_printed":256,"matched_lines":1,"matches":1}}}
{"type":"begin","data":{"path":{"text":"src/components/span.rs"}}}
{"type":"match","data":{"path":{"text":"src/components/span.rs"},"lines":{"text":"    Span::default()\n"},"line_number":6,"absolute_offset":117,"submatches":[{"match":{"text":"default"},"start":10,"end":17}]}}
{"type":"end","data":{"path":{"text":"src/components/span.rs"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":22025,"human":"0.000022s"},"searches":1,"searches_with_match":1,"bytes_searched":5221,"bytes_printed":277,"matched_lines":1,"matches":1}}}
{"data":{"elapsed_total":{"human":"0.006995s","nanos":6994920,"secs":0},"stats":{"bytes_printed":533,"bytes_searched":11285,"elapsed":{"human":"0.000160s","nanos":159647,"secs":0},"matched_lines":2,"matches":2,"searches":2,"searches_with_match":2}},"type":"summary"}

你可以看到,每個 JSON 文件都是一個包含 type 欄位的物件(地圖)。這讓我們可以為 rg 寫一個簡單的前端,它可以在文件進入時讀取這些文件,並在 ripgrep 仍然在搜尋時就顯示比對(連同它所在的檔案)。

如何處理被管入的輸入

我們假設有一個程式會讀取檔案中的字數

use clap::Parser;
use std::path::PathBuf;

/// Count the number of lines in a file
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// The path to the file to read
    file: PathBuf,
}

fn main() {
    let args = Cli::parse();
    let mut word_count = 0;
    let file = args.file;

    for line in std::fs::read_to_string(&file).unwrap().lines() {
        word_count += line.split(' ').count();
    }

    println!("Words in {}: {}", file.to_str().unwrap(), word_count)
}

它會取得檔案的路徑,逐行讀取,並計算以空白分隔的字數。

執行時,它會輸出檔案中的總字數

$ cargo run README.md
Words in README.md: 47

但是,如果我們想要計算被管入程式的字數會怎樣?Rust 程式可以使用 Stdin 結構 來讀取透過標準輸入傳遞的資料,你可以在標準函式庫中透過 stdin 函式 取得這個結構。類似於讀取檔案所用的那一行,它可以讀取 stdin 中的行。

以下是個計算透過 stdin 管入內容的字數的範例程式

use clap::{CommandFactory, Parser};
use is_terminal::IsTerminal as _;
use std::{
    fs::File,
    io::{stdin, BufRead, BufReader},
    path::PathBuf,
};

/// Count the number of lines in a file or stdin
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// The path to the file to read, use - to read from stdin (must not be a tty)
    file: PathBuf,
}

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

    let word_count;
    let mut file = args.file;

    if file == PathBuf::from("-") {
        if stdin().is_terminal() {
            Cli::command().print_help().unwrap();
            ::std::process::exit(2);
        }

        file = PathBuf::from("<stdin>");
        word_count = words_in_buf_reader(BufReader::new(stdin().lock()));
    } else {
        word_count = words_in_buf_reader(BufReader::new(File::open(&file).unwrap()));
    }

    println!("Words from {}: {}", file.to_string_lossy(), word_count)
}

fn words_in_buf_reader<R: BufRead>(buf_reader: R) -> usize {
    let mut count = 0;
    for line in buf_reader.lines() {
        count += line.unwrap().split(' ').count()
    }
    count
}

如果你使用 -(表示有意願從 stdin 讀取)透過管線輸入文字來執行該程式,它將會輸出字數

$ echo "hi there friend" | cargo run -- -
Words from stdin: 3

它要求 stdin 不是互動的,因為我們預期接收的輸入是被管入程式中,而不是在執行時輸入的文字。如果 stdin 是個終端機,它會輸出說明文件,這樣才能清楚為何該程式無法執行。