序言
錯誤處理在每個語言中都是一項重要內容。眾所周知,通常寫程式時遇到的分為異常與錯誤兩種,Golang中也不例外。Golang遵循『少即是多』的設計哲學,錯誤處理也力求簡潔明了,在錯誤處理上採用了類似c語言的錯誤處理方案,另外在錯誤之外也有異常的概念,Golang中引入兩個內建函數panic和recover來觸發和終止異常處理流程。
基礎知識
錯誤指的是可能出現問題的地方出現了問題,比如開啟一個檔案時可能失敗,這種情況在人們的意料之中 ;而異常指的是不應該出現問題的地方出現了問題,比如引用了null 指標,這種情況在人們的意料之外。可見, 錯誤是商務邏輯的一部分,而異常不是 。
我們知道在C語言裡面是通過返回-1或者NULL之類的資訊來表示錯誤,但是對於使用者來說,不查看相應的API說明文檔,根本搞不清楚這個傳回值究竟代表什麼意思,比如返回0是成功還是失敗?針對這樣情況Golang中引入error介面類型作為錯誤處理的標準模式,如果函數要返回錯誤,則傳回值類型列表中肯定包含error;Golang中引入兩個內建函數panic和recover來觸發和終止異常處理流程,同時引入關鍵字defer來順延強制defer後面的函數。一直等到包含defer語句的函數執行完畢時,延遲函數(defer後的函數)才會被執行,而不管包含defer語句的函數是通過return的正常結束,還是由於panic導致的異常結束。你可以在一個函數中執行多條defer語句,它們的執行順序與聲明順序相反。
程式運行時若出現了null 指標引用、數組下標越界等異常情況,則會觸發Golang中panic函數的執行,程式會中斷運行,並立即執行在該goroutine中被延遲的函數,如果不做捕獲,程式會崩潰。
錯誤和異常從Golang機制上講,就是error和panic的區別。很多其他語言也一樣,比如C++/Java,沒有error但有errno,沒有panic但有throw,但panic的適用情境有一些不同。由於panic會引起程式的崩潰,因此panic一般用於嚴重錯誤。
錯誤處理
我們編寫一個簡單的程式,該程式試圖開啟一個不存在的檔案:
package mainimport ( "fmt" "os")func main() { f, err := os.Open("/test.txt") if err != nil { fmt.Println("error:",err) return } fmt.Println(f.Name(), "open successfully")}
可以看到我們的程式調用了os包的Open方法,該方法定義如下:
// Open opens the named file for reading. If successful, methods on// the returned file can be used for reading; the associated file// descriptor has mode O_RDONLY.// If there is an error, it will be of type *PathError.func Open(name string) (*File, error) { return OpenFile(name, O_RDONLY, 0)}
參考注釋可以知道如果這個方法正常返回的話會返回一個可讀的檔案控制代碼和一個值為 nil 的錯誤,如果該方法未能成功開啟檔案會返回一個*PathError類型的錯誤。
如果一個函數 或方法 返回了錯誤,按照Go的慣例,錯誤會作為最後一個值返回。於是 Open 函數也是將 err 作為最後一個傳回值返回。
在Go語言中,處理錯誤時通常都是將返回的錯誤與 nil 比較。nil 值表示了沒有錯誤發生,而非 nil 值表示出現了錯誤。於是有個我們上面那行代碼:
if err != nil { fmt.Println("error:",err) return }
如果你閱讀任何一個Go語言的工程,會發現類似這樣的代碼隨處可見,Go語言就是用這種簡單的形式處理代碼中出現的錯誤。
我們在playground中執行,發現結果顯示
error: open /test.txt: No such file or directory
可以發現我們有效檢測並處理了程式中開啟一個不存在檔案所導致的錯誤,在樣本中我們僅僅是輸出該錯誤並返回。
上面提到Open方法出現錯誤會返回一個*PathError類型的錯誤,這個類型具體是什麼情況呢?別急,我們先來看一下Go中錯誤具體是怎麼實現的。
error類型
Go中返回的error類型究竟是什麼呢?看源碼發現error類型是一個非常簡單的介面類型,具體如下
// The error built-in interface type is the conventional interface for// representing an error condition, with the nil value representing no error.type error interface { Error() string}
error 有了一個簽名為 Error() string 的方法。所有實現該介面的類型都可以當作一個錯誤類型。Error() 方法給出了錯誤的描述。
fmt.Println 在列印錯誤時,會在內部調用 Error() string 方法來得到該錯誤的描述。上一節樣本中的錯誤描述就是這樣列印出的。
自訂錯誤類型
現在我們回到剛才代碼裡的*PathError類型,首先顯而易見os.Open方法返回的錯誤是一個error類型,故我們可以知道PathError類型一定實現了error類型,也就是說實現了Error方法。現在我們看下具體實現
type PathError struct { Op string Path string Err error}func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
可以看到PathError類型實現了Error方法,該方法返迴文件操作、路徑及error字串的拼接傳回值。
為什麼需要自訂錯誤類型呢,試想一下如果一個錯誤我們拿到的僅僅是錯誤的字串描述,那顯然無法從錯誤中擷取更多的資訊或者做一些邏輯相關的校正,這樣我們就可以通過自訂錯誤的結構體,通過實現Error()來使該結構體成為一個錯誤類型,使用時做一下類型推薦,我們就可以從返回的錯誤通過結構體中的一些成員就可以做邏輯校正或者錯誤分類等工作。例如:
package mainimport ( "fmt" "os")func main() { f, err := os.Open("/test.txt") if err, ok := err.(*os.PathError); ok { fmt.Println("File at path", err.Path, "failed to open") return } fmt.Println(f.Name(), "opened successfully")}
上面代碼中我們通過將error類型推斷為實際的PathError類型,就可以拿到發生錯誤的Op、Path等資料,更有助於實際情境中錯誤的處理。
我們組現在拉通了一套錯誤類型和錯誤碼規範,之前工程裡寫的時候都是通過在代碼中的controller裡面去根據不同情況去返回,這種處理方法有很多缺點,例如下層僅返回一個error類型,上層怎麼判斷該錯誤是哪種錯誤,該使用哪種錯誤碼呢?另外就是程式中靠程式員寫死某個邏輯錯誤碼為xxxx,使程式缺乏穩定性,錯誤碼返回也較為隨心所欲,因此我也去自訂了錯誤,具體如下:
var ( ErrSuccess = StandardError{0, "成功"} ErrUnrecognized = StandardError{-1, "未知錯誤"} ErrAccessForbid = StandardError{1000, "沒有存取權限"} ErrNamePwdIncorrect = StandardError{1001, "使用者名稱或密碼錯誤"} ErrAuthExpired = StandardError{1002, "認證到期"} ErrAuthInvalid = StandardError{1003, "無效簽名"} ErrClientInnerError = StandardError{4000, "用戶端內部錯誤"} ErrParamError = StandardError{4001, "參數錯誤"} ErrReqForbidden = StandardError{4003, "請求被拒絕"} ErrPathNotFount = StandardError{4004, "請求路徑不存在"} ErrMethodIncorrect = StandardError{4005, "要求方法錯誤"} ErrTimeout = StandardError{4006, "服務逾時"} ErrServerUnavailable = StandardError{5000, "服務不可用"} ErrDbQueryError = StandardError{5001, "資料庫查詢錯誤"})//StandardError 標準錯誤,包含錯誤碼和錯誤資訊type StandardError struct { ErrorCode int `json:"errorCode"` ErrorMsg string `json:"errorMsg"`}// Error 實現了 Error介面func (err StandardError) Error() string { return fmt.Sprintf("errorCode: %d, errorMsg %s", err.ErrorCode, err.ErrorMsg)}
這樣通過直接取StandardError的ErrorCode就可以知道應該返回的錯誤資訊及錯誤碼,調用時候也較為方便,並且做到了標準化,解決了之前項目中錯誤處理的問題。
斷言錯誤行為
有時候僅僅斷言自訂錯誤類型可能在某些情況下不夠方便,可以通過調用自訂錯誤的方法來擷取更多資訊,例如標準庫中的net包中的DNSError
type DNSError struct { Err string // description of the error Name string // name looked for Server string // server used IsTimeout bool // if true, timed out; not all timeouts set this IsTemporary bool // if true, error is temporary; not all errors set this}func (e *DNSError) Timeout() bool { return e.IsTimeout }func (e *DNSError) Temporary() bool { return e.IsTimeout || e.IsTemporary }
可以看到不僅僅自訂了DNSError的錯誤類型,還為該錯誤添加了兩個方法用來讓調用者判斷定該錯誤是臨時性錯誤,還是由逾時導致的。
package mainimport ( "fmt" "net")func main() { addr, err := net.LookupHost("gygolang.com") if err, ok := err.(*net.DNSError); ok { if err.Timeout() { fmt.Println("operation timed out") } else if err.Temporary() { fmt.Println("temporary error") } else { fmt.Println("generic error: ", err) } return } fmt.Println(addr)}
上述代碼中,我們試圖擷取 golangbot123.com(無效的網域名稱) 的 ip。然後通過 *net.DNSError 的類型斷言,擷取到了錯誤的底層值。然後用錯誤的行為檢查了該錯誤是由逾時引起的,還是一個臨時性錯誤。
異常處理
什麼時候使用panic
需要注意的是,你應該儘可能地使用錯誤,而不是使用 panic 和 recover。只有當程式不能繼續啟動並執行時候,才應該使用 panic 和 recover 機制。
panic 有兩個合理的用例:
- 發生了一個不能恢複的錯誤,此時程式不能繼續運行。 一個例子就是 網頁伺服器無法綁定所要求的連接埠。在這種情況下,就應該使用 panic,因為如果不能綁定連接埠,啥也做不了。
- 發生了一個編程上的錯誤。 假如我們有一個接收指標參數的方法,而其他人使用 nil 作為參數調用了它。在這種情況下,我們可以使用 panic,因為這是一個編程錯誤:用 nil 參數調用了一個只能接收合法指標的方法。
panic
內建的panic函數定義如下
func panic(v interface{})
當程式終止時,會列印傳入 panic 的參數。我們一起看一個例子加深下對panic的理解
package mainimport ( "fmt")func fullName(firstName *string, lastName *string) { if firstName == nil { panic("runtime error: first name cannot be nil") } if lastName == nil { panic("runtime error: last name cannot be nil") } fmt.Printf("%s %s\n", *firstName, *lastName) fmt.Println("returned normally from fullName")}func main() { firstName := "foo" fullName(&firstName, nil) fmt.Println("returned normally from main")}
上面的程式很簡單,如果firstName和lastName有任何一個為空白程式便會panic並列印出不同的資訊,程式輸出如下:
panic: runtime error: last name cannot be nilgoroutine 1 [running]:main.fullName(0x1042ff98, 0x0) /tmp/sandbox038383853/main.go:12 +0x140main.main() /tmp/sandbox038383853/main.go:20 +0x40
出現panic時,程式終止運行,列印出傳入 panic 的參數,接著列印出堆疊追蹤。程式首先會列印出傳入 panic 函數的資訊:
panic: runtime error: last name cannot be nil
然後列印堆棧資訊,首先列印堆棧中的第一項
main.fullName(0x1042ff98, 0x0) /tmp/sandbox038383853/main.go:12 +0x140
接著列印堆棧中下一項
main.main() /tmp/sandbox038383853/main.go:20 +0x40
在這個例子中這一項就是棧頂了,於是結束列印。
發生panic時的延遲函數
當函數發生 panic 時,它會終止運行,在執行完所有的延遲函數後,程式控制返回到該函數的調用方。這樣的過程會一直持續下去,直到當前協程的所有函數都返回退出,然後程式會列印出 panic 資訊,接著列印出堆疊追蹤,最後程式終止。
在上面的例子中,我們沒有延遲調用任何函數。如果有延遲函數,會先調用它,然後程式控制返回到函數調用方。我們來修改上面的樣本,使用一個延遲語句。
package mainimport ( "fmt")func fullName(firstName *string, lastName *string) { defer fmt.Println("deferred call in fullName") if firstName == nil { panic("runtime error: first name cannot be nil") } if lastName == nil { panic("runtime error: last name cannot be nil") } fmt.Printf("%s %s\n", *firstName, *lastName) fmt.Println("returned normally from fullName")}func main() { defer fmt.Println("deferred call in main") firstName := "foo" fullName(&firstName, nil) fmt.Println("returned normally from main")}
可以看到輸出如下:
deferred call in fullNamedeferred call in mainpanic: runtime error: last name cannot be nilgoroutine 1 [running]:main.fullName(0x1042ff90, 0x0) /tmp/sandbox170416077/main.go:13 +0x220main.main() /tmp/sandbox170416077/main.go:22 +0xc0
程式退出之前先執行了延遲函數。
recover
程式發生panic後會崩潰,recover用於重新獲得 panic 協程的控制。內建的recover函數定義如下
func recover() interface{}
只有在延遲函數的內部,調用 recover 才有用。在延遲函數內調用 recover,可以取到 panic 的錯誤資訊,並且停止 panic 續發事件(Panicking Sequence),程式運行恢複正常。如果在延遲函數的外部調用 recover,就不能停止 panic 續發事件。
我們來修改一下程式,在發生 panic 之後,使用 recover 來恢複正常的運行。
package mainimport ( "fmt")func recoverName() { if r := recover(); r!= nil { fmt.Println("recovered from ", r) }}func fullName(firstName *string, lastName *string) { defer recoverName() if firstName == nil { panic("runtime error: first name cannot be nil") } if lastName == nil { panic("runtime error: last name cannot be nil") } fmt.Printf("%s %s\n", *firstName, *lastName) fmt.Println("returned normally from fullName")}func main() { defer fmt.Println("deferred call in main") firstName := "foo" fullName(&firstName, nil) fmt.Println("returned normally from main")}
當 fullName 發生 panic 時,會調用延遲函數 recoverName(),它使用了 recover() 來停止 panic 續發事件。程式會輸出
recovered from runtime error: last name cannot be nilreturned normally from maindeferred call in main
當程式發生 panic 時,會調用延遲函數 recoverName,它反過來會調用 recover() 來重新獲得 panic 協程的控制。在執行完 recover() 之後,panic 會停止,程式控制返回到調用方(在這裡就是 main 函數),程式在發生 panic 之後,會繼續正常地運行。程式會列印 returned normally from main,之後是 deferred call in main。
運行時panic
執行階段錯誤也會導致 panic。這等價於調用了內建函數 panic,其參數由介面類型 runtime.Error 給出。
package mainimport ( "fmt")func a() { n := []int{5, 7, 4} fmt.Println(n[3]) fmt.Println("normally returned from a")}func main() { a() fmt.Println("normally returned from main")}
上述代碼是一個典型的數組越界造成的panic,程式輸出如下:
panic: runtime error: index out of rangegoroutine 1 [running]:main.a() /tmp/sandbox100501727/main.go:9 +0x20main.main() /tmp/sandbox100501727/main.go:13 +0x20
可以看到和我們剛才手動出發panic沒什麼不同,只是會列印執行階段錯誤。
那是否可以恢複一個運行時 panic?當然是可以的,也跟剛才恢複panic的方法一樣,在延遲函數中調用recover即可:
ackage mainimport ( "fmt")func r() { if r := recover(); r != nil { fmt.Println("Recovered", r) }}func a() { defer r() n := []int{5, 7, 4} fmt.Println(n[3]) fmt.Println("normally returned from a")}func main() { a() fmt.Println("normally returned from main")}
錯誤與異常的轉化
錯誤與異常有時候可以進行轉化,
- 錯誤轉異常,比如程式邏輯上嘗試請求某個URL,最多嘗試三次,嘗試三次的過程中請求失敗是錯誤,嘗試完第三次還不成功的話,失敗就被提升為異常了。
- 異常轉錯誤,比如panic觸發的異常被recover恢複後,將傳回值中error類型的變數進行賦值,以便上層函數繼續走錯誤處理流程。
例如我們工程中使用的Gin架構裡有這麼兩個函數:
// Get returns the value for the given key, ie: (value, true).// If the value does not exists it returns (nil, false)func (c *Context) Get(key string) (value interface{}, exists bool) { value, exists = c.Keys[key] return}// MustGet returns the value for the given key if it exists, otherwise it panics.func (c *Context) MustGet(key string) interface{} { if value, exists := c.Get(key); exists { return value } panic("Key \"" + key + "\" does not exist")}
可以看到同樣的功能不同的設計:
- Get函數基於錯誤設計,如果使用者的參數中無法取到某參數會返回一個bool類型的錯誤提示。
- MustGet基於異常設計,如果無法取到某參數程式會panic,用於強製取到某參數的寫入程式碼情境。
可以看到錯誤跟異常可以進行轉化,具體怎麼轉化要看業務情境來定。
如何正確且優雅地處理錯誤
error應放在傳回值類型列表的最後。
之前看到項目裡有錯誤在中間或者第一個返回的,這是非常不符合規範的。
錯誤值統一定義,而不是隨心所欲的去寫。
參考之前章節我們組內拉通的錯誤碼和錯誤資訊。
不要忽略錯誤
可能有些時候有些程式員犯懶寫了這樣的代碼
foo, _ := getResult(1)
忽略了錯誤,也就不需要進行校正了,但這是很危險的,一旦某一個錯誤被忽略沒處理很可能造成下面的程式出bug甚至直接panic。
不要去直接校正錯誤字串
比如我們最早的os.Open函數,我們去校正錯誤能這樣寫嗎?
if err.Error == "No such file or directory"
這樣顯然不行,代碼很挫,而且字元判斷很不保險,怎麼辦呢?用上文講的自訂錯誤去做。
小結
本文詳述了Go中錯誤與異常的概念及其處理方法,希望對大家能有啟發。
參考資料
https://studygolang.com/artic...