在该系列中
错误处理的话题足够有趣,为了保持零件的数量,我决定将其分成几个部分:
- error处理
- error包装
错误处理
我如何在Go中尝试捕捉异常?
在go中,error 是数值。
这意味着,error 不是"抛出",而是一个返回值,就像其他的值一样。例如,要打开一个文件:
f, err := os.Open("config.yml")
Open
函数返回两个值:文件描述符和 error。在成功时,错误为nil
,否则它有一些值。
if err != nil {
// ... do something
return err
}
通常的错误处理模式是检查错误是否为空。你可能会说,这样的明确性伴随着冗长的语言。是的,Golang代码中经常散布着if err != nil
。
哦,拜托,我是不是每次都要检查err !=nil?
在Go的bufio.Scanner
中可以找到一个巧妙的例子,避免不断检查错误是否为nil。
const input = "The quick brown fox jumps over lazy dog."
scanner := bufio.NewScanner(strings.NewReader(input))
scanner.Split(bufio.ScanWords)
count := 0
// We're not checking the error, just iterate
for scanner.Scan() {
count++
}
// See if there was any error in the end
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "reading input:", err)
}
scanner.Scan()
在有匹配的情况下返回true,在没有匹配或有错误的情况下返回false。任何在扫描过程中可能发生的错误都会被记录下来。在扫描循环结束后,将推迟检查:不需要每次迭代都检查。
实际上"error"是什么?
它可以是任何东西。一般来说,它是任何实现了简单的、单一方法的错误接口的类型:
type error interface {
Error() string
}
开箱即用,Go提供了内置的Error.New
构造函数。
errors.New("missing ID parameter")
或更灵活的fmt.Errorf():
return fmt.Errorf("Unsupported message type: %q", msgType)
返回error
这很容易,因为Go允许多个返回值。把错误类型作为最后一个。如果一切顺利,只需返回nil。
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero prohibited")
}
return a/b, nil
}
自定义 error 类型
在绝大多数情况下,你对内部是字符串的错误没有意见。最终,它们只是被记录在某个地方。
如果你需要根据返回的错误做出决定,不要试图解析其字符串值。为了给错误提供更多的背景,你可以创建你自己的类型。
想象一下,你想从HTTP服务器处理函数中返回带有状态码的错误:
type HttpError struct {
StatusCode int
Err error
}
func (h HttpError) Error() string {
return fmt.Sprintf("HTTP error: %v (%d)", h.Err, h.StatusCode)
}
error 是值,所以要像其他结构一样创建和返回:
func handlerFunc(r http.Request) error {
// ...
// something went wrong:
return HttpError{
StatusCode: 404,
Err: errors.New("product not found"),
}
// ...
}
然后使用类型断言将一般的 error 转换为我们特定的HttpError
:
he, ok := err.(HttpError)
if ok {
log.Printf("HTTP error with status = %d", he.StatusCode)
}
或者使用推荐的、更安全的方式:
var he HttpError
if errors.As(err, &he) {
log.Printf("HTTP error with status = %d", he.StatusCode)
}
日志
提示:在堆栈深处处理错误,在顶部记录。
这种方法有助于防止重复的日志。使用包装技术来增加上下文和调试时有用的信息。流行的软件包pkg.go.dev/github.com/… 也允许在日志中保留堆栈跟踪。
error 包装
让我们考虑一个例子:main()
调用readConfig()
,后者又调用readFromFile()
。这是一个简化的例子,说明代码中出现的模式:嵌套调用和错误发生在低层。
让我们使用谷歌错误包中的errors.Wrap()
。
func readFromFile() (string, error) {
data, err := os.ReadFile("wrong file name")
if err != nil {
return "", errors.Wrap(err, "readFromFile")
}
return string(data), nil
}
func readConfig() (string, error) {
data, err := readFromFile()
if err != nil {
return "", errors.Wrap(err, "readConfig")
}
// ...
return data, nil
}
func main() {
conf, err := readConfig()
if err != nil {
log.Printf("Cannot read: %v", err)
}
}
output:
2022/03/16 00:37:54 Cannot read: readConfig: readFromFile: open wrong file name: no such file or directory
在每个包裹着错误的层次,你可以添加一条信息。一个原因,也可能只是一个函数名称或其他抽象层次的标识。
检索根本原因
如果你需要跟踪的最底层错误,使用
errors.Cause(err)
来检索堆栈中的第一个错误。该函数是安全的,所以当提供一个没有包装的错误时,它将返回错误。
内置包装
第二种错误包装的方式,允许有一点灵活性,就是使用:
err = fmt.Errorf("read file: %w", err)
注意%w
格式指定符,它用错误的文本值来代替。请记住,这种形式不保留堆栈痕迹。
stack trace
使用errors.Wrap()
函数包装的错误会保留调用堆栈。要打印它,请使用%+v
格式指定符:
log.Printf("Cannot read: %+v", err)
output:
2022/07/22 18:51:54 Cannot read: open wrong file name: no such file or directory
readFromFile
awesomeProject/learnWrapping.readFromFile
/Code/learn/go/awesomeProject/learnWrapping/wrapping.go:13
awesomeProject/learnWrapping.readConfig
/Code/learn/go/awesomeProject/learnWrapping/wrapping.go:19
awesomeProject/learnWrapping.Main
/Code/learn/go/awesomeProject/learnWrapping/wrapping.go:28
main.main
/Users/tomek/Code/learn/go/awesomeProject/main.go:6
堆栈跟踪可以通过另一种有点隐晦的方式来检索。包含堆栈跟踪的错误实现了私有的stackTracer接口(它是私有的,因为名字以小写字母开头)。但是,这是Go,你可以在你的代码中重新声明这个接口:
type stackTracer interface {
StackTrace() errors.StackTrace
}
并单独访问每个堆栈框架:
if sterr, ok := err.(stackTracer); ok {
log.Printf("Stack trace:")
for n, f := range sterr.StackTrace() {
fmt.Printf("%d: %s %n:%d\n", n, f, f, f)
}
}
output:
2022/07/23 15:11:17 Stack trace:
0: wrapping.go readConfig:21
1: wrapping.go Main:32
2: main.go main:6
3: proc.go main:250
4: asm_arm64.s goexit:1259
在堆栈深处处理错误,在顶部记录
包裹是保存上下文信息的有用技术,有助于避免重复记录语句。
让我们重温一下上面的例子:在第四行,用黑体字突出显示,文件系统读取错误被记录下来。然后它又被main()
函数记录下来,导致双重日志。
func readFromFile() (string, error) {
data, err := os.ReadFile("wrong file name")
if err != nil {
log.Printf("readFromFile failed")
return "", errors.Wrap(err, "readFromFile")
}
return string(data), nil
}
func readConfig() (string, error) {
data, err := readFromFile()
if err != nil {
return "", errors.Wrap(err, "readConfig")
}
// ...
return data, nil
}
func main() {
conf, err := readConfig()
if err != nil {
log.Printf("Cannot read: %v", err)
}
}
结果我们会在日志中看到重复的语句:
2022/09/13 14:22:41 Cannot read file: "dummyfile.txt"
2022/09/13 14:22:41 Cannot read: readConfig: readFromFile: open dummyfile.txt: no such file or directory
在上面的例子中,包装好的错误组合提供了足够的上下文来确定错误的原因。在第一行,我们得到了断章取义的声明,这说明了什么。
当然,你可以将Context下游传递给每一个方法,但我不认为这样做的好处会超过污染造成的不清晰。