3、自定义错误类型:实现Error()方法、携带额外上下文信息、错误码与错误消息分离、自定义错误的结构体设计
在 Go 项目中,仅依赖标准库的 errors.New 或 fmt.Errorf 返回的简单字符串错误,无法满足业务层对错误分类、排查和展示的需求。业务错误需要携带错误码、上下文参数、调用链路等结构化信息。本章将系统讲解如何设计自定义错误类型,实现错误码与错误消息分离,并让错误携带丰富的上下文。
3.1 为什么需要自定义错误类型
标准错误本质是一个接口:
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都可以作为错误。但内置错误类型存在以下局限:
| 局限 | 说明 |
|---|---|
| 无结构化字段 | 无法携带错误码、HTTP 状态码、业务参数等 |
| 难以分类 | 只能通过字符串匹配或类型断言判断错误类型 |
| 上下文丢失 | 无法记录发生错误时的关键变量值 |
| 展示与逻辑耦合 | 错误消息直接暴露给用户,无法做国际化或脱敏 |
自定义错误类型通过结构体字段和接口方法,可以完美解决上述问题。
3.2 核心设计:错误码与错误消息分离
错误码是业务层错误处理的核心抽象。它代表一个可枚举的错误类别,与具体的展示文案解耦。推荐使用整数或字符串常量定义错误码,并配合一个映射表管理错误消息模板。
// 错误码定义
const (
ErrCodeSuccess = 0
ErrCodeInvalidParam = 1001
ErrCodeUserNotFound = 2001
ErrCodeOrderExpired = 3001
ErrCodeInternalError = 5000
)
// 错误码与默认消息映射
var codeMessages = map[int]string{
ErrCodeSuccess: "success",
ErrCodeInvalidParam: "invalid parameter",
ErrCodeUserNotFound: "user not found",
ErrCodeOrderExpired: "order has expired",
ErrCodeInternalError: "internal server error",
}
设计原则:错误码一旦发布,不应修改其含义。新增业务场景时,新增错误码而非复用旧码。错误消息模板支持占位符,用于动态注入上下文信息。
3.3 自定义错误结构体设计
一个成熟的业务错误结构体应包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
Code |
int |
业务错误码,用于程序逻辑判断 |
Message |
string |
面向开发者的错误描述,可包含占位符 |
Details |
map[string]any |
额外上下文键值对,用于日志和排查 |
Err |
error |
可选的原始错误(wrap),用于错误链 |
// BizError 业务错误结构体
type BizError struct {
Code int // 业务错误码
Message string // 错误描述(可含 %s 占位符)
Details map[string]any // 上下文信息
Err error // 原始错误(可选)
}
// Error 实现 error 接口
func (e *BizError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s | cause: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// Unwrap 支持 errors.Is / errors.As 错误链遍历
func (e *BizError) Unwrap() error {
return e.Err
}
关键点:
Error()方法返回的字符串用于日志和开发调试,不直接展示给终端用户。Unwrap()方法让自定义错误兼容 Go 1.13+ 的错误链机制。Details字段存储结构化上下文,便于日志系统自动索引。
3.4 携带额外上下文信息
业务错误发生时,往往需要记录当时的输入参数、用户 ID、订单号等关键信息。通过 Details 字段可以优雅地携带这些上下文:
// 创建带上下文的业务错误
func NewBizError(code int, msg string, details map[string]any) *BizError {
return &BizError{
Code: code,
Message: msg,
Details: details,
}
}
// 使用示例
func GetUser(ctx context.Context, userID string) (*User, error) {
user, err := db.FindUser(ctx, userID)
if err != nil {
return nil, NewBizError(
ErrCodeUserNotFound,
"user not found",
map[string]any{
"user_id": userID,
"trace_id": ctx.Value("trace_id"),
},
).Wrap(err) // 包装原始数据库错误
}
return user, nil
}
在日志输出时,可以统一提取 Details 字段,实现结构化日志:
// 日志中间件中提取上下文
if bizErr, ok := err.(*BizError); ok {
log.WithFields(bizErr.Details).Error(bizErr.Error())
}
3.5 错误码与错误消息的映射管理
推荐将错误码和消息模板集中管理,避免散落在各处。可以设计一个错误码注册表:
// ErrorRegistry 错误码注册表
type ErrorRegistry struct {
mu sync.RWMutex
messages map[int]string
}
func (r *ErrorRegistry) Register(code int, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.messages[code] = message
}
func (r *ErrorRegistry) Message(code int) string {
r.mu.RLock()
defer r.mu.RUnlock()
if msg, ok := r.messages[code]; ok {
return msg
}
return "unknown error"
}
// 全局注册表
var GlobalErrorRegistry = &ErrorRegistry{
messages: make(map[int]string),
}
避坑提示:不要将错误消息模板与用户展示文案混用。错误消息面向开发者,使用英文或固定占位符;用户展示文案应在前端或 API 网关层根据错误码做国际化映射。
3.6 自定义错误类型架构图
下图展示了自定义错误类型的核心结构及其与外部系统的关系:
3.7 完整示例:业务层错误处理
以下是一个完整的业务函数示例,展示如何创建、包装和返回自定义错误:
// 预定义错误码常量
const (
ErrOrderNotFound = 3002
ErrOrderPaid = 3003
)
// 创建业务错误工厂函数
func OrderNotFound(orderID string) *BizError {
return &BizError{
Code: ErrOrderNotFound,
Message: "order not found",
Details: map[string]any{"order_id": orderID},
}
}
func OrderAlreadyPaid(orderID string) *BizError {
return &BizError{
Code: ErrOrderPaid,
Message: "order already paid",
Details: map[string]any{"order_id": orderID},
}
}
// 业务函数
func PayOrder(ctx context.Context, orderID string) error {
order, err := repo.GetOrder(ctx, orderID)
if err != nil {
return OrderNotFound(orderID).Wrap(err)
}
if order.Status == "paid" {
return OrderAlreadyPaid(orderID)
}
// 执行支付逻辑...
return nil
}
// 调用方处理
func HandlePayOrder(w http.ResponseWriter, r *http.Request) {
orderID := r.URL.Query().Get("order_id")
err := PayOrder(r.Context(), orderID)
if err != nil {
var bizErr *BizError
if errors.As(err, &bizErr) {
// 根据错误码返回不同的 HTTP 状态码
switch bizErr.Code {
case ErrOrderNotFound:
http.Error(w, "order not found", http.StatusNotFound)
case ErrOrderPaid:
http.Error(w, "order already paid", http.StatusConflict)
default:
http.Error(w, "internal error", http.StatusInternalServerError)
}
// 记录结构化日志
log.WithFields(bizErr.Details).Error(bizErr.Error())
return
}
// 非业务错误,按未知错误处理
http.Error(w, "internal error", http.StatusInternalServerError)
log.Error(err)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"success"}`))
}
3.8 设计要点总结
| 设计维度 | 推荐做法 | 避免做法 |
|---|---|---|
| 错误码 | 使用整数常量,集中注册 | 使用字符串散落定义 |
| 错误消息 | 面向开发者,支持占位符 | 直接拼接用户展示文案 |
| 上下文 | 使用 map[string]any 结构化存储 |
拼接在 Error() 字符串中 |
| 错误链 | 实现 Unwrap() 方法 |
忽略原始错误 |
| 工厂函数 | 为每个错误码提供构造函数 | 到处手动创建结构体 |
最佳实践:在项目初期就定义好错误码范围和结构体规范,避免后期大规模重构。建议将错误码定义放在独立的
pkg/errcode 包中,供所有模块引用。