Skip to content

Commit

Permalink
7-4~7-7
Browse files Browse the repository at this point in the history
  • Loading branch information
litianshi committed Nov 23, 2022
1 parent fdecca5 commit 0a9a35d
Show file tree
Hide file tree
Showing 4 changed files with 354 additions and 0 deletions.
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 chapter/7-error-management/7-5-Handling-an-error-twice.md
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 中忽略错误的适当方法。
47 changes: 47 additions & 0 deletions chapter/7-error-management/7-6-Not-handling-a-error.md
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 chapter/7-error-management/7-7-Not-handling-defer-errors.md
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 调用返回的错误,我们至少应该明确地忽略它。如果这还不够,我们可以决定通过记录错误或将错误传播给调用者来直接处理错误,如本节所示。

0 comments on commit 0a9a35d

Please sign in to comment.