Go 的错误处理看起来很朴素:函数返回 error,调用方判断是否为 nil。这种方式没有异常机制那么“自动”,但优点是错误路径非常明确。项目规模变大后,真正的难点不是写 if err != nil,而是如何保留上下文、如何判断错误类型、什么时候使用 panic,以及如何让日志对排查问题有帮助。

error 的基本约定

Go 中的错误是一种普通值。一个函数如果可能失败,通常把 error 放在最后一个返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}

return &cfg, nil
}

调用方必须根据 err 决定下一步:

1
2
3
4
cfg, err := LoadConfig("config.json")
if err != nil {
return err
}

这种写法虽然重复,但可读性高。每个错误分支都能清楚看到资源释放、重试、降级或返回响应的处理逻辑。

给错误增加上下文

直接返回底层错误会丢失业务语义。例如只看到 open config.json: no such file or directory,还不清楚发生在哪个流程。推荐使用 %w 包装错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}

return &cfg, nil
}

%w 会保留原始错误链,调用方仍然可以通过 errors.Iserrors.As 判断底层错误。

不要在每一层都重复打印日志并返回错误,否则一次失败会产生多条相似日志。通常选择在边界层记录日志,例如 HTTP handler、任务入口或命令行入口;中间层负责补充上下文并返回错误。

使用 errors.Is 判断错误

当需要判断某个错误是否属于指定类型或哨兵错误时,使用 errors.Is,不要直接字符串匹配。

1
2
3
4
5
6
7
8
9
10
11
12
func EnsureFile(path string) error {
_, err := os.Stat(path)
if err == nil {
return nil
}

if errors.Is(err, os.ErrNotExist) {
return os.WriteFile(path, []byte("{}"), 0644)
}

return fmt.Errorf("stat %s: %w", path, err)
}

即使错误经过多层 %w 包装,errors.Is 仍然可以识别链路中的 os.ErrNotExist

使用 errors.As 提取具体类型

有些错误带有更多字段,例如网络错误、路径错误。此时可以用 errors.As 提取具体类型:

1
2
3
4
5
6
7
func IsTimeout(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout()
}
return false
}

这种写法比断言具体实现类型更稳,因为错误链可能经过包装。

自定义业务错误

业务系统经常需要把内部错误映射成 HTTP 状态码或错误码。可以定义一个结构体错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type AppError struct {
Code string
Message string
Err error
}

func (e *AppError) Error() string {
if e.Err == nil {
return e.Message
}
return e.Message + ": " + e.Err.Error()
}

func (e *AppError) Unwrap() error {
return e.Err
}

创建错误时保留底层原因:

1
2
3
4
5
return &AppError{
Code: "USER_NOT_FOUND",
Message: "user not found",
Err: sql.ErrNoRows,
}

边界层可以识别 AppError 并返回统一响应:

1
2
3
4
5
var appErr *AppError
if errors.As(err, &appErr) {
writeJSON(w, http.StatusBadRequest, appErr.Code, appErr.Message)
return
}

panic 应该用在哪里

panic 不应该用于普通业务失败。用户输入错误、数据库查不到数据、第三方接口超时都应该返回 errorpanic 更适合表示程序无法继续运行的内部错误,例如初始化配置缺失、模板必须存在却不存在、不可恢复的编程错误。

在 Web 服务中,通常会加一个 recover 中间件,防止单个请求的 panic 导致整个进程退出:

1
2
3
4
5
6
7
8
9
10
11
12
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
log.Printf("panic: %v", v)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()

next.ServeHTTP(w, r)
})
}

recover 只能兜底,不能替代正常的错误处理。真正的业务错误仍然应该显式返回。

日志与错误返回的分工

一个常见原则是“要么处理,要么返回”。如果当前函数能修复问题,例如重试、降级、返回默认值,那就处理掉。如果处理不了,就补充上下文后返回给上层。

日志一般放在请求入口或任务入口:

1
2
3
4
5
if err := service.CreateOrder(ctx, req); err != nil {
log.Printf("create order failed: user_id=%s err=%v", req.UserID, err)
writeError(w, err)
return
}

这样日志中既有业务字段,也有完整错误链。中间层不需要重复打印。

小结

Go 错误处理的关键是保持错误链清晰。底层返回具体错误,中间层用 %w 补充上下文,边界层统一记录日志和转换响应。需要判断错误时使用 errors.Iserrors.As,不要匹配字符串。panic 只用于不可恢复错误,并通过 recover 做服务稳定性的最后防线。