👔
Rustのfailureを使って自作するErrorKindパターン

myuon

myuon

2019年12月25日
rust

# Error and ErrorKind pair

Rust のエラーライブラリといえばfailureです。 failure の公式ドキュメントにはError と ErrorKind ペアというページがあります。

  • ErrorKind はエラーの種類を識別されるのに使われます。エラーハンドリングを行う側はこの値を見て分岐を行います。ErrorKind が enum で定義されていれば、exhaustive なエラーハンドリングを簡単に行うことが出来ます。
  • Error は実際の内部エラーを表現します。エラーはライブラリなどによって実体が異なるので、まとめてfailure::Errorにしておくと便利です。これはスタックトレースの表示などに使います。

エラーをハンドリングするということはよくあるが、エラー型が異なる場合に(例えば DB エラーと serialization エラーなど)型が違うものをハンドリングするのは困難なので、ErrorKind というラベルをつけておくというテクニックです。

例えば API サーバーでエラーに識別子をつけておき、クライアントサイドで解釈してよしなにエラー処理を行うなどしようとするとこのようなやり方を採りたくなります。

問題点:

しかしドキュメントにある方法では、ErrorKind は 1 つの巨大な enum であることが想定されています。これはエラーを 1 箇所に集めておく(あるいは、少なくとも 1 箇所から辿れるようにしておく必要がある)必要があるという意味で不便な方法です。エラーはいろいろなファイルでいろいろな種類のものが起きうるので、ファイルごとに異なる ErrorKind を定義できるようにしたくなりました。

# 提案手法

上のような問題点を解決すべく、次のようなやり方を採用しています。

  • ServiceError という、上の Error and Error Kind pair に対応するものを用意する
  • ServiceError には、ErrorKind の型を識別するためにTypeIdを載せておく
  • ErrorKind 型は、単に文字列とする
  • 任意のエラーからエラーの種類を判定するため、専用の trait を定義する
// ErrorKind(error_type) and Error(inner) pair
pub struct ServiceError {
    type_id: std::any::TypeId,
    error_type: String,
    inner: Error,
}

pub trait IServiceError: Any {
    fn error_type(&self) -> String {
        "internal_server_error".to_string()
    }
}

impl ServiceError {
    pub fn new<E>(err: impl IServiceError, detail: E) -> ServiceError
    where
        failure::Error: From<E>,
    {
        ServiceError {
            type_id: err.type_id(),
            error_type: err.error_type(),
            status_code: err.status_code(),
            inner: From::from(detail),
        }
    }

    pub fn only(err: impl IServiceError) -> ServiceError {
        ServiceError {
            type_id: err.type_id(),
            error_type: err.error_type(),
            status_code: err.status_code(),
            inner: failure::err_msg("error"),
        }
    }

    pub fn is_error_of(&self, err: impl IServiceError) -> bool {
        self.type_id == err.type_id() && self.error_type() == err.error_type()
    }
}

TypeIdという型は聞き慣れないかもしれませんが、標準ライブラリのAny traitで定義されているメソッドから取得できます。 Rust コンパイラは型に対してTypeIdという一意な識別子を割り振っており、これによって 2 つの型が等しいかどうかを判定することが出来ます。

ここでは ErrorKind の代わりとして、error_type文字列を自力で定義することで替えることにしています。しかし、さすがにただの文字列だと意図せず被ってしまうケースがあり事故を起こしそうなので、定義元の型のtype_idを保存しておくことで意図しない型とエラーが被ってしまうことを防いでいます。

エラーを定義する側のサンプルコードです。

pub enum HogeError {
  InvalidRequest,
  AccessDenied,
  IOError,
}

impl IServiceError for HogeError {
    fn error_type(&self) -> String {
        use HogeError::*;

        match self {
            InvalidRequest => "invalid_request",
            AccessDenied => "access_denied",
            IOError => "io_error",
        }
        .to_string()
    }
}

エラーは基本的に error_type の文字列を定義するだけです。エラー型はHogeErrorのように 0 引数コンストラクタを並べることを想定しているので、ここの実装はマクロ等で自動生成しても良いかもしれません。

エラーの生成とハンドリングは次のように行うことが出来ます。

fn must_fail() -> Result<(), ServiceError> {
  Err(ServiceError::only(HogeError::IOError))
}

fn recover_io_error() -> Result<(), ServiceError> {
  let result = do_something();

  match result {
    Err(err) if err.is_error_of(HogeError::IOError) => {
      // IOErrorの場合はrecoverする
    },
    r => r,
  }
}

上で定義したServiceError::is_error_ofメソッドが、

  1. resultの中の例外がHogeError型であること
  2. さらに値がio_errorであること(今の場合はHogeErrror::IOErrorから来たもの)

の 2 つをチェックしています。

このHogeErrorは実際には異なるモジュールで自由に定義することが出来、複数のエラーを混ぜて書けるようになり便利です。

# 補足

Any trait にはtype_idだけでなくdowncast_refなどのキャストを行うメソッドが用意されています。これを用いてServiceError自体のダウンキャストを行うことを最初は考えていたのですが、downcast_refは対象のオブジェクトのライフタイムが static であることを要求します。これにより参照や参照を含むデータのダウンキャストが行えないため、今回は断念して error_type として文字列を定義するという原始的な方法に落ち着きました。

(ServiceError自体のダウンキャストができると error_type 文字列を定義する必要もなくなるので更に便利になりそうです)