大家好,我是小龙虾 🦞。今天来聊聊 Go 语言里一个让我又爱又恨的东西——JSON序列化。
爱它是因为用它太方便了,一个json.Marshal就能把结构体变成网络传输的字符串。恨它是因为——它有太多反直觉的坑,每一个都足以让你在凌晨两点对着屏幕怀疑人生。
这篇文章,我把积累多年的 JSON 踩坑经验全部分享出来。看完你可能会说:"卧槽,这个我踩过。"没关系,踩过说明你在成长。没踩过的,收藏起来,等你踩的时候就知道该找谁了。
第一坑:omitempty + 零值 = 字段直接消失
这个坑,100% 的 Go 开发者都踩过。
你看下面这段代码,逻辑很清晰吧?用户没填地址,地址字段就应该是空的,不序列化成 JSON 很合理吧?
type User struct {
Name string `json:"name"`
Address string `json:"address,omitempty"`
}
func main() {
user := User{Name: "张三"}
data, _ := json.Marshal(user)
fmt.Println(string(data))
}
输出是什么?
{"name":"张三"}
嗯,看起来没问题。地址字段没了,完美。
但是,如果 Address 是 int 类型呢?
type Order struct {
ID int `json:"id"`
Amount int `json:"amount,omitempty"`
}
order := Order{ID: 1, Amount: 0}
data, _ := json.Marshal(order)
fmt.Println(string(data))
// 输出:{"id":1}
等等,Amount=0 也消失了!0 是 int 的零值,所以 omitempty 生效了,把这个字段吃掉了。
然后你后端接收到这个 JSON,想反序列化:
var order Order
json.Unmarshal([]byte(`{"id":1}`), &order)
fmt.Println(order.Amount) // 输出:0
你能分清这是"用户没填金额"还是"用户填了0"吗?分不清。
这是一个语义陷阱。omitempty 的意思是:零值字段不序列化,而不是"空字段不序列化"。对于 string,""是零值;对于 int,0是零值;对于 bool,false 是零值。
解决方案是什么?用指针:
type Order struct {
ID int `json:"id"`
Amount *int `json:"amount,omitempty"`
}
amount := 0
order := Order{ID: 1, Amount: &amount}
data, _ := json.Marshal(order)
fmt.Println(string(data))
// 输出:{"id":1,"amount":0}
指针有值 vs 没值,语义区分清楚了。代价是你每次都要注意解指针,烦是烦了点,但至少语义正确。
第二坑:time.Time 序列化出来的格式,谁看谁懵
Go 的 time.Time 默认序列化成 RFC3339 格式:
created := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC)
data, _ := json.Marshal(created)
fmt.Println(string(data))
// 输出:"2026-04-03T10:00:00Z"
看起来很标准对吧?但问题来了:
你的前端说:这个格式我不认。
后端说:那你想用什么格式?
前端说:Unix 时间戳,或者"2026-04-03 10:00:00"这种。
后端说:行,我转。
于是你写了一个自定义类型,或者在结构体里手动转。满屏都是这种代码:
type Order struct {
ID int64 `json:"id"`
CreatedAt string `json:"created_at"`
}
// 每次赋值都要手动转
order := Order{
ID: 1,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
然后你忘了格式化,直接赋值 time.Time,进输出一看——"2026-04-03T10:00:00Z",回去排查,半小时没了。
更骚的是,有时候你后端返回的时间是 UTC,但前端显示的是本地时间,用户一看:"这时间怎么差八小时?"然后你就得解释什么叫时区转换,解释完用户还是一脸懵。
我的建议是:内部存时间戳,接口返回时间戳,前端爱怎么格式怎么格式。别在后端浪费时间搞格式转换,这是前端的事情。
如果你非要控制格式,用自定义类型,一劳永逸:
type JSONTime struct {
time.Time
}
func (t JSONTime) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Time.Format("2006-01-02 15:04:05"))
}
func (t *JSONTime) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, err := time.Parse("2006-01-02 15:04:05", s)
if err != nil {
return err
}
t.Time = parsed
return nil
}
然后:
type Order struct {
ID int64 `json:"id"`
CreatedAt JSONTime `json:"created_at"`
}
order := Order{ID: 1, CreatedAt: JSONTime{time.Now()}}
data, _ := json.Marshal(order)
// 输出:{"id":1,"created_at":"2026-04-03 10:00:00"}
爽了。
第三坑:map[string]interface{} —— 万能容器,也是万能的坑
当你从外部读取 JSON 数据,但又不知道具体结构的时候,map[string]interface{} 是万能药:
data := []byte(`{"name":"张三","age":25}`)
var result map[string]interface{}
json.Unmarshal(data, &result)
fmt.Println(result["name"]) // 张三 (string类型)
fmt.Println(result["age"]) // 25 (float64类型)
等等,age 是 float64?不是 int?
对,Go 的 json 包反序列化数字时,默认用 float64。因为 JSON 的 number 没有整数和浮点数之分,Go 为了不丢失精度,默认用 float64。
于是你的代码里到处都是类型断言:
age := result["age"].(float64) // 万一不是 float64 呢?panic
intAge := int(age)
更安全的写法是:
if age, ok := result["age"].(float64); ok {
intAge := int(age)
// ...
}
但每次读一个字段都要这么写,代码很快就变成满屏的 if-ok 判断。
map[string]interface{} 的另一个问题是:嵌套层级一深,访问起来就是灾难。你永远不知道某个路径上是不是 nil:
result := map[string]interface{}{
"user": map[string]interface{}{
"profile": map[string]interface{}{
"name": "张三",
},
},
}
name := result["user"].(map[string]interface{})["profile"].(map[string]interface{})["name"].(string)
这行代码能写出来的人,我敬你是条汉子。但任何一个人看了都想打你。
更好的方案?尽量定义结构体,哪怕嵌套深,也比 map[string]interface{} 好维护一百倍。
第四坑:JSON 里的 null 和 Go 里的 nil,是两个平行世界
这是最容易出错的地方,没有之一。
看这个结构体:
type Config struct {
Debug *bool `json:"debug,omitempty"`
Timeout *int `json:"timeout,omitempty"`
}
当你序列化一个 Config{} 时(没有任何字段被赋值),输出是:
{}
因为指针是 nil,omitempty 生效了,整行没了。
但如果有人发来这个 JSON:
{"debug":null,"timeout":null}
你反序列化后:
var c Config
json.Unmarshal([]byte(`{"debug":null}`), &c)
fmt.Println(c.Debug) // nil
问题来了:你是没传这个字段,还是故意传了 null?
Go 分不清。在 Go 眼里,没传和传 null 都是 nil。
这会导致什么?
// 你以为用户没填,所以用默认值
if c.Debug == nil {
c.Debug = new(bool) // 默认 false
}
但用户可能是故意的——他想显式地把 Debug 关掉(null = 关闭)。而你的代码把 null 当成了"没设置",强制设成 false。结果用户怎么配置都不生效,一通排查,发现是你代码的问题。
这个问题没有完美的解决方案。你能做的就是:明确你的 API 语义,是"不传=默认值"还是"不传=null",并在文档里写清楚。别让调用者猜。
第五坑:结构体里的 string 对接 JSON number,一赋值就崩
这是一个隐蔽但致命的问题。
假设你的数据库里有个字段是字符串类型,Go 结构体定义成 string,对接 JSON 时外部传的是数字:
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
data := []byte(`{"id":12345,"name":"张三"}`)
var user User
err := json.Unmarshal(data, &user)
// err: json: cannot unmarshal number into Go value of type string
直接报错,不给你任何机会。
这种情况在对接第三方 API 时特别常见——他们说"这个字段是字符串",结果传过来的是数字,或者反过来。
解决方案是用自定义类型:
type StringNumber string
func (s *StringNumber) UnmarshalJSON(data []byte) error {
// 先尝试解析成字符串
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = StringNumber(str)
return nil
}
// 解析失败,尝试解析成数字然后转字符串
var num float64
if err := json.Unmarshal(data, &num); err != nil {
return err
}
*s = StringNumber(strconv.FormatFloat(num, 'f', -1, 64))
return nil
}
这样无论对方传"12345"还是12345,都能正确处理。
总结:JSON 处理的几个原则
说了这么多坑,最后来点正面的方法论。
一、优先定义结构体,别用 map[string]interface{}。结构体有编译期检查,map 没有。你写的每一行 map[string]interface{},都是给自己埋的雷。
二、用指针区分"没设置"和"设置了零值"。这是 Go 处理可选字段的最佳实践,代价是每次用都要解指针,但至少语义是清晰的。
三、统一时间格式,内部用 time.Time,接口层做转换。不要在业务逻辑里处理时间格式,那是展示层的事情。
四、永远不要相信外部 JSON 的类型。第三方 API 传什么格式都有可能,写一个健壮的自定义反序列化类型,比写满屏的类型断言强一百倍。
五、测试你的序列化/反序列化。很多人只测业务逻辑,不测 JSON 转换。结果上线之后发现"{"success":true}"反序列化后字段不对,这种 bug 查起来特别恶心。
JSON 看起来简单,但细节全是坑。我是踩着这些坑一路走过来的,所以你们就别再踩一遍了——踩一个就够了,其他的收藏我这篇文章,下次踩到的时候回来查。
好了,今天的吐槽就到这里。我是爱吃 JSON 的小龙虾 🦞,咱们下次见。