Go语言的100个错误使用场景(48-54)|错误管理

488 阅读9分钟

前言

大家好,这里是白泽。 《Go语言的100个错误以及如何避免》 是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“Go: Simple to learn but hard to master”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。

我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第六篇文章,对应书中第48-54个错误场景。

🌟 当然,如果您是一位 Go 学习的新手,您可以在我开源的学习仓库中,找到针对 《Go 程序设计语言》 英文书籍的配套笔记,其他所有文章也会整理收集在其中。

📺 B站:白泽talk,公众号【白泽talk】,聊天交流群:622383022,原书电子版可以加群获取。

前文链接:

7. 错误管理

🌟 章节概述:

  • 懂得何时使用 panic
  • 懂得何时包裹错误
  • 高效对比 error 类型和值(Go1.13)
  • 地道地处理 error
  • 懂得何时可以忽略 error
  • 在 defer 调用中处理 error

7.1 panicking(#48)

panic 使用示例:

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover", r)
        }
    }()
    f()
    fmt.Println("c")
}
​
func f() {
    fmt.Println("a")
    panic("foo")
    fmt.Println("b")
}
// 结果
a
recover foo

在触发 panic 之后,结束当前函数的执行,并且跳出函数调用栈:main(),在 main 函数中,在 return 之前,panic 被 recover 捕获。

⚠️ 注意:recover 必须声明在 defer 函数中,因为 defer 在程序 panic 之后,依旧会执行。

推荐使用 panic 的场景:

  1. 当发生系统错误,如返回的 HTTP 状态码 < 100 或者 > 999,此时意味着系统必然出错了。
  2. 当必要的依赖无法获取,且影响程序的功能运行时。

7.2 不清楚何时应该包裹一个 error(#49)

🌟 可能需要包裹一个 error 的场景:

  • 为一个 error 添加额外的上下文信息
// 在 Go1.13之后,可以通过 %w 实现
if err != nil {
    return fmt.Errorf("bar failed: %w", err)
}

此时创建的 error 本质是 wrapError 这个结构体,它有两个字段,msg 记录上述格式化的内容,而 err 字段保存一份原来的 err。

image-20240219225621301

这种场景的好处就是可以额外增加一些上下文信息,但是当解析错误的一端获取这个 error 之后,需要对应去 unwrap 这个错误,一定程度上也增加了耦合度,错误处理部分代码相当于与具体的错误有绑定关系,如果换一个错误可能需要编写其他的错误处理逻辑。

// 通过 %v 而不是 %w
if err != nil {
    return fmt.Errorf("bar failed: %w", err)
}

此时创建的 error 本质是一个单层的新的错误,它的内容就是格式化后的字符串,原先的 error 不存在了,好处就是无需额外 unwrap 一次。

image-20240219230200919

  • 将一个 error 标记为一个特定类型的错误。
type BarError struct {
    Err error
}
​
func (b BarError) Error() string {
    return "bar failed:" + b.Err.Error()
}
---------------------------------------------
if err != nil {
    return BarError{Err: err}
}

这种情况下需要自定义错误类型,好处是比较灵活,但是缺点是比较麻烦,要针对不同的类型的错误创建不同的结构体。

7.3 检查错误类型不够精确(#50)

场景假设:编写一个 HTTP 服务提供根据 ID 进行查询数量的功能,当 ID 传入格式错误时 HTTP 响应状态码 400,当数据库服务不可用时,响应状态码 503,根据这个场景,以下将提供几种错误类型检查方式。

自定义错误类型:

type transientError struct {
    err error
}
​
func (t transientError) Error() string {
    return fmt.Sprintf("transient error: %v": t.err)
}

错误类型检查方式一:

// 根据 ID 获取数量
func getTransactionAmount(transcationID string) (float32, error) {
    if len(transcationID) != 5 {
        return 0, fmt.Errorf("id is invaild: %s", transcationID)
    }
    
    amount, err := getTranscationAmountFromDB(transcationID)
    if err != nil {
        return 0, transientError{Err: err}
    }
    return amount, nil
}
// http 服务
func handler(w http.ResponseWriter, r *http.Request) {
    transcationID := r.URL.Query().Get("transcationID")
    
    amount, err := getTransactionAmount(transcationID)
    if err != nil {
        switch err := err.(type) {
        case transientError:
            http.Error(w, err.Error(), http.StatusServiceUnavailable)
        default:
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
        return
    }
    
    // 返回正确响应
}

这种场景下,根据断言检验返回的错误类型,通过 switch 分条件返回 400 和 503。

错误类型检查方式二:

// 假设 transientError 类型的错误将从 getTranscationAmountFromDB 返回
func getTranscationAmountFromDB(transcationID)float32, error) {
    // ...
    if err != nil {
        return 0, transientError{err: err}
    }
    // ...
}
​
// 根据 ID 获取数量
func getTransactionAmount(transcationID string) (float32, error) {
    if len(transcationID) != 5 {
        return 0, fmt.Errorf("id is invaild: %s", transcationID)
    }
    
    amount, err := getTranscationAmountFromDB(transcationID)
    if err != nil {
        return 0, fmt.Errorf("failed to get transcation %s: %w", transcationID, err)
    }
    return amount, nil
}
// http 服务
func handler(w http.ResponseWriter, r *http.Request) {
    transcationID := r.URL.Query().Get("transcationID")
    
    amount, err := getTransactionAmount(transcationID)
    if err != nil {
        if errors.As(err, &transienError{}) {
            http.Error(w, err.Error(),
                      http.StatusServiceUnavailable)
        } else {
            http.Error(w, err.Error(), 
                      http.StatusBadRequest)
        }
        return
    }
    
    // 返回正确响应
}

当 transientError 类型的错误将从 getTranscationAmountFromDB 返回,getTransactionAmount 函数返回的 warpError 结构将包裹 transientError,此时直接使用方式一中的断言将无法检测出被包裹的错误。

需要使用 Go1.13 提供的 `errors.As(err error, target interface{}) bool 方法,这个方法可以不断调用 warpError 结构的 Unwrap 方法,直到遇到 error 包装链中存在 errors.As() 函数第二个指针参数对应的错误类型,返回 true,并将错误存放在 target 变量中。

🌟 errors.As() 通常用于处理多种可能的错误类型,以便根据不同类型的错误执行不同逻辑,本质用于提取目标类型的错误。

7.4 检查错误值不够精确(#51)

示例代码:

err := query()
if err != nil {
    // 这里如果使用 == 比较两个类型错误,则如果遇到 wrapError,将永远为 false
    if errors.Is(err, sql.ErrNoRows) {
        // ...
    } else {
        // ...
    }
}

假设 err 可能是一个 warpError 结构,内部包裹着一个提前声明的错误:sql.ErrNoRows,但也有可能返回的 err 直接就是这个 sql.ErrNoRows,此时使用 errors.Is() 方法可以通过 unwrap 的方式,判断错误链上是否有某个具体的错误。

🌟 errors.Is() 本质用于判断目标错误类型是否存在。

7.5 一个错误处理两次(#52)

错误示例:

func getRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        log.Println("failed to validate source coordinates")
        return Route{}, err
    }
    
    err := validateCoordinates(dstLat, dstLng)
    if err != nil {
        log.Println("failed to validate source coordinates")
        return Route{}, err
    }
    return getRoute(srcLat, srcLng, dstLat, dstLng), nil
}
​
func validateCoordinates(lat, lng float32) error {
    if lat > 90.0 || lat < -90.0 {
        log.Printf("invalid latitude: %f", lat)
        return fmt.Errorf("invalid latitude: %f", lat)
    }
    if lng > 180.0 || lng < -180.0 {
        log.Printf("invalid longitude: %f", lng)
        return fmt.Errorf("invalid longitude: %f", lng)
    }
    return nil
}

这种情况下存在两个问题:

  1. 错误发生将打印两条日志,在高并发环境下,日志会出现乱序。
  2. 打印错误日志与 return 错误是两种处理错误的方式,只需要选择一种即可,打印了日志已经是处理了错误了。

修正示例1.0版本:

func getRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        return Route{}, err
    }
    
    err := validateCoordinates(dstLat, dstLng)
    if err != nil {
        return Route{}, err
    }
    return getRoute(srcLat, srcLng, dstLat, dstLng), nil
}
​
func validateCoordinates(lat, lng float32) error {
    if lat > 90.0 || lat < -90.0 {
        return fmt.Errorf("invalid latitude: %f", lat)
    }
    if lng > 180.0 || lng < -180.0 {
        return fmt.Errorf("invalid longitude: %f", lng)
    }
    return nil
}

此时放弃了错误日志打印,只保留了 return 的处理方式。但此时有一个问题,如果发生问题,getRoute 函数最后只会包含 invalid latitude: xxx 这样类似的错误信息,但是不知道是归属 src 还是 tar,因为原本第二条日志虽然会造成乱序,但本质还是提供了额外的错误信息的,这部分不能直接省略。

修正示例2.0版本:

func getRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        return Route{}, fmt.Errorf("failed to validate source coordinates: %w", err)
    }
    
    err := validateCoordinates(dstLat, dstLng)
    if err != nil {
        return Route{}, fmt.Errorf("failed to validate target coordinates: %w", err)
    }
    return getRoute(srcLat, srcLng, dstLat, dstLng), nil
}
​
func validateCoordinates(lat, lng float32) error {
    if lat > 90.0 || lat < -90.0 {
        return fmt.Errorf("invalid latitude: %f", lat)
    }
    if lng > 180.0 || lng < -180.0 {
        return fmt.Errorf("invalid longitude: %f", lng)
    }
    return nil
}

通过 fmt.Errorf() 的方式,将需要添加的上下文信息追加上去。

7.6 不处理错误(#53)

示例代码:

// At-most once delivery
// Hence, it's accepted to miss some of them in case of errors.
_ = notify()

如果确实可以忽略错误,则通过短下划线方式声明,而不是直接放弃返回值,同时配合注释说明原因。

7.7 不处理 defer 错误(#54)

场景:使用 sql.DB 数据库连接池查询数据库

错误示例1:

const query = "..."func getBalance(db *sql.DB, clientID string) (float32, error) {
    rows, err := db.Query(query, clientID)
    if err != nil {
        return 0, err
    }
    defer rows.Close()
    
    if rows.Next() {
        err := rows.Scan(&balance)
        if err != nil {
            return 0, err
        }
        return balance, nil
    }
    // ...
}
​
type Closer interface {
    Close() error
}

此时 rows.Close() 的调用可能会发生错误,表示连接池关闭失败,但是示例中没有处理这部分错误。

错误示例2:

func getBalance(db *sql.DB, clientID string) (float32, error) {
    rows, err := db.Query(query, clientID)
    if err != nil {
        return 0, err
    }
    defer func() {
        err = rows.Close()
    }()
    
    if rows.Next() {
        err := rows.Scan(&balance)
        if err != nil {
            return 0, err
        }
        return balance, nil
    }
    // ...
}

通过赋值的方式,将 rows.Close() 导致的错误传递给外部变量 err,但是这导致了一个新的问题,当下文 rows.Next() 发生错误的时候,无论情况如何,err 的内容最终都将被 rows.Close() 的执行结果覆盖,如果 rows.Next() 报错,但是 rows.Close() 正常关闭,则 err 最终为 nil。

🌟 修正示例:

defer func() {
    closeErr := rows.Close()
    if err != nil {
        if closeErr != nil {
            log.Printf("failed to close rows: %v", err)
        }
        return
    }
    err = closeErr
}
  • 当执行 rows.Close() 之前,如果 err 已经不是 nil

    • 如果 rows.Close() 报错,则打印一条日志
    • 如果 rows.Close() 关闭成功,则直接返回业务相关的 err
  • 当执行 rows.Close() 之前,如果 err 是 nil,则将 closeErr 赋值给 err,无论 closeErr 是否为 nil

🌟 上述的逻辑执行核心就是优先返回 getBalance() 内业务逻辑涉及到的错误,没有错误再考虑返回关闭连接池导致的错误。

小结

你已完成《Go语言的100个错误》全书学习进度54%,欢迎追更。