Gin 框架进阶系列(七):Gin 统一响应与错误处理最佳实践

Gin 框架进阶系列(七):Gin 统一响应与错误处理最佳实践


为什么需要统一

翻开很多 Gin 项目的代码,你会看到每个 handler 里都在重复同样的事情:手动拼 gin.H{"code": 0, "msg": "ok", "data": ...},出错时有的返回 400、有的返回 200 带错误码,字段名一会儿是 msg 一会儿是 message。前端对接时苦不堪言,后端自己维护也头疼。

统一响应和错误处理要解决的就是三个问题:所有接口返回格式一致,错误信息分层(用户看到什么、日志记录什么),handler 专注业务逻辑而不是重复拼 JSON。


统一响应结构

先定义一个全局通用的响应体:

// pkg/response/response.go
package response

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// Response 统一响应结构
type Response struct {
    Code int         `json:"code"`         // 业务状态码,0 表示成功
    Msg  string      `json:"msg"`          // 提示信息
    Data interface{} `json:"data,omitempty"` // 业务数据,无数据时省略该字段
}

// Success 成功响应
func Success(c *gin.Context, data interface{}) {
    c.JSON(http.StatusOK, Response{
        Code: 0,
        Msg:  "ok",
        Data: data,
    })
}

// SuccessWithMsg 成功响应,自定义消息
func SuccessWithMsg(c *gin.Context, msg string, data interface{}) {
    c.JSON(http.StatusOK, Response{
        Code: 0,
        Msg:  msg,
        Data: data,
    })
}

// Fail 失败响应
func Fail(c *gin.Context, httpStatus int, code int, msg string) {
    c.JSON(httpStatus, Response{
        Code: code,
        Msg:  msg,
    })
}

// FailWithData 失败响应,携带附加数据(如字段校验详情)
func FailWithData(c *gin.Context, httpStatus int, code int, msg string, data interface{}) {
    c.JSON(httpStatus, Response{
        Code: code,
        Msg:  msg,
        Data: data,
    })
}

这里做了一个重要设计:HTTP 状态码和业务状态码分离。HTTP 状态码给网关、负载均衡器、监控系统看(200 表示请求本身成功,401 表示未认证,500 表示服务端故障),业务状态码给前端看(10001 表示参数错误,20001 表示余额不足等等)。两者职责不同,不应混用。

所有接口统一返回的 JSON 永远只有 code、msg、data 三个字段,前端只需要一套解析逻辑。


自定义业务错误码

错误码不应该在代码里散落 magic number,需要集中管理:

// pkg/errcode/errcode.go
package errcode

// AppError 应用级错误
type AppError struct {
    HttpStatus int    `json:"-"`           // HTTP 状态码,不序列化
    Code       int    `json:"code"`        // 业务错误码
    Msg        string `json:"msg"`         // 面向用户的提示
    Internal   string `json:"-"`           // 内部错误信息,仅日志使用
}

// 实现 error 接口
func (e *AppError) Error() string {
    if e.Internal != "" {
        return e.Internal
    }
    return e.Msg
}

// New 创建 AppError
func New(httpStatus, code int, msg string) *AppError {
    return &AppError{
        HttpStatus: httpStatus,
        Code:       code,
        Msg:        msg,
    }
}

// WithInternal 附加内部错误信息
func (e *AppError) WithInternal(err error) *AppError {
    // 返回新对象,不修改原始定义
    newErr := *e
    newErr.Internal = err.Error()
    return &newErr
}

WithInternal 返回新对象而不是修改原对象,这一点很关键。因为下面我们会把错误码定义成包级变量,如果直接修改原始变量,并发场景下会出问题。


集中定义错误码

// pkg/errcode/code.go
package errcode

import "net/http"

// 通用错误 10xxx
var (
    ErrBadRequest     = New(http.StatusBadRequest, 10001, "请求参数错误")
    ErrUnauthorized   = New(http.StatusUnauthorized, 10002, "未登录或 Token 已过期")
    ErrForbidden      = New(http.StatusForbidden, 10003, "权限不足")
    ErrNotFound       = New(http.StatusNotFound, 10004, "资源不存在")
    ErrInternalServer = New(http.StatusInternalServerError, 10005, "服务器内部错误")
    ErrTooManyRequest = New(http.StatusTooManyRequests, 10006, "请求过于频繁")
)

// 用户模块 20xxx
var (
    ErrUserExist      = New(http.StatusConflict, 20001, "该邮箱已注册")
    ErrUserNotFound   = New(http.StatusNotFound, 20002, "用户不存在")
    ErrPasswordWrong  = New(http.StatusUnauthorized, 20003, "邮箱或密码错误")
    ErrTokenGenerate  = New(http.StatusInternalServerError, 20004, "Token 生成失败")
)

// 订单模块 30xxx
var (
    ErrOrderNotFound  = New(http.StatusNotFound, 30001, "订单不存在")
    ErrOrderCancelled = New(http.StatusBadRequest, 30002, "订单已取消,无法操作")
)

按模块分段编码:10xxx 通用,20xxx 用户,30xxx 订单。这样前端看到错误码就能大致定位问题域。


全局错误处理中间件

核心思路是让 handler 不再直接调用 c.JSON,而是通过 c.Error() 把错误挂到上下文,由统一的中间件收口处理:

// middleware/error_handler.go
package middleware

import (
    "errors"
    "log"
    "net/http"

    "your-project/pkg/errcode"
    "your-project/pkg/response"

    "github.com/gin-gonic/gin"
)

func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next() // 先执行后续 handler

        // 没有错误,直接返回
        if len(c.Errors) == 0 {
            return
        }

        // 取最后一个错误(通常也是最重要的那个)
        err := c.Errors.Last().Err

        // 判断是否是自定义 AppError
        var appErr *errcode.AppError
        if errors.As(err, &appErr) {
            // 记录内部错误到日志
            if appErr.Internal != "" {
                log.Printf("[BizError] code=%d msg=%s internal=%s path=%s",
                    appErr.Code, appErr.Msg, appErr.Internal, c.Request.URL.Path)
            }
            response.Fail(c, appErr.HttpStatus, appErr.Code, appErr.Msg)
            return
        }

        // 未知错误,统一返回 500,不暴露内部信息
        log.Printf("[UnknownError] err=%v path=%s", err, c.Request.URL.Path)
        response.Fail(c, http.StatusInternalServerError,
            errcode.ErrInternalServer.Code,
            errcode.ErrInternalServer.Msg)
    }
}

这个中间件做了两件非常重要的事。第一,它区分了业务错误和未知错误——业务错误返回友好的提示,未知错误一律返回"服务器内部错误",绝不把堆栈或数据库信息暴露给客户端。第二,内部错误信息只进日志,不进响应,这就是错误分层。


重构 Handler

有了统一响应和错误中间件后,handler 变得极其干净:

重构前(散乱版本)

func Login(c *gin.Context) {
    var req LoginReq
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"code": 400, "message": "参数错误"})
        return
    }

    var user model.User
    if err := config.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
        c.JSON(401, gin.H{"code": 401, "msg": "邮箱或密码错误"})
        return
    }

    if !pkg.CheckPassword(req.Password, user.Password) {
        c.JSON(401, gin.H{"code": 401, "msg": "邮箱或密码不对"})
        return
    }

    token, err := pkg.GenerateToken(user.ID, user.Email)
    if err != nil {
        c.JSON(500, gin.H{"code": 500, "msg": "token 生成失败"})
        return
    }

    c.JSON(200, gin.H{"code": 0, "msg": "ok", "data": gin.H{"token": token}})
}

字段名不统一(message vs msg),HTTP 状态码和业务码混为一谈,错误提示各自为战。

重构后(统一版本)

func Login(c *gin.Context) {
    var req LoginReq
    if err := c.ShouldBindJSON(&req); err != nil {
        c.Error(errcode.ErrBadRequest.WithInternal(err))
        return
    }

    var user model.User
    if err := config.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
        c.Error(errcode.ErrPasswordWrong)
        return
    }

    if !pkg.CheckPassword(req.Password, user.Password) {
        c.Error(errcode.ErrPasswordWrong)
        return
    }

    token, err := pkg.GenerateToken(user.ID, user.Email)
    if err != nil {
        c.Error(errcode.ErrTokenGenerate.WithInternal(err))
        return
    }

    response.Success(c, gin.H{"token": token})
}

handler 里再也看不到 HTTP 状态码和 JSON 拼接的噪音。每一行都在表达业务意图:绑定失败就是参数错误,查不到就是密码错误,生成失败就是 Token 异常,成功就返回数据。读代码的人三秒就能看懂。


参数校验错误的友好输出

ShouldBindJSON 返回的校验错误信息默认是英文且不够友好。我们可以把校验错误翻译成结构化的字段级提示:

// pkg/response/validator.go
package response

import (
    "github.com/gin-gonic/gin"
    "github.com/go-playground/validator/v10"

    "your-project/pkg/errcode"
)

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

// HandleValidationError 处理参数校验错误
func HandleValidationError(c *gin.Context, err error) {
    errs, ok := err.(validator.ValidationErrors)
    if !ok {
        // 不是 validator 的错误(比如 JSON 语法错误)
        c.Error(errcode.ErrBadRequest.WithInternal(err))
        return
    }

    fieldErrors := make([]FieldError, 0, len(errs))
    for _, e := range errs {
        fieldErrors = append(fieldErrors, FieldError{
            Field:   e.Field(),
            Message: msgForTag(e),
        })
    }

    // 使用 Abort 直接返回,不再走 ErrorHandler
    c.AbortWithStatusJSON(400, Response{
        Code: errcode.ErrBadRequest.Code,
        Msg:  "请求参数校验失败",
        Data: fieldErrors,
    })
}

func msgForTag(e validator.FieldError) string {
    switch e.Tag() {
    case "required":
        return "不能为空"
    case "email":
        return "邮箱格式不正确"
    case "min":
        return "长度不能小于 " + e.Param()
    case "max":
        return "长度不能大于 " + e.Param()
    case "oneof":
        return "值必须是以下之一: " + e.Param()
    default:
        return "校验失败: " + e.Tag()
    }
}

handler 中使用:

if err := c.ShouldBindJSON(&req); err != nil {
    response.HandleValidationError(c, err)
    return
}

前端收到的响应:

{
    "code": 10001,
    "msg": "请求参数校验失败",
    "data": [
        {"field": "Email", "message": "邮箱格式不正确"},
        {"field": "Password", "message": "长度不能小于 6"}
    ]
}

每个字段具体哪里不合格,一目了然。


Panic Recovery 增强

Gin 自带的 Recovery() 中间件会捕获 panic 并返回 500,但它的响应格式不符合我们的统一结构。替换掉它:

// middleware/recovery.go
package middleware

import (
    "log"
    "net/http"
    "runtime/debug"

    "your-project/pkg/errcode"
    "your-project/pkg/response"

    "github.com/gin-gonic/gin"
)

func CustomRecovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if r := recover(); r != nil {
                // 打印完整堆栈
                log.Printf("[PANIC] %v\n%s", r, debug.Stack())

                // 返回统一格式
                response.Fail(c,
                    http.StatusInternalServerError,
                    errcode.ErrInternalServer.Code,
                    errcode.ErrInternalServer.Msg,
                )
                c.Abort()
            }
        }()
        c.Next()
    }
}

注册时用 CustomRecovery 替换默认的 Recovery:

r := gin.New() // 不用 gin.Default(),它会自动加 Logger + Recovery
r.Use(gin.Logger())
r.Use(middleware.CustomRecovery())
r.Use(middleware.ErrorHandler())

注意中间件的顺序。CustomRecovery 必须在最外层,这样它才能兜住所有下游中间件和 handler 中的 panic。ErrorHandler 放在 CustomRecovery 之后,处理正常的业务错误。


404 和 405 处理

Gin 对未匹配的路由默认返回 404 page not found 纯文本,格式同样不统一:

// main.go
r.NoRoute(func(c *gin.Context) {
    response.Fail(c, http.StatusNotFound, errcode.ErrNotFound.Code, "接口不存在")
})

r.NoMethod(func(c *gin.Context) {
    response.Fail(c, http.StatusMethodNotAllowed, 10007, "请求方法不允许")
})

别忘了启用 405 检测,Gin 默认是关闭的:

r.HandleMethodNotAllowed = true

分页响应的封装

列表接口几乎都需要分页,单独封装一个分页响应:

// pkg/response/pagination.go
package response

import "github.com/gin-gonic/gin"

type PageResult struct {
    List     interface{} `json:"list"`
    Total    int64       `json:"total"`
    Page     int         `json:"page"`
    PageSize int         `json:"page_size"`
}

func SuccessWithPage(c *gin.Context, list interface{}, total int64, page, pageSize int) {
    Success(c, PageResult{
        List:     list,
        Total:    total,
        Page:     page,
        PageSize: pageSize,
    })
}

handler 中使用:

func ListUsers(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
    if pageSize > 100 {
        pageSize = 100 // 防止一次拉太多
    }

    var users []model.User
    var total int64

    config.DB.Model(&model.User{}).Count(&total)
    config.DB.Offset((page - 1) * pageSize).Limit(pageSize).Find(&users)

    response.SuccessWithPage(c, users, total, page, pageSize)
}

前端拿到的格式永远是:

{
    "code": 0,
    "msg": "ok",
    "data": {
        "list": [...],
        "total": 42,
        "page": 1,
        "page_size": 10
    }
}

请求 ID 追踪

当用户反馈"接口报错了",你需要快速从海量日志里找到那一条。给每个请求分配一个唯一 ID,响应头和日志都带上它:

// middleware/request_id.go
package middleware

import (
    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
)

func RequestID() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 优先从请求头取(上游网关可能已经生成了)
        rid := c.GetHeader("X-Request-ID")
        if rid == "" {
            rid = uuid.New().String()
        }
        c.Set("request_id", rid)
        c.Header("X-Request-ID", rid)
        c.Next()
    }
}

在 ErrorHandler 中把 request_id 一起记进日志:

rid, _ := c.Get("request_id")
log.Printf("[BizError] request_id=%v code=%d msg=%s internal=%s path=%s",
    rid, appErr.Code, appErr.Msg, appErr.Internal, c.Request.URL.Path)

前端报错时只需要提供 X-Request-ID,后端一条命令就能 grep 到。


完整中间件注册顺序

func main() {
    r := gin.New()

    // 第一层:请求 ID(最早分配,后续所有中间件都能用)
    r.Use(middleware.RequestID())

    // 第二层:Panic 兜底
    r.Use(middleware.CustomRecovery())

    // 第三层:日志
    r.Use(gin.Logger())

    // 第四层:统一错误处理
    r.Use(middleware.ErrorHandler())

    // 404 / 405
    r.HandleMethodNotAllowed = true
    r.NoRoute(func(c *gin.Context) {
        response.Fail(c, 404, errcode.ErrNotFound.Code, "接口不存在")
    })
    r.NoMethod(func(c *gin.Context) {
        response.Fail(c, 405, 10007, "请求方法不允许")
    })

    // 业务路由
    registerRoutes(r)

    r.Run(":8080")
}

中间件的顺序就是洋葱模型从外到内的顺序。RequestID 在最外层保证所有后续操作都能拿到 ID;Recovery 在第二层兜住一切 panic;Logger 在第三层记录请求日志;ErrorHandler 在第四层收口业务错误。


设计原则总结

第一,响应结构固定为 code + msg + data,没有例外。前端只需要一个拦截器就能统一处理所有接口。

第二,错误分两层。面向用户的 Msg 要可读、可展示("邮箱或密码错误"),面向开发者的 Internal 只进日志("record not found: SELECT * FROM users WHERE email = ...")。永远不要把数据库错误、堆栈信息返回给客户端。

第三,handler 只做三件事——绑定参数、调用业务逻辑、返回成功响应或 c.Error。任何格式拼装、状态码映射、日志记录的工作都交给中间件和工具函数。handler 越薄,越不容易出 bug。

第四,错误码集中管理,按模块分段。新增错误只需要在 errcode/code.go 里加一行,grep 错误码就能找到所有使用的地方。


posted @ 2026-04-05 19:23  平元兄  阅读(181)  评论(0)    收藏  举报