Go 部落格

錯誤處理與 Go

Andrew Gerrand
2011年7月12日

引言

如果你編寫過 Go 程式碼,你可能已經遇到過內建的 error 型別。Go 程式碼使用 error 值來表示異常狀態。例如,當 os.Open 函式無法開啟檔案時,它會返回一個非 nil 的 error 值。

func Open(name string) (file *File, err error)

以下程式碼使用 os.Open 開啟檔案。如果發生錯誤,它會呼叫 log.Fatal 列印錯誤訊息並停止。

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

僅僅瞭解 error 型別的這些知識,你就可以在 Go 中完成很多工作,但本文我們將仔細研究 error,並討論 Go 中錯誤處理的一些良好實踐。

錯誤型別

error 型別是一個介面型別。一個 error 變量表示任何可以將其自身描述為字串的值。這是介面的宣告

type error interface {
    Error() string
}

與所有內建型別一樣,error 型別在宇宙塊中是預宣告的

最常用的 error 實現是 errors 包的未匯出 errorString 型別。

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

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

你可以使用 errors.New 函式構造其中一個值。它接受一個字串,將其轉換為 errors.errorString 並作為 error 值返回。

// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

以下是你如何使用 errors.New

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // implementation
}

呼叫者向 Sqrt 傳遞負引數時,會收到一個非 nil 的 error 值(其具體表示是一個 errors.errorString 值)。呼叫者可以透過呼叫 errorError 方法或直接列印它來訪問錯誤字串(“math: square root of…”)

f, err := Sqrt(-1)
if err != nil {
    fmt.Println(err)
}

fmt 包透過呼叫 error 值的 Error() string 方法來格式化它。

錯誤實現有責任總結上下文。os.Open 返回的錯誤格式為“open /etc/passwd: permission denied”,而不僅僅是“permission denied”。我們的 Sqrt 返回的錯誤缺少有關無效引數的資訊。

為了新增這些資訊,一個有用的函式是 fmt 包的 Errorf。它根據 Printf 的規則格式化字串,並將其作為由 errors.New 建立的 error 返回。

if f < 0 {
    return 0, fmt.Errorf("math: square root of negative number %g", f)
}

在許多情況下,fmt.Errorf 已經足夠好,但由於 error 是一個介面,你可以使用任意資料結構作為錯誤值,以便呼叫者可以檢查錯誤的詳細資訊。

例如,我們假設的呼叫者可能想要恢復傳遞給 Sqrt 的無效引數。我們可以透過定義一個新的錯誤實現而不是使用 errors.errorString 來實現這一點

type NegativeSqrtError float64

func (f NegativeSqrtError) Error() string {
    return fmt.Sprintf("math: square root of negative number %g", float64(f))
}

一個複雜的呼叫者可以使用型別斷言來檢查 NegativeSqrtError 並特別處理它,而那些僅僅將錯誤傳遞給 fmt.Printlnlog.Fatal 的呼叫者將看到行為沒有變化。

再舉一個例子,json 包指定了一個 SyntaxError 型別,當 json.Decode 函式在解析 JSON blob 時遇到語法錯誤時會返回該型別。

type SyntaxError struct {
    msg    string // description of error
    Offset int64  // error occurred after reading Offset bytes
}

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

Offset 欄位甚至沒有在錯誤的預設格式中顯示,但呼叫者可以使用它來為錯誤訊息新增檔案和行資訊

if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
        line, col := findLine(f, serr.Offset)
        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
    }
    return err
}

(這是 Camlistore 專案中實際程式碼的略微簡化版本。)

error 介面只要求一個 Error 方法;特定的錯誤實現可能還有其他方法。例如,net 包按照通常的約定返回 error 型別的錯誤,但有些錯誤實現具有 net.Error 介面定義的附加方法

package net

type Error interface {
    error
    Timeout() bool   // Is the error a timeout?
    Temporary() bool // Is the error temporary?
}

客戶端程式碼可以使用型別斷言測試 net.Error,然後區分瞬態網路錯誤和永久性錯誤。例如,網路爬蟲在遇到臨時錯誤時可能會休眠並重試,否則就放棄。

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

簡化重複的錯誤處理

在 Go 中,錯誤處理很重要。該語言的設計和約定鼓勵你在錯誤發生的地方顯式檢查錯誤(這與在其他語言中丟擲異常並有時捕獲它們的約定不同)。在某些情況下,這會使 Go 程式碼變得冗長,但幸運的是,你可以使用一些技術來最大限度地減少重複的錯誤處理。

考慮一個帶有 HTTP 處理程式的 App Engine 應用程式,該處理程式從資料儲存中檢索記錄並使用模板對其進行格式化。

func init() {
    http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

此函式處理 datastore.Get 函式和 viewTemplateExecute 方法返回的錯誤。在這兩種情況下,它都向使用者顯示一個簡單的錯誤訊息,HTTP 狀態碼為 500(“內部伺服器錯誤”)。這看起來是一個可管理的少量程式碼,但新增更多 HTTP 處理程式後,你很快就會發現許多相同的錯誤處理程式碼副本。

為了減少重複,我們可以定義自己的 HTTP appHandler 型別,其中包含一個 error 返回值

type appHandler func(http.ResponseWriter, *http.Request) error

然後我們可以將 viewRecord 函式更改為返回錯誤

func viewRecord(w http.ResponseWriter, r *http.Request) error {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return err
    }
    return viewTemplate.Execute(w, record)
}

這比原始版本更簡單,但 http 包不理解返回 error 的函式。為了解決這個問題,我們可以在 appHandler 上實現 http.Handler 介面的 ServeHTTP 方法

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

ServeHTTP 方法呼叫 appHandler 函式並將返回的錯誤(如果有)顯示給使用者。請注意,該方法的接收器 fn 是一個函式。(Go 可以做到這一點!)該方法透過在表示式 fn(w, r) 中呼叫接收器來呼叫該函式。

現在,在 http 包中註冊 viewRecord 時,我們使用 Handle 函式(而不是 HandleFunc),因為 appHandler 是一個 http.Handler(而不是一個 http.HandlerFunc)。

func init() {
    http.Handle("/view", appHandler(viewRecord))
}

有了這個基本的錯誤處理基礎設施,我們可以使其更加使用者友好。除了簡單地顯示錯誤字串之外,最好向使用者提供一個帶有適當 HTTP 狀態碼的簡單錯誤訊息,同時將完整錯誤記錄到 App Engine 開發人員控制檯以進行除錯。

為此,我們建立了一個包含 error 和其他一些欄位的 appError 結構體

type appError struct {
    Error   error
    Message string
    Code    int
}

接下來,我們修改 appHandler 型別以返回 *appError

type appHandler func(http.ResponseWriter, *http.Request) *appError

(通常將錯誤的具體型別而不是 error 返回回來是一個錯誤,原因在Go FAQ中討論過,但在這裡這樣做是正確的,因為 ServeHTTP 是唯一看到該值並使用其內容的地方。)

並使 appHandlerServeHTTP 方法向用戶顯示 appErrorMessage,並帶有正確的 HTTP 狀態 Code,並將完整的 Error 記錄到開發人員控制檯

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if e := fn(w, r); e != nil { // e is *appError, not os.Error.
        c := appengine.NewContext(r)
        c.Errorf("%v", e.Error)
        http.Error(w, e.Message, e.Code)
    }
}

最後,我們將 viewRecord 更新為新的函式簽名,並在遇到錯誤時返回更多上下文

func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return &appError{err, "Record not found", 404}
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        return &appError{err, "Can't display record", 500}
    }
    return nil
}

這個版本的 viewRecord 與原始版本長度相同,但現在每行都有特定的含義,並且我們提供了更友好的使用者體驗。

不止於此;我們可以進一步改進應用程式中的錯誤處理。一些想法

  • 為錯誤處理程式提供一個漂亮的 HTML 模板,

  • 當用戶是管理員時,透過將堆疊跟蹤寫入 HTTP 響應來簡化除錯,

  • appError 編寫一個建構函式,用於儲存堆疊跟蹤以方便除錯,

  • appHandler 內部的 panic 中恢復,將錯誤記錄到控制檯為“嚴重”,同時告訴使用者“發生了嚴重錯誤”。這是一個很好的做法,可以避免將使用者暴露於因程式設計錯誤導致無法理解的錯誤訊息。有關更多詳細資訊,請參閱Defer、Panic 和 Recover 一文。

結論

正確的錯誤處理是優秀軟體的基本要求。透過採用本文中描述的技術,你應該能夠編寫更可靠、更簡潔的 Go 程式碼。

下一篇文章:Go for App Engine 現已全面推出
上一篇文章:Go 中的頭等函式
部落格索引