likes
comments
collection
share

Go使用tips: Error Handling错误处理

作者站长头像
站长
· 阅读数 29

只有在需要时才定义错误(var ErrXXX = errors.New)

在代码库中经常可以看到很多错误定义,每个错误都有详细的名称和冗长的描述。但这是否总是必要的呢?

让我们思考一下这个问题:

var (
    ErrPriceTooHigh = errors.New("sale: input price is too high")
    ErrPriceTooLow  = errors.New("sale: input price is too low")
    ErrAlreadySold  = errors.New("sale: already sold")
)

在这种情况下,开发人员试图严格控制每一种可能出现的错误,他们希望考虑到业务逻辑中可能出错的每一种情况。

但老实说,这种方法可能矫枉过正,原因有以下几点:

  • 对于任何维护代码的人来说,这都是一种负担,试想一下,他们不得不记住或不断查找每个错误的含义。
  • 你现在可能觉得这是个好主意,但几周或几个月后,连你自己都可能忘记为什么会产生这些错误。
  • 有时,我们的客户端甚至不需要知道这些错误。例如,如果您的前台已经限制了输入价格的范围,那为什么还要定义 ErrPriceTooHigh 或 ErrPriceTooLow 这样的错误呢?一般的错误信息就足够了。
  • 只有绕过前端直接访问应用程序接口的人才会遇到这些错误,而通常情况下,我们并不想支持这种行为。

不过度定义错误的原则不仅适用于客户端与服务器之间的交互

比方说,我们无法向消息队列发布一条消息。在我们急于创建 ErrPublishMessage 之前,请考虑一下是否真的有必要。是否有人需要捕捉这个特定的错误?

那么,在这种情况下建议采用什么方法呢?

如果我们不希望客户(无论是我们代码的其他部分还是我们库的外部用户)根据不同类型的错误采取特定的操作,通常最好还是保持简单。

在这些情况下可能更好用的策略。一种简单的方法是在出错时返回一个基本错误,就像下面这样:

func Sale(price int) error {
    ...
    if isPriceHigh(price) {
        return errors.New("sale: input price is too high")
    }
}

这种方法非常直接。但如果需要在错误信息中包含更多上下文,fmt.Errorf 可能是更好的选择。

它可以让你用动态数据来格式化错误信息,这对一目了然地了解问题很有帮助:

func Sale(price int) error {
    ...
    if isPriceHigh(price) {
        return fmt.Errorf("sale: input price (%d) is too high, cap (%d)", price, cap)
    }
}

现在,您可能需要一个可重复使用的自定义错误,而不是定义多个错误变量:

type Error struct {
    Input int
    Min   int 
    Max   int
}

但是,什么时候才需要定义特定的错误变量呢?

在某些情况下,这样做是完全合理的。例如,当你的应用程序逻辑需要根据错误类型做出不同反应时:

如果发生特定错误,您可能希望重试操作。 特定的错误可能会触发不同的日志机制,有些会触发警告,有些会触发错误。 也许您需要显示一个弹出窗口,如资金耗尽,请存入更多资金。 ... 在这种情况下,预定义的错误变量可以帮助您更好地管理这些不同的情况。

使用 fmt.Errorf 清除错误,不要让错误一览无余

在 Go 中,与其他一些可能会抛出异常的语言不同,错误被当作值来处理。这意味着我们通常会返回错误,而不是抛出错误:

func doOperation() error {
    err := doSomething() 
    if err != nil {
        return err
    }

    return nil
}

在没有任何上下文的情况下只返回这样的错误,有时会让试图调试代码的人感到疑惑,并试图找出到底是哪里出了问题。

使用fmt.Errorf with %w

现在,在 Go 1.13 中,有了一种更好的方法来为错误添加更多信息,同时保持原始错误不变。这种方法是使用 fmt.Errorf 和 %w 动词来封装错误。

这样,在提供更多上下文的同时,还能保留底层错误:

func doSomething() error {
    err := doAnotherThing()
    if err != nil {
        return fmt.Errorf("do another thing: %w", err)
    }

    return nil
}

“我还是看不出有什么好处,不管怎样看都是错误”。

那么,让我们通过一个实际的例子来看看为什么它是有用的:

func getResourceHandler(http.ResponseWriter, *http.Request)
|
| -> func authorizeUser(userID, resourceID string) error
|    |
|    | -> fetchUserFromDB(userID string) (User, error)
|
| -> func getResourceFromDB(resourceID string) (Resource, error)

这些错误信息中哪一个能提供更多信息?

  • “检索资源:授权检查:从数据库获取用户:Mongo:结果中没有文档
  • “检索资源:Mongo:结果中没有文档 第一个问题直接告诉你,问题出在从数据库获取用户上。第二个错误很模糊,不清楚问题出在用户还是资源上。

通过封装错误并添加这些信息层,你不仅能更清晰地了解出错的原因,还能使用 errors.Is() 等工具检查特定类型的错误:

func readConfig(path string) error {
    return fmt.Errorf("read config: %w", ErrNotFound)
}

func main() {
    err := readConfig("config.json")
    if errors.Is(err, ErrNotFound) {
        fmt.Println("config file not found")
    }
}

一定要尽可能清楚地说明自己的错误,这是在出错时帮助我们的线索。

增强错误处理:Wrapping vs. Joining Errors in Go

在 Go 中,封装错误是一种标准的方法,尤其是当你想添加更多上下文或同时处理多个错误时,这种方法非常有用。

让我们先看看传统上我们是如何为错误添加上下文的,我们经常使用 %w 和 fmt.Errorf,就像这样:

err := errors.New("this is an error")

return fmt.Errorf("call Func2: %w", err)

现在,当需要同时处理多个错误时,以前我们仍然依赖 fmt.Errorf。Go 1.20 发布后,fmt.Errorf 现在可以将多个错误封装在一起:

fmt.Errorf("%w: %w", err1, err2)

不过,在 Go 1.20 中,我们也有了一个更新、更简单的工具,叫做 errors.Join():

func Func2() error {
    err := Func1()
    if err != nil {
        return errors.Join(err, errors.New("error from Func2"))
    }
    return nil
}

但这并不意味着我们应该完全抛弃 fmt.Errorf。这两种方法都与 errors.Is() 兼容,后者可让您检查特定错误。

不过,它们的用途略有不同。

fmt.Errorf 非常适合为单个错误添加更多细节,从而更清晰地说明出错的原因和位置。另一方面,errors.Join 是对多个错误进行分组的理想选择,这在您同时遇到多个问题并希望在不遗漏任何细节的情况下跟踪所有问题时非常有用。

“我还是看不出有什么区别,为什么不一直使用 fmt.Errorf 呢?

让我们想想这样一种情况:我们有许多函数,每个函数都可能返回错误,而我们同时在不同的程序中运行这些函数。如果其中几个函数都失败了,我们就会出现多个错误。

如果我们使用 fmt.Errorf 来堆叠这些错误,例如 fmt.Errorf(“%w:%w”),就会产生令人困惑的信息。

这种方法暗示错误之间存在一连串的依赖关系,这并不准确,因为这些错误是同时发生的,而不是按顺序发生的。

在这种情况下,使用 errors.Join 会更合理。

错误信息不应大写或以标点符号结束

您可能想知道为什么 Go 中的错误信息不能以大写字母开头或以标点符号结尾。起初看起来有点奇怪,但这是有实际原因的。

当你在 Go 中处理错误消息时,它们经常会被封装或与其他消息组合在一起。试想一下,一条以大写字母开头的错误信息出现在另一句话的中间,看起来会非常不协调。

下面的示例可以让你明白我的意思:

func openDatabase() error {
    return fmt.Errorf("Could not open the database.")
}

func initModule() error {
    return fmt.Errorf("Initialize module: %w.", openDatabase())
}

func startApp() error {
    return fmt.Errorf("Application startup failed: %w.", initModule())
}

func main() {
    fmt.Println(startApp().Error())
}

// Output: Application startup failed: Initialize module: Could not open the database...

注意到每个部分是如何以大写字母开头的吗?这有点破坏了流程,让整个错误信息看起来有点突兀。现在,如果我们用小写字母开头,就会更协调:

func openDatabase() error {
    return fmt.Errorf("could not open the database")
}

func initModule() error {
    return fmt.Errorf("initialize module: %w", openDatabase())
}

func startApp() error {
    return fmt.Errorf("application startup failed: %w", initModule())
}

func main() {
    fmt.Println(startApp().Error())
}

// Output: application startup failed: initialize module: could not open the database

避免返回 -1 或 nil 表示错误

在其他编程语言中,函数通过返回特殊值(如-1、null 或甚至空字符串"")来指示错误或缺失结果的情况非常常见。这种方法被称为 "带内错误提示"。

func OpenFile(fileName) *string {
    if (cannotOpenFile) {
        return nil
    }

    return fileContent
}

带内错误信号的最大问题在于,它迫使调用函数的人始终记得检查这些特殊值。老实说,这很容易出错。

那么,Go 的解决方案是什么呢?

Go 的解决方案: 多个返回值

在 Go 中,函数可以在返回常规结果的同时返回一个错误值,以清楚地表明操作是否成功。

func OpenFile(filename string) (string, error) {
	if cannotOpenFile {
		return "", fmt.Errorf("open file %q", filename)
	}
	return fileContent, nil 
}

如果不检查错误就试图使用结果,Go 不会放过你。如果你不处理错误值,它就会抛出一个编译时错误,迫使你立即处理失败的可能性:

content, err := OpenFile("example.txt")

processFileContent(content)

// compile error: err is unused

那么,你会得到什么呢?

  • 明确区分关注点: 很明显,哪一部分返回的是实际结果,哪一部分返回的是出错信息。

  • 强制错误处理: Go 确保你不会忽略潜在的问题。这大大减少了因忽略错误而出现的隐蔽错误。

  • 更好的可读性和可维护性: 你的代码实际上是在记录自己,清楚地显示出哪些地方会出错。 当然,凡事都有例外。

尽管 Go 通常更倾向于使用多返回值来更清晰地处理错误,但有时返回 nil 或 -1 也是合理且实用的。

例如,Go 标准库中的一些函数,如 "strings "包中的函数,会使用这些特殊值来表示特定的结果,这可以让字符串操作变得不那么冗长。

但是,这也意味着您需要掌握领域知识,才能理解这些特殊值的使用时间和含义,例如 io 包中的 io.EOF。

单独捕获异常:减少不必要的错误干扰

我们有几个函数,A 和 B,它们是这样设置的:

func B() error {
    if err := doSomething(); err != nil {
        log.Printf("failed do something: %v", err)
        return err
    }

    return nil
}

func A() error {
    if err := B(); err != nil {
        log.Printf("unable to call B: %v", err)
        return err
    }

    return nil
}

在这种设置下,当 B 遇到麻烦时,它会记录下错误,然后将其上传给 A。

"为什么会出现这样的问题?

现在,你可能会认为这很可靠,因为它会在多个点上跟踪错误,但实际上这只会产生大量干扰。

下面就是为什么这会造成问题:

  • 重复日志: 这会在日志文件中产生噪音,使诊断问题变得更加困难,因为同一错误会被记录多次。
  • 复杂的错误处理: 这会使错误处理逻辑变得更加复杂。
  • 可能出现其他错误: 处理错误的代码越多,错误潜入的空间就越大。

单触式错误处理的理念非常简单:每个错误都应在代码中定义明确的位置处理一次,而且只能处理一次。

更好的解决方案

因此,处理整个错误记录和传播问题的更好方法是明确选择是就地处理错误,还是在不增加任何额外干扰的情况下将错误向上传递。

如果是向上传递,那么添加更多的上下文通常是个好主意,这样可以帮助最终处理它的人了解一路上出了什么问题。

func B() error {
    if err := doSomething(); err != nil {
        return fmt.Errorf("do something: %w", err)
    }

    return nil
}

func A() error {
    if err := B(); err != nil {
        return fmt.Errorf("call B: %w", err)
    }

    return nil
}

// Centralized error logging when calling A
if err := A(); err != nil {
    log.Printf("failed to do A: %v", err)
}

简化 fmt.Errorf 中的错误信息

在 Go 中处理错误时,掌握正确的细节极其重要,这样你才能清楚地知道哪里出错了。

我们都知道使用 fmt.Errorf 以及 %w 如何让我们对错误进行包装,以保持出错原因的链条

if err != nil {
    return fmt.Errorf("failed to open file %s: %w", filename, err)
}

但实事求是地说,有时我们的错误信息会变成类似小型传奇的内容:"抓取时出错:无法检索日志:打开文件 server-logs.txt 失败:文件不存在"。这固然能提供信息,但也有点矫枉过正

我们一直在重复 "error while "和 "failed to "这样的短语,老实说,我们早就知道了,因为这就是个错误。

这里有一种更简洁的处理方式:

if err != nil {
    return fmt.Errorf("open file %s: %w", filename, err)
}

看看这里的区别,你得到的不是冗长的信息,而是 "抓取:检索日志:打开文件 server-logs.txt:文件不存在"。它简单明了,易于阅读,直奔主题。

因此,当你在 Go 中编写错误信息时,切记要简明扼要,重点关注哪些操作没有成功。

这样,我们不仅能让代码更简洁、更短小,还能让日后阅读日志的人更轻松。

就我个人而言,我更喜欢在错误日志信息中保留 "失败 "或否定词语,而不是错误本身

转载自:https://juejin.cn/post/7365802529924497417
评论
请登录