Rust 実践講座 #2 エラー設計 — anyhow と thiserror、死に方を設計する

読了 5分

基礎第 6 回で、予期できる失敗は Result、起きてはならない状態は panic という境界を立てました。実践ではもう 1 層必要です。CLI ツールのエラーメッセージは、ユーザーが見る画面、つまりインターフェースの一部です。Error: No such file or directory (os error 2) と言って死ぬツールと、エラー: ログファイルを開けません: access.log と言って死ぬツールの差は、実装ではなく設計から生まれます。今回はその設計を担う 2 つのクレート、anyhow と thiserror です。

問題 — エラー型がばらばらである #

loglens が出会う失敗はすでに複数あります。ファイルを開くのは std::io::Error、ステータスコードのパースは ParseIntError、後で追加する JSON ログは serde_json::Error。基礎第 6 回の ? で伝播するには、関数の戻り値の型がこれらを全部収められなければなりません。Box<dyn Error> がその答えでしたが、実務では文脈(context)を載せられる改良版を使います。

インストール
cargo add anyhow
src/main.rs
use anyhow::{Context, Result};

fn run(cli: Cli) -> Result<()> {
    // ...
    Ok(())
}

fn main() -> Result<()> {
    run(Cli::parse())
}

anyhow::Result<T>Result<T, anyhow::Error> の省略形で、anyhow::Error は「どんなエラーでも収める」型です。役割は Box<dyn Error> と同じですが、決定的な違いが context です。

src/main.rs
use std::fs::File;

fn open_log(path: &Path) -> Result<File> {
    File::open(path)
        .with_context(|| format!("ログファイルを開けません: {}", path.display()))
}

これでファイルがないときの死に方がこうなります。

出力例
Error: ログファイルを開けません: access.log

Caused by:
    No such file or directory (os error 2)

上の層の説明(「何をしようとして失敗したか」)と下の層の原因(「なぜ失敗したか」)が連鎖として出力されます。? が通る道筋ごとに context を載せれば、エラーメッセージがそのまま失敗地点までの道案内になります。クロージャ(|| format!(...))で受け取る理由は、成功経路で文字列を作らないためです。成功が大半のコードで失敗用の文字列を毎回組み立てる無駄を遅延評価で防ぐ、基礎第 7 回と同じ原理です。

thiserror — 種類で分岐すべきエラー #

anyhow のエラーは人間が読む用途です。コードがエラーの種類を見て分岐しなければならないなら、話が変わります。第 3 回で作るログパーサーを考えると、「空行」と「形式が違う行」と「ステータスコードが数値でない行」は別々に扱いたいはずです(空行は静かにスキップ、残りは数えて報告)。こうしたときに具体的なエラー型を作る道具が thiserror です。

インストール
cargo add thiserror
src/parser.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ParseError {
    #[error("空行")]
    Empty,
    #[error("フィールドが不足しています(必要 6、実際 {found})")]
    TooFewFields { found: usize },
    #[error("ステータスコードが数値ではありません: {0}")]
    BadStatus(#[from] std::num::ParseIntError),
}

基礎第 5 回の列挙型と第 8 回のトレイトがここで合流します。エラーの種類はバリアントとして並べ、#[error("...")]Display トレイトの実装を、#[derive(Error)] が標準の Error トレイトの実装を自動生成します。#[from] は、?ParseIntErrorParseError::BadStatus へ自動変換するようにする指示です。手で書けば数十行の定型文が、宣言数行で終わります。

判断基準 — 境界で分かれる #

2 つのクレートの役割分担は一文にまとまります。呼び出し側がエラーの種類で分岐する必要があるなら thiserror、人に見せて終わりなら anyhow。 loglens に当てはめるとこうなります。

  • パーサーモジュール(第 3 回): Result<LogEntry, ParseError> — 呼び出す側が「空行ならスキップ、それ以外はカウント」と分岐するので thiserror
  • main とコマンド処理(アプリケーション層): anyhow::Result<()> — どんな失敗も最終的にはメッセージを見せて終了するので anyhow

2 つの層は自然につながります。anyhow::Error は標準の Error トレイトを実装したあらゆる型を受け取るので、パーサーの ParseError? 1 つでアプリケーション層に吸収されます。

終了コード — スクリプトで使う人との約束 #

CLI ツールの最後のインターフェースは終了コードです。mainErr を返すと、Rust はエラーを出力して終了コード 1 で終わります。シェルスクリプトで loglens stats access.log || alert のようにつなぐ利用者のための基本の約束です。ちなみに clap が不正な引数で返す終了コードは 2 です。「実行はしたが失敗(1)」と「使い方が間違っている(2)」の区別は Unix ツールの古くからの慣例で、既定の動作だけですでに守られています。

まとめ #

  • CLI のエラーメッセージはインターフェースです。原因だけのメッセージではなく、「何をしようとして失敗したか」の文脈を持たせるのが設計目標です。
  • アプリケーション層は anyhow が既定値です。main() -> anyhow::Result<()>、そして ? の道筋の with_context がエラーの連鎖を作ります。
  • 呼び出し側が種類で分岐する層は thiserror で具体的なエラー列挙型を作ります。#[error] が Display を、#[from]? の変換を自動生成します。
  • 2 つの層は ? 1 つでつながります。パーサーの具体的なエラーがアプリケーションの anyhow に自然に吸収されます。
  • 次回はこのエラー型を実際に埋めます。BufReader でファイルを読み、ログ 1 行を構造体に変えるパーサーを作ります。
X