13-错误处理:把失败变成可读、可判断的结果

🔖 Go 语言学习教程 · 第 13 篇

📌 本系列基于 Go 1.23 编写,代码兼容 Go 1.21+

全文约 4200 字 | 阅读约 13 分钟


欢迎来到第十三篇 👋

程序不可能永远成功:文件可能不存在,网络可能超时,用户输入可能不合法,数据库也可能暂时不可用。优秀的错误处理不是"把错误吞掉",而是让错误携带足够的上下文,并让调用方能稳定判断下一步该做什么。

Go 不使用传统的 try/catch,而是把错误作为普通返回值交给调用者处理。看起来啰嗦,却让失败路径显式可见。


✅ 上一篇练习题答案

以下是上一篇练习题的参考答案,欢迎在评论区分享你的解法~

1. Shape 接口

go 复制代码
type Shape interface {
	Area() float64
}

type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * c.Radius * c.Radius
}

type Rectangle struct {
	Width  float64
	Height float64
}

func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

2. 同时处理多种动态类型

go 复制代码
func printDetail(value any) {
	switch v := value.(type) {
	case string:
		fmt.Println("字符串:", v)
	case int:
		fmt.Println("整数:", v)
	case fmt.Stringer:
		fmt.Println("可格式化对象:", v.String())
	default:
		fmt.Printf("未知类型:%T\n", value)
	}
}

3. 为什么接口由使用方定义

使用方最清楚自己真正需要哪些方法。把接口放在使用方,可以让实现包不依赖业务接口,也能避免接口不断膨胀。

4. nil 接口实验

指针变量本身可以是 nil,但赋给接口后,接口还保存了动态类型,因此接口不等于 nil。这是判断接口参数时经常遇到的边界情况。


🧐 一、error 是什么?

error 是一个只有 Error() string 方法的接口:

go 复制代码
type error interface {
	Error() string
}

任何实现了这个方法的类型都可以作为错误返回:

go 复制代码
func validateName(name string) error {
	if strings.TrimSpace(name) == "" {
		return errors.New("名称不能为空")
	}
	return nil
}

Go 的惯用约定是:成功时返回 nil,失败时返回非 nil 错误:

go 复制代码
value, err := load()
if err != nil {
	return err
}
fmt.Println(value)

错误检查要紧跟在调用后面,不要先继续使用可能无效的 value。


📖 二、创建错误与补充上下文

errors.New

适合创建固定文本的错误:

go 复制代码
var ErrNotFound = errors.New("资源不存在")

定义包级哨兵错误后,调用方可以使用 errors.Is 判断,而不必比较字符串。

fmt.Errorf 与 %w

错误向上返回时,应该补充当前层的上下文:

go 复制代码
func loadUser(id int) error {
	if err := queryUser(id); err != nil {
		return fmt.Errorf("加载用户 %d 失败: %w", id, err)
	}
	return nil
}

%w 会保留底层错误链;使用普通 %v 只能显示文本,无法继续判断原始错误。

旧项目中可能会看到 github.com/pkg/errors 的 Wrap。为避免和标准库 errors 混淆,通常会给它起别名:

go 复制代码
return pkgerrors.Wrap(err, "加载用户失败")

在现代 Go 代码中,标准库的 fmt.Errorf("...: %w", err) 通常已经足够;是否迁移要结合项目版本和依赖策略。


💡 三、errors.Is:判断错误身份

go 复制代码
var ErrPermission = errors.New("没有权限")

func checkPermission() error {
	return fmt.Errorf("访问配置失败: %w", ErrPermission)
}

err := checkPermission()
if errors.Is(err, ErrPermission) {
	fmt.Println("请提示用户申请权限")
}

errors.Is 会沿着包装链查找目标错误,因此不应该直接写:

go 复制代码
// if err == ErrPermission { ... }

直接比较只适用于没有被包装、且明确要求同一个错误值的场景。


🧩 四、errors.As:提取结构化错误

自定义错误可以保存状态:

go 复制代码
type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return e.Field + ":" + e.Msg
}

调用方使用 errors.As 提取具体错误:

go 复制代码
err := fmt.Errorf("请求校验失败: %w", &ValidationError{
	Field: "email",
	Msg:   "格式不正确",
})

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println("错误字段:", validationErr.Field)
}

errors.As 的第二个参数必须是指向目标类型变量的指针。错误类型通常使用指针接收者,便于携带结构化信息。


📖 五、多个错误与错误边界

当一次操作需要执行多个独立步骤时,可以用 errors.Join 合并多个错误:

go 复制代码
func validate(name, email string) error {
	var errs []error
	if name == "" {
		errs = append(errs, errors.New("名称不能为空"))
	}
	if !strings.Contains(email, "@") {
		errs = append(errs, errors.New("邮箱格式不正确"))
	}
	return errors.Join(errs...)
}

调用方仍然可以通过 errors.Is 或 errors.As 检查合并后的错误链。

错误边界通常分三层:

层次 处理重点
底层 返回原始错误,保留细节
业务层 增加业务上下文,判断是否可重试
展示层 转换为用户能理解的提示,避免暴露内部细节

不要在每一层都打印一次相同错误,否则日志会重复;通常由最接近请求入口的层统一记录。


⚠️ 六、panic 与 recover

panic 表示程序遇到了无法继续保证正确性的状态:

go 复制代码
func mustPort(port int) int {
	if port <= 0 || port > 65535 {
		panic("端口号非法")
	}
	return port
}

业务输入校验一般不应该使用 panic,而应该返回 error。panic 更适合初始化失败、违反内部不变量等不可恢复情况。

recover 只能在 deferred 函数中捕获当前 goroutine 的 panic:

go 复制代码
func safeCall(fn func()) (err error) {
	defer func() {
		if value := recover(); value != nil {
			err = fmt.Errorf("调用发生 panic: %v", value)
		}
	}()
	fn()
	return nil
}

在 HTTP 服务边界可以用 recover 防止单个请求拖垮进程,但不能把所有错误都"恢复掉"。恢复后仍需要记录堆栈、返回合适的状态码,并修复真正的问题。


🛠️ 七、一个完整的错误处理示例

go 复制代码
type User struct {
	ID   int
	Name string
}

var ErrUserNotFound = errors.New("用户不存在")

func findUser(id int) (User, error) {
	if id <= 0 {
		return User{}, &ValidationError{Field: "id", Msg: "必须大于 0"}
	}
	if id != 1 {
		return User{}, fmt.Errorf("查询用户: %w", ErrUserNotFound)
	}
	return User{ID: 1, Name: "小明"}, nil
}

func handle(id int) {
	user, err := findUser(id)
	if err != nil {
		var validationErr *ValidationError
		switch {
		case errors.As(err, &validationErr):
			fmt.Println("参数错误:", validationErr)
		case errors.Is(err, ErrUserNotFound):
			fmt.Println("可以返回 404")
		default:
			fmt.Println("内部错误:", err)
		}
		return
	}
	fmt.Println("找到用户:", user)
}

这个模式的关键不是代码数量,而是每个错误都有清晰的语义:参数错误、资源不存在和系统异常可以被分别处理。


📝 八、本篇小结

要点 总结
error 用接口表示失败结果
%w 包装错误并保留错误链
errors.Is 判断错误身份
errors.As 提取结构化错误
errors.Join 合并多个错误
panic 表示不可恢复或违反内部不变量
recover 只在边界层谨慎使用,不能替代正常错误处理

💬 一句话总结:

错误要显式返回、向上补充上下文、在边界统一处理;用 errors.Is 判断身份,用 errors.As 提取细节。


✏️ 九、练习题

  1. 🖥️ 动手实践 :定义 ErrNotFound 和 ErrInvalidInput,实现一个查询函数并用 errors.Is 区分两类错误。
  2. 🔧 自定义错误 :定义 HTTPError,包含状态码和消息,并用 errors.As 提取状态码。
  3. 🤔 思考题 :为什么应该用 %w 而不是 %v 包装底层错误?
  4. 📚 边界实践 :写一个 safeCall,捕获 panic 并返回 error,同时记录发生 panic 的函数名称。

💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~


关注我,一起学编程

如果本文对你有帮助,欢迎 点赞、在看、转发、关注。

相关推荐
林壮Allen18 小时前
用 Go 从零实现多 Agent 协作框架:两个让我踩坑的设计问题
go·ai编程
代码方舟1 天前
Go数据工程:利用天远柠檬查出险优化二手车批发拍卖车源出险核查合规体验
人工智能·go
不是万能的豆学长1 天前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
用户125758524361 天前
Golang后台管理框架推荐:XYGo Admin与devinggo怎么选?
后端·go·ai编程
tachibana21 天前
Go语言里slice是怎么扩容的?
后端·golang·go·切片
tachibana22 天前
使用for range 的时候,它的地址会发生变化吗?
开发语言·后端·golang·go·语法
newerp2 天前
GC Pacer 调优算法:GOGC、GOMEMLIMIT 与容器 OOM 防御
后端·程序员·go
newerp2 天前
逃逸分析、栈分配与 sync.Pool 对象池
后端·程序员·go
Laurie三省3 天前
现场版却配上录音室版的歌词?聊聊我给开源 Mac 歌词软件写的多源打分算法
算法·macos·go