接手过一个老项目,错误处理是这样的:
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开头,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)
}
逻辑很直接:
- 接口返回error → 解出CodeMsg返回给前端
- 没有CodeMsg兜底 → 返回500000服务不可用
IgnoreLog()为true的错误(如密码错误)不记录日志- 其余错误统一打日志
对比其他方案
方案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")),
}
}
总结
这套错误码体系的核心思想:
- 分层:业务/公共/服务三层,语义清晰
- 链式:Wrap保留原始错误,可追踪
- 可控:IgnoreLog控制日志,避免刷屏
- 国际化:错误码不变,消息翻译
代码量不大,但解决了实际问题。适合中小型项目直接使用。