crate failure 的五种错误模式

总结一下failure曾给出的五种错误设计模式。他们适合什么场景?现在有更好的选择吗?

五种模式解析

  1. String

    即每个函数返回的类型为Result<T, String>

    示意图:

    string-err

    优点:不费脑子,基础设施代码少,方便快捷一把梭,?可以将各种错误转换为字符串

    缺点:

    1. scattering:错误信息字符串散乱在代码各处,维护起来比较困难
    2. brittle:如果调用者要针对不同的错误做特殊处理,只能靠字符串匹配,要是你改变了错误信息,下游的错误处理全部崩盘

    适用场景: 原型期,非lib工程,或者适用于那些十分罕见的错误,如果遇到要么写入日志,要么跳过,不会单独识别出来做特殊处理

 

  1. Using the Error type

    即函数的返回类型为Result<T, Error>

    示意图:

    error-type

    优点

    1. 不需要自定义错误类型
    2. 因为只要实现了Fail的都可以转换为Error类型,可以用?操作符
    3. 同时因为转换方便,面对将来新增依赖带来的新错误类型,也能不加修改地向外传递.

    缺点:

    1. Error要新分配内存,性能有时候没有自定义错误类型好
    2. 只有downcast才能获取原有的错误类型

    适用场景:错误的类型系统不是很重要,重要的是写入日志或者展示给用户。application比较适合,结构化的错误类型不是必须的,重点是能包容各方依赖的错误。其实这个看上去有点像String的升级版,不过支持downcast反向转换。

 

  1. Custom Fail type

    即函数返回的类型为Result<T, ProjectError>, 其中ProjectError是各种错误的枚举,并且实现相关错误Trait,比如failure::Fail、std::error::Error

    示意图:

    fail-type

    优点:

    1. 可以枚举可能出现的所有错误类型
    2. 完全控制每个错误类型的打印格式
    3. 调用者不用做downcast就可以获得详细的错误信息

    缺点:

    1. 在嵌套错误时,必须显示使用#[cause]指明下层错误,便于derive实现cause。这种形式不一定保证兼容之后所有依赖的错误类型。
    2. 错误枚举选项(variants)和底层错误是一对一关系。

    我不是很理解这个两个缺点的意思,翻译得可能也不准确。

    第二点是不是指,如果在只使用?操作符的情况下,你只能为底层错误写一个转换到ProjectError的From trait,只能转换到ProjectError的其中一个枚举,这样语义上可能达不到要求。比如io::Error可以是FileSystemError也可以是NetworkError,在lib层面做一个区分就会更有意义,而这在第4种Error and ErrorKind中能实现,因为使用显示的context来转化。

    适用场景:文档说的意思是,这个模式比较适合没有底层错误的错误。如果要组合底层错误,推荐使用后面Error and ErrorKind的模式。

 

  1. An Error and ErrorKind pair

    最健壮的error管理方式,当然维护起来是最麻烦的。

    整体思路是2、3的整合。具体是让自定义的ProjectError,承担Error的角色,让所有错误都可以相对快捷地转换到ProjectError类型。

    示意图:

    ErrorKind

    ProjecError的主体实际上是一个Context结构,Context左手一个ErrorKind,右手一个failure。ErrorKind就是lib层面自定义的结构化错误,外部代码可以通过对ProjectError调用kind方法来确定错误类型。failure记载导致ErrorKind的下层错误。和Custom Fail Error示意图一样,我用橙色标记了错误的主体,可以看到ErrorKind版本,实际上新增了一层统一的Context抽象,把原本当主体的ErrorKind拿来当一个字段。

    优点:

    1. 兼容性好
    2. 可以添加信息
    3. 把底层错误转换到统一的ProjectError
    4. 不用downcast

    缺点:

    1. 比较复杂,写很多模板代码
    2. 结构复杂,allocation比较多,不适合性能要求高的应用
    3. [个人] 不能完全控制错误的Display的方式,Project的Display实现调用Context,Context只会打印ErrorKind的Display,在Debug Trait里则会用换行符连接ErrorKind和cause failure。你只能控制ErrorKind的展示,但是不能控制ErrorKind和cause failure在一块的展示格式。

    适用场景:中间层的lib,有众多依赖,且面向生产环境使用。希望提供完整的结构化错误,并且添加足量的上下文信息,可以使用这个模式。

 

  1. Strings and Custom Fail Type

    本质是把ErrorKind换成String、&str。

    示意图:

    string-custom

    动机说明 & 优点 & 适用场景:

    1. 想提供除了Error之外的更具语义的ProjectError,比如例子中的EncodeError和DecodeError。同时还不想写复杂的ErrorKind,因为上下文实在太多了,并且上层调用实际上并不是很想错误处理,只想要一个友好的错误说明。

    缺点:

    1. Context\<String>要多出allocation
    2. [个人] string信息依然散乱在各处,不方便维护

 

还有更好的选择吗

其实看下来Custom Fail Type的结构是最简单的,Error and ErrorKind在其基础上引入Context而成。

ErrorKind模式不能完全控制展示格式,也正是因为引入的这个Context。如果想完全控制,就得把底层错误写入ErrorKind的字段里。这样就和Context的failure字段重复了,很呆。这个问题在使用context为io Error添加错误时有实际体现(见)。

既然如此,为什么不让context方法直接返回Custom Fail Type呢?这样也能规避Coustom Fail Type和底层错误一对一绑定的尴尬。这个思想就是 crate snafu 的实现依据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug, Snafu)]
enum Error {
#[snafu(display("Could not open config from {}: {}", filename.display(), source))]
OpenConfig {
filename: PathBuf,
source: std::io::Error,
},
#[snafu(display("Could not save config to {}: {}", filename.display(), source))]
SaveConfig {
filename: PathBuf,
source: std::io::Error,
},
#[snafu(display("The user id {} is invalid", user_id))]
UserIdInvalid { user_id: i32, backtrace: Backtrace },
}

// 理解了这个想法,使用上也挺直观的
// OpenConfig { filename } 是一个实现了IntoError的结构,由From trait调用转换为Error
let config = fs::read(filename).context(OpenConfig { filename })?;
fs::write(filename, config).context(SaveConfig { filename })?;

经过一个多星期的对比,我目前觉得 snafu 的设计是挺好的,适合需要严谨设计错误的中间lib。事实上,稍微有点追求的application也可以这样搞,因为可以集中修改display的信息,虽然使用String的方案很方便,但是依然存在信息散乱,修改不方便的问题。另外,snafu依赖的也是标准库的error,之后如果有变动,迁移起来会比failure方便。