本文是《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
}
}
不变的规则只有两条:
code字段永远在最外层,0 表示成功,负数表示失败(数值直接对应业务错误码)- 成功时有
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...))
}
至此,响应辅助函数家族完整了:returnSuccess、returnError、returnSuccessfulf、returnPaginated 四大金刚,覆盖了项目中所有的响应场景。
四、代码实战:响应"信封"在真实接口中的完整演练
光说不练假把式。我们拿几个真实的 API 端点,完整展示这套响应体系是如何工作的。
4.1 查询用户信息
service/UserService.go:39-75 的 GetUser 方法展示了标准的成功/错误响应流程:
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-242 的 GetFriendsList 展示了分页响应的完整链路:
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,94、service/UserService.go:123,130,每一次与外部系统(数据库、Redis)的交互错误,都必须经过这道安全过滤。
4.5 Swagger 注解:把响应格式写进文档
响应信封做得好,文档不能少。项目使用 swaggo 自动生成 API 文档,在每个 Handler 上通过注释标注响应格式。
比如 service/UserService.go:29-38 中 GetUser 的 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 中定义的 SuccMsg 和 ErrMsg 结构体作为 Swagger 的响应模型——Swagger 文档里的响应格式和我们代码里的响应信封结构完全一致,文档即代码,代码即文档。
再看 service/FriendsServer.go:244-254 中 AddFriend 的注解:
// @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 响应,背后藏着一整套工程决策:
| 层次 | 做的事 | 核心文件 |
|---|---|---|
| 结构体定义 | SuccMsg 和 ErrMsg,统一 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 / returnPaginated | service/ResponseHandler.go:87-123 |
| 安全过滤 | sanitizeErr 防止内部错误信息外泄 | service/UserService.go:22-27 |
| 结构化错误 | CodeError 带错误码的 error,实现分层隔离 | pkg/util/net.go:18-33 |
| 分页契约 | parsePagination + returnPaginated,统一分页元数据 | service/FriendsServer.go:11-29,service/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
326

被折叠的 条评论
为什么被折叠?



