CClara轻量论坛系统

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

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-08-14 20:24 ·114 浏览 ·0 回复
内容摘要

Go 结构体 JSON 序列化要留意导出字段才参与、omitempty 会吞掉零值和 false,应借助 json tag、指针和 ,string 区分未提供与零值并输出数字字符串,同时用 Encoder 关闭 HTML 转义、自行处理 time.Time 的 RFC3339 默认格式。

在社区里经常看到有人发帖:“我明明给结构体字段赋值了,序列化出来怎么不见了?”或者“这时间字段怎么变成字符串了?”这类问题十有八九都出在 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 掉。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-176.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~