Stories

Detail Return Return

重新認識 Golang 中的 json 編解碼 - Stories Detail

歡迎訪問我的個人小站 瑩的網絡日誌 ,不定時更新文章和技術博客~

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。

Add a new Comments

Some HTML is okay.