第31节 程序中的错误处理

❤️💕💕Go语言高级篇章,在此之前建议您先了解基础和进阶篇。Myblog:http://nsddd.topopen in new window

Go语言基础篇open in new window

Go语言100篇进阶open in new window


[TOC]

前言

任何一个稳定的程序中都会有大量的代码在处理错误,所以说,处理错误是程序中一件比较重要的事情。

处理错误最直接的方式是通过 错误码,这也是传统的方式,在过程式语言中通常都是用这样的方式处理错误的。

一般而言,这样的错误处理方式在大多数情况下是没什么问题的。但是也有例外的情况:

int atoi(const char *str)

这个函数是把一个字符串转成整型。但是问题来了,如果一个要传的字符串是非法的(不是数字的格式),如"ABC"或者整型溢出了,那么这个函数应该返回什么呢?出错返回,返回什么数都不合理,因为这会和正常的结果混淆在一起。比如,返回 0,那么会和正常的对 “0” 字符的返回值完全混淆在一起。这样就无法判断出错的情况。你可能会说,是不是要检查一下 errno,按道理说应该是要去检查。

像atoi(), atof(), atol() 或是 atoll() 这样的函数是不会设置 errno的,而且,还说了,如果结果无法计算的话,行为是undefined。所以,后来,libc 又给出了一个新的函数strtol(),这个函数在出错时会设置全局变量errno :

long strtol(const char *restrict str, char **restrict endptr, int base);

于是,我们就可以这样使用:

long val = strtol(in_str, &endptr, 10);  //10的意思是10进制

//如果无法转换
if (endptr == str) {
    fprintf(stderr, "No digits were found\n");
    exit(EXIT_FAILURE);
}

//如果整型溢出了
if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) {
    fprintf(stderr, "ERROR: number out of range for LONG\n");
    exit(EXIT_FAILURE);
 }

//如果是其它错误
if (errno != 0 && val == 0) {
    perror("strtol");
    exit(EXIT_FAILURE);
}

虽然,strtol() 函数解决了 atoi() 函数的问题,但是我们还是能感觉到不是很舒服和自然。

多返回值

于是,有一些语言通过多返回值来解决这个问题,比如 Go 语言。Go 语言的很多函数都会返回 result, err 两个值,于是:

  • 参数上基本上就是入参,而返回接口把结果和错误分离,这样使得函数的接口语义清晰;
  • 而且,Go 语言中的错误参数如果要忽略,需要显式地忽略,用 _ 这样的变量来忽略;
  • 另外,因为返回的 error 是个接口(其中只有一个方法 Error(),返回一个 string ),所以你可以扩展自定义的错误处理。

一个 json 语法错误:

type SyntaxError struct {
    msg    string // description of error
    Offset int64  // error occurred after reading Offset bytes
}

func (e *SyntaxError) Error() string { return e.msg }

在使用上:

if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
        line, col := findLine(f, serr.Offset)
        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
    }
    return err
}

多说一句,如果一个函数返回了多个不同类型的 error,你也可以使用下面这样的方式:


if err != nil {
  switch err.(type) {
    case *json.SyntaxError:
      ...
    case *ZeroDivisionError:
      ...
    case *NullPointerError:
      ...
    default:
      ...
  }
}

但即便像 Go 这样的语言能让错误处理语义更清楚,而且还有可扩展性,也有其问题。如果写过一段时间的 Go 语言,你就会明白其中的痛苦—— if err != nil 这样的语句简直是写到吐,只能在 IDE 中定义一个自动写这段代码的快捷键……而且,正常的逻辑代码会被大量的错误处理打得比较凌乱。

资源清理

程序出错时需要对已分配的一些资源做清理,在传统的玩法下,每一步的错误都要去清理前面已分配好的资源。于是就出现了 goto fail 这样的错误处理模式。

在 Go 语言中,使用defer关键字也可以做到这样的效果

func Close(c io.Closer) {
  err := c.Close()
  if err != nil {
    log.Fatal(err)
  }
}

func main() {
  r, err := Open("a")
  if err != nil {
    log.Fatalf("error opening 'a'\n")
  }
  defer Close(r) // 使用defer关键字在函数退出时关闭文件。

  r, err = Open("b")
  if err != nil {
    log.Fatalf("error opening 'b'\n")
  }
  defer Close(r) // 使用defer关键字在函数退出时关闭文件。
}

异常捕捉处理

上面,我们讲了错误检查和程序出错后对资源的清理这两个事。能把这个事做得比较好的其实是 try - catch - finally 这个编程模式。

Go语言并没有像其他语言那样有try-catch-finally语句。但是可以通过使用内置的panic和recover函数来实现类似的功能。

try {
  ... // 正常的业务代码
} catch (Exception1 e) {
  ... // 处理异常 Exception1 的代码
} catch (Exception2 e) {
  ... // 处理异常 Exception2 的代码
} finally {
  ... // 资源清理的代码
}

Panic函数在出现错误时终止当前函数的执行,并触发错误信息。Recover函数用于恢复程序的执行,并返回错误信息。

package main

func main() {
    defer func() {
        if err := recover(); err != nil {
            // 处理错误信息
            println(err)
        }
    }()
    divide(1, 0)
}

func divide(a, b int) {
    defer func() {
        println("Finally block")
    }()
    if b == 0 {
        panic("division by zero")
    }
    println(a / b)
}

这里的 defer func() 是 Go 语言的一个特性,它的作用是在函数返回前执行一段代码,在这里用来在程序发生 panic 时执行 recover() 函数。

在 divide 函数中,如果 b 等于 0,那么就会触发 panic("division by zero"),程序会终止运行,defer 中的 recover() 会捕获到 panic 信息并进行处理,最后输出 "division by zero"。

错误返回码 vs 异常捕捉

错误分类:

  • 资源的错误。当我们的代码去请求一些资源时导致的错误,比如打开一个没有权限的文件,写文件时出现的写错误,发送文件到网络端发现网络故障的错误,等等。这一类错误属于程序运行环境的问题。对于这类错误,有的我们可以处理,有的我们则无法处理。比如,内存耗尽、栈溢出或是一些程序运行时关键性资源不能满足等等这些情况,我们只能停止运行,甚至退出整个程序。

  • 程序的错误。比如:空指针、非法参数等。这类是我们自己程序的错误,我们要记录下来,写入日志,最好触发监控系统报警。

  • 用户的错误。比如:Bad Request、Bad Format 等这类由用户不合法输入带来的错误。这类错误基本上是在用户的 API 层上出现的问题。比如,解析一个 XML 或 JSON 文件,或是用户输入的字段不合法之类的。

    对于这类问题,我们需要向用户端报错,让用户自己处理修正他们的输入或操作。然后,我们正常执行,但是需要做统计,统计相应的错误率,这样有利于我们改善软件或是侦测是否有恶意的用户请求。

我们可以看到,这三类错误中,有些是我们希望杜绝发生的,比如程序的 Bug,有些则是我们杜绝不了的,比如用户的输入。而对于程序运行环境中的一些错误,则是我们希望可以恢复的。也就是说,我们希望可以通过重试或是妥协的方式来解决这些环境的问题,比如重建网络连接,重新打开一个新的文件。

Go异常捕捉:

package main

import (
    "fmt"
)

func main() {
    defer func() {
        // 捕获异常
        if err := recover(); err != nil {
            fmt.Println("发生异常:", err)
        }
    }()
    // 这里是需要捕捉异常的代码
    panic("这是一个异常")
}

总之,“报错的类型”和 “错误处理”是紧密相关的,错误处理方法多种多样,而且会在不同的层面上处理错误。有些底层错误就需要自己处理掉(比如:底层模块会自动重建网络连接),而有一些错误需要更上层的业务逻辑来处理(比如:重建网络连接不成功后只能让上层业务来处理怎么办?降级使用本地缓存还是直接报错给用户?)。

所以,不同的错误类型再加上不同的错误处理会导致我们代码组织层面上的不同,从而会让我们使用不同的方式。也就是说,使用错误码还是异常捕捉主要还是看我们的错误处理流程以及代码组织怎么写会更清楚。

异步编程世界里的错误处理

在异步编程的世界里,因为被调用的函数是被放到了另外一个线程里运行,这将导致:

  • 无法使用返回码。因为函数在“被”异步运行中,所谓的返回只是把处理权交给下一条指令,而不是把函数运行完的结果返回。所以,函数返回的语义完全变了,返回码也没有用了。
  • 无法使用抛异常的方式。因为除了上述的函数立马返回的原因之外,抛出的异常也在另外一个线程中,不同线程中的栈是完全不一样的,所以主线程的 catch 完全看不到另外一个线程中的异常。

对此,在异步编程的世界里,我们也会有好几种处理错误的方法,最常用的就是 callback 方式。在做异步请求的时候,注册几个 OnSuccess()、 OnFailure() 这样的函数,让在另一个线程中运行的异步代码回调过来。

在 Go 中,通常使用 channels 和 goroutines 来实现异步编程。对于错误处理,可以使用 channels 来传递错误信息,并在主 goroutine 中进行处理。

package main

import (
    "fmt"
)

func asyncFunc(done chan error) {
    // 模拟异步操作
    defer func() {
        if err := recover(); err != nil {
            done <- fmt.Errorf("发生异常: %v", err)
        }
    }()
    // 异步代码
    panic("这是一个异常")
}

func main() {
    done := make(chan error)
    go asyncFunc(done)

    // 等待异步操作完成
    err := <-done
    if err != nil {
        fmt.Println(err)
    }
}

在这个示例中,我们使用了一个 channel 来传递错误信息。在 asyncFunc 函数中,我们使用 defer 和 recover 来捕获异常,并将错误信息传递给 channel。在 main 函数中,我们启动了一个 goroutine 来执行 asyncFunc 函数,并等待异步操作完成。

这种方式在异步编程中非常常用,它可以让我们在主 goroutine 中处理错误,并避免在多个 goroutine 中进行错误处理的复杂性。

Go 语言的 Promise

Go 语言本身并没有内置支持 Promise,但是有很多第三方库可以实现这个功能。Promise 是 JavaScript 中的一种异步编程模式,它可以用来处理异步操作的结果。

Promise 实际上是一个对象,它可以在异步操作完成时触发一个回调函数。它有三种状态:pending,fulfilled 和 rejected。在异步操作开始时,Promise 的状态为 pending,当异步操作成功完成时,状态变为 fulfilled,并执行成功回调函数;当异步操作失败时,状态变为 rejected,并执行失败回调函数。

package main

import (
    "fmt"
    "github.com/tal-tech/go-zero/core/promise"
)

func main() {
    promise.Start(func(resolve promise.ResolveFunc, reject promise.RejectFunc) {
        // 模拟异步操作
        defer func() {
            if err := recover(); err != nil {
                reject(fmt.Errorf("发生异常: %v", err))
            }
        }()
        panic("这是一个异常")
    }).Then(func(value interface{}) {
        fmt.Println("操作成功:", value)
    }, func(err error) {
        fmt.Println("操作失败:", err)
    })
}

📜 对上面的解释:

我们使用了 panic 来模拟一个异常。在异步操作中发生了异常之后,通过 recover 捕获到了这个异常并使用 reject 函数将错误传递出去。

然后我们在 promise.Start 的返回值上调用 Then 方法,传入了两个回调函数,一个是成功回调函数,另一个是失败回调函数。当异步操作成功完成时,会调用第一个回调函数,当异步操作失败时,会调用第二个回调函数。

需要注意的是,在 Go 语言中,通常使用 channel 或者 callback 来实现异步编程,Promise 是一种更加类似于 JavaScript 的异步编程方式。

Promise 不仅可以用来处理单个异步操作的结果,还可以用来处理多个异步操作之间的关系,比如链式调用、并行调用等。还有一些第三方库提供了更丰富的功能,如 async/await 等。

简单的使用:

首先,先声明一个结构体。其中有三个成员:第一个 wg 用于多线程同步;第二个 res 用于存放执行结果;第三个 err 用于存放相关的错误。

type Promise struct {
  wg  sync.WaitGroup
  res string
  err error
}

然后,定义一个初始函数,来初始化 Promise 对象。其中可以看到,需要把一个函数 f() 传进来,然后调用 wg.Add(1) 对 waitGroup 做加一操作,新开一个 Goroutine 通过异步去执行用户传入的函数 f() ,然后记录这个函数的成功或错误,并把 waitGroup 做减一操作。

func NewPromise(f func() (string, error)) *Promise {
  p := &Promise{}
  p.wg.Add(1)
  go func() {
    p.res, p.err = f()
    p.wg.Done()
  }()
  return p
}

然后,我们需要定义 PromiseThen 方法。其中需要传入一个函数,以及一个错误处理的函数。并且调用 wg.Wait() 方法来阻塞(因为之前被wg.Add(1)),一旦上一个方法被调用了 wg.Done(),这个 Then 方法就会被唤醒。

唤醒的第一件事是,检查一下之前的方法有没有错误。如果有,那么就调用错误处理函数。如果之前成功了,就把之前的结果以参数的方式传入到下一个函数中。

func (p *Promise) Then(r func(string), e func(error)) (*Promise){
  go func() {
    p.wg.Wait()
    if p.err != nil {
      e(p.err)
      return 
    }
    r(p.res)
  }()
  return p
}

下面,我们定义一个用于测试的异步方法。这个方法很简单,就是在数数,然后,有一半的几率会出错。

func exampleTicker() (string, error) {
  for i := 0; i < 3; i++ {
    fmt.Println(i)
    <-time.Tick(time.Second * 1)
  }
  
  rand.Seed(time.Now().UTC().UnixNano())
  r:=rand.Intn(100)%2
  fmt.Println(r)
  if  r != 0 {
    return "hello, world", nil
  } else {
    return "", fmt.Errorf("error")
  }
}

下面,我们来看看我们实现的 Go 语言 Promise 是怎么使用的。代码还是比较直观的,我就不做更多的解释了。

func main() {
  doneChan := make(chan int)
  
  var p = NewPromise(exampleTicker)
  p.Then(func(result string) { fmt.Println(result); doneChan <- 1 }, 
      func(err error) { fmt.Println(err); doneChan <-1 })
      
  <-doneChan
}

错误处理的实践

  1. 统一分类的错误字典。无论你是使用错误码还是异常捕捉,都需要认真并统一地做好错误的分类。最好是在一个地方定义相关的错误。比如,HTTP 的 4XX 表示客户端有问题,5XX 则表示服务端有问题。也就是说,你要建立一个错误字典。
  2. 同类错误的定义最好是可以扩展的。这一点非常重要,而对于这一点,通过面向对象的继承或是像 Go 语言那样的接口多态可以很好地做到。这样可以方便地重用已有的代码。
  3. 定义错误的严重程度。比如,Fatal 表示重大错误,Error 表示资源或需求得不到满足,Warning 表示并不一定是个错误但还是需要引起注意,Info 表示不是错误只是一个信息,Debug 表示这是给内部开发人员用于调试程序的。
  4. 错误日志的输出最好使用错误码,而不是错误信息。打印错误日志的时候,应该使用统一的格式。但最好不要用错误信息,而应使用相应的错误码,错误码不一定是数字,也可以是一个能从错误字典里找到的一个唯一的可以让人读懂的关键字。这样,会非常有利于日志分析软件进行自动化监控,而不是要从错误信息中做语义分析。比如:HTTP 的日志中就会有 HTTP 的返回码,如:404。但我更推荐使用像 PageNotFound 这样的标识,这样人和机器都很容易处理。
  5. 忽略错误最好有日志。不然会给维护带来很大的麻烦。
  6. 对于同一个地方不停的报错,最好不要都打到日志里。不然这样会导致其它日志被淹没了,也会导致日志文件太大。最好的实践是,打出一个错误以及出现的次数。
  7. 不要用错误处理逻辑来处理业务逻辑。也就是说,不要使用异常捕捉这样的方式来处理业务逻辑,而是应该用条件判断。如果一个逻辑控制可以用 if - else 清楚地表达,那就不建议使用异常方式处理。异常捕捉是用来处理不期望发生的事情,而错误码则用来处理可能会发生的事。
  8. 对于同类的错误处理,用一样的模式。比如,对于 null 对象的错误,要么都用返回 null,加上条件检查的模式,要么都用抛 NullPointerException 的方式处理。不要混用,这样有助于代码规范。
  9. 尽可能在错误发生的地方处理错误。因为这样会让调用者变得更简单。
  10. 向上尽可能地返回原始的错误。如果一定要把错误返回到更高层去处理,那么,应该返回原始的错误,而不是重新发明一个错误。
  11. 处理错误时,总是要清理已分配的资源。这点非常关键,使用 RAII 技术,或是 try-catch-finally,或是 Go 的 defer 都可以容易地做到。
  12. 不推荐在循环体里处理错误。这里说的是 try-catch,绝大多数的情况你不需要这样做。最好把整个循环体外放在 try 语句块内,而在外面做 catch。
  13. 不要把大量的代码都放在一个 try 语句块内。一个 try 语句块内的语句应该是完成一个简单单一的事情。
  14. 为你的错误定义提供清楚的文档以及每种错误的代码示例。如果你是做 RESTful API 方面的,使用 Swagger 会帮你很容易搞定这个事。
  15. 对于异步的方式,推荐使用 Promise 模式处理错误。对于这一点,JavaScript 中有很好的实践。
  16. 对于分布式的系统,推荐使用 APM 相关的软件。尤其是使用 Zipkin 这样的服务调用跟踪的分析来关联错误。

END 链接