Go 错误处理进阶——自定义错误类型、错误码、多层 wrap 的工程实践
Go 错误处理进阶——自定义错误类型、错误码、多层 wrap 的工程实践
适读人群:Go 工程经验6个月以上、对错误处理有困惑的工程师 | 阅读时长:约16分钟 | 核心价值:建立一套真正可用的生产错误处理体系,不是教科书里那种简化版
为什么说 Go 的错误处理是门学问
我刚转 Go 的时候,看了两篇关于 Go 错误处理的文章,感觉学会了——errors.New、fmt.Errorf、errors.Is、errors.As,四个函数,不难嘛。
然后我写了半年代码,回头看自己早期的错误处理,每次都想关掉文件。
那时候的代码是这样的:
func (s *UserService) GetUser(id string) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, err // 直接返回,没有任何上下文
}
return user, nil
}然后调用方看到一个 sql: no rows in result set,完全不知道是哪个层发生的,是真的 not found 还是数据库连接问题,前端该返回 404 还是 500?
这就是"能跑但不能用"的错误处理。
生产环境需要什么
在生产环境里,一个错误需要携带以下信息:
- 错误码:前端/API 调用方用来做分支判断,比 HTTP 状态码更细粒度
- 错误消息:给用户看的(需要国际化,不能泄露内部信息)
- 调用链:给开发者排查用,知道错误是从哪一层传上来的
- 原始错误:底层错误,用于
errors.Is和errors.As判断 - 额外上下文:比如操作的资源 ID,帮助快速定位
踩坑实录
坑一:多层 wrap 之后 errors.Is 失效
现象: 底层返回了 ErrNotFound,经过三层 fmt.Errorf("%w", err) 包装之后,最顶层用 errors.Is(err, ErrNotFound) 却返回 false。
原因: 我用了自定义错误类型 + fmt.Errorf 混用,某一层用了 fmt.Errorf("%v", err)(%v 不是 %w),这导致错误链断裂。
解法: 统一使用 %w 包装,或者用自定义的 Wrap 函数确保包装正确。永远不要在应该传播错误的地方用 %v。
坑二:错误码和 HTTP 状态码耦合
现象: 刚开始我的错误类型里有一个 HTTPStatus int 字段,直接存 HTTP 状态码。后来加了一个 gRPC 接口,发现 gRPC 用的是自己的状态码体系,两套不兼容,改起来很痛苦。
原因: 把传输层协议(HTTP/gRPC)和业务错误码耦合在一起了。
解法: 错误类型只存业务错误码,在 HTTP handler 层做一次映射,在 gRPC interceptor 层做另一次映射。
坑三:panic 在中间件层被 recover 后丢失了 stack trace
现象: recover 捕获到 panic 之后,我直接返回了 500,但日志里没有 panic 发生的堆栈信息,排查起来非常困难。
原因: recover() 返回的是 panic 传入的值,不自带堆栈。
解法: 在 recover 的时候手动获取堆栈:
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
logger.Error("panic recovered",
"panic_value", r,
"stack", string(buf[:n]),
)
}完整的错误处理方案
定义错误类型
package errors
import (
"errors"
"fmt"
)
// Code 业务错误码
type Code int
const (
// 通用错误码
CodeOK Code = 0
CodeUnknown Code = 1000
CodeInvalidArg Code = 1001
CodeNotFound Code = 1002
CodeConflict Code = 1003
CodeUnauthorized Code = 1004
CodeForbidden Code = 1005
CodeInternal Code = 1006
CodeUnavailable Code = 1007
CodeTimeout Code = 1008
// 用户相关错误码
CodeUserNotFound Code = 2001
CodeUserDisabled Code = 2002
CodePasswordInvalid Code = 2003
CodeTokenExpired Code = 2004
// 资源相关错误码
CodeProjectNotFound Code = 3001
CodeProjectLimit Code = 3002 // 超出项目数量限制
CodeAPIKeyInvalid Code = 3003
)
// AppError 应用层错误,携带完整上下文
type AppError struct {
Code Code // 业务错误码
Message string // 用户可见的错误消息
cause error // 包装的原始错误(用于 Unwrap)
fields map[string]interface{} // 额外上下文字段
}
// Error 实现 error 接口
func (e *AppError) Error() string {
if e.cause != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.cause)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// Unwrap 支持 errors.Is 和 errors.As 穿透
func (e *AppError) Unwrap() error {
return e.cause
}
// Is 支持按错误码比较
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Code == t.Code
}
// WithField 添加上下文字段(链式调用)
func (e *AppError) WithField(key string, value interface{}) *AppError {
if e.fields == nil {
e.fields = make(map[string]interface{})
}
e.fields[key] = value
return e
}
// Fields 返回额外字段,用于日志记录
func (e *AppError) Fields() map[string]interface{} {
return e.fields
}
// New 创建新的 AppError
func New(code Code, message string) *AppError {
return &AppError{Code: code, Message: message}
}
// Wrap 包装一个现有错误,添加业务上下文
func Wrap(err error, code Code, message string) *AppError {
return &AppError{Code: code, Message: message, cause: err}
}
// Wrapf 包装一个现有错误,支持格式化消息
func Wrapf(err error, code Code, format string, args ...interface{}) *AppError {
return &AppError{
Code: code,
Message: fmt.Sprintf(format, args...),
cause: err,
}
}
// 预定义的哨兵错误,用于 errors.Is 比较
var (
ErrNotFound = New(CodeNotFound, "resource not found")
ErrUnauthorized = New(CodeUnauthorized, "unauthorized")
ErrForbidden = New(CodeForbidden, "forbidden")
ErrInternal = New(CodeInternal, "internal server error")
)
// IsNotFound 判断是否是 not found 错误
func IsNotFound(err error) bool {
return IsCode(err, CodeNotFound)
}
// IsCode 判断错误码
func IsCode(err error, code Code) bool {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr.Code == code
}
return false
}
// GetCode 从错误中提取错误码,如果不是 AppError 返回 CodeUnknown
func GetCode(err error) Code {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr.Code
}
return CodeUnknown
}在各层中使用
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/myapp/errors"
)
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email, status FROM users WHERE id = $1",
id,
).Scan(&user.ID, &user.Name, &user.Email, &user.Status)
if err == sql.ErrNoRows {
// 将数据库层的错误转换为业务层错误
return nil, errors.New(errors.CodeUserNotFound, "用户不存在").
WithField("user_id", id)
}
if err != nil {
return nil, errors.Wrap(err, errors.CodeInternal, "查询用户失败").
WithField("user_id", id)
}
return &user, nil
}package service
import (
"context"
"fmt"
"github.com/myapp/errors"
)
type UserService struct {
repo UserRepository
}
func (s *UserService) GetProfile(ctx context.Context, requesterID, targetID string) (*UserProfile, error) {
// 服务层:添加业务上下文,不隐藏底层错误
user, err := s.repo.FindByID(ctx, targetID)
if err != nil {
// 如果已经是 AppError,直接传播,不要二次包装
// 如果是未知错误,包装一下
var appErr *errors.AppError
if !stdErrors.As(err, &appErr) {
return nil, errors.Wrap(err, errors.CodeInternal,
fmt.Sprintf("获取用户 %s 的资料失败", targetID))
}
return nil, err
}
// 权限检查
if user.ID != requesterID && !s.isPublicProfile(user) {
return nil, errors.New(errors.CodeForbidden, "无权访问该用户资料").
WithField("requester_id", requesterID).
WithField("target_id", targetID)
}
return toProfile(user), nil
}HTTP handler 层的错误映射
package http
import (
"net/http"
"github.com/myapp/errors"
)
// errorCodeToHTTPStatus 业务错误码到 HTTP 状态码的映射
var errorCodeToHTTPStatus = map[errors.Code]int{
errors.CodeOK: http.StatusOK,
errors.CodeUnknown: http.StatusInternalServerError,
errors.CodeInvalidArg: http.StatusBadRequest,
errors.CodeNotFound: http.StatusNotFound,
errors.CodeUserNotFound: http.StatusNotFound,
errors.CodeProjectNotFound: http.StatusNotFound,
errors.CodeConflict: http.StatusConflict,
errors.CodeUnauthorized: http.StatusUnauthorized,
errors.CodeForbidden: http.StatusForbidden,
errors.CodeInternal: http.StatusInternalServerError,
errors.CodeUnavailable: http.StatusServiceUnavailable,
errors.CodeTimeout: http.StatusGatewayTimeout,
}
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
func renderError(w http.ResponseWriter, r *http.Request, err error, logger *slog.Logger) {
code := errors.GetCode(err)
httpStatus, ok := errorCodeToHTTPStatus[code]
if !ok {
httpStatus = http.StatusInternalServerError
}
// 内部错误不暴露详情给前端
message := err.Error()
if httpStatus >= 500 {
message = "服务器内部错误,请稍后重试"
// 记录详细错误日志(包含完整调用链)
var appErr *errors.AppError
logArgs := []interface{}{"error", err.Error()}
if stdErrors.As(err, &appErr) {
for k, v := range appErr.Fields() {
logArgs = append(logArgs, k, v)
}
}
logger.Error("internal error", logArgs...)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
json.NewEncoder(w).Encode(ErrorResponse{
Code: int(code),
Message: message,
})
}Go vs Java:异常 vs 错误值
Java 的异常机制让"快乐路径"很干净——正常逻辑写完,然后统一在某个地方 catch,逻辑和错误处理分离。
Go 的错误值让每一个可能出错的调用都必须显式处理,代码里大量的 if err != nil。
我在 Java 时代觉得 Go 的这种方式"很丑"。现在我改变了看法:错误处理是代码中最重要的逻辑之一,它不应该被隐藏到 catch 块里,而应该在每一个出错点都清晰可见。这种"丑",其实是一种诚实。
