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 块中,而是在外层的作用域中。
一般来说,我们会在函数开头便进行一连串的检查用来返回错误,在之后再进行具体的函数体逻辑。