Rust 実践講座 #1 プロジェクト設計と clap — サブコマンド CLI の骨組みを立てる
基礎講座 9 回で、所有権からライフタイムまで文法の背骨を立てました。この実践講座は、その文法で実際のツールを 1 つ、最初から最後まで作る 8 回です。設計と引数パースから始めて、エラー設計、ファイル IO、イテレータでの集計、並列化、テストを経て、リリースビルドと配布まで。「動くコード」ではなく「人にインストールを勧められるツール」がゴールです。
何を作るのか — アクセスログ分析ツール loglens #
作るのは、Web サーバーのアクセスログを分析する CLI ツールです。名前は loglens とします。
loglens stats access.log # リクエスト数、ステータスコード分布
loglens top access.log --count 10 # リクエストの多いパス上位 10 件
loglens errors access.log # 5xx の行だけを抽出題材にログ分析を選んだのには理由があります。数百万行のファイルを扱うので、イテレータと並列化の性能の話が飾りではなく本題になりますし、入力がいつも汚れている(壊れた行、想定外の形式)ので、エラー処理がそのまま実戦になります。そして CLI ツールは Rust の代表的な舞台です。ripgrep や uv が示したように、ランタイムのインストールが要らない単一バイナリとミリ秒単位の起動速度は、CLI で最も輝く強みです。
プロジェクト作成と clap の導入 #
cargo new loglens
cd loglens
cargo add clap --features derive引数パースは標準ライブラリだけでも可能ですが、実務の事実上の標準は clap です。--help の自動生成、型変換、タイプミスの指摘まで含めて、引数パースに使う時間をツールの本題に返してくれます。--features derive は、構造体の宣言で CLI を定義する方式を有効にするスイッチです。
インターフェースを型として宣言する #
clap の derive 方式では、CLI のインターフェースはそのまま型定義です。
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about = "アクセスログ分析ツール")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// リクエスト数とステータスコード分布を要約する
Stats { file: PathBuf },
/// リクエストの多いパス上位 N 件を表示する
Top {
file: PathBuf,
#[arg(long, default_value_t = 10)]
count: usize,
},
/// サーバーエラー(5xx)の行だけを抽出する
Errors { file: PathBuf },
}基礎第 5 回で「列挙型はいくつかの姿のうちの 1 つを表す」と言いましたが、サブコマンドはまさにその構造です。ユーザーは stats、top、errors のどれかを選び、それぞれの選択肢は異なる引数を持ちます。clap はこの列挙型の宣言からパーサーとヘルプを丸ごと作り出します。/// のドキュメントコメントがそのまま --help の説明文になる点も注目です。
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Stats { file } => {
println!("stats: {}", file.display());
todo!("第 3 回で実装");
}
Commands::Top { file, count } => {
println!("top {count}: {}", file.display());
todo!("第 4 回で実装");
}
Commands::Errors { file } => {
println!("errors: {}", file.display());
todo!("第 3 回で実装");
}
}
}Cli::parse() の 1 行が引数パースのすべてです。不正な引数は clap がエラーメッセージと終了コード 2 で処理してくれますし、match は基礎第 5 回の規則どおり 3 つのサブコマンドの処理をすべて強制します。後でサブコマンドを追加すれば、この match がコンパイルエラーで「処理していない箇所」を教えてくれるはずです。todo!() は「型は合っているが実装はまだ」を宣言するマクロで、骨組みを先に立ててから肉付けするこのシリーズの進め方と正確に噛み合います。
実行してみる #
$ cargo run -- --help
アクセスログ分析ツール
Usage: loglens <COMMAND>
Commands:
stats リクエスト数とステータスコード分布を要約する
top リクエストの多いパス上位 N 件を表示する
errors サーバーエラー(5xx)の行だけを抽出するcargo run -- の後ろの引数がプログラムに渡ります。宣言しか書いていないのに、ヘルプ、バージョン表示(--version)、引数の検証まで全部動きます。loglens top access.log --count 5 のようなオプションもパースされ、count はすでに usize 型です。文字列を数値に変換するコードは 1 行も書いていません。
8 回の地図 #
- #1 設計と clap ← 今回
- #2 エラー設計 — anyhow と thiserror、死に方を設計する
- #3 ファイル IO とパース — BufReader、ログ 1 行を構造体へ
- #4 イテレータ実践 — 集計、top-N、リリースビルドの差
- #5 rayon 並列化 — データ競合のない map-reduce
- #6 テスト — パーサーの単体テストから CLI の統合テストまで
- #7 リリース最適化とクロスコンパイル
- #8 配布 — crates.io と GitHub Releases の自動化
まとめ #
- 実践の題材はアクセスログ分析 CLI です。巨大な入力と汚れた入力という性質が、性能とエラー処理を学ぶ実戦の舞台になります。
- CLI のインターフェースは clap derive で型宣言になります。サブコマンドは列挙型で、
matchが処理漏れをコンパイル時に防ぎます。 ///のドキュメントコメントがそのまま--helpの文面です。宣言 1 つでパース、検証、ヘルプ、バージョン表示まで手に入ります。todo!()で骨組みを先に立てました。型の合った骨組みは、以降の回で安心して肉付けできる土台です。- 次回はエラー設計です。ファイルがないとき、形式が違うとき、このツールがどう死んでどう伝えるかを anyhow と thiserror で整理します。