Featured image of post Go 业务代码里的错误处理最佳实践

Go 业务代码里的错误处理最佳实践

Go 的错误处理常被吐槽『if err != nil 写到吐』——但用对模式、配合 errors.Is/As/Wrap,业务错误能极其清爽

写在前面

Java / Python / JS 抛异常,Go 用返回值——这是 Go 最被争论的设计。一段典型的 Go 业务代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
user, err := userRepo.GetByID(id)
if err != nil {
    return nil, err
}
order, err := orderRepo.Create(user, dto)
if err != nil {
    return nil, err
}
err = mqProducer.Send(orderCreatedEvent(order))
if err != nil {
    return nil, err
}
return order, nil

每隔三行就 if err != nil——很多新手吐槽"丑"。但写过几年 Go 你会发现:这种"显式错误传递"反而让错误处理路径极其清晰——不像异常那样在调用栈里随机冒出。

但工程上要做对,几个细节:

  • 怎么区分错误类型?err 都是 string 不利于业务判断
  • 怎么追溯错误源头?层层 return err 后只剩最后一层信息
  • panic 什么时候用
  • 业务错误 vs 系统错误怎么处理

本文把 Go 业务代码里错误处理的最佳实践讲清楚。


一、Go 错误处理的核心原则

四个关键原则:

  1. 错误是值(errors are values)——不是控制流
  2. 错误要有类型——光凭字符串无法做精确判断
  3. 错误要带上下文——知道错在哪里
  4. 错误要可断言——上层能判断"是不是某种特定错误"

老的 Go 代码多用 fmt.Errorf("%v: %v", ...) 拼字符串——丢失类型。 新的 Go(1.13+)有 errors.Iserrors.Asfmt.Errorf("%w", ...)——错误处理终于有了正经工具。


二、定义业务错误:sentinel + 类型

Sentinel 错误(哨兵错误)

1
2
3
4
5
6
7
8
9
package usercase

import "errors"

var (
    ErrUserNotFound  = errors.New("user not found")
    ErrUserInactive  = errors.New("user inactive")
    ErrInvalidStatus = errors.New("invalid status")
)

调用方判断:

1
2
3
4
user, err := getUser(id)
if errors.Is(err, ErrUserNotFound) {
    return c.Status(404).JSON(...)
}

errors.Is沿着 error 链查找——即便错误被 wrap 包过几层,仍然能识别。

类型化错误

光 sentinel 不够——业务错误经常带细节信息:

1
2
3
4
5
6
7
8
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}

调用方判断:

1
2
3
4
5
6
7
var ve *ValidationError
if errors.As(err, &ve) {
    return c.Status(400).JSON(map[string]string{
        "field":   ve.Field,
        "message": ve.Message,
    })
}

errors.As 把 error 链中类型匹配的错误赋值给目标变量——能拿到具体字段。


三、Wrap 错误:保留上下文

朴素的 return err 让错误传递时丢失链路:

1
2
// 反例:看不出错在哪里
return userRepo.GetByID(id)

正确做法是 wrap 上下文:

1
2
// 1.13+ 用 fmt.Errorf %w
return fmt.Errorf("getUserByID(%d): %w", id, err)

%w 让错误保留可被 errors.Is/As 追溯的链——而不是普通 %v 拼字符串后丢失类型。

错误打印长这样:

1
service.GetOrder: repo.GetOrder(123): db query: connection refused

每一层都加上下文——出问题时直接看错误信息就能定位

有些第三方库用 pkg/errors

1
2
3
import "github.com/pkg/errors"

err = errors.Wrap(err, "getOrder failed")

老 Go 项目还能见到这种写法——和标准库的 %w 同一思想,新项目直接用标准库即可


四、什么时候 wrap,什么时候不 wrap

不是每个 err 都要 wrap——盲目 wrap 会让信息冗余:

1
2
// 反例:每层都 wrap,错误信息变成俄罗斯套娃
service: handle: process: validate: check: getValue: db query: connection refused

实战原则

  • 跨层时 wrap(repo → service → handler)
  • 加业务上下文时 wrap(“creating order for user 123”)
  • 同层多次调用同一函数时 wrap(区分是哪次失败)
  • 直接 return 即可的简单透传,不 wrap

五、Sentinel 还是类型化?

sentinel errortyped error
表达力弱(只是个标识)强(带字段)
比较方式errors.Iserrors.As
模板代码多(要写 type)
适合简单标志(NotFound/Forbidden)携带详细信息

实战折中:简单错误用 sentinel,需要带字段用 typed

1
2
3
var ErrNotFound = errors.New("not found")              // sentinel

type RateLimitError struct { RetryAfter time.Duration } // typed

六、错误分层:业务错误 vs 系统错误

业务错误(用户输入错了、状态不允许)和系统错误(DB 挂了、网络超时)应该完全不同处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type DomainError struct {
    Code    string   // "USER_NOT_FOUND" / "INSUFFICIENT_BALANCE"
    Message string   // 给用户看的消息
    HTTPStatus int   // 4xx
}

func (e *DomainError) Error() string {
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

// system error 是普通 error

handler 里统一处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func errorHandler(c *fiber.Ctx, err error) error {
    var de *DomainError
    if errors.As(err, &de) {
        // 业务错误:返回给用户
        return c.Status(de.HTTPStatus).JSON(map[string]string{
            "code": de.Code,
            "message": de.Message,
        })
    }

    // 系统错误:500,且消息脱敏
    log.Errorf("internal error: %+v", err)
    return c.Status(500).JSON(map[string]string{
        "code": "INTERNAL_ERROR",
        "message": "服务暂时不可用",
    })
}

业务错误暴露给前端没问题;系统错误的细节(如 SQL 报错)绝不能传给用户——日志记录、外部模糊处理。


七、不要用 panic 做控制流

panic 是 Go 的"异常逃生通道"——但不是给业务用的

1
2
3
4
5
6
7
8
// ❌ 反例:用 panic 做错误处理
func GetOrder(id int) *Order {
    order, err := repo.GetByID(id)
    if err != nil {
        panic(err)
    }
    return order
}

业务代码里 panic 的合法用途几乎只有两类:

  1. 真正不应该发生的情况(如 nil pointer 解引用、数组越界)——表示编程 bug
  2. 初始化时的致命错误(如配置文件加载失败)——直接退出

正常业务流程永远 return error,让调用方决定怎么处理。

recover 的合法用途

1
2
3
4
5
6
7
8
9
func handler(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if r := recover(); r != nil {
            log.Errorf("panic recovered: %v", r)
            http.Error(w, "internal error", 500)
        }
    }()
    // 业务逻辑
}

每个 HTTP handler 入口、goroutine 入口加 recover——避免一个 panic 把整个服务搞挂。这是兜底,不是常规手段。


八、goroutine 里的错误

启动 goroutine 后,错误不会自动传回 main——必须显式:

1
2
3
4
5
6
7
// ❌ 错误丢失
go func() {
    err := doWork()
    if err != nil {
        // ??? 没人收
    }
}()

正确姿势:

1
2
3
4
5
6
7
errCh := make(chan error, 1)
go func() {
    errCh <- doWork()
}()
if err := <-errCh; err != nil {
    return err
}

或者用 errgroup——更优雅:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(ctx)
for _, task := range tasks {
    task := task
    g.Go(func() error {
        return process(ctx, task)
    })
}
if err := g.Wait(); err != nil {
    return err
}

errgroup 还会任一任务失败时自动 cancel ctx——其他任务能立刻退出。


九、错误日志的姿势

1
2
3
// 反例:错误又抛出又日志,导致重复日志
log.Errorf("query user: %v", err)
return err

原则:要么 log,要么 return。同一错误只 log 一次——通常在最上层(HTTP handler / cron entry)。

1
2
3
4
5
6
7
8
// 在 service / repo 层只 wrap:
return fmt.Errorf("getUser: %w", err)

// 在 handler 层一次性记录:
if err := service.GetUser(id); err != nil {
    log.Errorf("getUser failed: %+v", err)
    return ...
}

%+v 配合带 stack trace 的 error 库(如 pkg/errorscockroachdb/errors)能打印完整调用栈。


十、几个常见反模式

1. 错误吞掉

1
result, _ := json.Marshal(obj)   // ❌

_ 把错误丢了——出问题永远查不到。显式忽略只在你 100% 确定不会出错时才用,并加注释说明

2. 错误转字符串再判断

1
if err.Error() == "not found" { ... }   // ❌

字符串匹配脆得不行——别人改一个字你的判断就废了。errors.Is / errors.As

3. 把所有错误压成一个 Internal Error

1
return errors.New("internal error")

调用方完全不知道发生什么——永远要 wrap 原 error,保留链

4. 错误信息含敏感数据

1
return fmt.Errorf("query failed: %v", sqlPassword)   // ❌

错误信息可能上日志、上前端——敏感数据要脱敏

5. error 字段在结构体里

1
2
3
4
type Result struct {
    Data interface{}
    Err  error      // ❌ 反 Go 风格
}

Go 的约定是"return (T, error)"——别学其他语言把 error 塞结构体里。


十一、Go 1.20+ 的 errors.Join

Go 1.20 引入 errors.Join——支持组合多个错误:

1
2
3
err1 := process(1)
err2 := process(2)
return errors.Join(err1, err2)   // 把多个错误打包返回

errors.Is 仍能识别每个原错误:

1
2
3
joined := errors.Join(ErrA, ErrB)
errors.Is(joined, ErrA)   // true
errors.Is(joined, ErrB)   // true

适合并发任务、批量操作场景——以前要自己写一个 multi-error,现在标准库有了。


小结

把全文压一句:

Go 错误处理的核心是『错误是值,要有类型、有上下文、可断言』——if err != nil 看着烦,但带上 errors.Is/As/Wrap 后是 Go 最干净的设计。

工程实战清单:

  1. 业务错误用 sentinel 或 typed,不要 errors.New(...) 拼字符串
  2. 跨层 wrap,加业务上下文,用 %w
  3. errors.Is 比较 sentinel,errors.As 提取 typed
  4. 业务错误和系统错误分层处理——前端区分对待
  5. panic 不做业务控制流——只在不可恢复 + 初始化失败时
  6. goroutine 用 errgroup
  7. 错误只 log 一次,在最上层

把这些做对,“if err != nil 写到吐"会变成你 Go 代码里最可靠的一部分——而不是负担。

使用 Hugo 构建
主题 StackJimmy 设计