34日連続で雨だったそうで、そりゃー今年は涼しかったわけですね。その分千葉のあたりは大変なことになっておりますが。

Rustで真面目にWeb APIを作っていると、とても悩ましいのがエラーハンドリングだと思います。ちょっとやってみた内容を紹介します。

Result or panic

これについては、Javaとか他の言語のノリでpanicすると大変なことになるので、まず 原則panic禁止 くらいでいいかと思います。CLIならpanicしたほうがいいケースも多いと思いますが、Web APIでやったら多分ぶっ殺されます。 panic自体はhandlerがあったりはしますが、それに頼ったところでrewindとかそういったものもないので、SpringBootとかの黒魔術に慣れた人は、わりと気をつける必要があります(自分)。

anyhow/eyreで潰す or 潰さない

エラーハンドリングを語るときに外せない eyreまたは anyhow がまず出てくると思います。これは Box<dyn std::error::Error + Sync + Send + 'static> あたりを都度定義する・・・というところに対するカウンターだと理解してます。

これが効果を発揮するのは、 とりあえずResultにするけど、エラー定義がめんどくせぇ ってときだと思います。基本的に eyre! などで潰すことで、なんでも放り込めるエラーを作れます。これはプロトタイピングとか、 APIのトップレベル みたいな、もうハンドリングする必要がないところでは役立ちます。

が、これはTraitでは使えません。というか使うと困るのは多分自分です :angel:

pub trait Hoge {
    // うーん、失敗するんだろうけどどう失敗するかわかんないな。とりあえず潰しとこう
    fn foobar() -> eyre::Result<Huga>;
}

fn main() {
    let foo = Foo::new();

    foo.foobar().map_err(|e| {
        match e {
            // さて・・・?あれ、区別できないぞ
        }
    })
}

traitはinterfaceというか操作の契約=contractを表すので、エラーになるのであれば、きちんと定義しておくのがベターであるとは思います。今のところはこのように整理しました。

  • Trait境界ではeyre/anyhowを 使わない
  • eyre/anyhowはmainとかでは使ってよし
    • どうせpanicするレベルなので
  • DDDとかでドメイン層がある場合は・・・後述

みんなの味方、thiserror

thiserror は、おそらくエラーを定義する際にはほぼ必携のcrateです。手で書くとひっじょーにめんどくさいError traitの実装を肩代わりしてくれるのとともに、color_eyreを利用したときに役立つsource chainのハンドリングもしてくれます。

使い方はいいとして、じゃあ いつ 使うのか?というところです。個人的には、エラーの定義が必要だったら常に利用する、でいいとは思いますが、一個論点としては、 Errorのchainをするかどうか? があります。

#[derive(Debug, Error)]
pub enum HogeError {
    // Errorがないので、 **どのresultから上がってきたかはわからない**
    #[error("it is hoge: {0}")]
    Hoge(String),

    // source指定すると、chainがつながったことになる
    #[error(transparent)]
    Huga(#[source] #[from] HugaError)
}

すっごい雑ですが、こんな感じにすると、つながっていることがわかります。


#[derive(Debug, Error)]
enum Er {
    // sourceとして指定している
    #[error("this is E")]
    E(#[source] Box<dyn std::error::Error + Send + Sync + 'static>)
}


#[derive(Debug, Error)]
enum P {
    #[error("this is H")]
    H,
    // from指定で自動的にsource
    #[error("from Er")]
    E(#[from] Er)
}

fn er() -> Result<(), Er> {
    // h -> erの呼び出し + testを追加
    Err(Er::E(eyre!("test").into()))
}

fn h() -> eyre::Result<()> {
    println!("{:?}", P::H);
    // eyreのreporterを使うのでこうしておく
    // この場合、in h -> this is Eになります(ネストされたeyre!は同じ情報が入ってる状態)
    let e = eyre!(Er::E(eyre!("in h").into()));
    println!("{:?}", e);
    // この場合、from Er -> this is E -> testになります
    let e = eyre!(P::E(er().unwrap_err()));
    println!("{:?}", e);
    Ok(())
}

が、この場合 P::H は、たとえ何かしらのエラーが発生したとして、 どのエラーが起点になったのか がわかりません。 traitのエラーは前述の通り、何が起点になるのか・・・?はわかりません。ただ、 エラーの内容自体はぶっちゃけなんでもいい という特性もあります。

そこで、traitのエラーについてはこんな感じにします。

// 冗長すぎるので
type E = Box<dyn std::error::Error + Sync + Send + 'static>;

#[derive(thiserror::Error, Debug)]
enum TraitError {
    // traitの契約上発生しうるエラー。発生元のエラーはなんでもいいので潰す
    #[error("Got domain error")]
    DomainError(#[source] E),

    // 他はあるかもしれないけどわかんないので、無視するために追加する
    #[error(transparent)]
    Other(#[source] E)
}

多分 XxxOutCome とかのような形で、Ok/Errを一つのenumで表現する・・・ということもありだとは思いますが、ケースによってはfast returnが非常に多岐に発生したり、TryFromとの兼ね合いがあったりとすることと、 ドメインエラーも契約からしたらエラーだろう というところから、こっちのほうがいいかねぇ、と感じています。設定上結構ボイラープレートになりそうな雰囲気はしますが、Rustは HogeError::DomainError をそのまま map_err に突っ込めるので、まあめんどくさいですけど・・・というところかなと。

この形にしたときの欠点は、Fromの型が解決できなくなってしまうので、 #[from} を利用できなくなる、というのがあります。これが使えると ? のチェーンがキレイに書けるので悩ましいところですが。ただ、エラー本体に付加情報が必要な場合は、どっちにしろ利用できないので、そこはまあケースバイケースになるかなとは思います。

APIでのエラーはわかりやすく

HTTPレイヤーでのエラーは、もう全部潰してしまっていいかなと思います。

enum HogeApiError {
    NotFound,
    Conflict,
    Unknown
}

なぜかを考えると、

  • そもそもこのエラーが終着点なので、sourceで連結する必要がない
  • ログをだしたければ、ここに到着したerror + アルファでいい
  • HTTP responseとかを返すとき、 エラーの詳細が必要なケースはほぼない

ので、変換処理のめんどくささなどを考えると、これくらいにしつつ、axumだったら IntoResponse をこの型に実装してしまえば、unit testとかも自明として作成できますし、余計な依存とかも不要になります。

エラーの道は険しい

RustはResult/Optionのハンドリングが言語仕様側に組み込まれているものの、他の例外ベースだったり、OCaml/HaskellとかMonadベースのものともまた性質が異なっているのが特徴だと思います。個人的にはtrait境界指定がとにかくとにかくめんどくさすぎるので、もうちょっとなんとかならんか・・・と思いつつ書いてます。

asyncが絡まないんだったら楽なんですけどね、というところで今日はこんなところで。