Go 中的错误处理:errors.Is、errors.As 和错误包装

1. 概述

在 Go 中,错误是普通的值。函数将错误返回给调用者,调用者自己决定如何处理。当错误经过多个层级(如 repository、service、handler)时,你通常想要添加上下文,同时还能识别原始错误。

本文指导如何使用 fmt.Errorf 和 %w 进行错误包装,使用 errors.Is 检查错误,使用 errors.As 获取自定义错误,以及使用 errors.Join 合并多个错误。文章末尾包含测试和常见错误。所有示例仅使用标准库,可以在 Go 1.20 及以上版本运行。其中 errors.AsType 需要 Go 1.26。

2. Sentinel 错误和 == 比较的限制

在 Go 中,error 是一个非常小的接口:

type error interface {
	Error() string
}

定义一个“命名”错误的最简单方式是创建 sentinel 错误。这是一个 package 级别的变量,调用者可以直接比较:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("未找到订单")
	}
}
未找到订单

errors.New 创建一个新的指针。因此 err == ErrOrderNotFound 仅在 err 本身就是这个变量时才为真。这种方法可以很好地工作,直到你开始向错误添加上下文。

3. 使用 fmt.Errorf 和 %w 进行错误包装

3.1. %w 和 %v 的区别在哪里

假设 service 层想要在错误消息中添加订单 ID:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

逐行解释:

  • fmt.Errorf(... %w ...) 创建一个新的错误。该错误保持对原始 err 的引用,通过 Unwrap() error 方法。
  • err == ErrOrderNotFound 返回 false,因为 err 现在是一个不同的值,称为包装的错误。
  • errors.Is 遍历包装链并在其中找到 ErrOrderNotFound。
  • errors.Unwrap 剥去一层并返回原始错误。

如果你将 %w 更改为 %v,打印的消息仍然相同。但是 errors.Is 将返回 false,因为 %v 只是复制文本字符串,不保留原始错误。

3.2. 多层包装链

每一层都可以添加另一个包装。errors.Is 仍然可以找到原始错误,无论它有多深:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

错误消息现在读起来像一个压缩的堆栈跟踪:最外层在左边,根本原因在右边。

4. errors.Is:检查“这个错误是 X 吗”

errors.Is(err, target) 如果链中的任何错误满足以下两个条件之一,则返回 true:

  1. 当使用 == 比较时等于 target,或
  2. 有一个 Is(error) bool 方法,该方法对 target 返回 true。

第二个条件允许一个错误类型声称它“等价于”一个 sentinel。例如,你有一个通用的 ErrNotFound 错误和一个更详细的客户错误类型:

var ErrNotFound = errors.New("not found")

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

HTTP 处理程序只需检查 errors.Is(err, ErrNotFound) 来返回 404。它不需要知道错误来自订单、客户还是产品。

注意: 不要在 Is 方法内调用 Unwrap,因为 errors.Is 已经自动遍历链。Is 方法只应该回答当前错误类。

5. errors.As:提取特定类型的错误

Sentinel 只能告诉你错误是什么。当需要添加错误代码或 ID 等数据时,你使用自定义错误类型并用 errors.As 提取它。

5.1. 带有 Unwrap 的自定义错误类型

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

调用者处理如下:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

关键要点:

  • errors.As 遍历包装链。当遇到第一个可以分配给 *PaymentError 类型的错误时,它将该错误分配给 pe 然后返回 true。
  • 因为 PaymentError 有 Unwrap(),errors.Is(err, ErrInsufficientFunds) 仍然可以穿透到 Err 字段。
  • 第二个参数必须是指针指向目标变量。这里是 &pe,类型为 **PaymentError。

5.2. 指针接收者的陷阱

Error() 方法定义在 *PaymentError 上,所以只有 *PaymentError 实现了 error。这样写会出错:

var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error

go vet 在构建时会捕获这个错误。在 CI 中运行 go vet ./...。容易记住的规则:目标变量的类型必须完全相同你返回的类型。你返回 &PaymentError{...},那么目标变量必须是 *PaymentError。

5.3. errors.AsType (Go 1.26+)

从 Go 1.26 开始,标准库添加了一个更简洁的泛型版本。你不需要预先声明变量:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

这种方式在编译时是类型安全的,所以不会像第 5.2 节那样导致 panic。如果你的项目已升级到 Go 1.26,你应该使用它。

6. 使用 errors.Join 合并多个错误

从 Go 1.20 开始,一个错误可以包装多个子错误。典型的情况是数据验证,当你想一次性报告所有错误,而不是在第一个错误处停止时:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

记得在导入部分添加 "strings"。结果显示三件事:

  • errors.Join 使用换行符连接消息。
  • errors.Is 和 errors.As 可以检查每一个子错误。
  • 如果所有参数都是 nil 或列表为空,errors.Join 返回 nil。因此你不需要编写额外的 if len(errs) == 0。

fmt.Errorf 也可以在同一个格式字符串中接受多个 %w,例如 fmt.Errorf("sync order: %w; %w", errDB, errCache)。生成的错误将包装这两个错误。

7. 为错误行为编写测试

在测试时,应该使用 errors.Is 或 errors.As 检查错误的身份。不要比较错误消息字符串。下面是一个 checkout 的表格驱动测试,放在 checkout_test.go 文件中:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "资金充足", balance: 1000, amount: 500},
		{name: "资金不足", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("意外出错,收到:%v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("错误不包含 *PaymentError:%v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q,想要 %q", pe.Code, tc.wantCode)
			}
		})
	}
}

运行测试:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/资金充足
=== RUN   TestCheckout/资金不足
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/资金充足 (0.00s)
    --- PASS: TestCheckout/资金不足 (0.00s)
PASS

如果以后有人修改错误消息或添加另一层包装,这个测试仍然会正确运行。这就是为什么应该检查身份而不是字符串。

8. 常见错误以及何时不应包装

8.1. 比较 err.Error() 字符串

if strings.Contains(err.Error(), "not found") { ... } // 易于损坏

只要修改一次消息,或者另一个错误恰好包含“not found”短语,逻辑就会损坏。应该使用 sentinel 配合 errors.Is。

8.2. 类型化 nil

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... 没有错误 ...
	return pe // 不是 nil!
}

此时接口 error 包含类型 *PaymentError 的值 nil。由于接口已经有类型,err != nil 仍然返回 true。当没有错误时,应该始终明确地写 return nil。

8.3. 包装暴露内部细节

当你用 %w 包装 sql.ErrNoRows 时,你的 package 隐含承诺调用者可以检查 errors.Is(err, sql.ErrNoRows)。那个错误就成为了公开 API 的一部分。以后你改用 MongoDB 时,调用者的代码就会中断。

在 package 的边界上,你有两个更好的选择:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // 转换为领域错误
}
return fmt.Errorf("find order %d: %v", id, err) // 仅保留消息,隐藏原始错误

注意: %w 是关于 API 的决定,而不仅仅是格式。仅当你真的想让调用者检查原始错误时才进行包装。

8.4. 既记录又返回

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

在每个层级都这样做的话,一个错误会在日志中出现多次。一般规则是要么处理(记录),要么返回,不要同时做两件事。记录应该由最外层处理,例如 HTTP 处理程序或 main 函数。

8.5. 重复的上下文,缺少信息

类似 failed to load order: failed to query: failed to ... 的消息很长但没有说明任何额外信息。应该记录操作名称和重要数据,如 loadOrder id=7: ...。省略“failed”这个词,因为既然已经是错误,显然是失败了。

8.6. 何时不应该使用 errors.As

如果你只需要知道错误的类型是什么,使用 errors.Is 配合 sentinel 就足够了,也更简单。只有当调用者真的需要附带数据(如错误代码、错误字段或重试时间)时,才创建自定义错误类型。

9. 结论

你已经浏览了 Go 的错误处理工具集:

  • fmt.Errorf 配合 %w 添加上下文同时保留原始错误。%v 只保留文本。
  • errors.Is 检查错误是否匹配 sentinel,贯穿整个包装链。一个错误类型可以定义 Is 方法来声称等价性。
  • errors.As(或 Go 1.26 的 errors.AsType)提取特定类型的错误及其数据。目标变量的类型必须与返回的类型完全匹配。
  • errors.Join 和多个 %w 允许一个错误包装多个子错误。

最后,应该将包装视为 API 设计的一部分。有目的地进行包装,在 package 的边界处将基础设施错误转换为领域错误,并根据错误身份而不是消息字符串编写测试。

golangerror handlingerrorsbest practices