1

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

 6 months ago
source link: https://www.cnblogs.com/YLTFY1998/p/18024669
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

大家好,这里是白泽。《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%,欢迎追更。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK