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

简介: Go错误处理十诫:从忽略错误、跨包包装、内部直返,到动作化描述、避免重复、类型判断、%w慎用、错误翻译、日志去重及goroutine错误捕获——10条实战经验助你写出健壮、可维护的Go代码。(239字)

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 代码时,不妨对照这十诫,看看自己的错误处理是否足够优雅。

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

相关文章
|
29天前
|
人工智能 数据可视化 开发工具
Codex 新增/usage 系列命令:自适应主题查看token消耗
OpenAI Codex 新增 `/usage` 系列命令,支持在终端实时查看 Token 消耗(总体/日/周/累计),采用异步加载、主题自适应渲染与瞬态卡片设计,将成本管理无缝融入开发工作流,标志着其从代码助手向“AI 开发操作系统”演进的关键一步。(239字)
449 1
|
16天前
|
人工智能 数据可视化 安全
Claude Code /config`完全指南:AI 编程调教术
Claude Code 的 `/config` 是其核心交互式设置面板,一键呼出即可管理模型、上下文、权限、编辑器、输出等全部配置。支持 Tab 导航、实时搜索、多层级配置文件,并新增 `--help` 快捷键与更直觉的 Enter/Space 切换逻辑,让 AI 编程体验真正个性化、高效化。(239字)
258 0
|
1月前
|
人工智能 API C++
Claude Code 2.1.163 新特性:c to copy
Claude Code 新增「c to copy」快捷键:按 `c` 即可将 AI 的原始 Markdown 答案(含代码块、标题、列表等格式)一键复制,粘贴至 Notion/Obsidian/GitHub 等平台自动渲染。省去手动排版,守护心流,小功能见真功夫。(239字)
496 122
|
1月前
|
人工智能 前端开发 Shell
OpenAI 给 Codex 加了个 @ 功能,我的工作效率直接起飞
Codex TUI 新增智能 `@` 提及功能:一键唤起文件、插件、Skills三合一补全,支持颜色标签、路径自动引号、图片附件等细节优化,大幅降低上下文切换成本,让终端编程更流畅自然。(239字)
536 0
|
29天前
|
人工智能 开发工具 git
Zed Git 终于支持直接与任意分支对比了
Zed 编辑器新增「git: compare with branch」功能,支持在命令面板中直接选择任意分支进行对比,一步到位,无需中转默认上游分支。减少认知负担,提升多分支并行对比效率,细节优化彰显对开发者真实工作流的深刻理解。(239字)
185 2
|
29天前
|
设计模式 自然语言处理 测试技术
7个资深工程师的编码模式,相见恨晚!
本文分享高级工程师的7个核心编码习惯:早返回降嵌套、命名体现业务意图、用类型杜绝非法状态、函数职责单一、避免重复代码、错误带上下文、代码优先为人可读。强调“减少惊讶”比炫技更重要,真正价值在于长期可维护性。(239字)
159 3
|
29天前
|
人工智能 程序员 测试技术
VSCode 1.124 新特性:使用快捷键重构AI协作的“心流”
VSCode 1.124推出“会话间导航”,支持Ctrl+R选择、Ctrl+Tab前后切换、Alt+↑/↓按序跳转、Ctrl+1~9定位可见会话,覆盖时间与空间双重导航模型。该设计降低上下文切换成本,提升多任务协作效率,体现AI从“问答工具”向“可管理协作环境”的演进。(239字)
249 2
|
29天前
|
Web App开发 存储 前端开发
VSCode 1.124 全新特性:内置浏览器历史记录,比谷歌还好用
VSCode 1.124 为内置浏览器新增历史记录功能:支持地址栏智能建议、Ctrl+H 快速唤出历史面板,本地持久化存储,兼顾隐私与效率。它让浏览器从“临时窗口”升级为可回溯的开发工作区,提升上下文连贯性。(239字)
258 1
|
1月前
|
缓存 算法 Go
Go slices.Move 新提案:一次搬动,胜过两次删除插入
本文讲述Go开发者因TODO应用拖拽排序需求,写出低效“笨代码”的反思历程,引出社区提案`slices.Move`——用一次内存移动替代`Delete+Insert`两次复制,填补API空白。它不颠覆理论,却以实用主义提升性能与可读性,彰显Go“简洁而不简陋”的哲学。(239字)
123 1
|
1月前
|
编译器 Go Python
Go 创始人官宣:泛型方法来了
Go泛型方法提案(#77273)终结了“方法不能泛型”的历史枷锁:允许类型定义泛型方法,提升代码组织性与链式调用体验;但为保编译效率与接口简洁性,明确禁止泛型方法实现接口,亦不支持反射调用。实用主义的优雅妥协。
176 0