Go 结构体 JSON 序列化的坑与 tag 巧用

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-09 18:49 ·0 浏览 ·0 回复

在社区里经常看到有人发帖:“我明明给结构体字段赋值了,序列化出来怎么不见了?”或者“这时间字段怎么变成字符串了?”这类问题十有八九都出在 Go 结构体 JSON 序列化的细节上。Go 的 `encoding/json` 是出了名的“约定大于配置”,你要是没搞懂它的脾气,光是字段大小写这一条就能让你折腾半天。

先说最大的一个坑:字段名大小写直接决定序列化结果。

Go 语言里,结构体字段只有首字母大写(即导出字段)才会被 `json.Marshal` 捕获。你辛辛苦苦写了个 `type User struct { name string }`,序列化出来只会是一个空对象 `{}`。这还不是最坑的,最坑的是当你用 `json.Unmarshal` 去反序列化时,它同样忽略小写字段,你收到的数据全部静默丢失,不会报错,程序继续跑,结果全错。排查这种问题比看报错日志难十倍。

第二大坑是零值字段被莫名吞掉。 你以为你定义了 `Age int \`json:"age"\``,序列化出来必然有 `age` 字段?天真了。如果 `Age` 是 0,`json.Marshal` 默认还是会输出的,但你要是手贱加了 `omitempty`,那 0 就消失了。有人就会问了:“用户没填年龄,我要么传 `null` 要么不传,这有什么问题?”问题大了——如果下游是 PHP 或老系统,它用 `isset($age)` 判断,结果字段不存在,直接走默认分支,数据对不上。更隐蔽的是 `bool` 的 `false` 被吞,很多新人在用 `done bool \`json:"done,omitempty"\`` 时吃了大亏,明明事件已处理完,字段却不见了。

tag 能解决这些问题,但也得会用。

`json:"-"` 就不用多说了,忽略字段。但真正巧妙的是 `json:",omitempty"` 配合指针类型。很多资深 Gopher 在区分“字段未提供”和“字段提供但为零值”时,故意把字段声明成 `*int` 或 `*string`。如果 JSON 里没这个 key,指针就是 `nil`,序列化后就不输出,如果客户端传了 `0` 或空字符串,指针指向对应变量,就能如实传输。这是社区公认的“有状态”序列化手法。

再比如 `json:",string"` 这个 tag 选项,真乃神器。有些老接口规定所有数字都得是字符串格式,比如订单金额、用户 ID。在结构体里写 `ID uint64 \`json:"id,string"\``,序列化输出就是 `"id": "123456"`,反序列化时也能把 `"123456"` 转回 `uint64`。但注意,这招只对基本数值类型有效,对自定义类型就失灵了,这时候你得实现 `Marshaler` 接口。

然后得提两个非常憋屈的坑,社区里吐槽过无数回。

第一个是 HTML 字符转义。默认 `json.Marshal` 会把 `<`、`>`、`&` 转义成 `\u003c` 之类。你存了一份包含 HTML 片段的数据,序列化完了发现全被转成 Unicode 了。开眼界吧?解决办法是 `json.Encoder` 配合 `SetEscapeHTML(false)`,而不是改 `Marshal` 本身。

第二个坑是 时间字段默认格式。`time.Time` 实现的是 `MarshalJSON`,默认输出 RFC3339 格式,像 `2024-06-01T15:04:05Z`。但 MySQL 驱动、前端日历控件通常需要 `2006-01-02 15:04:05`。你不能直接改 tag,要自己定义时间类型,在 `MarshalJSON` 你重写格式,或者用 `json.RawMessage` 先解包再处理。

再说说嵌套结构体和 map 的序列化混乱场景。有人图省事,接口直接返回 `map[string]interface{}`,这本身没错,但 map 的 key 无论你定义成 `string` 还是别的接口类型,JSON 序列化后 key 永远是字符串。更麻烦的是 `map[string]interface{}` 套 `interface{}` 后,解码时数字变成 `float64`。你前端拿个大整数 ID 过来,比如 `18350948234233233`,你反序列化到 `interface{}` 里,`float64` 直接丢精度。你从接口下一层一层取数据时已经晚了。这种场景最好在结构体里定义清楚类型,手动转成 `json.Number` 再用。

最后一个 tag 巧用,是字段名前缀加 `omitempty` 的反向应用:字段融合。 如果你希望同一个结构体根据环境序列化成不同字段名,什么技巧都不如把 tag 写成多个名字,比如 `Auth \`json:"auth,omitempty"\`` 你要是嫌弃这个名字,可以用 `json:"token"` 输出到客户端,内部字段叫 `Token`,各端解耦。

说到底,tag 的功底考验的是你对数据契约的理解。在写结构体之前,先想一想:这个字段是必填还是可选?默认零值有业务含义吗?下游是强类型还是弱类型?如果这些没想清楚,再花哨的 tag 也只是给自己挖坑。

我建议的实践是:团队内定一套 JSON 字段规范,蛇形还是驼峰必须统一,用 `snake_case` 配 `json` tag 是主流。然后所有 DTO 与内层模型分离,DTO 专门控制输出格式,不要直接拿数据库模型上 API,你无法保证字段的序列化语义不被其他调用方不小心 break 掉。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-176.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~