forked from ZMbiubiubiu/go-mistakes
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
litianshi
committed
Nov 23, 2022
1 parent
fdecca5
commit 0a9a35d
Showing
4 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
66 changes: 66 additions & 0 deletions
66
chapter/7-error-management/7-4-Comparing-an-error-value-inaccurately.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
## 7.4 不准确地比较错误值 | ||
|
||
本节类似于我们刚刚讨论的那一节,但带有标记错误(错误值)。 首先,我们将定义标记错误传达的信息。 然后,我们将看到如何将错误与值进行比较。 | ||
|
||
标记错误是定义为全局变量的错误: | ||
|
||
```go | ||
import "errors" | ||
|
||
var ErrFoo = errors.New("foo") | ||
``` | ||
|
||
通常,约定是以 `Err` 开头,后跟错误类型;这里 `ErrFoo`,标记错误传达了 *预期* 的错误。但是我们所说的预期错误是什么意思呢?让我们在 SQL 库的上下文中讨论它。 | ||
|
||
我们想设计一个 `Query` 方法,允许对数据库执行查询。此方法返回行的一部分。找不到行的情况下应该怎么处理呢?这里我们有两个选择: | ||
|
||
* 返回标记值;例如,一个 nil 切片(想想 `strings.Index`,如果子字符串不存在则返回标记值 -1) | ||
* 或者返回客户端可以检查的特定错误 | ||
|
||
让我们采用第二种方法。因此,如果未找到任何行,我们的方法可以返回特定错误。我们可以将此错误归类为预期错误,因为允许传递不返回任何行的请求。相反,如果出现网络问题或连接轮询错误等错误,我们将面临意外错误。这并不意味着我们不想处理意外错误;这意味着在语义上,它们传达了不同的含义。 | ||
|
||
如果我们看一下标准库,我们会发现很多标记错误的例子: | ||
|
||
* `sql.ErrNoRows`:当查询没有返回任何行时返回(这正是我们的情况) | ||
* `io.EOF`:当没有更多输入时由 `io.Reader` 返回可用的。 | ||
|
||
这就是标记错误背后的一般原则。它们传达了客户期望检查的预期错误。因此,作为一般准则: | ||
|
||
* 预期错误应设计为错误值(标记错误):`var ErrFoo = errors.New("foo")` | ||
* 意外错误应设计为错误类型:`type BarError struct{ ... }` 用 `BarError` 实现 `error` 接口。 | ||
|
||
让我们回到常见的错误。我们如何将错误与特定值进行比较?我们可以使用 `==` 运算符: | ||
|
||
```go | ||
err := query() | ||
if err != nil { | ||
if err == sql.ErrNoRows { | ||
// ... | ||
} else { | ||
// ... | ||
} | ||
} | ||
``` | ||
|
||
在这里,我们调用了一个 `query` 函数并得到了一个错误。检查错误是否为 `sql.ErrNoRows` 是使用 `==` 运算符完成的。 | ||
|
||
但是,与我们在上一节中讨论的方式相同,也可以包装标记错误。 如果使用 `fmt.Errorf` 和 `%w` 指令包装 `sql.ErrNoRows`,则 `err == sql.ErrNoRows` 将始终为 false。 | ||
|
||
同样,从 1.13 版开始的 Go 提供了答案。我们已经看到了 `errors.As` 是如何被用来检查一个类型的错误的。对于错误值,我们应该使用它的对应方法:`errors.Is`。 让我们重写前面的例子: | ||
|
||
```go | ||
err := query() | ||
if err != nil { | ||
if errors.Is(err, sql.ErrNoRows) { | ||
// ... | ||
} else { | ||
// ... | ||
} | ||
} | ||
``` | ||
|
||
即使错误是使用 `%w` 包装的,也要使用 `errors.Is` 来替代 `==` 运算符去做比较工作。 | ||
|
||
总之,如果我们在应用程序中使用 `%w` 指令和 `fmt.Errorf` 进行错误包装,则不应该使用 `==` 而是使用 `errors.Is` 来检查特定值的错误。因此,即使标记错误被包装,`errors.Is` 也能够递归地解包并将链中的每个错误与提供的值进行比较。 | ||
|
||
现在,是时候讨论错误处理最重要的方面之一了:不要两次处理错误。 |
113 changes: 113 additions & 0 deletions
113
chapter/7-error-management/7-5-Handling-an-error-twice.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
## 7.5 两次处理错误 | ||
|
||
多次处理错误是开发人员经常犯的错误,而不是专门在 Go 中。让我们了解为什么这是一个问题以及如何有效地处理错误。 | ||
|
||
为了说明这个问题,让我们编写一个 `GetRoute` 函数来获取从一对源到一对目标坐标的路线。假设此函数将调用未导出的 `getRoute` 函数,其中包含计算最佳路线的业务逻辑。在调用 `getRoute` 之前,我们必须使用 `validateCoordinates` 验证源坐标和目标坐标。我们还希望记录可能的错误。这是一个可能的实现: | ||
|
||
```go | ||
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 target coordinates") | ||
return Route{}, err | ||
} | ||
|
||
return getRoute(srcLat, srcLng, dstLat, dstLng) | ||
} | ||
|
||
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 | ||
} | ||
``` | ||
|
||
该代码有什么问题?首先,我们可以注意到在 `validateCoordinates` 中重复 `invalid latitude` 或 `invalid longitude` 错误消息在日志记录和返回的错误中是多么麻烦。此外,如果我们以无效纬度运行它,例如,它将记录以下行: | ||
|
||
```shell | ||
2021/06/01 20:35:12 invalid latitude: 200.000000 | ||
2021/06/01 20:35:12 failed to validate source coordinates | ||
``` | ||
|
||
因此,一个错误有两个日志行,这是一个问题。为什么?因为它使调试变得更加困难。例如,如果该函数被并发调用多次,那么这两条消息在日志中可能不会一个接一个,从而使调试过程更加复杂。 | ||
|
||
根据经验,错误应该只处理一次。记录错误就是处理错误,返回错误也是如此。因此,我们应该记录或返回错误,而不是两者兼而有之。 | ||
|
||
让我们重写我们的实现以只处理一次错误: | ||
|
||
```go | ||
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) | ||
} | ||
|
||
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 | ||
} | ||
``` | ||
|
||
在这个版本中,每个错误只处理一次,直接返回。然后,假设 `GetRoute` 的调用者正在处理可能的日志错误,如果纬度无效,它将输出以下消息: | ||
|
||
```shell | ||
2021/06/01 20:35:12 invalid latitude: 200.000000 | ||
``` | ||
|
||
这个新的 Go 版本的代码完美吗?并不真地。例如,第一个实现在无效纬度的情况下导致两个日志。不过,我们知道哪个对 `validateCoordinates` 的调用失败了:源坐标或目标坐标。在这里,我们丢失了这些信息。因此,我们需要为错误添加额外的上下文。 | ||
|
||
让我们使用 Go 1.13 错误包装重写最新版本的代码(我们将省略 `validateCoordinates`,因为它将保持不变): | ||
|
||
```go | ||
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) | ||
} | ||
``` | ||
|
||
`validateCoordinates` 返回的每个错误现在都被包装为错误提供额外的上下文,无论它与源坐标还是目标坐标相关。因此,如果我们运行这个新版本,以下是调用者在源纬度无效的情况下将记录的内容: | ||
|
||
```shell | ||
2021/06/01 20:35:12 failed to validate source coordinates: | ||
invalid latitude: 200.000000 | ||
``` | ||
|
||
处理错误应该只进行一次。正如我们所见,记录错误就是处理错误。因此,我们应该记录或返回错误。通过这样做,我们可以简化代码并更好地了解错误情况。使用错误包装是最方便的方法,因为它允许我们传播源错误并将上下文添加到错误中。 | ||
|
||
在下一节中,我们将看到在 Go 中忽略错误的适当方法。 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
## 7.6 不被处理错误 | ||
|
||
在某些情况下,我们可能希望忽略函数返回的错误。如果我们遇到这种情况,在 Go 中应该只有一种方法。让我们明白为什么。 | ||
|
||
我们将考虑以下示例,其中我们调用一个返回单个 `error` 参数的 `notify` 函数。然而,由于我们对这个错误不感兴趣,我们将故意省略任何错误处理: | ||
|
||
```go | ||
func f() { | ||
// ... | ||
notify() | ||
} | ||
|
||
func notify() error { | ||
// ... | ||
} | ||
``` | ||
|
||
因为我们想忽略错误,所以在这个例子中,我们只是调用了 `notify` 而没有将其输出分配给经典的 `err` 变量。从功能的角度来看,这段代码没有任何问题:它将按预期编译和运行。 | ||
|
||
但是,从可维护性的角度来看,它可能会导致一些问题。让我们考虑一个新读者到达此代码。这位读者注意到 `notify` 返回一个错误,但它没有由父函数处理。他怎么能猜到是不是故意的?他怎么知道之前的开发者是忘记处理还是故意的? | ||
|
||
由于这些原因,当我们想要忽略 Go 中的错误时,只有一种方法可以编写它: | ||
|
||
```go | ||
_ = notify() | ||
``` | ||
|
||
我们没有将错误分配给变量,而是将其分配给空白标识符。编译和运行时方面,与第一段代码相比,它没有任何改变。然而,这个新版本明确表明我们对错误不感兴趣。 | ||
|
||
注释也可以伴随这样的代码。没有评论提及我们忽略了这样的错误: | ||
|
||
```go | ||
// Ignore the error | ||
_ = notify() | ||
``` | ||
|
||
此注释重复了代码的作用,应避免使用。但是,指出忽略此错误的原因可能是个好主意。例如: | ||
|
||
```go | ||
// Notifications are sent in best effort. | ||
// Hence, it's accepted to miss some of them in case of errors. | ||
_ = notify() | ||
``` | ||
|
||
忽略 Go 中的错误应该是例外的。在许多情况下,我们可能仍然倾向于记录它们,即使是 在较低的日志级别。然而,如果我们确定一个错误可以并且应该被忽略,我们必须通过将它分配给空白标识符来明确地做到这一点。这样,未来的读者就会明白这是故意的。 | ||
|
||
现在让我们讨论本章的最后一部分,如何处理 defer 函数返回的错误。 |
128 changes: 128 additions & 0 deletions
128
chapter/7-error-management/7-7-Not-handling-defer-errors.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
## 7.7 不被处理的 defer 错误 | ||
|
||
不处理 defer 语句中的错误是 Go 开发人员经常犯的错误。让我们了解问题是什么以及可能的解决方案是什么。 | ||
|
||
在下面的示例中,我们将实现一个函数来查询数据库以获取给定客户 ID 的余额。我们将使 用 `database/sql` 和 `Query` 方法。 | ||
|
||
> **Note** 让我们不要过多地研究这个包是如何工作的;我们将在 *SQL 常见错误的标准库* 章节中进行处理。 | ||
这是一个可能的实现(我们将关注查询本身,而不是结果的解析): | ||
|
||
```go | ||
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() | ||
|
||
// Use rows | ||
} | ||
``` | ||
|
||
`rows` 是一个 `*sql.Rows` 类型。它实现了 `Closer` 接口: | ||
|
||
```go | ||
type Closer interface { | ||
Close() error | ||
} | ||
``` | ||
|
||
此接口包含一个返回错误的 `Close` 方法(我们还将在 *不关闭瞬态资源* 中深入研究此主题)。我们在上一节中提到应该始终处理错误。然而,在这种情况下,由 defer 调用返回的错误被忽略: | ||
|
||
```go | ||
defer rows.Close() | ||
``` | ||
|
||
如上一节所述,如果我们不想处理错误,我们应该使用空白标识符显式忽略它: | ||
|
||
```go | ||
defer func() { _ = rows.Close() }() | ||
``` | ||
|
||
这个版本更冗长,但从可维护性的角度来看更好,因为我们明确标记了我们忽略错误。 | ||
|
||
然而,在这种情况下,与其盲目地忽略 defer 调用中的所有错误,不如问问自己这是否是最好的方法。在这种情况下,调用 `Close()` 将在无法从池中释放数据库连接时返回错误。因此,忽略这个错误可能不是我们想要做的。最有可能的是,更好的选择是记录一条消息: | ||
|
||
```go | ||
defer func() { | ||
err := rows.Close() | ||
if err != nil { | ||
log.Printf("failed to close rows: %v", err) | ||
} | ||
}() | ||
``` | ||
|
||
在这种情况下,如果关闭 `rows` 失败,它将记录一条消息,以便我们知道它。 | ||
|
||
现在,如果我们不处理错误,而是将其传播给 `getBalance` 的错误,以便他决定如何处理它,该怎么办?这种方法怎么样? | ||
|
||
```go | ||
defer func() { | ||
err := rows.Close() | ||
if err != nil { | ||
return err | ||
} | ||
}() | ||
``` | ||
|
||
此实现无法编译。实际上,`return` 语句与匿名 `func()` 函数相关联,而不是与 `getBalance` 相关联。 | ||
|
||
如果我们想将 `getBalance` 返回的错误与 defer 调用中捕获的错误联系起来,我们必须使用命名结果参数。让我们重写第一个版本: | ||
|
||
```go | ||
func getBalance(db *sql.DB, clientID string) ( | ||
balance float32, err 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` 变量,我们将在匿名函数中推迟对 `rows.Close()` 的调用。此函数将错误分配给 `err` 变量,使用命名结果参数进行初始化。 | ||
|
||
这段代码可能看起来不错;但是,它有一个问题。事实上,如果 `rows.Scan` 返回错误,`rows.Close` 无论如何都会被执行 。然而,由于此调用覆盖了 `getBalance` 返回的错误,而不是返回错误,如果 `rows.Close` 成功返回,我们可能会返回 nil 错误 。换句话说,如果调用 `db.Query` 成功(函数的第一行),`getBalance` 返回的错误将始终是 `rows.Close` 返回的错误,这不是我们想要的。 | ||
|
||
实现的逻辑并不简单。如果 `rows.Scan` 成功: | ||
|
||
* 如果 `rows.Close` 成功:不返回错误。 | ||
* 如果 `rows.Close` 失败:返回此错误。 | ||
|
||
但是,如果 `rows.Scan` 失败,逻辑会稍微复杂一些,因为我们可能需要处理两个错误: | ||
|
||
* 如果 `rows.Close` 成功:从 `rows.Scan` 返回错误。 | ||
* 如果 `rows.Close` 失败:? | ||
|
||
在 `rows.Scan` 和 `rows.Close` 都失败了,我们该怎么办?这里有不同的选择。例如,返回一个传达两个错误的自定义错误。我们将实施的另一个可能是返回 `rows.Scan` 错误但记录 `rows.Close` 错误。这是匿名函数的最终实现: | ||
|
||
```go | ||
defer func() { | ||
closeErr := rows.Close() | ||
if err != nil { | ||
if closeErr != nil { | ||
log.Printf("failed to close rows: %v", err) | ||
} | ||
return | ||
} | ||
err = closeErr | ||
}() | ||
``` | ||
|
||
`rows.Close` 错误分配给另一个变量:`closeErr`。在将其分配给 `err` 之前,我们检查 `err` 是否不同于 nil。如果是这种情况,则 `getBalance` 已返回错误。在这种情况下,我们决定记录它并返回现有错误。 | ||
|
||
如前所述,应该始终处理错误。对于 defer 调用返回的错误,我们至少应该明确地忽略它。如果这还不够,我们可以决定通过记录错误或将错误传播给调用者来直接处理错误,如本节所示。 |