Go 中的错误处理
Go 错误基本概念
在 Go 中,错误被视为一种值,并且被设计成普通的接口,而不是像其他语言中的异常。这种设计鼓励程序员明确地处理每一个可能的错误,并使代码更加清晰和预测。
error 接口
error 是 Go 中的一个基本接口,用于表示一个错误条件,其定义如下:
|
|
只要一个类型提供了这个 Error() string 方法,那么它就实现了 error 接口,可以被视为一个错误。这种方法允许开发者创建各种错误类型,只要它们都实现了该方法。
内建的错误实现
Go 提供了一些内建的工具来创建和操作错误。
-
errors.New: 这是创建新错误的最简单方法。给定一个字符串描述,它会返回一个新的错误,这个错误会返回给定的字符串作为它的
Error()方法的输出。1err := errors.New("my error description") -
fmt.Errorf: 这是一个更灵活的创建错误的方式,允许你像使用
fmt.Printf那样格式化错误消息。1 2val := 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 8type DNSError struct { Name string Server string IsTimeout bool // ... 其他字段 } func (e *DNSError) Error() string { /*...*/ } -
os.SyscallError: 当系统调用失败时,此错误提供有关调用和原因的详细信息。
1 2 3 4 5 6type 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 6type 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