天天看點

Go語言基礎篇--11.Go語言之【panic+recover】panic和recover

panic和recover

go語言使用panic和recover來處理程式當中出現的異常。

go抛出一個panic的異常,然後在defer中通過recover捕獲這個異常,然後正常處理。一般的,recover配合defer使用。

示例代碼:

package main

import "fmt"

func main() {
	testA()
	defer printInfo("-----3")
	testB(8)
	defer printInfo("-----4")

}

func printInfo(s string) {
	fmt.Println(s)
}
func testA() {
	fmt.Println("testA")

}

func testB(n int) {
	defer func() {
		if msg := recover(); msg != nil {
			fmt.Println("demo go on!!!")
		}
	}()
	fmt.Println("testB")
	defer printInfo("defer testB -----1")
	for i := 1; i < n+1; i++ {
		fmt.Printf("%d\t", i)
		if i == 3 {
			panic("pull a panic!!!")
		}
	}
	fmt.Println()
	defer printInfo("defer testB -----2")
}
/*result
testA
testB
1       2       3       defer testB -----1
demo go on!!!
-----4
-----3
*/
           

分析如下:

在testB中我們抛出一個panic異常,一旦抛出一個panic異常,程式中止,panic之後的代碼不再執行,但是panic之前的defer照常按照規則執行。是以要加recover放在panic之間。

recover()不需要任何參數,但是會傳回一個string類型,這個string類型就是之前抛出的panic異常。利用一個變量msg擷取到這個異常,接下來進行判斷,如果msg不為空,也就是程式抛出了一個panic,執行相關代碼,并recover程式。

什麼情況下用錯誤表達,什麼情況下用異常表達,就得有一套規則,否則很容易出現一切皆錯誤或一切皆異常的情況。

以下給出異常處理的作用域(場景):

  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
}
           

于是調用代碼變為:

姿勢三: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!!!”

這使得相同的錯誤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")
           

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

層層都加日志非常友善故障定位。

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

姿勢六:錯誤處理使用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函數會傳回可以讀取的位元組數以及錯誤資訊。對于這種情況,應該将讀取到的字元串和錯誤資訊一起列印出來。

說明:對函數的傳回值要有清晰的說明,以便于其他人使用。

異常處理的正确姿勢

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

速錯,簡單來講就是“讓它挂”,隻有挂了你才會第一時間知道錯誤。在早期開發以及任何釋出階段之前,最簡單的同時也可能是最好的方法是調用panic函數來中斷程式的執行以強制發生錯誤,使得該錯誤不會被忽略,因而能夠被盡快修複。

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

在Golang中,某個Goroutine如果panic了,并且沒有recover,那麼整個Golang程序就會異常退出。是以,一旦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 {/ 列印 && 錯誤處理 /}"代碼塊)。

本文部分内容引用自https://github.com/rubyhan1314

繼續閱讀