写 Go 一段时间之后,几乎一定会遇到一个问题:错误信息写得越详细,业务层越难稳定判断;只返回裸错误,又丢失上下文。
Go 1.13 之后的错误包装机制,就是用来解决这个矛盾的。
为什么不能只靠字符串比对
很多项目早期会写出这种代码:
1if err != nil && strings.Contains(err.Error(), "not found") {
2 // ...
3}
它的问题很明显:
- 错误文案一改,判断就失效
- 上下文一多,字符串更不稳定
- 第三方库返回格式不同,很难统一
更稳的方式,是把“判断依据”建立在错误链结构上,而不是错误文本上。
%w 的作用是什么
fmt.Errorf("query user: %w", err) 会把原始错误包进去,形成一条错误链。
这样你既能补充上下文,又不丢失底层错误身份。
1if err := repo.Find(ctx, id); err != nil {
2 return fmt.Errorf("load profile %d: %w", id, err)
3}
errors.Is 什么时候用
当你关心的是“这是不是某一类错误”时,用 errors.Is。
1if errors.Is(err, sql.ErrNoRows) {
2 return ErrUserNotFound
3}
它会沿着整条错误链往下查,所以即使中间包了很多层,也还能命中原始错误。
errors.As 什么时候用
当你想把错误还原成某种具体类型,并读取更多字段时,用 errors.As。
1type ValidationError struct {
2 Field string
3 Msg string
4}
5
6func (e *ValidationError) Error() string {
7 return e.Field + ": " + e.Msg
8}
9
10var verr *ValidationError
11if errors.As(err, &verr) {
12 fmt.Println("field:", verr.Field)
13}
工程里常见的一层翻译
比较常见的做法是:
- 基础设施层返回底层错误
- service 层补足业务语义
- handler 层只根据稳定错误做状态码映射
1func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
2 user, err := s.repo.Get(ctx, id)
3 if err != nil {
4 if errors.Is(err, sql.ErrNoRows) {
5 return nil, fmt.Errorf("user %d: %w", id, ErrUserNotFound)
6 }
7 return nil, fmt.Errorf("user %d: %w", id, err)
8 }
9 return user, nil
10}
这样上层就不需要知道 repo 到底用了 sql、gorm 还是别的实现。
不要过度包装
并不是每一层都必须 fmt.Errorf("xxx: %w", err)。
如果当前层没有增加新上下文,只是单纯把错误继续往上抛,直接 return err 往往更清晰。过度包装会让错误链非常长,阅读日志时反而费劲。
我的总结
- 用
%w叠加上下文,不要丢底层错误身份 - 用
errors.Is判断类别,用errors.As取具体类型 - 错误判断要面向稳定语义,不要面向字符串文案
- 真正有信息增量时再包装,别把每层都变成“套娃”