云原生系列Go语言篇-标准库Part 2

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
可观测可视化 Grafana 版,10个用户账号 1个月
简介: REST API将JSON奉为服务之通信的标准方式,Go 的标准库内置对Go 数据类型与 JSON 之间进行转换的支持。marshaling一词表示从 Go 数据类型转为另一种编码,而unmarshaling表示转换为 Go 数据类型。

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.Marshaljson.Unmarshal能够读取和写入任意类型的结构体。毕竟,我们编写的其他方法都只能处理在程序编译时已知的类型(甚至类型开关中列出的类型也是预先枚举的)。这两个问题的答案都是反射。可以在恶龙三剑客:反射、Unsafe 和 Cgo中了解更多关于反射的内容。

JSON、Reader和Writer

json.Marshaljson.Unmarshal函数处理的是字节切片。刚刚也看到了,Go 中的大部分数据源和数据宿都实现了io.Readerio.Writer接口。虽然可以使用ioutil.ReadAllio.Reader的全部内容复制到字节切片中,以供json.Unmarshal 读取,但这样做效率低下。同样,我们可以使用json.Marshal将数据写入内存中的字节切片缓冲区,然后将其写入网络或磁盘,但如果我们可以直接写入io.Writer,会更好。

encoding/json包有两种类型供我们处理这些场景。json.Decoderjson.Encoder类型分别从实现了io.Readerio.Writer接口的任意内容进行读取和写入。让我们快速看一下它们是如何工作的。

我们从一个实现简单结构体的toFile中的数据开始:

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
toFile := Person {
    Name: "Fred",
    Age:  40,
}

os.File类型同时实现了io.Readerio.Writer接口,我们可以使用它来演示json.Decoderjson.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.Decoderjson.Encoder处理这些情况。

假设有以下数据:

{"name": "Fred", "age": 40}
{"name": "Mary", "age": 21}
{"name": "Pat", "age": 30}

对于我们的示例,假设数据存储在一个名为data的字符串中,但它也可以是文件,甚至是传入的HTTP请求(我们稍后会了解HTTP服务端的原理)。

我们将该数据存在到变量t中,每次一个JSON 对象。

和之前一样,我们使用数据源初始化json.Decoder,但这次我们使用json.DecoderMore方法作为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.Marshalerjson.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.Marshalerjson.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
}

OrderMarshalJSON方法中,我们定义了底层类型为OrderDup类型。创建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格式,但OrderMarshalJSONUnmarshalJSON方法就与JSON中时间字段的格式发生了耦合。我们无法复用Order去支持其它时间格式的JSON。

为限制考虑JSON处理方式的代码量,定义两个不同的结构体。一个用于和JSON之间的转换,另一个用于数据数据。将JSON读入适配JSON的类型,然后拷贝至另一个类型。在写入JSON时,执行相反操作。这确实会导致一定的重复,但这会保持业务逻辑不依赖于连接协议。

可以将map[string]any传递给json.Marshaljson.Unmarshal来在JSON和Go之间进行转换,但把它用于代码的解释阶段,在清楚如何进行处理后替换为具体的类型。Go使用类型是有原因的,它表明了预期的数据及其类型。

虽然JSON是标准库中最常用的的编码器,Go还内置了其它编码器,如XML和Base64。如果你需要编码的数据格式无法在标准库或第三方库中找到支持,可以自行编写。我们会在使用反射编写数据序列化工具中学习到如何实现自己的编码器。

警告:标准库中内置了encoding/gob,这是针对Go的二进制表现,有点类似于Java的序列化。正如Java的序列化是Enterprise Java Beans和Java RMI的连接协议一样,gob协议用于net/rpc包中实现的Go RPC(远程过程调用)的连接格式。不要使用encoding/gobnet/rpc。如果你希望通过Go做远程方法调用,使用GRPC等标准协议,这样就限于某一种语言。不管你有多爱Go语言,如果希望服务有价值的话,就允许开发者使用其它语言调用它。

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

相关文章
|
3天前
|
安全 测试技术 Go
Go语言在高并发场景下的应用
在当今互联网高速发展的时代,高并发已成为众多应用系统面临的核心问题。本文探讨了Go语言在高并发场景下的优势,并通过具体实例展示了其在实际应用中的效果和性能表现。
|
2天前
|
存储 中间件 Go
在go语言服务中封装路由和示例
【6月更文挑战第23天】本文介绍golang后端按协议处理、中间件(一次性与每次请求执行)划分、以及服务架构Controller、Logic/Service、DAO/Repository和Routers划分。代码仓库在GitHub上提供。使用框架简化了交互和处理。后续章节深入探讨服务构建。
103 5
在go语言服务中封装路由和示例
|
3天前
|
Unix Go 开发者
探索Go语言并发模型:原理与实践
本文深入探讨了Go语言的并发模型,包括其设计原理、Goroutine和Channel的基本使用,以及如何在实际项目中高效地应用这些概念来提高程序的性能和可维护性。
|
4天前
|
Go
Go 语言是如何实现切片扩容
Go 语言是如何实现切片扩容
|
5天前
|
NoSQL Go Redis
如何使用 Go 和 `go-redis/redis` 库连接到 Redis 并执行一些基本操作
如何使用 Go 和 `go-redis/redis` 库连接到 Redis 并执行一些基本操作
8 1
|
5天前
|
存储 Go
Go 语言当中 CHANNEL 缓冲
Go 语言当中 CHANNEL 缓冲
|
6天前
|
中间件 Go
go语言后端开发学习(三)——基于validator包实现接口校验
go语言后端开发学习(三)——基于validator包实现接口校验
|
6天前
|
存储 Go 开发工具
go语言后端开发学习(二)——基于七牛云实现的资源上传模块
go语言后端开发学习(二)——基于七牛云实现的资源上传模块
|
6天前
|
JSON 算法 Go
go语言后端开发学习(一)——JWT的介绍以及基于JWT实现登录验证
go语言后端开发学习(一)——JWT的介绍以及基于JWT实现登录验证
|
6天前
|
Go 数据库
Go语言之GORM框架(四)——预加载,关联标签与多态关联,自定义数据类型与事务(完结篇)
Go语言之GORM框架(四)——预加载,关联标签与多态关联,自定义数据类型与事务(完结篇)