Golang通脈之錯誤處理

在實際工程項目中,總是通過程式的錯誤資訊快速定位問題,但是又不希望錯誤處理程式碼寫的冗餘而又啰嗦。Go語言沒有提供像JavaC#語言中的try...catch異常處理方式,而是通過函數返回值逐層往上拋。這種設計,鼓勵在程式碼中顯式的檢查錯誤,而非忽略錯誤,好處就是避免漏掉本應處理的錯誤。但是帶來一個弊端,讓程式碼冗餘。

什麼是錯誤

錯誤指的是可能出現問題的地方出現了問題。如打開一個文件時失敗,這種情況是在意料之中的 。

而異常指的是不應該出現問題的地方出現了問題。比如引用了空指針,這種情況在在意料之外的。可見,錯誤是業務過程的一部分,而異常不是 。

Go中的錯誤也是一種類型。錯誤用內置的error 類型表示。就像其他類型的,如int,float64,。錯誤值可以存儲在變數中,從函數中返回,等等。

演示錯誤

嘗試打開一個不存在的文件:

func main() {  
    f, err := os.Open("/test.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
  //根據f進行文件的讀或寫
    fmt.Println(f.Name(), "opened successfully")
}

在os包中有打開文件的功能函數:

func Open(name string) (file *File, err error)

如果文件已經成功打開,那麼Open函數將返迴文件處理。如果在打開文件時出現錯誤,將返回一個非nil錯誤。

如果一個函數或方法返回一個錯誤,那麼按照慣例,它必須是函數返回的最後一個值。因此,Open 函數返回的值是最後一個值。

處理錯誤的慣用方法是將返回的錯誤與nil進行比較。nil值表示沒有發生錯誤,而非nil值表示出現錯誤。

運行結果:

open /test.txt: No such file or directory

得到一個錯誤,說明該文件不存在。

錯誤類型表示

Go 語言通過內置的錯誤介面提供了非常簡單的錯誤處理機制。

它非常簡單,只有一個 Error 方法用來返回具體的錯誤資訊:

type error interface {
    Error() string
}

它包含一個帶有Error()字元串的方法。任何實現這個介面的類型都可以作為一個錯誤使用。這個方法提供了對錯誤的描述。

當列印錯誤時,fmt.Println函數在內部調用Error() 方法來獲取錯誤的描述。這就是錯誤描述是如何在一行中列印出來的。

從錯誤中提取更多資訊的不同方法

在上面的例子中,僅僅是列印了錯誤的描述。如果想要的是導致錯誤的文件的實際路徑。一種可能的方法是解析錯誤字元串,

open /test.txt: No such file or directory  

可以解析這個錯誤消息並從中獲取文件路徑”/test.txt”。但這是一個糟糕的方法。在新版本的語言中,錯誤描述可以隨時更改,程式碼將會中斷。

標準Go庫使用不同的方式提供更多關於錯誤的資訊。

斷言底層結構類型,結構欄位獲取

如果仔細閱讀打開函數的文檔,可以看到它返回的是PathError類型的錯誤。PathError是一個struct類型,它在標準庫中的實現如下,

type PathError struct {  
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }  

從上面的程式碼中,可以理解PathError通過聲明Error() string方法實現了錯誤介面。該方法連接操作、路徑和實際錯誤並返回它。這樣就得到了錯誤資訊,

open /test.txt: No such file or directory 

PathError結構的路徑欄位包含導致錯誤的文件的路徑。修改上面示例,並列印出路徑:

func main() {  
    f, err := os.Open("/test.txt")
    if err, ok := err.(*os.PathError); ok {
        fmt.Println("File at path", err.Path, "failed to open")
        return
    }
    fmt.Println(f.Name(), "opened successfully")
}

使用類型斷言獲得錯誤介面的基本值。然後用錯誤來列印路徑.這個程式輸出,

File at path /test.txt failed to open  

斷言底層結構類型,使用方法獲取

獲得更多資訊的第二種方法是斷言底層類型,並通過調用struct類型的方法獲取更多資訊:

type DNSError struct {  
    ...
}

func (e *DNSError) Error() string {  
    ...
}
func (e *DNSError) Timeout() bool {  
    ... 
}
func (e *DNSError) Temporary() bool {  
    ... 
}

從上面的程式碼中可以看到,DNSError struct有兩個方法Timeout() bool和Temporary() bool,它們返回一個布爾值,表示錯誤是由於超時還是臨時的。

編寫一個斷言*DNSError類型的程式,並調用這些方法來確定錯誤是臨時的還是超時的。

func main() {  
    addr, err := net.LookupHost("golangbot123.com")
    if err, ok := err.(*net.DNSError); ok {
        if err.Timeout() {
            fmt.Println("operation timed out")
        } else if err.Temporary() {
            fmt.Println("temporary error")
        } else {
            fmt.Println("generic error: ", err)
        }
        return
    }
    fmt.Println(addr)
}

在上面的程式中,嘗試獲取一個無效域名的ip地址,通過聲明它來輸入*net.DNSError來獲得錯誤的潛在價值。

在例子中,錯誤既不是暫時的,也不是由於超時,因此程式會列印:

generic error:  lookup golangbot123.com: no such host  

如果錯誤是臨時的或超時的,那麼相應的If語句就會執行,可以適當地處理它。

直接比較

獲得更多關於錯誤的詳細資訊的第三種方法是直接與類型錯誤的變數進行比較。

filepath包的Glob函數用於返回與模式匹配的所有文件的名稱。當模式出現錯誤時,該函數將返回一個錯誤ErrBadPattern

filepath包中定義了ErrBadPattern,如下所述:

var ErrBadPattern = errors.New("syntax error in pattern")  

errors.New()用於創建新的錯誤。

當模式出現錯誤時,由Glob函數返回ErrBadPattern

func main() {  
    files, error := filepath.Glob("[")
    if error != nil && error == filepath.ErrBadPattern {
        fmt.Println(error)
        return
    }
    fmt.Println("matched files", files)
}

運行結果:

syntax error in pattern  

不要忽略錯誤

永遠不要忽略一個錯誤。忽視錯誤會招致麻煩。下面一個示例,列出了與模式匹配的所有文件的名稱,而忽略了錯誤處理程式碼。

func main() {  
    files, _ := filepath.Glob("[")
    fmt.Println("matched files", files)
}

使用行號中的空白標識符,忽略了Glob函數返回的錯誤:

matched files []  

由於忽略了這個錯誤,輸出看起來好像沒有文件匹配到這個模式,但是實際上這個模式本身是畸形的。所以不要忽略錯誤。

自定義錯誤

創建自定義錯誤可以使用errors包下的New()函數,以及fmt包下的:Errorf()函數。

//errors包:
func New(text string) error {}

//fmt包:
func Errorf(format string, a ...interface{}) error {}

下面提供了錯誤包中的新功能的實現。

// Package errors implements functions to manipulate errors.
  package errors

  // New returns an error that formats as the given text.
  func New(text string) error {
      return &errorString{text}
  }

  // errorString is a trivial implementation of error.
  type errorString struct {
      s string
  }

  func (e *errorString) Error() string {
      return e.s
  }

既然知道了New()函數是如何工作的,那麼就使用它來創建一個自定義錯誤。

創建一個簡單的程式,計算一個圓的面積,如果半徑為負,將返回一個錯誤。

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, errors.New("Area calculation failed, radius is less than zero")
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

運行結果:

Area calculation failed, radius is less than zero 

使用Errorf向錯誤添加更多資訊

上面的程式運行沒有問題,但是如果要列印出導致錯誤的實際半徑,就不好處理了。這就是fmt包的Errorf函數的用武之地。這個函數根據一個格式說明器格式化錯誤,並返回一個字元串作為值來滿足錯誤。

使用Errorf函數,修改程式:

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, fmt.Errorf("Area calculation failed, radius %0.2f is less than zero", radius)
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

運行結果:

Area calculation failed, radius -20.00 is less than zero  

使用結構體和欄位提供錯誤的更多資訊

還可以使用將錯誤介面實現為錯誤的struct類型。這使得錯誤處理更加的靈活。在上述示例中,如果想要訪問導致錯誤的半徑,那麼唯一的方法是解析錯誤描述區域計算失敗,半徑-20.00小於零。這不是一種正確的方法,因為如果描述發生了變化,那麼程式碼就會中斷。

前面提到「斷言底層結構類型從struct欄位獲取更多資訊」,並使用struct欄位來提供對導致錯誤的半徑的訪問。可以創建一個實現錯誤介面的struct類型,並使用它的欄位來提供關於錯誤的更多資訊。

1、創建一個struct類型來表示錯誤。錯誤類型的命名約定是,名稱應該以文本Error結束:

type areaError struct {  
    err    string
    radius float64
}

上面的struct類型有一個欄位半徑,它存儲了為錯誤負責的半徑的值,並且錯誤欄位存儲了實際的錯誤消息。

2、實現error 介面

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

在上面的程式碼片段中,使用一個指針接收器區域錯誤來實現錯誤介面的Error() string方法。這個方法列印出半徑和錯誤描述。

type areaError struct {  
    err    string
    radius float64
}

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, &areaError{"radius is negative", radius}
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            fmt.Printf("Radius %0.2f is less than zero", err.radius)
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

程式輸出:

Radius -20.00 is less than zero

使用結構體方法提供錯誤的更多資訊

1、創建一個結構來表示錯誤。

type areaError struct {  
    err    string //error description
    length float64 //length which caused the error
    width  float64 //width which caused the error
}

上面的錯誤結構類型包含一個錯誤描述欄位,以及導致錯誤的長度和寬度。

2、實現錯誤介面,並在錯誤類型上添加一些方法來提供關於錯誤的更多資訊。

func (e *areaError) Error() string {  
    return e.err
}

func (e *areaError) lengthNegative() bool {  
    return e.length < 0
}

func (e *areaError) widthNegative() bool {  
    return e.width < 0
}

在上面的程式碼片段中,返回Error() string 方法的錯誤描述。當長度小於0時,lengthNegative() bool方法返回true;當寬度小於0時,widthNegative() bool方法返回true。這兩種方法提供了更多關於誤差的資訊。

面積計算函數:

func rectArea(length, width float64) (float64, error) {  
    err := ""
    if length < 0 {
        err += "length is less than zero"
    }
    if width < 0 {
        if err == "" {
            err = "width is less than zero"
        } else {
            err += ", width is less than zero"
        }
    }
    if err != "" {
        return 0, &areaError{err, length, width}
    }
    return length * width, nil
}

上面的rectArea函數檢查長度或寬度是否小於0,如果它返回一個錯誤消息,則返回矩形的面積為nil。

主函數:

func main() {  
    length, width := -5.0, -9.0
    area, err := rectArea(length, width)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            if err.lengthNegative() {
                fmt.Printf("error: length %0.2f is less than zero\n", err.length)

            }
            if err.widthNegative() {
                fmt.Printf("error: width %0.2f is less than zero\n", err.width)

            }
        }
        fmt.Println(err)
        return
    }
    fmt.Println("area of rect", area)
}

運行結果:

error: length -5.00 is less than zero  
error: width -9.00 is less than zero 

錯誤斷言

有了自定義的 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」。

原始錯誤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

可以解釋為:

如果 err 和 target 是同一個,那麼返回 true。

如果 err 是一個 wrapping error,target 也包含在這個嵌套 error 鏈中的話,也返回 true

可以簡單地概括為,兩個 error 相等或 err 包含 target 的情況下返回 true,其餘返回 false。用上面的示例判斷錯誤 w 中是否包含錯誤 e:

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()和recover()

Golang中引入兩個內置函數panicrecover來觸發和終止異常處理流程,同時引入關鍵字defer來延遲執行defer後面的函數。 一直等到包含defer語句的函數執行完畢時,延遲函數(defer後的函數)才會被執行,而不管包含defer語句的函數是通過return的正常結束,還是由於panic導致的異常結束。你可以在一個函數中執行多條defer語句,它們的執行順序與聲明順序相反。 當程式運行時,如果遇到引用空指針、下標越界或顯式調用panic函數等情況,則先觸發panic函數的執行,然後調用延遲函數。調用者繼續傳遞panic,因此該過程一直在調用棧中重複發生:函數停止執行,調用延遲執行函數等。如果一路在延遲函數中沒有recover函數的調用,則會到達該協程的起點,該協程結束,然後終止其他所有協程,包括主協程(類似於C語言中的主執行緒,該協程ID為1)。

panic:

  1. 內建函數
  2. 假如函數F中書寫了panic語句,會終止其後要執行的程式碼,在panic所在函數F內如果存在要執行的defer函數列表,按照defer的逆序執行
  3. 返回函數F的調用者G,在G中,調用函數F語句之後的程式碼不會執行,假如函數G中存在要執行的defer函數列表,按照defer的逆序執行,這裡的defer 有點類似 try-catch-finally 中的 finally
  4. 直到goroutine整個退出,並報告錯誤

recover:

  1. 內建函數
  2. 用來控制一個goroutine的panic行為,捕獲panic,從而影響應用的行為
  3. 一般的調用建議 a). 在defer函數中,通過recever來終止一個goroutine的panic過程,從而恢復正常程式碼的執行 b). 可以獲取通過panic傳遞的error

簡單來講:Go中可以拋出一個panic的異常,然後在defer中通過recover捕獲這個異常,然後正常處理

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

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

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

什麼情況下用錯誤表達,什麼情況下用異常表達,就得有一套規則,否則很容易出現一切皆錯誤或一切皆異常的情況。

以下給出異常處理的作用域(場景):

  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!!!”

這使得相同的錯誤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")

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

層層都加日誌非常方便故障定位。

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

姿勢六:錯誤處理使用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函數會返回可以讀取的位元組數以及錯誤資訊。對於這種情況,應該將讀取到的字元串和錯誤資訊一起列印出來。

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

異常處理的正確姿勢

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

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

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

在Golang中,某個Goroutine如果panic了,並且沒有recover,那麼整個Golang進程就會異常退出。所以,一旦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 {/ 列印 && 錯誤處理 /}”程式碼塊)。

錯誤封裝的實踐

用戶自定義類型

重寫的Go里自帶的error類型,首先從一個自定義的錯誤類型開始,該錯誤類型將在程式中識別為error類型。因此,引入一個封裝了Go的 error的新自定義Error類型。

type GoError struct {
   error
}

上下文數據

當在Go中說error是一個值時,它是字元串值 – 任何實現了Error() string函數的類型都可以視作error類型。將字元串值視為error會使跨層的error處理複雜化,因此處理error字元串資訊並不是正確的方法。所以可以使用嵌套錯誤把字元串和錯誤碼解耦:

type GoError struct {
   error
   Code    string
}

現在對error的處理將基於錯誤碼Code欄位而不是字元串。可以通過上下文數據進一步對錯誤字元串進行解耦,在上下文數據中可以使用i18n包進行國際化。

type GoError struct {
   error
   Code    string
   Data    map[string]interface{}
}

Data包含用於構造錯誤字元串的上下文數據。錯誤字元串可以通過數據模板化:

//i18N def
"InvalidParamValue": "Invalid parameter value '{{.actual}}', expected '{{.expected}}' for '{{.name}}'"

在i18N定義文件中,錯誤碼Code將會映射到使用Data構建的模板化的錯誤字元串中。

原因(Causes)

error可能發生在任何一層,有必要為每一層提供處理error的選項,並在不丟失原始error值的情況下進一步使用附加的上下文資訊對error進行包裝。GoError結構體可以用Causes進一步封裝,用來保存整個錯誤堆棧。

type GoError struct {
   error
   Code    string
   Data    map[string]interface{}
   Causes  []error
}

如果必須保存多個error數據,則causes是一個數組類型,並將其設置為基本error類型,以便在程式中包含該原因的第三方錯誤。

組件(Component)

標記層組件將有助於識別error發生在哪一層,並且可以避免不必要的error wrap。例如,如果service類型的error組件發生在服務層,則可能不需要wrap error。檢查組件資訊將有助於防止暴露給用戶不應該通知的error,比如資料庫error:

type GoError struct {
   error
   Code      string
   Data      map[string]interface{}
   Causes    []error
   Component ErrComponent
}

type ErrComponent string
const (
   ErrService  ErrComponent = "service"
   ErrRepo     ErrComponent = "repository"
   ErrLib      ErrComponent = "library"
)

響應類型(ResponseType)

添加一個錯誤響應類型這樣可以支援error分類,以便於了解什麼錯誤類型。例如,可以根據響應類型(如NotFound)對error進行分類,像DbRecordNotFoundResourceNotFoundUserNotFound等等的error都可以歸類為 NotFound error。這在多層應用程式開發過程中非常有用,而且是可選的封裝:

type GoError struct {
   error
   Code         string
   Data         map[string]interface{}
   Causes       []error
   Component    ErrComponent
   ResponseType ResponseErrType
}

type ResponseErrType string

const (
   BadRequest    ResponseErrType = "BadRequest"
   Forbidden     ResponseErrType = "Forbidden"
   NotFound      ResponseErrType = "NotFound"
   AlreadyExists ResponseErrType = "AlreadyExists"
)

重試

在少數情況下,出現error會進行重試。retry欄位可以通過設置Retryable標記來決定是否要進行error重試:

type GoError struct {
   error
   Code         string
   Message      string
   Data         map[string]interface{}
   Causes       []error
   Component    ErrComponent
   ResponseType ResponseErrType
   Retryable    bool
}

GoError 介面

通過定義一個帶有GoError實現的顯式error介面,可以簡化error檢查:

package goerr

type Error interface {
   error

   Code() string
   Message() string
   Cause() error
   Causes() []error
   Data() map[string]interface{}
   String() string
   ResponseErrType() ResponseErrType
   SetResponseType(r ResponseErrType) Error
   Component() ErrComponent
   SetComponent(c ErrComponent) Error
   Retryable() bool
   SetRetryable() Error
}

抽象error

有了上述的封裝方式,更重要的是對error進行抽象,將這些封裝保存在同一地方,並提供error函數的可重用性

func ResourceNotFound(id, kind string, cause error) GoError {
   data := map[string]interface{}{"kind": kind, "id": id}
   return GoError{
      Code:         "ResourceNotFound",
      Data:         data,
      Causes:       []error{cause},
      Component:    ErrService,
      ResponseType: NotFound,
      Retryable:    false,
   }
}

這個error函數抽象了ResourceNotFound這個error,開發者可以使用這個函數來返回error對象而不是每次創建一個新的對象:

//UserService
user, err := u.repo.FindUser(ctx, userId)
if err != nil {
   if err.ResponseType == NotFound {
      return ResourceNotFound(userUid, "User", err)
   }
   return err
}

結論

我們演示了如何使用添加上下文數據的自定義Go的error類型,從而使得error在多層應用程式中更有意義。可以在這裡[1]看到完整的程式碼實現和定義。

參考資料

[1] 這裡: //gist.github.com/prathabk/744367cbfc70435c56956f650612d64b

Tags: