深入分析 Golang 的 Error

KaoLengMian發表於2022-04-26

Golang中的error

Golang中的 error 就是一個簡單的介面型別。只要實現了這個介面,就可以將其視為一種 error

type error interface {
    Error() string
}

error的幾種玩法

翻看Golang原始碼,能看到許多類似於下面的這兩種error型別

哨兵錯誤

var EOF = errors.New("EOF")

var ErrUnexpectedEOF = errors.New("unexpected EOF")

var ErrNoProgress = errors.New("multiple Read calls return no data or error")

缺點:
1.讓 error 具有二義性

error != nil不再意味著一定發生了錯誤
比如io.Reader返回io.EOF來告知呼叫者沒有更多資料了,然而這又不是一個錯誤

2.在兩個包之間建立了依賴

如果你使用了io.EOF來檢查是否read完所有的資料,那麼程式碼裡一定會匯入io包

自定義錯誤型別

一個不錯的例子是os.PathError,它的優點是可以附帶更多的上下文資訊

type PathError struct {
    Op   string
    Path string
    Err  error
}

Wrap error

到這裡我們可以發現,Golang 的 error 非常簡單,然而簡單也意味著有時候是不夠用的
Golang的error一直有兩個問題:
1.error沒有附帶file:line資訊(也就是沒有堆疊資訊)
比如這種error,鬼知道程式碼哪一行報了錯,Debug時簡直要命

SERVICE ERROR 2022-03-25T16:32:10.687+0800!!!
       Error 1406: Data too long for column 'content' at row 1

2.上層error想附帶更多日誌資訊時,往往會使用fmt.Errorf()fmt.Errorf()會建立一個新的error,底層的error型別就被“吞”掉了

var errNoRows = errors.New("no rows")

// 模仿sql庫返回一個errNoRows
func sqlExec() error {
    return errNoRows
}

func serviceNoErrWrap() error {
    err := sqlExec()
    if err != nil {
        return fmt.Errorf("sqlExec failed.Err:%v", err)
    }

    return nil
}

func TestErrWrap(t *testing.T) {
    // 使用fmt.Errorf建立了一個新的err,丟失了底層err
    err := serviceNoErrWrap()
    if err != errNoRows {
        log.Println("===== errType don't equal errNoRows =====")
    }
}
-------------------------------程式碼執行結果----------------------------------
=== RUN   TestErrWrap
2022/03/26 17:19:43 ===== errType don't equal errNoRows =====

為了解決這個問題,我們可以使用github.com/pkg/error包,使用errors.withStack()方法將err保
存到withStack物件

// withStack結構體儲存了error,形成了一條error鏈。同時*stack欄位儲存了堆疊資訊。
type withStack struct {
    error
    *stack
}

也可以使用errors.Wrap(err, "自定義文字"),額外附帶一些自定義的文字資訊
原始碼解讀:先將err和message包進withMessage物件,再將withMessage物件和堆疊資訊包進withStack物件

func Wrap(err error, message string) error {
    if err == nil {
        return nil
    }
    err = &withMessage{
        cause: err,
        msg:   message,
    }
    return &withStack{
        err,
        callers(),
    }
}

Golang1.13版本error的新特性

Golang1.13版本借鑑了github.com/pkg/error包,新增瞭如下函式,大大增強了 Golang 語言判斷 error 型別的能力

errors.UnWrap()

// 與errors.Wrap()行為相反
// 獲取err鏈中的底層err
func Unwrap(err error) error {
    u, ok := err.(interface {
        Unwrap() error
    })
    if !ok {
        return nil
    }
    return u.Unwrap()
}

errors.Is()

在1.13版本之前,我們可以用err == targetErr判斷err型別
errors.Is()是其增強版:error 鏈上的任一err == targetErr,即return true

// 實踐:學習使用errors.Is()
var errNoRows = errors.New("no rows")

// 模仿sql庫返回一個errNoRows
func sqlExec() error {
    return errNoRows
}

func service() error {
    err := sqlExec()
    if err != nil {
        return errors.WithStack(err)    // 包裝errNoRows
    }

    return nil
}

func TestErrIs(t *testing.T) {
    err := service()

    // errors.Is遞迴呼叫errors.UnWrap,命中err鏈上的任意err即返回true
    if errors.Is(err, errNoRows) {
        log.Println("===== errors.Is() succeeded =====")
    }

    //err經errors.WithStack包裝,不能通過 == 判斷err型別
    if err == errNoRows {
        log.Println("err == errNoRows")
    }
}
-------------------------------程式碼執行結果----------------------------------
=== RUN   TestErrIs
2022/03/25 18:35:00 ===== errors.Is() succeeded =====

例子解讀:
因為使用errors.WithStack包裝了sqlErrorsqlError位於error鏈的底層,上層的error已經不再是sqlError型別,所以使用==無法判斷出底層的sqlError

原始碼解讀:

  • 我們很容易想到其內部呼叫了err = Unwrap(err)方法來獲取error鏈中底層的error
  • 自定義error型別可以實現Is介面來自定義error型別判斷方法
func Is(err, target error) bool {
    if target == nil {
        return err == target
    }

    isComparable := reflectlite.TypeOf(target).Comparable()
    for {
        if isComparable && err == target {
            return true
        }
        // 支援自定義error型別判斷
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}

下面我們來看看如何自定義error型別判斷:
自定義的errNoRows型別,必須實現Is介面,才能使用erros.Is()進行型別判斷

type errNoRows struct {
    Desc string
}

func (e errNoRows) Unwrap() error { return e }

func (e errNoRows) Error() string { return e.Desc }

func (e errNoRows) Is(err error) bool {
    return reflect.TypeOf(err).Name() == reflect.TypeOf(e).Name()
}

// 模仿sql庫返回一個errNoRows
func sqlExec() error {
    return &errNoRows{"Kaolengmian NB"}
}

func service() error {
    err := sqlExec()
    if err != nil {
        return errors.WithStack(err)
    }

    return nil
}

func serviceNoErrWrap() error {
    err := sqlExec()
    if err != nil {
        return fmt.Errorf("sqlExec failed.Err:%v", err)
    }

    return nil
}

func TestErrIs(t *testing.T) {
    err := service()

    if errors.Is(err, errNoRows{}) {
        log.Println("===== errors.Is() succeeded =====")
    }
}
-------------------------------程式碼執行結果----------------------------------
=== RUN   TestErrIs
2022/03/25 18:35:00 ===== errors.Is() succeeded =====

errors.As()

在1.13版本之前,我們可以用if _,ok := err.(targetErr)判斷err型別
errors.As()是其增強版:error 鏈上的任一err與targetErr型別相同,即return true

// 通過例子學習使用errors.As()
type sqlError struct {
    error
}

func (e *sqlError) IsNoRows() bool {
    t, ok := e.error.(ErrNoRows)
    return ok && t.IsNoRows()
}

type ErrNoRows interface {
    IsNoRows() bool
}

// 返回一個sqlError
func sqlExec() error {
    return sqlError{}
}

// errors.WithStack包裝sqlError
func service() error {
    err := sqlExec()
    if err != nil {
        return errors.WithStack(err)
    }

    return nil
}

func TestErrAs(t *testing.T) {
    err := service()

    // 遞迴使用errors.UnWrap,只要Err鏈上有一種Err滿足型別斷言,即返回true
    sr := &sqlError{}
    if errors.As(err, sr) {
        log.Println("===== errors.As() succeeded =====")
    }

    // 經errors.WithStack包裝後,不能通過型別斷言將當前Err轉換成底層Err
    if _, ok := err.(sqlError); ok {
        log.Println("===== type assert succeeded =====")
    }
}
----------------------------------程式碼執行結果--------------------------------------------
=== RUN   TestErrAs
2022/03/25 18:09:02 ===== errors.As() succeeded =====

例子解讀:
因為使用errors.WithStack包裝了sqlErrorsqlError位於error鏈的底層,上層的error已經不再是sqlError型別,所以使用型別斷言無法判斷出底層的sqlError

error處理最佳實踐

上面講了如何定義error型別,如何比較error型別,現在我們談談如何在大型專案中做好error處理

優先處理error

當一個函式返回一個非空error時,應該優先處理error,忽略它的其他返回值

只處理error一次

在Golang中,對於每個err,我們應該只處理一次。

  • 要麼立即處理err(包括記日誌等行為),return nil(把錯誤吞掉)。此時因為把錯誤做了降級,一定要小心處理函式返回值。

    比如下面例子json.Marshal(conf)沒有return err ,那麼在使用buf時一定要小心空指標等錯誤

  • 要麼return err,在上層處理err

反例:

// 試想如果writeAll函式出錯,會列印兩遍日誌
// 如果整個專案都這麼做,最後會驚奇的發現我們在處處打日誌,專案中存在大量沒有價值的垃圾日誌
// unable to write:io.EOF
// could not write config:io.EOF

type config struct {}

func writeAll(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        log.Println("unable to write:", err)
        return err
    }

    return nil
}

func writeConfig(w io.Writer, conf *config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        log.Printf("could not marshal config:%v", err)
    }

    if err := writeAll(w, buf); err != nil {
        log.Println("count not write config: %v", err)
        return err
    }

    return nil
}

不要反覆包裝error

我們應該包裝error,但只包裝一次
上層業務程式碼建議Wrap error,但是底層基礎Kit庫不建議
如果底層基礎 Kit 庫包裝了一次,上層業務程式碼又包裝了一次,就重複包裝了 error,日誌就會打重
比如我們常用的sql庫會返回sql.ErrNoRows這種預定義錯誤,而不是給我們一個包裝過的 error

不透明的錯誤處理

在大型專案中,推薦使用不透明的錯誤處理(Opaque errors):不關心錯誤型別,只關心error是否為nil
好處:

  • 耦合小,不需要判斷特定錯誤型別,就不需要匯入相關包的依賴。

    不過有時候,這種處理error的方式不夠用,比如:業務需要對引數異常error型別做降級處理,列印Warn級別的日誌
type ParamInvalidError struct {
    Desc string
}

func (e ParamInvalidError) Unwrap() error { return e }

func (e ParamInvalidError) Error() string { return "ParamInvalidError: " + e.Desc }

func (e ParamInvalidError) Is(err error) bool {
    return reflect.TypeOf(err).Name() == reflect.TypeOf(e).Name()
}

func NewParamInvalidErr(desc string) error {
    return errors.WithStack(&ParamInvalidError{Desc: desc})
}
------------------------------頂層列印日誌---------------------------------
if errors.Is(err, Err.ParamInvalidError{}) {
    logger.Warnf(ctx, "%s", err.Error())
    return
}
if err != nil {
    logger.Errorf(ctx, " error:%+v", err)
}

簡化錯誤處理

Golang因為程式碼中無數的if err != nil被詬病,現在我們看看如何減少if err != nil這種程式碼

bufio.scan

CountLines() 實現了”讀取內容的行數”功能
可以利用 bufio.scan() 簡化 error 的處理:

func CountLines(r io.Reader) (int, error) {
    var (
        br    = bufio.NewReader(r)
        lines int
        err   error
    )

    for {
        _, err := br.ReadString('\n')
        lines++
        if err != nil {
            break
        }
    }

    if err != io.EOF {
        return 0, nilsadwawa 
    }

    return lines, nil
}

func CountLinesGracefulErr(r io.Reader) (int, error) {
    sc := bufio.NewScanner(r)

    lines := 0
    for sc.Scan() {
        lines++
    }

    return lines, sc.Err()
}

bufio.NewScanner() 返回一個 Scanner 物件,結構體內部包含了 error 型別,呼叫Err()方法即可返回封裝好的error

Golang原始碼中蘊含著大量的優秀設計思想,我們在閱讀原始碼時從中學習,並在實踐中得以運用

type Scanner struct {
    r            io.Reader // The reader provided by the client.
    split        SplitFunc // The function to split the tokens.
    maxTokenSize int       // Maximum size of a token; modified by tests.
    token        []byte    // Last token returned by split.
    buf          []byte    // Buffer used as argument to split.
    start        int       // First non-processed byte in buf.
    end          int       // End of data in buf.
    err          error     // Sticky error.
    empties      int       // Count of successive empty tokens.
    scanCalled   bool      // Scan has been called; buffer is in use.
    done         bool      // Scan has finished.
}

func (s *Scanner) Err() error {
    if s.err == io.EOF {
        return nil
    }
    return s.err
}

errWriter

WriteResponse()函式實現了"構建HttpResponse"功能
利用上面學到的思路,我們可以自己實現一個errWriter物件,簡化對 error 的處理

type Header struct {
    Key, Value string
}

type Status struct {
    Code   int
    Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
    _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
    if err != nil {
        return err
    }

    for _, h := range headers {
        _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
        if err != nil {
            return err
        }
    }

    if _, err := fmt.Fprintf(w, "\r\n"); err != nil {
        return err
    }

    _, err = io.Copy(w, body)
    return err
}

type errWriter struct {
    io.Writer
    err error
}

func (e *errWriter) Write(buf []byte) (n int, err error) {
    if e.err != nil {
        return 0, e.err
    }

    n, e.err = e.Writer.Write(buf)

    return n, nil
}

func WriteResponseGracefulErr(w io.Writer, st Status, headers []Header, body io.Reader) error {
    ew := &errWriter{w, nil}

    fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

    for _, h := range headers {
        fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
    }

    fmt.Fprintf(w, "\r\n")

    io.Copy(ew, body)

    return ew.err
}

何時該用panic

在 Golang 中panic會導致程式直接退出,是一個致命的錯誤。
建議發生致命的程式錯誤時才使用 panic,例如索引越界、不可恢復的環境問題、棧溢位等等

小補充

errors.New()返回的是errorString物件的指標,其原因是防止字串產生碰撞,如果發生碰撞,兩個 error 物件會相等。
原始碼:

func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

實踐:error1error2的text都是"error",但是二者並不相等

func TestErrString(t *testing.T) {
    var error1 = errors.New("error")
    var error2 = errors.New("error")

    if error1 != error2 {
        log.Println("error1 != error2")
    }
}
---------------------程式碼執行結果--------------------------
=== RUN   TestXXXX
2022/03/25 22:05:40 error1 != error2

創作不易,希望大家能順手點個贊~這對我很重要,蟹蟹各位啦~

參考文獻
《Effective GO》
《Go程式設計語言》
dave.cheney.net/practical-go/prese...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章