学完这篇你能得到:把 Go 结构体稳定地映射成 JSON,并在默认规则不满足时,用标签和自定义序列化精确控制输出格式与解析行为。
第一步:先搞清字段可见性这条硬规则
encoding/json 只能访问导出字段(首字母大写)。没导出的字段会被静默跳过,不报错,这是新手最常见的"字段丢了"。
type User struct {
ID int `json:"id"`
Nickname string `json:"nickname"`
password string // 永远不会出现在 JSON 里
}
注意:小写字段不是"隐藏"技巧,而是"根本管不到"。需要隐藏敏感字段请用标签 json:"-",这样意图明确、可读。
第二步:掌握三个最常用的标签选项
type User struct {
ID int `json:"id,string"` // 数字按字符串输出:"123"
Email string `json:"email,omitempty"` // 零值时整个字段不出现
Password string `json:"-"` // 永不序列化,也不参与解析
}
string:只对 string、整型、布尔、浮点、encoding.TextMarshaler 有效,套在结构体上会直接报错。
omitempty:对 0、""、false、nil 指针/slice/map 生效,对结构体不生效(除非是空结构体这种情况也不省)。这才是"空对象还是被输出"的根因。
-:双向忽略。若只想序列化时不输出、解析时还能读入,写 json:"-" 不行,应改成 json:"password,omitempty" 之类,或者用自定义序列化。
注意:Go 1.24 起新增 omitzero 标签,用于"零值才省略",与 omitempty 的"空值才省略"语义不同,升级后别混用。
补一个常被忽略的:json.Number 和 Decoder.UseNumber()。默认解析到 interface{} 时数字全变 float64,长整型 ID 会丢精度,这时必须开 UseNumber。
第三步:处理嵌套与内嵌结构体
内嵌(匿名字段)会把字段"提升"到外层,这是组合复用的常用手法:
type Base struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Article struct {
Base // 字段被提升,JSON 里与其他字段平级
Title string `json:"title"`
}
想保留一层命名空间,就给它起字段名:Base Base \json:“base”\``。注意内嵌冲突时外层字段优先,被遮蔽的内层字段不会输出。
第四步:自定义序列化——实现两个接口
需要自定义格式(比如只要日期的 time.Time、金额精度、枚举转文本)时,实现这两个方法:
type Timestamp time.Time
func (t Timestamp) MarshalJSON() ([]byte, error) {
s := time.Time(t).Format("2006-01-02 15:04:05")
return []byte(strconv.Quote(s)), nil
}
func (t *Timestamp) UnmarshalJSON(data []byte) error {
s, err := strconv.Unquote(string(data))
if err != nil {
return fmt.Errorf("时间字段需为字符串: %w", err)
}
parsed, err := time.Parse("2006-01-02 15:04:05", s)
if err != nil {
return err
}
*t = Timestamp(parsed)
return nil
}
两个关键点:
- 解析方法必须用指针接收者,否则
Unmarshal 无法把结果写回,字段值静默丢失。
- 序列化方法里不要直接
json.Marshal(self),会无限递归栈溢出。用类型别名绕开:
func (u User) MarshalJSON() ([]byte, error) {
type plain User // 别名没有方法集,不会再次触发 MarshalJSON
return json.Marshal(plain(u))
}
注意:MarshalJSON 返回的字节必须是合法 JSON,encoding/json 会做一次校验和压缩,返回 nil, nil 会得到 unexpected end of JSON input 这种莫名其妙的错误。返回前统一用 json.Marshal 生成,最省心。
还有个容易踩的坑:指针方法的触发依赖值可寻址。[]T 的元素能取地址,指针接收者的 MarshalJSON 会生效;但 map[string]T 的值不可寻址,指针方法不会被调用,输出会是默认格式。遇到这种诡异现象,把 map 值改成 *T。
第五步:上线前做两件事
第一,解析时打开严格模式,让拼错的字段名立刻暴露:
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil { /* 返回 400 */ }
第二,把 nil slice 和空 slice 区分开:nil 序列化成 null,[]string{} 序列化成 []。前后端约定是数组就别返回 null,初始化时用 make([]T, 0)。
小结
- 只有导出字段(大写开头)参与 JSON 编解码,隐藏用
json:"-"。
omitempty 省略空值,string 把数值转成字符串输出,两者别套在结构体上。
- 内嵌结构体字段会被提升到外层,需要层级就显式命名。
- 自定义输出实现
MarshalJSON(值接收者)+ UnmarshalJSON(指针接收者),序列化内部用类型别名避免递归。
- 解析侧开
DisallowUnknownFields,数字精度敏感处用 UseNumber,slice 初始化避免 null。