錯誤處理:如何通過 error、deferred、panic 等處理錯誤?

Swenson1992發表於2021-02-04

錯誤

在 Go 語言中,錯誤是可以預期的,並且不是非常嚴重,不會影響程式的執行。對於這類問題,可以用返回錯誤給呼叫者的方法,讓呼叫者自己決定如何處理。

error 介面

在 Go 語言中,錯誤是通過內建的 error 介面表示的。它非常簡單,只有一個 Error 方法用來返回具體的錯誤資訊,如下面的程式碼所示:

type error interface {
   Error() string
}

看如下例子:

func main() {
   i,err:=strconv.Atoi("a")
   if err!=nil {
      fmt.Println(err)
   }else {
      fmt.Println(i)
   }
}

這裡故意使用了字串 “a”,嘗試把它轉為整數。但是 “a” 是無法轉為數字的,所以執行這段程式,會列印出如下錯誤資訊:

strconv.Atoi: parsing “a”: invalid syntax

這個錯誤資訊就是通過介面 error 返回的。我們來看關於函式 strconv.Atoi 的定義,如下所示:

func Atoi(s string) (int, error)

一般而言,error 介面用於當方法或者函式執行遇到錯誤時進行返回,而且是第二個返回值。通過這種方式,可以讓呼叫者根據錯誤資訊決定如何進行下一步處理。

小提示:因為方法和函式基本上差不多,區別只在於有無接收者,所以以後稱方法或函式,表達的是一個意思,不會把這兩個名字都寫出來。

error 工廠函式

除了可以使用其他函式,自己定義的函式也可以返回錯誤資訊給呼叫者,如下面的程式碼所示:

func add(a,b int) (int,error){
   if a<0 || b<0 {
      return 0,errors.New("a或者b不能為負數")
   }else {
      return a+b,nil
   }
}

add 函式會在 a 或者 b 任何一個為負數的情況下,返回一個錯誤資訊,如果 a、b 都不為負數,錯誤資訊部分會返回 nil,這也是常見的做法。所以呼叫者可以通過錯誤資訊是否為 nil 進行判斷。

下面的 add 函式示例,是使用 errors.New 這個工廠函式生成的錯誤資訊,它接收一個字串引數,返回一個 error 介面。

sum,err:=add(-1,2)
if err!=nil {
   fmt.Println(err)
}else {
   fmt.Println(sum)
}

自定義 error

上面採用工廠返回錯誤資訊的方式只能傳遞一個字串,也就是攜帶的資訊只有字串,如果想要攜帶更多資訊(比如錯誤碼資訊)該怎麼辦呢?這個時候就需要自定義 error 。

自定義 error 其實就是先自定義一個新型別,比如結構體,然後讓這個型別實現 error 介面,如下面的程式碼所示:

type commonError struct {
   errorCode int //錯誤碼
   errorMsg string //錯誤資訊
}
func (ce *commonError) Error() string{
   return ce.errorMsg
}

有了自定義的 error,就可以使用它攜帶更多的資訊,現在改造上面的例子,返回剛剛自定義的commonError,如下所示:

return 0, &commonError{
   errorCode: 1,
   errorMsg:  "a或者b不能為負數"
}

通過字面量的方式建立一個 *commonError 返回,其中 errorCode 值為 1,errorMsg 值為 “a 或者 b 不能為負數”。

error 斷言

有了自定義的 error,並且攜帶了更多的錯誤資訊後,就可以使用這些資訊了。需要先把返回的 error 介面轉換為自定義的錯誤型別,用到的知識是型別斷言。

下面程式碼中的 err.(*commonError) 就是型別斷言在 error 介面上的應用,也可以稱為 error 斷言。

sum, err := add(-1, 2)
if cm,ok:=err.(*commonError);ok{
   fmt.Println("錯誤程式碼為:",cm.errorCode,",錯誤資訊為:",cm.errorMsg)
} else {
   fmt.Println(sum)
}

如果返回的 ok 為 true,說明 error 斷言成功,正確返回了 *commonError 型別的變數 cm,所以就可以像示例中一樣使用變數 cm 的 errorCode 和 errorMsg 欄位資訊了。

錯誤巢狀

Error Wrapping

error 介面雖然比較簡潔,但是功能也比較弱。假如有這樣的需求:基於一個存在的 error 再生成一個 error,需要怎麼做呢?這就是錯誤巢狀。

這種需求是存在的,比如呼叫一個函式,返回了一個錯誤資訊 error,在不想丟失這個 error 的情況下,又想新增一些額外資訊返回新的 error。這時候,我們首先想到的應該是自定義一個 struct,如下面的程式碼所示:

type MyError struct {
    err error
    msg string
}

這個結構體有兩個欄位,其中 error 型別的 err 欄位用於存放已存在的 error,string 型別的 msg 欄位用於存放新的錯誤資訊,這種方式就是 error 的巢狀。

現在讓 MyError 這個 struct 實現 error 介面,然後在初始化 MyError 的時候傳遞存在的 error 和新的錯誤資訊,如下面的程式碼所示:

func (e *MyError) Error() string {
    return e.err.Error() + e.msg
}
func main() {
    //err是一個存在的錯誤,可以從另外一個函式返回
    newErr := MyError{err, "資料上傳問題"}
}

這種方式可以滿足我們的需求,但是非常煩瑣,因為既要定義新的型別還要實現 error 介面。所以從 Go 語言 1.13 版本開始,Go 標準庫新增了 Error Wrapping 功能,讓我們可以基於一個存在的 error 生成新的 error,並且可以保留原 error 資訊,如下面的程式碼所示:

e := errors.New("原始錯誤e")
w := fmt.Errorf("Wrap了一個錯誤:%w", e)
fmt.Println(w)

Go 語言沒有提供 Wrap 函式,而是擴充套件了 fmt.Errorf 函式,然後加了一個 %w,通過這種方式,便可以生成 wrapping error。

errors.Unwrap 函式

既然 error 可以包裹巢狀生成一個新的 error,那麼也可以被解開,即通過 errors.Unwrap 函式得到被巢狀的 error。

Go 語言提供了 errors.Unwrap 用於獲取被巢狀的 error,比如以上例子中的錯誤變數 w ,就可以對它進行 unwrap,獲取被巢狀的原始錯誤 e。

下面我們執行以下程式碼:

fmt.Println(errors.Unwrap(w))

可以看到這樣的資訊,即“原始錯誤 e”。

errors.Is 函式

有了 Error Wrapping 後,會發現原來用的判斷兩個 error 是不是同一個 error 的方法失效了,比如 Go 語言標準庫經常用到的如下程式碼中的方式:

if err == os.ErrExist

為什麼會出現這種情況呢?由於 Go 語言的 Error Wrapping 功能,令人不知道返回的 err 是否被巢狀,又巢狀了幾層?

於是 Go 語言為我們提供了 errors.Is 函式,用來判斷兩個 error 是否是同一個,如下所示:

func Is(err, target error) bool

以上就是errors.Is 函式的定義,可以解釋為:

  • 如果 err 和 target 是同一個,那麼返回 true。
  • 如果 err 是一個 wrapping error,target 也包含在這個巢狀 error 鏈中的話,也返回 true。

可以簡單地概括為,兩個 error 相等或 err 包含 target 的情況下返回 true,其餘返回 false。可以用上面的示例判斷錯誤 w 中是否包含錯誤 e,執行下面的程式碼,來看列印的結果是不是 true。

fmt.Println(errors.Is(w,e))

errors.As 函式

同樣的原因,有了 error 巢狀後,error 斷言也不能用了,因為不知道一個 error 是否被巢狀,又巢狀了幾層。所以 Go 語言為解決這個問題提供了 errors.As 函式,比如前面 error 斷言的例子,可以使用 errors.As 函式重寫,效果是一樣的,如下面的程式碼所示:

var cm *commonError
if errors.As(err,&cm){
   fmt.Println("錯誤程式碼為:",cm.errorCode,",錯誤資訊為:",cm.errorMsg)
} else {
   fmt.Println(sum)
}

所以在 Go 語言提供的 Error Wrapping 能力下,寫的程式碼要儘可能地使用 Is、As 這些函式做判斷和轉換。

Deferred 函式

在一個自定義函式中,開啟了一個檔案,然後需要關閉它以釋放資源。不管程式碼執行了多少分支,是否出現了錯誤,檔案是一定要關閉的,這樣才能保證資源的釋放。

如果這個事情由開發人員來做,隨著業務邏輯的複雜會變得非常麻煩,而且還有可能會忘記關閉。基於這種情況,Go 語言為我們提供了 defer 函式,可以保證檔案關閉後一定會被執行,不管自定義的函式出現異常還是錯誤。

下面的程式碼是 Go 語言標準包 ioutil 中的 ReadFile 函式,它需要開啟一個檔案,然後通過 defer 關鍵字確保在 ReadFile 函式執行結束後,f.Close() 方法被執行,這樣檔案的資源才一定會釋放。

func ReadFile(filename string) ([]byte, error) {
   f, err := os.Open(filename)
   if err != nil {
      return nil, err
   }
   defer f.Close()
   //省略無關程式碼
   return readAll(f, n)
}

defer 關鍵字用於修飾一個函式或者方法,使得該函式或者方法在返回前才會執行,也就說被延遲,但又可以保證一定會執行。

以上面的 ReadFile 函式為例,被 defer 修飾的 f.Close 方法延遲執行,也就是說會先執行 readAll(f, n),然後在整個 ReadFile 函式 return 之前執行 f.Close 方法。

defer 語句常被用於成對的操作,如檔案的開啟和關閉,加鎖和釋放鎖,連線的建立和斷開等。不管多麼複雜的操作,都可以保證資源被正確地釋放。

Panic 異常

Go 語言是一門靜態的強型別語言,很多問題都儘可能地在編譯時捕獲,但是有一些只能在執行時檢查,比如陣列越界訪問、不相同的型別強制轉換等,這類執行時的問題會引起 panic 異常。

除了執行時可以產生 panic 外,自己也可以丟擲 panic 異常。假設需要連線 MySQL 資料庫,可以寫一個連線 MySQL 的函式connectMySQL,如下面的程式碼所示:

func connectMySQL(ip,username,password string){
   if ip =="" {
      panic("ip不能為空")
   }
   //省略其他程式碼
}

在 connectMySQL 函式中,如果 ip 為空會直接丟擲 panic 異常。這種邏輯是正確的,因為資料庫無法連線成功的話,整個程式執行起來也沒有意義,所以就丟擲 panic 終止程式的執行。

panic 是 Go 語言內建的函式,可以接受 interface{} 型別的引數,也就是任何型別的值都可以傳遞給 panic 函式,如下所示:

func panic(v interface{})

小提示:interface{} 是空介面的意思,在 Go 語言中代表任意型別。

panic 異常是一種非常嚴重的情況,會讓程式中斷執行,使程式崩潰,所以如果是不影響程式執行的錯誤,不要使用 panic,使用普通錯誤 error 即可。

Recover 捕獲 Panic 異常

通常情況下,不對 panic 異常做任何處理,因為既然它是影響程式執行的異常,就讓它直接崩潰即可。但是也的確有一些特例,比如在程式崩潰前做一些資源釋放的處理,這時候就需要從 panic 異常中恢復,才能完成處理。

在 Go 語言中,可以通過內建的 recover 函式恢復 panic 異常。因為在程式 panic 異常崩潰的時候,只有被 defer 修飾的函式才能被執行,所以 recover 函式要結合 defer 關鍵字使用才能生效。

下面的示例是通過 defer 關鍵字 + 匿名函式 + recover 函式從 panic 異常中恢復的方式。

func main() {
   defer func() {
      if p:=recover();p!=nil{
         fmt.Println(p)
      }
   }()
   connectMySQL("","root","123456")
}

執行這個程式碼,可以看到如下的列印輸出,這證明 recover 函式成功捕獲了 panic 異常。

ip 不能為空

通過這個輸出的結果也可以發現,recover 函式返回的值就是通過 panic 函式傳遞的引數值。

總結

Go 語言的錯誤處理機制,包括 error、defer、panic 等。在 error、panic 這兩種錯誤機制中,Go 語言更提倡 error 這種輕量錯誤,而不是 panic。
defer 可以同時存在多個,多個defer的時候,倒序的執行,和堆一樣先進後出

本作品採用《CC 協議》,轉載必須註明作者和本文連結
?

相關文章