Go 企业级工程能力实战(11):一个 JSON 响应的进化史——从 {“ok“:true} 到企业级信封

本文是《Go 企业级工程能力实战》系列的第 11 篇,基于开源项目 user-service 的真实代码,拆解一个 JSON 响应格式从"能用"到"企业级"的完整进化过程。


一、开篇引入:一次凌晨三点的线上事故

凌晨三点,手机疯狂震动。

SRE 同学发来告警截图,前端页面一片空白。用户投诉雪片般飞来。

我眯着眼睛打开日志,翻了半天才在堆栈里找到一句话:

Error 1062: Duplicate entry '42-99' for key 'PRIMARY'

是个 MySQL 主键冲突。可我翻遍所有日志,找不到这个报错究竟是哪个 API 触发的、请求参数是什么、客户端收到了什么错误信息。

更糟糕的是,前端同学说"我们根本没有收到任何错误响应体,只是拿到了一个 HTTP 200 和一堆 HTML"——Gin 的 recovery 中间件默认返回的是 HTML 500 页面。

那晚我们修复只花了 10 分钟——加了个唯一索引的前置判断。但定位问题花了 2 个小时,因为我们的 API 响应完全没有规范可言:

  • 有的接口返回 {"ok": true},有的返回 {"success": 1},有的返回 {"code": 200}
  • 错误时有的返回 {"error": "something wrong"},有的直接 panic 让 Gin 吐 HTML
  • 分页接口没有统一的分页元数据,前端每次都要猜总页数

一个不规范的 API 响应格式,就像一栋大楼没有统一的地址编号系统——外来者永远找不到正确的门牌号。

那晚之后,我下定决心对整个项目的 API 响应层做大一统。这就是本文要讲的故事——一个企业级 JSON 响应"信封"的诞生。


二、概念铺垫:什么是 API 响应"信封"

在开始改造之前,我们先搞明白一个核心概念:什么是我说的"信封"(Envelope)?

2.1 一个生活类比

想象你去邮局寄信。

  • 你把信纸(业务数据)放进信封
  • 信封上写清楚收件人地址、寄件人地址(元信息)
  • 如果信有问题,退回时也会有"查无此人"或"地址不详"的标注(错误信息)

邮局不会让你把信纸直接裸着塞进邮筒,对吧?

API 响应也需要一个"信封"——在所有业务数据外面,包一层统一的元信息。无论成功还是失败,客户端的解析逻辑完全一致。

2.2 没有信封的世界

先看反面教材。以下是我见过的一些真实项目的响应格式:

// 接口 A:成功时
{ "id": 1, "name": "bobby" }

// 接口 B:成功时
{ "ok": true, "user": { "id": 1, "name": "bobby" } }

// 接口 C:失败时
"Internal Server Error"  // 直接返回字符串!

// 接口 D:分页时
{ "list": [...], "count": 100 }  // 是总数还是当前页数量?下一页怎么翻?

前端同学面对这些五花八门的格式,只能写出一堆 if (data.ok !== undefined) / else if (data.code === 0) / else if (data.success) 的判断逻辑。这难道不是梦魇吗?

2.3 企业级信封的标准结构

在 user-service 项目中,我们定义了一个"两段式信封":

成功响应:

{
    "code": 0,
    "data": {
        "id": 12,
        "name": "bobby哥",
        "create_at": "2022-06-09 05:03:53"
    }
}

错误响应:

{
    "code": -1,
    "msg": "param name not set"
}

分页响应:

{
    "code": 0,
    "data": [...],
    "pagination": {
        "total": 50,
        "page": 1,
        "page_size": 20,
        "total_pages": 3
    }
}

不变的规则只有两条:

  1. code 字段永远在最外层,0 表示成功,负数表示失败(数值直接对应业务错误码)
  2. 成功时有 data,失败时有 msg,结构清晰,绝不会同时出现

这个规范覆盖了项目中 16 个 API 端点,贯穿了用户管理、好友关系、黑名单、认证等所有模块。接下来,我们从零开始推导这个信封的进化过程。


三、循序渐进:从 {"ok":true}{"code":0,"data":{}} 的四次进化

3.1 第一代:裸奔时代

最原始的响应方式,直接用 gin.H 自由发挥:

// 想怎么写就怎么写
c.JSON(200, gin.H{"id": user.Id, "name": user.Name})                   // 成功
c.JSON(500, gin.H{"error": "something wrong"})                         // 失败
c.JSON(200, gin.H{"list": list, "count": count})                       // 分页

问题:

  • 客户端需要根据 HTTP 状态码 + body 结构组合判断,逻辑分散
  • 不同接口的响应字段名不统一(error / err / message / msg
  • 无法统一注入链路追踪 ID、请求 ID 到错误日志中
  • 无法在中间件层面做响应日志审计

3.2 第二代:定义基础结构体

发现"裸奔"方式无法做日志审计后,我们在 pkg/util/net.go 中定义了基础结构体:

// pkg/util/net.go:35-43
type ErrMsg struct {
    Code int    `json:"code"`
    Msg  string `json:"msg"`
}

type SuccMsg struct {
    Code int         `json:"code"`
    Data interface{} `json:"data"`
}

同时定义了辅助函数:

// pkg/util/net.go:45-57
func ReturnError(w http.ResponseWriter, log logger.Logger, errCode int, msg string) {
    errMsg := ErrMsg{Code: errCode, Msg: msg}
    json.NewEncoder(w).Encode(errMsg)
    bstr, _ := json.Marshal(errMsg)
    log.Error("return err:", string(bstr))
}

func ReturnSucc(w http.ResponseWriter, log logger.Logger, data interface{}) {
    succMsg := SuccMsg{Code: 0, Data: data}
    json.NewEncoder(w).Encode(succMsg)
    bstr, _ := json.Marshal(succMsg)
    log.Info("return succ:", string(bstr))
}

这一代的进步是:统一了结构体定义和日志记录,每次返回都会自动写入日志。

但它仍有不足:

  • 使用的是原生 http.ResponseWriter,而项目基于 Gin 框架
  • 没有与 HTTP 状态码做智能映射(错误时总是返回 200)
  • 分页场景没有专门的封装
  • 错误日志里没有 trace_id、span_id 等链路追踪信息

3.3 第三代:Gin 集成 + HTTP 状态码映射

第三代改造做了两件事:

第一件:将响应函数挂到 Gin Context 上。

我们把 ReturnError / ReturnSucc 改造成了 Service 的方法,操作 *gin.Context

// service/ResponseHandler.go:87-106
func (s *Service) returnError(c *gin.Context, code int, msg string) {
    reqID := getReqID(c.Request.Context())
    ctx := c.Request.Context()
    traceID := traceIDFromContext(ctx)
    spanID := spanIDFromContext(ctx)
    logFormat := "[%s] api: %s, code: %d, msg: %s"
    if traceID != "" {
        logFormat = "[trace_id=" + traceID + "] [span_id=" + spanID + "] " + logFormat
    }
    s.Logger.Errorf(logFormat, reqID, c.Request.RequestURI, code, msg)
    c.JSON(httpStatusFromCode(code), gin.H{"code": code, "msg": msg})
}

func (s *Service) returnSuccess(c *gin.Context, data interface{}) {
    c.JSON(http.StatusOK, gin.H{"code": 0, "data": data})
}

第二件:错误码到 HTTP 状态码的智能映射。

这是本次进化的核心。我们分析道:错误码应该和 HTTP 语义一一对应,RESTful API 不应该所有错误都返回 200。

// service/ResponseHandler.go:70-85
func httpStatusFromCode(code int) int {
    switch code {
    case constant.ERROR_PARAM_ERR:
        return http.StatusBadRequest       // 400
    case constant.ERROR_AUTH_FAIL:
        return http.StatusUnauthorized     // 401
    case constant.ERROR_PERMISSION_DENIED:
        return http.StatusForbidden        // 403
    case constant.ERROR_REQUEST_NOT_FOUND:
        return http.StatusNotFound         // 404
    case constant.ERROR_ALREADY_FRIENDS,
         constant.ERROR_FRIEND_REQUEST_EXISTS,
         constant.ERROR_REQUEST_NOT_PENDING,
         constant.ERROR_BLOCKED:
        return http.StatusConflict         // 409
    default:
        return http.StatusInternalServerError  // 500
    }
}

这里的业务错误码定义在 constant/ErrorCode.go:3-13

const (
    ERROR_PARAM_ERR              = -1
    ERROR_MYSQL_ERR              = -2
    ERROR_AUTH_FAIL              = -3
    ERROR_PERMISSION_DENIED      = -4
    ERROR_ALREADY_FRIENDS        = -5
    ERROR_FRIEND_REQUEST_EXISTS  = -6
    ERROR_REQUEST_NOT_FOUND      = -7
    ERROR_REQUEST_NOT_PENDING    = -8
    ERROR_BLOCKED                = -9
)

设计要点:同一个 HTTP 状态码(如 409 Conflict)可以对应多个业务错误码(-5-6-8-9)。HTTP 层告诉客户端"你当前的请求存在冲突",body 里的 code 告诉客户端"具体是什么冲突"。这是 HTTP 语义 + 业务语义 的分层设计。

你可能会问:为什么错误码用负数?这是为了直观区分——code >= 0 就是成功,code < 0 就是失败。你甚至可以用 code > 0 表示"成功但有警告"的中间态。这种约定非常简洁,没有任何认知负担。

3.4 第四代:分页封装 + 格式化变体

第三代完成后,成功和错误响应已经规整了。但分页场景还有痛点——有的接口把分页信息塞进 data 里,有的放外面,格式各异。

第四代做了最后一层封装:

// service/ResponseHandler.go:108-123
func (s *Service) returnPaginated(c *gin.Context, data interface{}, total int64, page, pageSize int) {
    totalPages := int(total) / pageSize
    if int(total)%pageSize != 0 {
        totalPages++
    }
    c.JSON(http.StatusOK, gin.H{
        "code": 0,
        "data": data,
        "pagination": gin.H{
            "total":       total,
            "page":        page,
            "page_size":   pageSize,
            "total_pages": totalPages,
        },
    })
}

分页信息独立成为一个顶层字段 pagination,不污染 data。为什么把 pagination 放在顶层而不是 data 内部?

这是一个架构决策:data 是纯业务数据,pagination 是传输层元数据。如果放在 data 里,当业务数据本身就有 total 字段时(比如统计类接口),会造成语义冲突。顶层分离是最干净的做法。

同时还加了一个格式化的便捷版本:

// service/ResponseHandler.go:100-102
func (s *Service) returnErrorf(c *gin.Context, code int, format string, a ...interface{}) {
    s.returnError(c, code, fmt.Sprintf(format, a...))
}

至此,响应辅助函数家族完整了:returnSuccessreturnErrorreturnSuccessfulfreturnPaginated 四大金刚,覆盖了项目中所有的响应场景。


四、代码实战:响应"信封"在真实接口中的完整演练

光说不练假把式。我们拿几个真实的 API 端点,完整展示这套响应体系是如何工作的。

4.1 查询用户信息

service/UserService.go:39-75GetUser 方法展示了标准的成功/错误响应流程:

func (s *Service) GetUser(c *gin.Context) {
    uidStr := c.Param("uid")
    if uidStr == "" {
        s.returnError(c, constant.ERROR_PARAM_ERR, "param uid not set")  // ①
        return
    }
    uid, err := strconv.Atoi(uidStr)
    if err != nil {
        s.returnError(c, constant.ERROR_PARAM_ERR, err.Error())           // ②
        return
    }
    userInfo, err := s.UserDao.FindUser(c.Request.Context(), uid)
    if err != nil {
        s.returnError(c, constant.ERROR_PERMISSION_DENIED, "user not found")  // ③
        return
    }
    // ... 黑名单检查 ...
    userInfo.Password = ""   // ④ 关键安全操作:脱敏
    s.returnSuccess(c, userInfo)   // ⑤
}

拆解这个流程:

  • ①②:参数校验失败 → ERROR_PARAM_ERR(-1)→ HTTP 400 Bad Request
  • :用户不存在 → ERROR_PERMISSION_DENIED(-4)→ HTTP 403 Forbidden(这里刻意不返回 404 以防止用户枚举攻击——攻击者无法通过 HTTP 状态码判断一个 UID 是否存在)
  • :密码字段置空——返回给客户端的 User 对象不应该包含密码哈希,这是一个安全底线,在下文 sanitizeErr 部分会详细展开
  • :一切正常 → code=0,HTTP 200,data 里带着用户信息

实际响应效果(来自 User API接口协议文档.md):

{
    "code": 0,
    "data": {
        "id": 12,
        "name": "bobby哥",
        "dob": "1990-01-10",
        "address": "sz",
        "description": "i am a boy, a coder",
        "create_at": "2022-06-09 05:03:53"
    }
}

4.2 好友列表分页查询

service/FriendsServer.go:202-242GetFriendsList 展示了分页响应的完整链路:

func (s *Service) GetFriendsList(c *gin.Context) {
    // ... 参数校验和权限检查 ...

    page, pageSize := parsePagination(c)    // ① 解析分页参数
    offset := (page - 1) * pageSize

    total, err := s.FriendsDao.CountFriendsList(c.Request.Context(), uid)   // ② 先 count
    if err != nil {
        s.returnError(c, constant.ERROR_MYSQL_ERR, sanitizeErr(err).Error())
        return
    }

    list, err := s.FriendsDao.GetFriendsList(c.Request.Context(), uid, pageSize, offset)  // ③ 再查数据
    if err != nil {
        s.returnError(c, constant.ERROR_MYSQL_ERR, sanitizeErr(err).Error())
        return
    }

    s.returnPaginated(c, list, total, page, pageSize)   // ④ 统一分页响应
}

① 分页参数解析service/FriendsServer.go:11-29):

func parsePagination(c *gin.Context) (page, pageSize int) {
    page = 1        // 默认第 1 页
    pageSize = 20   // 默认每页 20 条

    if p := c.Query("page"); p != "" {
        if n, err := strconv.Atoi(p); err == nil && n > 0 {
            page = n
        }
    }
    if ps := c.Query("page_size"); ps != "" {
        if n, err := strconv.Atoi(ps); err == nil && n > 0 {
            pageSize = n
            if pageSize > 100 {   // 防止客户端请求过大
                pageSize = 100
            }
        }
    }
    return
}

这个函数有几个设计细节值得注意:

  • 默认值策略:page 默认 1,pageSize 默认 20——即使客户端不传分页参数,也能正常工作
  • 上界限制:pageSize 不超过 100,防止一次查询拖垮数据库
  • 负数保护n > 0 检查,防止恶意传入负数 offset

②③④ 展示了一个经典的"先 count 后查"分页模式。有读者可能问:为什么不直接查就行,还要单独 count?答案很简单——returnPaginated 需要 total 来计算 total_pages,前端需要知道"一共有多少页"才能渲染分页组件。你可以用 SQL_CALC_FOUND_ROWS 一次查出,但 MySQL 8.0 已弃用该语法,所以"先 count 后查"是更通用的做法。

最终返回的 JSON 结构:

{
    "code": 0,
    "data": [...],
    "pagination": {
        "total": 50,
        "page": 1,
        "page_size": 20,
        "total_pages": 3
    }
}

4.3 CodeError:结构化错误的秘密武器

pkg/util/net.go:18-33 中定义了一个非常重要的类型:

type CodeError struct {
    Code int
    Msg  string
}

func (e *CodeError) Error() string {
    return e.Msg
}

func NewCodeError(code int, msg string) error {
    return &CodeError{Code: code, Msg: msg}
}

func NewCodeErrorf(code int, format string, a ...interface{}) error {
    return &CodeError{Code: code, Msg: fmt.Sprintf(format, a...)}
}

为什么需要一个"带错误码的 error"?

想象一个场景:DAO 层从数据库查出错误,它返回一个 error。Service 层需要判断:这是"用户不存在"(返回 403)还是"数据库连接断开"(返回 500)?

如果没有 CodeError,代码会变成这样:

if err == gorm.ErrRecordNotFound {
    returnError(ERROR_PERMISSION_DENIED)
} else {
    returnError(ERROR_MYSQL_ERR)
}

每个 Service 方法都要写这样的判断,到处散落着 gorm.ErrRecordNotFound 的引用——DAO 的实现细节泄露到了 Service 层,违反了分层架构原则。

有了 CodeError,DAO 层可以这样:

func (T *UserDao) FindUser(ctx context.Context, id int) (*model.User, error) {
    var user model.User
    err := T.db.WithContext(ctx).Where("id=?", id).First(&user).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, util.NewCodeError(constant.ERROR_PERMISSION_DENIED, "user not found")
    }
    return &user, err
}

DAO 自己把 gorm 错误翻译成业务错误码,Service 层无需关心底层 ORM 的实现细节。

4.4 sanitizeErr:防止内部信息泄露的最后一道防线

service/UserService.go:22-27 中有一个看似简单却极其重要的函数:

func sanitizeErr(err error) error {
    if _, ok := err.(*util.CodeError); ok {
        return err          // 已标记的错误,放心返回
    }
    return util.NewCodeError(constant.ERROR_MYSQL_ERR, "internal error")  // 未标记的错误,统一吞掉
}

这个函数的核心使命是:防止内部错误信息泄露到客户端。

举个例子,MySQL 驱动可能返回这样的错误:

Error 1146: Table 'bobby_test.xxx' doesn't exist

如果直接把这个错误信息返回给客户端,就等于把数据库表名暴露了出去。攻击者可以根据这个信息做 SQL 注入的定向探测。

sanitizeErr 的策略很简单:

  • 如果你是我明确构造的 CodeError(说明我已经审查过这个消息内容了),那就安全返回
  • 否则,不管是什么错误,统一返回 "internal error"

在 Service 层所有数据库调用处都能看到它的身影——service/FriendsServer.go:64,88,94service/UserService.go:123,130,每一次与外部系统(数据库、Redis)的交互错误,都必须经过这道安全过滤。

4.5 Swagger 注解:把响应格式写进文档

响应信封做得好,文档不能少。项目使用 swaggo 自动生成 API 文档,在每个 Handler 上通过注释标注响应格式。

比如 service/UserService.go:29-38GetUser 的 Swagger 注解:

// @Summary      Get user by ID
// @Description  Returns user information for the given user ID
// @Tags         Users
// @Produce      json
// @Param        uid path int true "User ID"
// @Success      200  {object}  util.SuccMsg{data=model.User}
// @Failure      400  {object}  util.ErrMsg
// @Failure      403  {object}  util.ErrMsg
// @Router       /user/{uid} [get]
// @Security     Bearer

这里引用了我们在 pkg/util/net.go 中定义的 SuccMsgErrMsg 结构体作为 Swagger 的响应模型——Swagger 文档里的响应格式和我们代码里的响应信封结构完全一致,文档即代码,代码即文档。

再看 service/FriendsServer.go:244-254AddFriend 的注解:

// @Summary      Add a friend
// @Description  Establish a bidirectional friendship between two users
// @Accept       json
// @Produce      json
// @Param        friendship body addFriendReq true "Friend request"
// @Success      200  {object}  util.SuccMsg
// @Failure      400  {object}  util.ErrMsg
// @Failure      403  {object}  util.ErrMsg
// @Router       /friends [post]
// @Security     Bearer

配合项目根部的 Swagger 主注释(cmd/main.go:1-18),运行 swag init 就能自动生成 docs/ 目录下的 OpenAPI 规范文件。

4.6 响应 DTO:绝不把数据库模型直接返回给前端

再看 model/Friends.go:15-28,项目为好友列表定义了两个专门的响应 DTO:

type RetListFriends struct {
    FriUid     int           `json:"fri_uid"`
    FriName    string        `json:"fri_name"`
    CreateTime util.JsonTime `json:"create_at"`
}

type RetNearbyFriendsList struct {
    FriUid     int           `json:"fri_uid"`
    FriName    string        `json:"fri_name"`
    CreateTime util.JsonTime `json:"create_at"`
    Latitude   float64       `json:"latitude"`
    Longitude  float64       `json:"longitude"`
    LocGeohash string        `json:"loc_geohash"`
}

注意:

  • 好友列表查询只需要返回对方的基本信息,不包含密码、邮箱等敏感字段——用 DTO 天然隔离
  • 附近好友列表比普通好友列表多返回经纬度和 geohash——用 RetNearbyFriendsList 精确控制字段

这就是 DTO(Data Transfer Object)的价值:数据库模型负责持久化,DTO 负责传输。两者各司其职,互不污染。你修改了数据库字段,不需要改接口;你要改接口返回字段,不涉及数据库迁移。


五、总结

回顾整个进化过程,一个看似简单的 JSON 响应,背后藏着一整套工程决策:

层次做的事核心文件
结构体定义SuccMsgErrMsg,统一 code/data/msg 字段pkg/util/net.go:35-43
业务错误码10 个常量,负数表示失败,语义清晰constant/ErrorCode.go:3-13
HTTP 映射业务错误码到 HTTP 状态码的 switch 映射service/ResponseHandler.go:70-85
响应辅助四大函数:returnSuccess / returnError / returnSuccessfulf / returnPaginatedservice/ResponseHandler.go:87-123
安全过滤sanitizeErr 防止内部错误信息外泄service/UserService.go:22-27
结构化错误CodeError 带错误码的 error,实现分层隔离pkg/util/net.go:18-33
分页契约parsePagination + returnPaginated,统一分页元数据service/FriendsServer.go:11-29service/ResponseHandler.go:108-123
文档生成Swagger 注解自动生成 OpenAPI 文档service/UserService.go:29-38
数据隔离DTO 与数据库模型分离model/Friends.go:15-28

核心心法只有一句话:让代码库中的每一个 API 响应都遵循相同的形状(shape),让客户端用一套逻辑解析所有接口,让运维同学在日志里用一条 grep 命令定位所有错误。

当你的响应信封设计到位后,下面这些事变得轻而易举:

  • 在网关层根据 code 字段做统一告警
  • 在客户端封装一个通用的 isSuccess(response) 判断
  • 在日志系统里用 code!=0 过滤所有错误请求
  • 在 Swagger 文档里声明一次 SuccMsg / ErrMsg,所有接口自动继承

凌晨三点的告警还会再来,但下一次,我们 10 秒钟就能定位到问题。


完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:
① 从面条代码到三层架构   ② API 安全洋葱模型   ③ 配置管理与密钥保护   ④ 单元测试   ⑤ 可观测性
⑥ 部署进化   ⑦ 好友请求状态机   ⑧ Redis 实战   ⑨ 中间件链   ⑩ Geohash
⑪ API 响应设计   ⑫ 优雅关闭   ⑬ GORM 避坑   ⑭ Makefile

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值