错误处理与统一响应
构建统一的错误码体系是 go-zero 工程化的核心环节。本章基于社区标杆项目(如 go-zero-looklook)的实战经验,阐述从 RPC 到 API 的全链路错误处理方案。
核心理念
“一份错误,两处使用”
- 前端:收到友好、可读的错误提示
- 日志:记录详细的调试信息(参数、堆栈、trace_id)
错误码体系设计
分层编码规范
| 编码范围 | 业务模块 | 示例 |
|---|---|---|
100001 ~ 199999 |
全局通用错误 | 参数错误、服务器内部错误 |
200001 ~ 299999 |
用户模块 | 用户不存在、登录失败 |
300001 ~ 399999 |
订单模块 | 订单创建失败、库存不足 |
400001 ~ 499999 |
支付模块 | 支付失败、退款异常 |
500001 ~ 599999 |
系统底层错误 | DB 异常、Redis 超时、网络错误 |
💡
uint32类型可直接转为 gRPC 的codes.Code,实现错误码跨服务透传。
错误码定义(common/xerr/errcode.go)
package xerr
// 成功
const OK uint32 = 0
// ===== 全局通用错误 (100001+) =====
const (
SERVER_COMMON_ERROR uint32 = 100001
REUQES_PARAM_ERROR uint32 = 100002
TOKEN_EXPIRE_ERROR uint32 = 100003
TOKEN_GENERATE_ERROR uint32 = 100004
DB_ERROR uint32 = 100005
REDIS_ERROR uint32 = 100006
)
// ===== 用户模块 (200001+) =====
const (
USER_NOT_FOUND uint32 = 200001
USER_ALREADY_EXISTS uint32 = 200002
WRONG_PASSWORD uint32 = 200003
)
// ===== 订单模块 (300001+) =====
const (
ORDER_NOT_FOUND uint32 = 300001
ORDER_CANCEL_FAIL uint32 = 300002
STOCK_NOT_ENOUGH uint32 = 300003
)错误信息映射(common/xerr/errMsg.go)
package xerr
var message map[uint32]string
func init() {
message = make(map[uint32]string)
message[OK] = "成功"
message[SERVER_COMMON_ERROR] = "服务器开小差啦,稍后再来试一试"
message[REUQES_PARAM_ERROR] = "参数错误"
message[TOKEN_EXPIRE_ERROR] = "Token 失效,请重新登录"
message[DB_ERROR] = "数据库繁忙,请稍后再试"
message[USER_NOT_FOUND] = "用户不存在"
message[WRONG_PASSWORD] = "用户名或密码错误"
message[ORDER_NOT_FOUND] = "订单不存在"
message[STOCK_NOT_ENOUGH] = "库存不足"
}
func MapErrMsg(errcode uint32) string {
if msg, ok := message[errcode]; ok {
return msg
}
return "服务器开小差啦,稍后再来试一试"
}
func IsCodeErr(errcode uint32) bool {
_, ok := message[errcode]
return ok
}自定义错误类型
// common/xerr/errors.go
package xerr
import "fmt"
// CodeError — 携带业务错误码的错误
type CodeError struct {
errCode uint32
errMsg string
}
func (e *CodeError) GetErrCode() uint32 { return e.errCode }
func (e *CodeError) GetErrMsg() string { return e.errMsg }
func (e *CodeError) Error() string {
return fmt.Sprintf("ErrCode: %d, ErrMsg: %s", e.errCode, e.errMsg)
}
// 通过错误码创建
func NewErrCode(errCode uint32) *CodeError {
return &CodeError{errCode: errCode, errMsg: MapErrMsg(errCode)}
}
// 自定义消息
func NewErrMsg(errMsg string) *CodeError {
return &CodeError{errCode: SERVER_COMMON_ERROR, errMsg: errMsg}
}
// 完整指定错误码 + 消息
func NewErrCodeMsg(errCode uint32, errMsg string) *CodeError {
return &CodeError{errCode: errCode, errMsg: errMsg}
}RPC 层错误处理
业务逻辑中使用
func (l *GetUserLogic) GetUser(in *rpc.GetUserReq) (*rpc.GetUserResp, error) {
user, err := l.svcCtx.UserModel.FindOne(l.ctx, in.Id)
if err != nil {
if errors.Is(err, sqlx.ErrNotFound) {
// 业务错误:用自定义错误码
return nil, errors.Wrapf(
xerr.NewErrCode(xerr.USER_NOT_FOUND),
"用户不存在 userId: %d, err: %v", in.Id, err,
)
}
// 系统错误
return nil, errors.Wrapf(
xerr.NewErrCode(xerr.DB_ERROR),
"查询用户失败 userId: %d, err: %v", in.Id, err,
)
}
return &rpc.GetUserResp{Id: user.Id, Name: user.Name}, nil
}💡
errors.Wrapf第 1 参数是给前端的CodeError,第 2 参数是给日志的详细信息。
gRPC 错误转换拦截器
func ErrorInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
if err != nil {
causeErr := errors.Cause(err) // 解包拿到原始 CodeError
if e, ok := causeErr.(*xerr.CodeError); ok {
// 记录完整日志
logx.WithContext(ctx).Errorf("【RPC-ERR】method: %s, err: %+v", info.FullMethod, err)
// 转为 gRPC status(错误码透传)
err = status.Error(codes.Code(e.GetErrCode()), e.GetErrMsg())
} else {
logx.WithContext(ctx).Errorf("【RPC-ERR】method: %s, err: %+v", info.FullMethod, err)
}
}
return resp, err
}API 层统一响应
响应结构
// common/xhttp/response.go
package xhttp
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
func Success(data interface{}) *Response {
return &Response{Code: 200, Msg: "OK", Data: data}
}
func Error(code int, msg string) *Response {
return &Response{Code: code, Msg: msg}
}核心响应处理函数
func HttpResult(r *http.Request, w http.ResponseWriter, resp interface{}, err error) {
if err == nil {
httpx.WriteJson(w, http.StatusOK, Success(resp))
return
}
errcode := int(xerr.SERVER_COMMON_ERROR)
errmsg := "服务器开小差啦,稍后再来试一试"
causeErr := errors.Cause(err)
if e, ok := causeErr.(*xerr.CodeError); ok {
// 来自 API 层自己的 CodeError
errcode = int(e.GetErrCode())
errmsg = e.GetErrMsg()
} else if gstatus, ok := status.FromError(causeErr); ok {
// 来自 RPC 的 gRPC error(内含自定义错误码)
grpcCode := uint32(gstatus.Code())
if xerr.IsCodeErr(grpcCode) {
errcode = int(grpcCode)
errmsg = gstatus.Message()
}
}
logx.WithContext(r.Context()).Errorf("【API-ERR】path: %s, err: %+v", r.URL.Path, err)
httpx.WriteJson(w, http.StatusOK, Error(errcode, errmsg))
}💡 注意:HTTP 状态码统一返回 200,业务错误码在
code字段中区分,供前端精确判断。
参数错误处理
func ParamErrorResult(r *http.Request, w http.ResponseWriter, err error) {
errMsg := fmt.Sprintf("%s: %s", xerr.MapErrMsg(xerr.REUQES_PARAM_ERROR), err.Error())
httpx.WriteJson(w, http.StatusOK, Error(int(xerr.REUQES_PARAM_ERROR), errMsg))
}在 goctl 模板中注入
修改 ~/.goctl/api/handler.tpl:
package handler
import (
"net/http"
"your-project/common/xhttp"
{{.ImportPackages}}
)
func {{.HandlerName}}(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
{{if .HasRequest}}
var req types.{{.RequestType}}
if err := httpx.Parse(r, &req); err != nil {
xhttp.ParamErrorResult(r, w, err) // 统一参数错误
return
}
{{end}}
l := logic.New{{.LogicType}}(r.Context(), ctx)
resp, err := l.{{.Call}}({{if .HasRequest}}req{{end}})
xhttp.HttpResult(r, w, resp, err) // 统一响应格式化
}
}💡 定制模板后,所有
goctl api go生成的 handler 都自动使用统一错误处理。
官方扩展方案(zeromicro/x)
go get github.com/zeromicro/ximport xhttp "github.com/zeromicro/x/http"
// handler 中使用
if err != nil {
xhttp.Error(w, err) // → {"code": 1001, "msg": "用户名或密码错误"}
} else {
xhttp.OkJson(w, resp) // → {"code": 0, "msg": "ok", "data": {...}}
}响应格式:
// 成功
{"code": 0, "msg": "ok", "data": {"uid": 1, "name": "go-zero"}}
// 错误
{"code": 1001, "msg": "用户名或密码错误"}错误码与 HTTP 状态码映射
| 业务错误码 | HTTP 状态码 | 含义 |
|---|---|---|
0 / 200 |
200 |
成功 |
100001 |
200 |
服务器内部错误(友好提示) |
100002 |
200 |
请求参数错误 |
100003 |
200 |
Token 失效 |
200001 |
200 |
用户不存在 |
200003 |
200 |
用户名或密码错误 |
300003 |
200 |
库存不足 |
💡 统一返回
200的好处:前端只需关注code字段,不需要同时处理 HTTP 状态码和业务错误码。
国际化(i18n)支持
// i18n/messages_zh.go
var zhMessages = map[uint32]string{
xerr.OK: "成功",
xerr.USER_NOT_FOUND: "用户不存在",
xerr.WRONG_PASSWORD: "用户名或密码错误",
}
// i18n/messages_en.go
var enMessages = map[uint32]string{
xerr.OK: "Success",
xerr.USER_NOT_FOUND: "User not found",
xerr.WRONG_PASSWORD: "Invalid username or password",
}
// 根据请求语言选择
func MapErrMsgWithLang(errcode uint32, lang string) string {
switch lang {
case "en", "en-US":
if msg, ok := enMessages[errcode]; ok {
return msg
}
default:
if msg, ok := zhMessages[errcode]; ok {
return msg
}
}
return "服务器开小差啦,稍后再来试一试"
}
// 从请求头获取语言
func GetLangFromRequest(r *http.Request) string {
lang := r.Header.Get("Accept-Language")
if lang == "" {
lang = "zh-CN"
}
return lang
}全链路错误传递
Logic 层抛出 CodeError
↓
RPC 拦截器捕获 → 转换为 status.Error (gRPC)
↓
网络传输(code + msg)
↓
API 层 HttpResult 捕获
→ 如果是 gRPC error → 提取业务错误码 + 消息
→ 如果是 CodeError → 提取业务错误码 + 消息
→ 写入 JSON 响应最佳实践
| 实践 | 说明 |
|---|---|
| 错误码用 uint32 | 兼容 gRPC codes.Code,支持跨服务透传 |
| 分层区分 | 全局/模块/底层分离,一眼看出错误来源 |
| 日志与响应分离 | errors.Wrapf 第一个参数给前端,第二个给日志 |
| RPC 拦截器统一转换 | CodeError → status.Error,不散落在每个 logic 中 |
| HTTP 统一 200 | 前端只看 code 字段,降低处理复杂度 |
| 模板定制 | goctl 模板注入统一错误处理,避免手写 |
| 区分内部/外部错误 | 底层错误(DB panic)不暴露给前端 |
常见陷阱
| 陷阱 | 说明 |
|---|---|
🚨 使用 errors.New 而非 CodeError |
RPC 拦截器无法识别自定义错误码 |
🚨 errors.Cause 使用错误 |
必须用 errors.Cause(err) 解包到最内层,否则类型断言失败 |
| 🚨 错误信息暴露内部细节 | 不要把 DB 连接串、SQL 等敏感信息放在给前端的 errMsg 中 |
| 🚨 错误码冲突 | 不同模块用了相同的错误码,用分层编码规范解决 |
| 🚨 不一致的 HTTP 状态码 | 团队内部约定统一用 200 还是区分 400/401/500 |