Go JSON 与序列化

encoding/json 如何用导出字段与 struct tag 映射内外模型;流式编解码、未知字段、空值与 DTO 边界是 API 契约的核心。

#type / concept #status / growing #tech / dev #tech / dev / backend #resource / go

[!info] 关联笔记

Go JSON 与序列化

这个概念为什么会出现

程序内部是 Go 类型与内存布局;进程边界外是 JSON、Protobuf、消息队列载荷、配置文件。必须稳定地回答:

  • 哪些字段对外可见
  • 名字如何映射(userId vs user_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}

服务端要:

  1. 解到 UserDTO(只收契约内字段)
  2. 默认忽略未知字段 extra(兼容老客户端乱加字段)
  3. 需要严格契约时再开 DisallowUnknownFields
  4. 编码回响应时用缩进方便调试;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
}

结合场景再看四个关注点

  1. 只有导出字段参与默认编解码
    小写字段再贴 tag 也不会被 encoding/json 导出。

  2. omitempty 管编码
    空邮箱不出现在响应里,避免噪声字段。

  3. 默认忽略未知字段
    兼容演进;对外严格 API 再 DisallowUnknownFields

  4. 流式 Decoder/Encoder 更适合 HTTP Body
    大 body 不必先 ReadAllUnmarshal

核心概念与准确模型

字段可见性与 tag

type Order struct {
	ID     string `json:"id"`
	amount int    // 未导出:忽略
	Note   string `json:"note,omitempty"`
	Skip   string `json:"-"`          // 显式忽略
	Raw    string `json:",omitempty"` // 名字仍为 Raw
}

常用 tag 选项:

选项含义
nameJSON 字段名
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.RawMessageany

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、对外命名
持久化 modelDB 列、sql 扫描、不一定等于 JSON

直接 json 输出 ORM 实体会导致:泄漏内部字段、tag 冲突、契约与表结构耦合。

设计动机

  1. 约定大于框架
    反射 + tag 足够覆盖 90% API,无需代码生成也能起步。
  2. 导出字段即边界提示
    与 Go 可见性体系一致:小写字段默认内部。
  3. 流式 API 与 io 一致
    Decoder/Encoder 对齐网络 Body 与文件。
  4. 显式契约演进
    DisallowUnknownFields、DTO 版本、自定义类型让兼容策略可讨论而非“碰巧能跑”。

边界情况与反直觉行为

1. omitempty 不区分“没有”和“零”

0false""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 tagtag 是通用元数据机制;json 只是消费者之一
Protobuf/msgpack更强 schema/性能场景;JSON 偏人机可读与 Web
校验解码成功 ≠ 业务合法;要字段级校验
反射json 包内部大量用反射;热路径需测分配
encoding/xml / yaml 第三方另一套 tag 与规则,勿混为一谈

工程实践

  1. DTO 与领域分离;API 版本变更改 DTO 映射,不直接拧实体。
  2. 统一时间与枚举格式;自定义类型集中放。
  3. 输入严格、输出稳定:入站可 DisallowUnknownFields(视兼容策略);出站字段集合可预测。
  4. 错误分类:JSON 语法错误 → 400;校验失败 → 400/422;映射内部错误勿原样回传。
  5. 性能:热路径复用 json.Encoder 缓冲策略、避免反复 Marshal 到 string 再写;必要时评测 encoding/json vs 兼容替代库(替换要评估行为差异)。
  6. 测试:表驱动覆盖 null/缺失/多余字段/溢出/时间格式;用 golden JSON 锁契约。
  7. 日志脱敏:序列化日志对象前去掉 token/PII。
  8. 与 HTTPContent-Type: application/json;注意 Encode 自动换行对某些签名场景的影响。

可验证实验

实验 1:导出与 tag

给未导出字段、json:"-"omitempty 各一例,打印 Marshal 结果。

实验 2:未知字段

同一段含 extra 的 JSON,对比默认 DecodeDisallowUnknownFields

实验 3:指针 PATCH

{"name":null}{}{"name":"x"} 三种输入解码到 *string 字段,观察值。

实验 4:nil vs empty slice

编码 var s []ints := make([]int, 0),看 null vs []

实验 5:大整数进 any

json.Unmarshal([]byte({“n”:9007199254740993}), &map[string]any{}) 观察精度。

本节总结

  • 本质:导出字段 + tag 驱动的约定式映射,不是自动 ORM。
  • 关键设计:DTO 边界、缺失/零值、未知字段、精度。
  • 工程关键:限流 Body、校验、稳定输出、测试钉契约。
  • 下一步校验net/http 组装输入输出;数据层勿与 JSON 模型强绑。

自测题

概念题

  1. 为什么小写字段默认不会出现在 JSON 里?
  2. omitemptyfalse0 会怎样?
  3. 何时该用 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 里应如何表示?为什么?

参考答案

展开
  1. 反射只能稳定操作导出标识符;也符合可见性即封装边界。
  2. 二者都是零值,编码时会被省略。
  3. 需要先读 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 与严格解码)
创建于 2026/6/20 更新于 2026/7/15