關於 Rust 錯誤處理的思考

本文首發於 RustMagazine 中文月刊:https://rustmagazine.github.io/rust_magazine_2021/chapter_2/rust_error_handle.html

錯誤處理並非一件容易的事情,儘管在使用 Rust 時,有編譯器不厭其煩地督促我們,基本不存在漏掉錯誤不處理的情況了,但這並不意味着錯誤處理這件事情變簡單了。這裏也記錄一下我使用 Rust 一段時間後,對於錯誤處理的一些思考,包含大量主觀看法,歡迎讀者拍磚。

不可恢復錯誤和可恢復錯誤

使用 Rust 的人都知道, Rust 錯誤處理的手段主要分爲兩種,對於不可恢復的錯誤(unrecoverable error),可以通過 panic 來直接中斷程序的執行,而對於可恢復的錯誤(recoverable error),一般會返回 Result 。至於什麼時候使用 panic ,什麼時候使用 Result ,官方提供了一些指導意見,很多文章對這塊都有討論,相信不少人在這上面是能達成共識的,因此本文在這塊也不做過多展開。

錯誤處理中最麻煩的,還是處理可恢復的錯誤。

Error 類型

在進行錯誤處理,首先,你得把自己 Error 類型給定義了。我認爲,對於一個新項目來說,定義好自己的 Error 類型甚至是屬於最先要做的幾件事情之一。即便一開始不做,等到你寫到了第一個 Result 時,你也不得不考慮了。定義 Error 類型是一個可簡單,可複雜的事情,畢竟在 Result<T, E> 裏,E 其實可以塞任何東西。如果你膽子夠大,甚至可以直接把 String 作爲 Error 來使用,還能帶上一定的錯誤信息。

fn make_string_err() -> Result<(), String> {    Err(format!("Oh, string is not {}", 1))}
fn string_err_example() -> Result<(), String> { make_string_err()?; Ok(())}

String 甚至可以轉爲來使用 Box<dyn Error>

fn string_box_err() -> Result<(), Box<dyn std::error::Error>> {    Err(format!("Oops, {}", 1))?;    Ok(())}

不過這種錯誤處理方式過於簡單粗暴,而錯誤一旦轉爲了 String ,就喪失了大部分可編程性,上層想要針對某些類型的錯誤做針對性的處理就會變得非常困難 —— 唯一的手段估計就只剩下字符串匹配了。

更多的時候,我們可能會想要把錯誤定義爲一個 Enum 或者 Struct ,並實現 Error 等相關的 trait 。這是個體力活,如果你還需要處理 std 或者第三方庫拋出來的 Error ,還需要手工實現一大堆 From 來爲自己的 Error 實現相應的轉換規則。這樣下去,還沒等 Error 類型定義完,寫代碼的熱情就已經冷卻了。

這些工作太枯燥了,就應該交給工具庫去做!而當你去找 Rust 相關的錯誤處理庫(嚴格來說,可能稱爲錯誤管理或者錯誤定義庫更合適)時,就會發現, Rust 的錯誤處理庫也太多了,而且以後可能會更多,這對於有選擇困難症的來說簡直是災難。後面我也會從早期到近期挑選出一些比較有代表性的錯誤處理庫,談下我對他們的理解和在錯誤處理上的一些看法。當然,由於不是每個庫我都使用過,所以也難免理解存在偏頗,歡迎大家指正

quick-error

在我剛接觸 Rust 時,市面上的錯誤處理庫還沒有現在多,或者說我對 Rust 錯誤處理還不如現在瞭解,挑選庫的過程反而比較簡單。由於當時 tikv 已經挺有名氣了,於是我直接打開 tikv 的項目,發現它在使用 quick-error ,就決定跟着它用了。當時我的需求也很簡單,就是希望有個工具庫幫我把定義錯誤的這些 boilerplate code 給包掉,而 quick-error 也正如其名,能夠比較麻利地幫我把 Error 類型定義出來。而 Rust 最早的錯誤處理庫基本上也就只幫你幹這樣的事情,因此其實更像是錯誤定義庫(如今 quick-error 也不僅僅只能幫你定義錯誤了,不過也是後話了)。

例如下面就是個使用 quick-error 的例子,定義了一個 Error 類型,並且自動實現了 From<io::Error>

quick_error! {    #[derive(Debug)]    pub enum MyError {        Io(err: io::Error) {            from()            display("I/O error: {}", err)            source(err)        }        Other(descr: &'static str) {            display("Error {}", descr)        }    }}

丟失上下文

然而,僅僅只是把 Error 定義出來只不過是剛剛踏入了錯誤處理的門,甚至可以說定義 Error 也只是錯誤處理那一系列 boilerplate code 的一小部分而已。單純見到錯誤就往上拋並不難,而且 Rust 還提供了 ? 運算符來讓你可以更爽地拋出錯誤,但與之相對的,直接上拋錯誤,就意味着丟棄了大部分錯誤的上下文,也會給時候定位問題帶來不便。

例如有類似下面的代碼,使用了剛剛在上面定義的 Error 類型,而 eat()/drink()/work()/sleep() 中任意一個都有可能拋出 io::Error 的函數。那麼當 daily() 出錯時,你拿到的最終信息可能只是個 "I/O error: failed to fill whole buffer" ,而到底是哪裏出的錯,爲什麼出錯了呢?不知道,因爲錯誤來源丟失了。

fn daily() -> Result<(), MyError> {    eat()?;    drink()?;    work()?;    sleep()?;    Ok(())}

丟失錯誤源頭這種問題在 Rust 裏還是很容易發生的,也是 Rust 錯誤處理裏較惱人的一件事。當然,很大的原因還是在於錯誤提供沒有 backtrace (現在也尚未 stable)。爲了避免出現類似的問題,遇到錯誤時就需要注意保存一些調用信息以及錯誤的現場,概況下來,就是兩樣東西

  • 調用棧,或者說 backtrace

  • 錯誤的上下文,如關鍵入參

嚴格來說, backtrace 也屬於上下文的一部分,這裏分開提更多是考慮到兩者在實現層面是有所區分的。有 backtrace 自然方便,但 backtrace 也並不能解決所有問題:

  • 光靠 backtrace 其實只能回答哪裏出了錯的問題,而回答不了爲什麼出錯的

  • 一些預期內時常會拋錯誤的代碼路徑也不宜獲取 backtrace

反過來,通過在日誌裏打印或者在 Error 類型中追加上下文信息,其實是能反過來推斷出調用鏈路的,使得排查問題不強依賴 backtrace。我在 Rust 裏進行的錯誤處理時做得最多的事情就是,考慮這個地方適不適合打印錯誤日誌

  • 如果適合,打下錯誤日誌和相關信息,繼續拋錯誤

  • 不適合,考慮錯誤直接拋上去了後續是否方便定位問題

    • 如果不方便,還會把 error 和上下文信息 format 下得到新的 error message ,然後產生個新的錯誤拋出去

這種方式雖說能解決問題,不過並不認爲是一種最佳實踐,更稱不上優雅,光是打印日誌和補充錯誤信息,就得寫不少代碼,更不提日誌和錯誤信息裏有不少內容可能還是相互重複的。

error-chain 和 failure

有沒有辦法更方便地將錯誤的上下文信息放到 Error 裏面呢?早期的 error-chain 庫在這方面做了不少嘗試,其中 chaining errors 模式有點類似 golang 中的 errors.Wrap() ,允許用戶通過 chain_err() 將錯誤或者可轉換爲錯誤的類型(如 String)不斷地串聯起來。

let res: Result<()> = do_something().chain_err(|| "something went wrong");

除此之外,這個庫還提供了 ensure! , bail! 等工具宏以及 backtrace 功能,這些我認爲對後來錯誤處理庫的發展都是一定啓發作用的。不過 error-chain 文檔裏那一大坨宏定義,各種概念以及說明,對於剛接觸 Rust 的人還是比較勸退的。

到了 failure 庫, chain_err() 的模式改爲了通過 context() 來攜帶錯誤的上下文信息。

use failure::{Error, ResultExt};
fn root() -> Result<(), Error> { a().context("a failed")?; b().context("b failed")?; Ok(())}

如今錯誤處理庫也基本沿用了 context() 這一 api 命名,甚至 context() 已經成爲了 Rust 風格錯誤處理的一部分。

儘管我也考慮過使用這兩個庫替換掉自己項目裏在用的 quick-error ,不過,一旦項目變龐大後,這種替換錯誤處理庫以及錯誤處理風格的工作就多少有點工作量抵不上收益了。另一方面, error-chain 和 failure 作爲出現得比較早的錯誤處理庫,更多起到探索和過渡的作用,他們當初需要解決的問題在 std 的 Error trait 的演進下,很多也都不復存在了(起碼在 nightly 上是這樣),因此他們的演進也基本走到盡頭了。包括 failure 的開發後來也逐漸停滯,現在已經是處於 deprecated 的狀態了,項目維護者也都推薦用一些更新的錯誤處理庫。

thiserror + anyhow

對於一些新的錯誤處理庫,目前社區裏較爲主流的建議可能是組合使用 thiserror 和 anyhow 這兩個庫。其中 thiserror 可以看作是定義 Error 的一個工具,它只幫你生成一些定義 Error 的代碼,別的什麼都不做,相當純粹。

而 anyhow 則爲你定義好了一個 Error 類型,基本可以看作是一個 Box<dyn Error> ,同時還提供了一些如 context 等擴展功能,用起來更加無腦。

use anyhow::{Context, Result};
fn main() -> Result<()> { ... it.detach().context("Failed to detach the important thing")?;
let content = std::fs::read(path) .with_context(|| format!("Failed to read instrs from {}", path))?; ...}

除此之外, anyhow 的 Error 只佔用一個指針大小的棧空間,相應的 Result 的棧空間佔用也會變小,在一些場景下也比較有用。

這兩個庫的作者 dtolnay 建議,如果你是在開發庫,則用 thiserror ,而如果是開發應用則使用 anyhow 。這在實踐時遇到的一個問題就是所謂庫和應用的邊界有時候並沒有那麼清晰:對一個多模塊的應用來說,本質上也可以看作是由若干個庫構成的,而這些模塊或者"庫"之間,也可能是有層級關係的。對於這些模塊,使用 anyhow 就存在以下問題

  • 需要使用 anyhow 專門提供的 Error 類型,可能直接將 anyhow::Error 暴露到庫的 api 上

  • 調用方拿到的不是明確的錯誤類型

  • 無法對 anyhow::Error 做 pattern match

  • 更近一步,應用也不保證不會有處理具體錯誤的需求

本質上, anyhow::Error 庫提供的 Error 類型,更類似一種 Report 類型,適合彙報錯誤,而不適合處理具體的錯誤。如果使用 thiserror ,就失去了便利的 context 功能,用起來相對沒那麼方便,而作者看上去也不打算支持這一點。總的看下來, thiserror + anyhow 的組合方案還是存在一定侷限性,似乎用起來並沒有那麼順手。

snafu

而 snafu 的方案,則讓我看到 context 也是可以和具體的 Error 類型比較優雅地結合起來。不妨看下 snafu 官方的例子

use snafu::{ResultExt, Snafu};use std::{fs, io, path::PathBuf};
#[derive(Debug, Snafu)]enum Error { #[snafu(display("Unable to read configuration from {}: {}", path.display(), source))] ReadConfiguration { source: io::Error, path: PathBuf },
#[snafu(display("Unable to write result to {}: {}", path.display(), source))] WriteResult { source: io::Error, path: PathBuf },}
type Result<T, E = Error> = std::result::Result<T, E>;
fn process_data() -> Result<()> { let path = "config.toml"; let configuration = fs::read_to_string(path).context(ReadConfiguration { path })?; let path = unpack_config(&configuration); fs::write(&path, b"My complex calculation").context(WriteResult { path })?; Ok(())}
fn unpack_config(data: &str) -> &str { "/some/path/that/does/not/exist"}

上面的例子就體現出 snafu 的一些特點

  • 基於 context selector 的 context 方案

    • 同樣是 io::Error , snafu 可以通過不同的 context 返回不同的 enum variant ,同時還能帶上一些錯誤相關信息

    • 比起爲 Error 直接實現 From<io::Error> 要更有意義,畢竟我們更希望拿到的錯誤告訴我是 read configuration 出錯了,還是 write result 出錯了,以及出錯的文件 path 是哪個

    • 本質上是把 context 的類型也提前定義了

  • 產生的 Error 就是我們自己定義的 Error,無需依賴 snafu 提供的 Error 類型

  • 這裏其實還有一個隱含的好處,就是這個 Error 是可以做 pattern match 的

關於 snafu 和錯誤處理, influxdb_iox 其實總結了一份他們錯誤處理的 style guide ,我覺得很有參考價值,裏面也提到了 snafu 的一些設計哲學

  • 同樣的底層錯誤可以根據上下文不同而轉換爲不同的領域特定錯誤,例如同樣是 io 錯誤,根據上層業務語義的不同能夠轉換爲不同的業務錯誤

  • 在庫和應用的場景下都同樣好用

  • 模塊級別的 Error 類型,每個模塊都應該定義一個,甚至多個自己專用的錯誤類型

而這些設計哲學,我認爲也是錯誤處理裏比較好的實踐。其中,關於 Error 類型應該做到模塊級別還是做到 crate 級別(全局),可能會有較多爭議,也值得發散開來聊聊。

模塊級 Error 類型與全局 Error 類型

先擺觀點,我認爲 Error 類型儘量做到模塊級別是更好的,甚至部分函數有專門的 Error 類型也不過分,但是也要擺一個事實,那就是我自己的代碼裏這一點做得也還不夠好。

所以,這裏還是要提一下全局 Error 類型的一些好處,起碼包括

  • 方便做一套全局的錯誤碼,而且類型參數不合法就是比較常見的錯誤

  • 不需要花太多精力定義 Error 類型,很多 enum variant 可以共用,Result<T, Error> 也只需要定義一份,,這也是全局 Error 類型最大的優勢

但是,全局 Error 類型也存在相應的缺陷

  • 所有用到了 Error 類型的模塊,其實通過 Error 類型間接和其他模塊耦合了,除非你的 Error 類型只想用 anyhow::Error 這樣的類型

  • 即使來源 Error 相同,上下文也不同,定義到一個 enum variant 裏面不見得合適

  • 更容易出現 Error 拋着拋着不知道哪來的情況

而模塊級的 Error 類型則看上去也更符合一個模塊化的 crate 應有的設計

  • 不存在共用 Error 類型導致的間接耦合

  • 更加內聚,每個模塊可以專心處理自己的錯誤, match 錯誤的範圍也大大減少

  • 即使不依賴 backtrace ,錯誤本身也能明確反映出了層次關係和鏈路

當然,模塊級的 Error 類型也並非沒有缺點,例如

  • 定義 Error 的工作會變多,做全局的錯誤碼會麻煩些,可能需要在上層做一次轉換

  • 模塊層次過深的話,或者一些模塊的 Error 字段較多,由於 Rust enum 的特點,越上層的 Error 類型就會越大(std::mem::size_of::()),像 snafu 同樣也會有這樣的問題

總結

錯誤處理可能不存在最佳方案一說,更多還是要結合實際場景。即便是談到錯誤處理庫,我要是大喊一聲 snafu 是 Rust 最好的錯誤處理庫,相信社區裏肯定也會有一堆人跳出來反對我。而實際上 snafu 也存在自身的缺點,例如 Error 定義的工作量相對大(需要定義各種 context), Error 類型體積可能會比較大等。

總的來說,錯誤處理一直是一件麻煩的事。我覺得能做到錯誤的現場可追溯,就已經算錯誤處理做得不錯了的。經過幾年的發展, Rust 的錯誤處理庫初步發展出了 context 和 backtrace 兩種記錄錯誤上下文的手段,同時也更加強大和易用了,但我認爲目前他們尚未發展到終態,也尚未出現一個庫獨大的局面。如果說現在我新起個項目或者模塊,需要選擇一個錯誤處理庫的話,我可能會先嚐試下 snafu 。

關於我們

我們是螞蟻智能監控技術中臺的時序存儲團隊,我們正在使用 Rust 構建高性能、低成本並具備實時分析能力的新一代時序數據庫,歡迎加入或者推薦,目前我們也正在尋找優秀的實習生,也歡迎廣大應屆同學來我們團隊實習,請聯繫:[email protected]

參考

  • https://blog.yoshuawuyts.com/error-handling-survey/

  • https://www.ncameron.org/blog/migrating-a-crate-from-futures-0-1-to-0-3/

  • https://zhuanlan.zhihu.com/p/225808164

  • https://nick.groenen.me/posts/rust-error-handling/

  • https://doc.rust-lang.org/book/ch09-00-error-handling.html

  • https://github.com/tikv/rfcs/pull/38#discussion_r370581410

  • https://github.com/shepmaster/snafu/issues/209

  • https://github.com/rust-lang/project-error-handling/issues/24

  • https://github.com/rust-lang/rust/issues/53487

  • https://github.com/rust-lang/rfcs/blob/master/text/2504-fix-error.md

  • https://zhuanlan.zhihu.com/p/191655266

  • https://docs.rs/snafu/0.6.10/snafu/guide/philosophy/index.html

  • https://doc.rust-lang.org/src/std/error.rs.html#48-153

  • https://github.com/facebook/rocksdb/blob/00519187a6e495f0be0bbc666cacd9da467a6c1e/include/rocksdb/status.h#L34

  • https://github.com/tailhook/quick-error/issues/22

  • https://github.com/dtolnay/anyhow

  • https://github.com/dtolnay/thiserror

  • https://github.com/tailhook/quick-error

  • https://github.com/rust-lang-nursery/failure

  • https://github.com/rust-lang-nursery/error-chain


本文分享自微信公衆號 - 螞蟻智能運維(gh_a6b742597569)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章