關(guān)于Golang錯(cuò)誤處理的一些思考
寫在前面:如果你還沒在 error 上栽跟頭,那么當(dāng)你栽了跟頭時(shí)才會(huì)哭著想起來,當(dāng)年為什么沒好好思考和反省錯(cuò)誤處理這么一個(gè)宏大的話題
關(guān)于 Golang 錯(cuò)誤處理的實(shí)踐
Golang 有很多優(yōu)點(diǎn),這也是它如此流行的主要原因。但是 Go 1 對(duì)錯(cuò)誤處理的支持過于簡(jiǎn)單了,以至于日常開發(fā)中會(huì)有諸多不便利,遭到很多開發(fā)者的吐槽。這些不足催生了一些開源解決方案。與此同時(shí), Go 官方也在從語言和標(biāo)準(zhǔn)庫(kù)層面作出改進(jìn)。這篇文章將給出幾種常見創(chuàng)建錯(cuò)誤的方式并分析一些常見問題,對(duì)比各種解決方案,并展示了迄今為止(go 1.13)的最佳實(shí)踐。
幾種創(chuàng)建錯(cuò)誤的方式
首先介紹幾種常見的創(chuàng)建錯(cuò)誤的方法
基于字符串的錯(cuò)誤
- err1 := errors.New("math: square root of negative number")
- err2 := fmt.Errorf("math: square root of negative number %g", x)
帶有數(shù)據(jù)的自定義錯(cuò)誤
- package serr
- import (
- "fmt"
- "github.com/satori/go.uuid"
- "log"
- "runtime/debug"
- "time"
- )
- // 自定義基礎(chǔ)錯(cuò)誤類型
- type BaseError struct {
- InnerError error
- Message string
- StackTrace string
- Misc map[string]interface{}
- }
- func WrapError(err error, message string, messageArgs ...interface{}) BaseError {
- return BaseError{
- InnerError: err,
- Message: fmt.Sprintf(message, messageArgs),
- StackTrace: string(debug.Stack()),
- Misc: make(map[string]interface{}),
- }
- }
- func (err *BaseError) Error() string {
- // 實(shí)現(xiàn) Error 接口
- return err.Message
- }
- // 具體使用
- // "intermediate" module
- type IntermediateErr struct {
- error
- }
- func runJob(id string) error {
- const jobBinPath = "/bad/job/binary"
- isExecutable, err := isGloballyExec(jobBinPath)
- iferr != nil{
- return IntermediateErr{wrapError( err,
- "cannot run job %q: requisite binaries not available",
- id, )}
- } else if isExecutable == false {
- return wrapError(
- nil,
- "cannot run job %q: requisite binaries are not executable", id,
- )
- }
- return exec.Command(jobBinPath, "--id="+id).Run()
- }
拋出問題
開發(fā)中經(jīng)常需要檢查返回的錯(cuò)誤值并作相應(yīng)處理。下面給出一個(gè)最簡(jiǎn)單的示例。
- import (
- "database/sql"
- "fmt"
- )
- func GetSql() error {
- return sql.ErrNoRows
- }
- func Call() error {
- return GetSql()
- }
- func main() {
- err := Call()
- if err != nil {
- fmt.Printf("got err, %+v\n", err)
- }
- }
- //Outputs:
- // got err, sql: no rows in result set
有時(shí)需要根據(jù)返回的 error 類型作不同處理,例如:
- import (
- "database/sql"
- "fmt"
- )
- func GetSql() error {
- return sql.ErrNoRows
- }
- func Call() error {
- return GetSql()
- }
- func main() {
- err := Call()
- if err == sql.ErrNoRows {
- fmt.Printf("data not found, %+v\n", err)
- return
- }
- if err != nil {
- // Unknown error
- }
- }
- //Outputs:
- // data not found, sql: no rows in result set
實(shí)踐中經(jīng)常需要為錯(cuò)誤增加上下文信息后再返回,以方便調(diào)用者了解錯(cuò)誤場(chǎng)景。例如 Getcall 方法時(shí)常寫成:
- func Getcall() error {
- return fmt.Errorf("GetSql err, %v", sql.ErrNoRows)
- }
不過這個(gè)時(shí)候 err==sql.ErrNoRows 就不成立了。除此之外,上述寫法都在返回錯(cuò)誤時(shí)都丟掉了調(diào)用棧這個(gè)重要的信息。我們需要更靈活、更通用的方式來應(yīng)對(duì)此類問題。
解決方案
針對(duì)存在的不足,目前有幾種解決方案。這些方式可以對(duì)錯(cuò)誤進(jìn)行上下文包裝,并攜帶原始錯(cuò)誤信息, 還能盡量保留完整的調(diào)用棧
方案 1:github.com/pkg/errors
如果只有錯(cuò)誤的文本,我們很難定位到具體的出錯(cuò)地點(diǎn)。雖然通過在代碼中搜索錯(cuò)誤文本也是有可能找到出錯(cuò)地點(diǎn)的,但是信息有限。所以,在實(shí)踐中,我們往往會(huì)將出錯(cuò)時(shí)的調(diào)用棧信息也附加上去。調(diào)用棧對(duì)消費(fèi)方是沒有意義的,從隔離和自治的角度來看,消費(fèi)方唯一需要關(guān)心的就是錯(cuò)誤文本和錯(cuò)誤類型。調(diào)用棧對(duì)實(shí)現(xiàn)者自身才是是有價(jià)值的。所以,如果一個(gè)方法需要返回錯(cuò)誤,我們一般會(huì)使用 errors.WithStack(err) 或者 errors.Wrap(err,"custom message") 的方式,把此刻的調(diào)用棧加到error里去,并且在某個(gè)統(tǒng)一地方記錄日志,方便開發(fā)者快速定位問題。
- Wrap 方法用來包裝底層錯(cuò)誤,增加上下文文本信息并附加調(diào)用棧。一般用于包裝對(duì)第三方代碼(標(biāo)準(zhǔn)庫(kù)或第三方庫(kù))的調(diào)用。
- WithMessage 方法僅增加上下文文本信息,不附加調(diào)用棧。如果確定錯(cuò)誤已被 Wrap 過或不關(guān)心調(diào)用棧,可以使用此方法。注意:不要反復(fù) Wrap ,會(huì)導(dǎo)致調(diào)用棧重復(fù)
- Cause 方法用來判斷底層錯(cuò)誤 。
現(xiàn)在我們用這三個(gè)方法來重寫上面的代碼:
- import (
- "database/sql"
- "fmt"
- "github.com/pkg/errors"
- )
- func GetSql() error {
- return errors.Wrap(sql.ErrNoRows, "GetSql failed")
- }
- func Call() error {
- return errors.WithMessage(GetSql(), "bar failed")
- }
- func main() {
- err := Call()
- if errors.Cause(err) == sql.ErrNoRows {
- fmt.Printf("data not found, %v\n", err)
- fmt.Printf("%+v\n", err)
- return
- }
- if err != nil {
- // unknown error
- }
- }
- /*Output:
- data not found, Call failed: GetSql failed: sql: no rows in result set
- sql: no rows in result set
- main.GetSql
- /usr/three/main.go:11
- main.Call
- /usr/three/main.go:15
- main.main
- /usr/three/main.go:19
- runtime.main
- ...
- */
從輸出內(nèi)容可以看到, 使用 %v 作為格式化參數(shù),那么錯(cuò)誤信息會(huì)保持一行, 其中依次包含調(diào)用棧的上下文文本。使用 %+v ,則會(huì)輸出完整的調(diào)用棧詳情。如果不需要增加額外上下文信息,僅附加調(diào)用棧后返回,可以使用 WithStack 方法:
- func GetSql() error {
- return errors.WithStack(sql.ErrNoRows)
- }
注意:無論是 Wrap , WithMessage 還是 WithStack ,當(dāng)傳入的 err 參數(shù)為 nil 時(shí), 都會(huì)返回nil, 這意味著我們?cè)谡{(diào)用此方法之前無需作 nil 判斷,保持了代碼簡(jiǎn)潔
方案 2:golang.org/x/xerrors
結(jié)合社區(qū)反饋,Go 團(tuán)隊(duì)開始考慮在 Go 2 中簡(jiǎn)化錯(cuò)誤處理的提案。Go 核心團(tuán)隊(duì)成員 Russ Cox 在xerrors中部分實(shí)現(xiàn)了提案中的內(nèi)容。它用與 github.com/pkg/errors 相似的思路解決同一問題, 引入了一個(gè)新的 fmt 格式化動(dòng)詞: %w,使用 Is 進(jìn)行判斷。
- import (
- "database/sql"
- "fmt"
- "golang.org/x/xerrors"
- )
- func Call() error {
- if err := GetSql(); err != nil {
- return xerrors.Errorf("bar failed: %w", GetSql())
- }
- return nil
- }
- func GetSql() error {
- return xerrors.Errorf("GetSql failed: %w", sql.ErrNoRows)
- }
- func main() {
- err := Call()
- if xerrors.Is(err, sql.ErrNoRows) {
- fmt.Printf("data not found, %v\n", err)
- fmt.Printf("%+v\n", err)
- return
- }
- if err != nil {
- // unknown error
- }
- }
- /* Outputs:
- data not found, Call failed: GetSql failed: sql: no rows in result set
- bar failed:
- main.Call
- /usr/four/main.go:12
- - GetSql failed:
- main.GetSql
- /usr/four/main.go:18
- - sql: no rows in result set
- */
與 github.com/pkg/errors 相比,它有幾點(diǎn)不足:
- 使用 : %w 代替了 Wrap , 看似簡(jiǎn)化, 但失去了編譯期檢查。如果沒有冒號(hào),或 : %w 不位于于格式化字符串的結(jié)尾,或冒號(hào)與百分號(hào)之間沒有空格,包裝將失效且不報(bào)錯(cuò);
- 而且,調(diào)用 xerrors.Errorf 之前需要對(duì)參數(shù)進(jìn)行nil判斷。這完全沒有簡(jiǎn)化開發(fā)者的工作
方案 3:Go 1.13 內(nèi)置支持
Go 1.13 將 xerrors 的部分功能(不是全部)整合進(jìn)了標(biāo)準(zhǔn)庫(kù)。它繼承了上面提到的 xerrors 的全部缺點(diǎn), 并額外貢獻(xiàn)了一項(xiàng)。因此目前沒有使用它的必要。
- import (
- "database/sql"
- "errors"
- "fmt"
- )
- func Call() error {
- if err := GetSql(); err != nil {
- return fmt.Errorf("Call failed: %w", GetSql())
- }
- return nil
- }
- func GetSql() error {
- return fmt.Errorf("GetSql failed: %w", sql.ErrNoRows)
- }
- func main() {
- err := Call()
- if errors.Is(err, sql.ErrNoRows) {
- fmt.Printf("data not found, %+v\n", err)
- return
- }
- if err != nil {
- // unknown error
- }
- }
- /* Outputs:
- data not found, Call failed: GetSql failed: sql: no rows in result set
- */
上面的代碼與 xerrors 版本非常接近。但是它不支持調(diào)用棧信息輸出, 根據(jù)官方的說法, 此功能沒有明確的支持時(shí)間。因此其實(shí)用性遠(yuǎn)低于 github.com/pkg/errors。
Golang 中將來可能的錯(cuò)誤處理方式
在 Go2 的草案中,我們看到了有關(guān)于 error 相關(guān)的一些提案,那就是 check/handle 函數(shù)。
我們也許在下一個(gè)大版本的 Golang 可以像下面這樣處理錯(cuò)誤:
- import "fmt"
- func game() error {
- handle err {
- return fmt.Errorf("dependencies error: %v", err)
- }
- resource := check findResource() // return resource, error
- defer func() {
- resource.Release()
- }()
- profile := check loadProfile() // return profile, error
- defer func() {
- profile.Close()
- }
- // ...
- }
感興趣的同學(xué)可以關(guān)注下這個(gè)提案:https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling-overview.md
得出結(jié)論
- 重要的是要記住,包裝錯(cuò)誤會(huì)使該錯(cuò)誤成為 API 的一部分。如果您不想將來將錯(cuò)誤作為 API 的一部分來支持,則不應(yīng)包裝該錯(cuò)誤。無論是否包裝錯(cuò)誤,錯(cuò)誤文本都將相同。那些試圖理解錯(cuò)誤的人將得到相同的信息,無論采用哪種方式; 是否要包裝錯(cuò)誤的選擇取決于是否要給程序提供更多信息,以便他們可以做出更明智的決策,還是保留該信息以保留抽象層。
通過以上對(duì)比, 相信你已經(jīng)有了選擇。再明確一下我的看法,如果你正在使用 github.com/pkg/errors ,那就保持現(xiàn)狀吧。目前還沒有比它更好的選擇。如果你已經(jīng)大量使用 golang.org/x/xerrors , 別盲目換成 go 1.13 的內(nèi)置方案。
總的來說,Go 在誕生之初就在各個(gè)方面表現(xiàn)得相當(dāng)成熟、穩(wěn)健。在演進(jìn)路線上很少出現(xiàn)猶疑和搖擺, 而在錯(cuò)誤處理方面卻是個(gè)例外。除了被廣泛吐槽的 if err != nil 之外, 就連其改進(jìn)路線也備受爭(zhēng)議、分歧明顯,以致于一個(gè)改進(jìn)提案都會(huì)因?yàn)閴旱剐缘姆磳?duì)意見而不得不作出調(diào)整。好在 Go 團(tuán)隊(duì)比以前更加樂于傾聽社區(qū)意見,團(tuán)隊(duì)甚至專門就此問題建了個(gè)反饋收集頁面。相信最終大家會(huì)找到更好的解決方案。