先睹為快,Go2 Error 的掙扎之路

煎魚發表於2020-12-29
若有任何問題或建議,歡迎及時交流和碰撞。我的公眾號是 【腦子進煎魚了】,GitHub 地址:https://github.com/eddycjy

大家好,我是煎魚。

自從 Go 語言在國內火熱以來,除去泛型,其次最具槽點的就是 Go 對錯誤的處理方式,一句經典的 if err != nil 暗號就能認出你是一個 Go 語言愛好者。

image

自然,大家對 Go error 的關注度更是高漲,Go team 也是,因此在 Go 2 Draft Designs 中正式提到了 error handling(錯誤處理)的相關草案,希望能夠在未來正式的解決這個問題。

在今天這篇文章中,我們將一同跟蹤 Go2 error,看看他是怎麼 “掙扎” 的,能不能破局?

為什麼要吐槽 Go1

要吐槽 Go1 error,就得先知道為什麼大家到底是在噴 Error 哪裡處理的不好。在 Go 語言中,error 其實本質上只是個 Error 的 interface

type error interface {
    Error() string
}

實際的應用場景如下:

func main() {
    x, err := foo()
    if err != nil {
         // handle error
    }
}

單純的看這個例子似乎沒什麼問題,但工程大了後呢?顯然 if err != nil 的邏輯是會堆積在工程程式碼中,Go 程式碼裡的 if err != nil 甚至會達到工程程式碼量的 30% 以上:

func main() {
    x, err := foo()
    if err != nil {
         // handle error
    }
    y, err := foo()
    if err != nil {
         // handle error
    }
    z, err := foo()
    if err != nil {
         // handle error
    }
    s, err := foo()
    if err != nil {
         // handle error
    }
}

暴力的對比一下,就發現四行函式呼叫,十二行錯誤,還要苦練且精通 IDE 的快速摺疊功能,還是比較麻煩的。

另外既然是錯誤處理,那肯定不單單是一個 return err 了。在工程實踐中,專案程式碼都是層層巢狀的,如果直接寫成:

if err != nil {
    return err
}

在實際工程中肯定是不行。你怎麼知道具體是哪裡丟擲來的錯誤資訊,實際出錯時只能瞎猜。大家又想出了 PlanB,那就是加各種描述資訊:

if err != nil {
    logger.Errorf("煎魚報錯 err:%v", err)
    return err
}

雖然看上去人模人樣的,在實際出錯時,也會遇到新的問題,因為你要去查這個錯誤是從哪裡丟擲來的,單純幾句錯誤描述是難以定位的。這時候就會發展成到處打錯誤日誌

func main() {
    err := bar()
    if err != nil {
        logger.Errorf("bar err:%v", err)
    }
    ...
}

func bar() error {
    _, err := foo()
    if err != nil {
        logger.Errorf("foo err:%v", err)
        return err
    }

    return nil
}

func foo() ([]byte, error) {
    s, err := json.Marshal("hello world.")
    if err != nil {
        logger.Errorf("json.Marshal err:%v", err)
        return nil, err
    }

    return s, nil
}

雖然到處打了日誌,就會變成錯誤日誌非常多,一旦出問題,人肉可能短時間內識別不出來。且最常見的就是到 IDE 上 ctrl + f 搜尋是在哪出錯,同時在我們常常會自定義一些錯誤型別,而在 Go 則需要各種判斷和處理:

if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
       ...
    }
    return err
}

首先你得判斷不等於 nil,還得對自定義的錯誤型別進行斷言,整體來講比較繁瑣。

彙總來講,Go1 錯誤處理的問題至少有:

  • 在工程實踐中,if err != nil 寫的煩,程式碼中一大堆錯誤處理的判斷,佔了相當的比例,不夠優雅。
  • 在排查問題時,Go 的 err 並沒有其他堆疊資訊,只能自己增加描述資訊,層層疊加,打一大堆日誌,排查很麻煩。
  • 在驗證和測試錯誤時,要自定義錯誤(各種判斷和斷言)或者被迫用字串校驗。

Go1.13 的挽尊

在 2019 年 09 月,Go1.13 正式釋出。其中兩個比較大的兩個關注點分別是包依賴管理 Go modules 的轉正,以及錯誤處理 errors 標準庫的改進:

image

在本次改進中,errors 標準庫引入了 Wrapping Error 的概念,並增加了 Is/As/Unwarp 三個方法,用於對所返回的錯誤進行二次處理和識別。同時也是將 Go2 error 預規劃中沒有破壞 Go1 相容性的相關功能提前實現了。

簡單來講,Go1.13 後 Go 的 error 就可以巢狀了,並提供了三個配套的方法。例子:

func main() {
    e := errors.New("腦子進煎魚了")
    w := fmt.Errorf("快抓住:%w", e)
    fmt.Println(w)
    fmt.Println(errors.Unwrap(w))
}

輸出結果:

$ go run main.go
快抓住:腦子進煎魚了
腦子進煎魚了

在上述程式碼中,變數 w 就是一個巢狀一層的 error。最外層是 “快抓住:”,此處呼叫 %w 意味著 Wrapping Error 的巢狀生成。因此最終輸出了 “快抓住:腦子進煎魚了”。

需要注意的是,Go 並沒有提供 Warp 方法,而是直接擴充套件了 fmt.Errorf 方法。而下方的輸出由於直接呼叫了 errors.Unwarp 方法,因此將 “取” 出一層巢狀,最終直接輸出 “腦子進煎魚了”。

對 Wrapping Error 有了基本理解後,我們簡單介紹一下三個配套方法:

func Is(err, target error) bool
func As(err error, target interface{}) bool
func Unwrap(err error) error

errors.Is

方法簽名:

func Is(err, target error) bool

方法例子:

func main() {
    if _, err := os.Open("non-existing"); err != nil {
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("file does not exist")
        } else {
            fmt.Println(err)
        }
    }

}

errors.Is 方法的作用是判斷所傳入的 err 和 target 是否同一型別,如果是則返回 true。

errors.As

方法簽名:

func As(err error, target interface{}) bool

方法例子:

func main() {
    if _, err := os.Open("non-existing"); err != nil {
        var pathError *os.PathError
        if errors.As(err, &pathError) {
            fmt.Println("Failed at path:", pathError.Path)
        } else {
            fmt.Println(err)
        }
    }

}

errors.As 方法的作用是從 err 錯誤鏈中識別和 target 相同的型別,如果可以賦值,則返回 true。

errors.Unwarp

方法簽名:

func Unwrap(err error) error

方法例子:

func main() {
    e := errors.New("腦子進煎魚了")
    w := fmt.Errorf("快抓住:%w", e)
    fmt.Println(w)
    fmt.Println(errors.Unwrap(w))
}

該方法的作用是將巢狀的 error 解析出來,若存在多級巢狀則需要呼叫多次 Unwarp 方法。

民間自救 pkg/errors

Go1 的 error 處理固然存在許多問題,因此在 Go1.13 前,早已有 “民間” 發現沒有上下文除錯資訊在實際工程應用中存在嚴重的體感問題。因此 github.com/pkg/errors 在 2016 年誕生了,目前該庫也已經受到了極大的關注。

官方例子如下:

type stackTracer interface {
    StackTrace() errors.StackTrace
}

err, ok := errors.Cause(fn()).(stackTracer)
if !ok {
    panic("oops, err does not implement stackTracer")
}

st := err.StackTrace()
fmt.Printf("%+v", st[0:2]) // top two frames

// Example output:
// github.com/pkg/errors_test.fn
//    /home/dfc/src/github.com/pkg/errors/example_test.go:47
// github.com/pkg/errors_test.Example_stackTrace
//    /home/dfc/src/github.com/pkg/errors/example_test.go:127

簡單來講,就是對 Go1 error 的上下文處理進行了優化和處理,例如型別斷言、呼叫堆疊等。若有興趣的小夥伴可以自行到 github.com/pkg/errors 進行學習。

另外你可能會發現 Go1.13 新增的 Wrapping Error 體系與 pkg/errors 有些相像。你並沒有體會錯,Go team 接納了相關的意見,對 Go1 進行了調整,但呼叫堆疊這塊因綜合原因暫時沒有納入。

Go2 error 要解決什麼問題

在前面我們聊了 Go1 error 的許多問題,以及 Go1.13 和 pkg/errors 的自救和融合。你可能會疑惑,那...Go2 error 還有出場的機會嗎?即使 Go1 做了這些事情,Go1 error 還有問題嗎?

並沒有解決,if err != nil 依舊一把梭,目前社群聲音依然認為 Go 語言的錯誤處理要改進。

Go2 error proposal

在 2018 年 8 月,官方正式公佈了 Go 2 Draft Designs,其中包含泛型和錯誤處理機制改進的初步草案:

image

注:Go1.13 正式將一些不破壞 Go1 相容性的 Error 特性加入到了 main branch,也就是前面提到的 Wrapping Error。

錯誤處理(Error Handling)

第一個要解決的問題就是大量 if err != nil 的問題,針對此提出了 Go2 error handling 的草案設計。

簡單例子:

if err != nil {
    return err
}

優化後的方案如下:

func CopyFile(src, dst string) error {
    handle err {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    r := check os.Open(src)
    defer r.Close()

    w := check os.Create(dst)
    handle err {
        w.Close()
        os.Remove(dst) // (only if a check fails)
    }

    check io.Copy(w, r)
    check w.Close()
    return nil
}

主函式:

func main() {
    handle err {
        log.Fatal(err)
    }

    hex := check ioutil.ReadAll(os.Stdin)
    data := check parseHexdump(string(hex))
    os.Stdout.Write(data)
}

該提案引入了兩種新的語法形式,首先是 check 關鍵字,其可以選中一個表示式 check f(x, y, z)check err,其將會標識這是一個顯式的錯誤檢查。

其次引入了 handle 關鍵字,用於定義錯誤處理程式流轉,逐級上拋,依此類推,直到處理程式執行 return 語句,才正式結束。

錯誤值列印(Error Printing)

第二個要解決的問題是錯誤值(Error Values)、錯誤檢查(Error Inspection)的問題,其引申出錯誤值列印(Error Printing)的問題,也可以認為是錯誤格式化的不便利。

官方針對此提出了提出了 Error ValuesError Printing 的草案設計。

簡單例子如下:

if err != nil {
    return fmt.Errorf("write users database: %v", err)
}

優化後的方案如下:

package errors

type Wrapper interface {
    Unwrap() error
}

func Is(err, target error) bool
func As(type E)(err error) (e E, ok bool)

該提案增加了錯誤鏈的 Wrapping Error 概念,並同時增加 errors.Iserrors.As 的方法,與前面說到的 Go1.13 的改進一致,不再贅述。

需要留意的是,Go1.13 並沒有實現 %+v 輸出呼叫堆疊的需求,因為此舉會破壞 Go1 相容性和產生一些效能問題,大概會在 Go2 加入。

try-catch 不香嗎

社群中另外一股聲音就是直指 Go 語言反人類不用 try-catch 的機制,在社群內也產生了大量的探討,具體可以看看相關的提案 Proposal: A built-in Go error check function, "try"

目前該提案已被拒絕,具體可參見 go/issues/32437#issuecomment-512035919Why does Go not have exceptions

總結

在這篇文章中,我們介紹了目前 Go1 Error 的現狀,概括了大家對 Go 語言錯誤處理的常見問題和意見。同時還介紹了在這幾年間,Go team 針對 Go2、Go1.13 Error 的持續優化和探索。

如果是你,你會怎麼去優化目前 Go 語言的錯誤處理機制呢,現在 Go2 error proposal 你又是否認可?

參考

我的公眾號

分享 Go 語言、微服務架構和奇怪的系統設計,歡迎大家關注我的公眾號和我進行交流和溝通。

最好的關係是互相成就,各位的點贊就是煎魚創作的最大動力,感謝支援。

相關文章