Go 語言 錯誤

2023-03-14 16:53 更新

原文鏈接:https://gopl-zh.github.io/ch5/ch5-04.html


5.4. 錯誤

在Go中有一部分函數(shù)總是能成功的運(yùn)行。比如strings.Contains和strconv.FormatBool函數(shù),對各種可能的輸入都做了良好的處理,使得運(yùn)行時幾乎不會失敗,除非遇到災(zāi)難性的、不可預(yù)料的情況,比如運(yùn)行時的內(nèi)存溢出。導(dǎo)致這種錯誤的原因很復(fù)雜,難以處理,從錯誤中恢復(fù)的可能性也很低。

還有一部分函數(shù)只要輸入的參數(shù)滿足一定條件,也能保證運(yùn)行成功。比如time.Date函數(shù),該函數(shù)將年月日等參數(shù)構(gòu)造成time.Time對象,除非最后一個參數(shù)(時區(qū))是nil。這種情況下會引發(fā)panic異常。panic是來自被調(diào)用函數(shù)的信號,表示發(fā)生了某個已知的bug。一個良好的程序永遠(yuǎn)不應(yīng)該發(fā)生panic異常。

對于大部分函數(shù)而言,永遠(yuǎn)無法確保能否成功運(yùn)行。這是因?yàn)殄e誤的原因超出了程序員的控制。舉個例子,任何進(jìn)行I/O操作的函數(shù)都會面臨出現(xiàn)錯誤的可能,只有沒有經(jīng)驗(yàn)的程序員才會相信讀寫操作不會失敗,即使是簡單的讀寫。因此,當(dāng)本該可信的操作出乎意料的失敗后,我們必須弄清楚導(dǎo)致失敗的原因。

在Go的錯誤處理中,錯誤是軟件包API和應(yīng)用程序用戶界面的一個重要組成部分,程序運(yùn)行失敗僅被認(rèn)為是幾個預(yù)期的結(jié)果之一。

對于那些將運(yùn)行失敗看作是預(yù)期結(jié)果的函數(shù),它們會返回一個額外的返回值,通常是最后一個,來傳遞錯誤信息。如果導(dǎo)致失敗的原因只有一個,額外的返回值可以是一個布爾值,通常被命名為ok。比如,cache.Lookup失敗的唯一原因是key不存在,那么代碼可以按照下面的方式組織:

value, ok := cache.Lookup(key)
if !ok {
    // ...cache[key] does not exist…
}

通常,導(dǎo)致失敗的原因不止一種,尤其是對I/O操作而言,用戶需要了解更多的錯誤信息。因此,額外的返回值不再是簡單的布爾類型,而是error類型。

內(nèi)置的error是接口類型。我們將在第七章了解接口類型的含義,以及它對錯誤處理的影響。現(xiàn)在我們只需要明白error類型可能是nil或者non-nil。nil意味著函數(shù)運(yùn)行成功,non-nil表示失敗。對于non-nil的error類型,我們可以通過調(diào)用error的Error函數(shù)或者輸出函數(shù)獲得字符串類型的錯誤信息。

fmt.Println(err)
fmt.Printf("%v", err)

通常,當(dāng)函數(shù)返回non-nil的error時,其他的返回值是未定義的(undefined),這些未定義的返回值應(yīng)該被忽略。然而,有少部分函數(shù)在發(fā)生錯誤時,仍然會返回一些有用的返回值。比如,當(dāng)讀取文件發(fā)生錯誤時,Read函數(shù)會返回可以讀取的字節(jié)數(shù)以及錯誤信息。對于這種情況,正確的處理方式應(yīng)該是先處理這些不完整的數(shù)據(jù),再處理錯誤。因此對函數(shù)的返回值要有清晰的說明,以便于其他人使用。

在Go中,函數(shù)運(yùn)行失敗時會返回錯誤信息,這些錯誤信息被認(rèn)為是一種預(yù)期的值而非異常(exception),這使得Go有別于那些將函數(shù)運(yùn)行失敗看作是異常的語言。雖然Go有各種異常機(jī)制,但這些機(jī)制僅被使用在處理那些未被預(yù)料到的錯誤,即bug,而不是那些在健壯程序中應(yīng)該被避免的程序錯誤。對于Go的異常機(jī)制我們將在5.9介紹。

Go這樣設(shè)計(jì)的原因是由于對于某個應(yīng)該在控制流程中處理的錯誤而言,將這個錯誤以異常的形式拋出會混亂對錯誤的描述,這通常會導(dǎo)致一些糟糕的后果。當(dāng)某個程序錯誤被當(dāng)作異常處理后,這個錯誤會將堆棧跟蹤信息返回給終端用戶,這些信息復(fù)雜且無用,無法幫助定位錯誤。

正因此,Go使用控制流機(jī)制(如if和return)處理錯誤,這使得編碼人員能更多的關(guān)注錯誤處理。

5.4.1. 錯誤處理策略

當(dāng)一次函數(shù)調(diào)用返回錯誤時,調(diào)用者應(yīng)該選擇合適的方式處理錯誤。根據(jù)情況的不同,有很多處理方式,讓我們來看看常用的五種方式。

首先,也是最常用的方式是傳播錯誤。這意味著函數(shù)中某個子程序的失敗,會變成該函數(shù)的失敗。下面,我們以5.3節(jié)的findLinks函數(shù)作為例子。如果findLinks對http.Get的調(diào)用失敗,findLinks會直接將這個HTTP錯誤返回給調(diào)用者:

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

當(dāng)對html.Parse的調(diào)用失敗時,findLinks不會直接返回html.Parse的錯誤,因?yàn)槿鄙賰蓷l重要信息:1、發(fā)生錯誤時的解析器(html parser);2、發(fā)生錯誤的url。因此,findLinks構(gòu)造了一個新的錯誤信息,既包含了這兩項(xiàng),也包括了底層的解析出錯的信息。

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函數(shù)使用fmt.Sprintf格式化錯誤信息并返回。我們使用該函數(shù)添加額外的前綴上下文信息到原始錯誤信息。當(dāng)錯誤最終由main函數(shù)處理時,錯誤信息應(yīng)提供清晰的從原因到后果的因果鏈,就像美國宇航局事故調(diào)查時做的那樣:

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

由于錯誤信息經(jīng)常是以鏈?zhǔn)浇M合在一起的,所以錯誤信息中應(yīng)避免大寫和換行符。最終的錯誤信息可能很長,我們可以通過類似grep的工具處理錯誤信息(譯者注:grep是一種文本搜索工具)。

編寫錯誤信息時,我們要確保錯誤信息對問題細(xì)節(jié)的描述是詳盡的。尤其是要注意錯誤信息表達(dá)的一致性,即相同的函數(shù)或同包內(nèi)的同一組函數(shù)返回的錯誤在構(gòu)成和處理方式上是相似的。

以os包為例,os包確保文件操作(如os.Open、Read、Write、Close)返回的每個錯誤的描述不僅僅包含錯誤的原因(如無權(quán)限,文件目錄不存在)也包含文件名,這樣調(diào)用者在構(gòu)造新的錯誤信息時無需再添加這些信息。

一般而言,被調(diào)用函數(shù)f(x)會將調(diào)用信息和參數(shù)信息作為發(fā)生錯誤時的上下文放在錯誤信息中并返回給調(diào)用者,調(diào)用者需要添加一些錯誤信息中不包含的信息,比如添加url到html.Parse返回的錯誤中。

讓我們來看看處理錯誤的第二種策略。如果錯誤的發(fā)生是偶然性的,或由不可預(yù)知的問題導(dǎo)致的。一個明智的選擇是重新嘗試失敗的操作。在重試時,我們需要限制重試的時間間隔或重試的次數(shù),防止無限制的重試。

gopl.io/ch5/wait

// WaitForServer attempts to contact the server of a URL.
// It tries for one minute using exponential back-off.
// It reports an error if all attempts fail.
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 // success
        }
        log.Printf("server not responding (%s);retrying…", err)
        time.Sleep(time.Second << uint(tries)) // exponential back-off
    }
    return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}

如果錯誤發(fā)生后,程序無法繼續(xù)運(yùn)行,我們就可以采用第三種策略:輸出錯誤信息并結(jié)束程序。需要注意的是,這種策略只應(yīng)在main中執(zhí)行。對庫函數(shù)而言,應(yīng)僅向上傳播錯誤,除非該錯誤意味著程序內(nèi)部包含不一致性,即遇到了bug,才能在庫函數(shù)中結(jié)束程序。

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

調(diào)用log.Fatalf可以更簡潔的代碼達(dá)到與上文相同的效果。log中的所有函數(shù),都默認(rèn)會在錯誤信息之前輸出時間信息。

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

長時間運(yùn)行的服務(wù)器常采用默認(rèn)的時間格式,而交互式工具很少采用包含如此多信息的格式。

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

我們可以設(shè)置log的前綴信息屏蔽時間信息,一般而言,前綴信息會被設(shè)置成命令名。

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

第四種策略:有時,我們只需要輸出錯誤信息就足夠了,不需要中斷程序的運(yùn)行。我們可以通過log包提供函數(shù)

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

或者標(biāo)準(zhǔn)錯誤流輸出錯誤信息。

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

log包中的所有函數(shù)會為沒有換行符的字符串增加換行符。

第五種,也是最后一種策略:我們可以直接忽略掉錯誤。

dir, err := ioutil.TempDir("", "scratch")
if err != nil {
    return fmt.Errorf("failed to create temp dir: %v",err)
}
// ...use temp dir…
os.RemoveAll(dir) // ignore errors; $TMPDIR is cleaned periodically

盡管os.RemoveAll會失敗,但上面的例子并沒有做錯誤處理。這是因?yàn)椴僮飨到y(tǒng)會定期的清理臨時目錄。正因如此,雖然程序沒有處理錯誤,但程序的邏輯不會因此受到影響。我們應(yīng)該在每次函數(shù)調(diào)用后,都養(yǎng)成考慮錯誤處理的習(xí)慣,當(dāng)你決定忽略某個錯誤時,你應(yīng)該清晰地寫下你的意圖。

在Go中,錯誤處理有一套獨(dú)特的編碼風(fēng)格。檢查某個子函數(shù)是否失敗后,我們通常將處理失敗的邏輯代碼放在處理成功的代碼之前。如果某個錯誤會導(dǎo)致函數(shù)返回,那么成功時的邏輯代碼不應(yīng)放在else語句塊中,而應(yīng)直接放在函數(shù)體中。Go中大部分函數(shù)的代碼結(jié)構(gòu)幾乎相同,首先是一系列的初始檢查,防止錯誤發(fā)生,之后是函數(shù)的實(shí)際邏輯。

5.4.2. 文件結(jié)尾錯誤(EOF)

函數(shù)經(jīng)常會返回多種錯誤,這對終端用戶來說可能會很有趣,但對程序而言,這使得情況變得復(fù)雜。很多時候,程序必須根據(jù)錯誤類型,作出不同的響應(yīng)。讓我們考慮這樣一個例子:從文件中讀取n個字節(jié)。如果n等于文件的長度,讀取過程的任何錯誤都表示失敗。如果n小于文件的長度,調(diào)用者會重復(fù)的讀取固定大小的數(shù)據(jù)直到文件結(jié)束。這會導(dǎo)致調(diào)用者必須分別處理由文件結(jié)束引起的各種錯誤?;谶@樣的原因,io包保證任何由文件結(jié)束引起的讀取失敗都返回同一個錯誤——io.EOF,該錯誤在io包中定義:

package io

import "errors"

// EOF is the error returned by Read when no more input is available.
var EOF = errors.New("EOF")

調(diào)用者只需通過簡單的比較,就可以檢測出這個錯誤。下面的例子展示了如何從標(biāo)準(zhǔn)輸入中讀取字符,以及判斷文件結(jié)束。(4.3的chartcount程序展示了更加復(fù)雜的代碼)

in := bufio.NewReader(os.Stdin)
for {
    r, _, err := in.ReadRune()
    if err == io.EOF {
        break // finished reading
    }
    if err != nil {
        return fmt.Errorf("read failed:%v", err)
    }
    // ...use r…
}

因?yàn)槲募Y(jié)束這種錯誤不需要更多的描述,所以io.EOF有固定的錯誤信息——“EOF”。對于其他錯誤,我們可能需要在錯誤信息中描述錯誤的類型和數(shù)量,這使得我們不能像io.EOF一樣采用固定的錯誤信息。在7.11節(jié)中,我們會提出更系統(tǒng)的方法區(qū)分某些固定的錯誤值。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號