2. 自定义错误类型:实现Error()方法、携带额外上下文信息、errors.New与fmt.Errorf

在Go语言中,error是一个内置接口,只包含一个Error() string方法。标准库提供的errors.New和fmt.Errorf只能返回简单的字符串错误,无法携带结构化信息。当我们需要在错误码断言和权限验证中传递错误码、用户ID、操作类型等上下文时,必须自定义错误类型。

2.1 自定义错误类型的基本结构

自定义错误类型只需要实现Error() string方法即可满足error接口。但在实战中,我们通常会在结构体中嵌入额外的字段,用于存储错误码、内部原因、时间戳等上下文信息。

// 基础自定义错误类型
type AppError struct {
    Code    int    // 业务错误码
    Message string // 用户可见的错误消息
    Err     error  // 内部包装的原始错误(可选)
}

// 实现 error 接口
func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("code=%d, message=%s, cause=%v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}
设计原则:自定义错误类型应同时满足“机器可读”(通过Code字段)和“人类可读”(通过Message字段),避免在错误消息中拼接敏感信息。

2.2 携带额外上下文信息

在权限验证场景中,错误需要携带用户ID、请求路径、操作类型等上下文,以便日志记录和问题定位。以下是一个权限验证专用的错误类型:

// 权限验证错误类型
type AuthError struct {
    UserID    string // 触发错误的用户ID
    Action    string // 被拒绝的操作(如 "read", "write", "delete")
    Resource  string // 被访问的资源路径
    Reason    string // 拒绝原因
    Timestamp time.Time
}

func (e *AuthError) Error() string {
    return fmt.Sprintf("[AuthError] user=%s action=%s resource=%s reason=%s time=%s",
        e.UserID, e.Action, e.Resource, e.Reason, e.Timestamp.Format(time.RFC3339))
}

// 使用示例
func CheckPermission(userID, action, resource string) error {
    if userID == "" {
        return &AuthError{
            UserID:    userID,
            Action:    action,
            Resource:  resource,
            Reason:    "user not authenticated",
            Timestamp: time.Now(),
        }
    }
    // ... 权限校验逻辑
    return nil
}

2.3 errors.New 与 fmt.Errorf 的适用场景

标准库的两种错误创建方式各有优劣,下表总结了它们的适用场景:

方式 适用场景 优点 缺点
errors.New("msg") 静态错误消息,无需格式化 简单、高效、返回唯一错误实例 无法携带动态数据
fmt.Errorf("code=%d: %s", code, msg) 需要格式化字符串的错误 灵活、支持占位符 返回扁平字符串,无法结构化访问
自定义类型 需要错误码、上下文、类型断言 结构化、可扩展、支持类型断言 需要额外定义结构体
避坑提示:不要滥用fmt.Errorf拼接结构化数据。例如fmt.Errorf("user_id=%s", uid)虽然能传递信息,但调用方无法通过类型断言提取user_id,只能通过字符串解析,极易出错。

2.4 错误类型断言与错误码提取

自定义错误类型的核心优势在于:调用方可以通过类型断言(Type Assertion)或errors.As提取结构化字段,从而精确判断错误类型并获取上下文信息。

// 错误类型断言示例
func HandleError(err error) {
    // 方式1:直接类型断言
    if appErr, ok := err.(*AppError); ok {
        fmt.Printf("业务错误码: %d, 消息: %s\n", appErr.Code, appErr.Message)
        return
    }

    // 方式2:使用 errors.As(推荐,支持错误链)
    var authErr *AuthError
    if errors.As(err, &authErr) {
        fmt.Printf("权限拒绝: user=%s action=%s\n", authErr.UserID, authErr.Action)
        return
    }

    // 方式3:使用 errors.Is 判断特定哨兵错误
    if errors.Is(err, ErrPermissionDenied) {
        fmt.Println("权限不足(通用错误)")
        return
    }

    fmt.Println("未知错误:", err)
}

2.5 错误码断言实战:从错误中提取业务码

在权限验证中间件中,我们经常需要从错误中提取业务错误码,以便返回给前端或记录日志。以下是一个完整的错误码断言函数:

// 定义业务错误码常量
const (
    CodeSuccess       = 0
    CodeInvalidParam  = 1001
    CodeUnauthorized  = 1002
    CodeForbidden     = 1003
    CodeNotFound      = 1004
    CodeInternalError = 5000
)

// 从 error 中提取业务错误码,默认返回 CodeInternalError
func ExtractErrorCode(err error) int {
    if err == nil {
        return CodeSuccess
    }

    // 尝试断言为 AppError
    var appErr *AppError
    if errors.As(err, &appErr) {
        return appErr.Code
    }

    // 尝试断言为 AuthError
    var authErr *AuthError
    if errors.As(err, &authErr) {
        // 根据 Reason 映射到标准错误码
        switch authErr.Reason {
        case "user not authenticated":
            return CodeUnauthorized
        case "permission denied":
            return CodeForbidden
        default:
            return CodeForbidden
        }
    }

    // 其他未知错误
    return CodeInternalError
}
实战要点:错误码断言应遵循“先具体后通用”的原则。优先匹配自定义错误类型,再回退到哨兵错误或默认值。避免在断言过程中产生新的错误。
自定义错误类型与断言流程 错误创建方式 errors.New("静态消息") fmt.Errorf("code=%d", c) 自定义结构体类型 error 接口实例 Error() string 可能包含嵌套错误 可能携带上下文 可被 errors.As 遍历 断言与提取 类型断言 errors.As errors.Is 提取错误码 提取上下文 核心原则: 1. 自定义错误类型应实现 Error() 方法,并携带结构化字段(错误码、用户ID、操作等)。 2. 使用 errors.As 遍历错误链进行断言,优先匹配具体类型,再回退到哨兵错误。

2.6 错误包装与错误链

Go 1.13 引入了%w格式化动词,允许fmt.Errorf包装已有错误,形成错误链。自定义错误类型也可以实现Unwrap() error方法,参与错误链的遍历。

// 支持错误链的自定义类型
type WrappedError struct {
    Code    int
    Message string
    Err     error // 被包装的原始错误
}

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

// Unwrap 方法让 errors.As / errors.Is 可以遍历到内部错误
func (e *WrappedError) Unwrap() error {
    return e.Err
}

// 使用 %w 包装
func ProcessRequest() error {
    err := db.Query()
    if err != nil {
        return &WrappedError{
            Code:    CodeInternalError,
            Message: "database query failed",
            Err:     err, // 保留原始错误
        }
    }
    return nil
}

// 调用方可以同时断言 WrappedError 和原始错误
var target *WrappedError
if errors.As(err, &target) {
    fmt.Println("业务错误码:", target.Code)
}
if errors.Is(err, sql.ErrNoRows) {
    fmt.Println("底层是记录不存在错误")
}
最佳实践:在自定义错误类型中实现Unwrap()方法,可以保留完整的错误链,让上层调用方既能获取结构化字段,又能通过errors.Is判断底层错误类型。这是构建可观测性错误体系的关键。

2.7 三种错误创建方式的对比总结

特性 errors.New fmt.Errorf 自定义类型
结构化字段 不支持 不支持(仅字符串) 支持任意字段
类型断言 无法区分 无法区分 支持精确断言
错误链支持 不支持 支持 %w 需实现 Unwrap()
性能 高 中 中
适用场景 简单哨兵错误 格式化消息 业务错误码、权限验证

在实际项目中,推荐采用“三层错误体系”:顶层使用自定义错误类型携带业务错误码和上下文,中间层使用fmt.Errorf包装底层错误,底层使用errors.New定义哨兵错误。这样既能保证结构化,又能保留完整的错误链。