Golang理解-错误处理策略

GO中错误处理理念

在Go中有一部分函数总是能成功的运行。比如strings.Contains和strconv.FormatBool函数,对各种可能的输入都做了良好的处理,使得运行时几乎不会失败,除非遇到灾难性的、不可预料的情况,比如运行时的内存溢出。导致这种错误的原因很复杂,难以处理,从错误中恢复的可能性也很低。

对于那些将运行失败看作是预期结果的函数,它们会返回一个额外的返回值,通常是最后一个,来传递错误信息。如果导致失败的原因只有一个,额外的返回值可以是一个布尔值,通常被命名为ok

通常,导致失败的原因不止一种,尤其是对I/O操作而言,用户需要了解更多的错误信息。因此,额外的返回值不再是简单的布尔类型,而是error类型

在Go中,函数运行失败时会返回错误信息,这些错误信息被认为是一种预期的值而非异常(exception),这使得Go有别于那些将函数运行失败看作是异常的语言。

虽然Go有各种异常机制,但这些异常机制仅被使用在处理那些未被预料到的错误,即bug而不是那些在健壮程序中应该被避免的程序错误

Go这样设计的原因是由于对于某个应该在控制流程中处理的错误而言,将这个错误以异常的形式抛出会混乱对错误的描述,这通常会导致一些糟糕的后果。当某个程序错误被当作异常处理后,这个错误会将堆栈根据信息返回给终端用户,这些信息复杂且无用,无法帮助定位错误。

Go使用控制流机制(如if和return)处理异常,这使得编码人员能更多的关注错误处理

Go处理错误的策略

当一次函数调用返回错误时,调用者有应该选择何时的方式处理错误。根据情况的不同,有很多处理方式,通常有以下5种方式:

1 传播错误

传播错误意味着:函数中某个子程序的失败,会变成该函数的失败。

很多时候我们需要子程序的错误信息来定位问题,或者说用来构造外层函数最终的错误信息;子程序需要将自己的错误传播出来,没上级程序获取;一般而言,被调函数f(x)会将调用信息和参数信息作为发生错误时的上下文放在错误信息中并返回给调用者,调用者需要添加一些错误信息中不包含的信息

通常使用fmt.Errorf函数来构建错误,它使用fmt.Sprintf格式化错误信息并返回。我们使用该函数前缀添加额外的上下文信息到原始错误信息。当错误最终由main函数处理时,错误信息应提供清晰的从原因到后果的因果链。

注意点:

  1. 由于错误信息经常是以链式组合在一起的,所以错误信息中应避免大写和换行符
  2. 编写错误信息时,我们要确保错误信息对问题细节的描述是详尽的。
  3. 要注意错误信息表达的一致性,即相同的函数或同包内的同一组函数返回的错误在构成和处理方式上是相似的。

2 尝试重试

我们开发的程序,不是所有的地方都可控的。有些错误的发生是偶然性的,或者说是不可预知的问题导致的。

那么在这种情况下,最好的做法就是选择重新尝试失败的操作。在重试时,我们需要限制重试的时间间隔或重试的次数,防止无限制重试;

下面应用go语言圣经种的例子:

// WaitForServer attempts to contact the server of a URL.
// It tries for one minute using exponential back-off.
// It reports an error if all attempts fail.
func WaitForServer(url string) error {
    const timeout = 1 * time.Minute
    deadline := time.Now().Add(timeout)
    for tries := 0; time.Now().Before(deadline); tries++ {
        _, err := http.Head(url)
        if err == nil {
            return nil // success
        }
        log.Printf("server not responding (%s);retrying…", err)
        time.Sleep(time.Second << uint(tries)) // exponential back-off
    }
    return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}

3 输出错误信息并终止程序

这种策略通常用在:如果错误发生后,程序无法继续运行,我们就可以采用这种方式,返回错误并终止程序,防止带来更大的破坏;

当然了,并不是所有的地方都能这么干。这种方式只应该在main中执行。对于库函数,我们应当采用第一种方式,将错误向上传播,除非该错误意味着程序内部包含不一致性,即遇到了bug,才能在库函数中结束程序。

// (In function main.)
if err := WaitForServer(url); err != nil {
    fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
    os.Exit(1)
}

log包提供了一些输出信息的函数,这个不fmt输出的信息多;我们可以设置log的前缀信息屏蔽时间信息,一般而言,前缀信息会被设置成命令名。

log.SetPrefix("wait: ")
log.SetFlags(0)

4 输出错误但不终止程序

有时,我们只需要输出错误信息就足够了,不需要中断程序的运行。我们可以通过log包提供函数或者标准错误流输出错误信息。

if err := Ping(); err != nil {
    log.Printf("ping failed: %v; networking disabled",err)
}
if err := Ping(); err != nil {
    fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)
}

5 忽略错误

dir, err := ioutil.TempDir("", "scratch")
if err != nil {
    return fmt.Errorf("failed to create temp dir: %v",err)
}
// ...use temp dir…
os.RemoveAll(dir) // ignore errors; $TMPDIR is cleaned periodically

尽管os.RemoveAll会失败,但上面的例子并没有做错误处理。这是因为操作系统会定期的清理临时目录。正因如此,虽然程序没有处理错误,但程序的逻辑不会因此受到影响。我们应该在每次函数调用后,都养成考虑错误处理的习惯,当你决定忽略某个错误时,你应该在清晰的记录下你的意图。

在Go中,错误处理有一套独特的编码风格。

  1. 检查某个子函数是否失败后,我们通常将处理失败的逻辑代码放在处理成功的代码之前。
  2. 如果某个错误会导致函数返回,那么成功时的逻辑代码不应放在else语句块中,而应直接放在函数体中。
  3. Go中大部分函数的代码结构几乎相同,首先是一系列的初始检查,防止错误发生,之后是函数的实际逻辑。

例子:文件结尾错误

函数经常会返回多种错误,这对终端用户来说可能会很有趣,但对程序而言,这使得情况变得复杂。

很多时候,程序必须根据错误类型,作出不同的响应。

让我们考虑这样一个例子:从文件中读取n个字节。如果n等于文件的长度,读取过程的任何错误都表示失败。如果n小于文件的长度,调用者会重复的读取固定大小的数据直到文件结束。这会导致调用者必须分别处理由文件结束引起的各种错误。基于这样的原因,io包保证任何由文件结束引起的读取失败都返回同一个错误——io.EOF,该错误在io包中定义:

package io

import "errors"

// EOF is the error returned by Read when no more input is available.
var EOF = errors.New("EOF")

调用者只需通过简单的比较,就可以检测出这个错误

因为文件结束这种错误不需要更多的描述,所以io.EOF有固定的错误信息——“EOF”。

对于其他错误,我们可能需要在错误信息中描述错误的类型和数量,这使得我们不能像io.EOF一样采用固定的错误信息

in := bufio.NewReader(os.Stdin)
for {
    r, _, err := in.ReadRune()
    if err == io.EOF {
        break // finished reading
    }
    if err != nil {
        return fmt.Errorf("read failed:%v", err)
    }
    // ...use r…
}

参考资料
Go语言圣经
Golang error 的突围

posted @ 2019-09-06 19:06  梧桐花落  阅读(273)  评论(0编辑  收藏  举报