argopt: Rust向けの宣言的なコマンドライン引数パーザー

TL;DR

簡潔で直感的に扱える、宣言的なRust向けのコマンドライン引数パーザーを作りました。

https://crates.io/crates/argopt

モチベーション

Rustにstructoptというライブラリがあります。これはコマンドライン引数をパーズするライブラリなんですが、僕はこのライブラリが大好きなんです。Rustのライブラリの中で一二を争うほど好きです。なんならコマンドラインツールをRustを書く理由の大部分がこのライブラリの存在といっても過言ではないかもしれません(過言ですけど)。

しかしstructoptも使い続けていると、どうにももっと便利にできるんじゃないのかと思う部分が出てきます。structoptでは名前の通りコマンドライン引数をstructで定義して、それに#[derive(StructOpt)] とStructOptをderiveすることでパーザーのコードを自動生成します。

例として、メッセージを指定した回数表示するだけの単純なプログラムを考えてみます。メッセージはオプションで変えられるようにします。

/// Simple print program
#[derive(StructOpt)]
struct Opt {
    /// message to print
    #[structopt(short, long, default_value = "Hello")]
    message: String,
    /// number of repetitions
    num: usize,
}

fn main() {
    let opt = Opt::from_args();
    for _ in 0..opt.num {
        println!("{}", opt.message);
    }
}

コマンドラインオプションを Opt という構造体のメンバーにまとめて、#[derive(StrucOpt)] をその構造体に付けてやってコマンドラインからパーズできるようにしてやります。ヘルプに出す説明は、構造体やメンバーのdocコメントに書くとそれを使ってくれます。Opt::from_args()コマンドライン引数が解析されて、--helpとかのケースを自動でハンドリングしてくれて、欲しいパラメーターの値だけがもらえます。

実行してみます。

$ cargo run
error: The following required arguments were not provided:
    <num>

USAGE:
    structopt-test <num> --message <message>

For more information try --help

$ cargo run -- --help
structopt-test 0.1.0
Simple print program

USAGE:
    structopt [OPTIONS] <num>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -m, --message <message>    message to print [default: Hello]

ARGS:
    <num>    number of repetitions

$ cargo run -- 3
Hello
Hello
Hello

$ cargo run -- --message hoge 2
hoge
hoge

これだけでここまで至れり尽くせりやってくれます。使い方も直感的で覚えやすく、使う際に調べることも少なくて、実際とても便利です。

structoptはバックエンドとしてclapというライブラリを使っていますが、直接clapを使うのと比べてもだいぶ楽です。

実際にclapでも書いてみました。

use clap::{App, Arg};

fn main() {
    let matches = App::new("clap-test")
        .version("0.1.0")
        .about("Simple print program")
        .arg(
            Arg::with_name("message")
                .short("m")
                .long("message")
                .default_value("Hello")
                .help("message to print"),
        )
        .arg(
            Arg::with_name("num")
                .required(true)
                .help("number of repetitions"),
        )
        .get_matches();

    let message = matches.value_of("message").unwrap();
    let num = matches.value_of("num").unwrap().parse::<usize>().unwrap();

    for _ in 0..num {
        println!("{}", message);
    }
}

ごらんの通りそれなりにめんどくさいです(しかもこのコードではnumに数じゃないものが渡されたときのメッセージが不親切極まりないです)。

clap自体は大変強力で多機能なライブラリですが、マクロを駆使しているようなタイプのライブラリではなくて、普通の(?)オブジェクトを組み立てていくタイプのものです(ある程度簡潔に書けるようにするためのマクロは用意されていたりしますが)。なので、Rustのstructの構造を生かして直感的にこれらの機能をコマンドライン引数の記述にマッピングしているstructoptと比べるとどうしても直感的な読み書きのしやすさで及ばない部分があります。まあ、そうでなければstructoptの存在意義がありませんが。

さて、コマンドラインはオワコンだとといわれ続けて数十年、むしろ昨今のCUIのプログラムはどんどんリッチに使いやすくなってきているような気さえします。その一因として、サブコマンドが充実したプログラムが増えてきていることがあるように思います。むしろ今どきのCUIツールは、サブコマンドなしには成り立たないといっても過言ではないかもしれません。

先ほどのツールもメッセージ表示だけではなんなので、認証機能(何の?)を新たに追加したくなってきました。

サブコマンドを追加するために、まずメッセージ表示コマンドをprintという関数に括りだします。

/// Printing command
#[derive(StructOpt)]
struct PrintOpt {
    /// message to print
    #[structopt(short, long, default_value = "Hello")]
    message: String,
    /// number of repetitions
    num: usize,
}

fn print(opt: PrintOpt) {
    for _ in 0..opt.num {
        println!("{}", opt.message);
    }
}

次に、認証コマンドを実装します(実際には何もしませんが)。

/// Authentication command
#[derive(StructOpt)]
struct AuthOpt {
    user: String,
    password: String,
}

fn auth(opt: AuthOpt) {
    println!("Access denied");
}

最後に、コマンドラインを解析して然るべき関数をディスパッチするコードを書きます。

/// SUGOI program
#[derive(StructOpt)]
enum Opt {
    Print(PrintOpt),
    Auth(AuthOpt),
}

fn main() {
    match Opt::from_args() {
        Opt::Print(opt) => print(opt),
        Opt::Auth(opt) => auth(opt),
    }
}

enumにサブコマンド一覧をまとめて、main関数でパターンマッチして引数を取り出します。各サブコマンドの引数をstructにまとめずに、enumにインラインに書くこともできますが、各コマンドに依存する内容がmainの近くに集まってしまうので、各サブコマンドにオプションを追加したり変更したりしたくなった時に、コードを変更する箇所が増えてしまうので、あまりすっきりしないのではないかと思います。パターンマッチの部分でも要素を全部ばらさないといけなくなるので、structにまとめる場合に比べて単に面倒くさくなる気がします。

では、このプログラムを実行してみます。

$ cargo run
SUGOI program

USAGE:
    structopt <SUBCOMMAND>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

SUBCOMMANDS:
    auth     Authentication command
    help     Prints this message or the help of the given subcommand(s)
    print    Printing command

$ cargo run -- print 1
Hello

$ cargo run -- auth user p4ssw0rd
Access denied

ちゃんと2つのサブコマンドができました。

この作業は十分シンプルですし作業量も少ない気もしますが、それでもボイラープレートというか、機械的なコードの繰り返しが気になります。このプログラムに、もう一つ別のサブコマンドを追加することを考えてみると、

  • 新しいコマンドに対するオプションをstructとして定義する
  • それを引数に取る関数を定義する
  • mainのenumに新しいコマンドのstructに対するコンストラクタを追加する
  • ディスパッチャにコードを追加する

このようなステップを踏むことになります。特に後者の2つが、どんなコマンドを追加するのかに依らずに、単に同じ作業を行わなければならないのでなんとなく嫌な面倒臭さを感じてしまいます。

もう少し立ち返って考えてみると、そもそもの話structの構造を用いて直感的な記法でclapのコードを自動的に生成させるというのがstructoptのコンセプトだと思うのですが、最終的にやりたいことはコマンドラインオプションの解析なのであって、別にstructやenumを定義したいわけでもないのです。

もっと別のアプローチがあってもいいんじゃないのか?そう考えてコードをこねこねいじってたらなんかそれっぽい形のものができた、・・・というのが今回のライブラリになります。まだそんなにしっくり来ていない部分とかいまいちスッキリしてない部分があったりもするので、もっとこうするといいよといったようなご意見がございましたらぜひともお知らせください。

イデア

皆さんは何かまとまった機能を持つコードを書くとき、どうしますか?どうしますか、といってもなんですけど、まあ普通は関数を書くと思います。関数の挙動をパラメーターによって制御したい場合、普通はどうするでしょうか?引数で値を渡してやるはずです。要するに何らかのコマンドラインプログラムってものは、大抵は自然に関数として表現できるものになるはずなのです。今更言うまでもないことかもしれませんが、関数というのはRustにおいて、というより現代のほとんどすべてのプログラミング言語において最も一般的で扱いやすい、誰もが当たり前のように使っているコードの抽象化手段というわけです。

例えば、先ほどのメッセージ表示プログラム print の場合だと、とてつもなく自明に

fn print(message: String, num: usize) {
    for _ in 0..num {
        println!("{}", message);
    }
}

こういう関数として記述できます。じゃあこの関数がすでにコマンドラインプログラムとして必要な情報を含んでいるんじゃないのか、この関数を直接的にコマンドラインプログラムへと変換することが可能なんじゃないのか?というのが本ライブラリのメインのアイデアです。

簡単な例

まず簡単な例から始めます。先ほどのprint関数をコマンドラインプログラムに変換します。

#[argopt::cmd] を関数に付けると、関数がコマンドラインプログラム化されます。

#[argopt::cmd]
fn print(message: String, num: usize) {
    for _ in 0..num {
        println!("{}", message);
    }
}

fn main() { print() }

それを main から呼び出してやれば完成です。あるいは、コマンド自体をmainにしてもいいでしょう。

#[argopt::cmd]
fn main(message: String, num: usize) {
    for _ in 0..num {
        println!("{}", message);
    }
}

実行してみます。

$ cargo run
error: The following required arguments were not provided:
    <message>
    <num>

USAGE:
    argopt-test <message> <num>

For more information try --help

$ cargo run -- hoge 3
hoge
hoge
hoge

デフォルトでは関数の引数は単にプログラムの引数になるので、実行するには二つとも引数を渡してやる必要があります。先ほどのプログラムと同じように、メッセージをオプションにして、指定しないときはデフォルトで"Hello"になるようにしてみます。引数の挙動を変えるには、#[opt(...)] を指定したい引数に付けてやります。ここで指定できるオプションはstructoptのものと大体同じです。

#[argopt::cmd]
fn main(
    #[opt(short, long, default_value = "Hello")]
    message: String,
    num: usize,
) {
    for _ in 0..num {
        println!("{}", message);
    }
}

パラメーターとかコマンドの説明がなくて寂しいので、ヘルプを追加してやります。

/// Printing command
#[argopt::cmd]
fn main(
    /// message to print
    #[opt(short, long, default_value = "Hello")]
    message: String,
    /// number of repetitions
    num: usize,
) {
    for _ in 0..num {
        println!("{}", message);
    }
}

これでstructoptを使ったものと同じ挙動のものができました。

$ cargo run -- --help
argopt-test 0.1.0
Printing command

USAGE:
    argopt-test [OPTIONS] <num>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -m, --message <message>    message to print [default: Hello]

ARGS:
    <num>    number of repetitions

$ cargo run -- 2
Hello
Hello

手順をまとめると、

  • コマンドにしたい処理を関数として書く
  • その関数にコマンドラインプログラム化する属性 #[cmd] をつける
  • 引数に属性を追加してして望みの挙動に変える
  • ヘルプをドキュメントとして書く

という風に、単なる関数からインクリメンタルにコマンドラインプログラムが構築できるようになっています。とりあえず今書いてるプログラムをコマンドラインから引数を受け取れるように変更したいなあ、という場面は少なくないと思うのですが、そんな時にあのライブラリの使い方ってどうだったっけ?という心理的なバリアなしに、単に一個属性をつけるだけでとりあえずのものが完成します。引数の細かい挙動を変更したくなったら、そこへちょっとずつアノテーションを足していけばフルスペックのコマンドラインプログラムが完成するというわけです。

サブコマンドの例

サブコマンドも同様に直接的に定義できるようにしています。要するにサブコマンドを持つプログラムとは、ただの複数の関数を含むプログラムです。

まずは先ほどと同様にprint関数を定義します。サブコマンドの場合は属性として #[argopt::cmd] ではなく #[argopt::subcmd] を付けます。

/// Printing command
#[argopt::subcmd]
fn print(
    /// message to print
    #[opt(short, long, default_value = "Hello")]
    message: String,
    /// number of repetitions
    num: usize,
) {
    for _ in 0..num {
        println!("{}", message);
    }
}

これまた同様に、auth 関数を定義します。

#[argopt::subcmd]
fn auth(user: String, password: String) {
    println!("Access denied");
}

最後に、これらのコマンドをディスパッチするコードを書きます。#[argopt::cmd_group()] に、定義したサブコマンドの一覧を渡してやります。

#[argopt::cmd_group(commands = [print, auth])]
fn main() {}

これで完成です。実行してみます。

$ cargo run
argopt-test 0.1.0

USAGE:
    simple <SUBCOMMAND>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

SUBCOMMANDS:
    auth     Authentication command
    help     Prints this message or the help of the given subcommand(s)
    print    Printing command

$ cargo run -- print --message hoge 3
hoge
hoge
hoge

$ cargo run -- auth foo bar
Access denied

というわけでサブコマンドを持つプログラムが完成しました。ここに新しいサブコマンドを追加したい場合は、

  • 新しいコマンドの処理を関数として記述する
  • ディスパッチャーにそのコマンドを追加する

の2ステップを行うことになります。本質的にはこの2つの工程、新しいコマンドのための処理を書くことと、そういうコマンドが追加されたことをディスパッチャーに知らせることは、サブコマンドを追加することにおいては不可欠なことだと思いますし、それを可能な限り退屈なコードを書かせることなしにやれていると思います。

その他の機能

コマンドラインプログラムを書く上で地味にめんどくさいverboseフラグの実装とかをやる機能をつけたりしました。

use argopt::cmd;
use log::*;

#[cmd(verbose)]
fn main() {
    error!("This is error");
    warn!("This is warn");
    info!("This is info");
    debug!("This is debug");
    trace!("This is trace");
}

これを実行すると次のような感じになります。

$ cargo run
This is error

$ cargo run -- -v
This is error
This is warn

$ cargo run -- -vv
This is error
This is warn
This is info

$ cargo run -- -vvv
This is error
This is warn
This is info
This is debug

$ cargo run -- -vvvv
This is error
This is warn
This is info
This is debug
This is trace

#[cmd()]verboseを渡すとlogクレートを使ってverbosityを自動で設定したうえで、適当なロガーを作ります。デフォルトではただ標準出力に出すロガーです。logクレートに対応したロガーはenv_loggerとかデファクトなものはいろいろありますが、装飾が多くて人間が操作するコマンドライン向けというよりはサーバー向けな印象で、ただverbosityを引数からもらって表示する情報の量を増やしたり減らしたりしたい用途には冗長かなと思ったので、プレーンなロガーで出すようにしています。

verboseフラグを自分で作って、オプションから取り出して各関数に取り廻してとかやっていたこともありましたが、まずめんどくさすぎるという問題がありました。いろいろそれ用の便利に使えそうなクレートを探したりもしましたが、結局何を使ったらいいんだというのは案外難しく、個人的にこういうのでいいんだよ的なものを機能としてつけてみました。個人的な感想なので、こういうのではだめなんだよってこともあるかもしれません。

今後の予定など

このライブラリは作りたてで、まだまだ機能が少ないですが、コンセプトとしてはそれなりに提示できたのではないかと思います。自分で使ってみて気に入らないところとか足りない機能とかをぼちぼち実装していく予定です。興味を持って下さった方がいらっしゃいましたら、ぜひ使ってみて感想などをいただけると大変うれしいです。