2020-02-17追記
現在はRust CLI WGからメンテされたbookが作成されているので、次のURLがおすすめです。
https://rust-cli.github.io/book/index.html
はじめに
もうすぐお休みですね!
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.
だったのでその一助になればと。
前提
- URLエンコードの仕様はWHATWG URL Standard
- 対象OSはmacOS、Ubuntu、Windows
- rustc 1.31以上
- Edition 2018
- Rustの文法や基本機能については公式ドキュメント(日本語版)を参照ください。
- サンプルコード
準備
何はともかく、まずはインストール。
次のリンクを参照してインストールしましょう。
すでにインストール済みの方も、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クレートを使えば簡単に実現できます。
[dependencies]
url = "1.7"
デコードの処理もまずは固定した文字列を引数として渡します。
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 // ⇦これ
大丈夫そうですね。
あとは引数を受け取れるようにすればドメイン部分の実装は完了です。
...
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
の結果を目視で確認する方法はつらいのでテストを書きます。
テストを書く前には、テストコードを書きやすくするリファクタリングをします。
まずは関数に分離します。
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メソッドのテストを同じファイルに追加します。
...
#[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となるテストも追加します。
...
#[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
だけで十分です。
[dependencies]
...
failure = "0.1"
unwrapメソッドの代わりに?
演算子を使い、戻り値の型もResult型に変更します。
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ライブラリでも使われてました。
use failure::Error;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
...
fn decode(input: &str) -> Result<String> {
...
テストも合わせて修正し、実行します。
以下のコードは一部だけですが、他の部分も同じように修正します。また以降のテスト実行結果は省略します。
...
#[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
の基本的な機能は利用できます。
[dependencies]
...
structopt = "0.2"
std::env::args
の部分をstructopt
を使ったものに書き換えます。
使い方はstructopt
のattributeを持つstruct
を定義し、from_args
メソッドを呼び出すだけです。
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>に変更し、引数がなければ標準入力を参照するよう修正します。
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
クレートを利用します。
また引数にハイフンが入力された場合も、標準入力から読み込むように合わせて変更します。
[dependencies]
...
atty = "0.2"
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
クレートを使ってみます。
[dependencies]
...
log = "0.4"
pretty_env_logger = "0.2"
使い方は簡単でロガーの初期化処理をするメソッドを呼び出します。
あとはレベルごとに用意されているinfo!
などのマクロを使うだけです。
ログの出力先はデフォルト設定では標準エラー出力になっています。
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
を参考にしています。
# 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とWindowsの環境でビルドする
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です。
「そもそもその設定が面倒」ってことであれば、Alfred
とHain
であれば、私が作った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で簡単に書けるようになってハッピー」でした。
確かにサーベイにあるとおり、Lifetimes
やOwnership
など難しいトピックはあります(私も十分に理解できていない)。
それでも実用的なツールは作れるので、気負わずにまずはRustの小さなプログラムを書いてみてはどうでしょうか。
最初はcomplierに怒られ、unwrap
やexpect
、clone
などの関数をたくさん使うかもしれません。
効率も良くないし、きれいにfunction、struct、そしてmoduleをうまく分割できないかもしれません。
でもCLIのような小さなプログラムを作れば、環境も便利になり、Rustのことも少しずつ理解が深まるはずです。
解決できない問題があってもドキュメントは十分に用意されており、何より素晴らしいコミュニティがRustにはあります。
Rustの良い点はなんですか。と聞かれたら、間違いなくコミュニティ運営が上がると私は思っています。
Slackなどからコミュニティに参加すればきっとRustを書くことがもっと楽しくなるはずです😃
https://rust-jp.slack.com/
こちらのURLから参加できます