0 前言
JSON 是很多開發者工作中經常使用的數據格式,一般多用於配置文件或網絡數據傳遞之類的場景。並且由於其簡單易懂,可讀性較好等特點,JSON 也成為了整個 IT 世界幾乎最常見的格式之一了。對於這樣的東西,Golang 和其他很多語言一樣,也提供了標準庫級別的支持,也就是 encoding/json。
就像 JSON 本身簡單易懂一樣,用於操作 JSON 的 encoding/json 庫也非常容易上手。但我相信許多小夥伴可能和我當初剛使用這個庫時一樣,都遇到過各種奇奇怪怪的問題或 bug。本文就我個人在使用 Golang 操作 JSON 時遇到的問題,犯的錯誤,做一個總結,希望能夠幫助更多看到這篇文章的開發者能更順利的掌握 Golang 操作 JSON 的技巧,少踩一些坑。
最後,本文的內容基於 Go 1.22 版本,不同版本之間可能會有些許差異,請讀者們在閲讀和使用時注意甄別。同時,本文所列舉的所有案例使用的都是 encoding/json ,不涉及任何第三方 JSON 庫。
1 基本使用
先簡單講一下 encoding/json 庫的基本使用。
JSON 作為一種數據格式,它的核心動作就是兩個:序列化,反序列化。序列化就是把一個 Go 對象轉化為 JSON 格式的字符串(或字節序列,這點區別不重要),反序列化則相反,把 JSON 格式的數據轉化成 Go 對象。
這裏説的對象是一個廣義的概念,不單指結構體對象,包括 slice、map 類型數據也支持 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 兩個函數,分別用於序列化和反序列化。兩個函數都會返回 error,這裏我簡單的做了 panic。
用過 encoding/json 的讀者可能知道,這個庫還有一對比較常用的序列化組合: NewEncoder 和 NewDecoder 。簡單看過源碼可以瞭解到,它倆的底層核心邏輯調用和 Marshal 它們是一樣的,所以這裏就不展開舉例了。
2 坑
2.1 公開(public)成員字段
這點可能是所有剛熟悉 Go 或 JSON 庫不久的開發者最容易犯的錯了。即,如果我們用結構體來操作 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 的源碼就能發現,它的底層實際上使用了反射對結構體對象進行動態解析:
// .../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.2 少用 map
前文裏提到,JSON 不僅能操作結構體,還能操作 slice、map 等類型的數據。slice 比較特殊,但 map 和結構體表現在 JSON 格式下其實是一樣的:
{
"ID": 1,
"Name": "Bruce"
}
這種情況下,除非有特情況或需求,否則,少用 map。因為 map 會帶來額外的開銷,額外的代碼量,以及額外的維護成本。
為什麼?
首先,像上面的 Person 例子,由於 ID 和 Name 是不同類型,因此我們如果要用 map 反序列化這個 JSON 數據,就只能申明一個 map[string]any 類型的 map。any,也就是 interface{} ,就意味着我們如果要單獨使用 Name 或 ID 時,需要用類型斷言來轉換類型:
var m map[string]any
// ...反序列化 JSON 數據,代碼忽略...
// 獲取成員
name, ok := m["Name"].(string)
類型斷言本身就是一個額外的步驟,為防止 panic,我們還需要判斷第二個參數 ok,這無疑增加了開發工作量以及代碼負擔。
另外,map 本身對數據就是無約束的。結構體中我們能夠預先定義各成員字段以及類型,但 map 不行。這就意味着,我們只能通過文檔或註釋或代碼本身來理解這個 map 裏到底裝了些什麼東西。並且,結構體可以限制 JSON 數據的 key 和 value 類型不被亂改,而 map 同樣無法約束 JSON 的變更,只能通過業務邏輯代碼來檢測。這其中的工作量和後期維護成本,想想就知道會有多少。
之所以我會提及這個坑,是因為我在使用 Go 開發之前,主語言是 Python。而 Python 嘛,你們懂的,沒有結構體,只有 dict(map)來加載 JSON 數據。在我剛接觸 Go 時,我也習慣性用 map 來與 JSON 交互。但因為 Go 是靜態類型,必須要顯式轉換類型(類型斷言),不能像 Python 一樣直接用,就一度讓我很頭疼。
總之,少用,或儘量不要用 map 來操作 JSON。
2.3 小心結構體組合
Go 雖然面向對象,但沒有 class ,只有結構體,並且結構體沒有繼承。因此 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",
},
}
// 用 p 直接訪問 Address 的成員和方法
fmt.Println(p.Code, p.Street)
p.PrintAddr()
}
// Output
100 Main St
100 Main St
很方便對吧,我也這麼覺得。但當我們將組合融入到 JSON 的使用當中時,這裏會有一個小坑需要注意。來看下面這段代碼:
// 這裏用的還是前面的結構體,就不重複寫了。error 也不捕獲了,節省篇幅。
func MarshalPerson() {
p := Person{
ID: 1,
Name: "Bruce",
address: address{
Code: 100,
Street: "Main St",
},
}
// 用 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:}}
這段代碼信息量稍微有點多,我們一點一點來捋。
先看 MarshalPerson 函數。這裏先申明瞭一個 Person 對象,然後用 MarshalIndent 美化一下序列化結果,並打印。從打印的結果中我們看到,整個 Person 對象被鋪平了。對於 Person 結構體來説,儘管用了組合,但它看上去還是有一個 address 成員字段。所以有時候我們會想當然地以為 Person 序列化後的 JSON 長這樣:
// 想象中的 JSON 序列化結果
{
"ID": 1,
"Name": "Bruce",
"address": {
"Code": 100,
"Street": "Main St"
}
}
但實際上並沒有,它被鋪平了。這一點倒是比較符合前面我們直接通過 Person 訪問 address 成員時的感覺,即,address 的成員似乎直接變成了 Person 的成員。這是一個需要注意的地方,組合會讓序列化後的 JSON 結果鋪平。
另一個稍微有些反直覺的點是,address 結構體是一個私有結構體,而私有成員似乎不應該被序列化?沒錯,這就是組合這種形式有一點不太好的地方了:它會暴露私有組合對象的公共成員。所以這裏就要注意了,這種暴露有時候是無意的,但它可能會造成不必要的數據泄漏。
然後是 UnmarshalPerson 函數。有了上一個函數的解讀,這個就好理解了,其實還是組合後 JSON 結果被鋪平的問題。因此我們如果需要反序列化回 Person 時,也需要一個鋪平後的 JSON 數據。
所以其實,我個人在這些年對 Go 的使用過程中,遇到這類需要轉化成 JSON 的結構體時,通常不太會用組合,除非有一些特殊的情況。畢竟它太容易帶來上面提及的問題了。並且,由於 JSON 是平鋪的而結構體定義上沒有平鋪,一旦這個結構體組被定義的越來越複雜,那麼它和原始鋪平的 JSON 數據就越難去直觀對比了,這樣會使這個代碼的可讀性將直線下降。
如果沒有特殊需求的話(譬如原始 JSON 數據就是平鋪的,並且存在多個結構體有重複字段需要複用),從我個人的角度建議,儘量這麼寫:
type Person struct {
ID int
Name string
Address address
}
當然,如果這個結構體並不涉及 JSON 序列化,那我還是更建議使用組合,確實方便。
2.4 部分成員反序列化時需要小心
直接看代碼:
type Person struct {
ID uint
Name string
}
// PartUpdateIssue 模擬了用同一個結構體解析兩個不同的 JSON 字符串的場景
func PartUpdateIssue() {
var p Person
// 第一個數據有 ID 字段,且不為 0
str := `{"ID":1,"Name":"Bruce"}`
_ = json.Unmarshal([]byte(str), &p)
fmt.Printf("%+v\n", p)
// 第二個數據沒有 ID 字段,再次用 p 反序列化,會保留上次的值
str = `{"Name":"Jim"}`
_ = json.Unmarshal([]byte(str), &p)
// 注意輸出的 ID 仍然是 1
fmt.Printf("%+v\n", p)
}
// Output
{ID:1 Name:Bruce}
{ID:1 Name:Jim}
註釋裏解釋的很明白了:當我們用同一個結構體去反覆反序列化不同的 JSON 數據時,一旦某個 JSON 數據的值只包含部分成員字段的,那麼未被覆蓋到的成員就會殘留上一次反序列化的值。其實就是個髒數據污染的問題。
這是一個很容易遭遇,而且一旦觸發,又很隱蔽的問題。此前我曾寫過一篇文章(Golang 中由零值和 gob 庫的特性引起的 BUG),講述的是 gob 庫使用時遇到的一個類似的情況。當然 gob 的問題和零值有關,與今天講的 JSON 問題還不太一樣,但兩者最終的表現比較像,都是部分成員字段被髒數據污染。
解決方案也很簡單:每次反序列化 JSON 時,都使用全新的結構體對象來加載數據。
總之,一定要小心這種情況。
2.5 處理指針成員
很多開發者一聽到指針兩個字就頭大,其實大可不必,這玩意兒沒那麼複雜。但 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)
// 下面這行會 panic
// fmt.Printf("%+v\n", p.Address.Street)
}
// Output
{ID:1 Name:Bruce Address:<nil>}
我們將 Address 成員定義為一個指針,此時我們去反序列化一段不包含 Address 的 JSON 數據時,這個指針成員由於沒有對應的數據,會被置為 nil。encoding/json 不會為該成員創建一個空的 &Address 。這個時候如果我們直接調用 p.Address.xxx ,程序就會因為 p.Address 為空而 panic。
所以,如果我們的結構體成員存在指針時,使用前請記得判斷指針是否為空。這有些繁瑣,但沒有辦法。畢竟與生產環境裏的 panic 造成的損失相比,多寫幾行代碼可能也沒什麼大不了的。
並且,在創建一個有指針字段的結構體時,指針字段的賦值也會相對麻煩一些:
type Person struct {
ID int
Name string
Age *int
}
func Foo() {
p := Person{
ID: 1,
Name: "Bruce",
Age: new(int),
}
*p.Age = 20
// ...
}
有人説了,那是不是建議 JSON 數據結構體的成員變量都儘量不要使用指針呢?這次我倒是不這麼建議,因為指針確實有比非指針成員更適合的場景。一個是指針能夠減少一些開銷,另外就是下一節要講的,零值相關的問題。
2.6 零值造成的混淆
所謂零值,是 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。此時就產生了誤解:我們無法將這種數據缺失的對象,與子女數確實為 0 的對象區分開。如例子裏的 Bruce 和 Jim,一個是數據缺失導致的子女數為 0,另一個是本來就為 0。而實際上 Bruce 的子女數量應該是“未知“,我們如果真當作 0 處理,在業務上可能就會產生問題。
這樣的混淆在一些對數據要求嚴格的場景下是非常致命的。那麼有沒有什麼辦法能避免這種零值的干擾?還真有,就是上一節最後遺留的指針的使用場景。
我們把 Person 的 ChildrenCnt 類型改為 *int ,看看會發生什麼:
type Person struct {
Name string
ChildrenCnt *int
}
// Output
{Name:Bruce ChildrenCnt:<nil>}
{Name:Jim ChildrenCnt:0xc0000124c8}
區別產生了。Bruce 沒有數據,所以 ChildrenCnt 是個 nil,而 Jim 則是一個非空指針。此時就能明確地知曉,Bruce 的子女數量是未知了。
本質上這種方式還是利用了零值,指針的零值。這也算是用魔法打敗魔法吧(大笑)。
2.7 標籤的坑
終於講到了標籤。標籤也是 Golang 中一個非常重要的特性,並且常與 JSON 相伴。而且其實用過 Go 標籤的讀者們應該知道,標籤其實是一個非常靈活、好用的東西。那這樣的好特性,在使用上會有什麼坑要注意呢?
一個是名稱問題。Tag 可以指定 JSON 數據中字段的名稱顯示,這點很靈活且實用,但它同時也容易出錯,並且一定程度上對程序員本身增加了一些職業素養的要求。
譬如某個程序員有意或無意地定義了這麼一個結構體:
type PersonWrong struct {
FirstName string `json:"last_name"`
LastName string `json:"first_name"`
}
Tag 對調了 FirstName 和 LastName。遇到這樣的代碼你會不會想把這個程序員打一頓?別説,我還真在生產環境的代碼中遇到過類似的。當然那次是無意的,屬於某次代碼變更時的失誤。然而真遇到這種情況的時候,這樣的 bug 通常也不太容易定位。主要是因為,這誰特麼能想到?
反正各位讀者千萬別這麼幹,寫的時候還是得多加留意。
另一個問題則與 omitempty + 零值的組合有關,看代碼:
type Person struct {
Name string `json:"person_name"`
ChildrenCnt int `json:"cnt,omitempty"`
}
func TagMarshal() {
p := Person{
Name: "Bruce",
ChildrenCnt: 0,
}
output, _ := json.MarshalIndent(p, "", " ")
println(string(output))
}
// Output
{
"person_name": "Bruce"
}
看出問題了麼?我們在新建結構體對象 p 時,為 ChildrenCnt 賦值為 0。而因為 omitempty 標籤的存在,它使得 JSON 被序列化或反序列化時,忽略空(empty)值。在序列化時的表現就是,輸出的 JSON 數據裏不包含 ChildrenCnt,看上去就像是沒有這個數據。什麼是空值?對了,就是零值。
於是熟悉的混淆又產生了:Bruce 的子女數量為 0,並非沒有數據。而輸出的 JSON 則表示 Bruce 的子女數據不存在。
反序列化存在同樣的問題,就不舉例了。
這種 omitempty 的問題又該怎麼解決呢?由於本質上還是零值惹得禍,所以,用指針。
3 總結
本文列舉了 7 個使用 encoding/json 庫時容易犯的錯,這些問題我自己在工作中基本上都遇到過。如果你還沒有遭遇過它們,那麼恭喜你!同時也提醒你今後要小心對待 JSON;如果你也遇到過這些問題,並且為其感到困惑,希望這篇文章能夠幫助到你。
本人技術有限,文章若有任何錯誤或不清晰的地方,還請各位不吝之處,感謝!