前言
大家好,这里是白泽。**《Go语言的100个错误以及如何避免》**是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“Go: Simple to learn but hard to master”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。
我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第六篇文章,对应书中第48-54个错误场景。
🌟 当然,如果您是一位 Go 学习的新手,您可以在我开源的学习仓库中,找到针对**《Go 程序设计语言》**英文书籍的配套笔记,其他所有文章也会整理收集在其中。
📺 B站:白泽talk,公众号【白泽talk】,聊天交流群:622383022,原书电子版可以加群获取。
前文链接:
- 《Go语言的100个错误使用场景(1-10)|代码和项目组织》
- 《Go语言的100个错误使用场景(11-20)|项目组织和数据类型》
- 《Go语言的100个错误使用场景(21-29)|数据类型》
- 《Go语言的100个错误使用场景(30-40)|数据类型与字符串使用》
- 《Go语言的100个错误使用场景(40-47)|字符串&函数&方法》
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 的场景:
- 当发生系统错误,如返回的 HTTP 状态码 < 100 或者 > 999,此时意味着系统必然出错了。
- 当必要的依赖无法获取,且影响程序的功能运行时。
7.2 不清楚何时应该包裹一个 error(#49)
🌟 可能需要包裹一个 error 的场景:
- 为一个 error 添加额外的上下文信息
// 在 Go1.13之后,可以通过 %w 实现
if err != nil {
return fmt.Errorf("bar failed: %w", err)
}
此时创建的 error 本质是 wrapError 这个结构体,它有两个字段,msg 记录上述格式化的内容,而 err 字段保存一份原来的 err。
这种场景的好处就是可以额外增加一些上下文信息,但是当解析错误的一端获取这个 error 之后,需要对应去 unwrap 这个错误,一定程度上也增加了耦合度,错误处理部分代码相当于与具体的错误有绑定关系,如果换一个错误可能需要编写其他的错误处理逻辑。
// 通过 %v 而不是 %w
if err != nil {
return fmt.Errorf("bar failed: %w", err)
}
此时创建的 error 本质是一个单层的新的错误,它的内容就是格式化后的字符串,原先的 error 不存在了,好处就是无需额外 unwrap 一次。
- 将一个 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
}
这种情况下存在两个问题:
- 错误发生将打印两条日志,在高并发环境下,日志会出现乱序。
- 打印错误日志与 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%,欢迎追更。