Marshal 与 Unmarshal
json.Marshal 把 Go 值转换成 JSON 字节。json.Unmarshal 用 JSON 字节填充一个 Go 值。结构体标签决定 JSON 中的字段名。
第一次输出中没有 Email,因为它带有 omitempty,而值是空字符串。Unmarshal 需要一个指针(&back);直接传入值本身会返回 InvalidUnmarshalError。
结构体标签
标签的语法是 json:"name,option,option"。
| 标签 | 效果 |
|---|---|
json:"user_id" | 使用 user_id 作为键名 |
json:"email,omitempty" | 值为 false、0、""、nil 或空切片、空 map 时省略 |
json:",omitzero"(Go 1.24) | 值为零值,或其 IsZero() 返回 true 时省略 |
json:"-" | 永远不编码或解码这个字段 |
json:"-," | 使用字面量 - 作为键名 |
json:"count,string" | 把数字或布尔值编码为 JSON 字符串("42") |
| 无标签 | 键名就是 Go 字段名,如 UserID |
密码永远不会离开结构体,余额带上了引号,deleted_at 消失了,而 created_at 打印出零值时间 0001-01-01T00:00:00Z。这个差别正是新增 omitzero 的原因:omitempty 从来都对结构体无效,在 time.Time 上更是长期让人意外。在 Go 1.23 及更早的版本中,可以用带 omitempty 的 *time.Time 达到同样的效果。
MarshalIndent(v, prefix, indent) 生成易读的输出。它适合用于配置文件和调试;API 通常发送紧凑的 JSON。
只处理导出字段
encoding/json 通过反射工作,只能看到导出的字段。这是 Go 中最常见的 JSON bug:
type point struct {
x, y int // lowercase: json.Marshal(point{1, 2}) gives {}
}
没有错误,也没有警告,编码时只得到 {},解码时字段保持零值。把字段名首字母大写,再为小写键名加上标签。
解码时如何匹配字段
解码到结构体时:
- 键会与标签名或字段名进行不区分大小写的匹配。
{"NAME": "x"}会填充标签为json:"name"的字段。 - 没有对应字段的键会被悄悄忽略。
- 没有对应键的字段会保留原来的值。Unmarshal 不会重置它们,所以解码到一个已有数据的结构体时,结果是合并进去的。
- 类型不匹配(结构体中是
int,JSON 里却是字符串)会返回*json.UnmarshalTypeError,但其他字段仍然会被填充。
如果要拒绝意料之外的键,比如在严格的 API 中,或者在可能有拼写错误的配置文件中,就使用带 DisallowUnknownFields 的 Decoder:
结构未知:map[string]any
如果事先不知道数据的结构,就解码到 map[string]any 或 any 中。对应关系是固定的:
| JSON | Go |
|---|---|
| 对象 | map[string]any |
| 数组 | []any |
| 字符串 | string |
| 数字 | float64 |
| true / false | bool |
| null | nil |
输出中可以看到两个陷阱。所有数字都是 float64,所以 m["stock"].(int) 会失败。而且大于 2^53 的整数存成 float64 时会丢失精度:id 9007199254740993 读回来变成了 ...992。UseNumber 可以同时避免这两个问题。
如果你知道部分结构,就把这部分解码到结构体中,其余部分使用 json.RawMessage。它保存字段的原始字节,等你知道它的类型后再解码。
切片、map、指针与 nil
| Go 值 | JSON |
|---|---|
nil 切片(var s []int) | null |
空切片([]int{}) | [] |
nil map | null |
nil 指针 | null |
[]byte | base64 字符串 |
map[string]T | 对象,键会被排序 |
map[int]T | 对象,整数键转成字符串 |
对于期望得到数组的 API 客户端来说,nil 切片和空切片的区别很重要。当字段必须是 [] 时,用 []T{} 或 make([]T, 0) 初始化。
如果需要在输入中区分“缺失”和“零值”,就使用指针字段(*int、*bool)。解码后,nil 指针表示这个键不存在或为 null;指向 0 的指针表示客户端发送的就是 0。
数据流:Encoder 与 Decoder
json.Marshal 和 Unmarshal 处理的是完整的字节切片。对于 io.Reader 或 io.Writer(HTTP 请求体、文件、标准输入),使用 json.NewDecoder 和 json.NewEncoder。Decoder 还可以逐个读取一连串 JSON 值:
Encoder.Encode 在每个值后面写一个换行符。默认情况下,Marshal 和 Encoder 都会把 \u003c、\u003e 和 \u0026 转义成 \u003c、\u003e 和 \u0026,这样 JSON 可以安全地嵌入 HTML。SetEscapeHTML(false) 可以关闭这一行为。在 HTTP 处理器中,json.NewDecoder(r.Body).Decode(&v) 和 json.NewEncoder(w).Encode(v) 是常用的一对。
自定义编码
一个类型可以通过实现 json.Marshaler 和 json.Unmarshaler 来控制自己的 JSON 形式。常见的例子是一个 iota 枚举,它应该显示为名字,而不是数字:
MarshalJSON 使用值接收者,所以对值和指针都有效;UnmarshalJSON 需要指针接收者,因为它要修改值。如果类型只需要字符串形式,可以改为实现 encoding.TextMarshaler(MarshalText),这样它还能用作 map 的键。
常见错误
- 字段名小写。 它们会被悄悄跳过。
- 把值传给
Unmarshal。 它需要指针。 - 忽略错误。 格式错误的 JSON 和类型不匹配只会通过错误报告出来。
- 以为
map[string]any中的数字是int。 它们是float64。 - 指望
omitempty省略空结构体或零值time.Time。 在 Go 1.24 上使用omitzero,或者使用指针。 - 依赖 map 中的字段顺序。 map 的键在输出时会排序;结构体字段则保持声明时的顺序。
常见问题
Go 语言怎么把结构体转成 JSON?
调用 json.Marshal(v),它返回 []byte 和一个 error。只有导出的字段(名字以大写字母开头)才会被包含进去。在字段上使用 json:"name" 这样的结构体标签,可以控制它的 JSON 键名。要输出带缩进的结果,使用 json.MarshalIndent(v, "", " ")。
为什么我的结构体字段没有出现在 JSON 输出里?
encoding/json 只能看到导出的字段。名为 name(小写)的字段对它来说是不可见的,编码和解码时都是如此。把字段名首字母大写,再用 json:"name" 这样的标签设置 JSON 键名。
Go JSON 中的 omitempty 有什么作用?
当字段保存的是空值时,omitempty 会把它从输出中省略:false、0、""、nil 指针或接口,以及空的切片或 map。它不会把结构体或 time.Time 视为空。Go 1.24 新增了 omitzero,它会省略任何等于其类型零值的值(或者 IsZero() 方法返回 true 的值),包括结构体和 time.Time。
Go 语言怎么解析结构未知的 JSON?
解码到 map[string]any(或 any)中。对象会变成 map[string]any,数组变成 []any,字符串变成 string,布尔值变成 bool,而所有数字都变成 float64。用类型断言读取这些值;如果大整数必须保持精确,就使用 Decoder.UseNumber。