Golang通脈之錯誤處理

發表於2021-10-26

在實際工程專案中,總是通過程式的錯誤資訊快速定位問題,但是又不希望錯誤處理程式碼寫的冗餘而又囉嗦。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!!!"
  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")

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

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

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

姿勢六:錯誤處理使用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] 這裡: https://gist.github.com/prathabk/744367cbfc70435c56956f650612d64b

相關文章