jiechen257

jiechen257

twitter
github

Go處理錯誤優雅化

當前 Go 版本 1.17.5

Go 的錯誤處理設計#

關於 Go 的錯誤處理,一直爭議不斷。其實 Go 本身的爭議也不少 😂。這裡不進行評價,只是簡單地和主流語言對比,總結一下 Go 的特徵。

作為一個歷史悠久的語言,C 的錯誤處理非常混亂。典型情況下,C 通過返回值表示執行是否成功,至於失敗的具體原因,通過額外的全局變量傳遞。這種設計的致命缺陷是錯誤與返回結果混淆在一起,例如函數 int atoi(const char *str) ,如果轉換出錯應該返回什麼呢?0?-1?其實都不合理,因為任何數字都有可能代表一個正常的結果。

為此,部分開發者選擇利用指針傳遞結果,而返回值僅表示是否出錯。這確實解決了上述問題,但又導致了「參數」語義的混淆。沒有經驗的同學看到什麼入參出參搞的一臉懵比。

而近現代語言通常採用 try-catch 的思想。例如 java 與 python。這種模型既分離的錯誤與返回值和參數,也提供了結構化處理的可能。通過面相對象的思想,開發者可以自定義錯誤類、子類,它們又可以包裝其他錯誤,確保錯誤上下文不會丟失。


Go 則與眾不同。它利用多返回值的特性做到了錯誤的分離,因此,很多 Go 的函數最後一個返回值都是一個 error 來標識是否出錯。相比於 C 這是一個進步,但沒有提供靈活的捕獲機制,這就仁者見仁智者見智了。

我個人的觀點是:不應該試圖在 A 語言中復刻 B 語言的功能,而是根據語言自身的特點,在宏觀理念的指導下,實現針對性的錯誤處理方案。 後文將以這個觀點為繼續進行探索。

基本理念#

雖然語法不同,但是一些語言無關的思想還是很重要的,我們應該盡量遵循。

首先,一個錯誤信息,至少要具有兩個作用:

  1. 給程序看。可以根據錯誤類型進入處理分支。
  2. 給人看。告訴我們到底發生了什麼。

不要重複處理錯誤#

私以為這是很容易踩的一個坑,考慮下面的代碼:

func foo() (Result, error) {
 result, err := repository.Find(id)
 if err != nil {
   log.Errof(err)
   return Result{}, err
 }
 return result, nil
}

這裡打印了錯誤(相當於處理),然後返回它。可以想象,foo() 的調用方會再次打印,再返回。最終一個錯誤會打印出一大堆東西。

錯誤要包含調用棧#

調用棧是 debug 的基本需要。如果僅僅是一層層返回 error,那麼最頂層的函數將收到最底層的錯誤。試想你用 Go 寫了一個 web 後端,在處理一個支付請求時出現了 io 錯誤。這對 debug 毫無幫助 —— 無法定位哪個環節出錯。

另一個不好的方案是,每一層只返回自己的錯誤,例如:

func pay(){
  if err := checkOrder(); err!=nil {
    return errors.New("支付異常")
  }
  return nil
}

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return errors.New("計算金額異常")
  }
  return nil
}

func calcMoney() error {
  if err := querySql(); err!=nil {
    return errors.New("查詢數據庫異常")
  }
  return nil
}

這種情況和上一種相反:頂層函數只能得到最近的錯誤,對引發的原因一無所知,顯然這不是我們希望的。

錯誤要是結構化的#

有聰明的同學改造了一下代碼來解決丟失調用棧的問題:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return fmt.Errorf("計算金額異常:%s", err)
  }
  return nil
}

確實,修改後頂層函數得到的異常如下:

支付異常:計算金額異常:查詢數據庫異常

現在基本上可以看出調用棧了,但僅限於人類。這個錯誤是否屬於數據庫異常?計算機無從得知。我們可以通過自然語言處理來解決,除非你瘋了。

所以需要結構化錯誤 —— 錯誤有包含關係,子錯誤是父錯誤的一種,頂層函數可以輕鬆判斷異常類型。

錯誤要有上下文#

這一點很好理解了,我們希望錯誤日誌包含一些相關的數據,比如用戶 id,訂單 id 等。

值得注意的是,這個原則要和「不重複處理」結合,否則將會得到這樣的天書 log:

支付異常 uid=123, orderId=456, reqId=328952104:計算金額異常 uid=123, orderId=456, reqId=328952104:查詢數據庫異常 uid=123, orderId=456, reqId=328952104

實踐#

錯誤鏈#

曾經,在 Go 中做到保留調用棧和結構化是很麻煩的,為此 errors 開源庫被廣泛使用。不過 Go 1.13 一定程度上增強了錯誤處理,Go 2 也計劃進一步改善。因此這個庫已進入維護狀態。

現在,調用棧不再需要我們自己構造,通過 fmt.Errorf("... %w", ..., err) 就可以包裝一個錯誤,層層套娃形成錯誤鏈。相應的,通過 errors.Is()errors.As() 可以判斷一個錯誤(鏈)是否是(包含)另一個錯誤,前者要求嚴格相等,後者只需類型一致。不知不覺中,「結構化」也基本上實現了。

等等!為什麼說「基本」?雖然標準庫提供了判斷錯誤類型的方法,但是錯誤類型是啥?相比 java 中拋出一個具體異常,Go 中基本上只會返回底層錯誤接口 error,而沒有具體結構體,那怎麼判斷呀?難道又要回到 err.msg 的時代?當然不是,包裝一個錯誤,除了使用 fmt 之外,還可以自己定義一個結構體。實際上,fmt.Errorf() 返回的是 wrapError,相當於是便捷函數,用於無需明確錯誤類型的場景。

自定義結構體#

自定義錯誤結構體不僅幫助識別錯誤類型,還順便解決了上下文問題。通過簡單的 string 自然是 OK 的,不過為了讓上下文本身也可以被程序識別,更好的辦法是作為結構體的一個字段:

type orderError struct {
  orderId int
  msg string
  err error
}

func (e *orderError) Error() string {
    return e.msg
}

func (e *orderError) Unwrap() error {
    return e.err
}

這個結構體不僅實現了 error 接口,還額外擁有 Unwrap() 方法,這樣就可以包裝其他異常,確保不丟失調用棧。

那麼就可以這麼來返回:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return orderError{123, "計算金額異常", err}
  }
  return nil
}

公开 OR 私有#

有了自己的結構體,隨之而來的就是它需要公開嗎?這個問題標準庫已經給出了答案:一般不需要。所以我們見到的大多數函數,只返回 error 而不是 xxxError。根據網上的資料,這麼做旨在隱藏實現細節,提高 lib 的靈活性,減少升級時需要考慮的兼容問題。

不公開結構體,也就是意味著外部無法通過 errors.As() 判斷了,為此,需要公開一個函數幫助外部確認這是否是屬於本 lib 的錯誤。

func IsOrderError(err error) bool {
  return errors.As(err, orderError)
}

Error Check Hell#

上面已經總結了返回錯誤的結構,符合一開始提出的基本理念。在實際代碼中,錯誤檢查可能會充斥著項目,甚至每一個調用都裹著一個 if 來及時打斷並返回 —— 因為 Go 沒有 throw 或 raise 機制。看下面這個惡心的例子:

func checkPersion(*p Persion) error {
  if err := checkAttr(p.name); err != nil{
    return err
  }
  if err := checkAttr(p.age); err != nil{
    return err
  }
  if err := checkAttr(p.country); err != nil{
    return err
  }
  if err := checkAttr(p.work); err != nil{
    return err
  }
  return nil
}

抽取匿名函數#

此方法適用於連續調用同一個函數。

把錯誤檢查抽取到匿名函數中,若已經存在錯誤,那麼不真正執行,直接返回。

func checkPersion(*p Persion) error {
  var err error
  check := func(attr interface{}){
    if err != nil{
      return
    }
    err = checkAttr(attr)
  }

  check(p.name)
  check(p.age)
  check(p.country)
  check(p.work)
  // more check
  return err
}

利用 panic#

⚠️ 使用 panic 來代替 error 是錯誤的習慣。不要濫用此技巧。

// checkAttr() 不再返回 error 而是直接 panic
func checkAttr(attr interface{}) {
  if attr == nil{
    panic(checkErr{...})
  }
}

func checkPersion(*p Persion) (err error) {
  defer func() {
    if r := recover(); r != nil {
      // 恢復 checkAttr() 的 panic 轉為 error
      err = r.(checkErr)
    }
  }()

  // do any thing
}

用 panic 簡化 check 的關鍵在於 recover 時只處理已知的錯誤,對於未知情況應該繼續傳遞 panic。因為 panic 原則上僅用於不可恢復的嚴重錯誤(例如數組越界),如果不分情況一律 recover 則可能會掩蓋 bug 引發未知的後果。

有的網站給出下面這種寫法,非常不推薦,除非你知道自己在幹嘛:

func checkPersion(*p Persion) (err error) {
  defer func() {
    if r := recover(); r != nil {
      var ok bool
      // 這裡對於未知錯誤也一並捕獲了
      err, ok = r.(error)
      if !ok {
        err = fmt.Errorf("failed to check persion: %v", r)
      }
    }
  }()
  // do something thing
}

儘管很多網站都宣傳不要濫用 panic,但我認為,如果像第一個例子那樣,確保自己只捕獲已知的異常來簡化 error check,應該不算做濫用 —— 此時 panic 不會對包外部的調用者造成任何影響 —— 原來會 panic 的現在依然會 panic,原來會返回 error 的現在依然只返回 error。Go 的官方文檔 effective go 也承認了這種用法:

With our recovery pattern in place, the do function (and anything it calls) can get out of any bad situation cleanly by calling panic. We can use that idea to simplify error handling in complex software.

有了 recovery 模式,我們就可以隨時通過調用 panic 簡單地擺脫異常情況,可以使用該思想來簡化複雜軟件中的錯誤處理。

轉載#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。