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.Is 或 errors.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 代码时,不妨对照这十诫,看看自己的错误处理是否足够优雅。
记住,错误不是敌人,而是帮助你构建更好系统的伙伴。