写在前面

Go中的错误处理与Java、JavaScript或Python等其他主流编程语言略有不同。Go的内置错误不包含堆栈跟踪,也不支持传统的尝试/捕获方法来处理它们。相反,Go中的错误只是函数返回的值,可以用与任何其他数据类型几乎相同的方式来处理它们,这导致了一个惊人的轻量级和简单的设计。

在本文中,我将演示Go中处理错误的基础知识,以及一些可以在代码中遵循的简单策略,以确保程序健壮且易于调试。

error关键字

在golang中error关键字是一个interface,他的定义如下:

type error interface {
    Error() string
}

因此任何实现了interface的类型,都可以有Error() 的表现,它会返回一段字符串,也正是这样简介的表达,让golang中的错误处理异与任何其他编程语言。

实现接口

在golang标准库中,总能让我看到特别优化的设计,就比如如何实现一个错误,官方标准库如下:

package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
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
}

在go中惯用的New关键字来实例化某个数据类型, 这里通过一个字符串(代表错误),实例化了一个错误,它实现了error接口(完成了Error方法的实现)

但是,golang的fmt也为我们设计了fmt.Errorf() 方法,能够让我们更加友好的表达错误字符串,例如如下:

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
}

在实际项目中总结下来, 还需要我们特别留意这么些点:

  • Errors 方法会返回nil, 代表着一个没有错误的值,我们都知道interface类型的变量,零值就是nil,所以我们可以拿这个判断我们的error是否真的存在。
  • 把错误放到最后一个返回参数, 哈哈, 养成习惯,不要问为什么。
  • 当我们返回有错误的error时, 我们其他的返回值就返回零值,作为开发,这个时候也不用关心有err的时候其他参数了。
  • 最后,错误表达的这个字符串就用小写吧,也不要在最后带个标点符号。

把错误定义在包内,通过变量表达

场景: 我们调用了底层的某个包,我们希望通过判断某个包的某个方法是否包含某种类型的错误而做出具体的处理,这个时候我们当然可以拿着返回的错误字符串做比对,例如: err.Error() == "错误XXX",但是go中有更方便的方法(Is)来做这件事情.

下面举个栗子:

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)
}

定义更加复杂的错误类型(或者包装简单的错误为更加负责的错误类型)

因为有error接口的存在, 所以当我们实现了Error方法,就可以表达更加复杂的业务场景,下面举例一个更加复杂的业务场景:

当我们调用了底层的一个包的方法,它可能是网络错误? 也可能是参数错误? 也可能是数据计算异常的错误(例如你提交了一个不存在的uid)?这个时候error的具体实现就不一样了, 我们需要拿到err错误后做进一步的判断类型,此时 As 关键字就有用途了, 它比 Is 关键字更加强大,能够判断 返回的err实现者是否是某种类型

具体我们可以用下面的代码了解

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)
}

Wrapping Errors

上面我们解决了业务上调用某个包的某个方法的处理逻辑,但是实际场景中,更复杂的一点在于我们将错误冒泡到了更多的堆栈中, 例如: 某个api接口的hander方法接受了查询user信息,此时去调用users包的getUsers() 方法, getUsers 方法需要调用 db包,db包负责查询数据,并可能遇到错误,这个就是典型的错误需要冒泡的逻辑。同时只有最底层的db层,才能知道是不是db 的connect timeout错误。

为了解决这个问题,golang 引入了 wrapping

tips: 我以往使用fmt.Errorf("读取文件出错:%s", err.Error()), 其实go中提供了w%,可以这样: fmt.Errorf("读取文件出错:%w", err)

老的方式 (Before Go 1.13)

下面我们继续通过栗子和代码来举例我之前对error的使用方式,然后我们看是否可以warp的方式更加友好的解决这个问题:

这个db包是典型的db层操作,它会返回错误:

package db

type User struct {
  ID       string
  Username string
  Age      int
}

func FindUser(username string) (*User, error) { /* ... */ }
func SetUserAge(user *User, age int) error { /* ... */ }

在main方法里面,我们假设捕获到了错误信息,那么我们将收到 failed finding or updating user:xxxx 的错误,但是我们不能肯定这是FindUser 还是 SetUserAge 方法返回的错误, 这也正是冒泡错误时经常遇到的问题:

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("bob@example.com", 21); err != nil {
        fmt.Println("failed finding or updating user: %s", err)
        return
    }

    fmt.Println("successfully updated user's age")
}

冒泡的错误应该尽可能被wrap

上面代码给出了冒泡错误的诟病,其实这个问题也可以老办法解决, 就是对Error() 给出的字符串二次wrap错误消息,然后再new出来,但是那样的话,代码层很不友好, 一堆的new错误实例,好在go新版本1.13之后就有了解决方案:

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("bob@example.com", 21); err != nil {
        fmt.Println("failed finding or updating user: %s", err)
        return
    }

    fmt.Println("successfully updated user's age")
}

运行上面的代码,我们得到这样的错误:

failed finding or updating user: FindAndSetUserAge: SetUserAge: failed executing db update: malformed request

我们仅仅使用 %w 就对错误进行了wrap,大大提高了代码的简洁性和可读性。

wrap也要看场景

上面列举了wrap的优势,但是我们不要刻意取使用它,当我们不想暴露底层错误的时候,我们应该不要将wrap的错误提交到最上层直到api的errmsg

最后总结

go的错误处理我一直非常喜欢,可能也写顺手了, 我觉得比一大坨代码一个try catch优雅很多,也简单很多, 由此我也体会到,有些工程设计, 复杂的包装并非好事,有可能带来的是维护的灾难。

总之,go的错误处理,简洁+够用,剩下的是你习惯它。

标签: 异常, error, 错误处理

评论已关闭