Go 中的异常/错误处理

即使是高质量的代码,也不能保证一定能够成功返回,因为有些因素并不受程序设计者掌控。例如任何 I/O 操作可能产生错误,事实上,这些地方便是程序员最需要关注的。

因此错误处理是包的 API 设计或应用程序用户接口的重要部分,发生错误只是许多预料行为中的一种,这就是 Go 语言处理错误的方法。

错误返回策略

当函数调用发生错误时,我们习惯返回一个附加的结果作为错误值,且一般作为最后一个返回结果。

1.如果错误只有一种情况,那么结果通常为「布尔类型」。例如下面的查询例子,只有在不存在对应键值的适合才返回错误:

value, ok := cache.Lookup(key)
if !ok {
    // chche[key] 不存在
}

2.但更多时候,尤其对于 I/O 操作,错误的原因可能多种多样,这时调用者需要一些详细信息,这种情况下,错误的类型往往为「error」。

reso, err := http.Get(url)
if err != nil {
    return nil, err
}

和许多其他语言不同,Go 语言通过使用普通的值而非异常来报告错误;Go 语言中的异常通常只是针对程序 bug 导致的预料外错误,而不应作为常规的错误处理方法出现在程序中。

如果用异常来报告错误,会出现下面这种情况:

  • 异常会陷入带有错误信息的控制流去处理它,通常导致预期外的结果:错误会以难以理解的栈跟踪信息报告给最终用户,这些信息大多关于程序结构方面而不是简单明了的错误信息

因此,Go 使用通常的控制流机制(如 if 和 return)来应对错误,这种方式对错误处理逻辑方面有更高的要求。

错误处理策略

当函数调用返回一个错误时,调用者应该检查是否存在错误并采取合适的处理应对,下面我们讲讲 5 个常见的处理方式:

将错误传递下去

将错误传递之后,在子例程中发生的错误会变成主调例程的错误;这时我们希望传递的错误能够返回一个可读的错误描述

error 中信息满足要求

例如我们调用http.Get失败,我们可以直接返回这个 HTTP 错误:

reso, err := http.Get(url)
if err != nil {
    return nil, err
}

它包含了失败的 url,也就是这里我们需要的信息。

error 中信息不足

但有时,error 中包含的信息并不清晰,例如我们对一个 response 的响应体调用http.Parse失败,这种情况下的 err 缺失两个关键信息:解析器的出错信息与被解析文档的 url。这种情况下我们会为它构建一个新的错误信息:

doc, err := html.Parse(resp.Body)\
resp.Body.Close()
if err != nil {
    return nil, fmt.Errorf("parsing %s as HTML: %v", usr, err)
}

fmt.Errorf会使用fmt.Sprintf格式化一条错误信息,并返回一个新的错误值。

这样,我们便为原始的错误信息添加了额外的上下文信息,建立了一个可读的错误描述。当错误最终返回程序的 main 函数处理时,它应当提供了一个从最根本问题到总体故障的清晰因果链。

例如 NASA 的事故调查例子:

genesis: crashed: no parachute: G-switch failed: bad relay orientation

构建错误信息的要求

因为错误信息频繁地串联,因此消息字符串首字母应该小写,且避免换行。这样可能会让错误信息很长,但我们可以使用grep这样的工具找到需要的信息。

一般地,一个函数f(x)的调用只报告函数的行为f参数值x,因为它们与错误上下文相关;再由「调用者」进一步添加信息。

给定失败操作一定重试次数和限定时间,超出后再报错

有些操作我们应该对它的失败有所容忍,它可能在短暂时间后便能成功:

// 尝试连接 URL 对应服务器
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 // 成功
        }
        log.Printf("server not responding (%s); retrying...", err)
        time.Sleep(time.Second << uint(tries)) // 使用指数退避策略进行重试
    }
    return fmt.Errorf("server %s failed to respond after %s", url, timeout) // 失败
}

输出错误并停止程序

一般来说,这个操作是留至主程序部分来处理的,其余函数应当将错误传递给调用者,除非这个错误是一个内部一致性错误(也就是该函数存在 bug)。

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

一个更方便的方式是调用log.Fatalf实现一样的效果,作为一个日志函数,它能够默认将时间和日期都作为前缀加到错误消息前面:

if err := WaitForServer(url); err != nil {
    log.Fatalf("Site is down: %v\n", err)
}

这样打印得出的格式有助于长期运行的服务器,它能够使我们方便的对错误定位。

我们还可以自定义命令名称作为log包的前缀,并将日期和时间略去:

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

仅记录错误信息然后程序继续运行

有时错误并不会对程序当前运行产生很大的影响,我们可以将错误信息先进行记录待后续处理。

我们可以用之前提到的log包来增加日志的常用前缀:

if err := Ping(); err != nil {
    // 所有 log 函数都会为缺少换行符的日志填充换行符
    log.Printf("ping failed: %v; networking disabled", err)
}

或是直接输出到标准错误流:

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

直接忽略整个错误日志

在一些罕见的情况下,错误日志并没有意义,这是我们可以直接安全地忽略掉整个日志:

// 创建临时目录
dir, err := iout.TempDir("", "scratch")
if err != nil {
    return fmt.Errorf("failed to create temp dir: %v", err)
}

// 使用临时目录
...

os.RemoveAll(dir) // 忽略这个语句可能产生的错误,$TMPDIR 会被周期性删除

调用os.Remove可能会失败,但操作系统自己会周期性的删除这个目录,也就是这个语句的失败与否并无大碍,因此我们忽略了这个错误。

在上例中,我们有意地抛弃了错误,但程序的逻辑看上去就像我们忘记处理了一样,因此如果我们需要有意地忽略一个错误,一定要在注释中清晰地写明理由。

Go 语言中,对语句进行错误检查后;如果检测到的失败导致函数返回,成功的逻辑一般不会放在 else 块中,而是在外层的作用域中。

一般来说,我们会在函数开头便进行一连串的检查用来返回错误,在之后再进行具体的函数体逻辑。