Rust 実践講座 #4 イテレータ実践 — 集計と top-N、そしてリリースビルドの差

読了 4分

第 3 回で 1 行を LogEntry に変えたので、今度は数百万行を統計に変えます。基礎第 7 回で学んだイテレータチェーンと HashMap が実戦投入される回で、最後にはこのシリーズで初めてストップウォッチを取り出します。

stats — 失敗も数える集計 #

まず stats コマンドの要件を決めます。総行数、パース成功数、パース失敗数、そしてステータスコード分布。errors コマンド(第 3 回)と違って失敗を捨てずに数える必要があるので、集計用の構造体を 1 つ作り、行を流し込みます。

src/main.rs
use std::collections::HashMap;

#[derive(Default)]
struct Stats {
    total: u64,
    parsed: u64,
    failed: u64,
    by_status: HashMap<u16, u64>,
    bytes_sum: u64,
}

impl Stats {
    fn feed(&mut self, line: &str) {
        self.total += 1;
        match parse_line(line) {
            Ok(entry) => {
                self.parsed += 1;
                *self.by_status.entry(entry.status).or_insert(0) += 1;
                self.bytes_sum += entry.bytes;
            }
            Err(ParseError::Empty) => {} // 空行は失敗に数えない
            Err(_) => self.failed += 1,
        }
    }
}

第 2 回で「空行と形式エラーは別々に扱いたい」と言った要求が、ここで実現します。ParseError が列挙型なので、match で種類ごとの方針を宣言できます。空行は total にだけ入り、failed からは外れます。anyhow の不透明なエラーだったら、この分岐は文字列比較という最悪の方法しか残っていなかったはずです。#[derive(Default)] は、全フィールドを 0 と空のマップで初期化する Stats::default() を作ってくれる導出です。

コマンド本体は短いです。

src/main.rs
fn cmd_stats(path: &Path) -> anyhow::Result<()> {
    let reader = BufReader::new(open_log(path)?);
    let mut stats = Stats::default();
    for line in reader.lines() {
        stats.feed(&line?);
    }

    println!("総行数:     {}", stats.total);
    println!("パース成功: {}", stats.parsed);
    println!("パース失敗: {}", stats.failed);
    println!("転送合計:   {} bytes", stats.bytes_sum);

    let mut statuses: Vec<_> = stats.by_status.iter().collect();
    statuses.sort();
    for (status, count) in statuses {
        println!("  {status}: {count}");
    }
    Ok(())
}

出力直前の 2 行が実務のイディオムです。HashMap には順序がないので、人に見せるときは Vec に集めてソートします。実行するたびに順序が変わる出力はツールへの信頼を削りますし、第 6 回で作るテストも不安定にします。

top — ソートで取り出す上位 N #

src/main.rs
fn cmd_top(path: &Path, count: usize) -> anyhow::Result<()> {
    let reader = BufReader::new(open_log(path)?);

    let mut by_path: HashMap<String, u64> = HashMap::new();
    for line in reader.lines() {
        if let Ok(entry) = parse_line(&line?) {
            *by_path.entry(entry.path).or_insert(0) += 1;
        }
    }

    let mut ranked: Vec<_> = by_path.into_iter().collect();
    ranked.sort_by(|a, b| b.1.cmp(&a.1)); // リクエスト数の降順
    for (path, hits) in ranked.into_iter().take(count) {
        println!("{hits:>8}  {path}");
    }
    Ok(())
}

集計は stats と同じ entry のイディオムで、新しく登場したのは締めの 3 行です。into_iter() でマップの所有権ごと取り出して(基礎第 7 回の区別)Vec に集め、sort_by に降順の比較を渡し、take(count) で上位 N 件だけ切り取ります。clap がパースしてくれた --count の値が take にそのまま差し込まれることで、第 1 回の宣言と第 4 回のロジックが出会います。数百万パス程度ならソートで十分で、それより大きくなったらヒープ(BinaryHeap)に切り替える選択肢がある、というくらいを覚えておけば足ります。

ストップウォッチ — debug と release の距離 #

ツールが動いたので、速度を測ってみます。計測コードは標準ライブラリで十分です。

src/main.rs
let start = std::time::Instant::now();
// ... 集計を実行 ...
eprintln!("所要: {:?}", start.elapsed());

println! ではなく eprintln!(標準エラー出力)である点に注目してください。計測値は分析結果ではないので、loglens stats access.log > result.txt とリダイレクトする利用者のファイルに混ざってはいけません。標準出力はデータ、標準エラーは付随情報という分離も、CLI の古くからの慣例です。

数百万行のログで 2 つのビルドを比べると、差は歴然です。

実行
cargo run -- stats big.log            # debug ビルド
cargo run --release -- stats big.log  # release ビルド

環境によりますが、release が数倍から数十倍速いのが普通です。debug ビルドはコンパイル速度とデバッグの便宜のために最適化を切り、基礎第 2 回で見たオーバーフロー検査のような安全装置も有効にします。イテレータチェーンが手書きのループと同等になる(基礎第 7 回)のも、最適化の効いた release 基準の話です。だから規則は単純です。開発の反復は debug で、性能測定と配布は必ず release で。 debug ビルドを見て「Rust は遅い」と結論づけるのが、入門段階で最もよくある測定ミスです。

まとめ #

  • 集計構造体 Stats に行を流し込む構造です。ParseError が列挙型なので、空行と形式エラーの方針を match で宣言できます。
  • HashMap を出力する前は Vec に集めてソートします。実行ごとに順序が変わる出力は、ツールへの信頼とテストの安定性を損ないます。
  • top-N は into_iter → 降順 sort_bytake(count) が基本形です。clap の引数がロジックに直結します。
  • 計測値は eprintln! で出します。標準出力はデータ、標準エラーは付随情報という分離が、リダイレクトする利用者を守ります。
  • 性能測定と配布は必ず release ビルドです。debug との差は数倍から数十倍で、debug で測った数値は結論の根拠になりません。
  • 次回は並列化です。rayon で集計を複数コアに分け、所有権がデータ競合をどうコンパイル時に防ぐのかを確かめます。
X