业务异常 (Exception)

exception 包提供了统一的业务异常处理机制,支持错误链追踪、错误分类、堆栈追踪等高级特性。

特性

HTTP友好:自动映射到HTTP状态码
🔗 错误链支持:兼容 Go 1.13+ 的 errors.Is/As
📊 错误分类:自动判断错误类型和是否可重试
🔍 堆栈追踪:可选的函数调用栈记录
🔒 线程安全:支持并发访问元数据
🎯 构建器模式:灵活的错误构造方式
完全向后兼容:保留所有旧版API

应用场景

我们在进行接口调用时,需要根据接口的报错信息来决定怎么处理后续逻辑,比如:

  • Token 过期,前端需要让用户跳转到重新登录的页面
  • 异地登录时,前端需要提醒用户注意密码安全
  • 用户余额不足时,前端需要让用户跳转到充值页面

通过为每一种异常定义独特的编码和标准结构,可以精准识别和处理各种业务异常

为什么需要异常码

传统使用 fmt.Errorferrors.New() 生成异常:

var ERR_TOKEN_EXPIRED = errors.New("用户Token已经过期")

前端只能通过字符串匹配来辨别异常,容易误伤且不利于国际化:

{
  "error": "用户Token已经过期"
}

使用异常码后,通过独特的编码精准识别异常:

{
  "code": 10001,
  "reason": "访问过期, 请刷新",
  "message": "用户Token已经过期"
}

快速开始

基础使用

package main

import "github.com/infraboard/mcube/v2/exception"

func main() {
    // 创建常见的业务异常
    err := exception.NewNotFound("用户 %s 不存在", "alice")
    
    // 添加元数据
    err.WithMeta("user_id", "123")
    
    // 获取HTTP状态码
    statusCode := err.GetHttpCode() // 404
    
    // 输出JSON格式
    fmt.Println(err.ToJson())
}

输出示例:

{
  "service": "service_a",
  "code": 404,
  "reason": "资源未找到",
  "message": "用户 alice 不存在",
  "meta": {
    "user_id": "123"
  },
  "data": null
}

全局异常

mcube 已经将一些常用的异常预先定义好了:

reasonMap = map[int]string{
    CODE_UNAUTHORIZED:          "认证失败",
    CODE_NOT_FOUND:             "资源未找到",
    CODE_CONFLICT:              "资源已经存在",
    CODE_BAD_REQUEST:           "请求不合法",
    CODE_INTERNAL_SERVER_ERROR: "系统内部错误",
    CODE_FORBIDDEN:             "访问未授权",
    CODE_UNKNOWN:               "未知异常",
    CODE_ACCESS_TOKEN_ILLEGAL:  "访问令牌不合法",
    CODE_REFRESH_TOKEN_ILLEGAL: "刷新令牌不合法",
    CODE_OTHER_PLACE_LGOIN:     "异地登录",
    CODE_OTHER_IP_LOGIN:        "异常IP登录",
    CODE_OTHER_CLIENT_LOGIN:    "用户已经通过其他端登录",
    CODE_SESSION_TERMINATED:    "会话结束",
    CODE_ACESS_TOKEN_EXPIRED:   "访问过期, 请刷新",
    CODE_REFRESH_TOKEN_EXPIRED: "刷新过期, 请登录",
    CODE_VERIFY_CODE_REQUIRED:  "异常操作, 需要验证码进行二次确认",
    CODE_PASSWORD_EXPIRED:      "密码过期, 请找回密码或者联系管理员重置",
}

使用全局异常

创建异常:使用 exception.NewXXX() 快捷函数

判断异常:使用 exception.IsApiException() 进行类型判断

import (
    "errors"
    "testing"

    "github.com/infraboard/mcube/v2/exception"
)

func TestNotFoundError(t *testing.T) {
    // 创建异常
    e := exception.NewNotFound("user %s not found", "alice")
    t.Log(e.ToJson())
    
    // 判断异常类型
    t.Log(exception.IsApiException(e, exception.CODE_NOT_FOUND))
}

异常返回示例:

{
  "service": "service_a",
  "code": 404,
  "reason": "资源未找到",
  "message": "user alice not found",
  "meta": null,
  "data": null
}

自定义异常

当内置的全局异常不够用时,可以通过 NewApiException 创建业务专属异常:

import "github.com/infraboard/mcube/v2/exception"

const (
    CODE_INSUFFICIENT_BALANCE = 100001
)

// 创建自定义异常
func NewErrInsufficientBalance(amount float64) *exception.ApiException {
    return exception.NewApiException(CODE_INSUFFICIENT_BALANCE, "余额不足").
        WithMessage("当前余额不足,需要 %.2f 元", amount)
}

// 判断是否为余额不足异常
func IsErrInsufficientBalance(err error) bool {
    return exception.IsApiException(err, CODE_INSUFFICIENT_BALANCE)
}

常用异常

mcube 提供了一系列快捷创建函数,自动映射到对应的 HTTP 状态码:

函数HTTP状态码说明
NewBadRequest(msg, args...)400请求参数错误
NewUnauthorized(msg, args...)401未认证
NewForbidden(msg, args...)403无权限
NewNotFound(msg, args...)404资源不存在
NewConflict(msg, args...)409资源冲突
NewInternalServerError(msg, args...)500服务器内部错误

使用示例:

// 参数验证失败
if username == "" {
    return exception.NewBadRequest("用户名不能为空")
}

// 资源不存在
user, err := repo.FindByID(id)
if err != nil {
    return exception.NewNotFound("用户 %s 不存在", id)
}

// 权限不足
if !user.HasPermission("admin") {
    return exception.NewForbidden("需要管理员权限")
}

错误链支持

基本用法

错误链允许你包装底层错误并添加上下文信息,同时保留原始错误:

import (
    "errors"
    "database/sql"
    "github.com/infraboard/mcube/v2/exception"
)

// 包装底层错误
dbErr := sql.ErrNoRows
apiErr := exception.Wrap(dbErr, exception.CODE_NOT_FOUND, "查询失败")

// 支持 errors.Is 判断
if errors.Is(apiErr, sql.ErrNoRows) {
    // 可以追溯到原始错误
}

// 查看完整错误链
fmt.Println(apiErr.ErrorChainString())
// 输出: 查询失败: sql: no rows in result set → sql: no rows in result set

错误链 API

// 包装错误
func Wrap(err error, code int, reason string) *ApiException

// 包装错误并格式化消息
func Wrapf(err error, code int, reason, format string, args ...any) *ApiException

// 获取原始错误
err.Unwrap() error
err.Cause() error

// 获取完整错误链
err.ErrorChain() []string
err.ErrorChainString() string

实际应用

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // 将数据库错误包装为业务异常
            return nil, exception.NewNotFound("用户不存在").
                WithMeta("user_id", id)
        }
        // 包装未知错误,保留错误链
        return nil, exception.Wrap(err, exception.CODE_INTERNAL_SERVER_ERROR, "查询用户失败").
            WithMeta("user_id", id).
            WithStack()
    }
    return user, nil
}

构建器模式

构建器模式提供了更灵活的异常构造方式,适合构建复杂的异常对象:

基础使用

// 使用构建器创建复杂异常
err := exception.NewBuilder(exception.CODE_BAD_REQUEST).
    WithReason("参数验证失败").
    WithMessage("邮箱格式不正确").
    WithMeta("field", "email").
    WithTraceID("trace-123").
    WithStack().  // 添加堆栈追踪
    Build()

构建器 API

// 创建构建器
builder := exception.NewBuilder(code)

// 链式调用设置各种属性
builder.
    WithReason(reason).                    // 设置异常原因
    WithMessage(message).                  // 设置消息
    WithMessagef(format, args...).         // 格式化消息
    WithHTTPCode(httpCode).                // 设置HTTP状态码
    WithMeta(key, value).                  // 添加元数据
    WithData(data).                        // 设置响应数据
    WithCause(err).                        // 设置原因错误
    WithService(serviceName).              // 设置服务名
    WithTraceID(traceID).                  // 设置追踪ID
    WithRequestID(requestID).              // 设置请求ID
    WithStack().                           // 添加堆栈追踪
    Build()                                // 构建异常对象

错误分类

异常包支持自动错误分类,帮助判断错误类型和是否可重试:

错误类型

const (
    ErrorTypeUnknown     // 未知
    ErrorTypeClient      // 客户端错误(4xx)
    ErrorTypeServer      // 服务端错误(5xx)
    ErrorTypeAuth        // 认证授权错误
    ErrorTypeValidation  // 验证错误
    ErrorTypeNotFound    // 资源不存在
    ErrorTypeConflict    // 资源冲突
)

使用示例

err := exception.NewInternalServerError("系统异常")

// 判断错误类型
if err.IsServerError() {
    // 服务端错误,记录详细日志
    log.Error().
        Str("stack", err.GetStack()).
        Msg("服务器内部错误")
}

if err.IsRetryable() {
    // 可以重试的错误
    return retryOperation()
}

// 获取错误类型
errType := err.Type() // ErrorTypeServer

错误分类方法

// 类型判断
err.Type() ErrorType        // 获取错误类型
err.IsRetryable() bool      // 是否可重试
err.IsClientError() bool    // 是否客户端错误(4xx)
err.IsServerError() bool    // 是否服务端错误(5xx)
err.IsAuthError() bool      // 是否认证错误

重试逻辑示例

func (c *Client) CallWithRetry(ctx context.Context, fn func() error) error {
    var lastErr error
    
    for i := 0; i < 3; i++ {
        err := fn()
        if err == nil {
            return nil
        }
        
        lastErr = err
        
        // 检查是否可重试
        if apiErr, ok := err.(*exception.ApiException); ok {
            if !apiErr.IsRetryable() {
                // 客户端错误或永久性错误,不重试
                return err
            }
        }
        
        // 等待后重试
        time.Sleep(time.Second * time.Duration(i+1))
    }
    
    return exception.Wrap(lastErr, exception.CODE_INTERNAL_SERVER_ERROR, "重试3次后仍失败")
}

堆栈追踪

堆栈追踪可以帮助快速定位错误来源,适合在关键错误路径使用:

基本使用

// 添加堆栈信息(默认3层调用栈)
err := exception.NewInternalServerError("系统错误").WithStack()

// 指定调用栈深度
err := exception.NewInternalServerError("系统错误").WithStackDepth(10)

// 获取堆栈信息
stack := err.GetStack()
fmt.Println(stack)

使用建议

// ✅ 关键错误或难以复现的问题
if err := criticalOperation(); err != nil {
    return exception.Wrap(err, exception.CODE_INTERNAL_SERVER_ERROR, "关键操作失败").
        WithStack()
}

// ✅ 系统级错误
if err := db.Connect(); err != nil {
    return exception.NewInternalServerError("数据库连接失败").
        WithCause(err).
        WithStack()
}

// ❌ 避免:常见业务错误收集堆栈(性能开销)
if user == nil {
    return exception.NewNotFound("用户不存在") // 无需 WithStack()
}

追踪标识

TraceID 和 RequestID 是直接字段,类型安全,自动序列化到 JSON:

// 设置追踪ID(用于分布式追踪)
err.WithTraceID("trace-xxx-xxx")

// 设置请求ID
err.WithRequestID("req-xxx-xxx")

// 获取方法
traceID := err.GetTraceID()
requestID := err.GetRequestID()

// 也可以直接访问字段
traceID := err.TraceID
requestID := err.RequestID

性能考虑

堆栈跟踪开销

堆栈跟踪有一定性能开销,建议按需使用:

// ✅ 仅在关键路径或难以调查的错误时收集堆栈
if criticalOperation {
    return exception.NewInternalServerError("critical error").WithStack()
}

// ✅ 简单的业务错误无需堆栈
return exception.NewBadRequest("invalid input")

元数据线程安全

Meta 并发访问已加锁保护,但频繁操作时考虑批量设置:

// ✅ 好的做法:构建时一次性设置
return exception.NewBadRequest("error").
    WithMeta("field1", val1).
    WithMeta("field2", val2).
    WithMeta("field3", val3)

// ❌ 避免:多处并发修改同一个异常实例

错误链性能

错误链遍历是 O(n) 操作,避免过深的嵌套:

// ✅ 合理:2-3层嵌套
err := exception.Wrap(lowLevelErr, exception.CODE_INTERNAL_SERVER_ERROR, "high level context")

// ⚠️ 避免:过深嵌套(如循环中多次Wrap)

实际使用示例

HTTP Handler 错误处理

func (h *Handler) GetUser(c *gin.Context) {
    userID := c.Param("id")
    
    user, err := h.service.GetUser(c.Request.Context(), userID)
    if err != nil {
        // 自动映射到HTTP状态码
        if apiErr, ok := err.(*exception.ApiException); ok {
            c.JSON(apiErr.GetHttpCode(), gin.H{"error": apiErr.Error()})
        } else {
            c.JSON(500, gin.H{"error": "Internal Server Error"})
        }
        return
    }
    
    c.JSON(200, user)
}

// Service层
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // 404错误
            return nil, exception.NewNotFound("用户不存在").
                WithMeta("user_id", id)
        }
        // 500错误,保留原始错误链
        return nil, exception.Wrap(err, exception.CODE_INTERNAL_SERVER_ERROR, "查询用户失败").
            WithMeta("user_id", id).
            WithStack()
    }
    return user, nil
}

微服务调用错误传播

func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
    // 调用库存服务
    if err := s.inventoryClient.Reserve(ctx, req.Items); err != nil {
        if apiErr, ok := err.(*exception.ApiException); ok {
            // 保留原始错误码并添加上下文
            return nil, exception.Wrap(apiErr, apiErr.Code(), "库存预留失败").
                WithMeta("order_items", req.Items)
        }
        return nil, exception.NewInternalServerError("库存服务异常").
            WithCause(err).
            WithStack()
    }
    
    // 调用支付服务
    if err := s.paymentClient.Charge(ctx, req.Amount); err != nil {
        // 回滚库存
        s.inventoryClient.Rollback(ctx, req.Items)
        
        return nil, exception.Wrap(err, exception.CODE_INTERNAL_SERVER_ERROR, "支付失败").
            WithMeta("amount", req.Amount)
    }
    
    // 创建订单...
    return order, nil
}

日志记录完整错误信息

func handleError(err error) {
    if apiErr, ok := err.(*exception.ApiException); ok {
        log.Error().
            Str("error", apiErr.Error()).
            Str("reason", apiErr.Reason()).
            Int("code", apiErr.Code()).
            Str("trace_id", apiErr.GetTraceID()).
            Interface("error_chain", apiErr.ErrorChain()).
            Str("stack", apiErr.GetStack()).
            Msg("操作失败")
    }
}

最佳实践

1. 选择合适的错误创建方式

// ✅ 使用语义明确的构造函数
return exception.NewBadRequest("用户名不能为空")

// ✅ 使用Builder构建复杂错误
return exception.NewBuilder(exception.CODE_BAD_REQUEST).
    WithReason("参数验证失败").
    WithMeta("field", "email").
    WithMeta("reason", "格式错误").
    Build()

// ❌ 避免:通用错误码损失语义
return exception.NewApiException(400, "错误")

2. 何时使用 Wrap

// ✅ 跨层调用时添加上下文
func (s *Service) Process(id string) error {
    data, err := s.repo.Get(id)
    if err != nil {
        // Wrap保留底层错误,添加业务上下文
        return exception.Wrap(err, exception.CODE_INTERNAL_SERVER_ERROR, "处理失败")
    }
    return nil
}

// ❌ 避免:包装已经是APIException的错误而不添加信息
if apiErr := s.doSomething(); apiErr != nil {
    return exception.Wrap(apiErr, apiErr.Code(), "") // 无意义
}

3. 合理添加元数据

// ✅ 添加有助于调查的信息
return exception.NewNotFound("订单不存在").
    WithMeta("order_id", orderID).
    WithMeta("user_id", userID).
    WithMeta("query_time", time.Now())

// ❌ 避免:敏感信息泄露
return exception.NewUnauthorized("认证失败").
    WithMeta("password", userPassword) // 危险!

4. 堆栈跟踪使用时机

// ✅ 关键错误或难以复现的问题
if err := criticalOperation(); err != nil {
    return exception.Wrap(err, exception.CODE_INTERNAL_SERVER_ERROR, "关键操作失败").
        WithStack()
}

// ✅ 系统级错误
if err := db.Connect(); err != nil {
    return exception.NewInternalServerError("数据库连接失败").
        WithCause(err).
        WithStack()
}

// ❌ 避免:常见业务错误收集堆栈(性能开销)
if user == nil {
    return exception.NewNotFound("用户不存在").WithStack() // 不必要
}

5. 错误类型分类

// ✅ 为可重试错误添加标记
if networkErr := callRemote(); networkErr != nil {
    return exception.NewServiceUnavailable("服务暂时不可用").
        WithCause(networkErr)
}

// ✅ 明确客户端错误
if !validateInput(req) {
    return exception.NewBadRequest("输入验证失败")
}

常见问题

Q1: 什么时候用Wrap 什么时候用NewXxx

A:

  • 使用 Wrap: 当你有一个底层错误需要添加上下文时
  • 使用 NewXxx: 当你创建新的业务错误时
// Wrap - 包装已有错误
dbErr := db.Query(...)
return exception.Wrap(dbErr, exception.CODE_INTERNAL_SERVER_ERROR, "查询用户失败")

// NewXxx - 创建新错误
if user == nil {
    return exception.NewNotFound("用户不存在")
}

Q2:WithCauseWrap 有什么区别?

A:

  • Wrap: 包装错误时会尝试保留原错误的HTTP状态码(如果是APIException)
  • WithCause: 仅存储原始错误,不影响当前错误的状态码
// Wrap - 保留底层APIException的状态码
lowLevelErr := exception.NewNotFound("resource not found") // 404
err := exception.Wrap(lowLevelErr, lowLevelErr.Code(), "operation failed")
// err.Code() == 404

// WithCause - 使用新的状态码
err2 := exception.NewInternalServerError("system error"). // 500
    WithCause(lowLevelErr)
// err2.Code() == 500

Q3: 错误链会影响性能吗?

A: 错误链本身开销很小,但堆栈跟踪有一定开销:

  • 错误链(Wrap/Unwrap):仅存储指针,开销可忽略
  • 堆栈跟踪(WithStack):需要收集调用栈,有一定开销
  • 建议:仅在关键路径或难以调查的错误时使用 WithStack()

Q4: 如何在日志中记录完整错误信息?

A: 使用 GetStack()ErrorChain() 获取详细信息:

if apiErr, ok := err.(*exception.ApiException); ok {
    log.Error().
        Str("error", apiErr.Error()).
        Str("trace_id", apiErr.GetTraceID()).
        Interface("error_chain", apiErr.ErrorChain()).
        Str("stack", apiErr.GetStack()).
        Msg("操作失败")
}

Q5: 是否线程安全?

A:

  • WithMeta/GetMeta: 线程安全(已加锁)
  • ✅ 其他方法:不可变操作,天然线程安全
  • ⚠️ 建议:错误创建后避免修改,使用构建器模式一次性构建

Q6: 如何与标准库errors 包协作?

A: 完全兼容 Go 1.13+ 错误处理:

// errors.Is
if errors.Is(err, sql.ErrNoRows) { ... }

// errors.As
var apiErr *exception.ApiException
if errors.As(err, &apiErr) {
    log.Printf("HTTP Code: %d", apiErr.Code())
}

// 错误链遍历
for e := err; e != nil; e = errors.Unwrap(e) {
    log.Println(e)
}

向后兼容

所有旧版 API 完全保留,现有代码无需修改:

// ✅ 所有旧的创建方法继续支持
err := exception.NewNotFound("resource not found")
err.WithMeta("key", "value")
err.WithData(data)

// ✅ 所有旧的判断方法继续支持
if exception.IsNotFoundError(err) {
    // ...
}

if exception.IsApiException(err, exception.CODE_NOT_FOUND) {
    // ...
}

// ✅ 所有常量继续可用
code := exception.CODE_NOT_FOUND