encoding/json
REST API将JSON奉为服务之通信的标准方式,Go 的标准库内置对Go 数据类型与 JSON 之间进行转换的支持。marshaling一词表示从 Go 数据类型转为另一种编码,而unmarshaling表示转换为 Go 数据类型。
使用结构体标签添加元数据
假设我们正在构建一个订单管理系统,并且需要读取和写入以下 JSON:
{ "id":"12345", "date_ordered":"2020-05-01T13:01:02Z", "customer_id":"3", "items":[{"id":"xyz123","name":"Thing 1"},{"id":"abc789","name":"Thing 2"}] }
我们定义映射该数据的类型:
type Order struct { ID string `json:"id"` DateOrdered time.Time `json:"date_ordered"` CustomerID string `json:"customer_id"` Items []Item `json:"items"` } type Item struct { ID string `json:"id"` Name string `json:"name"` }
我们使用结构体标签来指定处理JSON数据的规则,也即结构体内字段后面的字符串。尽管结构体标签是用反引号标记的字符串,但它们要放在同一行。结构体标签由一个或多个标签/值对组成,写作tagName:"tagValue"
,并用空格分隔。由于它们只是字符串,编译器无法验证其格式是否正确,但go vet
可以进行验证。此外,请注意这些字段都是导出的。与其他包一样,encoding/json
包中的代码无法访问另一个包中结构体的未导出字段。
对于JSON的处理,我们使用标签名json
来指定与结构体字段关联的JSON字段的名称。如果没有提供json
标签,那么默认行为是假定JSON 对象字段的名称与 Go 结构体字段的名称相匹配。尽管有这种默认行为,即使字段名称相同,最好也使用结构体标签显式指定字段的名称。
注:在将JSON反序列化到没有
json
标签的结构体字段时,名称匹配是不区分大小写的。在没有json
标签的结构体字段序列化为JSON 时,JSON 字段的首字母始终是大写的,因为该字段是导出的。
如果在序列化或反序列化时需忽略某个字段,对该字段的名称使用破折号(-
)。如果该字段在为空时应在输出中省云,可以在名称后添加,omitempty
。
警告:“空”定义与零值不完全对齐,可能读者也猜到了。结构体的零值不作为空,但是零长切片或字典则视为空。
结构体标签允许我们使用元数据来控制程序的行为。其他语言,尤其是 Java,鼓励开发人员在各种程序元素上放置注解,来描述应该如何处理它们,而并不明确指定进行处理的方式。虽然声明式编程可以使程序更加简洁,但元数据的自动处理会让程序的行为变得难以理解。任何使用过带有注解的大型 Java 项目的人都会在出现问题时陷入恐慌,因为他们不知道哪段代码正在处理特定的注解以及它做出了什么变化。Go 更偏向于显式的代码而不是短小的代码。结构体标签永远不会自动运行;它们在将结构体实例传递给函数时进行处理。
序列化和反序列化
encoding/json
包中的Unmarshal
函数用于将字节切片转换为结构体。如果我们有一个名为data
的字符串,以下是将data
转换为Order
类型结构体的代码:
var o Order err := json.Unmarshal([]byte(data), &o) if err != nil { return err }
json.Unmarshal
函数将数据填充到一个入参中,就像io.Reader
接口的实现一样。这样做有两个原因。首先,像io.Reader
的实现一样,这样可对相同的结构体进行高效的重用,从而控制内存使用。其次,没有其它实现的方式。因为Go长时间没有泛型,所以无法指定应该实例化哪种类型来存储正在读取的字节。即使Go添加了泛型,内存使用的优势也依旧存在。
我们使用encoding/json
包中的Marshal
函数将Order
实例以 JSON 的形式写回,并存储在一个字节切片中:
out, err := json.Marshal(o)
这带来了一个问题:我们是如何处理结构标签的?你可能还想知道为什么 json.Marshal
和json.Unmarshal
能够读取和写入任意类型的结构体。毕竟,我们编写的其他方法都只能处理在程序编译时已知的类型(甚至类型开关中列出的类型也是预先枚举的)。这两个问题的答案都是反射。可以在恶龙三剑客:反射、Unsafe 和 Cgo中了解更多关于反射的内容。
JSON、Reader和Writer
json.Marshal
和json.Unmarshal
函数处理的是字节切片。刚刚也看到了,Go 中的大部分数据源和数据宿都实现了io.Reader
和io.Writer
接口。虽然可以使用ioutil.ReadAll
将io.Reader
的全部内容复制到字节切片中,以供json.Unmarshal
读取,但这样做效率低下。同样,我们可以使用json.Marshal
将数据写入内存中的字节切片缓冲区,然后将其写入网络或磁盘,但如果我们可以直接写入io.Writer
,会更好。
encoding/json
包有两种类型供我们处理这些场景。json.Decoder
和json.Encoder
类型分别从实现了io.Reader
和io.Writer
接口的任意内容进行读取和写入。让我们快速看一下它们是如何工作的。
我们从一个实现简单结构体的toFile
中的数据开始:
type Person struct { Name string `json:"name"` Age int `json:"age"` } toFile := Person { Name: "Fred", Age: 40, }
os.File
类型同时实现了io.Reader
和io.Writer
接口,我们可以使用它来演示json.Decoder
和json.Encoder
。首先,我们将toFile
写入一个临时文件,将临时文件传递给json.NewEncoder
,它返回该临时文件的json.Encoder
。然后,我们将json.Encoder
传递给Encode
方法:
tmpFile, err := ioutil.TempFile(os.TempDir(), "sample-") if err != nil { panic(err) } defer os.Remove(tmpFile.Name()) err = json.NewEncoder(tmpFile).Encode(toFile) if err != nil { panic(err) } err = tmpFile.Close() if err != nil { panic(err) }
写入toFile
后,我们可以通过将临时文件的指针传递给json.NewDecoder
,并在返回的json.Decoder
上调用Decode
方法,将其读取为 JSON,并使用类型为Person
的变量来接收:
tmpFile2, err := os.Open(tmpFile.Name()) if err != nil { panic(err) } var fromFile Person err = json.NewDecoder(tmpFile2).Decode(&fromFile) if err != nil { panic(err) } err = tmpFile2.Close() if err != nil { panic(err) } fmt.Printf("%+v\n", fromFile)
完整示例请见Playground。
JSON数据流编解码
在需要一次读取或写入多个JSON结构体时该怎么办做呢?可以使用我们的老朋友json.Decoder
和json.Encoder
处理这些情况。
假设有以下数据:
{"name": "Fred", "age": 40} {"name": "Mary", "age": 21} {"name": "Pat", "age": 30}
对于我们的示例,假设数据存储在一个名为data
的字符串中,但它也可以是文件,甚至是传入的HTTP请求(我们稍后会了解HTTP服务端的原理)。
我们将该数据存在到变量t
中,每次一个JSON 对象。
和之前一样,我们使用数据源初始化json.Decoder
,但这次我们使用json.Decoder
的More
方法作为for
循环条件。这样可以逐个读取数据,每次一个JSON 对象:
var t struct { Name string `json:"name"` Age int `json:"age"` } dec := json.NewDecoder(strings.NewReader(data)) for dec.More() { err := dec.Decode(&t) if err != nil { panic(err) } // process t }
使用json.Encoder
写多个值的方式与写单个值的方式相同。本例中,我们写入bytes.Buffer
,但任意实现io.Writer
接口的类型都可以:
var b bytes.Buffer enc := json.NewEncoder(&b) for _, input := range allInputs { t := process(input) err = enc.Encode(t) if err != nil { panic(err) } } out := b.String()
可在Playground中运行本示例。
我们示例数据流中有多个没有封装到数组中的JSON 对象,但读者也可以使用json.Decoder
从数组中读取单个对象,而无需一次性将整个数组加载到内存中。这可以大幅提升性能并减少内存使用。在Go文档中有一个示例。
自定义JSON 解析
虽然默认功能通常已足够使用,但有时需要进行重载。尽管time.Time
默认支持 RFC 339 格式的 JSON 字段,但可能需要处理其他时间格式。我们可以通过创建一个实现json.Marshaler
和json.Unmarshaler
两个接口的新类型来进行处理:
type RFC822ZTime struct { time.Time } func (rt RFC822ZTime) MarshalJSON() ([]byte, error) { out := rt.Time.Format(time.RFC822Z) return []byte(`"` + out + `"`), nil } func (rt *RFC822ZTime) UnmarshalJSON(b []byte) error { if string(b) == "null" { return nil } t, err := time.Parse(`"`+time.RFC822Z+`"`, string(b)) if err != nil { return err } *rt = RFC822ZTime{t} return nil }
我们将一个time.Time
实例内嵌到名为RFC822ZTime
的新结构体中,这样仍可以访问time.Time
的其他方法。就像我们在指针接收器和值接收器中讨论的那样,读取时间值的方法对值接收器声明,而修改时间值的方法对指针接收器声明。
然后,我们更改了DateOrdered
字段的类型,可使用 RFC 822 格式的时间进行操作:
type Order struct { ID string `json:"id"` DateOrdered RFC822ZTime `json:"date_ordered"` CustomerID string `json:"customer_id"` Items []Item `json:"items"` }
可在Playground中运行这段代码。
这种方法存在一个缺点:JSON的日期格式决定了数据结构中字段的类型。这是encoding/json
方案本身的不足。可以让Order
实现json.Marshaler
和json.Unmarshaler
,但那会要求你编写代码处理所有字段,包括那些不需要自定义支持的字段。结构体标签格式没有提供指定函数来解决具体字段的方式。这样我们就得为该字段创建一个自定义类型了。
另一种方式在Ukiah Smith的博客文章中进行了描述。我们可以只重新定义不符合默认序列化行为的字段,利用到结构体内嵌所做的 JSON 序列化和反序列化(我们在使用内嵌实现组合中进行了讲解)。如果嵌套结构体的字段名与外层结构体中的相重复,在序列化和反序列化时就会忽略该字段。
本例中,Order
中的字段如下:
type Order struct { ID string `json:"id"` Items []Item `json:"items"` DateOrdered time.Time `json:"date_ordered"` CustomerID string `json:"customer_id"` }
MarshalJSON
方法如下:
func (o Order) MarshalJSON() ([]byte, error) { type Dup Order tmp := struct { DateOrdered string `json:"date_ordered"` Dup }{ Dup: (Dup)(o), } tmp.DateOrdered = o.DateOrdered.Format(time.RFC822Z) b, err := json.Marshal(tmp) return b, err }
在Order
的MarshalJSON
方法中,我们定义了底层类型为Order
的Dup
类型。创建Dup
的原因是基于其它类型的类型具有和底层类型相同的字段,但方法却不同。如果没有Dup
,在调用json.Marshal
时就会进入到对MarshalJSON
的无限调用循环,最终导致栈溢出。
我们定义了一个包含DateOrdered
字段并内嵌Dup
的匿名结构体。然后将Order
实例赋给tmp
中的内嵌字段,将tmp
中的DateOrdered
字段赋值为时间格式RFC822Z,对tmp
调用json.Marshal
。这会生成所需的JSON输出。
UnmarshalJSON
中的逻辑类似:
func (o *Order) UnmarshalJSON(b []byte) error { type Dup Order tmp := struct { DateOrdered string `json:"date_ordered"` *Dup }{ Dup: (*Dup)(o), } err := json.Unmarshal(b, &tmp) if err != nil { return err } o.DateOrdered, err = time.Parse(time.RFC822Z, tmp.DateOrdered) if err != nil { return err } return nil }
在UnmarshalJSON
中,json.Unmarshal
调用o
中字段(DateOrdered
除外),因为它嵌套在tmp
之中。解封后通过使用time.Parse
处理tmp
中的DateOrdered
字段来反序列化o
中的DateOrdered
。
可在The Go Playground 中运行这段代码。
虽然这样可以让Order
中的一个字段不必绑定JSON格式,但Order
的MarshalJSON
和UnmarshalJSON
方法就与JSON中时间字段的格式发生了耦合。我们无法复用Order
去支持其它时间格式的JSON。
为限制考虑JSON处理方式的代码量,定义两个不同的结构体。一个用于和JSON之间的转换,另一个用于数据数据。将JSON读入适配JSON的类型,然后拷贝至另一个类型。在写入JSON时,执行相反操作。这确实会导致一定的重复,但这会保持业务逻辑不依赖于连接协议。
可以将map[string]any
传递给json.Marshal
和json.Unmarshal
来在JSON和Go之间进行转换,但把它用于代码的解释阶段,在清楚如何进行处理后替换为具体的类型。Go使用类型是有原因的,它表明了预期的数据及其类型。
虽然JSON是标准库中最常用的的编码器,Go还内置了其它编码器,如XML和Base64。如果你需要编码的数据格式无法在标准库或第三方库中找到支持,可以自行编写。我们会在使用反射编写数据序列化工具中学习到如何实现自己的编码器。
警告:标准库中内置了encoding/gob
,这是针对Go的二进制表现,有点类似于Java的序列化。正如Java的序列化是Enterprise Java Beans和Java RMI的连接协议一样,gob协议用于net/rpc
包中实现的Go RPC(远程过程调用)的连接格式。不要使用encoding/gob
或net/rpc
。如果你希望通过Go做远程方法调用,使用GRPC等标准协议,这样就限于某一种语言。不管你有多爱Go语言,如果希望服务有价值的话,就允许开发者使用其它语言调用它。
本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。