使用Clap開發CLI工具

@Pawel Czerwinski

在工作中,我們常常會遇到一些需要重複執行的作業,專案時程安排不會特別開發複雜的界面,不需要精緻的使用者界面下往往會需要開發人員手動執行,這類的需求其實很適合寫成CLI來簡化流程。最近就因為類似的需求用Rust寫了一個簡單的小工具來增加工作效率。

Why Rust?

在開始之前我想先簡單分享一下感想,其實之前也試著用C#寫過CLI工具,那時候的DX其實不好,因為解析命令的部份其實不好處理,就算有框架的協助也需要撰寫非常多不好理解的程式碼。我覺得不方便的地方在於需要為每一個指令、參數、選項等等逐一設定,這些設定跟實際功能運作的連結其實不強。但Rust的Clap不太一樣,我覺得他的優點在於可使很直覺得使用資料結構來組織CLI工具中的指令與功能,這一點要歸功於Rust的型別系統具有強大的表達力。

Clap 介紹

Clap是rust中用來開發CLI的知名套件,查詢速度最快的ripgrep就是使用Clap開發的。下面是一個簡單的例子:

fn main() {
    let matches = Command::new("echor")
        .version("0.1.0")
        .author("marvinhsu")
        .about("Rust echo")
        .arg(
            Arg::new("text")
                .value_name("TEXT")
                .help("Input text")
                .required(true)
                .num_args(1..),
        )
        .arg(
            Arg::new("omit_newline")
                .short('n')
                .help("Do not print newline")
                .action(ArgAction::SetTrue),
        )
        .get_matches();
    
    todo!();
}

詳細的程式碼可以參考我之前練習的程式,這個repo是參考Command-Line Rust的練習。這段程式碼是使用Clap的Builder API撰寫,這個風格其實也是大多數CLI框架的寫法,需要針對不同的命令逐一設定,再用大量的判斷式來解析接收的命令並且分派需要的實做。但Clap在後來的版本中加入了Derive API,大大增強了這部份的DX,可以簡單直覺的用資料結構來組織這些命令,並且用物件的思維來為這些命令實作對應的程式。官方文件的建議是通常情況下應該使用Derive API,除非有強烈的性能要求或是需要更精細的設定才考慮使用Builder API,因為Derive API真的相對容易理解。

Clap With Derive API

接下來就來介紹Derive API吧,我們以大家常用的Git為例,Git具有checkout、branch、push、commit等等指令,以checkout為例後面需要加上一個分支名稱的參數,另外也有-f這個強制切換分之的flag,如果以derive的方式表達會是下面的程式碼:

use clap::{Parser, Args, Subcommand};

// Git的主要指令
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Git {
    #[command(subcommand)]
    subcmd: SubCommand,
}

// 使用enum來描述不同的subCommand
#[derive(Subcommand)]
enum SubCommand {
    // Checkout指令會有自己的資料成員
    Checkout(Checkout),
    Branch,
    Push,
    Commit
}

// Checkout指令需要的資料
#[derive(Args)]
struct Checkout {
    branch_name: String,
    
    #[arg(long, short)]
    force: bool,
}

// Checkout指令本身具有execute的關聯方法,呼叫後會取得該命令的參數並執行
impl Checkout {
    fn execute(&self) {
        println!("Checkout branch: {}", self.branch_name);
    }
}

fn main() {
    let git = Git::parse();

    // 使用Pattern Match來解析應該要執行的指令
    match git.subcmd {
        SubCommand::Checkout(checkout) => {
            checkout.execute();
        }
        SubCommand::Branch => todo!(),
        SubCommand::Push => todo!(),
        SubCommand::Commit => todo!(),
    }
}

我覺得這個寫法比Builder API清晰易讀很多,實做起來也十分簡單。唯一的缺點大概就是難以客製化生成的文件吧!下面是第一層的文件,看起來沒有異常:

❯ cargo run -- -h
Usage: clitest <COMMAND>

Commands:
  checkout  
  branch    
  push      
  commit    
  help      Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version

接下來看看checkout的文件:

❯ cargo run -- checkout -h
Usage: clitest checkout [OPTIONS] <BRANCH_NAME>

Arguments:
  <BRANCH_NAME>  

Options:
  -f, --force  
  -h, --help   Print help

基本上不妨礙使用,但在CLI的慣例中-f這類true/false的設定應該叫做flag而不是Option(雖然Git本身的文件沒有這種區別),如果想要客製化設定文件,就只能透過Builder API進行,因為Derive本來就是為了簡單易用而提供的API。

小結

Clap提供了Derive API的方式來開發CLI工具,透過資料結構來描述指令功能方式真的讓開發簡單而直覺,我覺得這主要是因為Rust的型別系統讓開發人員更容易用資料結構去描述功能吧!