如何優雅包裝錯誤:聊聊 Go 的 error

錯誤處理是 Golang 最常被討論的一個點。這有幾個因素,首先,這跟它「錯誤是值」的設計理念有關,開發者需要在業務流程中穿插錯誤處理,違反關注點分離的原則,當然會引發爭議。另外,在 1.13 前,Golang 標準 errors 庫的表現力有限,當需求較為複雜時,需要開發者自行發明錯誤處理輔助函式。這讓人不禁好奇,Golang 的錯誤處理設計原則是什麼?有沒有比較好的實踐?或者說,我們能不能找到一種方式,優雅地處理錯誤?

錯誤與異常

先來看看不同人的觀點,Robert Martin 在討論到錯誤處理時,是如此建議的

使用異常替代返回錯誤碼,錯誤處理代碼就能從主路徑代碼中分離出來,得到簡化

他給出的例子是

try {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
    logger.log(e.getMessage());
}

Martin 指的是 Try Catch 模型。在 Try Catch 中,錯誤由函式中拋出,並在上層的處理函式中接住,進行處理。如 Robert Martin 講的,Try Catch 的主路徑跟錯誤處理代碼分開在不同區塊,這樣的好處是能讓責任明確。

而 Golang 的錯誤處理方式則是

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f

很明顯的,主路徑跟錯誤處理會放在同一個區塊,可讀性沒有 Try Catch 來好。Rob Pike 對這問題曾經發表過一些意見

In other languages, one might use a try-catch block or other such mechanism to handle errors. Therefore, the programmer thinks, when I would have used a try-catch in my old language, I will just type if err != nil in Go. Over time the Go code collects many such snippets, and the result feels clumsy.

這段話講的有些曖昧,大致來說,Rob Pike 認為這問題的關鍵是,開發者使用了舊的習慣來開發 Golang,也就是說,他認為問題的解法應該要在應用端,而不是語言。

說曖昧的原因是,Pike 沒正面回答,為什麼 Golang 不支援 Try Catch?是 Try Catch 有什麼問題,或者只是 Golang 的設計者沒想到?我猜想,有個可能的原因是,Golang 嘗試區分出異常與錯誤。依照 Java 的定義,錯誤指該問題超出應用程式的處理能力,是執行期不該出現的狀況,例如 Out of Memory;而異常則是指不符合預期的情況,像是在資料庫中找不到特定的資料。對於前者,程式通常會立刻停止執行;而後者,程式會嘗試處理,給出當下可接受的回應。

在 Java 的模型中,錯誤跟異常使用同樣的方式來處理,當開發者需要明確處理異常時,他會指定 Exception 的類型,如果該類型沒有被上層的處理函式 Catch,就會繼續往上拋,直到程式錯誤。因此,這背後隱含一個邏輯,沒有被明確處理的異常就是錯誤,並帶來一個問題,由於 Exception 可以不斷上拋,使得開發者必須有意識地使用 Try Catch,否則會破壞分層原則。

相較於 Java,Golang 的設計理念更多取徑於 C 語言,C 語言常見的錯誤處理長這樣

int _shutdown_mib_modules(int majorID, int minorID, void *serve, void *client) {
    // do something
    return SNMPERR_SUCCESS;
}

在 C 語言的慣例中,錯誤會用 status code 回傳,通常 0 是正常,而其他值代表不同的 error。在這裡,「值」是很重要的概念,儘管 C 語言是因為語言限制,被迫把 error 當值回傳,但 Golang 有意識地繼承了這項設計。 對 Golang 來講,error 僅僅是一種回傳值,不會採取不同的方式來處理,而對於執行期發生的嚴重錯誤,則走 panic / recover 的機制

func mayPanic() {
    panic("a problem")
}

func main() {
    defer func() {
    if r := recover(); r != nil {
        fmt.Println("Recovered. Error:\n", r)
    }
    }()
    mayPanic()
    fmt.Println("After mayPanic()")
}

藉由這項設計,Golang 在語言的層次上區分錯誤與異常,並要求開發者在函式返回的當下,立刻顯式處理。

error 的實現方式

明白設計原則後,緊接而來的問題是,Golang 中的 error 是什麼?它又是如何實現?用最通俗的話來講,error 是實作 Error() 接口的結構,這是另一項設計理念「鴨子型別」的展現。對開發者來講,最簡單的方式,是使用標準庫的 errors.New 來產生 error

return errors.New("this is error")

底層的實作則是

package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
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
}

可以看到 errorString 本質上是個 struct,裡面帶有 text 字段儲存錯誤訊息。也因為 errors.New 回傳的是 pointer of struct,即使 text 的內容相同,兩個 error 也會被當成是不同的 error。

既然每個 error 都是不同的,那要如何確認錯誤類型呢?原理很簡單,只要同樣類型的 error 是來自同一個實例即可,例如,我們可以用 Sentinel Error 的方式,在包內部預先定義需要的 error,要使用時直接回傳,在標準庫內部常常可以看到類似的做法,像是 io.EOF

var EOF = errors.New("EOF")

還記得「錯誤是值」嗎?開發者可以用處理值的方式來處理 error

data, err := io.ReadAll(r)
if err == io.EOF {
    // error handling
}

這裡確實能感受到 Golang 跟 C 語言的系譜關係。

錯誤上下文

當我們將目光拉回開發應用場景,要求 error 資訊中需要有上下文(也就是,錯誤中包含哪些錯誤)時,我們會發現 Golang 1.13 前的標準 errors 庫能力有限,需要開發者自行實現。在 “Working with Errors in Go 1.13” 中,Damien Neil 跟 Jonathan Amsterdam 詳細說明了這個狀況

Frequently a function passes an error up the call stack while adding information to it, like a brief description of what was happening when the error occurred. A simple way to do this is to construct a new error that includes the text of the previous one
if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

要知道上下文,只要將前面 error 的 Message 提取出來,放入新的 Message 即可。然而,也如 Damien Neil 跟 Jonathan Amsterdam 提到的,單純將資訊放在字串,會有資訊劣化的問題,如果開發者想追蹤上下文中是否存在特定的 error,改成嵌入會是更好的方式

type QueryError struct {
    Query string
    Err   error
}

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

有人可能會問了,假如是多層嵌套的話,也需要一層一層來拆嗎?顯然的,需要有一個更簡便的方式,幫助我們返回底層錯誤,換句話說,需要把 error 的包裝跟拆裝標準化,讓我們可以用遞迴的方式來拆解 error。為了處理這問題,Golang 在 1.13 後,引入了 Unwrap function

// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error {
    u, ok := err.(interface {
        Unwrap() error
    })
    if !ok {
        return nil
    }
    return u.Unwrap()
}

Unwrap 檢查該 error 是否實作 Unwrap interface,如果有,就調用 Unwrap ,拆出下一層的 error。

既然有拆就有包,實作上要怎麼方便的包裝錯誤呢?自行將 error 嵌入自定義結構也是個選擇,但由於這個場景非常常見,Golang 1.13 在語言的標準庫中實現了包裝,用法是

if err != nil {
    // Return an error which unwraps to err.
    return fmt.Errorf("decompress %v: %w", name, err)
}

當參數 %w 出現,返回的 error 會自動帶上 Unwrap func,它的內部是包裝過的 wrapError

func Errorf(format string, a ...any) error {
    p := newPrinter()
    p.wrapErrs = true
    p.doPrintf(format, a)
    s := string(p.buf)
    var err error
    if p.wrappedErr == nil {
        err = errors.New(s)
    } else {
        err = &wrapError{s, p.wrappedErr}
    }
    p.free()
    return err
}

如此,就完成包裝拆裝的標準介面了。

當要追蹤 error 的上下文中是否包含特定的 error,我們可以先比較最外層的 error,如果不同,使用 Unwrap 拆裝後,再比較下一層的 error。Golang 1.13 同樣實現了 errors.Is function 來簡化操作

// Similar to:
// if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

它的實現是不停的比較跟拆包

func Is(err, target error) bool {
    if target == nil {
        return err == target
    }
    isComparable := reflectlite.TypeOf(target).Comparable()
    for {
        if isComparable && err == target {
            return true
        }
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}

有意思的是,在比較過程中,它不單單看 err == target,還允許開發者自定相等的條件 Is(error) bool。

呼叫堆疊

Golang 1.13 上下文問題的提案是參考社群方案

但不知道為什麼,Golang 標準庫只採用跟上下文有關的部分,有時候我們需要更多細節,像是為了追蹤 error 產生的位置,讓開發者能快速定位錯誤,還需要 Call Stack 資訊,這時要動用另一個標準庫 runtime

const depth = 32
var pcs [depth]uintptr
runtime.Callers(3, pcs[:])

透過呼叫 runtime.Callers,可以取得 Call Stack。回想剛剛要加入上下文資訊時,用的方式是在 struct 內嵌入 error 的字段;同樣的道理,要加入 Call Stack,也只要再多嵌入一個 stack 字段

type withStack struct {
    error
    *stack
}

stack 是個 pointer array,存放取得的 Call Stack 資訊。儘管標準庫沒有實現上述的內容,但 pkg/errors 有幫忙做好了

// customized message
err = errors.Wrapf("error num is: %d", num)

// just wrap call stack
err = errors.WithStack(err)

我們可以透過 WithStack func 來理解 pkg/errors 的實作方式

// WithStack annotates err with a stack trace at the point WithStack was called.
// If err is nil, WithStack returns nil.
func WithStack(err error) error {
    if err == nil {
        return nil
    }
    return &withStack{
        err,
        callers(),
    }
}

簡單明瞭,直接將 callers 塞進去。也印出來看看效果

err := errors.New("some error")
err = errors.WithStack(err)
fmt.Printf("%s\n", err.Error())
# console
$ go run main.go 
some error

咦?怎麼好像沒看到 Call Stack?這是因為 Call Stack 的資訊只是用來除錯,不會放在錯誤訊息 Error() 中,所以要印時要用 %+v

err := errors.New("some error")
err = errors.WithStack(err)
fmt.Printf("%+v\n", err)
# console
$ go run main.go 
some error
main.main
        ken/playground/error_demo/main.go:47
runtime.main
        ken/go/src/runtime/proc.go:250
runtime.goexit
        ken/go/src/runtime/asm_amd64.s:1571

如果有使用 Sentry 的話,也能在 issue 中看到 Call Stack 的資訊,對除錯很有幫助,會知道錯誤發生在哪一行、前面經過哪些路徑,加上必要的參數,就能輕鬆完成錯誤定位

領域 error 與 API error

接著來看題應用題。假設你跟我一樣是個後端應用開發的工程師,希望在 API 的 Error Response 中放入錯誤的詳細訊息,像是哪個參數錯誤,好讓對接的前端工程師可以用錯誤訊息進行初步判斷,但卻又不希望過度曝露系統資訊,例如完整的錯誤上下文。我們可以怎麼做?

這是個開發者體驗設計問題,具體而言,要達成的目標是:降低前端工程師的開發成本,降低後端系統除錯成本,以及隱藏不必要的資訊。因此我們從系統的角度來看,如果套用分層的概念,領域層級的 error 跟 API 層級的 error 應該要放在不同層

領域層級的 error 用來描述符合系統使用情境的錯誤;API 層級的 error 則專注於接口設計,以 gin 當範例,兩個的關係是

// httpErr represents an error that occurred while handling a request.
type httpErr struct {
    Message string `json:"message"`
}

// ErrorHandler responds error response according to given error.
func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
    c.Next()
    if len(c.Errors) == 0 {
        return
    }
    err := c.Errors[0]
    var status int
    switch {
    case errors.Is(err, entity.ErrInvalidInput):
        status = http.StatusBadRequest
    case errors.Is(err, entity.ErrUnauthorized):
        status = http.StatusUnauthorized
    case errors.Is(err, entity.ErrPermissionDenied):
        status = http.StatusForbidden
    default:
        status = http.StatusInternalServerError
    }
    c.JSON(status, httpErr{Message: err.Error()})
    }
}

上面的設計有個小問題,httpErr 直接使用 err.Error() 當 Error Response Body,如果 err 是透過 Wrap 來包裝,Error() 可能會有上下文資訊,像是違反哪條資料庫的 Constraint,而這資訊是我們不想曝露給外部調用者的。因此如果可以,應該要對 entity.Error 再進行一次包裝,讓它分開上下文與原始 error 的資訊。

我們可以照抄 pkg/errors 的方法,但多出一個 Message function,用來提供外界訊息

type AppError struct {
    cause   error
    message string
}

func (e *AppError) Error() string {
    return "app error: " + e.message + ":" + e.cause.Error()
}

func (e *AppError) Message() string {
    return e.message
}

func (e *AppError) Unwrap() error { return w.cause }

func Wrapf(err error, format string, args ...interface{}) error {
    if err == nil {
        return nil
    }
    err = &Error{
        cause:   err,
        message: fmt.Sprintf(format, args...),
    }
    return errors.WithStack(err)
}

在 gin 的 Middleware,先使用 errors.As() 來型別斷言,再調用 Message(),修改成

err := errors.Unwrap(c.Errors[0])
var appErr entity.AppError
if errors.As(err, &appErr) {
    var status int
    switch {
    case errors.Is(appErr, entity.ErrInvalidInput):
        status = http.StatusBadRequest
    case errors.Is(appErr, entity.ErrUnauthorized):
        status = http.StatusUnauthorized
    case errors.Is(appErr, entity.ErrPermissionDenied):
        status = http.StatusForbidden
    default:
        status = http.StatusInternalServerError
    }
    c.JSON(status, httpErr{Message: appErr.Message()})
}

要注意這裡有個微妙的前後關係,是先意識到分層的必要性,透過分離責任來分離訊息,而不是反過來,先實作結構,再透過分離訊息來分離責任。

結語

簡單總結內容:error 中最重要的兩項資訊是 Error Message 跟 Call Stack,儘管 Golang 在 1.13 後的標準庫已經有加入上下文的處理,但為了更符合應用場景與節省自己的時間,還是會建議用 pkg/errors 來處理 error。

也因為 Call Stack 是重要資訊,當收到 error 的第一時間,應該用 Wrapf 重新包裝成 AppError,讓開發者後續要追蹤時,可以一路查找到底。至於 ApiError 的處理,則是放在 Middleware,讓責任可以分離得更清楚。

當然,如果你是開發 lib,就像 Golang 標準庫常見的那樣,用 Sentinel Error 就可以了。lib 應該盡可能保持簡單;而應用則是盡可能讓責任保持簡單。希望大家看完這篇文後,都能優雅地處理錯誤。

Reference

Read more

Weekly Issue 第 21 期:JetBrains 發表 2025 Go 生態系調查

最近在讀 Tony Fadell 的 "Build",作者曾經參與過 iPhone 的開發,各種經驗談讓人嘆為觀止,例如這段:「如果故事有某個部分銜接不上,那麼產品本身也會有某個地方行不通…這便是為什麼最後 iPhone 的表面是玻璃,而不是塑膠,以及為什麼 iPhone 沒有硬體鍵盤。」 好在哪呢?好在如果能掌握這個觀念,就能知道如何「閱讀」產品,看見一個產品,就像閱讀一則故事一樣,知道它的抑揚頓挫,知道它想表現的東西。我相信每個經歷過產品開發的人,看這本書都會很有感覺。   🗞️ 熱門新聞 The Go Ecosystem in 2025: Key Trends in Frameworks, Tools, and Developer Practices JetBrains 前陣子公布 Go 生態系的調查結果。

By Ken Chen

Weekly Issue 第 20 期:AI 泡沫的遺產

2000 年的 .com 泡沫雖然造成嚴重的經濟問題,但也給後續的網路世代留下豐富的遺產。我們現在使用的網路基礎建設,很多是因為泡沫的原因,才能一次性投資到位。而當下經歷的 AI 浪潮,在時間過去後,又會給我們留下什麼遺產呢? 🗞️ 熱門新聞 The Benefits of Bubbles 我看 Ben Thompson 的文章通常會有兩種感受,負面是他太囉唆了,把簡單的觀念講得太長(儘管容易懂),而正面是他的觀點一向很有創造性。 這篇也是,前陣子看到有篇談 AI 泡沫後,什麼都不會留下,因為 GPU 很快會隨著時間折舊掉。我持保留態度,我認為重點不僅是 GPU(正如我認為 .com 泡沫的重點不是 CPU),還有其他的東西,至於是什麼,我沒想到。 BT 認為是晶圓製造與電力,It's amazing,

By Ken Chen

Weekly Issue 第 19 期:Coursera 的預覽模式宣告 MOOC 終結

我有時會上課程網站買課,特別是國外的網站,有些課程內容品質高,而且還能無價體驗,我常常在想這在商業上怎麼行得通。Coursera 最近推出預覽功能,某方面來說,也是在宣告長期要往付費走。 網路最大的特點是開放,因為開放,我們看到不可思議的成長,也因為開放,我們有時會很惋惜理想的落幕。 🗞️ 熱門新聞 The Day MOOCs Truly Died: Coursera's Preview Mode Kills Free Learning 很有趣的一篇新聞:Coursera 的預覽模式給了 MOOC 最後一擊。 我對 Coursera 的商業模式不熟,看起來它之前是靠證書與服務營利。很難想像線上課程能用免費支撐這麼久,這幾乎是公益了,將內容鎖在付費牆後比較像可理解的商業行為。 讓我困惑的是,這些年 Coursera 是如何獲利?以及,當時投資人對它的想像是什麼? The PSF has withdrawn

By Ken Chen

Weekly Issue 第 18 期:OpenAI 發布 AI 瀏覽器 Atlas

OpenAI 最近發布 AI 瀏覽器,加上稍早的 Sora 2,在技術圈中引起一些討論。 我認為 OpenAI 嘗試將模型領域的優勢帶到應用面,但這也讓它顯得更像是一家營利公司,而非研究單位(雖然現在沒人會把 OpenAI 當成研究單位了)。 🗞️ 熱門新聞 Dane Stuckey (OpenAI CISO) on prompt injection risks for ChatGPT Atlas Simon Willison 聊了他對 OpenAI Altas 的看法,主要是資安方面。 幾個點:1) 提示詞注入問題依然存在,而且還沒有好解法;2) OpenAI 設計了登出模式與監視模式,讓使用者更容易意識到安全性。 在我看來第二點很重要,好設計應該要避免使用者犯錯,如果 AI 瀏覽器可以在登出狀態下執行,能避免掉很多麻煩的狀況,當然這意味著沒辦法自動購物。

By Ken Chen