前回の記事でMixinを紹介したけれど、その後エラーハンドリングが全然分からず詰まったからメモ。
結論から言うとfailureのbookを読んだら全て解決した。
failure
自分の理解の確認も兼ねて、failureの説明を少し。
Fail
とError
failure::Fail
トレイトはfailureの中核をなす存在だ。
Fail
はstd::error::Error
を置き換えることを狙ったもので、トレイト境界がDisplay + Debug
からDisplay + Debug + Send + Sync + 'static
にパワーアップしている。このパワーアップによってスレッドを跨いだエラー伝達と、トレイトオブジェクトからのダウンキャストがそれぞれコンパイラによって保証されるようになる。
また、Fail
を実装したものは自動的にfailure::Error
へ一息に変換可能になる。もちろん?
で。
std::error::Error + Send + Sync + 'static
な型へのジェネリック実装があるため、おおよそのエラー型はFail
だ。
このため、どんなエラー型が来ようともとりあえずError
で早期リターンが可能になる。依存ライブラリが増えるたびFrom
を増やす必要はないのだ。
fn do_anything() -> Result<(), failure::Error> {
foo::bar()?;
piyo::piyo(hoge::fuga()?)?;
Ok(())
}
自作のエラー型にFail
を実装するのも簡単で、derive
が用意されている。
#[derive(Debug, Fail)]
pub enum MyError {
#[fail(display = "何もしていないのに壊れた")]
DidNothingButBroken,
#[fail(display = "猫")]
CatHanger(#[fail(cause)] CatError),
}
Error
でエラーが渡されてきてもダウンキャストしてハンドルを試みることができる。
match error.downcast::Piyo() {
Ok(Piyo::RecoverMe) => Ok(...)
Err(e) => Err(e)
}
別のエラーによってエラーが引き起こされる場合、cause()
で元となったエラーを取り出せるようにできる。cause
のチェーンをイテレータで辿ることも可能。
if let Some(caused) = fail.cause() { ... }
an Error and ErrorKind pairパターン
Patterns & Guidenceの章ではfailureを実際にプログラムに取り入れる際のパターンがいくつか分かりやすく紹介されている。
今回はその内の一つ、an Error and ErrorKind pairを使用する。
failureには、元のエラーをラップして別の説明を与える構造体Context<D>
が存在する。
Context<D>
は内部に元のError
を持つものの、Display
の実装ではそのエラーの代わりに、与えられたD
型の構造体/列挙型を用いる。中身が欲しいときはcause()
。
用途は分かりやすく、依存先のエラーをラップしてコンテキストに応じた情報を付加できる。
D
型は文字列に限らずDisplay + Send + Sync + 'static
であればいい。
そこで、ここに列挙型を渡すことにする;
#[derive(Copy, Clone, Eq, PartialEq, Debug, Fail)]
pub enum ErrorKind {
#[fail(display = "Database Access failed")]
DatabaseAccessFailed,
#[fail(display = "Given Name has been used")]
NameHasBeenUsed,
...
}
これで、Context<ErrorKind>
はよく分からないエラー型Error
とコンテキスト依存のエラー型ErrorKind
の二つの情報を含む型になる。cause()
で元のエラーが、get_context()
でErrorKind
が取り出せる。強そう。
更にContext<ErrorKind>
をカプセル化する構造体も定義する。現状derive
が働かないため手作業でFail
を実装する。
#[derive(Debug)]
pub struct Error {
inner: Context<ErrorKind>,
}
impl Fail for Error { /* self.innerに委譲するだけ */ }
impl Display for Error { /* self.innerに委譲するだけ */ }
impl MyError {
pub fn kind(&self) -> MyErrorKind {
*self.inner.get_context()
}
}
impl From<ErrorKind> for Error {
fn from(kind: ErrorKind) -> Error {
Error {
inner: Context::new(kind),
}
}
}
impl From<Context<ErrorKind>> for Error {
fn from(ctx: Context<ErrorKind>) -> Error {
Error { inner: ctx }
}
}
使う時はfailure::Error
の代わりにここで実装したError
を用いる。
依存ライブラリ等のエラーはもはや?
で雑に変換することはできなくなり、エラー型の翻訳作業が必要になる。翻訳といっても分かるエラーを取り分けるだけだから、適度にハンドルが強制されて良さそう。
use diesel::result::{Error as DieselError, DatabaseErrorKind};
fn create_user(name: String) -> Result<User, Error> {
// 何かして`Result<T, E>`が来る
let result = some_command(name);
let user = result.map_err(|e| match e {
DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => {
e.context(ErrorKind::NameHasBeenUsed)
}
_ => e.context(ErrorKind::DatabaseAccessFailed),
})?;
info!("🎉 {}", user);
Ok(user)
}
サービスでの利用
サービスというからには何かをする。何かをすると、普通、失敗したり成功したりする。サービスのインターフェースと同時にエラーの型についても定義されるべきだろう。
また、サービスのインターフェースからは依存関係を隔離し、何に依存するかは実装次第にしたい。
先述したan Error and ErrorKind pairパターンは、こういった層、粒度でのエラーハンドリングに適している。
サービスはドメイン固有の、一般に(実装によらず)発生しうるエラーの種類をErrorKind
として定義し、対応するError
をインターフェースに含める。
サービスを実装するときは、依存ライブラリや依存サービスのエラーを固有のエラーに翻訳する責任を負う。
より上層の、HTTPリクエストを受けサービスを使って応答するような層では各サービス固有のエラ-をfailure::Error
に、ひいては500
に変換する。
発生したエラーについて、原因が何で、どのような経路を通ってきたかは知りたいと思う。
エラーはサービスを経由するたびラップされるから、玉ねぎ状にError
及びErrorKind
の層が重なった状態になる。これを一枚ずつ剥いていけば経路がわかる。
剥いてロギングするための方法は2種類考えられる;
-
error!
するときにiter_chain()
してfold()
してformat!()
- 各サービスの
Error
についてDisplay
の実装をカスタムし、cause
を再帰的に表示
どのみちサービスとエラーの種類を同時に出力しようと思うとDisplay
の実装を変えることになるため、後者でいいような気がする。