在该系列中
错误处理的话题足够有趣,为了保持零件的数量,我决定将其分成几个部分:
- 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) }
日志
提示:在堆栈深处处理错误,在顶部记录。
这种方法有助于防止重复的日志。使用包装技术来增加上下文和调试时有用的信息。流行的软件包https://pkg.go.dev/github.com/pkg/errors 也允许在日志中保留堆栈跟踪。
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下游传递给每一个方法,但我不认为这样做的好处会超过污染造成的不清晰。