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
}
实战要点:错误码断言应遵循“先具体后通用”的原则。优先匹配自定义错误类型,再回退到哨兵错误或默认值。避免在断言过程中产生新的错误。
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定义哨兵错误。这样既能保证结构化,又能保留完整的错误链。