博客 / 詳情

返回

Go 錯誤處理指北:如何優雅的處理錯誤?

**公眾號首發地址:https://mp.weixin.qq.com/s/ImvwsAUhQ3MMZkKvnbNB3A
關注公眾號第一時間查看文章更新。**

本文是 Go 錯誤處理指北系列第三篇文章:如何優雅的處理錯誤?作為鋪墊,我在系列的前兩篇文章 Error vs Exception vs ErrNo 和 pkg/errors 源碼解讀 中分別講解了 Go 錯誤處理機制和流行的第三方包 pkg/errors,現在是時候對 Go 語言中的錯誤處理做一個比較全面的講解了。

Go 中為什麼沒有 Exception

我在 Error vs Exception vs ErrNo 一文中對比過 Python、C、Go 這三種編程語言錯誤處理的不同之處。其中 Python 的 Exception 異常處理機制是主流編程語言中最為流行的方式,可是 Go 為什麼採用了 Error 機制呢?

Go 官方的 FAQ: Why does Go not have exceptions? 中給出瞭解釋:

我們認為,將異常與控制結構耦合在一起(如 try-catch-finally 語句)會導致代碼變得複雜。同時,這也往往會促使程序員將太多普通的錯誤(比如打開文件失敗)標記為異常。

Go 採用了一種不同的處理方式。對於普通的錯誤處理,Go 函數支持多返回值機制使得在不覆蓋返回值的情況下,能夠輕鬆地報告錯誤。Go 還提供了一個標準的錯誤類型,再加上其他特性,使得錯誤處理變得簡潔而又與其他語言截然不同。

Go 還提供了一些內置函數,用於標識和恢復真正的異常情況。恢復機制只會在函數狀態因錯誤而被銷燬時執行,這足以處理災難性錯誤,同時不需要額外的控制結構。使用得當時,可以寫出簡潔的錯誤處理代碼。

詳情請參考 Defer, Panic, and Recover 一文。另外,博客文章 Errors are values 展示了一種整潔的錯誤處理方式,説明了由於錯誤只是值,Go 語言的全部能力都可以用於處理錯誤。

説白了,Go 官方認為 Error 機制更簡單有效,且符合 Go 語言大道至簡的調性。

構造錯誤

既然要講解如何處理錯誤,那麼就先從如何構造一個錯誤説起吧。

我們知道,Go 的 error 實際上就是一個普通的接口,普普通通:

type error interface {
    Error() string
}

得益於 Go 函數支持多返回值的能力,我們可以非常方便的返回一個錯誤:

func foo() (string, error) {
    // do something
    return "", nil
}
NOTE:
當函數返回多個值時,error 作為最後一個返回值是約定俗成的慣用法。如果你不這麼做,代碼當然能成功編譯,但你有更好的選擇。

Go 提供了兩種構造錯誤的方式:

// 創建一個錯誤值
err1 := errors.New("example err1")
// 格式化錯誤消息
err2 := fmt.Errorf("example err2: %d", userID)

這兩種構造錯誤的方式最終都是返回 errorString 類型的指針:

// errors.New 函數定義
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
}
NOTE:
其實 fmt.Errorf 內部也是調用 errors.New 來創建 error。當然,在 Go 1.13 版本以後,fmt.Errorf 可能會在特定條件下返回 wrapError 類型錯誤。

處理錯誤

現在我們已經可以構造一個錯誤,接下來看看如何優雅的處理錯誤。

錯誤處理慣用法

如下示例是 Go 中經典的錯誤處理方式:

data, err := foo()
if err != nil {
    // 處理錯誤
    return
}
// 正常邏輯
fmt.Println(data)

一切的錯誤處理都從 if err != nil 開始。

Sentinel error

預定義的錯誤值:Sentinel error,一般被譯為 哨兵錯誤。這是一種錯誤處理慣用法,在 Go 內置包中有大量應用。

比如:

https://github.com/golang/go/blob/go1.23.1/src/bufio/bufio.go#L22
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")
)

或者:

https://github.com/golang/go/blob/go1.23.1/src/io/io.go#L29
var ErrShortWrite = errors.New("short write")
var errInvalidWrite = errors.New("invalid write result")
var ErrShortBuffer = errors.New("short buffer")
var EOF = errors.New("EOF")
var ErrUnexpectedEOF = errors.New("unexpected EOF")
var ErrNoProgress = errors.New("multiple Read calls return no data or error")

這些都叫 Sentinel error,絕大多數 Sentinel error 都會被定義為包級別公開變量,可以看到也有內置的 errInvalidWrite 並沒有對外公開。

每個 error 變量都以前綴 Err 開頭,這是約定俗成的做法。io.EOF 是個特例,因為 EOF 是另一種約定用法,它的全拼是 end of file,表示文件結束,應用非常廣泛,可以算作專有名詞了。

我們可以像這樣處理 Sentinel error

if err != nil {
    if err == bufio.ErrBufferFull {
        // handle ErrBufferFull
    }
    // handle err
}

要處理多種錯誤類型時,可以使用 switch...case... 語句來簡化處理:

f, err := os.Open("example.txt")
if err != nil {
    return
}

b := bufio.NewReader(f)

data, err := b.Peek(10)
if err != nil {
    switch err {
    case bufio.ErrNegativeCount:
        // do something
        return
    case bufio.ErrBufferFull:
        // do something
        return
    default:
        // do something
        return
    }
}
fmt.Println(string(data))

示例中 b.Peek(10) 可能會返回 ErrNegativeCountErrBufferFull 錯誤變量,因為它們是依賴包中可導出的公開變量,所以我們可以在自己的代碼中使用這些變量來識別返回了哪個特定的錯誤消息。

也就是説,這些 Sentinel error 變量會成為包 API 的一部分,用於錯誤處理。

如果沒有 Sentinel error 的存在,我們可能需要通過字符串匹配的方式來識別錯誤類型:

if err != nil {
    if strings.Contains(err.Error(), "buffer full") {

    }
}

我個人完全不贊成這種寫法,不到萬不得已,千萬不要寫成這種代碼。

記住:error 接口上的 Error 方法適用於人類,而非代碼。只有我們需要查看錯誤信息,或者記錄日誌的時候,才應該使用 Error 方法。

此外,你可能在標準庫中見到過如下類似代碼:

https://github.com/golang/go/blob/go1.23.1/src/os/error.go#L16
var (
    // ErrInvalid indicates an invalid argument.
    // Methods on File will return this error when the receiver is nil.
    ErrInvalid = fs.ErrInvalid // "invalid argument"

    ErrPermission = fs.ErrPermission // "permission denied"
    ErrExist      = fs.ErrExist      // "file already exists"
    ErrNotExist   = fs.ErrNotExist   // "file does not exist"
    ErrClosed     = fs.ErrClosed     // "file already closed"
)

os.ErrInvalid 實際上等價於 fs.ErrInvalid,這種為 Sentinel error 重新賦值的操作也很常見。為了保持良好的分層架構,我們自己的代碼設計也可以這樣做。

另外,Sentinel error 還有一種看似“另類”的用法,表示錯誤沒有發生,比如 path/filepath.SkipDir

https://github.com/golang/go/blob/go1.23.1/src/path/filepath/path.go#L259
// SkipDir is used as a return value from [WalkDirFunc] to indicate that
// the directory named in the call is to be skipped. It is not returned
// as an error by any function.
var SkipDir = errors.New("skip this directory")

根據註釋我們可以瞭解到,SkipDir 變量用作 WalkDirFunc 的返回值,以指示將跳過調用中指定的目錄,它並不表示一個錯誤。

所以這裏 SkipDir 僅作為哨兵,而非錯誤。其實 io.EOF 也是哨兵,並且它們都沒有以 Err 來命名。

這也是我認為 Sentinel error 存在二義性的地方,我個人認為絕大多數情況下不應該這麼使用,儘量避免這種用法。

常量錯誤

因為 Sentinel error 是一個變量,所以我們可以隨意改變它的值:

oldEOF := io.EOF
io.EOF = errors.New("MyEOF")
fmt.Println(oldEOF == io.EOF) // false

這是一個很可怕的事情。

所以 Sentinel error 的確不是一個好的設計,起碼也應該將其定義成一個常量。

但問題是在 Go 中我們無法直接將 errors.New 的返回值賦值給一個常量。

如下示例:

const ErrMyEOF = errors.New("MyEOF")

這將得到編譯報錯:

errors.New("MyEOF") (value of type error) is not constant

為了解決這個問題,我們可以自定義 error 類型:

type Error string

func (e Error) Error() string { return string(e) }

Error 類型底層類型為 string,所以可以直接賦值給一個常量:

const ErrMyEOF = Error("MyEOF")

現在常量 ErrMyEOF 不可改變。

但是,這又會引入另外一個新的問題。以下示例代碼,執行結果為 true

const ErrMyEOF = Error("MyEOF")
const ErrNewMyEOF = Error("MyEOF")
fmt.Println(ErrMyEOF == ErrNewMyEOF) // true

這與 Go 內置的 errors.New 表現並不相同。

以下示例代碼,執行結果為 false

myEOF = errors.New("EOF")
fmt.Println(io.EOF == myEOF) // false

造成二者表現不同的原因是:內置的 errors.New 函數返回 errorString 的指針類型 &errorString{text},而我們構造的自定義 Error 實際上是 string 類型。

errors.New 返回指針類型是有意而為之的,目的就是在判斷兩個錯誤值是否相等時,會比較兩個對象是否為同一個對象,而不是比較 Error 方法所返回的字符串內容是否相等。如果僅比較字符串內容是否相等,則我們隨便使用 errors.New 函數創建的錯誤就可以實現與預置的 Sentinel error 相等。

所以常量錯誤並不常見,我個人其實也不太推薦一定要追求把錯誤定義為常量,適當引入的編碼規範更加切合實際。

儘管 errorString 類型僅包含一個字段 s string,但它還是被有意設計成 struct 而非簡單的 string 類型別名,否則 Sentinel error 實用價值將大大折扣。

定製錯誤類型

與使用 errors.New 創建出來的 *errorString 錯誤值相比,定製錯誤類型往往能提供更多的上下文信息。

Go 內置庫中就有這樣的例子,比如錯誤類型 os.PathError

https://github.com/golang/go/blob/go1.23.1/src/io/fs/fs.go#L250
// PathError records an error and the operation and file path that caused it.
type PathError struct {
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
NOTE:
錯誤類型命名通常以 Error 結尾,這是約定俗成的慣用法。

PathError 類型不僅能夠記錄錯誤,還會記錄導致出現錯誤的操作和文件路徑。在出現錯誤時,更方便排查問題。

有了新的錯誤類型後,最大的好處是可以通過類型斷言,來判斷錯誤的類型。如果斷言成立,則可以根據錯誤類型對當前錯誤做更為精細的控制。

示例如下:

// 嘗試打開一個不存在的文件
_, err := os.Open("nonexistent.txt")
if err != nil {
    // 使用類型斷言檢查是否為 *os.PathError 類型
    if pathErr, ok := err.(*os.PathError); ok {
        fmt.Printf("Failed to %s file: %s\n", pathErr.Op, pathErr.Path)
        fmt.Println("Error message:", pathErr.Err)
    } else {
        // 其他類型的錯誤處理
        fmt.Println("Error:", err)
    }
}

可以發現,為了實現錯誤類型的斷言檢查,PathError 類型必須是公開類型。

其實無論是 Sentinel error,還是自定義的錯誤類型,它們都存在同樣的問題,都會成為包 API 的一部分,被公開出去。這很可能導致包 API 的快速膨脹。並且,如果代碼分層設計不好,很容易出現循環依賴問題。

Opaque error

Opaque error 是 Go 語言佈道師 Dave Cheney 在 Gocon Spring 2016 演講中提出的一種叫法,姑且把它翻譯為 不透明的錯誤處理

Opaque error 非常簡單,它是最靈活的錯誤處理策略,因為它需要代碼和調用者之間的耦合最少。

示例如下:

import “github.com/quux/bar”

func fn() error {
        x, err := bar.Foo()
        if err != nil {
                return err
        }
        // use x
}

這就是 Opaque error 的全部內容了:只需返回錯誤,而不對其內容做出任何假設。

沒錯,遇到錯誤後直接 return err 的做法就是 Opaque error

顯然,這種代碼看似優雅,卻過於理想。現實中我們仍有很多情況下還是需要知道錯誤內容,然後決定是否對其進行處理。

錯誤值比較

比較兩個錯誤值是否相等的操作,一般結合 Sentinel error 一同使用:

if err != nil {
    if err == bufio.ErrBufferFull {
        // handle ErrBufferFull
    }
    // handle err
}

先使用 if err != nilnil 比較來判定是否存在錯誤,如果有錯誤,更進一步,使用 if err == bufio.ErrBufferFull 來判定錯誤是否為某個 Sentinel error

當可能出現多種錯誤時,還可以使用 switch...case... 來判定錯誤值:

if err != nil {
    switch err {
    case bufio.ErrNegativeCount:
        // do something
        return
    case bufio.ErrBufferFull:
        // do something
        return
    default:
        // do something
        return
    }
}

類型斷言

Go 支持兩種類型斷言,Type Assertion 和 Type Switch。

Go 的類型斷言語法可以直接應用於錯誤處理,因為 error 本身就是一個普通的接口。

斷言一個錯誤的類型,其實前文中我們已經見過了:

// 嘗試打開一個不存在的文件
_, err := os.Open("nonexistent.txt")
if err != nil {
    // 使用類型斷言檢查是否為 *os.PathError 類型
    if pathErr, ok := err.(*os.PathError); ok {
        fmt.Printf("Failed to %s file: %s\n", pathErr.Op, pathErr.Path)
        fmt.Println("Error message:", pathErr.Err)
    } else {
        // 其他類型的錯誤處理
        fmt.Println("Error:", err)
    }
}

如果改用 switch...case... 可以這樣寫:

// 嘗試打開一個不存在的文件
_, err := os.Open("nonexistent.txt")
if err != nil {
    // 使用 switch type 檢查錯誤類型
    switch e := err.(type) {
    case *os.PathError:
        fmt.Printf("Failed to %s file: %s\n", e.Op, e.Path)
        fmt.Println("Error message:", e.Err)
    default:
        // 其他類型的錯誤處理
        fmt.Println("Error:", err)
    }
}

值得一提的是,在使用 Type Switch 語法時,是禁止使用 fallthrough 關鍵字的,否則編譯報錯 cannot fallthrough in type switch

這種情況 case 語句只能使用逗號並提供多個選項:

if err != nil {
    switch err.(type) {
    case *os.PathError, *os.LinkError:
        // do something
    default:
        // do something
    }
}

這兩種方法的最大缺點就是我們需要導入指定的錯誤類型,如示例中的 os.PathErroros.LinkError。這會導致我們的代碼與錯誤所在的包存在較強的依賴關係。

行為斷言

隨着 Go 語言的演進,大家對 Go 的錯誤處理又有了新的理解。以前斷言錯誤類型,現在社區中則更推薦斷言錯誤行為。

Go 語言佈道師 Dave Cheney 在他的文章 Inspecting errors 中提出了斷言錯誤行為而不是類型

NOTE:
沒錯,Dave Cheney 的名字再一次出現,後文還會出現😄。這位大佬對 Go 社區的貢獻很大,尤其是錯誤處理,著名的 pkg/errors 包就是他開發的。
func isTimeout(err error) bool {
    type timeout interface {
        Timeout() bool
    }
    te, ok := err.(timeout)
    return ok && te.Timeout()
}

函數 isTimeout 用來判定一個錯誤對象是否表示 Timeout,內部通過斷言錯誤對象是否實現了 timeout 接口來實現。

我們不再假設錯誤的類型,而是假設其實現了某個接口,並且 timeout 接口是一個臨時接口,並不是從其他包中導入的接口類型。這樣就真正的實現了包之間的解耦,錯誤類型無需公開,它們不再必須是包 API 的一部分。

net.Error 就是一個比較不錯的實踐:

https://github.com/golang/go/blob/go1.23.1/src/net/net.go#L415
// An Error represents a network error.
type Error interface {
    error
    Timeout() bool // Is the error a timeout?

    // Deprecated: Temporary errors are not well-defined.
    // Most "temporary" errors are timeouts, and the few exceptions are surprising.
    // Do not use this method.
    Temporary() bool
}

客户端代碼可以斷言錯誤是否為 net.Error 類型,然後再根據行為區分暫時性網絡錯誤和永久性網絡錯誤。

例如,一個爬蟲程序在遇到臨時錯誤時可以短暫休眠並重試,否則放棄這個請求,直接處理錯誤。

示例代碼如下:

if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
    time.Sleep(1e9)
    continue
}
if err != nil {
    log.Fatal(err)
}

當然,這段代碼還可以寫成這樣:

if nerr, ok := err.(interface{
    Temporary() bool
}); ok {
    time.Sleep(1e9)
    continue
}
if err != nil {
    log.Fatal(err)
}

這樣就實現了我們的代碼與錯誤所在的包之間最大化的解耦。

當然這兩種寫法其實都可以,看個人喜好。

暫存錯誤狀態

在使用 Builder 模式、鏈式調用或者 for 循環等場景下,暫存中間過程所出現的錯誤,有助於簡化代碼,使編寫出的代碼邏輯更加連貫。

NOTE:
如果你不瞭解 Builder 模式,可以查閲我的另一篇文章:《Builder 模式在 Go 語言中的應用》。

以下示例中使用 K8s client-go SDK 提供的 clientset 客户端查詢 pod 信息:

pod, err := clientset.CoreV1().Pods("default").Get(ctx, "nginx", metav1.GetOptions{})
if err != nil {
    // do something
}

其中的 Get 方法內部,就使用了鏈式調用,源碼如下:

https://github.com/kubernetes/kubernetes/blob/release-1.30/staging/src/k8s.io/client-go/kubernetes/typed/core/v1/pod.go#L75
// Get takes name of the pod, and returns the corresponding pod object, and an error if there is any.
func (c *pods) Get(ctx context.Context, name string, options metav1.GetOptions) (result *v1.Pod, err error) {
    result = &v1.Pod{}
    err = c.client.Get().
        Namespace(c.ns).
        Resource("pods").
        Name(name).
        VersionedParams(&options, scheme.ParameterCodec).
        Do(ctx).
        Into(result)
    return
}

c.client.Get() 會返回一個 *Request 對象,接着調用它的 Namespace(c.ns) 方法:

https://github.com/kubernetes/kubernetes/blob/release-1.30/staging/src/k8s.io/client-go/rest/request.go#L294
// Namespace applies the namespace scope to a request (<resource>/[ns/<namespace>/]<name>)
func (r *Request) Namespace(namespace string) *Request {
    if r.err != nil {
        return r
    }
    if r.namespaceSet {
        r.err = fmt.Errorf("namespace already set to %q, cannot change to %q", r.namespace, namespace)
        return r
    }
    if msgs := IsValidPathSegmentName(namespace); len(msgs) != 0 {
        r.err = fmt.Errorf("invalid namespace %q: %v", namespace, msgs)
        return r
    }
    r.namespaceSet = true
    r.namespace = namespace
    return r
}

*Request.Namespace 方法首先會通過 if r.err != nil 判斷是否存在錯誤,如果存在則直接返回,不再繼續執行。如果不存在錯誤,則接下來每次可能出現錯誤的調用,都會將錯誤信息暫存到 r.err 屬性中。

接下來是調用 Resource("pods") 方法:

https://github.com/kubernetes/kubernetes/blob/release-1.30/staging/src/k8s.io/client-go/rest/request.go#L210
// Resource sets the resource to access (<resource>/[ns/<namespace>/]<name>)
func (r *Request) Resource(resource string) *Request {
    if r.err != nil {
        return r
    }
    if len(r.resource) != 0 {
        r.err = fmt.Errorf("resource already set to %q, cannot change to %q", r.resource, resource)
        return r
    }
    if msgs := IsValidPathSegmentName(resource); len(msgs) != 0 {
        r.err = fmt.Errorf("invalid resource %q: %v", resource, msgs)
        return r
    }
    r.resource = resource
    return r
}

*Request.Resource 方法內部代碼邏輯的套路,與 *Request.Namespace 方法如出一轍。

client-go 就是通過將錯誤暫存到 *Request.err 屬性的方式,簡化了使用側的代碼邏輯。我們可以放心編寫代碼中的鏈式調用,只在最後處理一次錯誤即可。如果調用鏈中間某個方法出現了錯誤,之後執行的方法都能夠自行處理。

返回錯誤而不是指針

Dave Cheney 在他的文章 Errors and Exceptions, redux 中列舉了一個程序示例:

func Positive(n int) (bool, bool) {
        if n == 0 {
                return false, false
        }
        return n > -1, true
}

這是一個判斷給定變量 n 的值為正負數的小函數。

0 既不是正數也不是負數,因此為了判斷傳進來的 n 是否為 0,函數必須返回兩個值,第一個 bool 值標識 正/負,第二個 bool 值標識返回的第一個值是否有效,即 n 是否為 0

還有一種實現方式是下面這樣:

func Positive(n int) (bool, error) {
        if n == 0 {
                return false, errors.New("undefined")
        }
        return n > -1, nil
}

我們使用 error 來作為 Sentinel,標識 n 是否為 0

以上兩種方式我個人認為都可以接受,看個人喜好選擇即可。

不過,有人可能會有不同的實現:

func Positive(n int) *bool {
        if n == 0 {
                return nil
        }
        r := n > -1
        return &r
}

這次實現的 Positive 函數僅有一個返回值,類型是 *bool

*bool 值為 nil,標識 n 是否為 0

*bool 值不為 nil,其解引用後,值為 true 標識結果為 ,值為 false 標識結果為

這種做法極其不推薦,不僅使返回值存在二義性,還使調用方代碼變得囉嗦。在任何地方使用返回值之前,我們都必須檢查它以確保它指向的地址有效。

Errors are values

Errors are values 是 Rob Pike 提出來的,旨在糾正人們對 Go 錯誤處理的認知。

Errors are values —— 錯誤就是值!它沒什麼特殊的,你在使用 Go 語言編程過程中,可以像對待其他任何普通類型一樣對待錯誤。

所以,我們可以對錯誤進行等值比較、類型斷言、行為斷言等操作。遺憾的是,這是非常基本的東西,大多數 Go 程序員卻沒有注意到。

現在我們有如下示例代碼:

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

這裏存在非常嚴重的重複,這也是 if err != nil 容易被吐槽的典型場景。

不過,我們可以編寫一個簡單的輔助函數,來解決這個問題:

var err error
write := func(buf []byte) {
    if err != nil {
        return
    }
    _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
// and so on
if err != nil {
    return err
}

現在,代碼看起來是不是好了一些,沒有了那麼多重複的 if err != nil

我們還可以對這個示例程序做進一步優化:

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

定義一個 errWriter 結構體,來代理 io.Writer 對象的寫操作,並能暫存錯誤。

可以這樣使用 errWriter

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

現在的代碼使用輔助結構體更為優雅的解決了 if err != nil 重複問題。

這裏正是採用了前文中講解的 暫存錯誤狀態 思想。

之所以列舉這個示例,就是為了告訴大家,你可以像對待其他任何普通類型一樣對待錯誤。錯誤可以作為 errWriter 結構體的一個屬性存在,這沒什麼不妥,像編寫其他代碼一樣正常的處理錯誤即可。

記住:錯誤就是值

不要忽略你的錯誤

任何時候不要寫出這種代碼:

data, _ := Foo()

如果你確信 Foo() 的確不會返回錯誤,可以對其進行包裝,在內部處理錯誤:

func MustFoo() string {
    data, err := Foo()
    if err != nil {
        panic(err)
    }
    return data
}

現在,我們可以不關心錯誤直接調用 MustFoo()

data := MustFoo()

MustXxx 這種做法也算比較常見,比如 Gin 框架中就有很多這種風格的實現:

https://github.com/gin-gonic/gin/blob/v1.10.0/context.go#L280
// Get returns the value for the given key, ie: (value, true).
// If the value does not exist it returns (nil, false)
func (c *Context) Get(key string) (value any, exists bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    value, exists = c.Keys[key]
    return
}

// MustGet returns the value for the given key if it exists, otherwise it panics.
func (c *Context) MustGet(key string) any {
    if value, exists := c.Get(key); exists {
        return value
    }
    panic("Key \"" + key + "\" does not exist")
}

切記,不要忽略你的錯誤,除非這你把它當作一個 demo 程序,而非生產項目。

錯誤只應處理一次

雖然,不應該忽略你的錯誤,但錯誤也不應該被重複處理,你應該只處理一次錯誤。

處理錯誤意味着檢查錯誤值並做出決定。也就是説,根據能否處理錯誤,我們實際上只有兩種選擇:

  1. 不能處理:直接向上傳遞錯誤,自身不對錯誤做任何假設,也就是 Opaque error
  2. 可以處理:降級處理,並向上返回 nil,因為自己已經處理了錯誤,表示不再有錯誤了,上層不應該繼續拿到這個錯誤。

有如下示例:

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    if err != nil {
        // do something
        return err
    }
    return err
}

func main() {
    err := Bar()
    if err != nil {
        // do something
    }
}

在函數調用鏈中,錯誤被處理了兩次。這是非常糟糕的實踐,如果兩處都記錄日誌,那麼最終看到的日誌將會有非常多的重複內容。

正確的做法應該是這樣:

func Foo () error {
    return errors.New("foo error")
}

func Bar() error {
    return Foo()
}

func main() {
    err := Bar()
    if err != nil {
        // do something
    }
}

僅在調用鏈頂端 main 函數中處理一次錯誤。

有時候,我們也可以這樣處理:

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    if err != nil {
        // do something
        // NOTE: 這裏提供服務降級處理,如記錄日誌
        return nil
    }
    // do something
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        // do something
    }
}

這次,Bar 函數中遇到調用 Foo() 出錯的情況,會進行降級,然後返回 nil

錯誤同樣只會被處理一次,main 函數永遠也得不到 Foo() 函數返回的錯誤。

審查你的錯誤處理代碼

一些代碼細節,也能讓處理錯誤的代碼更加優雅。

在縮進中處理錯誤

在縮進中編寫你的處理錯誤邏輯:

f, err := os.Open(filename)
if err != nil {
    // handle error
}
// normal logic

而不是在縮進中處理正常邏輯:

f, err := os.Open(filename)
if err != nil {
    // normal logic
}
// handle error

這樣在 IDE 中能夠非常方便的摺疊 if err != nil 邏輯,使得閲讀正常邏輯的代碼更加清晰。

不要做冗餘的錯誤檢查

不要寫出這種代碼:

func Foo() error {
    err := Bar()
    if err != nil {
        return err
    }
    return nil
}

這裏的錯誤檢查完全沒有任何意義,屬於冗餘代碼。除非你的工作以代碼行數來做 KPI 考核,否則沒有任何理由這樣寫。

正確寫法如下:

func Foo() error {
    return Bar()
}

參考社區中的 Go 代碼規範,更多代碼細節就等着你自己去發現了。

nil 錯誤值可能不等於 nil

這個問題來自 Go 的 FAQ: Why is my nil error value not equal to nil ? 是我們開發過程中要注意的一個點。

有如下示例代碼:

package main

import "fmt"

type MyError struct {
    msg string
}

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

func returnsError() error {
    var p *MyError = nil
    return p // Will always return a non-nil error.
}

func main() {
    err := returnsError()
    if err != nil {
        fmt.Println("err:", err)
        return
    }
    fmt.Println("success")
}

執行示例代碼,得到輸出如下:

$ go run main.go
err: <nil>

可以發現,main 函數中的 if err != nil 錯誤檢查結果為 true,但使用 fmt.Println 輸出的值卻為 nil

出現這一怪異現象的原因與 Go 的接口實現有關。

在 Go 中一個接口對象實際包含兩個屬性:類型 T 和具體的值 V。例如,如果我們將值為 3int 類型對象存儲在接口中,則生成的接口對象實際上內部保存了:T=int, V=3

僅當 TV 都未設置時(T=nil, V 未設置),接口的值才為 nil

如果我們將 *int 類型的 nil 指針存儲在接口對象中,則無論指針的值是什麼,接口類型都將是 T=*int, V=nil

同理雖然 p 在初始化時賦值為 nilvar p *MyError = nil),但是它會被賦值給接口類型 error,我們得到的接口類型將是 T=*MyError, V=nil

所以,我們應該避免寫出這種代碼。

錯誤與日誌

錯誤與日誌密不可分,在程序出錯時記錄日誌能夠方便我們排查問題。

顯式勝於隱式

我們知道 fmt.Printf%s 動詞能夠格式化一個字符串。如果參數是一個 error 對象,則會自動調用其 Error 方法。

示例如下:

fmt.Printf("%s", err)

但更好的方式是我們手動調用 Error 方法:

fmt.Printf("%s", err.Error())

Python 之禪中的「顯式勝於隱式」在這裏依然適用。

記錄日誌前請確保錯誤真的存在

如下示例代碼記錄了錯誤日誌:

package main

import (
    "fmt"
)

func Foo() error {
    return nil
}

func main() {
    err := Foo()
    // slog.Info(err.Error())
    fmt.Printf("INFO: call foo: %s\n", err)
}

執行示例代碼,得到輸出如下:

$ go run main.go
call foo: %!s(<nil>)

可以發現這裏格式化 err 對象失敗了。

在將錯誤記錄到日誌前,我們有責任確保錯誤真的存在。否則調用 err.Error() 程序將發生 panic

何時記錄錯誤日誌

記錄日誌其實是一個比較大的話題,並且存在一定爭議。何時記錄、記錄什麼以及如何更好的記錄都是比較複雜的問題。更糟糕的是,對於不同項目,可能會有不同的答案。

所以,這裏只講下我個人對何時記錄錯誤日誌的理解。

其實核心還是一句話:錯誤只應處理一次。

示例如下:

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    return Foo()
}

func main() {
    err := Bar()
    if err != nil {
        slog.Error(err.Error())
    }
}

示例代碼中只在 main 函數中記錄了一次錯誤日誌,調用鏈中間遇到錯誤直接返回,不做任何處理。

同樣,如果遇到服務降級的情況,我們也可以記錄日誌,並返回 nil,不再繼續向上報告當前錯誤:

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    if err != nil {
        // NOTE: 服務降級,記錄日誌
        slog.Error(err.Error())
        return nil
    }
    // do something
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        slog.Error(err.Error())
    }
}

此外,我們還可以在調用鏈中間為錯誤附加信息,並在頂層 main 函數中處理錯誤:

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    return errors.WithMessage(err, "Bar")
}

func main() {
    err := Bar()
    if err != nil {
        slog.Error(fmt.Sprintf("%+v", err))
    }
}

並且,這裏還使用 %+v 動詞記錄了錯誤的詳細堆棧信息到日誌中。

錯誤日誌應該記錄什麼

一般來説,記錄錯誤日誌是為了方便排查問題,所以信息要儘可能多,那麼錯誤日誌中堆棧信息就必不可少。

所以,仍然推薦使用 pkg/errors 來處理錯誤。

如果項目比較小,調用層數不深,錯誤日誌中只記錄 err.Error() 信息也沒什麼關係。

但是,如果是中大型項目,尤其是微服務開發,錯誤日誌應該記錄 err.Format() 信息,即使用 fmt.Sprintf("%+v", err) 結果。

pkg/errors

我在《Go 錯誤處理指北:pkg/errors 源碼解讀》 一文中對 pkg/errors 包源碼做了詳細的講解,不過卻沒有在用法上給出過多建議,本節就來補充一下。

pkg/errors 是由 Dave Cheney 所開發的,是目前 Go 錯誤處理的最優解。

記錄錯誤調用鏈

我們可以在錯誤調用鏈中,使用 pkg/errors 提供的 errors.Wrap 方法為錯誤附加一些信息,以此來記錄鏈路調用過程。

示例如下:

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    if err != nil {
        return errors.Wrap(err, "bar")
    }
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        fmt.Printf("err: %s\n", err)
    }
}

執行示例代碼,得到輸出如下:

$ go run main.go
err: bar: foo error

記錄錯誤堆棧

附加錯誤信息還不夠,pkg/errors 包最大的好處是可以記錄錯誤堆棧。

修改 main 函數的錯誤處理,只需要將 fmt.Printf 中格式化錯誤的動詞從 %s 改成 %+v 即可:

func main() {
    err := Bar()
    if err != nil {
        fmt.Printf("err: %+v\n", err)
    }
}

執行示例代碼,得到輸出如下:

$ go run main.go
err: foo error
main.Foo
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:32
main.Bar
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:36
main.main
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:44
runtime.main
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/proc.go:272
runtime.goexit
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/asm_arm64.s:1223
bar
main.Bar
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:38
main.main
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:44
runtime.main
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/proc.go:272
runtime.goexit
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/asm_arm64.s:1223

可以看到,錯誤從產生開始,整個調用鏈堆棧信息都被記錄了下來。

但是這裏存在重複的問題,錯誤調用鏈被打印了兩次。這其實是因為 pkg/errors 包提供的 errors.New 函數本身在構造錯誤時就已經記錄了堆棧信息,而 errors.Wrap 又記錄了一遍。

所以,如果錯誤是通過 errors.New 構造的,調用鏈中間不應該再次使用 errors.Wrap 附加錯誤信息,而應該使用 errors.WithMessage

修改 Bar 函數如下:

func Bar() error {
    err := Foo()
    if err != nil {
        return errors.WithMessage(err, "bar")
    }
    return nil
}

執行示例代碼,得到輸出如下:

$ go run main.go
err: foo error
main.Foo
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:32
main.Bar
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:36
main.main
        /go/blog-go-example/error/handling-error/pkg-errors/main.go:44
runtime.main
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/proc.go:272
runtime.goexit
        /go/pkg/mod/golang.org/toolchain@v0.0.1-go1.23.1.darwin-arm64/src/runtime/asm_arm64.s:1223
bar

現在記錄的錯誤堆棧就正常了。

不要做冗餘的錯誤檢查

其實 pkg/errors 包提供了更方便的使用方法。

我們無需編寫這種代碼:

func Bar() error {
    err := Foo()
    if err != nil {
        return errors.WithMessage(err, "bar")
    }
    return nil
}

可以直接去掉那冗餘的錯誤檢查:

func Bar() error {
    err := Foo()
    return errors.WithMessage(err, "bar")
}

這不對執行結果造成任何影響。

我們無需判斷 err 是否為 nil,因為 pkg/errors 內部的方法幫我們做好了這項檢查:

func WithMessage(err error, message string) error {
    if err == nil {
        return nil
    }
    return &withMessage{
        cause: err,
        msg:   message,
    }
}

對於 errors.Wrap/errors.WithStack 同樣如此。

Sentinel error 處理

因為 pkg/errors 包提供的 errors.Wrap/errors.WithStack/errors.WithMessage 這三個方法都會返回新的錯誤,所以默認情況下 Sentinel error 相等性判斷就會失效。

不過 pkg/errors 包考慮到了這點,提供了 errors.Cause 方法可以得到一個錯誤的根因。

示例如下:

func Foo() error {
    return io.EOF
}

func Bar() error {
    err := Foo()
    return errors.WithMessage(err, "bar")
}

func main() {
    err := Bar()
    if err != nil {
        if errors.Cause(err) == io.EOF {
            fmt.Println("EOF err")
            return
        }
        fmt.Printf("err: %+v\n", err)
    }
    return
}

執行示例代碼,得到輸出如下:

$ go run main.go
EOF err

小結

可以發現,pkg/errors 包充分考慮了人類和程序對錯誤的不同處理。

pkg/errors 包可以非常方便的向一個已有錯誤添加新的上下文,錯誤堆棧可以方便我們程序員排查問題,errors.Cause 獲取錯誤根因的方法,可以方便程序中對錯誤進行相等性檢查。

如果我們的代碼中全局都在使用 pkg/errors 包,那麼通過 errors.New/errors.Errorf 構造的錯誤天然就已經攜帶了錯誤堆棧信息。

通常在調用鏈中間過程直接返回底層錯誤即可,如果想要附加信息,則可以使用 errors.WithMessage,不要使用 errors.Wrap/errors.WithStack 以免造成堆棧信息的重複。

如果與標準庫或來自第三方的代碼包進行交互,可以考慮使用 errors.Wrap/errors.WithStack 在原錯誤基礎上建立堆棧跟蹤。

在錯誤處理調用鏈頂層,可以使用 %+v 來記錄包含足夠詳細信息的錯誤。

Go 1.13

Go 1.13 的發佈為錯誤處理進帶來了全新功能 Error wrapping

具體細節可以參考相關提案 Proposal: Go 2 Error Inspection 以及 issues/29934 討論。

Go 1.13 為 errorsfmt 標準庫包引入了新功能:

  • fmt.Errorf 支持使用 %w 動詞包裝錯誤。
  • 新增 errors.Unwrap 函數為錯誤解包以獲取根因。
  • 新增 errors.Is 函數取代 == 做等值比較。
  • 新增 errors.As 函數取代 Type Assertion 做類型斷言。

我們來一一講解。

fmt.Errorf

fmt.Errorf 新增的 %w 動詞功能對標 pkg/errors 包中的 errors.Wrap 函數,用法如下:

package main

import (
    "errors"
    "fmt"
)

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    if err != nil {
        return fmt.Errorf("bar: %w", err)
    }
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        fmt.Printf("err: %s\n", err)
    }
}

執行示例代碼,得到輸出如下:

$ go run main.go
err: bar: foo error

errors.Unwrap

errors.Unwrap 函數對標 pkg/errors 包中的 errors.Cause 函數,用法如下:

func Foo() error {
    return io.EOF
}

func Bar() error {
    err := Foo()
    if err != nil {
        return fmt.Errorf("bar: %w", err)
    }
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        if errors.Unwrap(err) == io.EOF {
            fmt.Println("EOF err")
            return
        }
        fmt.Printf("err: %+v\n", err)
    }
    return
}

執行示例代碼,得到輸出如下:

$ go run main.go
EOF err

errors.Is

errors.Is 函數可以取代 == 做等值比較,用法如下:

func Foo() error {
    return io.EOF
}

func Bar() error {
    err := Foo()
    if err != nil {
        return fmt.Errorf("bar: %w", err)
    }
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        // if err == io.EOF {
        if errors.Is(err, io.EOF) {
            fmt.Println("EOF err")
            return
        }
        fmt.Printf("err: %+v\n", err)
    }
    return
}

執行示例代碼,得到輸出如下:

$ go run main.go
EOF err

由於我們使用了 fmt.Errorf("bar: %w", err) 對初始錯誤進行了包裝,所以在 main 函數中不能直接使用 if err == io.EOF 來對 Sentinel error 進行相等性判斷。

除了可以使用 if errors.Unwrap(err) == io.EOF 這種方式,我們還可以使用 errors.Is(err, io.EOF) 方式來取代 ==,執行結果相同。

errors.As

errors.As 函數可以取代 Type Assertion 做類型斷言,用法如下:

type MyError struct {
    msg string
    err error
}

func (e *MyError) Error() string {
    return e.msg + ": " + e.err.Error()
}

func Foo() error {
    return &MyError{
        msg: "foo",
        err: io.EOF,
    }
}

func Bar() error {
    err := Foo()
    if err != nil {
        return fmt.Errorf("bar: %w", err)
    }
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        var e *MyError
        if errors.As(err, &e) {
            fmt.Printf("EOF err: %s\n", e)
            return
        }
        fmt.Printf("err: %+v\n", err)
    }
    return
}

執行示例代碼,得到輸出如下:

$ go run main.go
EOF err: foo: EOF

Go 1.20

Go 1.20 新增了 errors.Join 函數返回包裝後的錯誤列表。

用法如下:

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("err1")
    err2 := errors.New("err2")
    err := errors.Join(err1, err2)
    fmt.Println("---------")
    fmt.Println(err)
    fmt.Println("---------")
    if errors.Is(err, err1) {
        fmt.Println("err is err1")
    }
    if errors.Is(err, err2) {
        fmt.Println("err is err2")
    }
}

執行示例代碼,得到輸出如下:

$ go run main.go
---------
err1
err2
---------
err is err1
err is err2

可以發現通過 errors.Join(err1, err2) 得到的 err 對象打印結果中會在多個錯誤之間增加換行。並且 errors.Is 函數也做了升級,能夠處理這種情況,對 err1err2 的相等性比較結果都為 true

Go 1.23

Go 1.23 對 errors.Is 函數做了一點小優化。

這是 Go 1.23 中的 errors.Is 函數:

https://github.com/golang/go/blob/go1.23.0/src/errors/wrap.go#L44-L51
func Is(err, target error) bool {
    if err == nil || target == nil {
        return err == target
    }

    isComparable := reflectlite.TypeOf(target).Comparable()
    return is(err, target, isComparable)
}

這是 Go 1.22.8 中的 errors.Is 函數:

https://github.com/golang/go/blob/go1.22.8/src/errors/wrap.go#L44-L51
func Is(err, target error) bool {
    if target == nil {
        return err == target
    }

    isComparable := reflectlite.TypeOf(target).Comparable()
    return is(err, target, isComparable)
}

可以發現 Go 1.23 中 errors.Is 函數多了一個 if err == nil 邏輯的判斷。不過由於這個改動比較小,不會對現有代碼造成任何影響,這一改變並沒有出現在 Go 1.23 Release Notes 中。

僅此而已。

Go 2

Go 1.13 版本之前的錯誤處理是過去,Go 1.13 版本錯誤處理是現在,Go 2 版本錯誤處理是未來。

由於社區中對 Go 錯誤處理的吐槽聲一直不斷,Go 也在努力改變這一現狀,雖然進展緩慢。

Go 1.13 的出現已經是最大的誠意了 :)。

很多人依然不滿足於現狀,將錯誤處理希望寄託於 Go 2。不過我個人持悲觀態度,基於 Go 泛型的演進過程,我認為幾年內 Go 錯誤處理都不會有較大改變。

不過 Go 2 的藍圖的確已經在勾勒中了,感興趣的讀者可以進入 Go 2 Draft Designs 查看,裏面對 Error handling 以及 Error values 都羅列了幾個鏈接供讀者參閲。

期待 Go 2 的錯誤處理能夠更上一層樓。

總結

Go 官方認為 Error 機制更簡單有效,所以 Go 中並沒有 Exception

在 Go 中可以通過 errors.Newfmt.Errorf 構造一個錯誤對象。

有了錯誤對象,就需要對錯誤進行處理。Go 中一切的錯誤處理都從 if err != nil 開始。

Sentinel error,是一種錯誤處理慣用法,不過 Sentinel error 會成為公共 API 的一部分,它會引入源和運行時耦合,可能導致循環依賴。並且有些 Sentinel error 變量僅用於哨兵,而非錯誤,具有二義性,所以綜合來看,並不推薦使用。儘管標準庫中有大量應用,但是為了保持 Go 1 的兼容性承諾,短期來看不太可能有大變動。

因為 Sentinel error 是一個變量,值可以被改變。所以有人提出使用常量來定義錯誤,不過目前來看這種用法並不常見。

Opaque error 是最理想的錯誤處理方式,但它過於理想。

為了提供更多的上下文信息,我們可以自定義錯誤類型。

始終記住:Errors are values,無論是內置錯誤類型還是我們自定義的錯誤類型都是值,我們可以對其進行等值判斷、類型斷言、賦值給結構體的屬性等操作。

隨着 Go 語言的演進,現在社區中更推薦斷言錯誤行為。這樣能夠實現了我們的代碼與錯誤所在的包之間最大化的解耦。

在使用 Builder 模式、鏈式調用或者 for 循環等場景下,暫存中間過程所出現的錯誤,有助於簡化代碼,使編寫出的代碼邏輯更加連貫。

為了避免函數返回值存在二義性,我們應該返回錯誤而不是指針。

不要忽略你的錯誤,但錯誤也不應該被重複處理,錯誤只應被處理一次。

在在縮進代碼塊中處理錯誤有助於正常邏輯清晰可見。

由於 Go 接口實現的特殊方式,可能存在 nil 錯誤值可能不等於 nil 的情況,編寫代碼時需要注意不要踩坑。

記錄日誌前請確保錯誤真的存在,一個錯誤只應記錄一次日誌,並且為了方便拍錯,日誌信息最好包含堆棧信息。

Go 1.13 雖然新增了幾個方法,用來輔助錯誤處理,但是依然不能記錄堆棧信息。

所以,即使有 Go 1.13 的存在,現在也依然推薦使用 pkg/errors 來處理錯誤。

Go 2 承擔了 Go 未來錯誤處理的重任。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

聯繫我

  • 公眾號:Go編程世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 博客:https://jianghushinian.cn
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.