Rust
cli
RustDay 19

Command Line Toolを作ってみる in Rust


はじめに

もうすぐお休みですね!

Rustに入門したばかりの人、興味があるけど放置していた人、この休みに簡単なCommand Line Tool(以後CLI)をRustで作ってみるのはどうでしょうか。

「まだチュートリアルも途中だし」、「理解できてない文法や用語も多いから」など思い腰が上がらないかもしれません。

でも簡単なツールであれば、必要な知識は多くはないのでこの機会にチャレンジしてみませんか?

ただ作ると言っても、モチベーションがないとやる気が出ないですし、業務で困っていることをCLIで解決しようと大げさになりがちですよね。

そこで普段は何気なくWebサイトを使って処理している小さなタスクをCLIにすることを考えてみます。

「今平成何年だっけ?」ってGoogleに問い合わせたり、WebサイトでJWTをデコード・エンコードしてみたり。

あと思い出してみればWindowsの時には、ついつい外部のWebサイトを利用して何かのデータをデコード・エンコードしてた気がします。

他にも簡単に実装できそうなものを並べてみました。


  • パスワードの生成

  • LGTMのURL生成

  • タイムゾーン変換(Publicクラウドのインシデント発生時間って海外のタイムゾーンが多いですよね)

  • カラーコードの何か

  • Global IP アドレスを調べる(ipifyなどを使って)

「それ、ワンライナーで実現できるよ」っていう声も聞こえてきそうです。

ですが、LL言語を使うワンライナーであれば、Runtime依存のないバイナリに変更することは良い改善手段だと思います。

何か作ってみようと思ったCLIはあったでしょうか?

この記事では簡単なものが思いつかなかった私のために、URLデコードをサンプルにして、CLIの作り方を学んでいきます。


わたしのモチベーション

Rust Survey 2018の結果にあった、Rust Non-Usersでもっとも多かった理由がI have'nt lerned Rust yet, but I want to.だったのでその一助になればと。


前提


準備

何はともかく、まずはインストール。

次のリンクを参照してインストールしましょう。

すでにインストール済みの方も、rustup updateで最新版に更新し、rustc 1.31以上になっていることを確認しておきましょう。

次にプロジェクトの作成です。とりあえずurlという名前で作ります。

$ cargo new url

Created binary (application) `url` project

作成されたファイルはこんな感じです。

$ cd url

$ tree .
.
├── Cargo.toml
└── src
└── main.rs

1 directory, 2 files

まずは深く考えずにドメインの肝となるurlデコードの処理をサクッと実装しちゃいましょう。

ポイント:細かいことは棚に放り投げる

urlデコードの処理はurlクレートを使えば簡単に実現できます。


Cargo.toml

[dependencies]

url = "1.7"

デコードの処理もまずは固定した文字列を引数として渡します。


src/main.rs

use url::percent_encoding::percent_decode;

fn main() {
let input = "foo%20bar";
let decoded = percent_decode(input.as_byte()).decode_utf8();
println!("{}", decoded.unwrap());
}


期待した内容が出力されるか確認します。

$ cargo run

Compiling url v0.1.0 (/Users/watawuwu/dev/src/github.com/watawuwu/url)
Finished dev [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/url`
foo bar // ⇦これ

大丈夫そうですね。

あとは引数を受け取れるようにすればドメイン部分の実装は完了です。


src/main.rs

...

fn main() {
let args: Vec<String> = std::env::args().collect();
let input = &args[1];

let decoded = percent_decode(input.as_bytes()).decode_utf8();
println!("{}", decoded.unwrap());
}


引数をつけて実行します。

$ cargo run "foo%20bar"

Compiling url v0.1.0 (/Users/watawuwu/dev/src/github.com/watawuwu/url)
Finished dev [unoptimized + debuginfo] target(s) in 0.49s
Running `target/debug/url 'foo%20bar'`
foo bar

期待通りの結果となりました。

ひとまずこれで最初の目標である、簡単なCLIをRustで作ることは達成できました。

簡単すぎましたか?それとも知らない単語や記号、関数が出てきてモヤモヤしてますか?

文法などは後々公式ドキュメントで学習する必要はありますが、ここでは雰囲気がつかめていれば大丈夫です。

それでは少しずつ改善していきましょう!


テストの追加

改善するにあたり、cargo runの結果を目視で確認する方法はつらいのでテストを書きます。

テストを書く前には、テストコードを書きやすくするリファクタリングをします。

まずは関数に分離します。


src/main.rs

use url::percent_encoding::percent_decode;

fn main() {
let args: Vec<String> = std::env::args().collect();
let input = &args[1];

println!("{}", decode(input));
}

fn decode(input: &str) -> String {
percent_decode(input.as_bytes()).decode_utf8().unwrap().to_string()
}


そしてdecodeメソッドのテストを同じファイルに追加します。


src/main.rs

...

#[cfg(test)]
mod tests {

use crate::decode;

#[test]
fn decode_space_ok() {
let expected = "foo bar";
let input = "foo%20bar";
let actual = decode(input);
assert_eq!(expected, actual);
}
}


cargo testの結果も大丈夫そうです。

$ cargo test

Compiling url v0.1.0 (/Users/watawuwu/dev/src/github.com/watawuwu/url)
Finished dev [unoptimized + debuginfo] target(s) in 0.54s
Running target/debug/deps/url-9245405ccfe4bb2b

running 1 test
test
tests::decode_space_ok ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

アスキー文字のテストと、不正なUTF-8となるテストも追加します。


src/main.rs

...

#[test]
fn decode_ascii_ok() {
let expected = r##" !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ `"##;
let input = r##"
%20%21%22%23%24%25%26%27%28%29%2A%2B%2C%2D%2E%2F0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ `"##;
let actual = decode(input);
assert_eq!(expected, actual);
}

#[test]
#[should_panic]
fn decode_invalid_utf8_ng() {
let input = "%93%FA%96%7B%8C%EA%0D%0A";
decode(input);
}
...


これで改善する準備は整いました!

$ cargo test

Compiling url v0.1.0 (/Users/watawuwu/dev/src/github.com/watawuwu/url)
Finished dev [unoptimized + debuginfo] target(s) in 0.55s
Running target/debug/deps/url-9245405ccfe4bb2b

running 3 tests
test tests::decode_ascii_ok ... ok
test tests::decode_space_ok ... ok
test tests::decode_invalid_utf8_ng ... ok


エラーハンドリングの改善

最初はエラーハンドリングの改善をします。

といっても今回のような小さなCLIの場合は、unwrapメソッドでも十分だと思います。

しかしライブラリを使えば導入も簡単なのと、後からエラーハンドリングを追加すると修正カ所も多くなるので最初の改善として導入しちゃいます。

公式ドキュメント通りエラーハンドリングを追加すると、ボイラープレート感があります。

そこで以下の記事でも説明されているfailureを使用します。

上記の記事では主に、ライブラリ提供者の時の実装について説明されていますが、今回のケースではfailure::Errorだけで十分です。


Cargo.toml

[dependencies]

...
failure = "0.1"

unwrapメソッドの代わりに?演算子を使い、戻り値の型もResult型に変更します。


src/main.rs

use failure::Error;

fn main() -> Result<(), Error> {
let args: Vec<String> = std::env::args().collect();
let input = &args[1];

Ok(println!("{}", decode(input)?))
}

fn decode(input: &str) -> Result<String, Error> {
let decoded = percent_decode(input.as_bytes()).decode_utf8()?;
Ok(decoded.to_string())
}
...


またResultのErrorに使う型は、ほぼfailure::Errorとなるのでエイリスにすると記述量が少なくなります。

このエイリアスはstdライブラリでも使われてました。


src/main.rs

use failure::Error;

pub type Result<T> = std::result::Result<T, Error>;

fn main() -> Result<()> {
...
fn decode(input: &str) -> Result<String> {
...


テストも合わせて修正し、実行します。

以下のコードは一部だけですが、他の部分も同じように修正します。また以降のテスト実行結果は省略します。


src/main.rs

...

#[test]
fn decode_space_ok() {
let expected = "foo bar";
let input = "foo%20bar";
let actual = decode(input).unwrap();
assert_eq!(expected, actual);
}
...

あれ!せっかくunwrapメソッドをなくしたのに、テストコードにunwrapメソッドがあるよ?と思ったかもしれません。

この書き方や主義主張はいろいろあるので、詳しくはググってみてください。とりあえず今のところ自分は使ってます。
もちろん`Result`や`Option`型同士でテストする方法も良いと思います。


引数処理の改善

次は引数の処理を改善していきます。

今のコードでは引数なしで実行するとpanicが発生します。

CLIだったら、引数やオプションのエラーが発生したら、へルプコマンドを表示したいですよね。

そこで引数の処理にstructoptクレートを導入しましょう。

引数の処理で有名なクレートにはclapがありますが、structoptはstructを使って宣言的に記述できるので便利です。

structoptも内部ではclapを使っているので、clapの基本的な機能は利用できます。


Cargo.toml

[dependencies]

...
structopt = "0.2"

std::env::argsの部分をstructoptを使ったものに書き換えます。

使い方はstructoptのattributeを持つstructを定義し、from_argsメソッドを呼び出すだけです。


src/main.rs

use structopt::StructOpt;

#[derive(StructOpt, Debug)]
struct Opt {
#[structopt(name = "INPUT")]
input: String,
}

fn main() -> Result<()> {
let opt = Opt::from_args();
Ok(println!("{}", decode(&opt.input)?))
}


引数ありで実行すると今まで同じ結果になり、引数なしで実行するとヘルプテキストが表示されます。

$ cargo run "foo%20bar"

Compiling url v0.1.0 (/Users/watawuwu/dev/src/github.com/watawuwu/url)
Finished dev [unoptimized + debuginfo] target(s) in 1.45s
Running `target/debug/url 'foo bar'`
foo bar

$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/url`
error: The following required arguments were not provided:
<INPUT>

USAGE:
url <INPUT>

For more information try --help

また--helpオプションを実行すれば全ての引数オプションのヘルプテキストが表示されます。

$ cargo run -- --help

Finished dev [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/url --help`
url 0.1.0
Wataru Matsui <...@...>

USAGE:
url <INPUT>

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

ARGS:
<INPUT>


パイプ処理

これで普通に使えるようになりましたが、パイプ処理もできないと不便ですよね。

以下のコマンドでURLデコードできるようにします。

$ echo 'foo%20bar' | url

Opt.inputの型をOption<String>に変更し、引数がなければ標準入力を参照するよう修正します。


src/main.rs

use std::io::{self, Read};

...

#[derive(StructOpt, Debug)]
struct Opt {
#[structopt(name = "INPUT")]
input: Option<String>,
}

fn read_from_stdin() -> Result<String> {
let mut buf = String::new();
let stdin = io::stdin();
let mut handle = stdin.lock();
handle.read_to_string(&mut buf)?;

Ok(buf)
}

fn main() -> Result<()> {
let opt = Opt::from_args();
let input = match opt.input {
Some(i) => i,
None => read_from_stdin()?
};
if input.is_empty() {
Opt::clap().get_matches().usage();
}

Ok(println!("{}", decode(&input)?))
}


これでパイプ処理ができるようになりました。

echo 'foo%20bar' | cargo run

Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/url`
foo bar

このままだと引数なしで実行をした際にエラーにならず、標準入力を待つ状態となります。

エラーにするためにはpipeからの入力かどうかを判定する必要があります。

この処理はプラットフォームに依存するためattyクレートを利用します。

また引数にハイフンが入力された場合も、標準入力から読み込むように合わせて変更します。


Cargo.toml

[dependencies]

...
atty = "0.2"


src/main.rs

use atty::Stream;

...

fn is_stdin(input: Option<&String>) -> bool {
let is_request = match input {
// 引数に "-" の場合も標準入力から読み込む
Some(i) if i == "-" => true,
_ => false,
};

// Terminalでなければ標準入力から読み込む
let is_pipe = ! atty::is(Stream::Stdin);

is_request || is_pipe
}

fn main() -> Result<()> {
let opt = Opt::from_args();

if opt.input.is_none() && ! is_stdin(opt.input.as_ref()) {
// 引数がなければヘルプ表示
Opt::clap().print_help()?;
// 終了コード1で終了
std::process::exit(1);
}
...
}



ログの追加

次はログを追加しましょう。

UNIXのルールに合わせてSilence is goldenを尊重しますが、オプションなどでログレベルが変更できれば便利です。

今回は簡単に使えて、色付きで出力してくれるpretty_env_loggerクレートを使ってみます。


Cargo.toml

[dependencies]

...
log = "0.4"
pretty_env_logger = "0.2"

使い方は簡単でロガーの初期化処理をするメソッドを呼び出します。

あとはレベルごとに用意されているinfo!などのマクロを使うだけです。

ログの出力先はデフォルト設定では標準エラー出力になっています。


src/main.rs

use log::*;

use pretty_env_logger;

...

fn main() -> Result<()> {
pretty_env_logger::init();
let opt = Opt::from_args();
debug!("opt: {:?}", opt)
...


ログレベルの変更は環境変数RUST_LOGで指定できます。

基本的な指定方法は次の通りですが、他にも指定方法が複数用意されています。


  • {module_name}={log_level}

$ RUST_LOG=url=debug cargo run 'foo%20bar'

Finished dev [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/url 'foo%20bar'`
DEBUG url > opt: Opt { input: Some("foo%20bar") }
foo bar


Makefileの追加

デバッグ用のログを追加したのは良いですが、環境変数を毎回指定するのはシンドイです。

環境変数をexportコマンドで設定する方法では、半年後の自分には伝わらないですし、ログインシェルに個々のCLIの開発設定を入れるのは避けたいものです。

デフォルトのログレベルをdebugに変更するのも一つの方法ですが、今回はMakefileでよく使うコマンドをタスク化します。

Makefileが嫌いあなたには、Rust製のタスクランナーがあるので検討してみてください。

今回はMakefileでタスクを設定します。

# Option

#===============================================================
LOG_LEVEL := debug
APP_ARGS := "foo%20bar"

# Environment
#===============================================================
export RUST_LOG=url=$(LOG_LEVEL)

# Task
#===============================================================
run:
cargo run $(APP_ARGS)

test:
cargo test

check:
cargo check $(OPTION)

次のように実行できるようになりました。

ちょっと楽になりましたね。

$ make run

cargo run "foo%20bar"
Finished dev [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/url 'foo%20bar'`
DEBUG url > opt: Opt { input: Some("foo%20bar") }
foo bar

$ make check
cargo check
Checking url v0.1.0 (/Users/watawuwu/dev/src/github.com/watawuwu/url)
Finished dev [unoptimized + debuginfo] target(s) in 0.41s

$ make test
cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running target/debug/deps/url-283bf9797f58f53a

running 3 tests
test tests::decode_ascii_ok ... ok
test tests::decode_invalid_utf8_ng ... ok
test tests::decode_space_ok ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out


CI(Continuous Integration)設定の追加

せっかく作ったCLIも、プラットフォームが異なるサーバーで実行するためには、Rust環境の用意とビルドが必要です。

そこでCIを設定し、プラットフォームごとのバイナリをGithubでホストします。

そうすればバイナリをダウンロードするだけでCLIを使えます(リンク問題はありますが)。

CIサービスはたくさんありますが、Windows、Linux、そしてmacOS、これらすべてのプラットフォームをサポートしているCIサービスは多くはありません。

上記の条件を満たすCIの中から今回はTravis CIを使ってみます。

今回のように業務のドメインコードが入らなければ、OSSにできるのでフリープランが利用できます。

もしメインの利用プラットフォームがWindowsの場合は、x86_64-pc-windows-msvcでビルドできるAppVeyorを使った方が良いかもしれません。

一度こちらの記事と合わせて検証してみるのが吉
http://mpu.seesaa.net/article/455908337.html

Travis CIで実行するため、ビルド環境の基盤構築は必要ありませんが、クロスプラットプラットフォームのビルドでは依存するパッケージがプラットフォームごとに異なるので結構ハマリます。

この依存パッケージを解決してくれるツールであるcrossを使って少ない設定でCIを実行します。

またTravis CIの設定にはtrustを参考にしています。


.travis.yml

# Based on the "trust" template v0.1.2

# https://github.com/japaric/trust/tree/v0.1.2

dist: trusty
language: rust
services: docker
rust:
- stable
sudo: required
env:
global:
# CLIの名前を定義
- NAME=url

# LinuxとmacOSとWindwosの環境でビルドする
matrix:
include:
- env: TARGET=x86_64-unknown-linux-musl
- env: TARGET=x86_64-apple-darwin
os: osx
- env: TARGET=x86_64-pc-windows-gnu

before_install:
# ビルドツールをインストールする前にRustのツールチェインを最新版に更新
- rustup self update

install:
# crossツールのインストール
- source ~/.cargo/env
- cargo install --force cross

script:
# cacheが有効になるようにリリースビルドでテストを実行
- cross test --target $TARGET --release

before_deploy:
# リリースビルドを実行
- cross build --target $TARGET --release
- bin=$NAME
# Windowsのみ拡張子のexeがバイナリネームに含まれる
- if [[ $TARGET = "x86_64-pc-windows-gnu" ]]; then bin=$NAME.exe; fi
# ソースディレクトリ直下に配布用のパッケージを作成
- tar czf $NAME-$TRAVIS_TAG-$TARGET.tar.gz -C target/$TARGET/release $bin

deploy:
api_key:
# `https://travis-ci.com`でアカウントを作成して、Travis CI のGithub Appを対象リポジトリにインストール
# `https://github.com/settings/tokens/new` でGithubのPersonalAccessTokenを生成
# gem install travis -v 1.8.9 --no-rdoc --no-ri
# travis login --com
# travis encrypt --com {PersonalAccessToken}
# (Github Appの権限でやってくれればいいのに・・・)
secure: "..."
file_glob: true
file: $NAME-$TRAVIS_TAG-$TARGET.*
on:
tags: true
provider: releases
skip_cleanup: true

cache: cargo
before_cache:
- chmod -R a+r $HOME/.cargo

branches:
only:
# for release tags
- /^v?\d+\.\d+\.\d+.*$/
- master


実はいつも使っているCIは別のサービスなのでTravis CIは初めてだったりします。

今年に入って仕様が変更となりhttps://travis-ci.orgで実施していたCIもhttps://travis-ci.comと統合するそうなのでcom側で設定を行いました。

ネット上には古い情報も多いので、公式ドキュメントを参照するのがおすすめです。

詳細についてはコメント部分に記載していますが、理解できない部分が多いようであればドキュメントが用意されているtrustの利用を検討してみてください。

上記の設定であればSemantic Versioningの規則でつけられた命名されたtagをプッシュすればCIが実行されます。

CIが成功すれば、GithubのReleaseページから各プラットフォームのバイナリをダウンロードできます。

これで自宅、仕事、サーバー上、どんなプラットフォームでも同じCLIが活躍できます!!


より活用するために

CLIなのでコンソールで使えれば十分ですが、さらに便利になる方法を紹介します。

せっかく作ったCLIですが、いざ使おうと思ったときにブラウザーを開いていると、コンソールを開くよりそのままWeb検索をしたくなりますよね。

そこでランチャーです。

ランチャーを使っていれば、そこから実行することで利用機会がぐっと増えるはずです。

(使ってないかたはスキップしてください……)

まずはCLIをインストールするタスクを追加します。

# Option

#===============================================================
...
PREFIX := $(HOME)/.cargo
...
install:
cargo install --force --root $(PREFIX) --path .

make installを実施すると$(PREFIX)/bin配下にインストールされます。

PREFIXは自分の環境にあわせて書き換えるか、実行時に動的に変更できます。

例えば私の場合、こちらのsystemd file-hierarchy specに合わせて自作したツールは$HOME/.local/binに置いているのでmake install PREFIX=$HOME/.localを実行することもあります。

これでどこからでも実行できるようになったので、ランチャーへ登録をすればOKです。

「そもそもその設定が面倒」ってことであれば、AlfredHainであれば、私が作ったcargoのサブコマンドでCLIを登録できます。

複雑な引数や設定、ファイルが必要になる場合には使えませんが、大体のCLIでは使えるようになるのではないでしょうか。

$ cargo install cargo-launcher

# for Alfred
$ cargo launcher alfred

# for Hain
$ cargo launcher hain

AlfredとHainは、実行結果を素早くプレビューし、実行結果をクリップボードに保存できます。


  • Alfredの場合(Alred自体は無料で利用できますが、workflowは別途ライセンスの購入が必要)


  • Hainの場合


次にどんなことをすれば

そろそろ息切れしてきました。

次に改善できることはなんでしょうか。

URLエンコードのドメインロジックを追加するのはどうでしょうか。そしたらきっとファイルを分離した方が良さそうですね。

まさに公式ドキュメントの「リファクタリングしてモジュール性の向上」に、そのあたりの手順が記載されています。

ドメインロジックの追加や改善の他に以下のような改善が考えられます。


  • README.mdやrust-docなどのドキュメントの追加

  • fmtとclippyの組み込み

  • ライセンスファイルの追加

  • ライブラリの分離


    • ファイル数が多くなればライブラリとバイナリでワークスペースを分けるのも良いアイデアです。



  • Rust API Guidelinesに沿ったプログラミング



  • crates.ioへの登録、公開

  • Code Coverageの追加

Rustをよく理解できていなくても、できそうなことがまだまだありそうです。


おわり

ドメインロジックはたったの数行でしたが、ライブラリをツールを利用することで実用的なCLIが作れたのではないでしょうか。

今回の動機は最初に書いたとおりですが、その他のメッセージとして「Edition2018で簡単に書けるようになってハッピー」でした。

確かにサーベイにあるとおり、LifetimesOwnershipなど難しいトピックはあります(私も十分に理解できていない)。

それでも実用的なツールは作れるので、気負わずにまずはRustの小さなプログラムを書いてみてはどうでしょうか。

最初はcomplierに怒られ、unwrapexpectcloneなどの関数をたくさん使うかもしれません。

効率も良くないし、きれいにfunction、struct、そしてmoduleをうまく分割できないかもしれません。

でもCLIのような小さなプログラムを作れば、環境も便利になり、Rustのことも少しずつ理解が深まるはずです。

解決できない問題があってもドキュメントは十分に用意されており、何より素晴らしいコミュニティがRustにはあります。

Rustの良い点はなんですか。と聞かれたら、間違いなくコミュニティ運営が上がると私は思っています。

Slackなどからコミュニティに参加すればきっとRustを書くことがもっと楽しくなるはずです😃

https://rust-jp.slack.com/