重新认识 Golang 中的 json 编解码

简介: 欢迎访问[莹的网络日志](https://lifukun.com),分享技术探索与思考。本文深入解析Go中json编解码特性,涵盖字段映射、omitempty行为、性能对比、自定义编解码及json/v2新特性,助你真正掌握json使用细节。

欢迎访问我的个人小站 莹的网络日志 ,不定时更新文章和技术博客~

json 是我的老朋友,上份工作开发 web 应用时就作为前后端数据交流的协议,现在也是用 json 数据持久化到数据库。虽然面熟得很但还远远达不到知根知底,而且在边界的探索上越发束手束脚。比如之前想写一个范型的结构提高通用性,但是不清楚对范型的支持如何,思来想去还是用了普通类型;还有项目中的规范不允许使用指针类型的字段存储,我一直抱有疑问。归根结底还是不熟悉 json 编解码的一些特性,导致我不敢尝试也不敢使用,生怕出了问题。所以近些日子也是狠狠研究了一把,补习了很多之前模棱两可的概念。

有一句话说的好:“多和旧人做新事”,我想我和 json 大概也属于这种关系吧(?)

json 解析时字段名称保持一致

这个疑问是,假如我们编码不太规范,不给字段添加 Tag,序列化和反序列化后的字段字符串会是什么?

type Object struct {
   
    ID      string
    VaLuE2T int64
}

func TestFunc(t *testing.T) {
   
    obj := Object{
   
        ID:      "the-id",
        VaLuE2T: 7239,
    }
    marshal, err := json.Marshal(obj)
    assert.Nil(t, err)
    fmt.Println(string(marshal))
}
{"ID":"the-id","VaLuE2T":7239}

用代码验证的结果是,json 编码并不会将程序中定义的字段名称改成驼峰或者什么特殊大小写规则,而是完完全全使用原本的字符。如果是我目前的这个需求,即仅用来保存数据,编码和解码都在后端进行,那这样完全可用不需要考虑更多,但如果是需要前后端数据对齐,而且有特殊的字段名称规范,那就要使用 tag 对编码字段进行规定,比如下方的代码。

type Object struct {
   
    ID      string `json:"id"`
    VaLuE2T int64  `json:"value2t"`
}

func TestFunc(t *testing.T) {
   
    obj := Object{
   
        ID:      "the-id",
        VaLuE2T: 7239,
    }
    marshal, err := json.Marshal(obj)
    assert.Nil(t, err)
    fmt.Println(string(marshal))
}
{"id":"the-id","value2t":7239}

但这只是编码,对于解码来说,是大小写不敏感的,就算传过来的是某种形式的妖魔鬼怪也可以解析出来,比如

type Object struct {
   
    CaSeTesT string
    CAsEteSt string
}

func TestFunc(t *testing.T) {
   
    newObj := Object{
   }
    testString := `{"cAsEteSt":"test"}`
    err := json.Unmarshal([]byte(testString), &newObj)
    assert.Nil(t, err)
    fmt.Println("CaSeTesT:", newObj.CaSeTesT, " CAsEteSt:", newObj.CAsEteSt)
}
CaSeTesT: test  CAsEteSt:

也因为如此,最好不要在相关结构体里定义名称相同的字段,即便有大小写的区别,也会导致不可预料的情况发生。而且严格按照驼峰格式命名的话,不存在大小写区别,相同字母的字段就是唯一的。

而 Go 团队也将在 json/v2 中默认大小写敏感,规范的行为肯定会带来更少的 bug ~ 关于 json/v2 具体可以参考:A new experimental Go API for JSON

哦哦还有一点,如果不想某个字段参与解码编码可以使用特殊的 tag。

type Object struct {
   
    Value string `json:"-"`
}

可以编解码接口和范型

我们知道 json 官方包底层是依靠反射实现的,所以获取到传入接口的结构体类型不是问题,就可以使用原结构体类型去编解码,所以只要是 Golang 支持的类型都可以,甚至是范型。当然也有一些反例需要注意,比如 func 这种类型就不行。

type Object struct {
   
    Func func()
}

func TestFunc(t *testing.T) {
   
    obj := Object{
   
        Func: func() {
   },
    }
    marshal, err := json.Marshal(obj)
    fmt.Println(err)
}
json: unsupported type: func()

omitempty 和字段类型

  • 当字段是结构体类型的,那么 omitempty 无效。
  • 当字段是指针类型的,如果值是 nil,那么有 omitempty 就不进行编码,没有 omitempty 会编码成 null。
  • 经过测试不仅是指针类型的结构体,指针类型的基础类型比如 string 或者 int64 也是如此。
type Object struct {
   
    TheStructO AObject  `json:"theStructO,omitempty"`
    TheStruct  AObject  `json:"theStruct"`
    ThePointO  *AObject `json:"thePointO,omitempty"`
    ThePoint   *AObject `json:"thePoint"`
}

type AObject struct {
   
    Values interface{
   }
}

func TestFunc(t *testing.T) {
   
    obj := Object{
   }
    marshal, err := json.Marshal(obj)
    assert.Nil(t, err)
    fmt.Println(string(marshal))
}
{"theStructO":{"Values":null},"theStruct":{"Values":null},"thePoint":null}

结构体类型和指针类型性能比较

使用 Benchmark 测试结构体类型和指针类型的性能。结论是在 CPU 性能上两者差不多,但是一个指针类型的字段会多进行一次内存分配,在一定程度上增加了 GC 的压力,所以看起来小的结构体还是结构体值类型更合适。

type ObjectStruct struct {
   
    TheStruct AObject `json:"theStruct"`
}

type ObjectPoint struct {
   
    TheStruct *AObject `json:"theStruct"`
}

func BenchmarkFunc(b *testing.B) {
   
    data := []byte(`{"theStruct":{"valueString":"text","valueInt":123,"valueFloat":3.14}}`)
    b.Run("unmarshal-struct", func(b *testing.B) {
   
        for i := 0; i < b.N; i++ {
   
            _ = json.Unmarshal(data, &ObjectStruct{
   })
        }
    })
    b.Run("unmarshal-point", func(b *testing.B) {
   
        for i := 0; i < b.N; i++ {
   
            _ = json.Unmarshal(data, &ObjectPoint{
   })
        }
    })
}
BenchmarkFunc
BenchmarkFunc/unmarshal-struct
BenchmarkFunc/unmarshal-struct-8        457996     2518 ns/op     304 B/op     8 allocs/op
BenchmarkFunc/unmarshal-point
BenchmarkFunc/unmarshal-point-8          471489     2517 ns/op     312 B/op     9 allocs/op
PASS

自定义 json 编解码方式

可以实现 json 规定的接口,使结构体执行特定的编解码方式,假设下面一种情况,我希望业务代码开发中使用方便查询和操作的map,然后存储或者通讯使用占用空间更少的数组或者切片,但同时我又不想增加开发人员的心智负担,想要之前怎么使用现在就如何使用,或者无法更改一些库的执行方式只能绕路。也就是说平时开发时需要直接调用 json.Marshaljson.UnMarshal,而不需要额外操作,这时就可以通过实现接口的方式达成目的,见如下代码。

type Object struct {
   
    UserMap map[string]struct{
   }
}

func (o Object) MarshalJSON() ([]byte, error) {
   
    list := make([]string, 0, len(o.UserMap))
    for key := range o.UserMap {
   
        list = append(list, key)
    }
    return json.Marshal(list)
}

func (o *Object) UnmarshalJSON(b []byte) error {
   
    var list []string
    err := json.Unmarshal(b, &list)
    if err != nil {
   
        return err
    }
    o.UserMap = make(map[string]struct{
   }, len(list))
    for i := range list {
   
        o.UserMap[list[i]] = struct{
   }{
   }
    }
    return nil
}

type ObjectNormal struct {
   
    UserMap map[string]struct{
   }
}

func TestFunc(t *testing.T) {
   
    userMap := map[string]struct{
   }{
   
        "user1": {
   },
        "user2": {
   },
        "user3": {
   },
    }
    obj1 := &Object{
   
        UserMap: userMap,
    }
    obj2 := &ObjectNormal{
   
        UserMap: userMap,
    }
    marshal1, err := json.Marshal(obj1)
    assert.Nil(t, err)
    fmt.Println("len:", len(marshal1), string(marshal1))
    marshal2, err := json.Marshal(obj2)
    assert.Nil(t, err)
    fmt.Println("len:", len(marshal2), string(marshal2))
}
len: 25 ["user1","user2","user3"]
len: 46 {"UserMap":{"user1":{},"user2":{},"user3":{}}}

此处还有一个小 Tips,UnmarshalJSON 用指针接收器没问题,因为需要修改调用这个方法的结构体的字段值,但是 MarshalJSON 尽量用值接收器,因为这样在调用 json.Marshal 时无论传入的是值还是指针都能正常编码,同时也避免了传入的是 nil 导致 panic。

被遗忘在角落的 gob

在 golang 源码的 encoding 包下有很多编解码方式,比如 json、xml、base64 等等,但其中也有一个 gob,假如你之前没有接触过 golang 这门编程语言那你大概率没有听说过这种编码解码方式,因为它就独属于 golang,其他语言基本上可以说无法解析。

type G struct {
   
    Value string
}

func TestGOB(t *testing.T) {
   
    g := &G{
   Value: "hello"}

    var buf bytes.Buffer
    enc := gob.NewEncoder(&buf)
    if err := enc.Encode(g); err != nil {
   
        panic(err)
    }
    fmt.Println("Gob encoded bytes:", buf.Bytes())

    var decoded G
    dec := gob.NewDecoder(&buf)
    if err := dec.Decode(&decoded); err != nil {
   
        panic(err)
    }
    fmt.Println("Decoded struct:", decoded)
}

使用方式大差不差,但与 json 的行为相比需要依赖 bytes.Buffer,也正因如此可以连续向 Buffer 编码多个结构体,然后连续解码多个结构体。此外和 json 一样也可以实现特定的接口来自定义编解码行为,具体可以参考https://pkg.go.dev/encoding/gob

向 json 和 xml 这种编码方式方便让我们肉眼观察,但因此也牺牲了性能和空间,而 gob 类似 protobuf 都是生成二进制,但是 gob 仅存在于 golang 生态中,普及度远远不及可以生成多种语言代码的 protobuf。

type User struct {
   
    Name string
}

func Benchmark(b *testing.B) {
   
    b.Run("gob", func(b *testing.B) {
   
        var buf bytes.Buffer
        enc := gob.NewEncoder(&buf)
        dec := gob.NewDecoder(&buf)
        user := User{
   Name: "hello"}
        for i := 0; i < b.N; i++ {
   
            _ = enc.Encode(user)
            _ = dec.Decode(&user)
        }
    })
    b.Run("json", func(b *testing.B) {
   
        user := User{
   Name: "hello"}
        for i := 0; i < b.N; i++ {
   
            marshal, _ := json.Marshal(user)
            _ = json.Unmarshal(marshal, &user)
        }
    })
    b.Run("protobuf", func(b *testing.B) {
   
        user := ttt.User{
   Name: "hello"}
        for i := 0; i < b.N; i++ {
   
            data, _ := proto.Marshal(&user)
            _ = proto.Unmarshal(data, &user)
        }
    })
}

控制变量法,我设计了相同的结构体 proto。

message User {
  string Name = 1;
}
Benchmark
Benchmark/gob
Benchmark/gob-8              1230975          954.7 ns/op          32 B/op           3 allocs/op
Benchmark/json
Benchmark/json-8             1000000          1130 ns/op         256 B/op           7 allocs/op
Benchmark/protobuf
Benchmark/protobuf-8         2500924          483.2 ns/op          16 B/op           2 allocs/op
PASS

可能是由于我用的是简单结构体,gob 和 json 在 CPU 性能上并没有看到什么差距,但是内存分配差了蛮多,如果不考虑通用性和扩展性的话,gob 也是个不错的选择,虽然事实是这两方面不可能不考虑。而且在性能方面也远远不及代码生成派,生产实践中多多用 protobuf 才是正道。

RawMessage 的应用场景

试想这样一种情况,某个推荐业务有两层分别是 A 和 B ,通常是是 A 调用 B 的接口(RPC),然后 A 再组织数据发给前端,QA和运营需求要获取到 B 持有的信息用来 debug 和测试,这个时候因为是不关键的 debug 信息所以也就懒得定义消息结构体,而是直接在B中用 json 将数据序列化成字符串传给 A,然后 A 在外面封装一层错误码和数据传给前端,如果直接这么操作会有一个问题:

type ResponseB struct {
   
    Name string
}

type ResponseA struct {
   
    Data string
}

func TestRaw(t *testing.T) {
   
    r := ResponseB{
   
        Name: "hello-world",
    }
    marshal, err := json.Marshal(r)
    assert.Nil(t, err)

    ra := &ResponseA{
   
        Data: string(marshal),
    }
    marshal2, err := json.Marshal(ra)
    assert.Nil(t, err)
    fmt.Println(string(marshal), string(marshal2))
}
{"Name":"hello-world"} {"Data":"{\"Name\":\"hello-world\"}"}

字符串类型的字段在 json.Marshal 时,其中的双引号会被转义,甚至于三层四层来回传递后转移符号会越来越多。所以这个时候就可以使用 json.RawMessage。

type ResponseB struct {
   
    Name string
}

type ResponseA struct {
   
    Data json.RawMessage
}

func TestRaw(t *testing.T) {
   
    r := RawStruct{
   
        Name: "hello-world",
    }
    marshal, err := json.Marshal(r)
    assert.Nil(t, err)

    rj := &RawJson{
   
        Data: json.RawMessage(marshal),
    }
    marshal3, err := json.Marshal(rj)
    assert.Nil(t, err)
    fmt.Println(string(marshal), string(marshal3))
}
{"Name":"hello-world"} {"Data":{"Name":"hello-world"}}

除了编码之外,解码时的 RawMessage 也有大用处,尤其是需要二次解码的情况。比如有一个接口是聊天室发送消息,然后消息有不同的类型,每个类型的内容的结构都不一样,这时需要先解码通用结构,然后拿到消息类型,再根据消息类型解码具体消息内容。比如下面这个例子,如果不使用 RawMessage,就一定要在字符串内增加转义。

type Inside struct {
   
    Name string
}

type Outside struct {
   
    Data       interface{
   }
    DataString string
    DataRaw    json.RawMessage
}

func TestRaw(t *testing.T) {
   
    data := `{"Data":"{"Name":"hello-world"}","DataString":"{"Name":"hello-world"}","DataRaw":{"Name":"hello-world"}}`
    rj := Outside{
   }
    err := json.Unmarshal([]byte(data), &rj)
    assert.Nil(t, err)
    fmt.Println(rj)
}
Expected nil, but got: &json.SyntaxError{msg:"invalid character 'N' after object key:value pair", Offset:12}

新时代的明星 json v2

https://pkg.go.dev/encoding/json?tab=versions 中可以看到,json 包在 go1 也就是最初的版本就已经存在了,只是当时有一些设计和特性放到当下来看是有些老旧的,由于 Go 的兼容性承诺也不便对其进行大刀阔斧的改动,正是因为如此,在最近的版本中 go 团队推出了新的 json 包也就是 json/v2 来解决 json 编解码的一些痛点问题。如果对具体内容感兴趣可以去阅读官方的文档 https://pkg.go.dev/encoding/json/v2,包括 v1 版本和 v2 版本的一些区别 https://pkg.go.dev/encoding/json#hdr-Migrating_to_v2,以及介绍新版本 json 的博客 https://go.dev/blog/jsonv2-exp

会用 v2 实现 v1,只是 v1 中原本的一些特性在 v2 中会变成可选择的 Option 提供出来以保证兼容性,这些选项不乏上文提到的一些特殊性质,譬如:

  • 编解码结构体时字段大小写敏感 (case-sensitive)
  • omitempty 起作用的对象会发生变化
  • nil 的 slice 和 map 会编码成空数组和空结构体而不是 null
  • 以及其他的一些性质

当然不只是一些编解码行为发生了变化,性能方面也有了很大提高,甚至还能看到专门的文章介绍和分析当前社区流行的诸多 json 库和 json/v2 的对比,老熟人 sonic 也在其中,具体内容详见 https://github.com/go-json-experiment/jsonbench

欢迎访问我的个人小站 莹的网络日志 ,不定时更新文章和技术博客~

相关文章
|
2天前
|
存储 弹性计算 人工智能
【2025云栖精华内容】 打造持续领先,全球覆盖的澎湃算力底座——通用计算产品发布与行业实践专场回顾
2025年9月24日,阿里云弹性计算团队多位产品、技术专家及服务器团队技术专家共同在【2025云栖大会】现场带来了《通用计算产品发布与行业实践》的专场论坛,本论坛聚焦弹性计算多款通用算力产品发布。同时,ECS云服务器安全能力、资源售卖模式、计算AI助手等用户体验关键环节也宣布升级,让用云更简单、更智能。海尔三翼鸟云服务负责人刘建锋先生作为特邀嘉宾,莅临现场分享了关于阿里云ECS g9i推动AIoT平台的场景落地实践。
【2025云栖精华内容】 打造持续领先,全球覆盖的澎湃算力底座——通用计算产品发布与行业实践专场回顾
|
4天前
|
云安全 数据采集 人工智能
古茗联名引爆全网,阿里云三层防护助力对抗黑产
阿里云三层校验+风险识别,为古茗每一杯奶茶保驾护航!
古茗联名引爆全网,阿里云三层防护助力对抗黑产
|
4天前
|
存储 机器学习/深度学习 人工智能
大模型微调技术:LoRA原理与实践
本文深入解析大语言模型微调中的关键技术——低秩自适应(LoRA)。通过分析全参数微调的计算瓶颈,详细阐述LoRA的数学原理、实现机制和优势特点。文章包含完整的PyTorch实现代码、性能对比实验以及实际应用场景,为开发者提供高效微调大模型的实践指南。
533 2
kde
|
4天前
|
人工智能 关系型数据库 PostgreSQL
n8n Docker 部署手册
n8n是一款开源工作流自动化平台,支持低代码与可编程模式,集成400+服务节点,原生支持AI与API连接,可自托管部署,助力团队构建安全高效的自动化流程。
kde
362 3
|
2天前
|
Linux 虚拟化 iOS开发
VMware Workstation Pro 25H2 for Windows & Linux - 领先的免费桌面虚拟化软件
VMware Workstation Pro 25H2 for Windows & Linux - 领先的免费桌面虚拟化软件
747 4
VMware Workstation Pro 25H2 for Windows & Linux - 领先的免费桌面虚拟化软件
|
3天前
|
JavaScript 开发工具 Android开发
如何在原生 App 中调用 Uniapp 的页面?
如何在原生 App 中调用 Uniapp 的页面?
243 138
|
4天前
|
存储 人工智能 Java
AI 超级智能体全栈项目阶段四:学术分析 AI 项目 RAG 落地指南:基于 Spring AI 的本地与阿里云知识库实践
本文介绍RAG(检索增强生成)技术,结合Spring AI与本地及云知识库实现学术分析AI应用,利用阿里云Qwen-Plus模型提升回答准确性与可信度。
254 91
AI 超级智能体全栈项目阶段四:学术分析 AI 项目 RAG 落地指南:基于 Spring AI 的本地与阿里云知识库实践