3. 自定义错误类型:实现 Error() 方法、携带错误码的结构体、错误码与错误消息分离
在 Go 语言中,error 是一个内置接口,仅包含一个 Error() string 方法。标准库的 errors.New 和 fmt.Errorf 只能返回简单的字符串错误,无法携带结构化信息(如错误码、上下文)。在生产级系统中,我们需要自定义错误类型,将错误码与错误消息分离,实现可编程的错误处理与安全断言。
3.1 自定义错误类型的基本结构
自定义错误类型的核心是定义一个结构体,并为其实现 Error() string 方法。该结构体至少应包含两个字段:错误码(Code) 和 错误消息(Message)。错误码用于程序逻辑判断,错误消息用于人类阅读。
// MyError 自定义错误类型,携带错误码与错误消息
type MyError struct {
Code int // 错误码,用于程序断言
Message string // 错误消息,用于日志与展示
}
// Error 实现 error 接口
func (e *MyError) Error() string {
return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}
设计原则:错误码使用整数类型,便于 switch/case 断言;错误消息使用字符串,支持格式化。两者分离后,上层调用方可以根据 Code 做安全断言,而不必解析字符串。
3.2 错误码与错误消息分离的价值
| 维度 | 传统字符串错误 | 自定义错误码分离 |
|---|---|---|
| 程序断言 | 需要字符串匹配,脆弱且不安全 | 直接比较整数错误码,稳定高效 |
| 国际化 | 错误消息硬编码,难以翻译 | 错误码不变,消息可替换为多语言 |
| 日志分析 | 日志中混杂自然语言,难以聚合 | 错误码可索引,便于监控告警 |
| 安全审计 | 用户输入可能注入错误字符串 | 错误码为预定义常量,无注入风险 |
3.3 预定义错误码常量
为了在项目中统一管理错误码,建议使用 const 块定义错误码常量,并配合 iota 或显式赋值。错误码通常按模块或严重程度分段。
const (
// 通用错误码 1xx
ErrCodeSuccess = 0
ErrCodeUnknown = 100
ErrCodeInvalidInput = 101
ErrCodeNotFound = 102
// 业务错误码 2xx
ErrCodeUserNotExist = 201
ErrCodeOrderExpired = 202
ErrCodeBalanceInsufficient = 203
)
安全注意:错误码不应暴露内部实现细节(如数据库行 ID、堆栈路径)。错误码范围应提前规划,避免与第三方库错误码冲突。建议为每个模块分配独立的错误码段。
3.4 带上下文的错误包装
在实际调用链中,我们经常需要保留原始错误(wrap)。自定义错误类型可以嵌套 error 接口,形成错误链,同时保持错误码的独立访问。
// WrapError 包装错误,携带错误码与原始错误
type WrapError struct {
Code int
Message string
Err error // 原始错误,可为 nil
}
func (e *WrapError) 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)
}
// Unwrap 支持 errors.Is 与 errors.As 链式查找
func (e *WrapError) Unwrap() error {
return e.Err
}
3.5 错误码断言实战:类型断言与 errors.As
调用方可以通过类型断言或 errors.As 获取自定义错误类型,然后直接比较错误码,实现安全断言。
func processOrder(orderID string) error {
// 模拟业务逻辑
return &WrapError{
Code: ErrCodeOrderExpired,
Message: "订单已过期",
Err: nil,
}
}
func main() {
err := processOrder("ORD-12345")
if err != nil {
var myErr *WrapError
if errors.As(err, &myErr) {
switch myErr.Code {
case ErrCodeOrderExpired:
fmt.Println("安全断言:订单过期,触发重新下单流程")
case ErrCodeBalanceInsufficient:
fmt.Println("安全断言:余额不足,引导充值")
default:
fmt.Println("未知错误码,记录告警")
}
}
}
}
实战要点:使用
errors.As 而非直接类型断言,因为 errors.As 可以遍历错误链,找到匹配的自定义类型。错误码断言应放在系统边界层(如 HTTP handler、gRPC interceptor),避免在业务逻辑深处散落断言。
3.6 自定义错误类型架构图
下图展示了自定义错误类型在 Go 接口契约中的位置:错误码与消息分离后,通过断言层实现安全路由。
3.7 常见陷阱与最佳实践
- 陷阱1:在
Error()方法中拼接敏感信息(如密码、token)。
解决:错误消息只包含错误码和通用描述,敏感信息通过独立字段传递。 - 陷阱2:错误码使用
string类型,导致断言时仍需字符串比较。
解决:统一使用int或uint32作为错误码类型,配合常量枚举。 - 陷阱3:忘记实现
Unwrap()方法,导致errors.Is无法遍历错误链。
解决:包装错误时始终实现Unwrap()方法,返回原始错误。 - 陷阱4:错误码重复或冲突,不同模块使用相同数值表示不同含义。
解决:为每个模块分配独立错误码段(如 1000-1999 为用户模块,2000-2999 为订单模块)。
3.8 完整示例:生产级自定义错误
package errcode
import "fmt"
// Error 自定义错误结构体
type Error struct {
Code int
Message string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *Error) Unwrap() error {
return e.Err
}
// New 创建带错误码的错误
func New(code int, message string) *Error {
return &Error{Code: code, Message: message}
}
// Wrap 包装已有错误
func Wrap(code int, message string, err error) *Error {
return &Error{Code: code, Message: message, Err: err}
}
// 预定义错误码
const (
Success = 0
InvalidInput = 1001
NotFound = 1002
InternalError = 5000
)