深入Go错误处理:提升代码健壮性的实用技巧

前言

Go 设计的错误就是值,错误处理就是值比较后作的决策。

业务逻辑需要忽略错误,否则不要去忽视错误。

这种设计理论上会让编程人员有意识的处理每一个错误,让程序更加健壮

我们这篇文章来聊一聊处理好错误的实践。

  1. 业务逻辑需要忽略错误时才忽视,否则应处理每个错误
  2. 使用 errors 包装错误以获取堆栈信息,更精确的打印出错误信息,分布式系统中使用 trace_id 来链接同个请求的错误
  3. 错误应只被处理一次,包括打印日志、实现错误的兜底机制。
  4. 保持错误抽象层级一致,避免抛出高于当前模块的错误引起混乱。
  5. 通过顶层设计减少 if err != nil 出现的频率。

精确打印错误

错误日志是帮助我们排查问题的重要手段,所以打印不容易混淆的日志非常重要。如何通过 err 来获取错误的堆栈日志帮助我们排查问题.

经常反问自己:这样打出来的错误真的能帮助排查问题吗?

如果看日志我们无法排查到对应的错误,那相当于没有打出错误。

github.com/pkg/errors 给我们提供了一个携带堆栈的封装。

func callers() *stack {
 const depth = 32
 var pcs [depth]uintptr
 n := runtime.Callers(3, pcs[:])
 var st stack = pcs[0:n]
 return &st
}

func New(message string) error {
 return &fundamental{
  msg:   message,
  stack: callers(),
 }
}

堆栈的打印是 fundamental 实现了 Format 接口

然后 fmt.Printf("%+v", err) 可以打印出对应的堆栈信息

func (f *fundamental) Format(s fmt.State, verb rune) {
 switch verb {
 case 'v':
  if s.Flag('+') {
   io.WriteString(s, f.msg)
   f.stack.Format(s, verb)
   return
  }
  fallthrough
 case 's':
  io.WriteString(s, f.msg)
 case 'q':
  fmt.Fprintf(s, "%q", f.msg)
 }
}

我们看一下具体的使用

func foo() error {
 return errors.New("something went wrong")
}

func bar() error {
 return foo() // 将堆栈信息附加到错误上
}

通过 foo 调用 errors.New 来实例化一个错误,然后再增加一层 bar 的调用。

再写一个 Test 来打印出我们的错误

func TestBar(t *testing.T) {
 err := bar()
 fmt.Printf("err: %+v\n", err)
}

最终打印出来的内容就带了 foobar 的堆栈。

err: something went wrong
golib/examples/writer_good_code/exception.foo
 E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:8
golib/examples/writer_good_code/exception.bar
 E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:12
...

能够看到第一行则准确打印出我们的错误字符串,然后第一行堆栈就是我们错误的产生地方。

有了这两个错误信息帮忙定位,我们能看到写入的错误信息和产生错误的地方。

分布式下的错误追踪

单机的错误我们已经能够准确的打印,但是在实际的程序中,我们往往会有存在许多并发的情况,怎么能让错误堆栈是属于一起请求的,这就需要 trace_id 来实现

我们可以根据自己的实际情况和想要的格式来生成,然后设置在上下文中。

func CtxWithTraceId(ctx context.Context, traceId string) context.Context {
 ctx = context.WithValue(ctx, TraceIDKey, traceId)
 return ctx
}

打印日志的时候我们可以通过 CtxTraceID 来获取 traceId

func CtxTraceID(c context.Context) string {
 if gc, ok := c.(*gin.Context); ok {
  // 从gin的请求去获取trace id ...
 }
 
 // get from go context
 traceID := c.Value(TraceIDKey)
 if traceID != nil {
  return traceID.(string)
 }
 
 // 如果没有 trace id则生成一个
 return TraceIDPrefix + xid.New().String()
}

通过给日志增加了 traceID ,我们查询日志的时候就可以通过 traceId 来拉取到整条链路的错误日志,降低了我们排查问题查找日志的难度。

错误只处理一次

错误应该只被处理一次,打印日志也算是对错误的处理。

如果一处打印日志无法解决问题,合适的做法应该是让错误增加更多上下文信息,然后让错误信息可以看出来程序是哪里出了问题,而不是将错误进行多次处理。

最开始我自己使用错误日志的时候有一个误区,觉得返回了一些业务错误就应该打印出来,比如:

  • 用户账号密码错误
  • 用户短信验证码错误

因为是由用户自己的输入错误导致,并不需要我们去做处理。
我们真正需要关心的是程序出现BUG的时候导致的错误
业务逻辑正常的错误我们实际上没有必要用 error 级别的日志去输出,过多的错误噪音反而掩盖了真实问题的排查。

错误的兜底

通过上面我们已经能够在实际项目中准确打印出错误来帮助我们排查问题,但是很多时候我们希望程序能够“自适应”的去消解错误。

最常见的是获取缓存失败后,我们应该去回源数据库。

func GerUser() (*User, error) {
 user, err := getUserFromCache()
 if err == nil {
  return user, nil
 }

 user, err = getUserFromDB()
 if err != nil {
  return nil, err
 }
 return user, nil
}

或者是在事务失败之后,我们要发起补偿机制,比如订单完成后,希望给用户发送站内消息。

我们可能会同步发送失败,但是这个场景的实时性要求并不是特别高,我们可以通过异步重新尝试站内信的发送。

func CompleteOrder(orderID string) error {
 // 完成订单的其他逻辑...
 message := Message{}
 err := sendUserMessage(message)
 if err != nil {
  asyncRetrySendUserMessage(message)
 }
 return nil
}

有意忽视错误

如果一个API 能不抛出错误,调用方就不用花精力去处理错误,所以如果产生了错误但是不需要调用方做额外的处理,那我们就没必要抛出错误,只要当做代码是正常执行即可。

这个就是“空对象模式”,我们本来应该返回错误或者的nil对象,但是为了免去调用方的错误处理,我们可以返回空结构体,免去调用方错误处理的逻辑。

我们在使用事件处理器的时候,如果有不存在的事件,就可以用“空对象模式”

比如通知系统中,我们可能不希望发出实际的通知,此时可以使用空对象来避免通知逻辑中的nil判断。

// 定义Notifier接口
type Notifier interface {
    Notify(message string)
}

// 具体的EmailNotifier实现
type EmailNotifier struct{}

func (n *EmailNotifier) Notify(message string) {
    fmt.Printf("Sending email notification: %s\n", message)
}

// 空通知实现
type NullNotifier struct{}

func (n *NullNotifier) Notify(message string) {
    // 空实现,什么都不做
}

如果方法返回了错误,但是我们作为调用方不想做任何处理,那么最好的方式就是通过 _ 接收完错误,这样后面加入的开发者就不会疑惑是忘记处理错误还是有意忽视错误

func f() {
  // ...
  _ = notify()
}

func notify() error {
  // ...
}

封装自定义错误

透明错误能降低错误处理和错误值构造之间的耦合,但是就不能很好的利用错误进行逻辑的处理。

根据错误进行逻辑处理会让错误成为 API 的一部分。

我们需要根据错误给上层不一样的错误展示。

在Go 1.13 后也尽量使用 errors.Is 对错误进行判断。

错误类型则可以通过 errors.As 进行判断,但是这样的话发布出去的API 仍然需要仔细的对错误进行维护。

那么我们有没有什么手段可以降低这种错误处理方式带来的耦合呢?

可以通过统一提取错误特征,然后调用方将错误转换成统一的接口进行判断, net 包的错误就是这么处理的。

type Error interface {
 error
 Timeout() bool // Is the error a timeout?
}

然后net.OpError 实现对应的 Timeout 方法来判断是否是超时,进行特定的业务逻辑处理。

错误的抽象层级

避免抛出抽象级别高于当前模块的错误,比如说我们在 dao 层取数据,如果数据库找不到记录报错,可以报出错误 RecordNotFound 的错误,但是如果为了上层不用转换,在 dao 层直接抛出 APIError 就不合适了

同样的也要包装将低级别的抽象错误包装成符合当前层级的错误,在上层做了包装后,如果底层需要对错误进行改变,也不会对上层有什么影响。

比如用户登录最开始是使用 mysql 作为存储,无法匹配上则是记录未找到。

后续用 redis 匹配用户,匹配不上则是缓存未命中的错误,这个时候我们并不希望上层会感受到底层存储的不同,而是统一报错为用户不存在即可。

减少 err ≠ nil

if err != nil 的消除可以通过顶层设计来实现,有些错误处理可以在底层就进行封装,不需要透露给上层。

减少函数中的圈复杂度,可以减少 if err != nil 的重复检查次数。比如函数逻辑的封装,给外层暴露的只需要处理一次 err .

func CreateUser(user *User) error {
 // 验证的话只需要抛出一个错误,而不会平铺出来
 if err := ValidateUser(user); err != nil {
  return err
 }
}

内置 error 的状态,将错误封装在结构体内,如果逻辑出现错误,则不做任何操作,等到最后返回的时候再一起返回,外面统一处理,避免了在业务逻辑里面插入很多的 if err != nil

我们以数据复制任务为例,传入数据源的配置和写入目标源的配置,然后进行数据的复制。

type CopyDataJob struct {
 source      *DataSourceConfig
 destination *DataSourceConfig

 err error
}

func (job *CopyDataJob) newSrc() {
 if job.err != nil {
  return
 }

 if job.source == nil {
  job.err = errors.New("source is nil")
  return
 }

 // 实例化读取数据源
}

func (job *CopyDataJob) newDst() {
 if job.err != nil {
  return
 }

 if job.destination == nil {
  job.err = errors.New("destination is nil")
  return
 }

 // 实例化写入数据源
}

func (job *CopyDataJob) copy() {
 if job.err != nil {
  return
 }

 // 复制数据 ...
}

func (job *CopyDataJob) Run() error {
 job.newSrc()
 job.newDst()

 job.copy()

 return job.err
}

可以看到如果一旦出现错误,虽然 Run 里面的步骤都会正常跑,但是都是判断完 err 之后立即返回,然后在最后才通过 job.err 返回错误给外部。

这种方式虽然能在主逻辑的时候减少 err != nil ,但是实际上只是打散了,并没有真正意义上的减少,所以在实际开发中我也很少使用这种技巧。

关于我
loading