Go 错误处理十诫:每个程序员都该掌握的实战指南

举报
golang学习记 发表于 2026/06/05 16:02:53 2026/06/05
【摘要】 Go 语言的错误处理一直是开发者讨论的热点。有人觉得繁琐,有人觉得优雅。但无论如何,掌握正确的错误处理方式,是写出健壮 Go 代码的关键。这些年在 Go 项目中摸爬滚打,踩过无数坑,也总结出了一套错误处理的实战经验。今天就把这十条"诫命"分享出来,每条都配上具体的代码示例,希望能帮你避开那些我踩过的坑。 第一诫:不可忽略错误错误不是可以跳过的仪式,而是程序逻辑的一部分。 ❌ 错误示范pack...

Go 语言的错误处理一直是开发者讨论的热点。有人觉得繁琐,有人觉得优雅。但无论如何,掌握正确的错误处理方式,是写出健壮 Go 代码的关键。

这些年在 Go 项目中摸爬滚打,踩过无数坑,也总结出了一套错误处理的实战经验。今天就把这十条"诫命"分享出来,每条都配上具体的代码示例,希望能帮你避开那些我踩过的坑。


第一诫:不可忽略错误

错误不是可以跳过的仪式,而是程序逻辑的一部分。

❌ 错误示范

package main

import (
    "os"
)

func main() {
    // 直接忽略错误 - 这是最危险的做法
    os.Remove("temp.txt")
    
    file, _ := os.Open("config.json")  // 下划线丢弃错误
    defer file.Close()
}

这段代码的问题:如果文件不存在怎么办?如果权限不足怎么办?你完全不知道。

✅ 正确做法

package main

import (
    "fmt"
    "log"
    "os"
)

func main() {
    // 检查并处理错误
    err := os.Remove("temp.txt")
    if err != nil {
        log.Printf("删除临时文件失败: %v", err)
        // 根据业务决定:继续执行还是退出
    }
    
    file, err := os.Open("config.json")
    if err != nil {
        log.Fatalf("打开配置文件失败: %v", err)
    }
    defer file.Close()
    
    // 安全地使用 file
}

核心原则:每个错误都应该被检查和处理。即使你选择忽略,也要明确地记录为什么忽略。


第二诫:跨包边界时包装错误

当错误跨越包边界时,添加上下文信息(如 ID、路径等)。

场景说明

假设你有一个 user 包,提供用户查询功能:

// user/service.go
package user

import (
    "database/sql"
    "fmt"
)

type Service struct {
    db *sql.DB
}

func (s *Service) GetUser(id string) (*User, error) {
    var user User
    err := s.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
    if err != nil {
        // ❌ 直接返回底层错误 - 调用者不知道是哪个用户出错
        return nil, err
    }
    return &user, nil
}

✅ 正确做法

// user/service.go
package user

import (
    "database/sql"
    "fmt"
)

type Service struct {
    db *sql.DB
}

func (s *Service) GetUser(id string) (*User, error) {
    var user User
    err := s.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
    if err != nil {
        // ✅ 添加上下文:哪个用户的查询失败了
        return nil, fmt.Errorf("获取用户 %s: %w", id, err)
    }
    return &user, nil
}

调用时的效果:

// main.go
user, err := userService.GetUser("12345")
if err != nil {
    // 错误信息清晰:获取用户 12345: sql: no rows in result set
    log.Printf("查询失败: %v", err)
}

核心原则:使用 fmt.Errorf("操作描述 %s: %w", 参数, err) 包装错误,让错误信息更有意义。


第三诫:包内部直接返回错误

如果调用者和被调用者在同一个包内,直接返回原始错误,避免冗余的包装链。

场景说明

// order/internal/processor.go
package internal

import (
    "fmt"
)

// 内部辅助函数
func validateOrder(order *Order) error {
    if order.Amount <= 0 {
        return fmt.Errorf("订单金额必须大于0")
    }
    if order.CustomerID == "" {
        return fmt.Errorf("客户ID不能为空")
    }
    return nil
}

// 内部另一个辅助函数
func saveOrder(order *Order) error {
    // 验证
    err := validateOrder(order)
    if err != nil {
        // ❌ 过度包装 - 都是内部函数,不需要层层包装
        return fmt.Errorf("保存订单时验证失败: %w", err)
    }
    
    // 保存到数据库...
    return nil
}

✅ 正确做法

// order/internal/processor.go
package internal

import (
    "fmt"
)

func validateOrder(order *Order) error {
    if order.Amount <= 0 {
        return fmt.Errorf("订单金额必须大于0")
    }
    if order.CustomerID == "" {
        return fmt.Errorf("客户ID不能为空")
    }
    return nil
}

func saveOrder(order *Order) error {
    // 验证
    err := validateOrder(order)
    if err != nil {
        // ✅ 直接返回 - 同一包内,调用者能看到完整上下文
        return err
    }
    
    // 保存到数据库...
    return nil
}

// 对外暴露的函数才需要包装
func ProcessOrder(order *Order) error {
    err := saveOrder(order)
    if err != nil {
        // ✅ 跨包边界时才包装
        return fmt.Errorf("处理订单失败: %w", err)
    }
    return nil
}

核心原则:包内部直接 return err,只在导出函数(跨包)时包装错误。


第四诫:让错误通过动作讲述故事

添加描述"动作"的上下文,避免使用前缀如 “failed to”。

❌ 错误示范

func PlaceOrder(order *Order) error {
    err := validateOrder(order)
    if err != nil {
        // ❌ "failed to" 是冗余的,错误本身就表示失败
        return fmt.Errorf("failed to validate order: %w", err)
    }
    
    err := chargePayment(order)
    if err != nil {
        // ❌ 同样的问题
        return fmt.Errorf("failed to charge payment: %w", err)
    }
    
    return nil
}

✅ 正确做法

func PlaceOrder(order *Order) error {
    err := validateOrder(order)
    if err != nil {
        // ✅ 描述正在执行的动作
        return fmt.Errorf("验证订单: %w", err)
    }
    
    err := chargePayment(order)
    if err != nil {
        // ✅ 清晰的动作描述
        return fmt.Errorf("扣款: %w", err)
    }
    
    err := updateInventory(order)
    if err != nil {
        // ✅ 最终错误信息会是:"下单失败: 扣款: 余额不足"
        return fmt.Errorf("更新库存: %w", err)
    }
    
    return nil
}

调用时:

err := PlaceOrder(order)
if err != nil {
    // 输出:下单失败: 扣款: 余额不足
    // 这是一个完整的故事链条
    log.Printf("下单失败: %v", err)
}

核心原则:用动词描述动作(“验证订单”、“扣款”),而不是说"失败"。


第五诫:不要重复内部错误已有的信息

如果底层错误已经包含详细信息(如文件路径),包装时只需添加更高层次的意图。

❌ 错误示范

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // ❌ 重复了路径信息 - os.ReadFile 的错误已经包含了 path
        return nil, fmt.Errorf("读取文件 %s 失败: %w", path, err)
    }
    
    // 解析配置...
}

os.ReadFile 的错误已经是:open /etc/app/config.json: permission denied

你再包装成:读取文件 /etc/app/config.json 失败: open /etc/app/config.json: permission denied

路径重复了!

✅ 正确做法

func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // ✅ 只添加高层意图,不重复路径
        return nil, fmt.Errorf("读取配置: %w", err)
    }
    
    var config Config
    err = json.Unmarshal(data, &config)
    if err != nil {
        // ✅ 添加业务含义
        return nil, fmt.Errorf("解析配置: %w", err)
    }
    
    return &config, nil
}

最终错误信息:读取配置: open /etc/app/config.json: permission denied

清晰、简洁、无冗余。

核心原则:底层错误已包含的详细信息(路径、ID等),不要在包装时重复。


第六诫:不要基于错误字符串做判断

错误消息是给人类看的。代码分支应该使用 errors.Iserrors.As

❌ 错误示范

func GetUser(id string) (*User, error) {
    user, err := db.QueryUser(id)
    if err != nil {
        // ❌ 基于错误字符串判断 - 脆弱且不可靠
        if strings.Contains(err.Error(), "no rows") {
            return nil, ErrNotFound
        }
        return nil, err
    }
    return user, nil
}

问题:如果数据库驱动更新了错误消息怎么办?如果你的代码国际化了怎么办?

✅ 正确做法

package user

import (
    "database/sql"
    "errors"
)

// 定义哨兵错误
var ErrNotFound = errors.New("用户不存在")

func GetUser(id string) (*User, error) {
    user, err := db.QueryUser(id)
    if err != nil {
        // ✅ 使用 errors.Is 判断标准错误
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("查询用户 %s: %w", id, err)
    }
    return user, nil
}

调用时:

user, err := GetUser("123")
if err != nil {
    // ✅ 使用 errors.Is 判断
    if errors.Is(err, user.ErrNotFound) {
        // 处理用户不存在的情况
        return http.StatusNotFound, nil
    }
    // 其他错误
    return http.StatusInternalServerError, err
}

核心原则:错误消息给人看,errors.Is/As 给代码用。


第七诫:记住 %w 是一个 API 承诺

%w 会暴露实现细节。如果不希望泄漏依赖,使用 %v 切断 unwrap 链。

场景说明

假设你的服务使用了第三方库:

// payment/service.go
package payment

import (
    "github.com/stripe/stripe-go"  // 第三方支付库
)

type Service struct {
    stripeClient *stripe.Client
}

func (s *Service) Charge(amount int64) error {
    err := s.stripeClient.Charge(amount)
    if err != nil {
        // ❌ 使用 %w 会暴露 Stripe 的实现细节
        return fmt.Errorf("扣款失败: %w", err)
    }
    return nil
}

调用者可以这样做:

err := paymentService.Charge(100)
if err != nil {
    // ⚠️ 调用者能访问到 Stripe 的内部错误类型
    var stripeErr *stripe.Error
    if errors.As(err, &stripeErr) {
        // 你的 API 现在和 Stripe 耦合了
    }
}

✅ 正确做法

// payment/service.go
package payment

import (
    "errors"
    "github.com/stripe/stripe-go"
)

// 定义自己的错误类型
var ErrChargeFailed = errors.New("扣款失败")

func (s *Service) Charge(amount int64) error {
    err := s.stripeClient.Charge(amount)
    if err != nil {
        // ✅ 使用 %v 切断 unwrap 链,不暴露实现细节
        return fmt.Errorf("%w: %v", ErrChargeFailed, err)
    }
    return nil
}

调用者只能看到:

err := paymentService.Charge(100)
if err != nil {
    // ✅ 只能判断你的领域错误,无法访问 Stripe 内部
    if errors.Is(err, payment.ErrChargeFailed) {
        // 处理扣款失败
    }
}

核心原则%w 会让调用者能 Unwrap 到你的依赖。如果不希望泄漏实现,用 %v


第八诫:将外部错误翻译为自己的错误词汇

在系统边界处,将外部错误映射为你的领域哨兵错误。

场景说明

你的应用使用了多个外部服务:

// repository/user_repo.go
package repository

import (
    "database/sql"
    "github.com/go-redis/redis/v8"
)

type UserRepository struct {
    db    *sql.DB
    cache *redis.Client
}

func (r *UserRepository) GetUser(id string) (*User, error) {
    // 先从缓存查
    data, err := r.cache.Get(ctx, "user:"+id).Result()
    if err != nil {
        // ❌ 直接返回 Redis 错误 - 调用者不应该知道你用 Redis
        return nil, err
    }
    
    // 解析数据...
}

✅ 正确做法

// repository/user_repo.go
package repository

import (
    "context"
    "database/sql"
    "errors"
    
    "github.com/go-redis/redis/v8"
)

// 定义仓库层的统一错误
var (
    ErrNotFound      = errors.New("记录不存在")
    ErrCacheFailed   = errors.New("缓存查询失败")
    ErrDatabaseError = errors.New("数据库查询失败")
)

type UserRepository struct {
    db    *sql.DB
    cache *redis.Client
}

func (r *UserRepository) GetUser(id string) (*User, error) {
    // 先从缓存查
    data, err := r.cache.Get(context.Background(), "user:"+id).Result()
    if err != nil {
        // ✅ 翻译 Redis 错误为自己的错误
        if errors.Is(err, redis.Nil) {
            // 缓存未命中,继续查数据库
        } else {
            // 其他缓存错误
            return nil, fmt.Errorf("%w: %v", ErrCacheFailed, err)
        }
    }
    
    // 从数据库查
    var user User
    err = r.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
    if err != nil {
        // ✅ 翻译数据库错误
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("%w: %v", ErrDatabaseError, err)
    }
    
    return &user, nil
}

调用者视角:

user, err := repo.GetUser("123")
if err != nil {
    // ✅ 只关心领域错误,不关心底层实现
    if errors.Is(err, repository.ErrNotFound) {
        // 处理不存在
    }
    if errors.Is(err, repository.ErrCacheFailed) {
        // 缓存问题,可能降级查数据库
    }
}

核心原则:在边界处翻译外部错误,调用者只应看到你的领域错误。


第九诫:不要既记录日志又返回错误

二选一。每层都记录会产生重复日志,只有终端处理器应该记录。

❌ 错误示范

func ProcessOrder(order *Order) error {
    err := validateOrder(order)
    if err != nil {
        // ❌ 记录日志
        log.Printf("验证订单失败: %v", err)
        // ❌ 又返回错误
        return err
    }
    
    err := saveOrder(order)
    if err != nil {
        // ❌ 又记录
        log.Printf("保存订单失败: %v", err)
        return err
    }
    
    return nil
}

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    err := ProcessOrder(order)
    if err != nil {
        // ❌ 再次记录 - 同一个错误被记录了三次!
        log.Printf("处理请求失败: %v", err)
        http.Error(w, "Internal Error", 500)
    }
}

日志输出:

2024/01/01 验证订单失败: 订单金额必须大于0
2024/01/01 处理请求失败: 验证订单失败: 订单金额必须大于0

✅ 正确做法

func ProcessOrder(order *Order) error {
    err := validateOrder(order)
    if err != nil {
        // ✅ 只返回错误,不记录
        return fmt.Errorf("验证订单: %w", err)
    }
    
    err := saveOrder(order)
    if err != nil {
        // ✅ 只返回错误
        return fmt.Errorf("保存订单: %w", err)
    }
    
    return nil
}

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    err := ProcessOrder(order)
    if err != nil {
        // ✅ 只在最外层记录一次
        log.Printf("处理请求失败: %v", err)
        http.Error(w, "Internal Error", 500)
        return
    }
    
    w.WriteHeader(http.StatusOK)
}

例外情况:如果你需要记录额外的上下文信息,可以记录,但要确保不会重复:

func ProcessOrder(order *Order) error {
    err := saveOrder(order)
    if err != nil {
        // ✅ 记录额外信息(订单ID),然后返回
        log.Printf("订单 %s 保存失败: %v", order.ID, err)
        return fmt.Errorf("保存订单 %s: %w", order.ID, err)
    }
    return nil
}

核心原则:通常只在最外层记录错误。如果中间层记录,确保添加了有价值的额外信息。


第十诫:不要让错误在 goroutine 中无声消失

通过 channel 或 errgroup 收集结果。

❌ 错误示范

func ProcessBatch(items []Item) error {
    for _, item := range items {
        go func(i Item) {
            // ❌ 错误直接丢失 - goroutine 中的 panic 或错误无人知晓
            err := processItem(i)
            if err != nil {
                log.Printf("处理项目失败: %v", err)
                return  // 错误就这样消失了
            }
        }(item)
    }
    
    // 主函数立即返回,不知道 goroutine 是否成功
    return nil
}

✅ 正确做法(方式一:使用 channel)

func ProcessBatch(items []Item) error {
    errChan := make(chan error, len(items))
    
    for _, item := range items {
        go func(i Item) {
            err := processItem(i)
            errChan <- err  // 发送错误到 channel
        }(item)
    }
    
    // 收集所有错误
    for i := 0; i < len(items); i++ {
        if err := <-errChan; err != nil {
            return fmt.Errorf("批量处理失败: %w", err)
        }
    }
    
    return nil
}

✅ 正确做法(方式二:使用 errgroup)

import "golang.org/x/sync/errgroup"

func ProcessBatch(items []Item) error {
    var g errgroup.Group
    
    for _, item := range items {
        item := item  // 捕获循环变量
        g.Go(func() error {
            // ✅ 错误会被 errgroup 收集
            return processItem(item)
        })
    }
    
    // Wait 会返回第一个遇到的错误
    if err := g.Wait(); err != nil {
        return fmt.Errorf("批量处理失败: %w", err)
    }
    
    return nil
}

✅ 正确做法(方式三:单个 goroutine)

func DoWorkAsync() error {
    errChan := make(chan error, 1)
    
    go func() {
        // 确保错误被发送到 channel
        defer close(errChan)
        errChan <- doWork()
    }()
    
    // 等待结果
    if err := <-errChan; err != nil {
        return fmt.Errorf("执行工作: %w", err)
    }
    
    return nil
}

核心原则:goroutine 中的错误必须通过 channel 或 errgroup 传递出来,不能让它无声消失。


最后的思考

这十条诫命不是硬性规则,而是经过实践检验的最佳实践。它们的核心思想是:

让错误信息有意义,让错误处理可维护。

好的错误处理能让调试变得简单,让系统更加健壮。下次写 Go 代码时,不妨对照这十诫,看看自己的错误处理是否足够优雅。

记住,错误不是敌人,而是帮助你构建更好系统的伙伴。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。