1. 程式人生 > >Golang錯誤和異常處理的正確姿勢

Golang錯誤和異常處理的正確姿勢

序言

錯誤和異常是兩個不同的概念,非常容易混淆。很多程式設計師習慣將一切非正常情況都看做錯誤,而不區分錯誤和異常,即使程式中可能有異常丟擲,也將異常及時捕獲並轉換成錯誤。從表面上看,一切皆錯誤的思路更簡單,而異常的引入僅僅增加了額外的複雜度。 但事實並非如此。眾所周知,Golang遵循“少即是多”的設計哲學,追求簡潔優雅,就是說如果異常價值不大,就不會將異常加入到語言特性中。

錯誤和異常處理是程式的重要組成部分,我們先看看下面幾個問題:

1. 錯誤和異常如何區分?
2. 錯誤處理的方式有哪幾種?
3. 什麼時候需要使用異常終止程式?
4. 什麼時候需要捕獲異常?
 ... 

如果你對這幾個問題的答案不是太清楚,那麼就抽一點時間看看本文,或許能給你一些啟發

基礎知識

錯誤指的是可能出現問題的地方出現了問題,比如開啟一個檔案時失敗,這種情況在人們的意料之中 ;而異常指的是不應該出現問題的地方出現了問題,比如引用了空指標,這種情況在人們的意料之外。可見,錯誤是業務過程的一部分,而異常不是 。

Golang中引入error介面型別作為錯誤處理的標準模式,如果函式要返回錯誤,則返回值型別列表中肯定包含error。error處理過程類似於C語言中的錯誤碼,可逐層返回,直到被處理。

Golang中引入兩個內建函式panic和recover來觸發和終止異常處理流程,同時引入關鍵字defer來延遲執行defer後面的函式。 一直等到包含defer語句的函式執行完畢時,延遲函式(defer後的函式)才會被執行,而不管包含defer語句的函式是通過return的正常結束,還是由於panic導致的異常結束。你可以在一個函式中執行多條defer語句,它們的執行順序與宣告順序相反。 當程式執行時,如果遇到引用空指標、下標越界或顯式呼叫panic函式等情況,則先觸發panic函式的執行,然後呼叫延遲函式。呼叫者繼續傳遞panic,因此該過程一直在呼叫棧中重複發生:函式停止執行,呼叫延遲執行函式等。如果一路在延遲函式中沒有recover函式的呼叫,則會到達該攜程的起點,該攜程結束,然後終止其他所有攜程,包括主攜程(類似於C語言中的主執行緒,該攜程ID為1)。

錯誤和異常從Golang機制上講,就是error和panic的區別。很多其他語言也一樣,比如C++/Java,沒有error但有errno,沒有panic但有throw。

Golang錯誤和異常是可以互相轉換的:

  1. 錯誤轉異常,比如程式邏輯上嘗試請求某個URL,最多嘗試三次,嘗試三次的過程中請求失敗是錯誤,嘗試完第三次還不成功的話,失敗就被提升為異常了。
  2. 異常轉錯誤,比如panic觸發的異常被recover恢復後,將返回值中error型別的變數進行賦值,以便上層函式繼續走錯誤處理流程。

一個啟示

regexp包中有兩個函式Compile和MustCompile,它們的宣告如下:

func
Compile(expr string) (*Regexp, error) func MustCompile(str string) *Regexp

同樣的功能,不同的設計:

  1. Compile函式基於錯誤處理設計,將正則表示式編譯成有效的可匹配格式,適用於使用者輸入場景。當用戶輸入的正則表示式不合法時,該函式會返回一個錯誤。
  2. MustCompile函式基於異常處理設計,適用於硬編碼場景。當呼叫者明確知道輸入不會引起函式錯誤時,要求呼叫者檢查這個錯誤是不必要和累贅的。我們應該假設函式的輸入一直合法,當呼叫者輸入了不應該出現的輸入時,就觸發panic異常。

於是我們得到一個啟示:什麼情況下用錯誤表達,什麼情況下用異常表達,就得有一套規則,否則很容易出現一切皆錯誤或一切皆異常的情況。

在這個啟示下,我們給出異常處理的作用域(場景):

1. 空指標引用
2. 下標越界
3. 除數為0
4. 不應該出現的分支,比如default
5. 輸入不應該引起函式錯誤

其他場景我們使用錯誤處理,這使得我們的函式介面很精煉。對於異常,我們可以選擇在一個合適的上游去recover,並列印堆疊資訊,使得部署後的程式不會終止。

說明: Golang錯誤處理方式一直是很多人詬病的地方,有些人吐槽說一半的程式碼都是”if err != nil { / 列印 && 錯誤處理 / }”,嚴重影響正常的處理邏輯。當我們區分錯誤和異常,根據規則設計函式,就會大大提高可讀性和可維護性。

錯誤處理的正確姿勢

姿勢一:失敗的原因只有一個時,不使用error

我們看一個案例:

func (self *AgentContext) CheckHostType(host_type string) error {
    switch host_type {
    case "virtual_machine":
        return nil
    case "bare_metal":
        return nil
    }
    return errors.New("CheckHostType ERROR:" + host_type)
}

我們可以看出,該函式失敗的原因只有一個,所以返回值的型別應該為bool,而不是error,重構一下程式碼:

func (self *AgentContext) IsValidHostType(hostType string) bool {
    return hostType == "virtual_machine" || hostType == "bare_metal"
}

說明:大多數情況,導致失敗的原因不止一種,尤其是對I/O操作而言,使用者需要了解更多的錯誤資訊,這時的返回值型別不再是簡單的bool,而是error。

姿勢二:沒有失敗時,不使用error

error在Golang中是如此的流行,以至於很多人設計函式時不管三七二十一都使用error,即使沒有一個失敗原因。
我們看一下示例程式碼:

func (self *CniParam) setTenantId() error {
    self.TenantId = self.PodNs
    return nil
}

對於上面的函式設計,就會有下面的呼叫程式碼:

err := self.setTenantId()
if err != nil {
    // log
    // free resource
    return errors.New(...)
}

根據我們的正確姿勢,重構一下程式碼:

func (self *CniParam) setTenantId() {
    self.TenantId = self.PodNs
}

於是呼叫程式碼變為:

self.setTenantId()

姿勢三:error應放在返回值型別列表的最後

對於返回值型別error,用來傳遞錯誤資訊,在Golang中通常放在最後一個。

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

bool作為返回值型別時也一樣。

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

姿勢四:錯誤值統一定義,而不是跟著感覺走

很多人寫程式碼時,到處return errors.New(value),而錯誤value在表達同一個含義時也可能形式不同,比如“記錄不存在”的錯誤value可能為:

1. "record is not existed."
2. "record is not exist!"
3. "###record is not existed!!!"
4. ...

這使得相同的錯誤value撒在一大片程式碼裡,當上層函式要對特定錯誤value進行統一處理時,需要漫遊所有下層程式碼,以保證錯誤value統一,不幸的是有時會有漏網之魚,而且這種方式嚴重阻礙了錯誤value的重構。

於是,我們可以參考C/C++的錯誤碼定義檔案,在Golang的每個包中增加一個錯誤物件定義檔案,如下所示:

var ERR_EOF = errors.New("EOF")
var ERR_CLOSED_PIPE = errors.New("io: read/write on closed pipe")
var ERR_NO_PROGRESS = errors.New("multiple Read calls return no data or error")
var ERR_SHORT_BUFFER = errors.New("short buffer")
var ERR_SHORT_WRITE = errors.New("short write")
var ERR_UNEXPECTED_EOF = errors.New("unexpected EOF")

說明:筆者對於常量更喜歡C/C++的“全大寫+下劃線分割”的命名方式,讀者可以根據團隊的命名規範或個人喜好定製。

姿勢五:錯誤逐層傳遞時,層層都加日誌

根據筆者經驗,層層都加日誌非常方便故障定位。

說明:至於通過測試來發現故障,而不是日誌,目前很多團隊還很難做到。如果你或你的團隊能做到,那麼請忽略這個姿勢:)

姿勢六:錯誤處理使用defer

我們一般通過判斷error的值來處理錯誤,如果當前操作失敗,需要將本函式中已經create的資源destroy掉,示例程式碼如下:

func deferDemo() error {
    err := createResource1()
    if err != nil {
        return ERR_CREATE_RESOURCE1_FAILED
    }
    err = createResource2()
    if err != nil {
        destroyResource1()
        return ERR_CREATE_RESOURCE2_FAILED
    }

    err = createResource3()
    if err != nil {
        destroyResource1()
        destroyResource2()
        return ERR_CREATE_RESOURCE3_FAILED
    }

    err = createResource4()
    if err != nil {
        destroyResource1()
        destroyResource2()
        destroyResource3()
        return ERR_CREATE_RESOURCE4_FAILED
    }
    return nil
}

當Golang的程式碼執行時,如果遇到defer的閉包呼叫,則壓入堆疊。當函式返回時,會按照後進先出的順序呼叫閉包。
對於閉包的引數是值傳遞,而對於外部變數卻是引用傳遞,所以閉包中的外部變數err的值就變成外部函式返回時最新的err值。
根據這個結論,我們重構上面的示例程式碼:

func deferDemo() error {
    err := createResource1()
    if err != nil {
        return ERR_CREATE_RESOURCE1_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource1()
        }
    }()
    err = createResource2()
    if err != nil {
        return ERR_CREATE_RESOURCE2_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource2()
        }
    }()

    err = createResource3()
    if err != nil {
        return ERR_CREATE_RESOURCE3_FAILED
    }
    defer func() {
        if err != nil {
            destroyResource3()
        }
    }()

    err = createResource4()
    if err != nil {
        return ERR_CREATE_RESOURCE4_FAILED
    }
    return nil
}

姿勢七:當嘗試幾次可以避免失敗時,不要立即返回錯誤

如果錯誤的發生是偶然性的,或由不可預知的問題導致。一個明智的選擇是重新嘗試失敗的操作,有時第二次或第三次嘗試時會成功。在重試時,我們需要限制重試的時間間隔或重試的次數,防止無限制的重試。

兩個案例:

  1. 我們平時上網時,嘗試請求某個URL,有時第一次沒有響應,當我們再次重新整理時,就有了驚喜。
  2. 團隊的一個QA曾經建議當Neutron的attach操作失敗時,最好嘗試三次,這在當時的環境下驗證果然是有效的。

姿勢八:當上層函式不關心錯誤時,建議不返回error

對於一些資源清理相關的函式(destroy/delete/clear),如果子函數出錯,列印日誌即可,而無需將錯誤進一步反饋到上層函式,因為一般情況下,上層函式是不關心執行結果的,或者即使關心也無能為力,於是我們建議將相關函式設計為不返回error。

姿勢九:當發生錯誤時,不忽略有用的返回值

通常,當函式返回non-nil的error時,其他的返回值是未定義的(undefined),這些未定義的返回值應該被忽略。然而,有少部分函式在發生錯誤時,仍然會返回一些有用的返回值。比如,當讀取檔案發生錯誤時,Read函式會返回可以讀取的位元組數以及錯誤資訊。對於這種情況,應該將讀取到的字串和錯誤資訊一起打印出來。

說明:對函式的返回值要有清晰的說明,以便於其他人使用。

異常處理的正確姿勢

姿勢一:在程式開發階段,堅持速錯

去年學習Erlang的時候,建立了速錯的理念,簡單來講就是“讓它掛”,只有掛了你才會第一時間知道錯誤。在早期開發以及任何釋出階段之前,最簡單的同時也可能是最好的方法是呼叫panic函式來中斷程式的執行以強制發生錯誤,使得該錯誤不會被忽略,因而能夠被儘快修復。

姿勢二:在程式部署後,應恢復異常避免程式終止

在Golang中,雖然有類似Erlang程序的Goroutine,但需要強調的是Erlang的掛,只是Erlang程序的異常退出,不會導致整個Erlang節點退出,所以它掛的影響層面比較低,而Goroutine如果panic了,並且沒有recover,那麼整個Golang程序(類似Erlang節點)就會異常退出。所以,一旦Golang程式部署後,在任何情況下發生的異常都不應該導致程式異常退出,我們在上層函式中加一個延遲執行的recover呼叫來達到這個目的,並且是否進行recover需要根據環境變數或配置檔案來定,預設需要recover。
這個姿勢類似於C語言中的斷言,但還是有區別:一般在Release版本中,斷言被定義為空而失效,但需要有if校驗存在進行異常保護,儘管契約式設計中不建議這樣做。在Golang中,recover完全可以終止異常展開過程,省時省力。

我們在呼叫recover的延遲函式中以最合理的方式響應該異常:

  1. 列印堆疊的異常呼叫資訊和關鍵的業務資訊,以便這些問題保留可見;
  2. 將異常轉換為錯誤,以便呼叫者讓程式恢復到健康狀態並繼續安全執行。

我們看一個簡單的例子

func funcA() error {
    defer func() {
        if p := recover(); p != nil {
            fmt.Printf("panic recover! p: %v", p)
            debug.PrintStack()
        }
    }()
    return funcB()
}

func funcB() error {
    // simulation
    panic("foo")
    return errors.New("success")
}

func test() {
    err := funcA()
    if err == nil {
        fmt.Printf("err is nil\\n")
    } else {
        fmt.Printf("err is %v\\n", err)
    }
}

我們期望test函式的輸出是:

err is foo

實際上test函式的輸出是:

err is nil

原因是panic異常處理機制不會自動將錯誤資訊傳遞給error,所以要在funcA函式中進行顯式的傳遞,程式碼如下所示:

func funcA() (err error) {
    defer func() {
        if p := recover(); p != nil {
            fmt.Println("panic recover! p:", p)
            str, ok := p.(string)
            if ok {
                err = errors.New(str)
            } else {
                err = errors.New("panic")
            }
            debug.PrintStack()
        }
    }()
    return funcB()
}

姿勢三:對於不應該出現的分支,使用異常處理

當某些不應該發生的場景發生時,我們就應該呼叫panic函式來觸發異常。比如,當程式到達了某條邏輯上不可能到達的路徑:

switch s := suit(drawCard()); s {
    case "Spades":
    // ...
    case "Hearts":
    // ...
    case "Diamonds":
    // ... 
    case "Clubs":
    // ...
    default:
        panic(fmt.Sprintf("invalid suit %v", s))
}

姿勢四:針對入參不應該有問題的函式,使用panic設計

入參不應該有問題一般指的是硬編碼,我們先看“一個啟示”一節中提到的兩個函式(Compile和MustCompile),其中MustCompile函式是對Compile函式的包裝:

func MustCompile(str string) *Regexp {
    regexp, error := Compile(str)
    if error != nil {
        panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
    }
    return regexp
}

所以,對於同時支援使用者輸入場景和硬編碼場景的情況,一般支援硬編碼場景的函式是對支援使用者輸入場景函式的包裝。
對於只支援硬編碼單一場景的情況,函式設計時直接使用panic,即返回值型別列表中不會有error,這使得函式的呼叫處理非常方便(沒有了乏味的”if err != nil {/ 列印 && 錯誤處理 /}”程式碼塊)。

小結

本文以Golang為例,闡述了錯誤和異常的區別,並且分享了很多錯誤和異常處理的正確姿勢,這些姿勢可以單獨使用,也可以組合使用,希望對大家有一點啟發。