『每周譯Go』Golang 中高效的錯(cuò)誤處理
Go 中的錯(cuò)誤處理與其他主流編程語言如 Java、JavaScript 或 Python 有些不同。Go 的內(nèi)置錯(cuò)誤不包含堆棧痕跡,也不支持傳統(tǒng)的try/catch方法來處理它們。相反,Go 中的錯(cuò)誤只是由函數(shù)返回的值,它們的處理方式與其他數(shù)據(jù)類型基本相同 - 這帶來了令人驚嘆的輕量級(jí)和簡單設(shè)計(jì)。
在這篇文章中,我將展示 Go 中處理錯(cuò)誤的基礎(chǔ)知識(shí),以及一些你可以在代碼中遵循的簡單策略,以確保你的程序健壯且易于調(diào)試。
錯(cuò)誤類型
Go 中的錯(cuò)誤類型是通過以下接口實(shí)現(xiàn)的:
type?error?interface?{
????Error()?string
}
所以基本上,一個(gè)error是任何實(shí)現(xiàn)Error()方法的東西,它以字符串形式返回錯(cuò)誤信息。就是這么簡單!
構(gòu)建錯(cuò)誤
錯(cuò)誤可以使用 Go 的內(nèi)置 errors 包或 fmt 包來即時(shí)構(gòu)建。例如,下面的函數(shù)使用 errors 包來返回一個(gè)帶有靜態(tài)錯(cuò)誤信息的新錯(cuò)誤:
package?main
import?"errors"
func?DoSomething()?error?{
????return?errors.New("something?didn't?work")
}
同樣地,fmt包可以用來給錯(cuò)誤添加動(dòng)態(tài)數(shù)據(jù),比如一個(gè)int、string或其他 error。例如:
package?main
import?"fmt"
func?Divide(a,?b?int)?(int,?error)?{
????if?b?==?0?{
????????return?0,?fmt.Errorf("can't?divide?'%d'?by?zero",?a)
????}
????return?a?/?b,?nil
}
注意fmt.Errorf在用來包裝另一個(gè)%w格式動(dòng)詞的錯(cuò)誤時(shí)將被證明是非常有用的 - 我將在文章中進(jìn)一步詳細(xì)說明。
在上述例子中,還有一些重要的事情需要注意。
error可以以nil的形式返回,事實(shí)上,它是 Go 中error的默認(rèn)值,或者說 “零值”。這一點(diǎn)很重要,因?yàn)闄z查if err != nil是確定是否遇到錯(cuò)誤的習(xí)慣用法(取代你在其他編程語言中可能熟悉的try/catch語句)。error通常作為一個(gè)函數(shù)的最后一個(gè)參數(shù)返回。因此在我們上面的例子中,我們依次返回一個(gè)int和一個(gè)error。當(dāng)我們確實(shí)返回一個(gè) error時(shí),函數(shù)返回的其他參數(shù)通常會(huì)以其默認(rèn)的零值返回。一個(gè)函數(shù)的用戶可能期望,如果返回一個(gè)非零值的error,那么返回的其他參數(shù)就沒有意義了。最后,錯(cuò)誤信息通常以小寫字母書寫,不以標(biāo)點(diǎn)符號(hào)結(jié)束。但也有例外,例如,當(dāng)包括一個(gè)專有名詞、一個(gè)以大寫字母開頭的函數(shù)名稱等。
定義預(yù)期的錯(cuò)誤
Go 中另一個(gè)重要的技術(shù)是定義預(yù)期錯(cuò)誤,這樣就可以在代碼的其他部分明確地檢查這些錯(cuò)誤。當(dāng)你需要在遇到某種錯(cuò)誤時(shí)執(zhí)行不同的代碼分支時(shí),這就非常有用。
定義哨兵錯(cuò)誤
在前面的Divide函數(shù)的基礎(chǔ)上,我們可以通過預(yù)先定義一個(gè) “哨兵” 錯(cuò)誤改進(jìn)錯(cuò)誤提示。調(diào)用函數(shù)可以使用errors.Is明確地檢查這個(gè)error:
package?main
import?(
????"errors"
????"fmt"
)
var?ErrDivideByZero?=?errors.New("divide?by?zero")
func?Divide(a,?b?int)?(int,?error)?{
????if?b?==?0?{
????????return?0,?ErrDivideByZero
????}
????return?a?/?b,?nil
}
func?main()?{
????a,?b?:=?10,?0
????result,?err?:=?Divide(a,?b)
????if?err?!=?nil?{
????????switch?{
????????case?errors.Is(err,?ErrDivideByZero):
????????????fmt.Println("divide?by?zero?error")
????????default:
????????????fmt.Printf("unexpected?division?error:?%s\n",?err)
????????}
????????return
????}
????fmt.Printf("%d?/?%d?=?%d\n",?a,?b,?result)
}
定義自定義錯(cuò)誤類型
大多數(shù)錯(cuò)誤處理的用例都可以用上面的策略來覆蓋,然而,有時(shí)你可能需要一些額外的功能。也許你想讓一個(gè)error攜帶額外的數(shù)據(jù)字段,或者當(dāng)錯(cuò)誤信息被打印出來時(shí),能用動(dòng)態(tài)值來填充自己。
你可以在 Go 中通過實(shí)現(xiàn)自定義錯(cuò)誤類型來做到這一點(diǎn)。
下面是對(duì)以前的例子的輕微改造。注意新的類型DivisionError,它實(shí)現(xiàn)了Error接口。我們可以利用errors.As來檢查并從標(biāo)準(zhǔn)error轉(zhuǎn)換到我們更具體的DivisionError。
package?main
import?(
????"errors"
????"fmt"
)
type?DivisionError?struct?{
????IntA?int
????IntB?int
????Msg??string
}
func?(e?*DivisionError)?Error()?string?{?
????return?e.Msg
}
func?Divide(a,?b?int)?(int,?error)?{
????if?b?==?0?{
????????return?0,?&DivisionError{
????????????Msg:?fmt.Sprintf("cannot?divide?'%d'?by?zero",?a),
????????????IntA:?a,?IntB:?b,
????????}
????}
????return?a?/?b,?nil
}
func?main()?{
????a,?b?:=?10,?0
????result,?err?:=?Divide(a,?b)
????if?err?!=?nil?{
????????var?divErr?*DivisionError
????????switch?{
????????case?errors.As(err,?&divErr):
????????????fmt.Printf("%d?/?%d?is?not?mathematically?valid:?%s\n",
??????????????divErr.IntA,?divErr.IntB,?divErr.Error())
????????default:
????????????fmt.Printf("unexpected?division?error:?%s\n",?err)
????????}
????????return
????}
????fmt.Printf("%d?/?%d?=?%d\n",?a,?b,?result)
}
注意:必要時(shí),你也可以自定義errors.Is和errors.As的行為。請(qǐng)看這個(gè) Go.dev 博客的一個(gè)例子。
另一個(gè)說明:errors.Is是在 Go 1.13 中添加的,比檢查err == ...更可取。下面有更多關(guān)于這個(gè)問題的內(nèi)容。
包裝錯(cuò)誤
在迄今為止的這些例子中,error都是通過一個(gè)函數(shù)調(diào)用來創(chuàng)建、返回和處理的。換句話說,參與 “冒泡” error的函數(shù)堆棧只有一層深度。
通常在現(xiàn)實(shí)世界的程序中,可能會(huì)有更多的函數(shù)參與其中 – 從產(chǎn)生error的函數(shù),到最終處理error的地方,以及中間的任何數(shù)量的附加函數(shù)。
在 Go 1.13 中,引入了幾個(gè)新的errors API,包括errors.Wrap和errors.Unwrap,它們?cè)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">error “冒泡” 時(shí)對(duì)其應(yīng)用額外的上下文,以及檢查特定的error類型,不管一個(gè)error被包裹了多少次。
一段歷史: 在 2019 年 Go 1.13 發(fā)布之前,標(biāo)準(zhǔn)庫并不包含很多處理錯(cuò)誤的 API–基本上只有errors.New和fmt.Errorf。因此,你可能會(huì)在別的包里遇到?jīng)]有實(shí)現(xiàn)一些較新錯(cuò)誤 API 的遺留 Go 程序。許多遺留程序也使用第三方錯(cuò)誤庫,如 pkg/errors。最后,正式提案在 2018 年被記錄下來,其中提出了許多我們今天在 Go 1.13+ 中看到的功能。
舊的方式(Go 1.13 之前)
通過看一些舊的 API 有局限性的例子,對(duì)比一下就很容易看出 Go 1.13+ 中新的錯(cuò)誤 API 多么有用。
讓我們考慮一個(gè)管理用戶數(shù)據(jù)庫的簡單程序。在這個(gè)程序中,我們將有幾個(gè)函數(shù)參與到數(shù)據(jù)庫錯(cuò)誤的生命周期中。
為了簡單起見,讓我們用一個(gè)完全 “假” 的數(shù)據(jù)庫來代替真正的數(shù)據(jù)庫,我們從"example.com/fake/users/db"導(dǎo)入。
我們還假設(shè)這個(gè)假數(shù)據(jù)庫已經(jīng)包含了一些查找和更新用戶記錄的功能。而且,用戶記錄被定義為一個(gè)結(jié)構(gòu)體,看起來像:
package?db
type?User?struct?{
??ID???????string
??Username?string
??Age??????int
}
func?FindUser(username?string)?(*User,?error)?{?/*?...?*/?}
func?SetUserAge(user?*User,?age?int)?error?{?/*?...?*/?}
下面是我們的示例程序:
package?main
import?(
????"errors"
????"fmt"
????"example.com/fake/users/db"
)
func?FindUser(username?string)?(*db.User,?error)?{
????return?db.Find(username)
}
func?SetUserAge(u?*db.User,?age?int)?error?{
????return?db.SetAge(u,?age)
}
func?FindAndSetUserAge(username?string,?age?int)?error?{
??var?user?*User
??var?err?error
??user,?err?=?FindUser(username)
??if?err?!=?nil?{
??????return?err
??}
??if?err?=?SetUserAge(user,?age);?err?!=?nil?{
??????return?err
??}
??return?nil
}
func?main()?{
????if?err?:=?FindAndSetUserAge("[email protected]",?21);?err?!=?nil?{
????????fmt.Println("failed?finding?or?updating?user:?%s",?err)
????????return
????}
????fmt.Println("successfully?updated?user's?age")
}
現(xiàn)在,如果我們的一個(gè)數(shù)據(jù)庫操作因?yàn)橐恍?malformed request (錯(cuò)誤的請(qǐng)求) 而失敗,會(huì)發(fā)生什么?
在main函數(shù)中的錯(cuò)誤檢查應(yīng)該捕獲它,并打印出類似這樣的東西:
failed?finding?or?updating?user:?malformed?request
但這兩個(gè)數(shù)據(jù)庫操作中的哪一個(gè)產(chǎn)生了錯(cuò)誤?不幸的是,我們的錯(cuò)誤日志中沒有足夠的信息來知道它是來自FindUser還是SetUserAge。
Go 1.13 增加了一個(gè)簡單的方法來添加這些信息。
錯(cuò)誤更好地被包裝起來
下面的代碼段經(jīng)過重構(gòu),使用fmt.Errorf和%w動(dòng)詞來 “包裝” 錯(cuò)誤,因?yàn)樗鼈兺ㄟ^其他函數(shù)調(diào)用 “冒泡” 了。這增加了所需的上下文,從而有可能推斷出在前面的例子中哪些數(shù)據(jù)庫操作失敗了。
package?main
import?(
????"errors"
????"fmt"
????"example.com/fake/users/db"
)
func?FindUser(username?string)?(*db.User,?error)?{
????u,?err?:=?db.Find(username)
????if?err?!=?nil?{
????????return?nil,?fmt.Errorf("FindUser:?failed?executing?db?query:?%w",?err)
????}
????return?u,?nil
}
func?SetUserAge(u?*db.User,?age?int)?error?{
????if?err?:=?db.SetAge(u,?age);?err?!=?nil?{
??????return?fmt.Errorf("SetUserAge:?failed?executing?db?update:?%w",?err)
????}
}
func?FindAndSetUserAge(username?string,?age?int)?error?{
??var?user?*User
??var?err?error
??user,?err?=?FindUser(username)
??if?err?!=?nil?{
??????return?fmt.Errorf("FindAndSetUserAge:?%w",?err)
??}
??if?err?=?SetUserAge(user,?age);?err?!=?nil?{
??????return?fmt.Errorf("FindAndSetUserAge:?%w",?err)
??}
??return?nil
}
func?main()?{
????if?err?:=?FindAndSetUserAge("[email protected]",?21);?err?!=?nil?{
????????fmt.Println("failed?finding?or?updating?user:?%s",?err)
????????return
????}
????fmt.Println("successfully?updated?user's?age")
}
如果我們重新運(yùn)行程序并遇到同樣的錯(cuò)誤,日志應(yīng)該打印如下:
failed?finding?or?updating?user:?FindAndSetUserAge:?SetUserAge:?failed?executing?db?update:?malformed?request
現(xiàn)在我們的錯(cuò)誤消息包含了足夠的信息,我們可以看到問題起源于db.SetUserAge函數(shù)。咻!這無疑為我們節(jié)省了一些調(diào)試的時(shí)間!
如果使用得當(dāng),錯(cuò)誤包裝可以提供關(guān)于錯(cuò)誤脈絡(luò)的額外內(nèi)容,其方式類似于傳統(tǒng)的堆棧跟蹤。
包裝也保留了原始錯(cuò)誤,這意味著errors.Is和errors.As同樣有效,不管一個(gè)錯(cuò)誤被封裝了多少次。我們還可以調(diào)用errors.Unwrap來返回錯(cuò)誤鏈中的前一個(gè)錯(cuò)誤。
好奇地想知道錯(cuò)誤包裝是如何工作的?看看 fmt.Errorf, %w 動(dòng)詞 和 錯(cuò)誤 API的內(nèi)部細(xì)節(jié)吧。
何時(shí)包裝
一般來說,在每次 “冒泡” 時(shí),即每次從一個(gè)函數(shù)中收到錯(cuò)誤并想繼續(xù)將其返回到函數(shù)鏈中時(shí),至少用函數(shù)的名稱來包裹錯(cuò)誤是個(gè)好主意。

Wrapping an error adds the gift of context 然而,也有一些例外情況,在這種情況下,包裝錯(cuò)誤可能是不合適的。
由于包裝錯(cuò)誤總是保留原始的錯(cuò)誤信息,有時(shí)暴露這些潛在的問題可能是一個(gè)安全、隱私,甚至是用戶體驗(yàn)的問題。在這種情況下,可能值得處理錯(cuò)誤并返回一個(gè)新的錯(cuò)誤,而不是包裝它。如果你正在編寫一個(gè)開源庫或 REST API,不希望將底層錯(cuò)誤信息返回給第三方用戶,就可能是這種情況。
結(jié)論
這是個(gè)總結(jié)!綜上所述,這就是這里所涉及的主要內(nèi)容:- Go 中的錯(cuò)誤只是一些輕量級(jí)的值,實(shí)現(xiàn)了Error的interface - 預(yù)定義的錯(cuò)誤將改善信號(hào),允許我們檢查是哪個(gè)錯(cuò)誤發(fā)生了 - 包裝錯(cuò)誤以增加足夠的上下文來跟蹤函數(shù)調(diào)用(類似于堆棧跟蹤)
我希望你覺得這個(gè)有效的錯(cuò)誤處理指南很有用。如果你想了解更多,我附上了一些相關(guān)的文章,這些文章是我在 Go 中進(jìn)行強(qiáng)大錯(cuò)誤處理的過程中發(fā)現(xiàn)的。
參考文獻(xiàn)
Error handling and Go Go 1.13 Errors Go Error Doc Go By Example: Errors Go By Example: Panic
原文地址:https://earthly.dev/blog/golang-errors/
原文作者:Brandon Schurman
本文永久鏈接:https://github.com/gocn/translator/blob/master/2022/w06_effective_error_handling_in_golang.md
譯者:Cluas
想要了解關(guān)于 Go 的更多資訊,還可以通過掃描的方式,進(jìn)群一起探討哦~
