Rust 実践講座 #1 プロジェクト設計と clap — サブコマンド CLI の骨組みを立てる

読了 5分

基礎講座 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 のインターフェースはそのまま型定義です。

src/main.rs
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 の説明文になる点も注目です。

src/main.rs
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 で整理します。
X