[Go] 如何正確地 拋出 錯誤 和 異常(error/panic/recover)?


序言

錯誤 和 異常 是兩個不同的概念,非常容易混淆。很多程序員習慣將一切非正常情況都看做錯誤,而不區分錯誤和異常,即使程序中可能有異常拋出,也將異常及時捕獲並轉換成錯誤。從表面上看,一切皆錯誤的思路更簡單,而異常的引入僅僅增加了額外的復雜度。

但事實並非如此。眾所周知,Golang 遵循“少即是多”的設計哲學,追求簡潔優雅,就是說如果異常價值不大,就不會將異常加入到語言特性中。

錯誤 和 異常 處理是程序的重要組成部分,我們先看看下面幾個問題:

  1. 錯誤 和 異常 如何區分?
  2. 錯誤處理的方式有哪幾種?
  3. 什么時候需要使用異常終止程序?
  4. 什么時候需要捕獲異常?
  5. ...

如果你對這幾個問題的答案不是太清楚,那么就抽一點時間看看本文,或許能給你一些啟發。

基礎知識

錯誤指的是可能出現問題的地方出現了問題,比如打開一個文件時失敗,這種情況在人們的意料之中;而異常指的是不應該出現問題的地方出現了問題,比如引用了空指針,這種情況在人們的意料之外。可見,錯誤是業務過程的一部分,而異常不是

Golang 中引入 error 接口類型作為錯誤處理的標准模式,如果函數要返回錯誤,則返回值類型列表中肯定包含 error。error 處理過程類似於 C 語言中的錯誤碼,可逐層返回,直到被處理。

Golang 中引入兩個內置函數 panicrecover 來觸發和終止異常處理流程,同時引入關鍵字 defer 來延遲執行 defer 后面的函數。

一直等到包含 defer 語句的函數執行完畢時,延遲函數(defer后的函數)才會被執行,而不管包含 defer 語句的函數是通過 return 的正常結束,還是由於 panic 導致的異常結束。你可以在一個函數中執行多條 defer 語句,它們的執行順序與聲明順序相反。

當程序運行時,如果遇到引用空指針、下標越界或顯式調用 panic 函數等情況,則先觸發 panic 函數的執行,然后調用延遲函數。調用者繼續傳遞 panic,因此該過程一直在調用棧中重復發生:函數停止執行,調用延遲執行函數等。如果一路在延遲函數中沒有 recover 函數的調用,則會到達該協程的起點,該協程結束,然后終止其他所有協程,包括主協程(類似於 C 語言中的主線程,該協程 ID 為 1)。

錯誤和異常從 Golang 機制上講,就是 error 和 panic 的區別。很多其他語言也一樣,比如 C++/Java,沒有 error 但有 errno,沒有 panic 但有 throw。

Golang 錯誤 和 異常 是可以互相轉換的:

  1. 錯誤轉異常,比如程序邏輯上嘗試請求某個 URL,最多嘗試三次,嘗試三次的過程中請求失敗是錯誤,嘗試完第三次還不成功的話,失敗就被提升為異常了。
  2. 異常轉錯誤,比如 panic 觸發的異常被 recover 恢復后,將返回值中 error 類型的變量進行賦值,以便上層函數繼續走錯誤處理流程。

一個啟示

regexp 包中有兩個函數 CompileMustCompile,它們的聲明如下:

func Compile(expr string) (*Regexp, error)
func MustCompile(str string) *Regexp

同樣的功能,不同的設計:

  1. Compile 函數基於錯誤處理設計,將正則表達式編譯成有效的可匹配格式,適用於用戶輸入場景。當用戶輸入的正則表達式不合法時,該函數會返回一個錯誤。
  2. MustCompile 函數基於異常處理設計,適用於硬編碼場景。當調用者明確知道輸入不會引起函數錯誤時,要求調用者檢查這個錯誤是不必要和累贅的。我們應該假設函數的輸入一直合法,當調用者輸入了不應該出現的輸入時,就觸發 panic 異常。

於是我們得到一個啟示:什么情況下用錯誤表達,什么情況下用異常表達,就得有一套規則,否則很容易出現一切皆錯誤或一切皆異常的情況。

在這個啟示下,我們給出 異常處理 的場景:

  1. 空指針引用
  2. 下標越界
  3. 除數為0
  4. 不應該出現的分支,比如 default
  5. 輸入不應該引起函數錯誤

其他場景我們使用 錯誤處理,這使得我們的函數接口很精煉。對於異常,我們可以選擇在一個合適的上游去 recover,並打印堆棧信息,使得部署后的程序不會終止。

說明: Golang 錯誤處理方式一直是很多人詬病的地方,有些人吐槽說一半的代碼都是 "if err != nil { / 打印 && 錯誤處理 / }",嚴重影響正常的處理邏輯。當我們區分錯誤和異常,根據規則設計函數,就會大大提高可讀性和可維護性。

錯誤處理的正確姿勢

姿勢一:失敗的原因只有一個時,不使用 error

我們看一個案例:

func (self *AgentContext) CheckHostType(host_type string) error {
  switch host_type {
  case "virtual_machine":
    return nil
  case "bare_metal":
    return nil
  }
  return errors.New("CheckHostType ERROR:" + host_type)
}

我們可以看出,該函數失敗的原因只有一個,所以返回值的類型應該為 bool,而不是 error,重構一下代碼:

func (self *AgentContext) IsValidHostType(hostType string) bool {
  return hostType == "virtual_machine" || hostType == "bare_metal"
}

說明:大多數情況,導致失敗的原因不止一種,尤其是對 I/O 操作而言,用戶需要了解更多的錯誤信息,這時的返回值類型不再是簡單的 bool,而是 error。

姿勢二:沒有失敗時,不使用 error

error 在 Golang 中是如此的流行,以至於很多人設計函數時不管三七二十一都使用 error,即使沒有一個失敗原因。我們看一下示例代碼:

func (self *CniParam) setTenantId() error {
  self.TenantId = self.PodNs
  return nil
}

對於上面的函數設計,就會有下面的調用代碼:

err := self.setTenantId()
if err != nil {
  // log
  // free resource return errors.New(...)
}

根據我們的正確姿勢,重構一下代碼:

func (self *CniParam) setTenantId() {
  self.TenantId = self.PodNs
}

於是調用代碼變為:

self.setTenantId()

姿勢三:error 應放在返回值類型列表的最后

對於返回值類型 error,用來傳遞錯誤信息,在 Golang 中通常放在最后一個。

resp, err := http.Get(url)
if err != nil {
  return nill, err
}

bool 作為返回值類型時也一樣。

value, ok := cache.Lookup(key)
if !ok {
  // ...cache[key] does not exist…
}

姿勢四:錯誤值統一定義,而不是跟着感覺走

很多人寫代碼時,到處 return errors.New(value),而錯誤 value 在表達同一個含義時也可能形式不同,比如“記錄不存在”的錯誤 value 可能為:

  1. "record is not existed."
  2. "record is not exist!"
  3. "###record is not existed!!!"
  4. ...

這使得相同的錯誤 value 撒在一大片代碼里,當上層函數要對特定錯誤 value 進行統一處理時,需要漫游所有下層代碼,以保證錯誤 value 統一,不幸的是有時會有漏網之魚,而且這種方式嚴重阻礙了錯誤 value 的重構。

於是,我們可以參考 C/C++ 的錯誤碼定義文件,在 Golang 的每個包中增加一個錯誤對象定義文件,如下所示:

var ERR_EOF = errors.New("EOF")
var ERR_CLOSED_PIPE = errors.New("io: read/write on closed pipe")
var ERR_NO_PROGRESS = errors.New("multiple Read calls return no data or error")
var ERR_SHORT_BUFFER = errors.New("short buffer")
var ERR_SHORT_WRITE = errors.New("short write")
var ERR_UNEXPECTED_EOF = errors.New("unexpected EOF")

說明:筆者對於常量更喜歡 C/C++ 的“全大寫+下划線分割”的命名方式,讀者可以根據團隊的命名規范或個人喜好定制。

姿勢五:錯誤逐層傳遞時,層層都加日志

根據筆者經驗,層層都加日志非常方便故障定位。

說明:至於通過測試來發現故障,而不是日志,目前很多團隊還很難做到。如果你或你的團隊能做到,那么請忽略這個姿勢:)

姿勢六:錯誤處理使用 defer

我們一般通過判斷 error 的值來處理錯誤,如果當前操作失敗,需要將本函數中已經 create 的資源 destroy 掉,示例代碼如下:

func deferDemo() error {
  err := createResource1()
  if err != nil {
    return ERR_CREATE_RESOURCE1_FAILED
  }

  err = createResource2()
  if err != nil {
    destroyResource1()
    return ERR_CREATE_RESOURCE2_FAILED
  }

  err = createResource3()
  if err != nil {
    destroyResource1()
    destroyResource2()
    return ERR_CREATE_RESOURCE3_FAILED
  }

  err = createResource4()
  if err != nil {
    destroyResource1()
    destroyResource2()
    destroyResource3()
    return ERR_CREATE_RESOURCE4_FAILED
  }

  return nil
}

當 Golang 的代碼執行時,如果遇到 defer 的閉包調用,則壓入堆棧。當函數返回時,會按照 后進先出 的順序調用閉包。

對於閉包的參數是 值傳遞,而對於外部變量卻是 引用傳遞,所以閉包中的外部變量 err 的值就變成外部函數返回時最新的 err 值。

根據這個結論,我們重構上面的示例代碼:

func deferDemo() error {
  err := createResource1()
  if err != nil {
    return ERR_CREATE_RESOURCE1_FAILED
  }
  defer func() {
    if err != nil {
      destroyResource1()
    }
  }()

  err = createResource2()
  if err != nil {
    return ERR_CREATE_RESOURCE2_FAILED
  }
  defer func() {
    if err != nil {
      destroyResource2()
    }
  }()

  err = createResource3()
  if err != nil {
    return ERR_CREATE_RESOURCE3_FAILED
  }
  defer func() {
    if err != nil {
      destroyResource3()
    }
  }()

  err = createResource4()
  if err != nil {
    return ERR_CREATE_RESOURCE4_FAILED
  }

  return nil
}

姿勢七:當嘗試幾次可以避免失敗時,不要立即返回錯誤

如果錯誤的發生是偶然性的,或由不可預知的問題導致。一個明智的選擇是重新嘗試失敗的操作,有時第二次或第三次嘗試時會成功。在重試時,我們需要限制重試的時間間隔或重試的次數,防止無限制的重試。

兩個案例:

  1. 我們平時上網時,嘗試請求某個 URL,有時第一次沒有響應,當我們再次刷新時,就有了驚喜。
  2. 團隊的一個 QA 曾經建議當 Neutron 的 attach 操作失敗時,最好嘗試三次,這在當時的環境下驗證果然是有效的。

姿勢八:當上層函數不關心錯誤時,建議不返回 error

對於一些資源清理相關的函數(destroy/delete/clear),如果子函數出錯,打印日志即可,而無需將錯誤進一步反饋到上層函數,因為一般情況下,上層函數是不關心執行結果的,或者即使關心也無能為力,於是我們建議將相關函數設計為不返回 error。

姿勢九:當發生錯誤時,不忽略有用的返回值

通常,當函數返回 non-nil 的 error 時,其他的返回值是未定義的(undefined),這些未定義的返回值應該被忽略。然而,有少部分函數在發生錯誤時,仍然會返回一些有用的返回值。比如,當讀取文件發生錯誤時,Read 函數會返回可以讀取的字節數以及錯誤信息。對於這種情況,應該將讀取到的字符串和錯誤信息一起打印出來。

說明:對函數的返回值要有清晰的說明,以便於其他人使用。

異常處理的正確姿勢

姿勢一:在程序開發階段,堅持速錯

去年學習 Erlang 的時候,建立了速錯的理念,簡單來講就是“讓它掛”,只有掛了你才會第一時間知道錯誤。在早期開發以及任何發布階段之前,最簡單的同時也可能是最好的方法是調用 panic 函數來中斷程序的執行以強制發生錯誤,使得該錯誤不會被忽略,因而能夠被盡快修復。

姿勢二:在程序部署后,應恢復異常避免程序終止

在 Golang 中,雖然有類似 Erlang 進程的 Goroutine,但需要強調的是 Erlang 的掛,只是 Erlang 進程的異常退出,不會導致整個 Erlang 節點退出,所以它掛的影響層面比較低,而 Goroutine 如果 panic 了,並且沒有 recover,那么整個 Golang 進程(類似 Erlang 節點)就會異常退出。所以,一旦 Golang 程序部署后,在任何情況下發生的異常都不應該導致程序異常退出,我們在上層函數中加一個延遲執行的 recover 調用來達到這個目的,並且是否進行 recover 需要根據環境變量或配置文件來定,默認需要 recover。

這個姿勢類似於 C 語言中的 斷言,但還是有區別:一般在 Release 版本中,斷言被定義為空而失效,但需要有 if 校驗存在進行異常保護,盡管契約式設計中不建議這樣做。在 Golang 中,recover 完全可以終止異常展開過程,省時省力。

我們在調用 recover 的延遲函數中以最合理的方式響應該異常:

  1. 打印堆棧的異常調用信息和關鍵的業務信息,以便這些問題保留可見;
  2. 將異常轉換為錯誤,以便調用者讓程序恢復到健康狀態並繼續安全運行。

我們看一個簡單的例子:

func funcA() error {
  defer func() {
    if p := recover(); p != nil {
      fmt.Printf("panic recover! p: %v", p)
      debug.PrintStack()
    }
  }()
  return funcB()
}

func funcB() error {
  // simulation

  panic("foo")
  return errors.New("success")
}

func test() {
  err := funcA()
  if err == nil {
    fmt.Printf("err is nil\\n")
  } else {
    fmt.Printf("err is %v\\n", err)
  }
}

我們期望 test 函數的輸出是:

err is foo

但實際上 test 函數的輸出是:

err is nil

原因是 panic 異常處理機制不會自動將錯誤信息傳遞給 error,所以要在 funcA 函數中進行顯式的傳遞,代碼如下所示:

func funcA() (err error) {
  defer func() {
    if p := recover(); p != nil {
      fmt.Println("panic recover! p:", p)
      str, ok := p.(string)
      if ok {
        err = errors.New(str)
      } else {
        err = errors.New("panic")
      }
      debug.PrintStack()
    }
  }()
  return funcB()
}

姿勢三:對於不應該出現的分支,使用異常處理

當某些不應該發生的場景發生時,我們就應該調用 panic 函數來觸發異常。比如,當程序到達了某條邏輯上不可能到達的路徑:

switch s := suit(drawCard()); s {
  case "Spades":
  // ...
  case "Hearts":
  // ...
  case "Diamonds":
  // ...
  case "Clubs":
  // ...
  default: panic(fmt.Sprintf("invalid suit %v", s))
}

姿勢四:針對入參不應該有問題的函數,使用 panic 設計

入參不應該有問題一般指的是硬編碼,我們先看“一個啟示”一節中提到的兩個函數(Compile 和 MustCompile),其中 MustCompile 函數是對 Compile 函數的包裝:

func MustCompile(str string) *Regexp {
  regexp, error := Compile(str)
  if error != nil {
    panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
  }
  return regexp
}

所以,對於同時支持用戶輸入場景和硬編碼場景的情況,一般支持硬編碼場景的函數是對支持用戶輸入場景函數的包裝。

對於只支持硬編碼單一場景的情況,函數設計時直接使用 panic,即返回值類型列表中不會有 error,這使得函數的調用處理非常方便(沒有了乏味的 “if err != nil {/ 打印 && 錯誤處理 /}” 代碼塊)。

小結

本文以 Golang 為例,闡述了 錯誤 和 異常 的區別,並且分享了很多 錯誤 和 異常 處理的正確姿勢,這些姿勢可以單獨使用,也可以組合使用,希望對大家有一點啟發。

 

延伸閱讀:

[Go] panic 和 recover

 

 

摘自:

http://www.jianshu.com/p/f30da01eea97

 

 

 

后記

針對 業務方法 之間 業務錯誤 處理這一塊,其實還可以參考一下先前的文章:

如何設計PHP業務模塊(函數/方法)返回結果的結構?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM