Go語言錯誤處理策略

2020-07-16 10:05:08
有一些函數總是成功返回的,比如,strings.Contains 和 strconv.FormatBool 對所有可能的引數變數都有定義好的返回結果,不會呼叫失敗——儘管還有災難性的和不可預知的場景,像記憶體耗盡,這類錯誤的表現和起因相差甚遠而且恢復的希望也很渺茫。

其他的函數只要符合其前置條件就能夠成功返回。比如函數始終會利用年、月等構成 time.Time,但是如果最後一個引數(表示時區)為 nil 則會導致宕機。這個宕機標誌著這是一個明顯的 bug,應該避免這樣呼叫程式碼。

對於許多其他函數,即使在高品質的程式碼中,也不能保證一定能夠成功返回,因為有些因素並不受程式設計者的掌控。比如任何操作 I/O 的函數都一定會面對可能的錯誤,只有沒有經驗的程式設計師會認為一個簡單的讀或寫不會失敗。事實上,這些地方是我們最需要關注的,很多可靠的操作都可能會毫無徵兆地發生錯誤。

因此錯誤處理是包的 API 設計或者應用程式使用者介面的重要部分,發生錯誤只是許多預料行為中的一種而已。這就是Go語言處理錯誤的方法。

如果當函數呼叫發生錯誤時返回一個附加的結果作為錯誤值,習慣上將錯誤值作為最後一個結果返回。如果錯誤只有一種情況,結果通常設定為布林型別,就像下面這個查詢快取值的例子裡面,往往都返回成功,只有不存在對應的鍵值的時候返回錯誤:

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

更多時候,尤其對於 I/O 操作,錯誤的原因可能多種多樣,而呼叫者則需要一些詳細的資訊。在這種情況下,錯誤的結果型別往往是 error。

error 是內建的介面型別。目前我們已經了解到,一個錯誤可能是空值或者非空值,空值意味著成功而非空值意味著失敗,且非空的錯誤型別有一個錯誤訊息字串,可以通過呼叫它的 Error 方法或者通過呼叫 fmt.Println(err) 或 fmt.Printf("%v", err) 直接輸出錯誤訊息:

一般當一個函數返回一個非空錯誤時,它其他的結果都是未定義的而且應該忽略。然而,有一些函數在呼叫出錯的情況下會返回部分有用的結果。比如,如果在讀取一個檔案的時候發生錯誤,呼叫 Read 函數後返回能夠成功讀取的位元組數與相對應的錯誤值。正確的行為通常是在呼叫者處理錯誤前先處理這些不完整的返回結果。因此在文件中清晰地說明返回值的意義是很重要的。

與許多其他語言不同,Go語言通過使用普通的值而非異常來報告錯誤。儘管Go語言有異常機制,但是Go語言的異常只是針對程式 bug 導致的預料外的錯誤,而不能作為常規的錯誤處理方法出現在程式中。

這樣做的原因是異常會陷入帶有錯誤訊息的控制流去處理它,通常會導致預期外的結果:錯誤會以難以理解的棧跟蹤資訊報告給終端使用者,這些資訊大都是關於程式結構方面的而不是簡單明瞭的錯誤訊息。

相比之下,Go 程式使用通常的控制流機制(比如 if 和 return 語句)應對錯誤。這種方式在錯誤處理邏輯方面要求更加小心謹慎,但這恰恰是設計的要點。

錯誤處理策略

當一個函數呼叫返回一個錯誤時,呼叫者應當負責檢查錯誤並採取合適的處理應對。根據情形,將有許多可能的處理場景。接下來我們看 5 個例子。

首先也最常見的情形是將錯誤傳遞下去,使得在子例程中發生的錯誤變為主調例程的錯誤。《函數的多返回值》的一節中討論過 findLinks 函數的範例。如果呼叫 http.Get 失敗,findLinks 不做任何操作立即向呼叫者返回這個 HTTP 錯誤。

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

對比之下,如果呼叫 html.Parse 失敗,findLinks 將不會直接返回 HTML 解析的錯誤,因為它缺失兩個關鍵資訊:解析器的出錯資訊與被解析文件的 URL。在這種情況下,findLinks 構建一個新的錯誤訊息,其中包含我們需要的所有相關資訊和解析的錯誤資訊:

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

fmt.Errorf 使用 fmt.Sprintf 函數格式化一條錯誤訊息並且返回一個新的錯誤值。我們為原始的錯誤訊息不斷地新增額外的上下文資訊來建立一個可讀的錯誤描述。當錯誤最終被程式的 main 函數處理時,它應當能夠提供一個從最根本問題到總體故障的清晰因果鏈,這讓我想到 NASA 的事故調查有這樣一個例子:

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

因為錯誤訊息頻繁地串聯起來,所以訊息字串首字母不應該大寫而且應該避免換行。錯誤結果可能會很長,但能夠使用 grep 這樣的工具找到我們需要的資訊。

設計一個錯誤訊息的時候應當慎重,確保每一條訊息的描述都是有意義的,包含充足的相關資訊,並且保持一致性,不論被同一個函數還是同一個包下面的一組函數返回時,這樣的錯誤都可以保持統一的形式和錯誤處理方式。

比如,OS 包保證每一個檔案操作(比如 os.Open 或針對開啟的檔案的 Read、Write 或 Close 方法)返回的錯誤不僅包括錯誤的資訊(沒有許可權、路徑不存在等)還包含檔案的名字,因此呼叫者在構造錯誤訊息的時候不需要再包含這些資訊。

一般地,f(x) 呼叫只負責報告函數的行為 f 和引數值 x,因為它們和錯誤的上下文相關。呼叫者負責新增進一步的資訊,但是 f(x) 本身並不會,就像上面函數中 URL 和 html.Parse 的關係。

我們接下來看一下第二種錯誤處理策略。對於不固定或者不可預測的錯誤,在短暫的間隔後對操作進行重試是合乎情理的,超出一定的重試次數和限定的時間後再報錯退出。
// WaitForServer 嘗試連線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: %vn", err)
    os.Exit(1)
}

一個更加方便的方法是通過呼叫 log.Fatalf 實現相同的效果。就和所有的紀錄檔函數一樣,它預設會將時間和日期作為字首新增到錯誤訊息前。

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

預設的格式有助於長期執行的伺服器,而對於互動式的命令列工具則意義不大:

2006/01/02 15:04:05 Site is down: no such domain: bad.gopl.io

一種更吸引人的輸岀方式是自己定義命令的名稱作為 log 包的字首,並且將日期和時間略去。

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

第四,在一些錯誤情況下,只記錄下錯誤資訊然後程式繼續執行。同樣地,可以選擇使用 log 包來增加紀錄檔的常用字首:

if err := Ping(); err != nil {
    log.Printf("ping failed: %v; networking disabled", err)
}

並且直接輸出到標準錯誤流:

if err := Ping(); err != nil {
    fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabledn", 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 可能會失敗,但程式忽略了這個錯誤,原因是作業系統會周期性地清理臨時目錄。在這個例子中,我們有意地拋棄了錯誤,但程式的邏輯看上去就和我們忘記去處理了一樣。要習慣考慮到每一個函數呼叫可能發生的出錯情況,當有意地忽略一個錯誤的時候,清楚地註釋一下你的意圖。

Go語言的錯誤處理有特定的規律。進行錯誤檢查之後,檢測到失敗的情況往往都在成功之前。如果檢測到的失敗導致函數返回,成功的邏輯一般不會放在 else 塊中而是在外層的作用域中。函數會有一種通常的形式,就是在開頭有一連串的檢查用來返回錯誤,之後跟著實際的函數體一直到最後。

檔案結束標識

通常,終端使用者會對函數返回的多種錯誤感興趣而不是中間涉及的程式邏輯。偶爾,一個程式必須針對不同各種類的錯誤採取不同的措施。考慮如果要從一個檔案中讀取 n 個位元組的資料。如果 n 是檔案本身的長度,任何錯誤都代表操作失敗。

另一方面,如果呼叫者反復地嘗試讀取固定大小的塊直到檔案耗盡,呼叫者必須把讀取到檔案尾的情況區別於遇到其他錯誤的操作。為此,io 包保證任何由檔案結束引起的讀取錯誤,始終都將會得到一個與眾不同的錯誤 io.EOF,它的定義如下:

package io
import "errors"
//當沒有更多輸入時,將會返回 EOF
var EOF = errors.New("EOF")

呼叫者可以使用一個簡單的比較操作來檢測這種情況,在下面的迴圈中,不斷從標準輸入中讀取字元。
in := bufio.NewReader(os.Stdin)
for {
    r, _, err := in.ReadRune()
    if err == io.EOF {
        break //結束讀取
    }
    if err != nil {
        return fmt.Errorf("read failed: %v", err)
    }
    //...使用 r...
}
除了反映這個實際情況外,因為檔案結束的條件沒有其他資訊,所以 io.EOF 有一條固定的錯誤訊息“EOF”。對於其他錯誤,我們可能需要同時得到錯誤相關的本質原因和數量資訊,因此一個固定的錯誤值並不能滿足我們的需求。