 第8章 错误与恐慌(panic)
第8章 错误与恐慌(panic)
  # 第8章 错误与恐慌(panic)
Go语言的错误处理方式极具争议性。有异常处理(如Java)语言背景的人往往不喜欢它,而有函数返回常规错误值(如C语言)语言背景的人却对此感到得心应手。
我对这两种语言背景都有所了解,我认为错误处理的显式特性会促使你在开发的每一步都考虑到异常情况。错误的生成、传递和处理,与程序正常运行(即未发生错误的情况)一样,都需要遵循相同的规范并仔细审查。
如果你留意的话,会发现我将错误处理分为三个阶段:
- 错误的检测与生成:涉及检测异常情况并记录下来。
- 错误的传递:指允许错误在调用栈中向上传播,还可选择性地添加上下文信息。
- 错误的处理:即实际解决错误,这可能包括终止程序。
在本章中,你将学习以下内容:
- 如何生成错误。
- 如何通过添加上下文信息传递错误。
- 如何处理错误。
- 如何在项目中组织错误处理。
- 如何处理恐慌(panics)。
# 返回与处理错误
本方法展示如何检测错误,以及如何用额外的上下文信息包装错误。
# 操作方法……
使用函数或方法的最后一个返回值来返回错误:
func DoesNotReturnError() {...}
func MayReturnError() error {...}
func MayReturnStringAndError() (string, error) {...}
2
3
如果函数或方法执行成功,将返回nil表示无错误。如果在函数或方法内部检测到错误情况,可以直接返回该错误,或者用包含上下文信息的另一个错误来包装它:
func LoadConfig(f string) (*Config, error) {
    file, err := os.Open(f)
    if err != nil {
        return nil, fmt.Errorf("file %s: %w", f, err)
    }
    
    defer file.Close()
    var cfg Config
    err = json.NewDecoder(file).Decode(&cfg)
    if err != nil {
        return nil, fmt.Errorf("While unmarshaling %s: %w", f, err)
    }
    
    return &cfg, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 提示 不要用 panic替代错误处理。panic应该用于表示潜在的错误或不可恢复的情况。错误用于表示与上下文相关的情况,例如文件缺失或输入无效。 | 
|---|
# 工作原理……
Go语言采用显式的错误检测和处理机制。这意味着错误不会有隐式或隐藏的执行路径(比如抛出异常)。Go语言中的错误只是接口值,当错误为nil时,表示没有错误。上述函数调用了一些可能返回错误的文件管理函数。当发生错误(即函数返回非nil的错误)时,该函数会简单地用额外信息包装这个错误并返回。这些额外信息能让调用者,有时也能让程序的用户,确定正确的应对措施。
# 包装错误以添加上下文信息
使用标准库的errors包,你可以用包含额外上下文信息的另一个错误来包装一个错误。这个包还提供了一些工具和约定,让你能够检查错误链中是否包含特定错误,或者从错误链中提取特定错误。
# 操作方法……
使用fmt.Errorf为错误添加上下文信息。在下面的例子中,返回的错误将包含os.Open返回的错误,同时还会包含文件名:
file, err := os.Open(fileName)
if err != nil {
    return fmt.Errorf("%w: While opening %s", err, fileName)
}
2
3
4
# 比较错误
当你用额外信息包装一个错误时,新的错误值在类型和值上都与原始错误不同。例如,如果文件未找到,os.Open可能会返回os.ErrNotExist,如果你用额外信息(如文件名)包装这个错误,该函数的调用者就需要一种方法来获取原始错误,以便正确处理。本方法展示了如何处理这类被包装的错误值。
# 操作方法……
检查是否有错误很简单:检查错误值是否为nil即可:
file, err := os.Open(fileName)
if err != nil {
    // 文件无法打开
}
2
3
4
要检查错误是否是你预期的,应该使用errors.Is:
file, err := os.Open(fileName)
if errors.Is(err, os.ErrNotExist) {
    // 文件不存在
}
2
3
4
# 工作原理……
errors.Is(err,target error)通过以下步骤比较err是否等于target:
- 检查err == target。
- 如果不相等,检查err是否有Is(error) bool方法,并调用err.Is(target)。
- 如果上述检查失败,检查err是否有Unwrap() error方法且err.Unwrap()不为nil,再检查err.Unwrap()是否等于target。
- 如果还是失败,检查err是否有Unwrap() []error方法,以及target是否等于该切片中的任何一个元素。
这意味着,即使你包装了错误,调用者仍然可以检查被包装的错误是否发生,并据此做出相应处理。
如果你使用errors.New()或fmt.Errorf()定义一个错误,那么返回的错误接口包含一个指向对象的指针。在这种情况下,两个错误的字符串表示相同并不意味着它们相等。下面的程序展示了这种情况:
var e1 = errors.New("test")
var e2 = errors.New("test")
if e1 != e2 {
    fmt.Println("Errors are different!")
}
2
3
4
5
在上述代码片段中,尽管错误字符串相同,但e1和e2是指向不同对象的指针。程序会输出“Errors are different”。因此,像下面这样声明错误是可行的:
var (
    ErrNotFound = errors.New("Not found")
)
2
3
与ErrNotFound进行比较时,会检查错误值是否是指向与ErrNotFound相同对象的指针。
# 结构化错误
结构化错误提供的上下文信息,对于在错误到达程序用户之前进行处理至关重要。本方法展示了如何使用这类错误。
# 操作方法……
- 定义一个包含元数据的结构体,用于记录错误情况。
- 实现Error() string方法,使其成为一个错误类型。
- 如果该错误可以包装其他错误,包含一个error或[]error来存储这些错误。
- 可选地,实现Is(error) bool方法,控制如何比较这个错误。
- 可选地,实现Unwrap() error或Unwrap() []error方法,返回被包装的错误。
# 工作原理……
任何实现了error接口(仅包含一个方法Error() string)的数据类型都可以用作错误。这意味着你可以创建包含详细错误信息的数据结构,以便后续处理。所以,如果你需要多个数据字段来描述一个错误,与其构建一个复杂的字符串并通过fmt.Errorf返回,不如使用结构体。
例如,假设你正在解析一个多行格式化的文本输入。向用户返回准确有用的信息很重要,没人会喜欢只收到“语法错误”的消息,却不知道错误在哪里。因此,你可以声明如下错误结构:
type ErrSyntax struct {
    Line int
    Col  int
    Diag string
}
func (err ErrSyntax) Error() string {
    return fmt.Sprintf("Syntax error line: %d col: %d, %s", err.Line, err.Col, err.Diag)
}
2
3
4
5
6
7
8
9
现在你可以生成有用的错误信息:
func ParseInput(input string) error {
    ...
    
	if nextRune != ',' {
        return ErrSyntax{
            Line: line,
            Col:  col,
            Diag: "Expected comma",
        }
    }
    
    ...
}
2
3
4
5
6
7
8
9
10
11
12
13
你可以使用这些错误信息向用户显示有用的消息,或者控制交互式响应,比如将光标定位到错误位置,或者突出显示错误附近的文本。
# 包装结构化错误
结构化错误可以通过包装另一个错误,为其添加额外信息。本方法展示了如何实现这一点。
# 操作方法……
- 在结构体中保留一个错误成员变量(或错误切片),用于存储根本原因。
- 实现Unwrap() error(或Unwrap() []error)方法。
# 工作原理……
你可以将根本原因错误包装在结构化错误中,这能让你添加关于错误的结构化上下文信息:
type ErrFile struct {
    Name string
    When string
    Err  error
}
func (err ErrFile) Error() string {
    return fmt.Sprintf("%s: file %s, when %s", err.Err, err.Name, err.When)
}
func (err ErrFile) Unwrap() error {
    return err.Err
}
func ReadConfigFile(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return ErrFile{
            Name: name,
            Err:  err,
            When: "opening configuration file",
        }
    }
    
   ...
}
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
注意,Unwrap方法是必要的。如果没有这个方法,下面的代码将无法检测到错误是由os.ErrNotFound派生的:
err := ReadConfig("config.json")
if errors.Is(err, os.ErrNotFound) {
    // 文件未找到
}
2
3
4
有了Unwrap方法,errors.Is函数就可以深入检查包含的错误,判断其中是否至少有一个是os.ErrNotFound。
# 按类型比较结构化错误
在支持try-catch代码块的语言中,通常会根据错误类型来捕获错误。在Go语言里,你可以借助errors.Is来模拟相同的功能。
# 如何操作...
在你的错误类型中实现Is(error) bool方法,以此定义你所关心的等价类型。
# 它的工作原理...
你可能还记得,errors.Is(err, target)函数首先会测试err == target是否成立,如果不成立,且err实现了Is(error) bool方法,那么它会测试err.Is(target)是否成立。所以,你可以使用Is(error) bool方法来调整自定义错误类型的比较方式。如果没有Is(error) bool方法,errors.Is会使用==进行比较,这样一来,即使两个错误属于同一类型,但只要内容不同,比较就会失败 。下面的示例能让你检查给定的错误在错误树中是否包含ErrSyntax:
type ErrSyntax struct {
    Line int
    Col int
    Err error
}
func (err ErrSyntax) Error() string {...}
func (err ErrSyntax) Is(e error) bool {
    _,ok := e. (ErrSyntax)
    return ok
}
2
3
4
5
6
7
8
9
10
11
12
现在,你可以测试一个错误是否为语法错误:
err: = Parse(input)
if errors.Is(err, ErrSyntax{}) {
	// err是一个语法错误
}
2
3
4
# 从错误树中提取特定错误
# 如何操作...
使用errors.As函数遍历错误树,查找特定的错误并提取它。
# 它的工作原理...
与errors.Is函数类似,errors.As(err error, target any) bool会遍历err的错误树,直到找到一个可以赋值给target的错误。具体步骤如下:
- 检查target指向的值是否可以赋值给err指向的值。
- 如果不成立,通过调用err.As(target)检查err是否有As(error) bool方法。如果返回true,则表示找到了错误。
- 如果没有找到,检查err是否有Unwrap() error方法且err.Unwrap()不为nil,若满足条件则继续向下遍历错误树。
- 否则,检查err是否有Unwrap() []error方法,如果返回一个非空切片,则对切片中的每个错误向下遍历错误树,直到找到匹配的错误。
换句话说,errors.As会将可以赋值给target的错误复制到target中。下面的示例展示了如何从错误树中提取ErrSyntax的实例:
func (err ErrSyntax) As(target any) bool {
    if tgt, ok := target. (*ErrSyntax); ok {
        *tgt = err
        return true
    }
    
    return false
}
func main() {
    ...
    
    err: = Parse(in)
    var syntaxError ErrSyntax
    if errors.As(err,&syntaxError) {
    	// syntaxError包含了ErrSyntax的一个副本
    }
    
    ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
注意这里使用了指针。错误结构体是作为值使用的,而你想要获取该错误结构体的副本,所以要传递一个指向它的指针:ErrSyntax的实例可以被复制到*ErrSyntax的实例中。如果你的程序将*ErrSyntax用作错误值,你需要声明var syntaxError *ErrSyntax并传递&syntaxError,即发送**ErrSyntax,这样才能将指针复制到双指针指向的内存位置。
# 处理恐慌(Panics)
一般来说,恐慌表示一种不可恢复的情况,比如资源耗尽或违反不变量(也就是程序中的漏洞)。有些恐慌,比如内存不足或除零操作,会由运行时引发(或者由硬件引发并作为恐慌传递给程序)。当你在程序中检测到漏洞时,也应该触发恐慌。但如何判断一种情况是应该触发恐慌的漏洞,还是应该作为错误处理呢?
通常,外部输入(用户输入、API提交的数据或从文件读取的数据)不应该导致恐慌。这类情况应该被检测出来,并作为有意义的错误返回给用户。例如,程序中被声明为常量字符串的正则表达式编译失败,这种情况就应该触发恐慌。因为这种输入问题无法通过使用不同的输入重新运行程序来解决,它就是一个程序漏洞。
如果恐慌没有被recover处理,程序将会终止,并打印诊断输出,其中包括恐慌的原因和活动的goroutine的堆栈信息。
# 在必要时触发恐慌
大多数时候,决定是触发恐慌还是返回错误并非易事。本方法提供了一些指导原则,帮助你更轻松地做出决策。
# 如何操作...
在以下两种情况下可以触发恐慌:
- 违反了不变量
- 程序在当前状态下无法继续运行
不变量是程序中不能被违反的条件。因此,如果你检测到不变量被违反,与其返回错误,不如触发恐慌。
下面的例子来自我编写的一个图(graph)库。图包含节点和边,由*Graph结构体管理。Graph.NewEdge方法用于在两个节点之间创建一条新边。这两个节点必须与NewEdge方法的接收者属于同一个图,如果不是这种情况,触发恐慌是合理的,如下所示:
func (g *Graph) NewEdge(from,to *Node) *Edge {
    if from.graph != g {
    	panic("from node is not in graph")
    }
    
    if to.graph != g {
    	panic("to node is not in graph")
    }
    
    ...
}
2
3
4
5
6
7
8
9
10
11
在前面的例子中,从这个方法返回错误没有任何意义。这显然是调用者没有意识到的一个漏洞,如果允许程序继续运行,Graph对象的完整性将会受到破坏,进而产生难以发现的漏洞。此时最好的做法就是触发恐慌。
第二种情况是程序无法继续运行的宽泛场景。例如,假设你正在编写一个Web应用程序,并且从文件系统加载HTML模板。如果模板编译失败,程序就无法继续运行,这时你就应该触发恐慌。
# 从恐慌中恢复
未处理的恐慌会导致程序终止。通常,这是唯一正确的处理方式。然而,在某些情况下,你可能希望仅让引发错误的部分失败,记录错误信息,然后继续运行程序。例如,一个同时处理多个请求的服务器不会因为其中一个请求触发了恐慌就终止运行。本方法展示了如何从恐慌中恢复。
# 如何操作...
在延迟(defer)函数中使用recover语句:
func main() {
    defer func() {
        if r: = recover(); r != nil {
        // 处理恐慌
        }
    }()
    ...
}
2
3
4
5
6
7
8
# 它的工作原理...
当程序发生恐慌时,发生恐慌的函数会在所有延迟代码块执行完毕后返回。该goroutine的堆栈会逐个展开函数,通过运行它们的延迟语句进行清理,直到到达goroutine的起始位置,或者其中一个延迟函数调用了recover。如果恐慌没有被恢复,程序将会崩溃,并打印诊断信息和堆栈信息。如果恐慌被恢复,recover()函数会返回传递给panic的任何参数,这个参数可以是任意值。
所以,当你从恐慌中恢复时,应该检查恢复的值是否为错误,以便获取更多有用信息。
# 在恢复时更改返回值
当从恐慌中恢复时,你通常希望返回某种描述发生情况的错误。本方法展示了如何实现这一点。
# 如何操作...
要在从恐慌中恢复时更改函数的返回值,可以使用命名返回值。
# 它的工作原理...
命名返回值允许你使用名称访问和设置函数的返回值。如下所示,你可以使用命名返回值更改函数的返回值:
func process() (err error) {
    defer func() {
    	r: = recover()
    	if e, ok := r.(error); ok {
    		err = e
    	}
2
3
4
5
6
# 捕获恐慌的堆栈跟踪信息
在检测到恐慌时打印或记录堆栈跟踪信息,这是在运行时识别问题的重要手段。本方法展示了如何在日志消息中添加堆栈跟踪信息。
# 如何操作...
结合recover使用debug.Stack函数:
import "runtime/debug"
import "fmt"
func main() {
    defer func() {
        if r := recover(); r != nil {
            stackTrace := string(debug.Stack())
            // 处理stackTrace
            fmt.Println(stackTrace)
        }
    }()
    
    f()
}
func f() {
    var i *int 
    *i = 0
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在恢复函数内部,debug.Stack函数会返回正在被恢复的恐慌的堆栈信息,而不是调用它的地方的堆栈信息。因此,如果你能够记录或打印这些信息,就能看到恐慌发生的确切位置。
| 警告 以这种方式获取堆栈信息是一项开销较大的操作。请谨慎使用,仅在必要时使用。 | 
|---|
| 前面的程序将打印以下内容: | 
goroutine 1 [running]:
runtime/debug.Stack()
/usr/local/go-faketime/src/runtime/debug/stack.go:24 +0x5e
main.main.func1()
/tmp/sandbox381445105/prog.go:13 +0x25
panic({0x48bbc0?, 0x5287c0?})
/usr/local/go-faketime/src/runtime/panic.go:770 +0x132
main.f(...)
/tmp/sandbox381445105/prog.go:23
main.main()
/tmp/sandbox381445105/prog.go:18 +0x2e
2
3
4
5
6
7
8
9
10
11
这里:
- prog.go:13是调用- debug.Stack()的位置
- prog.go:23是执行- *i=0的位置
- prog.go:18是调用- f()的位置
如你所见,堆栈信息精确指出了错误发生的位置(prog.go:23)。
