Go 中的错误处理
Go 错误基本概念
在 Go 中,错误被视为一种值,并且被设计成普通的接口,而不是像其他语言中的异常。这种设计鼓励程序员明确地处理每一个可能的错误,并使代码更加清晰和预测。
error 接口
error
是 Go 中的一个基本接口,用于表示一个错误条件,其定义如下:
|
|
只要一个类型提供了这个 Error() string
方法,那么它就实现了 error
接口,可以被视为一个错误。这种方法允许开发者创建各种错误类型,只要它们都实现了该方法。
内建的错误实现
Go 提供了一些内建的工具来创建和操作错误。
-
errors.New: 这是创建新错误的最简单方法。给定一个字符串描述,它会返回一个新的错误,这个错误会返回给定的字符串作为它的
Error()
方法的输出。1
err := errors.New("my error description")
-
fmt.Errorf: 这是一个更灵活的创建错误的方式,允许你像使用
fmt.Printf
那样格式化错误消息。1 2
val := 42 err := fmt.Errorf("my error with value: %d", val)
这些内建的方法为错误处理提供了基础,但 Go 的生态系统还提供了更多的工具和模式来帮助开发者更好地处理、传播和记录错误。
错误处理模式
在 Go 中,错误处理是编写健壮和可维护代码的核心组成部分。Go 不使用传统的异常处理机制,而是选择明确地返回错误作为函数的返回值。这种方法鼓励开发者更加明确地处理错误,但也产生了几种常见的错误处理模式。
传统的 “if err != nil” 检查
这是 Go 中最基本也是最常见的错误处理模式。当函数返回一个错误时,你会立即检查这个错误,然后决定如何处理它。
|
|
这种方法的优势在于它很直接、明确。你总是知道在什么情况下会检查错误,以及当发生错误时会发生什么。
使用 defer 进行错误处理
defer
语句允许你延迟函数的执行,通常用于资源的清理操作,如文件关闭、解锁资源等。与错误处理结合,你可以确保在函数退出时始终执行某些操作,无论函数是正常退出还是因为错误而返回。
一个常见的例子是确保已打开的文件在函数结束时关闭:
|
|
此外,有些库提供了支持错误处理的延迟函数,这允许你在 defer
语句中处理错误。
使用多值返回来同时返回结果和错误
Go 支持多值返回,这使得函数可以同时返回结果和错误。这是 Go 错误处理的核心模式,因为它允许调用者明确地处理错误,同时还可以接收函数的结果。
例如,一个函数可能会这样定义和调用:
|
|
这种方法的优势是它强制调用者处理错误,因为他们必须接收和检查返回的错误值。
常见的错误类型
哨兵错误(Sentinel Errors)
哨兵错误是预先声明的特定错误,通常用作特定函数调用失败时的返回值。它们是已命名的值,全局可见,并由包的使用者直接检查。
例如,当你到达文件或输入流的末尾时,Go 的 io
包会返回 io.EOF
错误。哨兵错误是明确的,调用者可以使用 if err == io.EOF
来检查它。
优点:
- 易于使用,因为它是一个已知的值。
缺点:
- 过度使用可能导致 API 与具体错误值紧密耦合,限制了将来的变更。
- 可能导致错误处理代码过于分散。
以下是 Go 标准库中的一些哨兵错误类型的示例:
- io.EOF: 这可能是最知名的哨兵错误。当在输入结束时尝试从 io.Reader 读取数据时,它表示没有更多的数据可读。
- os.ErrExist and os.ErrNotExist: 当文件或目录存在或不存在时,这些错误会被返回,例如在尝试创建一个已存在的文件或查找一个不存在的文件时。
- io.ErrShortWrite: 当只有部分数据被写入 io.Writer 时,返回此错误。
- io.ErrUnexpectedEOF: 当在读取更多的数据之前输入结束时,返回此错误。
自定义错误类型(Custom Error Types)
自定义错误类型是一种特殊的错误,它们是实现了 error
接口的自定义类型。与哨兵错误不同,这些错误类型可以携带更多的上下文信息。
例如,你可以定义一个表示网络错误的类型,它包含一个状态码和一个错误消息:
|
|
优点:
- 可以提供关于错误的更多上下文。
- 更加灵活,易于修改和扩展。
缺点:
- 需要更多的代码来定义和处理。
以下是 Go 标准库中的一些自定义错误类型的示例:
-
net.DNSError: 当查找域名时,此错误表示 DNS 查询的问题。
1 2 3 4 5 6 7 8
type DNSError struct { Name string Server string IsTimeout bool // ... 其他字段 } func (e *DNSError) Error() string { /*...*/ }
-
os.SyscallError: 当系统调用失败时,此错误提供有关调用和原因的详细信息。
1 2 3 4 5 6
type SyscallError struct { Syscall string Err error } func (e *SyscallError) Error() string { return e.Syscall + ": " + e.Err.Error() }
-
json.SyntaxError: 此错误表示在解析 JSON 时发生的语法问题。
1 2 3 4 5 6
type SyntaxError struct { Offset int64 // contains filtered or unexported fields } func (e *SyntaxError) Error() string { /*...*/ }
这些自定义错误类型允许库提供关于发生的错误更多的上下文。调用者可以通过类型断言来检查和处理这些特定类型的错误,并获取错误的额外字段或方法。
错误包装与展开
错误包装与展开是 Go 1.13 中引入的特性,使得开发者可以更灵活地处理、检查和传递错误信息。
Wrap errors 主要用于以下几个目的:
- 记录错误:确保错误信息被记录到日志中。
- 确保应用完整性:在处理错误的同时,确保应用程序的完整性达到 100%。
- 防止错误重复报告:避免在多处重复报告同一错误。
使用 %w
进行错误包装
在 Go 中,你可能想要添加上下文到一个错误中,使其更具有描述性。为了实现这个目的,你可以使用 fmt.Errorf
函数中的 %w
格式符来“包装”一个错误。
示例:
|
|
在上面的例子中,如果 anotherFunction()
返回一个错误,那么我们会使用 %w
将这个错误“包装”在一个新的错误中,并添加一些额外的上下文。
正确的使用 wrap 的时机
错误包装(error wrapping)是指将错误包装在一个容器中,该容器还会提供原始错误。这在 Golang 中被称为 error chaining。错误包装通常用于以下两种情况:
- 添加额外的上下文信息到错误中
- 将错误标记为特定的错误
|
|
Option | Extra context | Marking an error | Source error available |
---|---|---|---|
直接返回错误 | ❌ | ❌ | ✅ |
自定义错误类型 | ✅ | ✅ | ✅ |
fmt.Errorf with %w | ✅ | ❌ | ✅ |
fmt.Errorf with %v | ✅ | ❌ | ❌ |
- Option:列出了不同的错误处理和创建方法。
- Extra context:描述了是否可以为错误添加额外的上下文。例如,给出详细的错误信息或额外的状态数据。
- Marking an error:描述了是否可以将错误标记为某种特定类型。这在后续处理中可能非常有用,因为它允许我们基于错误的类型或标记采取特定的行动。
- Source error available:描述了原始错误是否可用,即是否可以从包装或标记的错误中提取原始错误。
使用 errors.Is
和 errors.As
进行错误展开和检查
- errors.Is
这个函数允许你检查一个错误值是否与一个特定的错误相匹配,即使它可能已经被包装在其他错误中。
示例:
|
|
在上面的例子中,即使我们的 err
是一个包装了 myErr
的新错误,errors.Is
也能正确地检测出其原始错误是 myErr
。
- errors.As
这个函数用于检查包装的错误是否可以赋值给一个特定的错误类型。它是类型安全的,并且在错误链中查找匹配的错误。
示例:
|
|
在上面的例子中,我们首先定义了一个自定义错误类型 MyCustomError
。然后我们使用 %w
创建了一个包装了这个自定义错误的新错误。最后,我们使用 errors.As
来检查这个包装的错误是否为 MyCustomError
类型,并成功地获取了它的原始值。
pkg/errors(Public archive)
pkg/errors
是 Go 社区中一个非常受欢迎的错误处理包。尽管 Go 1.13 中对标准库的 errors
包进行了扩展,加入了类似的功能,但 pkg/errors
仍然有其特色和用途。以下是对 pkg/errors
的简要介绍:
主要功能:
-
创建新的错误:
errors.New
:基础的创建新错误的函数,类似于标准库中的同名函数。errors.Errorf
:带有格式化功能的创建错误的函数,可以像fmt.Errorf
那样使用。
-
错误包装(Wrap errors):
errors.Wrap
和errors.Wrapf
:这两个函数都可以将现有的错误包装起来,同时添加描述信息。最重要的是,它们还会记录当前的堆栈信息,这对于后期调试是非常有价值的。
-
获取错误原因和堆栈信息:
errors.Cause
:这个函数可以从包装过的错误中提取出原始的错误(或称为“根错误”)。- 使用
%+v
与fmt
函数(如fmt.Println
)一同使用,可以打印出错误的详细堆栈信息。
使用场景与建议:
-
当你希望除了错误消息外,还保留错误发生的上下文和堆栈信息时,使用
pkg/errors
会非常有帮助。 -
在应用代码的更低层(如数据访问层或第三方服务接口)使用
errors.Wrap
或errors.Wrapf
来包装返回的错误。这样,在更上层,你可以利用堆栈信息更容易地识别和定位错误发生的位置。 -
在应用的顶层或主函数中,你可以使用
errors.Cause
来检查错误是否匹配预期的 sentinel errors 或特定的错误类型。
最佳实践:
- 使用基本错误构造:在应用的代码中,如果需要构造新的错误,使用
errors.New
或errors.Errorf
。 - 在跨包调用时直接返回错误:当你的函数或方法调用其他包中的函数或方法时,如果出现错误,通常应直接返回该错误,而不是进行额外的包装。
- 保存堆栈信息:当你需要与其他库或标准库协作时,考虑使用
errors.Wrap
或errors.Wrapf
来保存关于错误来源的堆栈信息。 - 避免冗余的错误记录:不要在产生错误的每个地方都进行日志记录。相反,将错误信息直接返回,并在更上层进行处理和记录。
- 记录堆栈详情:在程序的主函数或工作的 goroutine 的入口点,使用
%+v
格式描述符来记录完整的错误堆栈详情。 - 获取并比较根错误:当需要检查一个特定的基础错误时,使用
errors.Cause
来获取最底层的原因,并与已知的 sentinel error 进行比较。
Panic and Recover
在 Go 语言中,panic
和 recover
是两个用于处理运行时错误和异常情况的内建函数。它们为开发者提供了一种处理非预期错误的机制,但在常规的错误处理中,我们更推荐使用 error
类型。下面来详细介绍这两个函数:
panic
panic
是一个内建函数,它中止当前函数的执行,并开始在当前的 Goroutine 中进行 panic。当函数 F 调用 panic
时,F 的执行会立即停止,同时 F 中的任何 defer
函数都会执行。然后 F 返回到调用它的函数,在那里也执行相同的过程,直到当前的 Goroutine 中所有函数都返回,此时程序会终止。除非此 panic 被 recover 捕获,否则程序将显示 panic 信息并退出。
示例:
|
|
何时应用 panic
在 Go 语言中,panic
是用来处理非常特殊的错误情况的,它不同于常规的错误处理方式,如通过 error
类型来返回错误。以下是对您提到的两种情境的进一步讨论:
-
程序性错误 (Programmer Errors): 不想让进程继续执行
- 这些是由于编程错误引起的,而不是预期的运行时错误。例如,传递一个无效的 HTTP 状态码或尝试注册一个已经存在的数据库驱动。
- 这些错误通常是由于程序员的疏忽,或者在某些情况下,是对 API 的误用。
- 对于这类错误,你期望调用者立即知道并修复错误,而不是运行时尝试恢复或忽略它。这就是为什么使用
panic
的原因,因为它会导致程序崩溃(除非使用recover
捕获)。
-
强依赖 (Critical Dependencies): 程序启动
- 这些是程序必须要满足的条件,如果不满足,程序就不能正确运行。例如,如果一个 web 服务启动时不能连接到其数据库,那么它可能会
panic
,因为它不能在没有数据库的情况下正常工作。 - 在这种情况下,程序无法继续执行,并且最好停止运行,而不是尝试继续在一个已知不稳定或不可靠的状态下运行。
- 这些是程序必须要满足的条件,如果不满足,程序就不能正确运行。例如,如果一个 web 服务启动时不能连接到其数据库,那么它可能会
recover
recover
也是一个内建函数,它可以让你从 panic 的状态中恢复。如果与一个 defer
语句一同使用,可以捕获到 panic
的输入参数,从而允许程序从 panic 中恢复。
recover
只有在与 defer
一同使用时才有效。在正常的执行过程中调用 recover
,它将返回 nil
。但如果当前的 Goroutine 由于 panic
而崩溃,调用 recover
将捕获 panic
的输入参数,并恢复正常的执行。
示例:
|
|
尽管 panic
和 recover
为处理异常情况提供了一种机制,但在 Go 社区中,我们推荐使用 error
类型来处理错误,并保留 panic
用于真正的异常情况,例如程序的不可恢复的状态。
[!question] 为什么
recover
只有在与defer
一同使用时才有效?
recover()
函数设计的初衷是为了从panic
中恢复正常执行,并给予程序一个处理错误或清理资源的机会。它的工作方式与panic
有关,只有当程序处于panic
状态时,recover()
才能捕获到该panic
。为什么
recover
只在defer
中有效有以下原因:
执行流程:当一个函数中出现
panic
,该函数的执行会立即停止,并运行任何defer
语句。如果没有在defer
中调用recover()
,那么panic
会继续在调用堆栈中传播,直到程序终止或者在某个defer
中调用了recover()
。栈展开:当
panic
发生时,Go 会开始展开栈,即它会停止当前函数的执行并开始执行该函数的所有defer
语句。如果在这个过程中没有调用recover()
,panic
会继续向上在调用堆栈中展开。恢复的时机:由于
recover
是为了捕获和处理panic
而设计的,所以它只在panic
进行时有意义。在普通函数中调用recover()
会返回nil
,因为没有panic
可以恢复。而defer
保证了其内部的代码在函数退出或发生panic
时都会执行,因此它为recover
提供了正确的时机来捕获panic
。为了简单总结,
recover
之所以只在defer
中有效,是因为defer
提供了在panic
时执行代码的机制,这使得recover
有机会在正确的时机捕获和处理panic
。
忽略错误
当我们谈论“不处理错误”时,我们实际上是指在程序中遇到错误后,我们选择不对其采取任何措施。这在 Go 中并不是推荐的做法,因为 Go 鼓励明确地处理所有可能出现的错误。但在某些情况下,开发人员可能会选择不处理错误,原因可能有以下几点:
- 预期的错误:在某些情况下,错误可能是预期之内的,开发者可能已经知道这种错误会发生,并且知道它对程序的运行没有实质性的影响。
- 日志记录而不是处理:在某些情况下,开发人员可能选择记录错误而不是处理它,这样他们可以后续分析它,而不是立即对其采取措施。
- 忽略不重要的错误:有时,某些错误可能不会对程序的核心功能产生影响,因此可以被安全地忽略。
- 疏忽:这并不是一个好的理由,但有时开发人员可能会不小心遗漏错误处理。
忽略错误的示例:
|
|
总结
panic
与 recover
使用指南:
- 限制使用:在绝大多数场景中,建议使用
error
类型,而不是panic
和recover
来处理错误。 - 异常情况:只有在程序无法继续运行的场景下才使用
panic
,如初始化失败或不应出现的错误。 - 不替代常规错误:如文件未找到时,不应使用
panic
,而应返回一个error
。 - 中间件和框架:在可能的顶部位置使用
recover
来捕捉panic
,以确保整体程序稳定性。 defer
中的recover
:仅在defer
中可以捕获panic
。- 提供详尽信息:捕获
panic
后,记录详细信息,如堆栈信息。 - 资源清理:使用
defer
确保资源,如文件、网络连接等,被正确释放。 - 重新抛出
panic
:有时可能需要重新抛出,但需明确原因并理解可能的后果。 - 避免在库中使用:编写供他人使用的库时,尽量不要使用
panic
。 - 文档说明:确保在文档中明确标明什么情况下会出现
panic
。
Go 错误处理最佳实践:
- 限制哨兵错误:过多的哨兵错误可能导致代码紧耦合。如需使用,务必清晰定义。
- 提供明确上下文:使用错误包装为错误提供明确上下文,方便错误追踪。
- 返回不透明错误:隐藏实现细节,返回简单的
error
,增强 API 的灵活性。 - 使用
error
:常规错误处理优先选择error
,而不是panic
或recover
。 - 返回具体错误类型:提供详细的错误信息,帮助调用者更准确地处理错误。
- 遵循标准接口:无论使用哪个库,都应遵循标准的
error
接口。 - 明确处理:不应忽略函数返回的错误。如需忽略,使用空标识符明确表示。
总的来说,正确和一致地处理错误是保证 Go 程序健壮性和可维护性的关键。遵循这些最佳实践可以帮助你构建更加健壮、可靠和易于维护的 Go 程序。
参考
- https://go.dev/doc/effective_go#errors
- https://go.dev/blog/error-handling-and-go
- https://go.dev/blog/go1.13-errors
- https://go.dev/blog/defer-panic-and-recover
- https://go.dev/doc/tutorial/handle-errors
- https://go.googlesource.com/proposal/+/master/design/29934-error-values.md
- https://go.dev/blog/defer-panic-and-recover