Go 零基础教程

Go 自定义错误类型

虽然内置的 error 接口和 errors.New() 足够应付简单的场景,但在复杂的业务系统中,我们往往需要知道更多关于错误的上下文信息:是哪个商品缺货了?缺了多少?调用外部 API 失败的状态码是多少?这个错误能重试吗?

通过创建自定义错误类型 (Custom Error Types),你可以为错误附加字段、方法和行为,使得错误不仅易于阅读,更易于被代码识别、分类和自动化处理。本章我们将带你全方位掌握这一高级技巧。

1. 定义自定义错误类型

在 Go 中,error 只是一个接口,它唯一的要求就是实现 Error() string 方法。因此,任何结构体 (Struct) 只要实现了这个方法,它摇身一变就成了一个合法的错误类型

1.1 创建一个基础的自定义错误

假设我们正在开发一个电商系统,我们需要一个“商品缺货”的错误,它不仅要有错误信息,还得记录商品 ID 和用户请求的购买数量。

package main

import (
	"fmt"
)

// 1. 定义一个结构体,包含我们需要的上下文信息
type OutOfStockError struct {
	ProductID string
	Quantity  int
}

// 2. 为该结构体实现 Error() string 方法 (通常使用指针接收者)
func (e *OutOfStockError) Error() string {
	return fmt.Sprintf("购买失败:商品 '%s' 库存不足,您请求的数量为: %d", e.ProductID, e.Quantity)
}

func main() {
    // 实例化我们的自定义错误,并当作普通 error 打印
	err := &OutOfStockError{ProductID: "IPHONE-15", Quantity: 100}
	fmt.Println(err) 
    // 输出: 购买失败:商品 'IPHONE-15' 库存不足,您请求的数量为: 100
}

1.2 为自定义错误添加专属方法

自定义错误最强大的地方在于,它是一个结构体,所以你可以给它绑定任意数量的方法。这在微服务架构中判断错误是否可重试时极其有用。

package main

import "fmt"

// APIError 代表调用外部接口发生的错误
type APIError struct {
	Endpoint   string
	StatusCode int
	Message    string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("API 请求失败: 接口=%s, 状态码=%d, 信息=%s", e.Endpoint, e.StatusCode, e.Message)
}

// IsRetryable 专属方法:判断该错误是否值得重试
func (e *APIError) IsRetryable() bool {
	// 例如:5xx 服务端错误,或者明确的限流报错,可以重试
	return e.StatusCode >= 500 || e.Message == "Rate limit exceeded"
}

func main() {
	err1 := &APIError{Endpoint: "/api/pay", StatusCode: 503, Message: "服务不可用"}
	fmt.Println(err1)
	fmt.Println("是否可重试:", err1.IsRetryable()) // 输出: true

	err2 := &APIError{Endpoint: "/api/pay", StatusCode: 400, Message: "参数非法"}
	fmt.Println("是否可重试:", err2.IsRetryable()) // 输出: false
}

2. 提取自定义错误细节:类型断言与 errors.As

当底层的业务函数返回一个 error 接口给你时,编译器只知道它是个错误,但不知道它是不是你定义的 OutOfStockError。如果你想读取里面特有的 ProductID 字段,你需要将它还原。

2.1 使用 errors.As (官方推荐最佳实践)

Go 1.13 引入了 errors.As 函数。它不仅能检查错误的类型,还能穿透错误包装链去寻找匹配的类型,并直接将值赋给目标变量,这是目前最安全、最优雅的做法。

package main

import (
	"errors"
	"fmt"
)

type InsufficientFundsError struct {
	AccountID string
	Balance   float64
	Attempted float64
}

func (e *InsufficientFundsError) Error() string {
	return fmt.Sprintf("账户 '%s' 余额不足: 余额=%.2f, 试图扣款=%.2f", e.AccountID, e.Balance, e.Attempted)
}

// 模拟扣款函数
func Withdraw(accountID string, balance, amount float64) error {
	if balance < amount {
		return &InsufficientFundsError{AccountID: accountID, Balance: balance, Attempted: amount}
	}
	return nil
}

func main() {
	err := Withdraw("USER-123", 50.0, 100.0)

	if err != nil {
		// 1. 声明一个你期望的自定义错误类型的指针变量
		var fundsErr *InsufficientFundsError
		
		// 2. 使用 errors.As 尝试将 err 转换为 fundsErr
		// 注意:第二个参数必须是指针的指针 (因为 fundsErr 本身就是指针)
		if errors.As(err, &fundsErr) {
			// 转换成功!现在你可以安全地使用 fundsErr 特有的字段了
			fmt.Printf("⚠️ 扣款被拒!账户 %s 缺口 %.2f 元\n", 
                fundsErr.AccountID, fundsErr.Attempted - fundsErr.Balance)
		} else {
			// 转换失败,说明它不是余额不足错误,是个别的未知错误
			fmt.Println("发生系统错误:", err)
		}
	}
}

(注:原教程提到了传统的类型断言 if fundsErr, ok := err.(*InsufficientFundsError); ok,虽然可用,但它无法处理被 fmt.Errorf("%w") 包装过的错误,所以在现代 Go 开发中,请优先使用 errors.As。)

3. 错误包装与拆包 (Wrapping and Unwrapping)

正如上一章所述,当你处于函数的上层,想要给底层抛上来的错误增加一些上下文(比如添加当前正在处理的文件名),但不希望破坏底层错误原始的类型结构时,你需要用到错误包装。

package main

import (
	"errors"
	"fmt"
)

// 底层的自定义错误
type FileNotFoundError struct {
	Filename string
}

func (e *FileNotFoundError) Error() string {
	return fmt.Sprintf("底层错误: 文件 [%s] 彻底找不到了", e.Filename)
}

func ReadConfig(filename string) error {
	// 模拟抛出底层自定义错误
	return &FileNotFoundError{Filename: filename}
}

func ProcessApp(filename string) error {
	err := ReadConfig(filename)
	if err != nil {
		// 使用 %w 包装错误,添加当前层的上下文
		return fmt.Errorf("ProcessApp 初始化失败: %w", err)
	}
	return nil
}

func main() {
	err := ProcessApp("db_config.yaml")

	if err != nil {
		fmt.Println("打印完整错误链:", err)
		// 输出: ProcessApp 初始化失败: 底层错误: 文件 [db_config.yaml] 彻底找不到了

		// 极其强大:虽然 err 被 fmt.Errorf 包装了一层
		// 但 errors.As 依然能穿透包装,精准提取出最里面的 FileNotFoundError!
		var notFoundErr *FileNotFoundError
		if errors.As(err, ¬FoundErr) {
			fmt.Println("✅ 成功提取到内层根因,丢失的文件是:", notFoundErr.Filename)
		}
	}
}

4. 哨兵错误 (Sentinel Errors) vs 自定义类型

在 Go 中定义错误,通常有两种流派:

哨兵错误 (Sentinel Errors):

  • 定义方式: var ErrNotFound = errors.New("not found")
  • 特点: 简单、全局唯一。调用方使用 errors.Is(err, ErrNotFound) 进行等值比较。
  • 适用场景: 错误状态非常固定,不需要携带任何动态参数或上下文数据(比如标准库里的 io.EOF)。

自定义错误类型 (Custom Error Types):

  • 定义方式: 创建一个 struct 并实现 Error()
  • 特点: 结构化、可携带大量动态数据、可绑定方法。调用方使用 errors.As 提取数据。
  • 适用场景: 需要记录发生错误的具体参数(如用户 ID、请求 URL)、或者需要根据错误类型执行不同分支逻辑的复杂业务场景。
核心建议: 在构建健壮的企业级应用时,尽量多用自定义错误类型。