Go 中的異常/錯誤處理


即使是高質量的代碼,也不能保證一定能夠成功返回,因為有些因素並不受程序設計者掌控。例如任何 I/O 操作可能產生錯誤,事實上,這些地方便是程序員最需要關注的。

因此錯誤處理是包的 API 設計或應用程序用戶接口的重要部分,發生錯誤只是許多預料行為中的一種,這就是 Go 語言處理錯誤的方法。

錯誤返回策略

當函數調用發生錯誤時,我們習慣返回一個附加的結果作為錯誤值,且一般作為最后一個返回結果。

1.如果錯誤只有一種情況,那么結果通常為「布爾類型」。例如下面的查詢例子,只有在不存在對應鍵值的適合才返回錯誤:

value, ok := cache.Lookup(key)
if !ok {
    // chche[key] 不存在
}

2.但更多時候,尤其對於 I/O 操作,錯誤的原因可能多種多樣,這時調用者需要一些詳細信息,這種情況下,錯誤的類型往往為「error」。

reso, err := http.Get(url)
if err != nil {
    return nil, err
}

和許多其他語言不同,Go 語言通過使用普通的值而非異常來報告錯誤;Go 語言中的異常通常只是針對程序 bug 導致的預料外錯誤,而不應作為常規的錯誤處理方法出現在程序中。

如果用異常來報告錯誤,會出現下面這種情況:

  • 異常會陷入帶有錯誤信息的控制流去處理它,通常導致預期外的結果:錯誤會以難以理解的棧跟蹤信息報告給最終用戶,這些信息大多關於程序結構方面而不是簡單明了的錯誤信息

因此,Go 使用通常的控制流機制(如 if 和 return)來應對錯誤,這種方式對錯誤處理邏輯方面有更高的要求。

錯誤處理策略

當函數調用返回一個錯誤時,調用者應該檢查是否存在錯誤並采取合適的處理應對,下面我們講講 5 個常見的處理方式:

將錯誤傳遞下去

將錯誤傳遞之后,在子例程中發生的錯誤會變成主調例程的錯誤;這時我們希望傳遞的錯誤能夠返回一個可讀的錯誤描述

error 中信息滿足要求

例如我們調用http.Get失敗,我們可以直接返回這個 HTTP 錯誤:

reso, err := http.Get(url)
if err != nil {
    return nil, err
}

它包含了失敗的 url,也就是這里我們需要的信息。

error 中信息不足

但有時,error 中包含的信息並不清晰,例如我們對一個 response 的響應體調用http.Parse失敗,這種情況下的 err 缺失兩個關鍵信息:解析器的出錯信息與被解析文檔的 url。這種情況下我們會為它構建一個新的錯誤信息:

doc, err := html.Parse(resp.Body)\
resp.Body.Close()
if err != nil {
    return nil, fmt.Errorf("parsing %s as HTML: %v", usr, err)
}

fmt.Errorf會使用fmt.Sprintf格式化一條錯誤信息,並返回一個新的錯誤值。

這樣,我們便為原始的錯誤信息添加了額外的上下文信息,建立了一個可讀的錯誤描述。當錯誤最終返回程序的 main 函數處理時,它應當提供了一個從最根本問題到總體故障的清晰因果鏈。

例如 NASA 的事故調查例子:

genesis: crashed: no parachute: G-switch failed: bad relay orientation

構建錯誤信息的要求

因為錯誤信息頻繁地串聯,因此消息字符串首字母應該小寫,且避免換行。這樣可能會讓錯誤信息很長,但我們可以使用grep這樣的工具找到需要的信息。

一般地,一個函數f(x)的調用只報告函數的行為f參數值x,因為它們與錯誤上下文相關;再由「調用者」進一步添加信息。

給定失敗操作一定重試次數和限定時間,超出后再報錯

有些操作我們應該對它的失敗有所容忍,它可能在短暫時間后便能成功:

// 嘗試連接 URL 對應服務器
func WaitForServer(url string) error {
    const timeout = 1 * time.Minute
    deadline := time.Now().Add(timeout)
    for tries := 0; time.Now().Before(deadline); tries++ {
        _, err := http.Head(url)
        if err == nil {
            return nil // 成功
        }
        log.Printf("server not responding (%s); retrying...", err)
        time.Sleep(time.Second << uint(tries)) // 使用指數退避策略進行重試
    }
    return fmt.Errorf("server %s failed to respond after %s", url, timeout) // 失敗
}

輸出錯誤並停止程序

一般來說,這個操作是留至主程序部分來處理的,其余函數應當將錯誤傳遞給調用者,除非這個錯誤是一個內部一致性錯誤(也就是該函數存在 bug)。

// in function main
if err := WaitForServer(url); err != nil {
    fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
    os.Exit(1)
}

一個更方便的方式是調用log.Fatalf實現一樣的效果,作為一個日志函數,它能夠默認將時間和日期都作為前綴加到錯誤消息前面:

if err := WaitForServer(url); err != nil {
    log.Fatalf("Site is down: %v\n", err)
}

這樣打印得出的格式有助於長期運行的服務器,它能夠使我們方便的對錯誤定位。

我們還可以自定義命令名稱作為log包的前綴,並將日期和時間略去:

log.SetPrefix("wait: ")
log.SetFlags(0)

僅記錄錯誤信息然后程序繼續運行

有時錯誤並不會對程序當前運行產生很大的影響,我們可以將錯誤信息先進行記錄待后續處理。

我們可以用之前提到的log包來增加日志的常用前綴:

if err := Ping(); err != nil {
    // 所有 log 函數都會為缺少換行符的日志填充換行符
    log.Printf("ping failed: %v; networking disabled", err)
}

或是直接輸出到標准錯誤流:

if err := Ping(); err != nil {
    fmt.Fprintf(os,Stderr, "ping failed: %v; networking disabled\n", err)
}

直接忽略整個錯誤日志

在一些罕見的情況下,錯誤日志並沒有意義,這是我們可以直接安全地忽略掉整個日志:

// 創建臨時目錄
dir, err := iout.TempDir("", "scratch")
if err != nil {
    return fmt.Errorf("failed to create temp dir: %v", err)
}

// 使用臨時目錄
...

os.RemoveAll(dir) // 忽略這個語句可能產生的錯誤,$TMPDIR 會被周期性刪除

調用os.Remove可能會失敗,但操作系統自己會周期性的刪除這個目錄,也就是這個語句的失敗與否並無大礙,因此我們忽略了這個錯誤。

在上例中,我們有意地拋棄了錯誤,但程序的邏輯看上去就像我們忘記處理了一樣,因此如果我們需要有意地忽略一個錯誤,一定要在注釋中清晰地寫明理由。

Go 語言中,對語句進行錯誤檢查后;如果檢測到的失敗導致函數返回,成功的邏輯一般不會放在 else 塊中,而是在外層的作用域中。

一般來說,我們會在函數開頭便進行一連串的檢查用來返回錯誤,在之后再進行具體的函數體邏輯。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM