写点什么

Golang Error 处理:机制与最佳实践

作者:cqyanbo
  • 2025-01-22
    上海
  • 本文字数:6479 字

    阅读完需:约 21 分钟

在开发可靠且可维护的应用程序时,Error 处理是至关重要的一环。在 Go(Golang)中,Error 处理设计简洁而高效,旨在使开发者能够编写易于理解和可预测的代码。本文将结合 Go 官方文档,详细介绍 Golang 的 Error 处理机制,并分享一些实用的最佳实践,帮助您提升代码质量。

Go 的 Error 处理简介

Go 的 Error 处理与许多编程语言不同,它没有使用传统的打断程序控制流的异常机制,而是通过明确的返回值来处理 Error。这种做法让 Error 的信息更显眼,开发者可以更加清晰自主的识别和处理 Error,避免了隐藏的控制流带来的不确定性。

Error 类型和基本创建方法

Go 中的 Error 是一个接口。所有的 Errors 都是通过实现 Error() 方法的接口来定义的。

// error 接口type error interface {    Error() string}
复制代码

 

任何实现了该方法的类型都可以作为 Error类型使用。Go 官方推荐使用标准的 errors.Newfmt.Errorf 来创建基础的 Error。具体可以看下面的例子:

 

例子 1:使用 errors.New 创建 Error

import "errors"

func foo() error { return errors.New("something went wrong")}
复制代码

这个使用的是 errors 包提供的原生的 New 方法来生成一个 error,同时给这个 error 了一个描述性的文字输入。Go 官方提供的源码如下:

// because the former will succeed if err wraps an [*io/fs.PathError].package errors

// New returns an error that formats as the given text.// Each call to New returns a distinct error value even if the text is identical.func New(text string) error { return &errorString{text}}

// errorString is a trivial implementation of error.type errorString struct { s string}

func (e *errorString) Error() string { return e.s}

复制代码

通过源码得知官方只提供了一个最简单的带一个描述性字段的 error。但我们的应用程序却是复杂和多层次的,这个简单的 errror 并不一定能覆盖所有的场景。我们在后面高级应用中会介绍如何在这基础上扩展出不同的,带着更多信息的 error 类型。

 

例子 2:使用 fmt.Errorf 来格式化 Error 信息:

import "fmt"

func bar(a int) error { if a < 0 { return fmt.Errorf("negative number: %d", a) } return nil}
复制代码

这个方法提供了额外的格式化字符串的方法。他返回的 error 就不一定是一个简单的 error 了,有可能是一个复杂的,多层 wrapped 的 error。这个方法的详细介绍我会另外写一个博客来介绍。

Error 的返回和检查

Go 中,可能会发生 Error 的函数通常将 Error 作为最后一个返回值返回。这种设计让 Error 检查变得显而易见。所以在调用函数时,开发者就需要自己去检查 Error,并采取相应的措施。

 

例子 3:返回和检查 Error:

func divide(a, b int) (int, error) {    if b == 0 {        return 0, errors.New("除数不能为零")    }    return a / b, nil}

func main() { result, err := divide(4, 0) if err != nil { fmt.Println("Error:", err) return } fmt.Println("结果:", result)}
复制代码

高级 Error 处理

包装(wrapping)Error

在 Go 语言中,包装(wrapping)Error 的设计思想主要是为了解决以下问题:

1. 错误上下文丢失的问题:当函数调用链较长时,直接返回原始错误可能导致无法追踪错误发生的具体位置和原因。包装错误可以携带更多上下文信息。

2. 错误调试和处理:通过包装错误,可以在日志中记录更多信息,帮助开发者快速定位问题。同时,包装后的错误依然可以通过原始错误类型进行判定,便于代码的错误处理逻辑。

3. 符合接口约定:包装错误的设计遵循 Go 的 error 接口,确保包装后的错误仍然可以通过 error 使用,而不会破坏已有代码的兼容性。

 

例子 4:我们可以根据项目需求自定义 Error:

package main

import ( "fmt")

type MyError struct { Code int Message string}

func (e *MyError) Error() string { return fmt.Sprintf("Error %d: %s", e.Code, e.Message)}

func main() { originalErr := &MyError{Code: 404, Message: "Not Found"} wrappedErr := fmt.Errorf("failed to fetch data: %w", originalErr)

// 类型断言提取错误 var myErr *MyError if errors.As(wrappedErr, &myErr) { fmt.Printf("Custom error code: %d, message: %s\n", myErr.Code, myErr.Message) }}
复制代码


内部方法

Go 1.13 引入了 errors.Iserrors.As,这些功能帮助开发者处理被包装的 Error,增强了 Error 处理的灵活性和可维护性。

 

例子 5:使用 fmt.Errorf 添加上下文信息,并使用 errors.Iserrors.As 进行 Error 检查:

import (    "errors"    "fmt")

type MyCustomError struct { Code int Message string}

func (e *MyCustomError) Error() string { return fmt.Sprintf("Error %d: %s", e.Code, e.Message)}

func readFile(filename string) error { return fmt.Errorf("无法读取文件 %s: %w", filename, errors.New("文件未找到"))}

func main() { baseErr := errors.New("文件未找到") wrappedErr := fmt.Errorf("上下文信息: %w", baseErr)

if errors.Is(wrappedErr, baseErr) { fmt.Println("匹配到基础 Error!") }

var targetErr *MyCustomError if errors.As(wrappedErr, &targetErr) { fmt.Println("匹配到自定义 Error!") }}
复制代码

在这个例子中,fmt.Errorf 用来为 Error 添加上下文信息,%w 标志表示将一个 Error“包装”到另一个 Error 中。errors.Iserrors.As 使得开发者能够更方便地解包和检查 Error。

 

Panic 和 Recover

在 Go 中,panic 用于表示程序的异常情况(如严重的编程 Error 或关键性的失败),而 recover 允许我们从 panic 中恢复。Go 不鼓励使用 panic 进行常规 Error 处理

panic 是 Go 中引发运行时错误的机制,用于表示程序进入了不可恢复的状态,例如:

• 访问数组越界

• 空指针解引用

• 程序逻辑中预期的致命错误

当调用 panic 时,当前函数会立即终止执行,开始执行 逐层回退(unwinding the stack) 的过程:

• 延迟函数(defer)按照逆序依次执行,就是后入先出的方式。

• 如果没有被 recover 捕获,最终程序崩溃并输出堆栈跟踪信息。

通常不建议在正常逻辑中使用 panic,但在以下场景可能会用到:

• 检测无法恢复的错误,例如编译器或框架的严重错误。

• 测试用例中引发的错误。

 

例子 6:panic 基本用法

package main

import "fmt"

func main() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered from panic:", r) } }() fmt.Println("Start") panic("Something went wrong!") // 触发 panic fmt.Println("End") // 不会执行}
复制代码

 

例子 7:使用 panic 进入退出阶段,最终 panic 3 的结果会覆盖前面的 panic 1 和 2:

ackage main

import "fmt"

func main() { defer func() { fmt.Println(recover()) // 3 }() defer panic(3) // 将替换恐慌 2 defer panic(2) // 将替换恐慌 1 defer panic(1) // 将替换恐慌 0 panic(0)}
复制代码

 

panic 和 recover 的执行流程

1. 遇到 panic:

  • 立即停止当前函数执行。

  • 执行所有已注册的 defer。

  • 如果 defer 中调用了 recover,并成功捕获异常,程序继续运行。

2. 如果没有调用 recover:

  • 当前函数向调用栈上传递 panic。

  • 如果传播到最顶层的 main 或 goroutine,程序崩溃并退出。

 

注意事项

1. 滥用 panicrecover:

• 不建议在普通的错误处理中使用 panic,应该使用返回错误的方式(error)。

• 仅在无法恢复的情况下使用 panic。

2. 嵌套 panic:

• 如果在 defer 中再次触发 panic,原来的 panic 会被覆盖。

• 避免在错误恢复代码中引发新的 panic。

3. recover 仅在 defer 中有效

• 如果直接调用 recover,不会捕获任何异常。

 

哨兵 Error

哨兵 Error 主要就是针对一些需要对不同类型的 Error 进行特殊处理的场景。哨兵 Error 的例子如下:

例子 8:哨兵 Error 的定义

package mypackage

import "errors"

var ErrNotFound = errors.New("resource not found")var ErrInvalidInput = errors.New("invalid input")
复制代码

 

例子 9:哨兵 Error 的使用

package mypackage

func FindResource(id int) error { if id <= 0 { return ErrInvalidInput } // 假设资源不存在 return ErrNotFound}
复制代码

 

例子 10:分类型处理 Error

package main

import ( "fmt" "mypackage")

func main() { err := mypackage.FindResource(-1)

if err == mypackage.ErrInvalidInput { fmt.Println("Invalid input provided") } else if err == mypackage.ErrNotFound { fmt.Println("Resource not found") } else { fmt.Println("Unknown error:", err) }}
复制代码

 

例子 11:在 Go 1.13+ 中,推荐使用 errors.Is 来判断哨兵错误是否匹配,因为它可以处理错误链中被包装的情况。

package main

import ( "errors" "fmt")

var ErrNotFound = errors.New("resource not found")

func FindResource() error { // 包装错误 return fmt.Errorf("database query failed: %w", ErrNotFound)}

func main() { err := FindResource()

// 使用 errors.Is 判断 if errors.Is(err, ErrNotFound) { fmt.Println("Resource not found") } else { fmt.Println("Other error:", err) }}
复制代码

Go Error 处理的最佳实践

Go 的 Error 处理机制简洁而强大,合理运用最佳实践不仅能提升代码的可读性和健壮性,还能增强系统的可维护性和可扩展性。以下是一些在实际开发中非常重要的 Error 处理实践,您可以根据项目需求灵活调整应用。

1. 立即检查 Error

尽早检查 Error 是 Go 中的核心原则,Error 发生后应尽快处理,而不是等待或忽略它。早期检查 Error 有助于防止进一步的 Error 传播,减少程序出错的概率。

最佳实践

  • 每当函数返回 Error 时,立即检查并处理。

  • 如果 Error 处理不当,可能会导致程序崩溃或产生不可预测的行为。

 

例子 12

func divide(a, b int) (int, error) {    if b == 0 {        return 0, errors.New("除数不能为零")    }    return a / b, nil}

func main() { result, err := divide(4, 0) if err != nil { // 立即处理 Error fmt.Println("Error:", err) return } fmt.Println("结果:", result)}
复制代码

在这个例子中,divide 函数返回的 Error 应该立即在 main 函数中进行处理。这种做法使得 Error 处理更加清晰和直接。

2. 为 Error 添加上下文

在 Error 发生的地方添加更多的上下文信息,能帮助开发者更好地理解 Error 的背景,尤其在处理复杂的系统时尤其重要。使用 fmt.Errorf%w 操作符,可以将 Error 包装并添加更多有用的信息。

最佳实践

  • 使用 fmt.Errorf 将 Error 与额外的上下文信息结合,帮助调试时提供更多的线索。

  • 尽量避免直接返回原始 Error,应该提供详细的上下文信息。

 

例子 13

func readFile(filename string) error {    return fmt.Errorf("无法读取文件 %s: %w", filename, errors.New("文件未找到"))}

func main() { err := readFile("test.txt") if err != nil { fmt.Println("发生 Error:", err) }}
复制代码

通过这种方式,Error 不仅传递了原始的 Error 信息,还包含了“无法读取文件”的上下文,帮助开发者快速定位问题所在。

3. 谨慎使用哨兵 Error

哨兵 Error(Sentinel Errors)是预定义的 Error,用于表示某些常见的 Error 情况。例如,可以定义一个特定的 Error 值来表示“文件未找到”。然而,过度依赖哨兵 Error 可能会导致 Error 处理过于僵化,限制了灵活性。

最佳实践

  • 为常见的 Error 场景定义哨兵 Error,但避免滥用。

  • 使用 errors.Iserrors.As 来处理和检查哨兵 Error,确保代码的灵活性。

 

例子 14

var ErrNotFound = errors.New("资源未找到")

func findResource(id string) error { return ErrNotFound}

func main() { err := findResource("123") if errors.Is(err, ErrNotFound) { fmt.Println("未找到资源") }}
复制代码

在这个例子中,ErrNotFound 是一个常见的哨兵 Error,表示某个资源未找到。当 Error 发生时,开发者可以通过 errors.Is 来判断是否是这个特定的 Error。

4. 利用 errors.Iserrors.As

Go 1.13 引入的 errors.Iserrors.As 可以帮助开发者更方便地检查和处理包装的 Error。这两个函数大大提升了 Error 处理的灵活性和可维护性。

最佳实践

  • 使用 errors.Is 判断 Error 是否是某种特定的类型。

  • 使用 errors.As 将 Error 转换为特定类型,方便获取更详细的信息。

 

例子 15

type PathError struct {    Path string    Err  error}

func (e *PathError) Error() string { return fmt.Sprintf("路径 Error: %s", e.Path)}

func main() { baseErr := errors.New("文件未找到") wrappedErr := &PathError{ Path: "/test/path", Err: baseErr, }

if errors.Is(wrappedErr, baseErr) { fmt.Println("匹配到基础 Error") }

var pathErr *PathError if errors.As(wrappedErr, &pathErr) { fmt.Println("路径 Error:", pathErr.Path) }}
复制代码

通过 errors.Iserrors.As,开发者能够灵活地处理不同类型的 Error,无论是基础 Error,还是经过包装的 Error。

 5. 避免使用 panic 和 recover

panic 用于程序遇到无法恢复的 Error 时,如严重的编程 Error 或不可预料的系统故障。Go 不推荐在正常 Error 处理时使用 panic。只有在程序遇到极其特殊的情况时,才考虑使用它。

最佳实践

  • panic 保留给极端情况,不要用于常规的 Error 处理。

  • 使用 recoverpanic 中恢复,但要确保程序依然能继续稳定运行。

 

例子 16

func safeDivide(a, b int) {    defer func() {        if r := recover(); r != nil {            fmt.Println("从 panic 中恢复:", r)        }    }()

if b == 0 { panic("除数不能为零") } fmt.Println(a / b)}

func main() { safeDivide(4, 0) fmt.Println("程序继续运行...")}
复制代码

在此例中,panic 发生时,程序通过 recover 恢复并继续运行,这避免了程序崩溃。

 6. 适当记录 Error

Error 的记录不仅对调试有帮助,还是生产环境中监控和诊断问题的重要工具。合理地记录 Error 信息,可以帮助开发者追踪问题的根源,特别是在面对复杂系统时。

最佳实践

  • 记录 Error 时,要避免暴露过多的内部 Error 细节,保护系统安全。

  • 使用适当的日志级别(如信息、警告、Error),确保日志的可读性和可追溯性。

 

例子 17

import "log"

func processData() error { // 假设此处发生了一个 Error return errors.New("数据处理失败")}

func main() { err := processData() if err != nil { log.Printf("处理数据时发生 Error: %v", err) }}
复制代码

通过日志记录 Error,开发者能够及时获取系统出现的问题,同时保持系统的可维护性。

 

7. 为 Error 场景编写测试

在实际开发中,Error 测试不仅能够帮助验证代码的正确性,还能保证 Error 处理在各种场景下都能正常工作。编写单元测试来检查 Error 是开发高质量代码的有效方式。

最佳实践

  • 在编写单元测试时,模拟 Error 发生的情况,确保 Error 能被正确地捕获和处理。

  • 测试正常情况和 Error 情况,确保程序在两种情况下都能按预期运行。

 

例子 18

import "testing"

func TestDivide(t *testing.T) { _, err := divide(4, 0) if err == nil { t.Errorf("expected error but got nil") }}
复制代码

通过单元测试,可以确保 Error 处理代码的质量和可靠性。


谢谢阅读!更多内容可以到公众号【go 工坊】。我们一起学 Go:


发布于: 刚刚阅读数: 4
用户头像

cqyanbo

关注

还未添加个人签名 2018-05-30 加入

还未添加个人简介

评论

发布
暂无评论
Golang Error处理:机制与最佳实践_cqyanbo_InfoQ写作社区