前回の記事で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の実装を変えることになるため、後者でいいような気がする。