3、自定义错误类型:实现error接口、携带额外上下文信息、结构化错误设计

Go 语言内置的 error 接口仅包含一个 Error() string 方法,这在 Web 服务开发中远远不够。生产级错误需要携带状态码、内部错误链、用户可读消息、错误码等上下文信息。本章将系统讲解如何通过自定义错误类型实现结构化错误处理。

3.1 error 接口与自定义类型基础

Go 的 error 是一个预声明接口:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都可以作为错误使用。最简单的自定义错误:

type SimpleError struct {
    Message string
}

func (e *SimpleError) Error() string {
    return e.Message
}

但这种方式无法区分错误类型,也无法携带结构化数据。Web 服务需要更丰富的错误模型。

3.2 携带 HTTP 状态码与业务错误码

在 Web 服务中,每个错误都应映射到 HTTP 状态码和业务错误码。以下是一个生产级自定义错误类型:

type AppError struct {
    Code       int    // 业务错误码,如 10001
    HttpStatus int    // HTTP 状态码,如 400
    Message    string // 用户可读消息
    Err        error  // 内部错误链
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("code=%d, http=%d, msg=%s, err=%v",
            e.Code, e.HttpStatus, e.Message, e.Err)
    }
    return fmt.Sprintf("code=%d, http=%d, msg=%s",
        e.Code, e.HttpStatus, e.Message)
}

// 工厂函数,方便创建常见错误
func NewBadRequest(code int, msg string, err error) *AppError {
    return &AppError{
        Code:       code,
        HttpStatus: http.StatusBadRequest,
        Message:    msg,
        Err:        err,
    }
}

func NewNotFound(code int, msg string, err error) *AppError {
    return &AppError{
        Code:       code,
        HttpStatus: http.StatusNotFound,
        Message:    msg,
        Err:        err,
    }
}

func NewInternalError(code int, msg string, err error) *AppError {
    return &AppError{
        Code:       code,
        HttpStatus: http.StatusInternalServerError,
        Message:    msg,
        Err:        err,
    }
}
设计原则:业务错误码(Code)用于客户端精确识别错误类型;HTTP 状态码(HttpStatus)用于网络协议层;Message 用于用户展示;Err 用于内部日志和调试。四者各司其职,互不混淆。

3.3 结构化错误设计:分层上下文

复杂 Web 服务需要错误携带多层上下文信息,包括请求 ID、用户 ID、操作名称、时间戳等。以下是一个更完整的结构化错误模型:

type StructuredError struct {
    Code       int               `json:"code"`
    HttpStatus int               `json:"http_status"`
    Message    string            `json:"message"`
    Details    map[string]string `json:"details,omitempty"`
    Err        error             `json:"-"`
    RequestID  string            `json:"request_id,omitempty"`
    Timestamp  time.Time         `json:"timestamp"`
    Stack      string            `json:"-"`
}

func NewStructuredError(code, httpStatus int, msg string) *StructuredError {
    return &StructuredError{
        Code:       code,
        HttpStatus: httpStatus,
        Message:    msg,
        Details:    make(map[string]string),
        Timestamp:  time.Now(),
    }
}

func (e *StructuredError) WithDetail(key, value string) *StructuredError {
    e.Details[key] = value
    return e
}

func (e *StructuredError) WithRequestID(id string) *StructuredError {
    e.RequestID = id
    return e
}

func (e *StructuredError) WithCause(err error) *StructuredError {
    e.Err = err
    return e
}

func (e *StructuredError) Error() string {
    b, _ := json.Marshal(e)
    return string(b)
}
链式调用示例:
err := NewStructuredError(10003, 400, "用户输入无效").
    WithDetail("field", "email").
    WithDetail("reason", "格式不正确").
    WithRequestID("req-abc-123").
    WithCause(fmt.Errorf("原始错误: %w", originalErr))

3.4 错误类型对比表

特性 内置 error AppError StructuredError
HTTP 状态码 ❌ 无 ✅ 有 ✅ 有
业务错误码 ❌ 无 ✅ 有 ✅ 有
用户消息 ❌ 无 ✅ 有 ✅ 有
内部错误链 ❌ 无 ✅ 支持 ✅ 支持
结构化详情 ❌ 无 ❌ 无 ✅ map 字段
请求追踪 ❌ 无 ❌ 无 ✅ RequestID
JSON 序列化 ❌ 无 ❌ 无 ✅ 原生支持
时间戳 ❌ 无 ❌ 无 ✅ 自动记录

3.5 错误类型体系架构

下图展示了自定义错误类型在 Web 服务中的完整架构:

自定义错误类型体系架构 error 接口 基础实现:Error() string SimpleError 仅 Message 字段 AppError Code + HttpStatus + Err StructuredError 完整上下文 + JSON 简单错误包装 HTTP 响应映射 API 日志 + 追踪 选择建议 小型项目用 AppError;中大型项目用 StructuredError 实现可观测性 所有自定义错误类型必须实现 error 接口的 Error() 方法 推荐使用工厂函数创建错误,避免手动设置字段

3.6 错误类型断言与类型判断

在中间件或错误处理层,需要根据错误类型执行不同逻辑:

func ErrorHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                w.WriteHeader(http.StatusInternalServerError)
                json.NewEncoder(w).Encode(map[string]string{
                    "error": "内部服务器错误",
                })
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func WriteError(w http.ResponseWriter, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        w.WriteHeader(appErr.HttpStatus)
        json.NewEncoder(w).Encode(map[string]interface{}{
            "code":    appErr.Code,
            "message": appErr.Message,
        })
        return
    }

    var structErr *StructuredError
    if errors.As(err, &structErr) {
        w.WriteHeader(structErr.HttpStatus)
        json.NewEncoder(w).Encode(structErr)
        return
    }

    // 未知错误类型,返回 500
    w.WriteHeader(http.StatusInternalServerError)
    json.NewEncoder(w).Encode(map[string]string{
        "error": "未知错误",
    })
}
重要:使用 errors.As 而非类型断言 err.(*AppError)。errors.As 可以穿透错误链(通过 %w 包装的错误),而类型断言只能匹配最外层错误。这在错误包装场景下至关重要。

3.7 错误码与 HTTP 状态码映射策略

业务错误码与 HTTP 状态码并非一一对应。一个 HTTP 状态码可能对应多个业务错误码:

HTTP 状态码 业务错误码范围 典型场景
400 Bad Request 10001 - 10099 参数校验失败、JSON 解析错误
401 Unauthorized 10101 - 10199 Token 过期、签名无效
403 Forbidden 10201 - 10299 权限不足、IP 被限制
404 Not Found 10301 - 10399 资源不存在、路由未匹配
409 Conflict 10401 - 10499 数据冲突、唯一约束违反
429 Too Many Requests 10501 - 10599 频率限制、配额不足
500 Internal Server Error 50001 - 50099 数据库异常、第三方服务故障
502 Bad Gateway 50101 - 50199 上游服务不可达
503 Service Unavailable 50201 - 50299 服务过载、熔断开启

3.8 错误链与上下文传播

Go 1.13 引入的 %w 格式化动词和 errors.Is/errors.As 函数,使错误链成为可能。自定义错误类型应支持错误链:

// 支持 Unwrap 方法,使错误链可穿透
func (e *AppError) Unwrap() error {
    return e.Err
}

// 使用示例
func GetUser(id int) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, NewNotFound(10301, "用户不存在", err)
    }
    return user, nil
}

// 调用方可以穿透错误链
func HandleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := GetUser(123)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // 识别底层数据库错误
            log.Printf("数据库查询无结果: %v", err)
        }
        WriteError(w, err)
        return
    }
    json.NewEncoder(w).Encode(user)
}
最佳实践:
  • 始终在自定义错误中保留 Unwrap() 方法,支持错误链穿透
  • 使用 errors.Is 检查错误链中是否包含特定 sentinel 错误
  • 使用 errors.As 获取错误链中特定类型的错误实例
  • 日志记录时输出完整错误链,便于排查根因

3.9 错误码定义与管理

集中管理错误码,避免散落在代码各处:

// errorcode/codes.go
package errorcode

const (
    // 通用错误 10000-10099
    Success          = 0
    UnknownError     = 10000
    InvalidParams    = 10001
    InvalidJSON      = 10002
    MissingField     = 10003

    // 认证错误 10100-10199
    TokenExpired     = 10100
    InvalidToken     = 10101
    InsufficientPerm = 10102

    // 资源错误 10300-10399
    NotFound         = 10300
    AlreadyExists    = 10301

    // 系统错误 50000-50099
    DatabaseError    = 50000
    ThirdPartyError  = 50001
    TimeoutError     = 50002
)

// 错误码到 HTTP 状态码的映射
var CodeToHTTP = map[int]int{
    Success:          200,
    UnknownError:     500,
    InvalidParams:    400,
    InvalidJSON:      400,
    MissingField:     400,
    TokenExpired:     401,
    InvalidToken:     401,
    InsufficientPerm: 403,
    NotFound:         404,
    AlreadyExists:    409,
    DatabaseError:    500,
    ThirdPartyError:  502,
    TimeoutError:     504,
}
错误码设计建议:错误码采用 5 位数字,前两位表示模块(10=通用,50=系统),后三位表示具体错误。这样既保留了模块信息,又便于扩展。错误码一旦发布,不应修改或删除,只能废弃(deprecated)。

3.10 完整错误处理流程

下图展示了从错误产生到 HTTP 响应的完整流程:

错误处理完整流程 业务层 产生错误 错误包装层 AppError / StructuredError 错误中间件 类型断言 + 日志 errors.As 类型判断 已知错误类型 提取 HttpStatus + Code + Message 未知错误 默认 500 + 通用消息 JSON 响应 w.WriteHeader + json.Encode JSON 响应 500 + 通用错误码 所有错误最终统一输出 JSON 格式,客户端根据 code 字段处理业务逻辑 日志层记录完整错误链,包括 Stack 和 RequestID

3.11 常见陷阱与避坑指南

  • 陷阱 1:忽略错误链 — 自定义错误不实现 Unwrap(),导致 errors.Is/errors.As 无法穿透。解决方案:始终保留内部错误字段并实现 Unwrap。
  • 陷阱 2:错误码重复 — 不同模块使用相同错误码,导致客户端无法区分。解决方案:使用集中式错误码管理,按模块分配范围。
  • 陷阱 3:暴露内部错误 — 将数据库错误、堆栈信息直接返回给客户端。解决方案:Message 字段只放用户可读信息,内部错误存入 Err 字段仅用于日志。
  • 陷阱 4:类型断言而非 errors.As — 直接使用 err.(*AppError) 无法处理被 fmt.Errorf("...%w...", err) 包装的错误。解决方案:始终使用 errors.As。
  • 陷阱 5:忽略零值错误 — 返回未初始化的自定义错误指针,导致 err != nil 为 true 但内部字段全为空。解决方案:使用工厂函数确保所有字段被正确初始化。
安全提醒:永远不要在错误消息中包含敏感信息,如数据库密码、API Key、内部 IP 地址、文件路径等。这些信息应仅记录在服务器日志中,且日志系统应具备脱敏能力。