了解如何优雅地处理 Golang 中的异常并提高代码的可靠性。
在Go语言中,错误类型是一种通常用于表示错误的接口类型。它的定义如下:
type error interface {
Error() string
}
error 接口只有一个方法,即 Error() 方法,它返回一个描述错误的字符串。这意味着任何实现 Error() 方法的类型都可以用作错误类型。
通常,Go 程序中在遇到错误时会返回错误类型的值,以便调用者处理或记录错误信息。
如何构造异常?
Go语言的设计者为Go开发者提供了两种方便的方法来构造异常值:errors.New和fmt.Errorf。
- error.New() 函数是创建错误值的最简单方法,因为它只包含错误消息字符串。此方法适合创建简单的误差值。
- fmt.Errorf() 函数允许您构造类似于 fmt.Printf() 函数的格式化错误消息。当您需要构建更复杂的错误消息时,这非常有用
使用这两个方法,我们可以轻松构造一个满足异常接口的错误值,就像下面的代码一样:
err := errors.New("hello error")
errWithCtx = fmt.Errorf("index %i is out of bounds", i)
这两种方法实际上返回了相同的未导出类型的实例,该类型实现了错误接口。
这个未导出类型就是errors.errorString,其定义如下:
// $GOROOT/src/errors/errors.go
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
大多数情况下,使用这两种方法构建的错误值就可以满足我们的需求。
但是,需要注意的是,虽然这些方法可以方便地创建错误值,但它们向错误处理程序提供的错误上下文仅限于以字符串形式呈现的信息,即 Error 方法返回的信息
如何自定义错误类型?
在某些情况下,错误处理程序可能需要从错误值中提取更多信息,以帮助他们选择合适的错误处理路径。
显然,这两种方法不足以应对这种情况。
在这种情况下,我们可以自定义错误类型来满足这些需求。这是一个例子:
package main
import "fmt"
// 自定义错误类型
type MyError struct {
ErrorCode int
ErrorMessage string
}
// 实现 `error` 接口的 `Error`()方法
func (e MyError) Error() string {
return fmt.Sprintf("Error %d: %s", e.ErrorCode, e.ErrorMessage)
}
func someFunction() error {
// 创建自定义错误值
err := MyError{
ErrorCode: 404,
ErrorMessage: "Not Found",
}
return err
}
func main() {
err := someFunction()
fmt.Println("Error:", err)
}
我们再举个例子,比如标准库中的net包,它定义了带有附加上下文的错误类型
// $GOROOT/src/net/net.go
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
这样,错误处理程序就可以根据此类错误值提供的附加上下文信息(例如 Op、Net、Source 等)来决定错误处理路径。
例如,在标准库中的以下代码中:
// $GOROOT/src/net/http/server.go
func isCommonNetReadError(err error) bool {
if err == io.EOF {
return true
}
if neterr, ok := err.(net.Error); ok && neterr.Timeout() {
return true
}
if oe, ok := err.(*net.OpError); ok && oe.Op == "read" {
return true
}
return false
}
从上面的代码中我们可以看到,它利用类型断言来判断错误变量err的动态类型是*net.OpError还是net.Error。
如果 err 的动态类型是 *net.OpError,则类型断言返回该动态类型的值(存储在 oe 中),然后代码可以通过检查其 Op 字段是否被读取来确定是否是 CommonNetRead 类型的错误。
Go 错误处理的常见做法
1. 透明的错误处理策略
简单来说,Go语言中的错误处理就是根据函数返回的错误类型变量携带的错误值信息来做出决策并选择后续代码执行路径的过程。
使用这种方法,最简单的错误策略是完全忽略返回的错误值携带的特定上下文信息。相反,每当发生错误时,都会导致单个错误处理执行路径。例如,考虑以下代码
err := doSomething()
if err != nil {
... ...
return err
}
这是Go语言中最常见的错误处理策略,占错误处理场景的80%以上。在这种策略中,由于错误处理程序不关心错误值的上下文,因此错误的创建者(例如示例中的函数 doSomething)可以直接使用 Go 标准提供的两种基本错误值构造方法库,像这样:
func doSomething(...) error {
... ...
return errors.New("some error occurred")
}
以这种方式构造的错误值表示对错误处理程序透明的上下文信息。
在错误处理程序不需要了解错误上下文的情况下,透明错误处理策略可以最大限度地减少错误处理程序和错误值创建者之间的耦合。
当错误上下文与错误处理无关时,此方法可有效减少错误处理和错误值构造之间的相互依赖性。
2.哨兵错误处理策略
当错误处理程序无法仅根据透明错误值做出错误处理决策时,它们可能会尝试检查返回的错误值,从而导致类似以下代码的反模式:
data, err := b.Peek(1)
if err != nil {
switch err.Error() {
case "1":
// ... ...
return
case "2":
// ... ...
return
case "3":
// ... ...
return
default:
// ... ...
return
}
}
简而言之,反模式是错误处理程序仅依赖“透明错误值”(描述错误的字符串)提供的单个上下文信息来做出错误处理决策。
然而,这种“反模式”会导致显著的隐式耦合。
这意味着即使错误值创建者对错误描述字符串进行微小的更改,也可能会导致错误处理行为发生变化。
此外,这种通过字符串比较来检查错误值的方法在性能方面效率不高。
那么,解决办法是什么? Go 标准库采用定义导出的“哨兵”错误值的做法来协助错误处理程序检查错误值并做出错误处理决策。
例如,以下是 bufio 包中定义的“哨兵错误”:
// $GOROOT/src/bufio/bufio.go
var (
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
ErrBufferFull = errors.New("bufio: buffer full")
ErrNegativeCount = errors.New("bufio: negative count")
)
以下代码片段利用前面提到的哨兵错误在错误处理分支中做出决策:
data, err := b.Peek(1)
if err != nil {
switch err {
case bufio.ErrNegativeCount:
// ... ...
return
case bufio.ErrBufferFull:
// ... ...
return
case bufio.ErrInvalidUnreadByte:
// ... ...
return
default:
// ... ...
return
}
}
然而,对于 API 开发人员来说,公开“哨兵”错误值意味着这些错误值与包的公共函数一起成为 API 的一部分。
一旦发布,开发人员就有责任对其进行有效维护。此外,“哨兵”错误值会为使用它们的错误处理程序创建依赖关系。
从 Go 1.13 开始,标准库错误包引入了 Is 函数,供错误处理程序检查错误值。Is 函数类似于将错误变量与“sentinel”错误值进行比较,如以下代码所示:
if errors.Is(err, ErrOutOfBounds) {
// do something
}
不同之处在于,如果错误变量包含包装错误,则errors.Is方法将遍历包装错误内的错误链,将其与链中的所有包装错误进行比较,直到找到匹配的错误。
以下是如何使用 Is 函数的示例:
var ErrSentinel = errors.New("the underlying sentinel error")
func main() {
err1 := fmt.Errorf("wrap sentinel: %w", ErrSentinel)
err2 := fmt.Errorf("wrap err1: %w", err1)
println(err2 == ErrSentinel) //false
if errors.Is(err2, ErrSentinel) {
println("err2 is ErrSentinel")
return
}
println("err2 is not ErrSentinel")
}
在此示例中,我们使用 fmt.Errorf 函数和 %w 动词创建包装错误变量 err1 和 err2。
err1包装“哨兵”错误值ErrSentinel,并err2包装err1,创建错误链。错误链的顶部是err2,底部是ErrSentinel。
然后,我们使用值比较和函数来确定和errors.Is之间的关系。err2ErrSentinel
运行代码时,您将观察到以下结果:
false
err2 is ErrSentinel
我们可以看到,在比较err2和ErrSentinel使用相等运算符时,它们并不相同。
然而,该errors.Is函数会遍历其中的错误链并找到包裹在最深层的err2“哨兵”错误值。ErrSentinel
如果您使用 Go 1.13 及更高版本,建议使用该errors.Is方法来检查给定的错误值是否是预期的错误值或包含特定的“哨兵”错误值。
这种方法提供了更健壮和灵活的错误处理,特别是在涉及错误包装和错误链的场景中。
3.错误值类型检查策略。
正如我们之前所看到的,使用 Go 标准库提供的错误值构造方法构造的“哨兵”错误值除了执行目标值比较的能力之外,不提供额外的错误上下文信息。
如果错误处理程序需要来自错误值的更多“错误上下文”信息,则前面讨论的策略和错误值构造方法可能还不够。
在这种情况下,我们需要使用自定义错误类型构建错误值以提供额外的“错误上下文”信息。
由于所有错误值统一通过接口变量呈现error,因此获取底层错误类型携带的错误上下文信息需要使用Go的类型断言或类型切换机制。
这种依赖于检查错误值类型的错误处理方法可以称为“错误值类型检查策略”。
让我们看一下标准库中的一个示例来加深我们的理解。在包中,定义了json一个名为的自定义错误类型:UnmarshalTypeError
// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
Value string
Type Reflect.Type
Offset int64
Struct string
Field string
}
错误处理程序可以使用错误值类型检查策略来获取更多错误上下文信息。json下面是包中使用此策略的方法的实现:
// $GOROOT/src/encoding/json/decode.go
func (d *decodeState) addErrorContext(err error) error {
if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
switch err := err.(type) {
case *UnmarshalTypeError:
err.Struct = d.errorContext.Struct.Name()
err.Field = strings.Join(d.errorContext.FieldStack, ".")
return err
}
}
return err
}
在此代码中,使用类型 switch 语句来确定变量所表示的动态类型和值err,然后利用错误上下文信息在匹配的 case 分支中进行处理。
通常,自定义导出的错误类型以XXXError.
与“哨兵”错误处理策略类似,错误值类型检查策略将自定义错误类型公开给错误处理程序,使它们与包的公共函数一起成为 API 的一部分。
一旦发布,开发人员就有责任维护这些自定义错误类型。此外,它们还为检查和使用这些类型的错误处理程序创建依赖项。
从 Go 1.13 开始,标准库errors包为错误处理程序提供了As检查错误值的函数。
该As功能类似于使用类型断言来确定错误变量是否是特定的自定义错误类型,如以下代码所示:
var customErr *CustomError
iferrors.As (err, &customErr) {
// 将错误作为 CustomError 处理
}
不同之处在于,如果错误变量包含包装错误,则该errors.As函数将遍历包装错误内的错误链,将其与链中的所有包装错误进行比较,直到找到匹配的错误类型,类似于 的行为errors.Is。
As下面是如何使用该函数的示例:
type MyError struct {
e string
}
func (e *MyError) Error() string {
return ee
}
func main () {
var err = &MyError{ "MyError 错误演示" }
err1 := fmt.Errorf( "wrap err: %w " , err)
err2 := fmt.Errorf( "wrap err1: %w" , err1)
var e *MyError
iferrors.As (err2, &e) {
println ( "MyError 位于 err2 的链上" )
println (e == err)
return
}
println ( "MyError 不在 err2 的链上" )
}
结果。
MyError 在 err2 true的链上
正如我们所看到的,该errors.As函数成功遍历了 中的错误链err2,找到最深的包装错误,并err2与*MyError类型匹配。
一旦成功匹配,errors.As将匹配的错误值存储在As函数的第二个参数中。这就是为什么println(e == err)结果是true.
如果您使用 Go 1.13 及更高版本,建议使用该errors.As方法检查给定错误值是否是特定自定义错误类型的实例。
这种方法提供了一种更灵活、更有效的方法来处理自定义错误,特别是在处理包装错误时。
4.错误行为特征检查策略
您可能已经注意到,在我们前面讨论的三种策略中,只有第一种策略“透明错误处理策略”有效地减少了错误创建者和错误处理程序之间的耦合。
虽然第二和第三种策略在现实世界的编码中很实用,但它们仍然在错误创建者和错误处理程序之间引入了一定程度的耦合。
那么,除了“透明错误处理策略”之外,还有没有其他方法可以减少错误处理程序和错误值创建者之间的耦合呢?
在Go标准库中,我们发现了一种不同的错误处理方法,其中包括对包内的错误类型进行分类,提取常见的错误行为特征,并将这些错误行为特征放入公共接口类型中。
这种方法也称为“错误行为特征检查策略”。
举个例子,net标准库中的包抽象了包内所有错误类型的常见错误行为特征,并将其放入接口中net.Error,如下代码所示:
// $GOROOT/src/net/net.go
type Error interface {
error
Timeout() bool
Temporary() bool
}
我们可以看到,该net.Error接口包含两种确定错误行为特征的方法:Timeout检查是否是超时错误和Temporary检查是否是临时错误。
错误处理程序只需要依赖这个公共接口来检查特定错误的行为特征,并根据这些信息做出后续错误处理分支的决策。
下面是包中的另一个例子http,它使用错误行为特征检查策略进行错误处理,进一步增强我们的理解:
// $GOROOT/src/net/http/server.go
func (srv *Server) Serve(l net.Listener) error {
... ...
for {
rw, e := l.Accept()
if e ! = nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default :
}
if ne, ok := e.(net.Error); ok && ne.Temporary() {
... ...
time.Sleep(tempDelay)
continue
}
return e
}
...
}
... ...
}
在上面的代码片段中,该Accept方法实际上返回类型为 的错误*OpError,这是包中的自定义错误类型net。OpError实现常见错误特征接口net.Error,代码如下:
// $GOROOT/src/net/net.go
type OpError struct {
... ...
// Err 是操作过程中发生的错误。
Err error
}
type temporary interface {
Temporary() bool
}
func (e *OpError) Temporary() bool {
if ne, ok := e.Err.(*os.SyscallError); ok {
t, ok := ne.Err.(temporary)
return ok && t.Temporary()
}
t, ok := e.Err.(temporary)
return ok && t.Temporary()
}
事实上,错误处理程序可以使用 net.Error 接口提供的方法来检查 OpError 实例,以确定它们的行为是否符合 Temporary 或 Timeout 等特征。
这使得错误处理程序可以根据错误的具体行为做出明智的决策,无论该错误是临时的还是与网络相关操作的超时相关。
总结
在Go编程语言中,统一的错误类型是接口error,并提供了各种函数来快速构造可以分配给类型的错误值error,包括errors.New、fmt.Errorf 等。
error我们还讨论了使用统一类型作为错误类型的优点。
深刻理解这个概念很重要。
- 透明的错误处理策略:通过统一错误类型并且不提供额外的错误上下文,它简化了错误处理,适合大多数情况。
- Sentinel错误处理策略:通过定义一组符号错误值,允许错误处理程序通过值比较来选择错误处理路径。
- 错误值类型检查策略:通过自定义错误类型并提供更多错误上下文,适合需要额外错误上下文并使用类型断言或类型开关检查错误类型的场景。
- 错误行为特征检查策略:通过公共接口定义错误行为特征,使错误处理程序能够检查错误特征而不是特定类型,适用于错误行为特征重要的场景。
每种策略都有其优点和用例。选择正确的策略取决于您在编程中的具体要求和考虑因素,例如可维护性、耦合性、代码复杂性和错误上下文需求。
通过了解这些策略,您可以更好地处理和管理错误,提高代码的可靠性和可维护性。