如何在 Go 中優雅的處理和返回錯誤(1)——函式內部的錯誤處理

amc發表於2021-10-01

在使用 Go 開發的後臺服務中,對於錯誤處理,一直以來都有多種不同的方案,本文探討並提出一種從服務內到服務外的錯誤傳遞、返回和回溯的完整方案,還請讀者們一起討論。

問題提出

在後臺開發中,針對錯誤處理,有三個維度的問題需要解決:

  • 函式內部的錯誤處理: 這指的是一個函式在執行過程中遇到各種錯誤時的錯誤處理。這是一個語言級的問題
  • 函式/模組的錯誤資訊返回: 一個函式在操作錯誤之後,要怎麼將這個錯誤資訊優雅地返回,方便呼叫方(也要優雅地)處理。這也是一個語言級的問題
  • 服務/系統的錯誤資訊返回: 微服務/系統在處理失敗時,如何返回一個友好的錯誤資訊,依然是需要讓呼叫方優雅地理解和處理。這是一個服務級的問題,適用於任何語言

針對這三個維度的問題,筆者準備寫三篇文章一一說明。首先本文就是第一篇:函式內部的錯誤處理

高階語言的錯誤處理機制

一個程式導向的函式,在不同的處理過程中需要 handle 不同的錯誤資訊;一個物件導向的函式,針對一個操作所返回的不同型別的錯誤,有可能需要進行不同的處理。此外,在遇到錯誤時,也可以使用斷言的方式,快速中止函式流程,大大提高程式碼的可讀性。

在許多高階語言中都提供了 try ... catch 的語法,函式內部可以通過這種方案,實現一個統一的錯誤處理邏輯。而即便是 C 這種 “中級語言”,雖然沒有 try catch,但是程式設計師也可以使用巨集定義配合 goto LABEL 的方式,來實現某種程度上的錯誤斷言和處理。

Go 的錯誤斷言

在 Go 的情況就比較尷尬了。我們先來看斷言,我們的目的是,僅使用一行程式碼就能夠檢查錯誤並終止當前函式。由於沒有 throw、沒有巨集,如果要實現一行斷言,有兩種方法。

方法一:單行 if + return

第一種是把 if 的錯誤判斷寫在一行內,比如:

    if err != nil { return err }

這種方法有值得商榷的點:

  • 雖然符合 Go 的程式碼規範,但是在實操中,if 語句中的花括號不換行這一點還是非常有爭議的,並且筆者在實際程式碼中也很少見到過
  • 程式碼不夠直觀,大致瀏覽程式碼的時候,斷言程式碼不顯眼,而且在花括號中除了 return 之外也沒法別的了,原因是 Go 的規範中強烈不建議使用 ; 來分隔多條語句(if 條件判斷除外)

因此,筆者強烈不建議這麼做。

方法二:panic + recover

第二種方法是借用 panic 函式,結合 recover 來實現,如以下程式碼所示:

func SomeProcess() (err error)
    defer func() {
        if e := recover(); e != nil {
            err = e.(error)
        }
    }()

    assert := func(cond bool, e error) {
        if !cond {
            panic(e)
        }
    }

    // ...

    err = DoSomething()
    assert(err == nil, fmt.Errorf("DoSomething() error: %w", err))

    // ...
}

這種方法好不好呢?我們要分情況看

首先,panic 的設計原意,是在當程式或協程遇到嚴重錯誤,完全無法繼續執行下去的時候,才會呼叫(_比如段錯誤、共享資源競爭錯誤_)。這相當於 Linux 中 FATAL 級別的錯誤日誌,用這種機制,僅僅用來進行普通的錯誤處理(ERROR 級別),殺雞用牛刀了。

其次,panic 呼叫本身,相比於普通的業務邏輯的系統開銷是比較大的。而錯誤處理這種事情,可能是常態化邏輯,頻繁的 panic - recover 操作,也會大大降低系統的吞吐。

但是話雖這麼說,使用 panic 來斷言的方案,雖然在業務邏輯中基本上不用,但在測試場景下則是非常常見的。測試嘛,用牛刀有何不可?稍微大一點的系統開銷也沒啥問題。對於 Go 來說,非常熱門的單元測試框架 goconvey 就是使用 panic 機制來實現單元測試中的斷言,用的人都說好。

結論建議

綜上,在 Go 中,對於業務程式碼,筆者是不建議採用斷言的,遇到錯誤的時候建議還是老老實實採用這種格式:

if err := DoSomething(); err != nil {
    // ...
}

而在單測程式碼中,則完全可以大大方方地採用類似於 goconvey 之類基於 panic 機制的斷言。

Go 的 try ... catch

眾所周知,Go(當前版本 1.17)是沒有 try ... catch 的,而且從官方的態度而言,短時間內也沒有明確的計劃。但是程式設計師有這個需求呀。這裡也催生出了集中解決方案

defer 函式

筆者採用的方法,是將需要返回的 err 變數在函式內部全域性化,然後結合 defer 統一處理:

func SomeProcess() (err error) { // <-- 注意,err 變數必須在這裡有定義
    defer func() {
        if err == nil {
            return
        }

        // 這下面的邏輯,就當作 catch 作用了
        if errors.Is(err, somepkg.ErrRecordNotExist) {
            err = nil        // 這裡是舉一個例子,有可能捕獲到某些錯誤,對於該函式而言不算錯誤,因此 err = nil
        } else if errors.Like(err, somepkg.ErrConnectionClosed) {
            // ...            // 或者是說遇到連線斷開的操作時,可能需要做一些重連操作之類的;甚至乎還可以在這裡重連成功之後,重新拉起一次請求
        } else {
            // ...
        }
    }()

    // ...

    if err = DoSomething(); err != nil {
        return
    }

    // ...
}

這種方案要特別注意變數作用域問題。

比如前面的 if err = DoSomething(); err != nil { 行,如果我們將 err = ... 改為 err := ...,那麼這一行中的 err 變數和函式最前面定義的 (err error) 不是同一個變數,因此即便在此處發生了錯誤,但是在 defer 函式中無法捕獲到 err 變數了。

try ... catch 方面,筆者其實沒有特別好的方法來模擬,即便是上面的方法也有一個很讓人頭疼的問題:defer 寫法導致錯誤處理前置,而正常邏輯後置了。

命名的錯誤處理函式

要解決前文提及的 defer 寫法導致錯誤處理前置的問題,有第一種解決方法是比較常規的,那就是將 defer 後面的匿名函式改成一個命名函式,抽象出一個專門的錯誤處理函式。這個時候我們可以將上一段函式進行這樣的改造:

func SomeProcess() error {
    // ...

    if err = DoSomething(); err != nil {
        return unifiedError(err)
    }

    // ...
}

func unifiedError(err error) error {
    if errors.Is(err, somepkg.ErrRecordNotExist) {
        return nil        // 有可能捕獲到某些錯誤,對於該函式而言不算錯誤,因此 err = nil

    } else if errors.Like(err, somepkg.ErrConnectionClosed) {
        return fmt.Errorf("handle XXX error: %w", err)

    // ...

    } else {
        return err
    }
}

這樣就舒服一些了,至少邏輯前置,錯誤處理後置。不過讀者肯定會發現——這不是什麼語言都可以這麼搞嘛?誠然,這怎麼看都不像是對 try ... catch 的模擬,但這種方法依然很推薦,特別是錯誤處理程式碼很長的時候。

goto LABEL

理論上,我們可以通過 goto 語句,將錯誤處理後置,比如:

func SomeProcess() error {
    // ...

    if err = DoSomething(); err != nil {
        goto ERR
    }

    // ...

    return nil

ERR:
    // ...
}

C 語言比較熟悉的同學可能會覺得很親切,因為在 Linux 核心中就有大量這種寫法。這種寫法呢,筆者其實說不出具體不好的地方,但是這個看起來很像 C 的寫法,其實限制很多,反而比起 C 而言,需要注意的地方也更多:

  • 僅限於 ANSI-C 的話,要求所有的區域性變數都需要前置宣告,這就避免了因為變數作用域而帶來的同名變數覆蓋;但 Go 需要注意這個問題。
  • C 支援巨集定義,配合前文可以實現斷言,使得錯誤處理語句可以做得比較優雅;而 Go 不支援
  • Go 經常有很多匿名函式,匿名函式無法 goto 到外層函式的標籤,這也限制了 goto 的使用

不過筆者倒也不是不支援使用 goto,只是覺得在現有機制下,還是使用前兩種模式比較符合 Go 的習慣。


下一篇文章是《如何在 Go 中優雅的處理和返回錯誤(2)——函式/模組的錯誤資訊返回》,筆者詳細整理了 Go 1.13 之後的 error wrapping 功能,敬請期待~~


本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。

本文最早釋出於雲+社群,也是amc的部落格。

原作者: amc,歡迎轉載,但請註明出處。

原文標題:《如何在 Go 中優雅的處理和返回錯誤(1)——函式內部的錯誤處理》

釋出日期:2021-09-30

原文連結:https://segmentfault.com/a/1190000040762538

相關文章