錯誤處理(包括日誌記錄)
簡單的錯誤處理是使用 Fprintf 和 %v 在標準錯誤流上輸出一條消息,%v 可以使用默認格式顯示任意類型的值。
為了保持示例代碼簡短,有時會對錯誤處理有意進行一定程度的忽略。明顯的錯誤還是要處理的。但是有些出現概率很小的錯誤,就忽略了,不過要標記所跳過的錯誤檢查,就是加上註釋。
根據情形,將有許多可能的處理場景,接下來是5個例子。
一、將錯誤傳遞下去
最常見的情形是將錯誤傳遞下去,使得在子例程中發生的錯誤變為主調例程的錯誤。
一種是不做任何操作立即向調用者返回錯誤:
resp, err := http.Get(url) if err != nil { return nil, err }
還有一種,不會直接返回,因為錯誤信息中缺失一些關鍵信息:
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v\n", url, err)
}
這裏格式化了一條錯誤消息並且返回一個新的錯誤值。可以為原始的錯誤消息不斷地添加上下文信息來建立一個可讀的錯誤描述。當錯誤最終被程序的 main 函數處理時,它應該能夠提供一個從最根本問題到總體故障的清晰因果鏈、這裏有一個 NASA 的事故調查的例子:
genesis: crashed: no parachute: G-switch failed: bad relay orientation
因為錯誤頻繁地串聯起來,所以消息字符串首字母不應該大寫而且應該避免換行。錯誤結果可能會很長,但能能夠使用 grep 這樣的工具找到需要的信息。
需要添加的關鍵信息
有時候可以不用添加信息直接返回,有時候需要添加一些關鍵信息,因為錯誤信息裏沒有。比如 os.Open 打開文件時,返回的錯誤不僅僅包括錯誤的信息,還包含文件的名字,因此調用者構造錯誤消息的時候不需要包含文件的名字這類信息。具體哪些信息是缺少的關鍵信息需要在原始的錯誤消息的基礎上添加?
一般地,f(x) 調用只負責報告函數的行為 f 和參數值 x,因為它們和錯誤的上下文相關。調用者則負責添加進一步的信息,但是 f(x) 本身並不會,並且在函數內部也沒有這些信息。
二、嘗試重試
對於不固定或者不可預測的錯誤,在短暫的間隔後對操作進行重試是合乎情理的。超出一定的重試次數和限定的時間後再報錯退出。
下面給出了完整的代碼,暫時只看 WaitForServer 函數:
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
)
// 嘗試連接 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)
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "需要提供 url 參數\n")
os.Exit(1)
}
url := os.Args[1]
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
}
這裏的指數退避策略,以及嘗試多次簡單的超時退出的實現也很有意思。
三、輸出日誌並退出
接著看上面的代碼,如果多次重試後依然不能成功,調用者能夠輸出錯誤然後優雅地停止程序,但一般這樣的處理應該留給主程序部分:
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
通常,如果是庫函數,應該將錯誤傳遞給調用者,除非這個錯誤表示一個內部的一致性錯誤,這意味著庫內部存在 bug。
這裏還有一個更加方便的方法是通過調用 log.Fatalf 實現上面相同的效果。和所有的日誌函數一樣,它默認會將時間和日期作為前綴添加到錯誤消息前:
if err := WaitForServer(url); err != nil {
log.Fatalf("Site is down: %v\n", err)
}
這種帶日期時間的默認格式有助於長期運行的服務器,而對於交互式的命令行工具則意義不大。
還可以自定義命令的名稱作為 log 包的前綴,並且將日期和時間略去:
log.SetPrefix("wait: ")
log.SetFlags(0)
四、記錄log日誌
在一些錯誤情況下,只記錄下錯誤信息然後程序繼續運行。同樣地,可以選擇使用 log 包來增加日誌的常用前綴:
if err := Ping(): err != nil {
log.Printf("Ping failed: %v; networking disabled", err)
}
所有 log 函數都會為缺少換行符的日誌補充一個換行符。
或者是,直接輸出到標準錯誤流:
if err := Ping(): err != nil {
fmt.Fprintf(os.Stderr, "Ping failed: %v; networking disabled\n", err)
}
沒有用 log 函數,所以沒有時間日期,當然也不需要。上面說了,對於交互式的命令工具意義不大。
五、忽略錯誤
在某些罕見的情況下,還可以直接安全地忽略掉整個日誌:
dir, err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v", err)
}
// 使用臨時的目錄
os.RemoveAll(dir) // 忽略錯誤,$TMPDIR 會被周期性刪除
調用 os.RemoveAll 可能會失敗,但程序忽略了這個錯誤,原因是操作系統會周期性地清理臨時目錄。在這個例子中,有意的拋棄了錯誤,但程序的邏輯看上去就和忘記去處理一樣了。要習慣考慮到每一個函數調用可能發生的出錯情況,當有意忽略一個錯誤的時候,要清楚地註釋一下你的意圖。
error 接口
之前已經使用過 error 類型了,實際上它是一個接口類型,包含一個返回錯誤消息的方法:
type error interface {
Error() string
}
errors 包
構造 error 最簡單的方法是調用 errors.New,它會返回一個包含指定錯誤消息的新 error 實例。
完整的 errors 包其實只有如下的4行代碼:
package errors
func New(text string) error { return &errorString{text} }
type errorString struct { s string }
func (e *errorString) Error() string { return e.s }
底層的 errorString 類型是一個結構體,而不是像其他包裏那樣定義字符串的別名類型。這主要是為了保護它所表示的錯誤值無意間的(或者也可能是故意的)更新。
定義的 Error 方法是指針方法,而不是值方法。這樣每次 New 分配的 error 實例都互不相等,即使是同樣的錯誤值,也是不同的地址:
fmt.Println(errors.New("TEST") == errors.New("TEST")) // false
這樣可以避免比如像 io.EOF 這樣重要的錯誤,與僅僅只是包含同樣錯誤消息的一個錯誤相等。
fmt.Errorf
直接調用 errors.New 的情況比較少,只在直接能取得錯誤值的字符串信息的時候使用:
func startCPUProfile(w io.Writer) error {
if w == nil {
return errors.New("nil File")
}
return pprof.StartCPUProfile(w)
}
更多的情況是會得到一個錯誤值 err,而我們可以在這個錯誤值之上做一點包裝,還需要做字符串格式化。有一個更易用的封裝函數 fmt.Errorf,它額外還提供了字符串格式化的功能,所以一般都是用這個:
doc, err := html.Parse(resp.Body)
if err != nil {
return fmt.Errorf("parseing %s as HTML: %v", url, err)
}
錯誤處理(包括日誌記錄)