Một CLI Rust có cảm giác hoàn chỉnh
Rust là lựa chọn mặc định của tôi cho các công cụ dòng lệnh, chủ yếu vì một binary tĩnh duy nhất
không cần runtime chính là thứ mà một CLI nên là. clap lo phần phân tích tham số, còn phần còn lại
của bài này nói về những thứ khiến một công cụ có cảm giác hoàn chỉnh thay vì giống một cái script.
API derive
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "notes", version, about = "Một công cụ ghi chú")]
struct Cli {
/// Đường dẫn tới thư mục ghi chú
#[arg(short, long, env = "NOTES_DIR", default_value = "~/notes")]
directory: PathBuf,
/// Tăng mức độ chi tiết của đầu ra
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Tạo một ghi chú mới
New {
title: String,
#[arg(short, long)]
tags: Vec<String>,
},
/// Tìm trong các ghi chú có sẵn
Search {
query: String,
#[arg(short, long, default_value_t = 10)]
limit: usize,
},
}
Ba thứ trong đó đáng được chỉ ra:
Doc comment trở thành phần trợ giúp. Dòng /// phía trên mỗi trường chính là thứ mà --help in
ra. Tài liệu và phần trợ giúp không thể lệch pha nhau vì chúng là một.
env = "NOTES_DIR" khiến một tham số đặt được từ biến môi trường, với cờ dòng lệnh có quyền ưu
tiên. Một thuộc tính, và công cụ trở nên cấu hình được trong CI mà không cần file cấu hình nào.
version không kèm giá trị sẽ đọc nó từ Cargo.toml, nên --version không thể lạc hậu.
Việc phân tích tham số khi ấy là một dòng, và --help, --version, các thông báo lỗi cùng gợi ý cho
cờ gõ nhầm đều có sẵn:
let cli = Cli::parse();
Bốn thứ khiến nó có cảm giác hoàn chỉnh
Mã thoát
Một công cụ luôn thoát ra với mã 0 thì không dùng được trong một script.
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("lỗi: {error:#}");
ExitCode::FAILURE
}
}
}
{error:#} với anyhow in ra cả chuỗi lỗi chứ không chỉ thông điệp ngoài cùng, và đó là khác biệt
giữa “hỏng” và “hỏng: không mở được ~/notes: không đủ quyền”.
stdout cho dữ liệu, stderr cho mọi thứ khác
Quy tắc khiến một công cụ ghép nối được:
println!("{}", result); // dữ liệu — đưa qua pipe được
eprintln!("Đang tìm trong {} ghi chú…", count); // tiến độ — không qua pipe
Các thông điệp tiến độ đặt trên stdout sẽ chui vào file khi ai đó chạy
notes search foo > results.txt. Đây là sai lầm phổ biến nhất ở các CLI đầu tay và nó vô hình cho
tới khi có người đưa đầu ra của bạn qua pipe.
Phát hiện xem bạn có đang ở trong terminal không
Mã màu và thanh tiến độ là nhiễu khi đầu ra là một cái pipe:
use std::io::IsTerminal;
let use_colour = std::io::stdout().is_terminal();
Hãy tôn trọng cả NO_COLOR — đó là một dòng kiểm tra và nó là quy ước.
Bổ sung tự động cho shell
Được sinh ra lúc build, và chúng là thứ khiến một công cụ có cảm giác thuộc về hệ thống:
use clap_complete::{generate, Shell};
Command::Completions { shell } => {
generate(shell, &mut Cli::command(), "notes", &mut std::io::stdout());
}
notes completions zsh > ~/.zfunc/_notes và việc bổ sung bằng phím tab chạy được cho mọi lệnh con và
mọi cờ, tất cả suy ra từ chính cái struct đó.
Mẹo
Hãy thêm --dry-run cho mọi thứ mang tính phá hủy, và làm cho nó in ra chính xác điều gì sẽ
xảy ra. Nó tốn một tiếng và nó là khác biệt giữa một công cụ mà người ta tin tưởng dùng với
--force và một công cụ mà lần nào họ cũng phải chạy dè dặt.
Cấu trúc để kiểm thử được
Hãy giữ main mỏng và đặt phần việc vào một thư viện:
src/
main.rs — phân tích tham số, mã thoát
lib.rs — mọi thứ khác
main.rs trở thành hai mươi dòng và mọi thứ thật đều kiểm thử được mà không phải sinh ra một tiến
trình. Với các trường hợp đầu-cuối, assert_cmd chạy cái binary rồi khẳng định về đầu ra và mã thoát:
#[test]
fn search_with_no_results_exits_zero() {
Command::cargo_bin("notes").unwrap()
.args(["search", "nonexistent"])
.assert()
.success()
.stdout(predicate::str::contains("Không có kết quả"));
}
Bạn có cần async không
Thường là không, và điều đó đáng được cưỡng lại.
Một CLI làm dăm ba thao tác tuần tự thì đơn giản hơn, biên dịch nhanh hơn và dễ gỡ lỗi hơn với I/O
chặn. ureq cho HTTP và std::fs cho file lo được phần lớn các công cụ, và cái binary nhỏ hơn đáng
kể khi không có runtime.
Hãy với tới tokio khi công cụ thật sự có tính đồng thời — tải năm mươi URL, theo dõi nhiều file
cùng lúc — chứ không phải vì async là mặc định hiện đại. Với một chương trình làm ba việc theo thứ
tự, đó là chi phí thừa mà chẳng được lợi gì.