Rustのエラー処理:本番環境におけるanyhowとthiserrorの活用ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

本番環境における「Result」の悩み

私が初めてRustに移行したとき、Result<T, E>型はまるでスーパーパワーのように感じられました。try-catchして祈る」ようなロジックとは対照的に、失敗のケースに即座に向き合うことを強制されたからです。しかし、プロジェクトが単純なスクリプトから50以上のモジュールを持つマイクロサービスへと成長するにつれ、壁にぶつかりました。関数のシグネチャはネストされたジェネリクスの山となり、実際に機能を実装するよりも、30行にも及ぶFrom<OtherError>の実装を書くことに時間を費やすようになってしまったのです。

本番環境において、煩雑なエラー処理は単に見栄えが悪いだけではありません。エラーを適切に構造化しないと、最終的にコンパイラを黙らせるためだけに.unwrap()を使ってしまうという罠に陥ります。これでは「安全な」はずのRustバイナリが、クラッシュしやすい負債に変わってしまいます。これを避けるために、私は業界標準となっている2つのクレート、thiserroranyhowを活用しています。これらはコードをクリーンで分かりやすく保つだけでなく、何より午前2時に本番環境のポッドがダウンした際のデバッグを容易にしてくれます。

ツールの選択:thiserror vs. anyhow

Rustの初心者はよく「どちらのクレートが優れているか」と尋ねますが、実際にはそれぞれ役割が異なります。どちらもDavid Tolnay氏によってメンテナンスされていますが、エラー処理の異なる側面を解決します。

thiserror:コントラクトの定義

私はライブラリや内部のドメインモジュールを構築する際にthiserrorを使用します。これはErrorトレイトを自動生成する手続き型マクロです。呼び出し側が特定のケースに対応できるよう、**何が**起きたのかを正確に定義する必要がある場合に使用します。これは、明確で型付けされたAPIコントラクトを作成するためのものです。

anyhow:フローの制御

一方、anyhowはアプリケーションレベル(main.rsやCLIのエントリポイント、高レベルのリクエストハンドラなど)のために取っておきます。これは特定のエラー型を気にする必要がなく、単にエラーを報告し、コンテキストを追加したい場合に適しています。失敗した後の**処理**を行うための究極のツールです。

実践:堅牢なデータパイプラインの構築

よくあるシナリオを見てみましょう。JSON設定ファイルを読み込み、データベースに接続し、ユーザーレコードを処理するサービスが必要です。I/O、パース、ネットワークといった各ステップで、それぞれ特有の失敗が発生する可能性があります。

ステップ1:設定

まず、Cargo.tomlを以下の依存関係で更新します。

[dependencies]
thiserror = "1.0"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

ステップ2:ドメインエラーの定義

汎用的な文字列を使う代わりに、データモジュール専用の構造化されたenumを定義します。これにより、他の開発者はどのような失敗モードが予想されるかを正確に把握できます。

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataError {
    #[error("設定ファイルが見つかりません: {0}")]
    NotFound(String),

    #[error("設定ファイルのJSON形式が不正です")]
    InvalidFormat(#[from] serde_json::Error),

    #[error("30秒後にデータベース接続がタイムアウトしました: {0}")]
    ConnectionError(String),

    #[error("内部データの破損が検出されました")]
    Unknown,
}

#[from]属性は大幅な時短になります。これによりFrom<serde_json::Error>の実装が自動生成されます。その結果、?演算子を使ってJSONエラーをカスタムのDataErrorに自動変換できるようになります。これで15行のボイラープレートを削減できました。

ステップ3:anyhowでコンテキストを追加

次にロジックを実装しましょう。ここではanyhowを使用します。エラーに「コンテキスト(文脈)」を付加できるからです。これは、単に「ファイルが見つかりません」と表示されるログと、どのファイルが見つからず、なぜアプリがそれを読み込もうとしていたのかを正確に伝えるログとの違いを生みます。

use anyhow::{Context, Result};
use std::fs;

fn load_config(path: &str) -> Result<String> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("致命的な失敗: {} から設定を読み込めませんでした", path))?;
    
    Ok(content)
}

fn process_data() -> Result<()> {
    let config = load_config("settings.json")?;
    // 以降にロジックが続く...
    Ok(())
}

もしload_configが失敗した場合、anyhowは詳細なエラーチェーンを生成します。例えば、*「致命的な失敗: settings.json から設定を読み込めませんでした: No such file or directory (os error 2)」*といった具合です。このレベルの詳細は、分散システムのデバッグにおいて命綱となります。

ネストされたエラーの管理

よくある間違いは、結果をラップする際に元のエラー原因を失ってしまうことです。2つのクレートを組み合わせることで、スタックトレース全体を保持できます。マイクロサービスにおいて最も苛立たしいバグは、下流の依存関係での単純なタイムアウトを隠蔽してしまう「Internal Server Error」メッセージです。

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("ダウンストリームサービスの失敗")]
    External(#[source] anyhow::Error),

    #[error("ユーザー入力のバリデーションに失敗しました: {0}")]
    Validation(String),
}

fn call_third_party_api() -> anyhow::Result<()> {
    Err(anyhow::anyhow!("ゲートウェイタイムアウト"))
}

fn handle_request() -> Result<(), ApiError> {
    call_third_party_api().map_err(ApiError::External)?;
    Ok(())
}

#[source]を使用することで、anyhow::Errorが根本原因であることをRustに伝えます。その後, anyhow.chain()メソッドを使用して、エラーの全レイヤーを反復処理し、どこで失敗が始まったのかを正確に特定できます。

データパイプラインの堅牢性を保つ

1,000件のレコードをバッチ処理する場合、1つの不正なエントリのためにプロセス全体を終了させたくはないでしょう。私はResultとイテレータを組み合わせて、成功と失敗を分離する方法を好みます。これにより、エラーをログに記録しつつ、パイプラインを動かし続けることができます。

let paths = vec!["prod.json", "staging.json", "broken.json"];

let (successes, failures): (Vec<_>, Vec<_>) = paths
    .into_iter()
    .map(|p| load_config(p))
    .partition(Result::is_ok);

let valid_configs: Vec<String> = successes.into_iter().map(Result::unwrap).collect();
let errors: Vec<anyhow::Error> = failures.into_iter().map(Result::unwrap_err).collect();

このパターンにより、ワーカースレッドがパニックを起こさないことが保証されます。有効なデータを処理し、5%の失敗についてはデッドレターキューやロギングサービスに送り、後で調査できるようにします。

最後に

thiserroranyhowの選択は、どちらが優れているかではなく、コンテキストの問題です。他の人がインポートするライブラリを書いているなら、明確で型付けされたエラーを提供するためにthiserrorを使いましょう。アプリケーションの「接着剤」となるコードを書いているなら、簡潔さを保ちログを情報豊かにするためにanyhowを使いましょう。

これらのツールを採用することで、Rustのコードベースは格段に読みやすくなります。エラーと借用チェッカーの関係に頭を悩ませるのをやめ、型システムを活用してより信頼性の高いソフトウェアを構築しましょう。今日からいくつかのmatchブロックanyhow::Contextに置き換えてみてください。次にバグが発生したとき、未来の自分に感謝することになるはずです。

Share: