Go语言的错误处理
洪笳淏 Lv4

Error vs Exception

先看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"errors"
"fmt"
)

// Create a named type for our new error type
type errorString string

// Implement the error interface.
func (e errorString) Error() string {
return string(e)
}

// New creates interface values of type error.
func New(text string) error {
return errorString(text)
}

var ErrNamedType = New("EOF")
var ErrStructType = errors.New("EOF")

func main() {
if ErrNamedType == New("EOF") { // true
fmt. Println("Named Type Error")
}
if ErrStructType == errors.New("EOF") { // false
fmt.Println("Struct Type Error")
}
}

  ErrNamedType 是我们自己定义的Error 方法,它是一个string 类型的变量(错误描述),只要错误描述相同,那么两个Error是一样的。ErrStructType是通过go标准库定义的Error 类型,它是指向string 类型的错误描述的指针,即使错误描述一样,也会因为指向其地址的指针不同而不同。

各语言的演进历史

  • C

  单返回值,一般通过传递指针作为入参,返回值为 int 表示成功还是失败。

  • C++

  引入了exception,但是无法知道被调用方会城出什么异常。

  • Java

  引入了checked exception,方法的所有者必须申明,调用者必须处理。在启动时拋出大量的异常是司空见惯的事情,并在它们的调用堆栈中尽职地记录下来。Java 异常不再是异常,而是变得司空见惯了。它们从良性到灾难性都有使用,异常的严重性由两数的调用者来区分。

  • Go

  Go 的处理异常逻辑是不引入 exception,支持多参数返回,所以很容易的在函数签名中带上实现了 error interface 的对象,交由调用者来判定。如果一个西数返回了(value,error),你不能对这个 value 做任何假设,必须先判定 error。唯一可以忽路error 的是,如果你连 value 也不关心。Go 中有 panic 的机制,如果你认为和其他语言的 exception 一样,那你就错了。当我们拋出异常的时候,相当于你把 exception 扔给了调用者来处理。比如,你在C++ 中,把string 转为 int, 如果转换失败,拋出异常。或者在 java 中转换 string 为 date 失败时,拋出异常。Go panic 意味着 fatal error(就是挂了)。不能假设调用者来解决 panic,意味着代码不能继续运行。

  使用多个返回值和一个简单的约定,Go解决了让程序员知道什么时候出了问题,并为真正的异常情况保留了 panic。可以更好的分别什么时候是良性异常,什么时候是真正的错误。通过 panic + recover 还可以为代码兜底,放弃掉这个request 进而保证别的request 的正常处理。

  对于真正意外的情况,那些表示不可恢复的程序错误,例如索引l越界、不可恢复的环境问题、栈溢出,我们才使用 panic。对于其他的错误情况,我们应该是期望使用 error 来进行判定。下面是Go使用这种机制的理由:

  1. 简单
  2. 考虑失败,而不是成功(plan for failure, not success)
  3. 没有隐藏的控制流
  4. 完全交给你来控制 Error
  5. Error are values

Sentinel Error

  预定义的特定错误,我们叫做 sentinel error,这个名字来源于计算机编程中使用一个特定的值来表示不可能进行进一步处理的做法。所以对于Go,我们使用特定的值来表示错误。

1
2
3
4
5
if err == ErrSomething {...}


type Error string
func (e Error) Error() string { return string(e) }

  使用 sentinel值是最不灵活的错误处理策略,因为调用方法必须使用 == 将结果与预先声明的值进行比较。当你想要提供更多的上下文,这就出现了一个问题,因为返回一个不通的错误将破坏相等性检查。

  甚至是一些有意义的 fmt.Errof携带一些上下文,也会破坏调用者的 ==,将调用者被迫查看error.Error()方法的输出,一查看它是否与特定的字符串匹配。

  • 不依赖 error.Error() 的输出

  不应该依赖检测 error.Error() 的输出,Error 方法存在于 error 接口主要用于方便程序员使用而不是程序(编写测试可能会依赖这个返回)。这个输出的字符串用于记录日志、输出到 stdout。

  • Sentinel errors 成为你 API 公共部分

  如果你的公共函数或方法返回一个特定的错误,那么该值必须是公共的,当然要有文档记录,这会增加API的表面积。

  如果 API 定义了一个返回特定错误的 interface,则该接口的所有实现豆浆杯限制为仅返回该错误,即使它们可以提供更具描述性的错误。

  比如 io.Reader。像 io.Copy 这类函数需要 reader 的实现者返回 io.EOF 来告诉调用者没有更多的数据了,但这又不是错误。

  • Sentinel errors 在两个包之间创建了依赖

  sentinel errors最糟糕的问题是它在两个包之间床架了源代码依赖关系。例如,检查错误是否等于 io.EOF,你的代码必须导入 io 包。这个特定的例子听起来并不是十分糟糕,因为它非常常见,但是想想一下,当项目中的许多包到处错误值时,存在耦合,项目中的其他包必须注入这些错误值才能检查特定的错误条件。

  • 结论:尽可能避免 Sentinel errors

  避免在编写代码时使用 Sentinel errors。在标准库中有一些使用它们的情况,但这不是好的模仿对象。

Error Types

Error type 是实现了 error 接口的自定义类型。例如 MyError 类型记录了文件和行号以展示发生了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
type MyError struct {
Msg string
File string
Line int
}

func (e *MyError) Error() string {
return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Msg)
}

func test() error {
return &MyError{"Something happened", "server.go", 42}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// main.go

func main() {
err := test()
switch err := err.(type) {
case nil:
// call succeeded, nothing to do
case *MyError:
fmt.Println("error occurred on line", err.Line)
default:
// unknown error
}
}

  与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。一个不错的例子就是 os.PathError 它提供了底层执行了什么操作、那个路径出了什么问题。

1
2
3
4
5
6
7
type PathError struct {
Op string
Path string
Err error
}

func (e *PathError) Error() string

  调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

  结论是尽量避免使用 error types,虽然错误类型比 sentinel errors 更好,因为它们可以捕获关于错误的更多上下文,但是 error types 共享 error values 许多相同的问题。因此,建议避免错误类型,或者至少避免它们成为公共 API 的一部分。

Opaque errors

  这是一种最灵活的错误处理策略,因为它要求代码和调用者之间的耦合最少。我们将这种风格成为不透明的错误处理,因为虽然知道发生了错误,但没有能力看到错误的内部。作为调用者,关于操作的结果,你所知道的就是它起作用了,或者没有起作用(成功还是失败)。

  这就是不透明错误处理的全部功能-只需返回错误而不假设其内容。

1
2
3
4
5
6
7
8
9
import "github.com/quux/bar"

func fn() error {
x, err := bar. Foo
if err != nil f
return err
}
// use x
}
  • Assert errors for behaviour, not type

  在少数情況下,这种二分错误处理方法是不够的。例如,与进程外的世界进行交互(如网络活动),需要调用方调查错误的性质,以确定重试该操作是否合理。在这种情況下,我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。考虑这个例子:

1
2
3
4
5
6
7
8
9
type temporary interface {
Temporary()
}

// IsTemporary returns true if err is temporary
func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}

这里的关键是,这个逻辑可以在不导入定义错误的包或者实际上不了解 err 的底层类型的情况下实现——只对它的行为感兴趣。

Handling Error

  无错误的正常流程代码,将成为一条直线,而不是缩紧的代码。

1
2
3
4
5
6
7
8
9
10
11
f, err := os.Open(path)
if err != nil {
// handle error
}
// do stuff

f, err := os.Open(path)
if err == nil {
// do stuff
}
// handle error

  我们看一下net/http 标准包里对 WriteResponse 的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Header struct {
Key, Value string
}

type Status struct {
Code int
Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body o.Reader) error {
-, err := fmt.Fprintf(w,"HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
if err != nil {
return err
}

for _, h := range headers {
_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}

if _, err := fmt.Fprint(w "\r\n"); err != nil {
return err
}

_, err = io.Copy(w, body)
return err
}

这种方式写了大量的 if err != nil,如果采用下面这种方式,可以避免出现大量的重复代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type errWriter struct {
io.Writer
err error
}

func (e *errWriter) Write(buf []byte) (int, error) {
if e.err != nil {
return 0, e.err
}

var n int
n, e.err = e.Writer.Write(buf)
return n, nil
}

func WriteResponse(wio.Writer, st Status, headers []Header, body io.Reader) error {
ew := &errWriter{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

for _, h := range headers {
fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
}

fmt.Fprint(ew, "\r\n")
io.Copy(ew, body)

return ew.err
}

我们实际上在封装的 errWriter 中处理掉了

Wrap erros

  没有生成错误的 file:line 信息。没有导致错误的调用堆栈的堆栈跟踪。这段代码的作者将被迫进行长时间的代码分割,以发现是哪个代码路径触发了文件未找到错误。

1
2
3
4
5
6
7
func AuthenticateRequest (r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed: %v", err)
}
return nil
}

  但是正如我们前面看到的,这种模式与 sentinel errors 或 type assertions 的使用不兼容,因为将错误值转换为字符串,将其与另一个字符串合并,然后将其转换回 fmt.Errorf 破坏了原始的错误,导致等值判定失败。

  You should only handle errors once. Handling an error means inspecting the error value and making a single decision.

我们经常发现类似的代码,在错误处理中,带了两个任务:记录日志井且再次返回错误。

1
2
3
4
5
6
7
8
func WriteAll(w i0.Writer, buf []byte) error {
-, err : = w.Write(buf)
if err != nil {
log.Println("unable to write:", err) // annotated error goes to log file
return err // unannotated error returned to caller
}
return nil
}

  在下面这个例子中,如果在 w.Write 过程中发生了一个错误,那么一行代码将被写入日志文件中,记录错误发生的文件和行,并且错误也会返回给调用者,调用者可能会记录并返回它,一直返回到程序的顶部。

1
2
3
4
5
6
7
8
9
10
11
12
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log. Printf("could not marshal config: %v", err)
return err
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}

  Go 中的错误处理契约规定,在出现错误的情况下,不能对其他返回值的内容做出任何假设。由于 JSON 序列化失败,buf 的内容是未知的,可能它不包含任何内容,但更糟糕的是,他可能包含一个半写的 JSON 片段。

  由于程序员在检查并记录错误后忘记 return,损坏的缓冲区将被传递给 WriteAll,这可能会成功,因此配置文件将被错误地写入。但是,该函数返回的结果是正确的。

  日志记录与错误无关且对调试没有帮助的信息应被视为噪音,应予以质疑。记录的原因是因为某些东西失败了,而日志包含了答案。

  • 错误要被日志记录。
  • 应用程序处理错误,保证100%完整性。
  • 之后不再报告当前错误。

使用 github.com/pkg/errors 库

avatar

avatar

  通过使用 pkg/errors 包,可以想错误值添加上下文,这种方式既可以由人也可以由机器检查。

1
2
3
4
5
6
7
8
9
10
11
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
// annotated error goes to log file
log.Println("unable to write:", err)

// unannotated error returned to caller
return err
}
return nil
}

上面这种方式既打印了日志,还抛出了错误,违背了只处理一次的行为,使用下面的代码可以完美解决。

1
2
3
4
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
return errors.Wrap(err, "write failed")
}
  • 在你的业务代码中,使用 errors.New 或者 errors.Errorf 返回错误,它们同errors.Wrap 一样,都会返回错误的堆栈信息。
1
2
3
4
5
6
func parseArgs(args []string) error {
if len(args) < 3 {
return errors.Errorf("not enough arguments, expected at least 3")
}
// ...
}
  • 如果调用其他包内的函数,通常简单的直接返回
1
2
3
if err != nil {
return err
}
  • 如果和其他库进行协作,考虑使用 errors.Wrap 或者 errors.Wrapf 保存堆栈信息。同样适用于和标准库协作的时候。
1
2
3
4
f, err := os.Open(path)
if err != nil {
return errors.Wrapf(err, "failed to open %q", path)
}
  • 直接返回错误,而不是每个错误产生的地方到处打印日志
  • 在程序的顶部或者是工作的 goroutine 顶部(请求入口),使用 %+v 把堆栈详情记录
1
2
3
4
5
6
7
func main() {
err := app.Run()
if err != nil {
fmt.Printf("FATAL: %+v\n", err)
os.Exit(1)
}
}
  • 使用 errors.Cause 获取root error, 在进行和 sentinel error 判定。还可以对其进行断言。
1
2
3
4
5
6
switch err := errors.Cause(err).(type) {
case *MyError:
// handle specifically
default:
// unknown error
}

总结:

  • Packages that are reusable across many projects only return root error values.

选择 wrap error 是只有 applications 可以选择应用的策略。具有最高可重用性的包只能返回根错误值。此机制与 Go 标准库中使用的相同。

  • If the error is not going to be handled, wrap and return up the call stack.

这是关于函数/方法调用返回的每个错误的基本问题。如果函数/方法不打算处理错误,那么用足够的上下文 wrap errors 并将其返回到调用堆栈中。例如,额外的上下文可以是使用的输入参数或失败的查询语句。确定您记录的上下文是足够多还是大多的一个好方法是检查日志并验证它们在开发期间是否为您工作。

  • Once an error is handled, it is not allowed to be passed up the call stack any longer.

一旦确定函数/方法将处理错误,错误就不再是错误。如果函数/方法仍然需要发出返回,则它不能返
错误值。它应该只返回零(比如降级处理中,你返回了降级数据,然后需要 return nil)。

  • Post title:Go语言的错误处理
  • Post author:洪笳淏
  • Create time:2021-12-05 01:56:00
  • Post link:https://jiahaohong1997.github.io/2021/12/05/Go语言的错误处理/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.
 Comments