Go JSON 与序列化
encoding/json 如何用导出字段与 struct tag 映射内外模型;流式编解码、未知字段、空值与 DTO 边界是 API 契约的核心。
[!info] 关联笔记
- 所属 MOC:Web 与后端 · 数据访问 · 标准库 · 学习路线
- 前置概念:struct 与方法、struct tag、基本类型
- 并列概念:net/http、校验、database/sql
- 容易混淆:内部领域模型 vs 外部 DTO、
omitempty与“字段缺失”
Go JSON 与序列化
这个概念为什么会出现
程序内部是 Go 类型与内存布局;进程边界外是 JSON、Protobuf、消息队列载荷、配置文件。必须稳定地回答:
- 哪些字段对外可见
- 名字如何映射(
userIdvsuser_id) - 空值、零值、字段缺失如何区分
- 未知字段是忽略还是拒绝
- 大载荷是一次性进内存还是流式处理
在 Go 里,encoding/json 是默认答案。它看起来“加个 tag 就行”,工程事故却常出在模型边界:把 DB 实体直接吐给前端、用 map[string]any 失去类型、或对钱/大整数用 float64。
[!abstract] 一句话理解 JSON 编解码默认只处理导出字段,用 tag 控制名字与 omit 行为;真正的设计点是内部模型与外部契约分离,并显式决定未知字段、精度与缺失/零值语义。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:注册接口解 JSON body,再回显用户 DTO
客户端 POST:
{"id":"u1","name":"Ada","age":36,"extra":true}
服务端要:
- 解到
UserDTO(只收契约内字段) - 默认忽略未知字段
extra(兼容老客户端乱加字段) - 需要严格契约时再开
DisallowUnknownFields - 编码回响应时用缩进方便调试;
email为空则omitempty不输出
生产里 Decoder 更常直接对着 r.Body,这里用 strings.Reader 模拟 body。
package main
import (
"encoding/json"
"fmt"
"log"
"strings"
)
// UserDTO:API 边界对象;字段名由 json tag 决定。
type UserDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Age int `json:"age"`
}
func main() {
// 模拟 HTTP body:多了一个契约外字段 extra
in := `{"id":"u1","name":"Ada","age":36,"extra":true}`
dec := json.NewDecoder(strings.NewReader(in))
// 严格 API 可打开下一行:遇到 extra 直接 Decode 失败
// dec.DisallowUnknownFields()
var u UserDTO
if err := dec.Decode(&u); err != nil {
log.Fatal(err)
}
// Email 未出现 → 零值 "";omitempty 编码时会省略
fmt.Printf("decoded: %+v\n", u)
out, err := json.MarshalIndent(u, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(out))
}
建议运行:
go run .
期望输出类似:
decoded: {ID:u1 Name:Ada Email: Age:36}
{
"id": "u1",
"name": "Ada",
"age": 36
}
结合场景再看四个关注点
-
只有导出字段参与默认编解码
小写字段再贴 tag 也不会被encoding/json导出。 -
omitempty管编码
空邮箱不出现在响应里,避免噪声字段。 -
默认忽略未知字段
兼容演进;对外严格 API 再DisallowUnknownFields。 -
流式
Decoder/Encoder更适合 HTTP Body
大 body 不必先ReadAll再Unmarshal。
核心概念与准确模型
字段可见性与 tag
type Order struct {
ID string `json:"id"`
amount int // 未导出:忽略
Note string `json:"note,omitempty"`
Skip string `json:"-"` // 显式忽略
Raw string `json:",omitempty"` // 名字仍为 Raw
}
常用 tag 选项:
| 选项 | 含义 |
|---|---|
name | JSON 字段名 |
omitempty | 空值时编码省略(空定义依类型:0、""、nil、空容器等) |
- | 永远忽略 |
string | 把数字等以 JSON 字符串读写(需谨慎) |
参考:encoding/json。
值与指针:缺失 vs 零值
type Patch struct {
Name *string `json:"name"`
}
- 字段是值类型:JSON 缺失 → Go 零值;无法区分“没传”和“传了 0/
""”。 - 字段是指针或自定义 Optional:缺失 →
nil;显式null与显式值可再区分。 - PATCH/部分更新 API 几乎总需要指针或等价模型。
常用 API
b, err := json.Marshal(v)
err = json.Unmarshal(b, &v)
err = json.NewEncoder(w).Encode(v) // 末尾带换行
err = json.NewDecoder(r).Decode(&v) // 流式读一个值
HTTP Handler 惯用:
func createUser(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req UserDTO
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// 可选:确保 Body 无拖挂垃圾数据
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(req)
}
任意 JSON:json.RawMessage 与 any
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"` // 延迟解析
}
json.RawMessage:延迟反序列化子树,做多态事件很有用。map[string]any/any:灵活但把类型检查推迟到运行时,边界层慎用。
自定义编解码
实现:
MarshalJSON() ([]byte, error)
UnmarshalJSON([]byte) error
适用:时间格式、金额、枚举字符串、兼容历史脏数据。实现必须处理 null、保持对称,并写表驱动测试。
时间、数字与精度
- 默认
time.Time↔ RFC3339 字符串。 - JSON 数字进
any时是float64,大整数可能丢精度;对外 ID 常用 string。 - 金额不要用
float64;用整数最小单位或string+ 小数库。
内部模型 vs DTO
| 层 | 职责 |
|---|---|
| 领域/实体 | 业务不变量、行为、持久化关注点 |
| DTO / API model | 版本化契约、json tag、对外命名 |
| 持久化 model | DB 列、sql 扫描、不一定等于 JSON |
直接 json 输出 ORM 实体会导致:泄漏内部字段、tag 冲突、契约与表结构耦合。
设计动机
- 约定大于框架
反射 + tag 足够覆盖 90% API,无需代码生成也能起步。 - 导出字段即边界提示
与 Go 可见性体系一致:小写字段默认内部。 - 流式 API 与
io一致
Decoder/Encoder 对齐网络 Body 与文件。 - 显式契约演进
DisallowUnknownFields、DTO 版本、自定义类型让兼容策略可讨论而非“碰巧能跑”。
边界情况与反直觉行为
1. omitempty 不区分“没有”和“零”
0、false、""、nil slice 都可能被省略;前端若依赖字段存在性会踩坑。
2. nil slice vs 空 slice
var a []int // 编码为 null
b := []int{} // 编码为 []
API 响应是否允许 null 数组要统一约定。
3. map 的键类型与无序
编码 map 时键会排序以保持确定性(实现细节以文档为准);解码到 map 后迭代顺序仍不应依赖。
4. 内嵌 struct 的提升
匿名字段导出字段会提升到外层 JSON(除非 tag 干预),容易意外暴露或名字冲突。
5. Decode 可多次调用
从流中连续读多个 JSON 值;HTTP 单对象 API 通常只应成功一次,可用“再 Decode 应 EOF”做校验。
6. 接口字段解码
解码到接口字段需要具体类型信息,否则可能得到 map[string]any。多态需 type 字段 + 二次解析。
常见误区
[!warning] 常见误区:领域实体直接当 JSON 契约 错误:DB 密码哈希字段因导出而泄漏,或改列名逼 API 大版本。
正确:边界 DTO + 显式映射。
[!warning] 常见误区:用 float64 表示钱或雪花 ID 错误:精度与 JS 安全整数问题。
正确:整数分/小数 string;ID 用 string。
[!warning] 常见误区:认为 Unmarshal 失败就“什么都没改” 错误:部分字段可能已写入目标 struct。
正确:先解码到空值变量,成功后再替换;错误路径勿用半成品。
[!warning] 常见误区:无限制解码用户 Body 错误:巨型 JSON 撑爆内存。
正确:MaxBytesReader+ 超时 + 合理结构深度。
[!warning] 常见误区:到处
map[string]interface{}错误:重构失明、字段拼写错误运行时才爆。
正确:边界用类型;动态部分局部化并用测试钉死。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| struct tag | tag 是通用元数据机制;json 只是消费者之一 |
| Protobuf/msgpack | 更强 schema/性能场景;JSON 偏人机可读与 Web |
| 校验 | 解码成功 ≠ 业务合法;要字段级校验 |
| 反射 | json 包内部大量用反射;热路径需测分配 |
encoding/xml / yaml 第三方 | 另一套 tag 与规则,勿混为一谈 |
工程实践
- DTO 与领域分离;API 版本变更改 DTO 映射,不直接拧实体。
- 统一时间与枚举格式;自定义类型集中放。
- 输入严格、输出稳定:入站可
DisallowUnknownFields(视兼容策略);出站字段集合可预测。 - 错误分类:JSON 语法错误 → 400;校验失败 → 400/422;映射内部错误勿原样回传。
- 性能:热路径复用
json.Encoder缓冲策略、避免反复Marshal到 string 再写;必要时评测encoding/jsonvs 兼容替代库(替换要评估行为差异)。 - 测试:表驱动覆盖 null/缺失/多余字段/溢出/时间格式;用 golden JSON 锁契约。
- 日志脱敏:序列化日志对象前去掉 token/PII。
- 与 HTTP:
Content-Type: application/json;注意Encode自动换行对某些签名场景的影响。
可验证实验
实验 1:导出与 tag
给未导出字段、json:"-"、omitempty 各一例,打印 Marshal 结果。
实验 2:未知字段
同一段含 extra 的 JSON,对比默认 Decode 与 DisallowUnknownFields。
实验 3:指针 PATCH
{"name":null}、{}、{"name":"x"} 三种输入解码到 *string 字段,观察值。
实验 4:nil vs empty slice
编码 var s []int 与 s := make([]int, 0),看 null vs []。
实验 5:大整数进 any
json.Unmarshal([]byte({“n”:9007199254740993}), &map[string]any{}) 观察精度。
本节总结
- 本质:导出字段 + tag 驱动的约定式映射,不是自动 ORM。
- 关键设计:DTO 边界、缺失/零值、未知字段、精度。
- 工程关键:限流 Body、校验、稳定输出、测试钉契约。
- 下一步:校验 与 net/http 组装输入输出;数据层勿与 JSON 模型强绑。
自测题
概念题
- 为什么小写字段默认不会出现在 JSON 里?
omitempty对false和0会怎样?- 何时该用
json.RawMessage?
代码推理题
type T struct {
N *int `json:"n"`
}
var t T
_ = json.Unmarshal([]byte(`{}`), &t)
t.N 是什么?若输入是 {"n":null} 呢?
工程思考题
公开 API 要同时服务浏览器 JS 与移动端,订单 ID 是 uint64 雪花。JSON 里应如何表示?为什么?
参考答案
展开
- 反射只能稳定操作导出标识符;也符合可见性即封装边界。
- 二者都是零值,编码时会被省略。
- 需要先读 type/discriminator 再按类型二次解析,或透传未解释子树时。
代码题:{}→N == nil;{"n":null}→ 通常也是N == nil(null 解到指针为 nil)。若要区分“缺省”和“显式 null”,需要更复杂的自定义类型。
工程题:用 string 传递,避免 JS number 与 float64 精度问题,并保持跨语言一致性。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package encoding/json | 标准库文档 | Marshal/Decoder/tag |
| JSON and Go | 官方博客 | 设计导读 |
| Go Blog: Custom JSON Marshalling | 官方博客 | 自定义策略(见文中示例) |
| encoding/json package comment | 文档 | 空值与类型规则 |
笔记元信息
- 建议文件名:
go-json-and-serialization.md - 所属阶段:Web/数据边界
- 学习顺序:接 net/http 后的契约篇
- 建议下一篇:Go 校验 或 struct tag
- 本篇状态:已深化(结构完整;强调 DTO 与严格解码)