原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch1-basic/ch1-07-error-and-panic.html
錯誤處理是每個編程語言都要考慮的一個重要話題。在 Go 語言的錯誤處理中,錯誤是軟件包 API 和應(yīng)用程序用戶界面的一個重要組成部分。
在程序中總有一部分函數(shù)總是要求必須能夠成功的運行。比如 strconv.Itoa
將整數(shù)轉(zhuǎn)換為字符串,從數(shù)組或切片中讀寫元素,從 map
讀取已經(jīng)存在的元素等。這類操作在運行時幾乎不會失敗,除非程序中有 BUG,或遇到災(zāi)難性的、不可預(yù)料的情況,比如運行時的內(nèi)存溢出。如果真的遇到真正異常情況,我們只要簡單終止程序就可以了。
排除異常的情況,如果程序運行失敗僅被認為是幾個預(yù)期的結(jié)果之一。對于那些將運行失敗看作是預(yù)期結(jié)果的函數(shù),它們會返回一個額外的返回值,通常是最后一個來傳遞錯誤信息。如果導(dǎo)致失敗的原因只有一個,額外的返回值可以是一個布爾值,通常被命名為 ok。比如,當(dāng)從一個 map
查詢一個結(jié)果時,可以通過額外的布爾值判斷是否成功:
if v, ok := m["key"]; ok {
return v
}
但是導(dǎo)致失敗的原因通常不止一種,很多時候用戶希望了解更多的錯誤信息。如果只是用簡單的布爾類型的狀態(tài)值將不能滿足這個要求。在 C 語言中,默認采用一個整數(shù)類型的 errno
來表達錯誤,這樣就可以根據(jù)需要定義多種錯誤類型。在 Go 語言中,syscall.Errno
就是對應(yīng) C 語言中 errno
類型的錯誤。在 syscall
包中的接口,如果有返回錯誤的話,底層也是 syscall.Errno
錯誤類型。
比如我們通過 syscall
包的接口來修改文件的模式時,如果遇到錯誤我們可以通過將 err
強制斷言為 syscall.Errno
錯誤類型來處理:
err := syscall.Chmod(":invalid path:", 0666)
if err != nil {
log.Fatal(err.(syscall.Errno))
}
我們還可以進一步地通過類型查詢或類型斷言來獲取底層真實的錯誤類型,這樣就可以獲取更詳細的錯誤信息。不過一般情況下我們并不關(guān)心錯誤在底層的表達方式,我們只需要知道它是一個錯誤就可以了。當(dāng)返回的錯誤值不是 nil
時,我們可以通過調(diào)用 error
接口類型的 Error
方法來獲得字符串類型的錯誤信息。
在 Go 語言中,錯誤被認為是一種可以預(yù)期的結(jié)果;而異常則是一種非預(yù)期的結(jié)果,發(fā)生異??赡鼙硎境绦蛑写嬖?BUG 或發(fā)生了其它不可控的問題。Go 語言推薦使用 recover
函數(shù)將內(nèi)部異常轉(zhuǎn)為錯誤處理,這使得用戶可以真正的關(guān)心業(yè)務(wù)相關(guān)的錯誤處理。
如果某個接口簡單地將所有普通的錯誤當(dāng)做異常拋出,將會使錯誤信息雜亂且沒有價值。就像在 main
函數(shù)中直接捕獲全部一樣,是沒有意義的:
func main() {
defer func() {
if r := recover(); r != nil {
log.Fatal(r)
}
}()
...
}
捕獲異常不是最終的目的。如果異常不可預(yù)測,直接輸出異常信息是最好的處理方式。
讓我們演示一個文件復(fù)制的例子:函數(shù)需要打開兩個文件,然后將其中一個文件的內(nèi)容復(fù)制到另一個文件:
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}
dst, err := os.Create(dstName)
if err != nil {
return
}
written, err = io.Copy(dst, src)
dst.Close()
src.Close()
return
}
上面的代碼雖然能夠工作,但是隱藏一個 bug。如果第一個 os.Open
調(diào)用成功,但是第二個 os.Create
調(diào)用失敗,那么會在沒有釋放 src
文件資源的情況下返回。雖然我們可以通過在第二個返回語句前添加 src.Close()
調(diào)用來修復(fù)這個 BUG;但是當(dāng)代碼變得復(fù)雜時,類似的問題將很難被發(fā)現(xiàn)和修復(fù)。我們可以通過 defer
語句來確保每個被正常打開的文件都能被正常關(guān)閉:
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}
defer src.Close()
dst, err := os.Create(dstName)
if err != nil {
return
}
defer dst.Close()
return io.Copy(dst, src)
}
defer
語句可以讓我們在打開文件時馬上思考如何關(guān)閉文件。不管函數(shù)如何返回,文件關(guān)閉語句始終會被執(zhí)行。同時 defer
語句可以保證,即使 io.Copy
發(fā)生了異常,文件依然可以安全地關(guān)閉。
前文我們說到,Go 語言中的導(dǎo)出函數(shù)一般不拋出異常,一個未受控的異??梢钥醋魇浅绦虻?BUG。但是對于那些提供類似 Web 服務(wù)的框架而言;它們經(jīng)常需要接入第三方的中間件。因為第三方的中間件是否存在 BUG 是否會拋出異常,Web 框架本身是不能確定的。為了提高系統(tǒng)的穩(wěn)定性,Web 框架一般會通過 recover
來防御性地捕獲所有處理流程中可能產(chǎn)生的異常,然后將異常轉(zhuǎn)為普通的錯誤返回。
讓我們以 JSON 解析器為例,說明 recover 的使用場景??紤]到 JSON 解析器的復(fù)雜性,即使某個語言解析器目前工作正常,也無法肯定它沒有漏洞。因此,當(dāng)某個異常出現(xiàn)時,我們不會選擇讓解析器崩潰,而是會將 panic 異常當(dāng)作普通的解析錯誤,并附加額外信息提醒用戶報告此錯誤。
func ParseJSON(input string) (s *Syntax, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("JSON: internal error: %v", p)
}
}()
// ...parser...
}
標準庫中的 json
包,在內(nèi)部遞歸解析 JSON 數(shù)據(jù)的時候如果遇到錯誤,會通過拋出異常的方式來快速跳出深度嵌套的函數(shù)調(diào)用,然后由最外一級的接口通過 recover
捕獲 panic
,然后返回相應(yīng)的錯誤信息。
Go 語言庫的實現(xiàn)習(xí)慣: 即使在包內(nèi)部使用了 panic
,但是在導(dǎo)出函數(shù)時會被轉(zhuǎn)化為明確的錯誤值。
有時候為了方便上層用戶理解;底層實現(xiàn)者會將底層的錯誤重新包裝為新的錯誤類型返回給用戶:
if _, err := html.Parse(resp.Body); err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url,err)
}
上層用戶在遇到錯誤時,可以很容易從業(yè)務(wù)層面理解錯誤發(fā)生的原因。但是魚和熊掌總是很難兼得,在上層用戶獲得新的錯誤的同時,我們也丟失了底層最原始的錯誤類型(只剩下錯誤描述信息了)。
為了記錄這種錯誤類型在包裝的變遷過程中的信息,我們一般會定義一個輔助的 WrapError
函數(shù),用于包裝原始的錯誤,同時保留完整的原始錯誤類型。為了問題定位的方便,同時也為了能記錄錯誤發(fā)生時的函數(shù)調(diào)用狀態(tài),我們很多時候希望在出現(xiàn)致命錯誤的時候保存完整的函數(shù)調(diào)用信息。同時,為了支持 RPC 等跨網(wǎng)絡(luò)的傳輸,我們可能要需要將錯誤序列化為類似 JSON 格式的數(shù)據(jù),然后再從這些數(shù)據(jù)中將錯誤解碼恢復(fù)出來。
為此,我們可以定義自己的 github.com/chai2010/errors
包,里面是以下的錯誤類型:
type Error interface {
Caller() []CallerInfo
Wraped() []error
Code() int
error
private()
}
type CallerInfo struct {
FuncName string
FileName string
FileLine int
}
其中 Error
為接口類型,是 error
接口類型的擴展,用于給錯誤增加調(diào)用棧信息,同時支持錯誤的多級嵌套包裝,支持錯誤碼格式。為了使用方便,我們可以定義以下的輔助函數(shù):
func New(msg string) error
func NewWithCode(code int, msg string) error
func Wrap(err error, msg string) error
func WrapWithCode(code int, err error, msg string) error
func FromJson(json string) (Error, error)
func ToJson(err error) string
New
用于構(gòu)建新的錯誤類型,和標準庫中 errors.New
功能類似,但是增加了出錯時的函數(shù)調(diào)用棧信息。FromJson
用于從 JSON 字符串編碼的錯誤中恢復(fù)錯誤對象。NewWithCode
則是構(gòu)造一個帶錯誤碼的錯誤,同時也包含出錯時的函數(shù)調(diào)用棧信息。Wrap
和 WrapWithCode
則是錯誤二次包裝函數(shù),用于將底層的錯誤包裝為新的錯誤,但是保留的原始的底層錯誤信息。這里返回的錯誤對象都可以直接調(diào)用 json.Marshal
將錯誤編碼為
JSON 字符串。
我們可以這樣使用包裝函數(shù):
import (
"github.com/chai2010/errors"
)
func loadConfig() error {
_, err := ioutil.ReadFile("/path/to/file")
if err != nil {
return errors.Wrap(err, "read failed")
}
// ...
}
func setup() error {
err := loadConfig()
if err != nil {
return errors.Wrap(err, "invalid config")
}
// ...
}
func main() {
if err := setup(); err != nil {
log.Fatal(err)
}
// ...
}
上面的例子中,錯誤被進行了 2 層包裝。我們可以這樣遍歷原始錯誤經(jīng)歷了哪些包裝流程:
for i, e := range err.(errors.Error).Wraped() {
fmt.Printf("wrapped(%d): %v\n", i, e)
}
同時也可以獲取每個包裝錯誤的函數(shù)調(diào)用堆棧信息:
for i, x := range err.(errors.Error).Caller() {
fmt.Printf("caller:%d: %s\n", i, x.FuncName)
}
如果需要將錯誤通過網(wǎng)絡(luò)傳輸,可以用 errors.ToJson(err)
編碼為 JSON 字符串:
// 以 JSON 字符串方式發(fā)送錯誤
func sendError(ch chan<- string, err error) {
ch <- errors.ToJson(err)
}
// 接收 JSON 字符串格式的錯誤
func recvError(ch <-chan string) error {
p, err := errors.FromJson(<-ch)
if err != nil {
log.Fatal(err)
}
return p
}
對于基于 http 協(xié)議的網(wǎng)絡(luò)服務(wù),我們還可以給錯誤綁定一個對應(yīng)的 http 狀態(tài)碼:
err := errors.NewWithCode(404, "http error code")
fmt.Println(err)
fmt.Println(err.(errors.Error).Code())
在 Go 語言中,錯誤處理也有一套獨特的編碼風(fēng)格。檢查某個子函數(shù)是否失敗后,我們通常將處理失敗的邏輯代碼放在處理成功的代碼之前。如果某個錯誤會導(dǎo)致函數(shù)返回,那么成功時的邏輯代碼不應(yīng)放在 else
語句塊中,而應(yīng)直接放在函數(shù)體中。
f, err := os.Open("filename.ext")
if err != nil {
// 失敗的情形, 馬上返回錯誤
}
// 正常的處理流程
Go 語言中大部分函數(shù)的代碼結(jié)構(gòu)幾乎相同,首先是一系列的初始檢查,用于防止錯誤發(fā)生,之后是函數(shù)的實際邏輯。
Go 語言中的錯誤是一種接口類型。接口信息中包含了原始類型和原始的值。只有當(dāng)接口的類型和原始的值都為空的時候,接口的值才對應(yīng) nil
。其實當(dāng)接口中類型為空的時候,原始值必然也是空的;反之,當(dāng)接口對應(yīng)的原始值為空的時候,接口對應(yīng)的原始類型并不一定為空的。
在下面的例子中,試圖返回自定義的錯誤類型,當(dāng)沒有錯誤的時候返回 nil
:
func returnsError() error {
var p *MyError = nil
if bad() {
p = ErrBad
}
return p // Will always return a non-nil error.
}
但是,最終返回的結(jié)果其實并非是 nil
:是一個正常的錯誤,錯誤的值是一個 MyError
類型的空指針。下面是改進的 returnsError
:
func returnsError() error {
if bad() {
return (*MyError)(err)
}
return nil
}
因此,在處理錯誤返回值的時候,沒有錯誤的返回值最好直接寫為 nil
。
Go 語言作為一個強類型語言,不同類型之間必須要顯式的轉(zhuǎn)換(而且必須有相同的基礎(chǔ)類型)。但是,Go 語言中 interface
是一個例外:非接口類型到接口類型,或者是接口類型之間的轉(zhuǎn)換都是隱式的。這是為了支持鴨子類型,當(dāng)然會犧牲一定的安全性。
panic
支持拋出任意類型的異常(而不僅僅是 error
類型的錯誤),recover
函數(shù)調(diào)用的返回值和 panic
函數(shù)的輸入?yún)?shù)類型一致,它們的函數(shù)簽名如下:
func panic(interface{})
func recover() interface{}
Go 語言函數(shù)調(diào)用的正常流程是函數(shù)執(zhí)行返回語句返回結(jié)果,在這個流程中是沒有異常的,因此在這個流程中執(zhí)行 recover
異常捕獲函數(shù)始終是返回 nil
。另一種是異常流程: 當(dāng)函數(shù)調(diào)用 panic
拋出異常,函數(shù)將停止執(zhí)行后續(xù)的普通語句,但是之前注冊的 defer
函數(shù)調(diào)用仍然保證會被正常執(zhí)行,然后再返回到調(diào)用者。對于當(dāng)前函數(shù)的調(diào)用者,因為處理異常狀態(tài)還沒有被捕獲,和直接調(diào)用 panic
函數(shù)的行為類似。在異常發(fā)生時,如果在 defer
中執(zhí)行 recover
調(diào)用,它可以捕獲觸發(fā) panic
時的參數(shù),并且恢復(fù)到正常的執(zhí)行流程。
在非 defer
語句中執(zhí)行 recover
調(diào)用是初學(xué)者常犯的錯誤:
func main() {
if r := recover(); r != nil {
log.Fatal(r)
}
panic(123)
if r := recover(); r != nil {
log.Fatal(r)
}
}
上面程序中兩個 recover
調(diào)用都不能捕獲任何異常。在第一個 recover
調(diào)用執(zhí)行時,函數(shù)必然是在正常的非異常執(zhí)行流程中,這時候 recover
調(diào)用將返回 nil
。發(fā)生異常時,第二個 recover
調(diào)用將沒有機會被執(zhí)行到,因為 panic
調(diào)用會導(dǎo)致函數(shù)馬上執(zhí)行已經(jīng)注冊 defer
的函數(shù)后返回。
其實 recover
函數(shù)調(diào)用有著更嚴格的要求:我們必須在 defer
函數(shù)中直接調(diào)用 recover
。如果 defer
中調(diào)用的是 recover
函數(shù)的包裝函數(shù)的話,異常的捕獲工作將失??!比如,有時候我們可能希望包裝自己的 MyRecover
函數(shù),在內(nèi)部增加必要的日志信息然后再調(diào)用 recover
,這是錯誤的做法:
func main() {
defer func() {
// 無法捕獲異常
if r := MyRecover(); r != nil {
fmt.Println(r)
}
}()
panic(1)
}
func MyRecover() interface{} {
log.Println("trace...")
return recover()
}
同樣,如果是在嵌套的 defer
函數(shù)中調(diào)用 recover
也將導(dǎo)致無法捕獲異常:
func main() {
defer func() {
defer func() {
// 無法捕獲異常
if r := recover(); r != nil {
fmt.Println(r)
}
}()
}()
panic(1)
}
2 層嵌套的 defer
函數(shù)中直接調(diào)用 recover
和 1 層 defer
函數(shù)中調(diào)用包裝的 MyRecover
函數(shù)一樣,都是經(jīng)過了 2 個函數(shù)幀才到達真正的 recover
函數(shù),這個時候 Goroutine 的對應(yīng)上一級棧幀中已經(jīng)沒有異常信息。
如果我們直接在 defer
語句中調(diào)用 MyRecover
函數(shù)又可以正常工作了:
func MyRecover() interface{} {
return recover()
}
func main() {
// 可以正常捕獲異常
defer MyRecover()
panic(1)
}
但是,如果 defer
語句直接調(diào)用 recover
函數(shù),依然不能正常捕獲異常:
func main() {
// 無法捕獲異常
defer recover()
panic(1)
}
必須要和有異常的棧幀只隔一個棧幀,recover
函數(shù)才能正常捕獲異常。換言之,recover
函數(shù)捕獲的是祖父一級調(diào)用函數(shù)棧幀的異常(剛好可以跨越一層 defer
函數(shù))!
當(dāng)然,為了避免 recover
調(diào)用者不能識別捕獲到的異常, 應(yīng)該避免用 nil
為參數(shù)拋出異常:
func main() {
defer func() {
if r := recover(); r != nil { ... }
// 雖然總是返回 nil, 但是可以恢復(fù)異常狀態(tài)
}()
// 警告: 用 nil 為參數(shù)拋出異常
panic(nil)
}
當(dāng)希望將捕獲到的異常轉(zhuǎn)為錯誤時,如果希望忠實返回原始的信息,需要針對不同的類型分別處理:
func foo() (err error) {
defer func() {
if r := recover(); r != nil {
switch x := r.(type) {
case string:
err = errors.New(x)
case error:
err = x
default:
err = fmt.Errorf("Unknown panic: %v", r)
}
}
}()
panic("TODO")
}
基于這個代碼模板,我們甚至可以模擬出不同類型的異常。通過為定義不同類型的保護接口,我們就可以區(qū)分異常的類型了:
func main {
defer func() {
if r := recover(); r != nil {
switch x := r.(type) {
case runtime.Error:
// 這是運行時錯誤類型異常
case error:
// 普通錯誤類型異常
default:
// 其他類型異常
}
}
}()
// ...
}
不過這樣做和 Go 語言簡單直接的編程哲學(xué)背道而馳了。
![]() | ![]() |
更多建議: