🔖 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提取细节。
✏️ 九、练习题
- 🖥️ 动手实践 :定义
ErrNotFound和ErrInvalidInput,实现一个查询函数并用errors.Is区分两类错误。 - 🔧 自定义错误 :定义
HTTPError,包含状态码和消息,并用errors.As提取状态码。 - 🤔 思考题 :为什么应该用
%w而不是%v包装底层错误? - 📚 边界实践 :写一个
safeCall,捕获 panic 并返回 error,同时记录发生 panic 的函数名称。
💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~
关注我,一起学编程
如果本文对你有帮助,欢迎 点赞、在看、转发、关注。