Rust 実践講座 #2 エラー設計 — anyhow と thiserror、死に方を設計する
基礎第 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 anyhowuse 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 です。
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 thiserroruse 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] は、? が ParseIntError を ParseError::BadStatus へ自動変換するようにする指示です。手で書けば数十行の定型文が、宣言数行で終わります。
判断基準 — 境界で分かれる #
2 つのクレートの役割分担は一文にまとまります。呼び出し側がエラーの種類で分岐する必要があるなら thiserror、人に見せて終わりなら anyhow。 loglens に当てはめるとこうなります。
- パーサーモジュール(第 3 回):
Result<LogEntry, ParseError>— 呼び出す側が「空行ならスキップ、それ以外はカウント」と分岐するので thiserror - main とコマンド処理(アプリケーション層):
anyhow::Result<()>— どんな失敗も最終的にはメッセージを見せて終了するので anyhow
2 つの層は自然につながります。anyhow::Error は標準の Error トレイトを実装したあらゆる型を受け取るので、パーサーの ParseError も ? 1 つでアプリケーション層に吸収されます。
終了コード — スクリプトで使う人との約束 #
CLI ツールの最後のインターフェースは終了コードです。main が Err を返すと、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 行を構造体に変えるパーサーを作ります。