Golang 中 JSON 操作的 5 個常見陷阱(建議收藏!)
JSON 是許多開發人員在工作中經常使用的一種數據格式。它一般用於配置文件或網絡數據傳輸等場景。由於其簡單、易懂、可讀性好,JSON 已成爲整個 IT 界最常用的格式之一。對於這種情況,Golang 和許多其他語言一樣,也提供了標準庫級別的支持, encoding/json[1]。
就像 JSON 本身很容易理解一樣,用於操作 JSON 的編碼 / JSON 庫也非常容易使用。但我相信,很多開發者可能會像我第一次使用這個庫時一樣,遇到各種奇怪的問題或 bug。本文總結了我個人在使用 Golang 操作 JSON 時遇到的問題和錯誤。希望能幫助更多閱讀本文的開發者掌握 Golang 的使用技巧,更正確地操作 JSON,避免掉入不必要的 "坑"。
本文內容基於 Go 1.22。不同版本之間可能存在細微差別。讀者在閱讀和使用時請注意。同時,本文列出的所有案例均使用 encoding/json,不涉及任何第三方 JSON 庫。
基本用法
先來看下 encoding/json 的基本用法。作爲一種數據格式,JSON 的核心操作只有兩個:序列化和反序列化。序列化是將 Go 對象轉換爲 JSON 格式的字符串(或字節序列)。反序列化則相反,是將 JSON 格式的數據轉換成 Go 對象。
這裏提到的對象是一個寬泛的概念。它不僅指結構對象,還包括切片和映射類型的數據。它們也支持 JSON 序列化。
import (
"encoding/json"
"fmt"
)
type Person struct {
ID uint
Name string
Age int
}
func MarshalPerson() {
p := Person{
ID: 1,
Name: "Bruce",
Age: 18,
}
output, err := json.Marshal(p)
if err != nil {
panic(err)
}
println(string(output))
}
func UnmarshalPerson() {
str := `{"ID":1,"Name":"Bruce","Age":18}`
var p Person
err := json.Unmarshal([]byte(str), &p)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", p)
}
核心是兩個函數 json.Marshal 和 json.Unmarshal,分別用於序列化和反序列化。這兩個函數都會返回錯誤,在這裏我只是簡單地 panic 一下。使用過 encoding/json 的讀者可能知道,還有另一對工具會經常用到:NewEncoder 和 NewDecoder。簡單看一下源代碼就會發現,這兩個工具的底層核心邏輯調用與 Marshal 是一樣的,所以我就不在這裏舉例說明了。
常見的 ‘坑’
1. public or private 字段處理
這可能是剛接觸 Go 的開發人員最容易犯的錯誤。也就是說,如果我們使用結構體處理 JSON,那麼結構體的成員字段必須是公有的,即首字母大寫的,而私有成員是無法解析的。例如:
type Person struct {
ID uint
Name string
age int
}
func MarshalPerson() {
p := Person{
ID: 1,
Name: "Bruce",
age: 18,
}
output, err := json.Marshal(p)
if err != nil {
panic(err)
}
println(string(output))
}
func UnmarshalPerson() {
str := `{"ID":1,"Name":"Bruce","age":18}`
var p Person
err := json.Unmarshal([]byte(str), &p)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", p)
}
// Output Marshal:
{"ID":1,"Name":"Bruce"}
// Output Unmarshal:
{ID:1 Name:Bruce age:0}
在這裏,age 被設置爲私有變量,因此序列化的 JSON 字符串中沒有 age 字段。同樣,在將 JSON 字符串反序列化爲 Person 時,也無法正確讀取 age 的值。原因很簡單。如果我們深入研究 Marshal 下的源代碼,就會發現它實際上使用了 reflect 來動態解析 struct 對象:
// .../src/encoding/json/encode.go
func (e *encodeState) marshal(v any, opts encOpts) (err error) {
// ...skip
e.reflectValue(reflect.ValueOf(v), opts)
return nil
}
而 Golang 在語言設計層面禁止對結構的私有成員進行反射式訪問,因此這種反射式解析自然會失敗,反序列化也是如此。
2. 警惕結構體組合
Go 是面向對象的,但它沒有類,只有結構,而結構沒有繼承性。因此,Go 使用一種組合來重用不同的結構。在許多情況下,這種組合非常方便,因爲我們可以像操作結構本身的成員一樣操作組合中的其他成員,就像下面這樣:
type Person struct {
ID uint
Name string
address
}
type address struct {
Code int
Street string
}
func (a address) PrintAddr() {
fmt.Println(a.Code, a.Street)
}
func Group() {
p := Person{
ID: 1,
Name: "Bruce",
address: address{
Code: 100,
Street: "Main St",
},
}
// Access all address's fields and methods directly
fmt.Println(p.Code, p.Street)
p.PrintAddr()
}
// Output
100 Main St
100 Main St
結構體組合使用起來確實挺方便。不過,在使用 JSON 解析時,我們需要注意一個小問題。請看下面的代碼:
// The structure used here is the same as the previous one,
// so I won't repeat it. error is also not captured to save space.
func MarshalPerson() {
p := Person{
ID: 1,
Name: "Bruce",
address: address{
Code: 100,
Street: "Main St",
},
}
// It would be more pretty by MarshalIndent
output, _ := json.MarshalIndent(p, "", " ")
println(string(output))
}
func UnmarshalPerson() {
str := `{"ID":1,"Name":"Bruce","address":{"Code":100,"Street":"Main St"}}`
var p Person
_ = json.Unmarshal([]byte(str), &p)
fmt.Printf("%+v\n", p)
}
// Output MarshalPerson:
{
"ID": 1,
"Name": "Bruce",
"Code": 100,
"Street": "Main St"
}
// Ouptput UnmarshalPerson:
{ID:1 Name:Bruce address:{Code:0 Street:}}
這裏先聲明一個 Person 對象,然後使用 MarshalIndent 美化序列化結果並打印出來。從打印輸出中我們可以看到,整個 Person 對象都被扁平化了。就 Person 結構而言,儘管進行了組合,但它看起來仍有一個地址成員字段。因此,有時我們會想當然地認爲 Person 的序列化 JSON 看起來是這樣的:
// The imagine of JSON serialization result
{
"ID": 1,
"Name": "Bruce",
"address": {
"Code": 100,
"Street": "Main St"
}
}
但事實並非如此,它被扁平化了。這更符合我們之前直接通過 Person 訪問地址成員時的感覺,即地址成員似乎直接成爲了 Person 的成員。這一點需要注意,因爲這種組合會使序列化後的 JSON 結果扁平化。
另一個有點違反直覺的問題是,地址結構是一個私有結構,而私有成員似乎不應該被序列化?沒錯,這也是這種組合結構體做 JSON 解析的缺點之一:它暴露了私有組合對象的公共成員。如果沒有特殊需要(例如,原始 JSON 數據已被扁平化,並且有多個結構體的重複字段需要重複使用),從我個人的角度來看,建議這樣編寫:
type Person struct {
ID int
Name string
Address address
}
3. 反序列化部分成員字段
直接查看代碼:
type Person struct {
ID uint
Name string
}
// PartUpdateIssue simulates parsing two different
// JSON strings with the same structure
func PartUpdateIssue() {
var p Person
// The first data has the ID field and is not 0
str := `{"ID":1,"Name":"Bruce"}`
_ = json.Unmarshal([]byte(str), &p)
fmt.Printf("%+v\n", p)
// The second data does not have an ID field,
// deserializing it again with p preserves the last value
str = `{"Name":"Jim"}`
_ = json.Unmarshal([]byte(str), &p)
// Notice the output ID is still 1
fmt.Printf("%+v\n", p)
}
// Output
{ID:1 Name:Bruce}
{ID:1 Name:Jim}
從代碼註釋中可以知道,當我們重複使用同一結構來反序列化不同的 JSON 數據時,一旦某個 JSON 數據的值只包含部分成員字段,那麼未包含的成員就會使用最後一次反序列化的值,會產生髒數據污染問題。
4. 處理指針字段
許多開發人員一聽到指針這個詞就頭疼不已,其實大可不必。但 Go 中的指針確實給開發人員帶來了 Go 程序中最常見的 panic 之一:空指針異常。當指針與 JSON 結合時會發生什麼呢?
看下面的代碼:
type Person struct {
ID uint
Name string
Address *Address
}
func UnmarshalPtr() {
str := `{"ID":1,"Name":"Bruce"}`
var p Person
_ = json.Unmarshal([]byte(str), &p)
fmt.Printf("%+v\n", p)
// It would panic this line
// fmt.Printf("%+v\n", p.Address.Street)
}
// Output
{ID:1 Name:Bruce Address:<nil>}
我們將 Address 成員定義爲指針,當我們反序列化一段不包含 Address 的 JSON 數據時,指針字段會被設置爲 nil,因爲它沒有對應的數據。如果我們直接調用 p.Address.xxx,程序會因爲 p.Address 爲空而崩潰。
因此,如果有一個指針指向我們結構中的一個成員,請記住在使用它之前先確定指針是否爲 nil。這有點繁瑣,但也沒辦法。畢竟,編寫幾行代碼與生產環境中的 panic 所造成的損失相比可能並不算什麼。
此外,在創建帶有指針字段的結構時,指針字段的賦值也會相對繁瑣:
type Person struct {
ID int
Name string
Age *int
}
func Foo() {
p := Person{
ID: 1,
Name: "Bruce",
Age: new(int),
}
*p.Age = 20
// ...
}
5. 零值(默認值)可能造成的問題
零值是 Golang 變量的一個特性,我們可以簡單地將其視爲默認值。也就是說,如果我們沒有顯式地給變量賦值,Golang 就會給它賦一個默認值。例如,我們在前面的例子中看到,int 的默認值爲 0,string 的默認值爲空字符串,指針的零值爲 nil,等等。
處理帶有零值的 JSON 有哪些隱患?請看下面的例子:
type Person struct {
Name string
ChildrenCnt int
}
func ZeroValueConfusion() {
str := `{"Name":"Bruce"}`
var p Person
_ = json.Unmarshal([]byte(str), &p)
fmt.Printf("%+v\n", p)
str2 := `{"Name":"Jim","ChildrenCnt":0}`
var p2 Person
_ = json.Unmarshal([]byte(str2), &p2)
fmt.Printf("%+v\n", p2)
}
// Output
{Name:Bruce ChildrenCnt:0}
{Name:Jim ChildrenCnt:0}
我們在 Person 結構中添加了一個 ChildrenCnt 字段,用於計算該人的子女數量。由於該字段的值爲零,當 p 加載的 JSON 數據中沒有 ChildrenCnt 數據時,該字段的賦值爲 0。在 Bruce 和 Jim 的例子中,由於數據缺失,其中一個的子女數爲 0,而另一個的子女數爲 0。而實際上布魯斯的子女數應該是 "未知",如果我們真的將其視爲 0,可能會在業務上造成問題。在一些對數據有嚴格要求的場景中,這種混淆是非常致命的。那麼,有沒有辦法避免這種零值干擾呢?讓我們將 Person 的 ChildrenCnt 類型改爲 *int 並看看會發生什麼:
type Person struct {
Name string
ChildrenCnt *int
}
// Output
{Name:Bruce ChildrenCnt:<nil>}
{Name:Jim ChildrenCnt:0xc0000124c8}
不同之處在於 Bruce 沒有數據,因此 ChildrenCnt 爲零,而 Jim 是一個非零指針。這樣就很明顯了,Bruc 的孩子數量是未知的。從本質上講,這種方法仍然使用零值,即指針的零值,有點像以毒攻毒(笑)。
總結
在這篇文章中,我列舉了自己在使用編碼 / json 庫時犯過的 7 個錯誤,其中大部分都是我在工作中遇到的。如果你還沒有遇到過,那麼恭喜你!這也提醒我們今後在使用 JSON 時要小心謹慎;如果你遇到過這些問題,併爲此感到困惑,希望本文能對你有所幫助。
參考資料
[1]
encoding/json: https://pkg.go.dev/encoding/json
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Cfflw5tX3SLBwAqGDavstg