Rust 実践講座 #3 ファイル IO とパース — BufReader で読み、ログ 1 行を構造体へ

読了 4分

骨組み(第 1 回)とエラー経路(第 2 回)が整ったので、ツールの心臓である「読んで解釈する」を作ります。題材は最も広く使われる nginx・Apache 系の combined ログ形式です。

access.log
203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "GET /api/users HTTP/1.1" 200 512

読み方の戦略 — 丸ごと載せるか、流すか #

ファイルの読み方は大きく 2 つです。

src/main.rs
// 戦略 1: 全体をメモリへ
let text = std::fs::read_to_string(path)?;

// 戦略 2: 行単位のストリーミング
use std::io::{BufRead, BufReader};
let reader = BufReader::new(File::open(path)?);
for line in reader.lines() {
    let line = line?;
    // 1 行ずつ処理
}

read_to_string は単純ですが、ファイルの大きさぶんメモリを使います。10GB のログなら 10GB です。BufReader は一定サイズのバッファだけを使って行単位に流すので、ファイルサイズと無関係にメモリが一定です。loglens の基本はストリーミングです(第 5 回の並列化で戦略 1 に戻る理由が生まれるので、そこでもう一度比較します)。

BufReader を挟む理由はシステムコールのコストです。File を直接読むと読み取り要求のたびに OS 呼び出しが発生し、このコストは行単位処理のように細かく読むパターンで致命的です。BufReader は一度に数 KB を先読みしてバッファに置き、行の要求はバッファから返します。ファイルを行単位で読むときに BufReader::new で包むのは、迷う必要のない基本です。

LogEntry — 1 行の行き先 #

src/parser.rs
#[derive(Debug, PartialEq)]
pub struct LogEntry {
    pub ip: String,
    pub method: String,
    pub path: String,
    pub status: u16,
    pub bytes: u64,
}

パーサーが作る構造体です。#[derive(Debug, PartialEq)] は基礎第 8 回の常備薬そのままで、PartialEq は第 6 回のテストで assert_eq! を使うための準備です。時刻フィールドは今回のシリーズの分析では使わないので、ひとまず省略します。必要になったら追加すればよく、そのときは各所の match がコンパイルエラーで修正箇所を教えてくれます。

パーサー — &str から LogEntry へ #

src/parser.rs
pub fn parse_line(line: &str) -> Result<LogEntry, ParseError> {
    if line.trim().is_empty() {
        return Err(ParseError::Empty);
    }

    // "GET /api/users HTTP/1.1" の部分を引用符で分離する
    let mut quoted = line.splitn(3, '"');
    let before = quoted.next().unwrap_or("");
    let request = quoted.next().ok_or(ParseError::TooFewFields { found: 0 })?;
    let after = quoted.next().ok_or(ParseError::TooFewFields { found: 0 })?;

    let ip = before
        .split_whitespace()
        .next()
        .ok_or(ParseError::TooFewFields { found: 0 })?;

    let mut req = request.split_whitespace();
    let method = req.next().ok_or(ParseError::TooFewFields { found: 1 })?;
    let path = req.next().ok_or(ParseError::TooFewFields { found: 2 })?;

    let mut tail = after.split_whitespace();
    let status: u16 = tail
        .next()
        .ok_or(ParseError::TooFewFields { found: 3 })?
        .parse()?; // ParseIntError → ParseError::BadStatus (#[from])
    let bytes: u64 = tail.next().and_then(|s| s.parse().ok()).unwrap_or(0);

    Ok(LogEntry {
        ip: ip.to_string(),
        method: method.to_string(),
        path: path.to_string(),
        status,
        bytes,
    })
}

短い関数ですが、基礎講座の回収地点がびっしり詰まっています。入力が &str(基礎第 4 回の慣例)、戻り値が Result<LogEntry, ParseError>(第 2 回の設計)、ok_orOptionResult に変える橋(基礎第 5〜6 回)、status.parse()??#[from] のおかげで ParseIntErrorParseError::BadStatus へ自動変換します(第 2 回)。引用符を基準に先に 3 分割する理由は、リクエスト行("GET /api ...")の中に空白があり、行全体の split_whitespace では安全に切れないからです。bytes フィールドが - の行(本文のない応答)は 0 として寛大に受け取ります。どこに厳格で、どこに寛大かをパーサーが決め、その決定がエラー型に文書化されるのがこの設計の核心です。

接続 — errors コマンドの完成 #

パーサーができたので、第 1 回の todo!() を 1 つ、実装に置き換えます。

src/main.rs
fn cmd_errors(path: &Path) -> anyhow::Result<()> {
    let reader = BufReader::new(open_log(path)?); // 第 2 回の context 付きオープン
    for line in reader.lines() {
        let line = line?;
        if let Ok(entry) = parse_line(&line) {
            if entry.status >= 500 {
                println!("{line}");
            }
        }
    }
    Ok(())
}

パースに失敗した行は静かに通り過ぎます(if let Ok)。5xx の抽出が目的のとき、壊れた行は関心の外だからです。一方、次回の stats は「何行読めなかったか」も報告する必要があるので、失敗を数えることになります。同じパーサーを使いながら、失敗の扱いがコマンドごとに違う。これが Result を返すパーサーの柔軟さです。

拡張 — JSON Lines ログも受け付ける #

最近はログを JSON Lines(1 行に JSON オブジェクト 1 つ)で残すサーバーも多いです。serde を付けると、この対応が驚くほど短く済みます。

インストール
cargo add serde --features derive
cargo add serde_json
src/parser.rs
#[derive(Debug, PartialEq, serde::Deserialize)]
pub struct LogEntry { /* フィールドは同じ */ }

pub fn parse_json_line(line: &str) -> Result<LogEntry, serde_json::Error> {
    serde_json::from_str(line)
}

derive の一覧に Deserialize を足すのが事実上すべてで、フィールド名と型の検証まで serde が行います。手書きのパーサーと自動導出のパーサーが同じ LogEntry を作り出すので、この後の集計コードはログの形式を知らなくて済みます。

まとめ #

  • 大きなファイルの基本は BufReader のストリーミングです。メモリがファイルサイズと無関係になり、バッファリングがシステムコールのコストを吸収します。
  • パーサーは &str を受け取り Result<LogEntry, ParseError> を返します。リクエスト行の空白のせいで、引用符での 3 分割が先です。
  • どこに厳格(ステータスコード)で、どこに寛大(bytes の -)かはパーサーの設計判断で、エラー型がその文書になります。
  • 同じパーサーでも失敗の扱いはコマンドごとに違います。errors はスキップ、stats はカウント。Result を返すことがその柔軟さの源です。
  • JSON Lines 対応は Deserialize の derive 1 行です。形式が違っても行き先(LogEntry)が同じなら、後続のコードはそのままです。
  • 次回はこのパーサーの上に集計を載せます。イテレータチェーンで stats と top を完成させ、リリースビルドが性能をどれだけ変えるか測ります。
X