Go项目错误码体系设计:从混乱到清晰

7 阅读7分钟

接手过一个老项目,错误处理是这样的:

if err != nil {
    return errors.New("操作失败")
}

前端问:"什么操作失败了?" 后端:"不知道,看日志吧。"

后来我重构了一个电商项目,从零设计了一套错误码体系。用下来觉得挺顺手的,分享出来。


先想清楚三个问题

1. 错误码给谁看?

  • 前端:需要知道具体错误类型,决定弹什么提示
  • 运维:需要快速定位问题,日志要能搜
  • 用户:看到的是翻译后的文案,不是错误码本身

2. 错误码要区分什么?

  • 错误类型:参数错误、权限不足、余额不足、服务异常
  • 错误来源:用户操作问题、业务逻辑问题、系统故障
  • 是否需要记录:密码错误不需要记录,数据库挂了必须记录

3. 错误信息怎么国际化?

同一个错误,中文是"余额不足",英文是"Insufficient balance"。错误码不变,消息要变。


分层设计

我用了三段式错误码:

区间含义特点维护方式
100000-199999业务错误按接口隔离,不同接口可使用相同错误码表示不同含义各接口/各服务独立维护
300000-399999公共错误全局唯一,所有服务含义一致所有服务同步一份
500000-599999服务错误全局唯一,所有服务含义一致所有服务同步一份

为什么跳过2和4?留空,方便以后扩展。

分层设计的好处:

  • 接口之间互不影响:新增接口不影响老的错误码
  • 微服务拆分友好:项目拆成多个独立服务后,各自维护自己的1开头,3和5开头两边各存一份相同的定义,不需要跨服务协调

业务错误(1开头)

业务错误的特点:按接口隔离,不跨接口复用

同一个错误码100001:

  • 提现接口 → 提现失败,原因可能是余额不足、账户被冻结
  • 用户信息接口 → 用户状态异常

两个"100001"含义完全不同。所以业务错误码是接口私有的,每个接口维护自己的一套1开头错误码。

这样设计的原因:

  1. 防止错误码膨胀:如果全局复用,一个复杂项目可能有上百个接口,每个接口都要避免和其他接口冲突,错误码表会越来越难维护
  2. 微服务拆分友好:项目拆成多个独立服务后,各自维护自己的1开头,3和5开头两边同步一份就行,不需要跨服务协调

公共错误(3开头)

公共错误的特点:语义固定,全局唯一

  • 300000:未授权(未登录)
  • 300001:授权已过期(token过期)
  • 300002:无效的刷新令牌
  • 300003:禁止访问(无权限)
  • 300004:参数错误
  • 300005:请求过于频繁
  • 300006:资源不存在

这些错误在任何接口含义都一样,全局唯一,不可重复。微服务拆分后,不同服务需要各维护一份相同内容的3开头错误码定义。

服务错误(5开头)

服务错误的特点:不是用户的问题,是系统的问题,全局唯一

  • 500000:服务不可用(数据库挂了、Redis挂了)
  • 500001:服务超时

这类错误需要告警。微服务拆分后,5开头也是全局同步的,不同服务各维护一份相同的定义。


核心结构

type CodeMsg struct {
    Code int    // 错误码
    Msg  string // 错误消息(已国际化)
}

type BizError struct {
    CodeMsg
    
    ignoreLog bool       // 是否忽略日志
    err       error      // 原始错误(链式)
    print     string     // 格式化后的详细信息
    frame     xerrors.Frame // 调用栈
}

关键字段说明

ignoreLog:有些错误不需要记录日志。

// 密码错误不需要记录,否则日志全是暴力破解
ErrInvalidPassword: newBizError(100005, "密码错误", IgnoreLog())

// 数据库挂了必须记录
ErrServiceUnavailable: newServiceError(500000, "服务不可用")

err + print:链式错误,保留上下文。

// 使用
if balance < amount {
    return ErrInsufficientBalance.Wrap(err, "余额不足: 当前%.2f, 需要%.2f", balance, amount)
}

// 日志输出
// [ERROR] 余额不足: 当前100.00, 需要200.00
//   at withdraw.go:45
//   caused by: sql: no rows in result set

frame:自动捕获调用栈,用 golang.org/x/xerrors 实现。


使用方式

定义错误

// 公共错误(errorc包)
var Default = &Errorc{
    ErrUnAuthorized:      newCommonError(300000, "未授权", IgnoreLog()),
    ErrInvalidParams:     newCommonError(300004, "参数错误"),
    ErrServiceUnavailable: newServiceError(500000, "服务不可用"),
}

// 业务错误(errs包,按模块分)
var Fund = &FundErrs{
    ErrInsufficientBalance:    New(100009, "余额不足"),
    ErrFundAccountUnavailable: New(100010, "资金账户不可用"),
}

使用错误

func (l *WithdrawLogic) Withdraw(req *WithdrawReq) error {
    // 参数校验失败
    amount, err := decimal.NewFromString(req.Amount)
    if err != nil {
        return l.ErrInvalidParams.Wrap(err, "无效金额: %s", req.Amount)
    }
    
    // 业务校验失败
    if account.Available.LessThan(amount) {
        return l.ErrInsufficientBalance.Wrap(nil, "余额不足")
    }
    
    // 数据库操作失败
    wallet, err := l.svcCtx.Wallets.FindOne(ctx, req.WalletId)
    if errors.Is(err, ErrNotFound) {
        return l.ErrNotFound.Wrap(err, "钱包不存在")
    } else if err != nil {
        return l.ErrServiceUnavailable.Wrap(err, "查询钱包失败")
    }
    
    return nil
}

响应格式

正常返回:

{"code": 0, "msg": "ok", "data": {...}}

业务错误返回:

{"code": 100009, "msg": "余额不足"}

公共错误返回:

{"code": 300004, "msg": "参数错误"}

未捕获的错误(没有CodeMsg兜底):

{"code": 500000, "msg": "服务不可用"}

前端根据code判断错误类型,msg是已国际化的消息。


国际化

错误消息通过i18n翻译:

// 定义时使用翻译key
ErrInsufficientBalance: New(100009, i18n.T("ErrInsufficientBalance"))

// 翻译文件
// zh.json
{"ErrInsufficientBalance": "余额不足"}

// en.json
{"ErrInsufficientBalance": "Insufficient balance"}

请求时通过 Accept-Language 头指定语言:

Accept-Language: zh-CN  → "余额不足"
Accept-Language: en-US  → "Insufficient balance"

日志处理

日志不是业务逻辑里手动调的,是在HTTP响应拦截器里统一处理的。

func JsonBaseResponseCtx(ctx context.Context, w http.ResponseWriter, v any) {
    if err, ok := v.(error); ok {
        // 解出CodeMsg,没CodeMsg就返回500000
        cme, ok := errorc.As(errorc.Unwrap(err))
        if ok {
            v = cme.CodeMsg
        } else {
            v = errorc.WithCtx(ctx).ErrServiceUnavailable.CodeMsg
        }

        // 只有不需要忽略日志的才记录
        if !ok || !cme.IgnoreLog() {
            // 调用框架的日志记录方法
            logc.Errorf(ctx, "http response error: %+v", err)
        }
    }
    // 调用框架的接口响应方法
    xhttp.JsonBaseResponseCtx(ctx, w, v)
}

逻辑很直接:

  1. 接口返回error → 解出CodeMsg返回给前端
  2. 没有CodeMsg兜底 → 返回500000服务不可用
  3. IgnoreLog()为true的错误(如密码错误)不记录日志
  4. 其余错误统一打日志

对比其他方案

方案1:直接返回error

if balance < amount {
    return errors.New("余额不足")
}

问题:

  • 前端无法区分错误类型
  • 无法国际化
  • 日志没结构

方案2:返回code + msg

if balance < amount {
    return 100009, "余额不足"
}

问题:

  • 没有原始错误,无法追踪
  • 没有调用栈
  • 链式错误丢失

方案3:用这个错误码体系

if balance < amount {
    return ErrInsufficientBalance.Wrap(nil, "余额不足")
}

优点:

  • 有错误码,前端可区分
  • 有原始错误,可追踪
  • 有调用栈,可定位
  • 支持国际化
  • 可控制日志级别

完整代码

核心就两个文件:

errorc.go - 错误码基础定义

package errorc

import (
    "errors"
    "fmt"
    
    zerrors "github.com/zeromicro/x/errors"
    "golang.org/x/xerrors"
)

type CodeMsg struct {
    zerrors.CodeMsg
    
    ignoreLog bool
    err       error
    print     string
    frame     xerrors.Frame
}

func (c CodeMsg) Wrap(err error, format string, a ...any) error {
    c.err = err
    c.print = fmt.Sprintf(format, a...)
    c.frame = xerrors.Caller(1)
    return c
}

func (c CodeMsg) IgnoreLog() bool {
    return c.ignoreLog
}

// 业务错误(1开头)
func New(code int, msg string, opts ...CodeMsgOption) CodeMsg {
    if code < 100000 || code >= 200000 {
        panic("业务错误码必须是1开头的6位数字")
    }
    return new(code, msg, opts...)
}

// 公共错误(3开头)
func newCommonError(code int, msg string, opts ...CodeMsgOption) CodeMsg {
    if code < 300000 || code >= 400000 {
        panic("公共错误码必须是3开头的6位数字")
    }
    return new(code, msg, opts...)
}

// 服务错误(5开头)
func newServiceError(code int, msg string, opts ...CodeMsgOption) CodeMsg {
    if code < 500000 || code >= 600000 {
        panic("服务错误码必须是5开头的6位数字")
    }
    return new(code, msg, opts...)
}

errs.go - 业务错误定义

package errs

type Errs struct {
    *errorc.Errorc  // 继承公共错误
    
    ErrInsufficientBalance    errorc.CodeMsg
    ErrProductIsOutOfStock    errorc.CodeMsg
    ErrFundAccountUnavailable errorc.CodeMsg
}

func WithI18n(i *i18n.Localizer) *Errs {
    return &Errs{
        Errorc: errorc.WithI18n(i),
        
        ErrInsufficientBalance:    errorc.New(100009, i.T("ErrInsufficientBalance")),
        ErrProductIsOutOfStock:    errorc.New(100008, i.T("ErrProductIsOutOfStock")),
        ErrFundAccountUnavailable: errorc.New(100010, i.T("ErrFundAccountUnavailable")),
    }
}

总结

这套错误码体系的核心思想:

  1. 分层:业务/公共/服务三层,语义清晰
  2. 链式:Wrap保留原始错误,可追踪
  3. 可控:IgnoreLog控制日志,避免刷屏
  4. 国际化:错误码不变,消息翻译

代码量不大,但解决了实际问题。适合中小型项目直接使用。