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)、或者需要根据错误类型执行不同分支逻辑的复杂业务场景。
核心建议: 在构建健壮的企业级应用时,尽量多用自定义错误类型。