Go 函式多返回值錯誤處理與error 型別介紹

賈維斯Echo發表於2023-10-18

Go 函式多返回值錯誤處理與error 型別介紹

一、error 型別與錯誤值構造

1.1 Error 介面介紹

在Go語言中,error 型別是一個介面型別,通常用於表示錯誤。它定義如下:

type error interface {
    Error() string
}

error 介面只有一個方法,即 Error() 方法,該方法返回一個描述錯誤的字串。這意味著任何實現了 Error() 方法的型別都可以被用作錯誤型別。通常,Go程式中的函式在遇到錯誤時會返回一個 error 型別的值,以便呼叫方可以處理或記錄錯誤資訊。

1.2 構造錯誤值的方法

1.2.1 使用errors包

Go 語言的設計者提供了兩種方便 Go 開發者構造錯誤值的方法: errors.Newfmt.Errorf

  • errors.New() 函式是建立最簡單的錯誤值的方法,它只包含一個錯誤訊息字串。這個方法適用於建立簡單的錯誤值。
  • fmt.Errorf() 函式允許你構造一個格式化的錯誤訊息,類似於 fmt.Printf() 函式。這對於需要構建更復雜的錯誤訊息時非常有用。

使用這兩種方法,我們可以輕鬆構造出一個滿足 error 介面的錯誤值,就像下面程式碼這樣:

err := errors.New("your first demo error")
errWithCtx = fmt.Errorf("index %d is out of bounds", i)

這兩種方法實際上返回的是同一個實現了 error 介面的型別的例項,這個未匯出的型別就是 errors.errorString,它的定義是這樣的:

// $GOROOT/src/errors/errors.go

type errorString struct {
    s string
}

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

大多數情況下,使用這兩種方法構建的錯誤值就可以滿足我們的需求了。但我們也要看到,雖然這兩種構建錯誤值的方法很方便,但它們給錯誤處理者提供的錯誤上下文(Error Context)只限於以字串形式呈現的資訊,也就是 Error 方法返回的資訊。

1.2.2 自定義錯誤型別

在一些場景下,錯誤處理者需要從錯誤值中提取出更多資訊,幫助他選擇錯誤處理路徑,顯然這兩種方法就不能滿足了。這個時候,我們可以自定義錯誤型別來滿足這一需求。以下是一個示例:

package main

import "fmt"

// 自定義錯誤型別
type MyError struct {
	ErrorCode    int
	ErrorMessage string
}

// 實現 error 介面的 Error 方法
func (e MyError) Error() string {
	return fmt.Sprintf("錯誤 %d: %s", e.ErrorCode, e.ErrorMessage)
}

func someFunction() error {
	// 建立自定義錯誤值
	err := MyError{
		ErrorCode:    404,
		ErrorMessage: "未找到",
	}
	return err
}

func main() {
	// 呼叫 someFunction,返回自定義錯誤值
	err := someFunction()
	// 列印錯誤資訊
	fmt.Println("錯誤:", err)
}

我們再來看一個例子,比如:標準庫中的 net 包就定義了一種攜帶額外錯誤上下文的錯誤型別:

// $GOROOT/src/net/net.go
type OpError struct {
    Op string
    Net string
    Source Addr
    Addr Addr
    Err error
}

這樣,錯誤處理者就可以根據這個型別的錯誤值提供的額外上下文資訊,比如 Op、Net、Source 等,做出錯誤處理路徑的選擇,比如下面標準庫中的程式碼:

// $GOROOT/src/net/http/server.go
func isCommonNetReadError(err error) bool {
    if err == io.EOF {
        return true
    }
    if neterr, ok := err.(net.Error); ok && neterr.Timeout() {
        return true
    }
    if oe, ok := err.(*net.OpError); ok && oe.Op == "read" {
        return true
    }
    return false
}

我們看到,上面這段程式碼利用型別斷言(Type Assertion),判斷 error 型別變數 err 的動態型別是否為 *net.OpError 或 net.Error。如果 err 的動態型別是 *net.OpError,那麼型別斷言就會返回這個動態型別的值(儲存在 oe 中),程式碼就可以透過判斷它的 Op 欄位是否為"read"來判斷它是否為 CommonNetRead 型別的錯誤。

二、error 型別的好處

2.1 第一點:統一了錯誤型別

如果不同開發者的程式碼、不同專案中的程式碼,甚至標準庫中的程式碼,都統一以 error 介面變數的形式呈現錯誤型別,就能在提升程式碼可讀性的同時,還更容易形成統一的錯誤處理策略。

2.2 第二點:錯誤是值

我們構造的錯誤都是值,也就是說,即便賦值給 error 這個介面型別變數,我們也可以像整型值那樣對錯誤做“==”和“!=”的邏輯比較,函式呼叫者檢視錯誤時的體驗保持不變。

由於 error 是一個介面型別,預設零值為nil。所以我們通常將呼叫函式返回的錯誤與nil進行比較,以此來判斷函式是否返回錯誤。如果返回的錯誤為 nil,則表示函式執行成功,否則表示出現了錯誤。這種約定使得錯誤處理變得一致和直觀。例如你會經常看到類似下面的錯誤判斷程式碼。

func someFunction() error {
    // 模擬一個出錯的情況
    return errors.New("這是一個錯誤")
}

func main() {
    err := someFunction()

    if err != nil {
        fmt.Println("函式執行失敗,錯誤資訊:", err)
    } else {
        fmt.Println("函式執行成功")
    }
}

2.3 第三點:易擴充套件,支援自定義錯誤上下文

雖然錯誤以 error 介面變數的形式統一呈現,但我們很容易透過自定義錯誤型別來擴充套件我們的錯誤上下文,就像前面的 Go 標準庫的 OpError 型別那樣。

error 介面是錯誤值的提供者與錯誤值的檢視者之間的契約。error 介面的實現者負責提供錯誤上下文,供負責錯誤處理的程式碼使用。這種錯誤具體上下文與作為錯誤值型別的 error 介面型別的解耦,也體現了 Go 組合設計哲學中“正交”的理念。

三、Go 錯誤處理的慣用策略

3.1 策略一:透明錯誤處理策略

簡單來說,Go 語言中的錯誤處理,就是根據函式 / 方法返回的 error 型別變數中攜帶的錯誤值資訊做決策,並選擇後續程式碼執行路徑的過程。

這樣,最簡單的錯誤策略莫過於完全不關心返回錯誤值攜帶的具體上下文資訊,只要發生錯誤就進入唯一的錯誤處理執行路徑,比如下面這段程式碼:

err := doSomething()
if err != nil {
    // 不關心err變數底層錯誤值所攜帶的具體上下文資訊
    // 執行簡單錯誤處理邏輯並返回
    ... ...
    return err
}

這是 Go 語言中最常見的錯誤處理策略,80% 以上的 Go 錯誤處理情形都可以歸類到這種策略下。在這種策略下,由於錯誤處理方並不關心錯誤值的上下文,所以錯誤值的構造方(如上面的函式 doSomething)可以直接使用 Go 標準庫提供的兩個基本錯誤值構造方法 errors.Newfmt.Errorf 來構造錯誤值,就像下面這樣:

func doSomething(...) error {
    ... ...
    return errors.New("some error occurred")
}

這樣構造出的錯誤值代表的上下文資訊,對錯誤處理方是透明的,因此這種策略稱為“透明錯誤處理策略”。在錯誤處理方不關心錯誤值上下文的前提下,透明錯誤處理策略能最大程度地減少錯誤處理方與錯誤值構造方之間的耦合關係。

3.2 策略二:“哨兵”錯誤處理策略

當錯誤處理方不能只根據“透明的錯誤值”就做出錯誤處理路徑選取的情況下,錯誤處理方會嘗試對返回的錯誤值進行檢視,於是就有可能出現下面程式碼中的反模式

data, err := b.Peek(1)
if err != nil {
    switch err.Error() {
    case "bufio: negative count":
        // ... ...
        return
    case "bufio: buffer full":
        // ... ...
        return
    case "bufio: invalid use of UnreadByte":
        // ... ...
        return
    default:
        // ... ...
        return
    }
}

簡單來說,反模式就是,錯誤處理方以透明錯誤值所能提供的唯一上下文資訊(描述錯誤的字串),作為錯誤處理路徑選擇的依據。但這種“反模式”會造成嚴重的隱式耦合。這也就意味著,錯誤值構造方不經意間的一次錯誤描述字串的改動,都會造成錯誤處理方處理行為的變化,並且這種透過字串比較的方式,對錯誤值進行檢視的效能也很差。

那這有什麼辦法嗎?Go 標準庫採用了定義匯出的(Exported)“哨兵”錯誤值的方式,來輔助錯誤處理方檢視(inspect)錯誤值並做出錯誤處理分支的決策,比如下面的 bufio 包中定義的“哨兵錯誤”:

// $GOROOT/src/bufio/bufio.go
var (
    ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
    ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
    ErrBufferFull        = errors.New("bufio: buffer full")
    ErrNegativeCount     = errors.New("bufio: negative count")
)

下面的程式碼片段利用了上面的哨兵錯誤,進行錯誤處理分支的決策:

data, err := b.Peek(1)
if err != nil {
    switch err {
    case bufio.ErrNegativeCount:
        // ... ...
        return
    case bufio.ErrBufferFull:
        // ... ...
        return
    case bufio.ErrInvalidUnreadByte:
        // ... ...
        return
    default:
        // ... ...
        return
    }
}

你可以看到,一般“哨兵”錯誤值變數以 ErrXXX 格式命名。和透明錯誤策略相比,“哨兵”策略讓錯誤處理方在有檢視錯誤值的需求時候,可以“有的放矢”。

不過,對於 API 的開發者而言,暴露“哨兵”錯誤值也意味著這些錯誤值和包的公共函式 / 方法一起成為了 API 的一部分。一旦釋出出去,開發者就要對它進行很好的維護。而“哨兵”錯誤值也讓使用這些值的錯誤處理方對它產生了依賴。

Go 1.13 版本開始標準庫 errors 包提供了 Is 函式用於錯誤處理方對錯誤值的檢視。Is 函式類似於把一個 error 型別變數與“哨兵”錯誤值進行比較,比如下面程式碼:

// 類似 if err == ErrOutOfBounds{ … }
if errors.Is(err, ErrOutOfBounds) {
    // 越界的錯誤處理
}

不同的是,如果 error 型別變數的底層錯誤值是一個包裝錯誤(Wrapped Error),errors.Is 方法會沿著該包裝錯誤所在錯誤鏈(Error Chain),與鏈上所有被包裝的錯誤(Wrapped Error)進行比較,直至找到一個匹配的錯誤為止。下面是 Is 函式應用的一個例子:

var ErrSentinel = errors.New("the underlying sentinel error")

func main() {
  err1 := fmt.Errorf("wrap sentinel: %w", ErrSentinel)
  err2 := fmt.Errorf("wrap err1: %w", err1)
    println(err2 == ErrSentinel) //false
  if errors.Is(err2, ErrSentinel) {
    println("err2 is ErrSentinel")
    return
  }

  println("err2 is not ErrSentinel")
}

在這個例子中,我們透過 fmt.Errorf 函式,並且使用%w建立包裝錯誤變數 err1 和 err2,其中 err1 實現了對 ErrSentinel 這個“哨兵錯誤值”的包裝,而 err2 又對 err1 進行了包裝,這樣就形成了一條錯誤鏈。位於錯誤鏈最上層的是 err2,位於最底層的是 ErrSentinel。之後,我們再分別透過值比較和 errors.Is 這兩種方法,判斷 err2 與 ErrSentinel 的關係。執行上述程式碼,我們會看到如下結果:

false
err2 is ErrSentinel

我們看到,透過比較運運算元對 err2 與 ErrSentinel 進行比較後,我們發現這二者並不相同。而 errors.Is 函式則會沿著 err2 所在錯誤鏈,向下找到被包裝到最底層的“哨兵”錯誤值ErrSentinel

如果你使用的是 Go 1.13 及後續版本,建議你儘量使用errors.Is方法去檢視某個錯誤值是否就是某個預期錯誤值,或者包裝了某個特定的“哨兵”錯誤值。

3.3 策略三:錯誤值型別檢視策略

上面我們看到,基於 Go 標準庫提供的錯誤值構造方法構造的“哨兵”錯誤值,除了讓錯誤處理方可以“有的放矢”的進行值比較之外,並沒有提供其他有效的錯誤上下文資訊。那如果遇到錯誤處理方需要錯誤值提供更多的“錯誤上下文”的情況,上面這些錯誤處理策略和錯誤值構造方式都無法滿足。

這種情況下,我們需要透過自定義錯誤型別的構造錯誤值的方式,來提供更多的“錯誤上下文”資訊。並且,由於錯誤值都透過 error 介面變數統一呈現,要得到底層錯誤型別攜帶的錯誤上下文資訊,錯誤處理方需要使用 Go 提供的型別斷言機制(Type Assertion)或型別選擇機制(Type Switch),這種錯誤處理方式,我稱之為錯誤值型別檢視策略

我們來看一個標準庫中的例子加深下理解,這個 json 包中自定義了一個 UnmarshalTypeError 的錯誤型別:

// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
    Value  string       
    Type   reflect.Type 
    Offset int64        
    Struct string       
    Field  string      
}

錯誤處理方可以透過錯誤型別檢視策略,獲得更多錯誤值的錯誤上下文資訊,下面就是利用這一策略的json包的一個方法的實現:

// $GOROOT/src/encoding/json/decode.go
func (d *decodeState) addErrorContext(err error) error {
    if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
        switch err := err.(type) {
        case *UnmarshalTypeError:
            err.Struct = d.errorContext.Struct.Name()
            err.Field = strings.Join(d.errorContext.FieldStack, ".")
            return err
        }
    }
    return err
}

我們看到,這段程式碼透過型別 switch 語句得到了 err 變數代表的動態型別和值,然後在匹配的 case 分支中利用錯誤上下文資訊進行處理。

這裡,一般自定義匯出的錯誤型別以 XXXError 的形式命名。和“哨兵”錯誤處理策略一樣,錯誤值型別檢視策略,由於暴露了自定義的錯誤型別給錯誤處理方,因此這些錯誤型別也和包的公共函式 / 方法一起,成為了 API 的一部分。一旦釋出出去,開發者就要對它們進行很好的維護。而它們也讓使用這些型別進行檢視的錯誤處理方對其產生了依賴。

Go 1.13 版本開始,標準庫 errors 包提供了As函式給錯誤處理方檢視錯誤值。As函式類似於透過型別斷言判斷一個 error 型別變數是否為特定的自定義錯誤型別,如下面程式碼所示:

// 類似 if e, ok := err.(*MyError); ok { … }
var e *MyError
if errors.As(err, &e) {
    // 如果err型別為*MyError,變數e將被設定為對應的錯誤值
}

不同的是,如果 error 型別變數的動態錯誤值是一個包裝錯誤,errors.As函式會沿著該包裝錯誤所在錯誤鏈,與鏈上所有被包裝的錯誤的型別進行比較,直至找到一個匹配的錯誤型別,就像 errors.Is 函式那樣。下面是As函式應用的一個例子:

type MyError struct {
    e string
}

func (e *MyError) Error() string {
    return e.e
}

func main() {
    var err = &MyError{"MyError error demo"}
    err1 := fmt.Errorf("wrap err: %w", err)
    err2 := fmt.Errorf("wrap err1: %w", err1)
    var e *MyError
    if errors.As(err2, &e) {
        println("MyError is on the chain of err2")
        println(e == err)                  
        return                             
    }                                      
    println("MyError is not on the chain of err2")
} 

執行上述程式碼會得到:

MyError is on the chain of err2
true

我們看到,errors.As 函式沿著 err2 所在錯誤鏈向下找到了被包裝到最深處的錯誤值,並將 err2 與其型別 * MyError 成功匹配。匹配成功後,errors.As 會將匹配到的錯誤值儲存到 As 函式的第二個引數中,這也是為什麼 println(e == err)輸出 true 的原因。

如果你使用的是 Go 1.13 及後續版本,請儘量使用 errors.As方法去檢視某個錯誤值是否是某自定義錯誤型別的例項

3.4 策略四:錯誤行為特徵檢視策略

不知道你注意到沒有,在前面我們已經講過的三種策略中,其實只有第一種策略,也就是“透明錯誤處理策略”,有效降低了錯誤的構造方與錯誤處理方兩者之間的耦合。雖然前面的策略二和策略三,都是我們實際編碼中有效的錯誤處理策略,但其實使用這兩種策略的程式碼,依然在錯誤的構造方與錯誤處理方兩者之間建立了耦合。

那麼除了“透明錯誤處理策略”外,我們是否還有手段可以降低錯誤處理方與錯誤值構造方的耦合呢?

在 Go 標準庫中,我們發現了這樣一種錯誤處理方式:將某個包中的錯誤型別歸類,統一提取出一些公共的錯誤行為特徵,並將這些錯誤行為特徵放入一個公開的介面型別中。這種方式也被叫做錯誤行為特徵檢視策略。

以標準庫中的net包為例,它將包內的所有錯誤型別的公共行為特徵抽象並放入 net.Error 這個介面中,如下面程式碼:

// $GOROOT/src/net/net.go
type Error interface {
    error
    Timeout() bool  
    Temporary() bool
}

我們看到,net.Error 介面包含兩個用於判斷錯誤行為特徵的方法:Timeout 用來判斷是否是超時(Timeout)錯誤,Temporary 用於判斷是否是臨時(Temporary)錯誤。

而錯誤處理方只需要依賴這個公共介面,就可以檢視具體錯誤值的錯誤行為特徵資訊,並根據這些資訊做出後續錯誤處理分支選擇的決策。

這裡,我們再看一個 http 包使用錯誤行為特徵檢視策略進行錯誤處理的例子,加深下理解:

// $GOROOT/src/net/http/server.go
func (srv *Server) Serve(l net.Listener) error {
    ... ...
    for {
        rw, e := l.Accept()
        if e != nil {
            select {
            case <-srv.getDoneChan():
                return ErrServerClosed
            default:
            }
            if ne, ok := e.(net.Error); ok && ne.Temporary() {
                // 注:這裡對臨時性(temporary)錯誤進行處理
                ... ...
                time.Sleep(tempDelay)
                continue
            }
            return e
        }
        ...
    }
    ... ...
}

在上面程式碼中,Accept 方法實際上返回的錯誤型別為 *OpError,它是 net 包中的一個自定義錯誤型別,它實現了錯誤公共特徵介面 net.Error,如下程式碼所示:

// $GOROOT/src/net/net.go
type OpError struct {
    ... ...
    // Err is the error that occurred during the operation.
    Err error
}

type temporary interface {
    Temporary() bool
}

func (e *OpError) Temporary() bool {
  if ne, ok := e.Err.(*os.SyscallError); ok {
      t, ok := ne.Err.(temporary)
      return ok && t.Temporary()
  }
  t, ok := e.Err.(temporary)
  return ok && t.Temporary()
}

因此,OpError 例項可以被錯誤處理方透過 net.Error 介面的方法,判斷它的行為是否滿足 Temporary 或 Timeout 特徵。

四、總結

Go 語言統一錯誤型別為 error 介面型別,並提供了多種快速構建可賦值給 error 型別的錯誤值的函式,包括 errors.New、fmt.Errorf 等,我們還講解了使用統一 error 作為錯誤型別的優點,你要深刻理解這一點。

基於 Go 錯誤處理機制、統一的錯誤值型別以及錯誤值構造方法的基礎上,Go 語言形成了多種錯誤處理的慣用策略,包括透明錯誤處理策略、“哨兵”錯誤處理策略、錯誤值型別檢視策略以及錯誤行為特徵檢視策略等。這些策略都有適用的場合,但沒有某種單一的錯誤處理策略可以適合所有專案或所有場合。

在錯誤處理策略選擇上,你可以參考以下:

  • 請儘量使用“透明錯誤”處理策略,降低錯誤處理方與錯誤值構造方之間的耦合;
  • 如果可以從眾多錯誤型別中提取公共的錯誤行為特徵,那麼請儘量使用“錯誤行為特徵檢視策略”;
  • 在上述兩種策略無法實施的情況下,再使用“哨兵”策略和“錯誤值型別檢視”策略;
  • Go 1.13 及後續版本中,儘量用 errors.Iserrors.As 函式替換原先的錯誤檢視比較語句。

相關文章