JSON包新提案:用“omitzero”解決編碼中的空值困局
Go標(biāo)準(zhǔn)庫是Go號稱“開箱即用”的重要因素,而標(biāo)準(zhǔn)庫中的encoding/json包又是標(biāo)準(zhǔn)庫最常用的Go包。雖然其性能不是最好的,但好在由Go團(tuán)隊維護(hù),對JSON規(guī)范兼容性好,且質(zhì)量很高。但json包也不是沒有“瑕疵”的,Go官方繼math/rand/v2[1]之后,也開啟了encoding/json/v2的討論[2],v2包含了對功能的增強(qiáng),其中就包含了對空值編碼的改進(jìn)的考量,以及性能方面的優(yōu)化。但json/v2畢竟還屬于“長遠(yuǎn)”規(guī)劃,當(dāng)前版本的json包的問題也要修正和完善。
一個提出于2021年的issue[3]近期被即將“功成身退”的Russ Cox[4],該issue就當(dāng)前json包對空值編碼的“瑕疵”做了描述并提出了修正方案。本文就將針對這一問題以及其方案進(jìn)行探討,希望能幫助大家更好地理解該issue以及其對應(yīng)的方案。
1. 問題溯源:omitempty的局限性
在encoding/json包中,omitempty標(biāo)簽是開發(fā)者控制JSON序列化行為的重要工具。它的設(shè)計初衷是允許開發(fā)者指定:當(dāng)某個字段值為“空”時,在JSON編碼過程中應(yīng)該被忽略。然而,omitempty的空值定義存在一些固有的局限性。下面是json包中對omitempty的說明:
The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any empty array, slice, map, or string.
總結(jié)一下,omitempty標(biāo)簽的判斷邏輯如下:
- 對于布爾類型:false被視為空
- 對于數(shù)值類型:0被視為空
- 對于字符串:""(空字符串)被視為空
- 對于指針、接口:nil被視為空
- 對于數(shù)組、切片、map:長度為0被視為空
下面是一個完整的Go示例,展示了omitempty標(biāo)簽在不同類型上的應(yīng)用:
package main
import (
"encoding/json"
"fmt"
)
type Example struct {
BoolField bool `json:"bool_field,omitempty"`
IntField int `json:"int_field,omitempty"`
StringField string `json:"string_field,omitempty"`
PointerField *string `json:"pointer_field,omitempty"`
InterfaceField interface{} `json:"interface_field,omitempty"`
ArrayField [0]int `json:"array_field,omitempty"` // 空數(shù)組
SliceField []string `json:"slice_field,omitempty"` // 空切片
MapField map[string]int `json:"map_field,omitempty"` // 空地圖
}
func main() {
var nilString *string = nil
example := Example{
BoolField: false, // 布爾類型
IntField: 0, // 數(shù)值類型
StringField: "", // 空字符串
PointerField: nilString, // nil 指針
InterfaceField: nil, // nil 接口
ArrayField: [0]int{}, // 空數(shù)組
SliceField: []string{}, // 空切片
MapField: map[string]int{}, // 空地圖
}
jsonData, err := json.Marshal(example)
if err != nil {
fmt.Println("Error marshalling example:", err)
}
fmt.Println(string(jsonData)) // 輸出:{}
}
然而,這種預(yù)定義的"空"值判斷邏輯并不能滿足所有實際場景的需求。讓我們來看幾個具體的例子:
- 空結(jié)構(gòu)體問題
package main
import (
"encoding/json"
"fmt"
)
type Config struct {
EmptyStruct struct{} `json:",omitempty"`
}
func main() {
cfg := Config{}
data, _ := json.Marshal(cfg)
fmt.Println(string(data)) // 輸出:{"EmptyStruct":{}}
}
我們看到:在這個例子中,盡管Config中的EmptyStruct字段是一個空結(jié)構(gòu)體類型,且添加了omitempty標(biāo)簽,但它仍然出現(xiàn)在JSON輸出中。
- 零值結(jié)構(gòu)體
除了空結(jié)構(gòu)體,零值結(jié)構(gòu)體也是目前omitempty標(biāo)簽語義覆蓋不到的類型:
package main
import (
"encoding/json"
"fmt"
)
type ZeroStruct struct {
A int
B string
C float64
}
type Config struct {
ZeroStruct ZeroStruct `json:",omitempty"`
}
func main() {
cfg := Config{}
data, _ := json.Marshal(cfg)
fmt.Println(string(data)) // 輸出:{"ZeroStruct":{"A":0,"B":"","C":0}}
}
我們看到:即便ZeroStruct中各個類型的值都為零,且有了omitempty標(biāo)簽,json.Marshal依然輸出了Config中的ZeroStruct字段。
- time.Time類型的處理
在開發(fā)實踐中,我們發(fā)現(xiàn)json對time.Time類型在omitempty下的處理也與“常理”不符,比如下面這個示例:
package main
import (
"encoding/json"
"fmt"
"time"
)
type Event struct {
Time time.Time `json:",omitempty"`
}
func main() {
evt := Event{Time: time.Time{}} // 零值時間
data, _ := json.Marshal(evt)
fmt.Println(string(data)) // 輸出:{"Time":"0001-01-01T00:00:00Z"}
}
我們看到:time.Time類型的零值依然被輸出了。并且輸出的是公元1年1月1日UTC時間。對于很多應(yīng)用來說,這個時間并不具有實際意義,更合理的零值是"January 01, 1970 00:00:00 UTC"。
很顯然,Gopher們希望json包能更好的處理上述情形。
2. 社區(qū)討論與omitzero標(biāo)簽方案的確認(rèn)
關(guān)于上述問題的解決方法,在Go社區(qū)引發(fā)了廣泛討論。不過大家普遍認(rèn)為不要改變現(xiàn)有omitempty語義,那樣會導(dǎo)致破壞性的change,無法向后兼容。
在討論過程中,社區(qū)成員提出了一些其他的解決方案:
- 允許MarshalJSON()方法返回nil來完全忽略某個字段
這個方案的優(yōu)點是利用了已有的接口,不需要引入新的標(biāo)簽。但缺點是需要為每個支持零值的類型都實現(xiàn)MarshalJSON()方法。
- 添加OmitJSONField方法
這個方案提議為每個類型添加一個OmitJSONField() bool方法,由開發(fā)者自己控制字段的忽略邏輯,該方案提供了很大的靈活性,但可能會導(dǎo)致JSON序列化邏輯過于分散。
最終,"omitzero"方案最終被認(rèn)為是一個相對平衡的解決方案,因為它可以與現(xiàn)有的標(biāo)簽系統(tǒng)兼容,開發(fā)者可以很容易地將omitempty替換為omitzero,或者在需要的地方同時使用兩者。此外,omitzero也保持了簡潔性,相比其他需要大量代碼修改的方案,omitzero只需要添加標(biāo)簽或?qū)崿F(xiàn)一個方法(可選項)即可。
"omitzero"標(biāo)簽提案的核心內(nèi)容是:在序列化時,"omitzero"選項指定如果字段值為零,則該結(jié)構(gòu)體字段應(yīng)被省略。如果該類型定義了IsZero bool方法,那么這個零值就通過IsZero方法來判斷;否則是根據(jù)字段是否是零值(通過reflect.Value.IsZero判斷)來判斷。該omitzero選項在反序列化(unmarshal)時沒有效果。如果同時指定了"omitempty"和"omitzero",則字段是否被省略基于兩者的邏輯或關(guān)系。 這將意味著,在省略切片時,omitzero會省略空指針切片,但對于長度為零的非空切片,則不會。對于time.Time類型,會省略time.Time{}。
此外,omitzero不強(qiáng)制你實現(xiàn)IsZero方法,但開發(fā)者可以利用IsZero方法來自由控制自定義類型在omitzero標(biāo)簽下是否會被省略。
一旦有了omitzero,我們就可以用它解決上面提到的問題(omitzero尚未實現(xiàn),下面是偽代碼):
- 解決空結(jié)構(gòu)體問題
type Config struct {
EmptyStruct struct{} `json:",omitzero"`
}
cfg := Config{}
data, _ := json.Marshal(cfg)
fmt.Println(string(data)) // 輸出:{}
- 更好地處理time.Time類型
type Event struct {
Time time.Time `json:",omitzero"`
}
evt := Event{Time: time.Time{}} // 零值時間
data, _ := json.Marshal(evt)
fmt.Println(string(data)) // 輸出:{}
- 自定義類型的"零值"判斷
type CustomInt int
func (ci CustomInt) IsZero() bool {
return ci <= 0 // 自定義零值判斷邏輯
}
type Data struct {
Value CustomInt `json:",omitzero"`
}
d := Data{Value: CustomInt(-1)}
data, _ := json.Marshal(d)
fmt.Println(string(data)) // 輸出:{}
3. 小結(jié)
通過引入"omitzero"標(biāo)簽,Go語言在解決JSON編碼中"空"值處理的痛點上邁出了重要一步。這個方案不僅滿足了開發(fā)者對更靈活的"空"值定義的需求,還保持了與現(xiàn)有系統(tǒng)的兼容性。目前該omitzero的落地時間尚未確定,最早也要等到Go 1.24版本。此外,encoding/xml等也會效仿json包,增加omitzero標(biāo)簽。
此外,伴隨著omitzero提案被接受,另外一個在2021年由Josh Bleecher Snyder提出的相關(guān)提案:proposal: cmd/vet: warn about structs marked json omitempty[5]也被重新“喚醒”,針對該提案,目前社區(qū)在active discussions。
隨著后續(xù)encoding/json/v2的到來,我們可以期待Go語言在數(shù)據(jù)序列化領(lǐng)域會有更出色的表現(xiàn)。這不僅將提升json編解碼效率,還將為構(gòu)建更加健壯和靈活的基于json的Go應(yīng)用程序鋪平了道路。