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:
- 当使用
==比较时等于target,或 - 有一个
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 的边界处将基础设施错误转换为领域错误,并根据错误身份而不是消息字符串编写测试。