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

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

相关文章
|
30天前
|
人工智能 IDE Shell
Zed IDE这个终端新功能,治好了我的窗口切换焦虑
Zed IDE近期发布多项重磅更新,尤其新增“New Center Terminal”功能,让终端可直接在编辑区并排打开,告别拖拽拼图式操作。本文详解其双终端模式、心流提升逻辑及开源协作精神,并展望AI驱动的智能终端未来。(239字)
180 2
|
30天前
|
人工智能 Rust 开发工具
Zed 1.0正式发布:VS Code慌了?
Zed 1.0正式发布!这款用Rust打造、GPU加速的“游戏引擎级”编辑器,告别Electron瓶颈,实现毫秒级响应;原生集成AI多Agent协作,支持DeltaDB字符级同步。它不是VS Code替代品,而是对编辑器本质的重新定义——性能即自由,人机协作为常态。(239字)
231 1
|
8天前
|
人工智能 前端开发 Shell
OpenAI 给 Codex 加了个 @ 功能,我的工作效率直接起飞
Codex TUI 新增智能 `@` 提及功能:一键唤起文件、插件、Skills三合一补全,支持颜色标签、路径自动引号、图片附件等细节优化,大幅降低上下文切换成本,让终端编程更流畅自然。(239字)
199 0
|
30天前
|
算法 安全 程序员
这个主题绝了,转为程序员设计,VS Code完美配合。
这是一款专为开发者设计的VS Code荧光绿主题套件,含6种风格(如Midnight、Liquid Glass),兼顾护眼、降噪与审美。高亮关键字、柔化字符串、弱化注释,提升代码可读性;同步终端配色,消除视觉割裂。小改变,大心流——让眼睛更轻松,思维更专注。(239字)
194 1
|
2月前
|
人工智能 自然语言处理 安全
Claude Code Routines:给你的代码装上“自动巡航“
Routines 是 Claude 的可编程自动化代理,支持定时、API 和 GitHub webhook 三种触发方式,将重复开发任务(如修 Bug、更新文档、安全审查)转为 AI 驱动的云端流水线,解放开发者专注高价值工作。
433 1
|
14天前
|
人工智能 运维 网络安全
我这半年实际使用过的 4 款 SSH 工具体验分享
本文精选4款主流SSH工具:轻量稳定的PuTTY、Windows全能神器MobaXterm、跨平台云同步的Termius,以及AI驱动的智能终端Aeroshell,覆盖从经典运维到AI辅助新场景,助开发者与运维人员高效管理服务器。(239字)
271 1
我这半年实际使用过的 4 款 SSH 工具体验分享
|
3天前
|
存储 人工智能 安全
|
3天前
|
API
阿里云微服务引擎 MSE 及 API 网关 2026 年 5 月产品动态
阿里云微服务引擎 MSE 及 API 网关 2026 年 5 月产品动态。