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

使用Go进行开发的最大优势之一是其标准库。与Python类似,Go也采取了"内置电池"的理念,提供了构建应用程序所需的许多工具。由于Go是一种相对较新的语言,它附带了一个专注于现代编程环境中遇到的问题的库。

我们无法涵盖所有标准库包,所幸也不需要,因为有许多优秀的信息源可以了解标准库,比如​​官方文档​​。我们将重点关注几个最重要的包及其设计和用法来演示地道Go语言的基本原则。一些包(​​errors​​、​​sync​​、​​context​​、​​testing​​、​​reflect​​和​​unsafe​​)在各自的章节中进行过介绍。在本章中,我们将学习Go对I/O、时间、JSON和HTTP的内置支持。

I/O和它的小伙伴们

要使程序有价值,它需要能读取和写出数据。Go的输入/输出理念的核心在​​io​​包中有体现。特别是,在该包中定义的两个接口可能是Go中第二和第三最常用的接口:​​io.Reader​​和​​io.Writer​​。

注:第一名是谁呢?自然是​​error​​,我们已经在​​错误处理​​一章中学习过了。

​io.Reader​​和​​io.Writer​​各自定义了一个方法:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

​io.Writer​​接口中的​​Write​​方法接收一个字节切片参数,位于接口的实现中。它返回写入的字节数,如果出现错误则返回错误信息。​​io.Reader​​中的​​Read​​方法更有趣。它不是通过返回参数来返回数据,而是将一个切片作为入参传入实现,并进行修改。最多会将​​len(p)​​个字节写入到该切片中。该方法返回写入的字节数。这可能看起来有点奇怪。读者期望的可能是:

type NotHowReaderIsDefined interface {
    Read() (p []byte, err error)
}

标准库中定义​​io.Reader​​的方式是有原因的。我们来编写一个函数说明如何使用​​io.Reader​​方便大家理解:

func countLetters(r io.Reader) (map[string]int, error) {
    buf := make([]byte, 2048)
    out := map[string]int{}
    for {
        n, err := r.Read(buf)
        for _, b := range buf[:n] {
            if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
                out[string(b)]++
            }
        }
        if err == io.EOF {
            return out, nil
        }
        if err != nil {
            return nil, err
        }
    }
}

有三点需要注意。首先,我们只需创建一次缓冲区,在每次调用​​r.Read​​.时复用它即可。这样我们能够使用单次内存分配读取可能很大的数据源。如果​​Read​​方法返回​​[]byte​​,那么每次调用都需要重新分配内存。每次分配最终都会出现在堆上,这会给垃圾回收器带来很大的工作量。

如果我们想进一步减少分配,可以在程序启动时创建一个缓冲池。然后在函数开始处从池中获取一个缓冲区,结束时归还。通过将切片传递给​​io.Reader​​,内存分配就由开发人员所控制。

其次,我们使用​​r.Read​​返回的​​n​​值来了解有多少字节被写入缓冲区,并遍历​​buf​​切片的子切片,处理所读取的数据。

最后,在​​r.Read​​返回的错误是​​io.EOF​​时,对​​r​​的读取就结束了。这个错误有点奇怪,因为它实际上并不是一个错误。它表示​​io.Reader​​中没有剩余可读取的内容。在返回​​io.EOF​​时,我们结束处理并返回结果。

​io.Reader​​的​​Read​​方法有一个特别之处。在大多数情况下,在函数或方法具有错误返回值时,我们在尝试处理非错误返回值之前先检查错误。但在​​Read​​的情况中情况相反,因为在数据流结束或意外情况触发错误之前可能已经返回了一些字节,所以操作相反。

注:如果意外到达了​​io.Reader​​的末尾,会返回一个另一个哨兵错误(​​io.ErrUnexpectedEOF​​)。注意它以字符串​​Err​​开头,表示这是一种意料外的状态。

因为​​io.Reader​​和​​io.Writer​​接口非常简单,可以用多种方式进行实现。我们可以使用​​strings.NewReade​​函数通过字符串创建一个​​io.Reader​​:

s := "The quick brown fox jumped over the lazy dog"
sr := strings.NewReader(s)
counts, err := countLetters(sr)
if err != nil {
    return err
}
fmt.Println(counts)

我们在​​接口是类型安全的鸭子类型​​中讨论过,​​io.Reader​​ 和​​io.Writer​​的实现通常以装饰器模式链接。由于​​countLetters​​依赖于​​io.Reader​​,我们可以使用完全相同的​​countLetters​​函数来计算gzip压缩文件中的英文字母。首先编写一个函数,给定文件名时,返回​​*gzip.Reader​​:

func buildGZipReader(fileName string) (*gzip.Reader, func(), error) {
    r, err := os.Open(fileName)
    if err != nil {
        return nil, nil, err
    }
    gr, err := gzip.NewReader(r)
    if err != nil {
        return nil, nil, err
    }
    return gr, func() {
        gr.Close()
        r.Close()
    }, nil
}

这个函数演示了实现​​io.Reader​​合适的封装类型。我们创建了一个​​*os.File​​*(* 符合​​io.Reader​​接口*),* 在确保其为有效之后,将它传递给​​gzip.NewReader​​函数,该函数返回一个​​*gzip.Reader​​实例。如果有效,我们返回​​*gzip.Reader​​和一个关闭器闭包,当调用它时可以恰如其分地清理我们的资源。

因​​*gzip.Reader​​实现了​​io.Reader​​,我们可以像之前使用的​​*strings.Reader​​一样使其与​​countLetters​​一起使用:

r, closer, err := buildGZipReader("my_data.txt.gz")
if err != nil {
    return err
}
defer closer()
counts, err := countLetters(r)
if err != nil {
    return err
}
fmt.Println(counts)

因为我们有用于读取和写入的标准接口,在​​io​​包中有一个标准函数用于从​​io.Reader​​拷贝至​​io.Writer​​,即​​io.Copy​​。还有其他标准函数可为已有的​​io.Reader​​和​​io.Writer​​实例添加新功能。其中包括:

​io.MultiReader​​返回一个从多个​​io.Reader​​实例逐一读取的​​io.Reader​​。​​io.LimitReader​​返回一个仅从提供的​​io.Reader​​中读取指定字节数的​​io.Reader​​。​​io.MultiWriter​​返回一个同时向多个​​io.Writer​​实例写入的​​io.Writer​​。

其它标准库的包提供了各自的类型和函数,用于处理​​io.Reader​​和​​io.Writer​​。我们已学习过一些,但还有很多。有压缩算法、存档、加密、缓冲、字节切片和字符串。

在​​io​​中还定义了其他单个方法的接口,如​​io.Closer​​和​​io.Seeker​​:

type Closer interface {
        Close() error
}

type Seeker interface {
        Seek(offset int64, whence int) (int64, error)
}

​io.Closer​​接口由像​​os.File​​这样需要在读取或写入完成时进行清理的类型实现。通常,使用​​defer​​调用​​Close​​函数:

f, err := os.Open(fileName)
if err != nil {
    return nil, err
}
defer f.Close()
// use f

警告: 如果在循环中打开资源,请不要使用​​defer​​,因为它在函数退出时才会执行。应该在循环迭代结束之前调用​​Close​​方法。如果存在可能导致退出的错误,你也必须在该处调用​​Close​​方法。

​io.Seeker​​接口用于对资源进行随机访问。​​whence​​参数的有效值为​​io.SeekStart​​、​​io.SeekCurrent​​和​​io.SeekEnd​​这些常量。本应使用自定义类型来更清晰地表示,但出现了一个令人吃惊的设计失误,​​whence​​的类型是​​int​​。

​io​​包中定义了组合这四个接口各种组合。它们有​​io.ReadCloser​​、​​io.ReadSeeker​​、​​io.ReadWriteCloser​​、​​io.ReadWriteSeeker​​、​​io.ReadWriter​​、​​io.WriteCloser​​和​​io.WriteSeeker​​。使用这些接口来指定函数期望对数据的操作。例如,不单使用​​os.File​​作为参数,而是使用接口来明确指定函数如何处理参数。这不仅会使函数更通用,还会让开发者的意图更加清晰。此外,如果你正在编写自己的数据源和接收端,要保持代码与这些接口兼容。总体来说,尽量创建像​​io​​中定义的接口一样简单和解耦的接口。它们展示了简单抽象的强大。

​ioutil​​包提供了一些简单的实用工具,用于将整个​​io.Reader​​实现一次性读入字节切片,读取和写入文件以及处理临时文件等。​​ioutil.ReadAll​​、​​ioutil.ReadFile​​和​​ioutil.WriteFile​​函数可处理小型数据源,但对于大数据源最好使用​​bufio​​包中的​​Reader​​、​​Writer​​和​​Scanner​​来做处理。

​ioutil​​中更巧妙的一个函数演示了如何为Go类型添加方法的模式。如果一个类型实现了​​io.Reader​​但没有实现​​io.Closer​​的类型(比如​​strings.Reader​​),并且需要将其传递给接收​​io.ReadCloser​​的函数,可以将​​io.Reader​​传递给​​ioutil.NopCloser​​函数,会得到一个实现了​​io.ReadCloser​​的类型。其实现非常简单:

type nopCloser struct {
    io.Reader
}

func (nopCloser) Close() error { return nil }

func NopCloser(r io.Reader) io.ReadCloser {
    return nopCloser{r}
}

在需要为类型添加额外的方法实现接口时,可以使用这种嵌入类型模式。

注:​​ioutil.NopCloser​​函数违反了不从函数返回接口的一般规则,但它是一个用于确定不会改变的接口的简单适配器,因为它来自标准库。

time

和大部分编程语言一样,Go标准库包含对时间支持,位于​​time​​包中。有两种表示时间的主要类型,​​time.Duration​​和​​time.Time​​。

时间段由​​time.Duration​​表示,其类型为int64。Go可以表示的最小时间单位是一纳秒,但​​time​​包定义了​​time.Duration​​类型的常量来表示纳秒、微秒、毫秒、秒、分钟和小时。例如,可以用以下方式表示2小时30分钟的时长:

d := 2 * time.Hour + 30 * time.Minute // d is of type time.Duration

这些常量使得​​time.Duration​​既易读又类型安全。它们展示了对带类型常量很好的使用。

Go 定义了一个易理解的字符串格式,由一系列数字组成,可以用​​time.ParseDuration​​函数解析为​​time.Duration​​。如标准库文档所述:

时长字符串是有符号的十进制数序列,可带小数及后接单位,例如 "300ms"、"-1.5h" 或 "2h45m"。有效的时间单位包括 "ns"、"us"(或 "µs")、"ms"、"s"、"m"、"h"。

​time.Duration​​上定义了多个方法。它实现了​​fmt.Stringer​​接口,并通过 ​​String​​ 方法返回格式化的时长字符串。它有获取小时、分钟、秒、毫秒、微秒或纳秒等数值的方法。​​Truncate​​ 和 ​​Round​​ 方法将​​time.Duration​​截取或四舍五入为指定的​​time.Duration​​单位。

某个时间由​​time.Time​​类型表示,包含时区。可以使用 ​​time.Now​​函数获取当前时间。它返回一个本地时区的​​time.Time​​实例。

小贴士:​​time.Time​​实例包含时区信息,因此不应使用​​==​​来检查两个​​time.Time​​实例是否对应同一时刻。而应使用​​Equal​​方法,该方法会校正时区。

​time.Parse​​函数将字符串转换为​​time.Time​​,而​​Format​​方法将​​time.Time​​转换为字符串。尽管 Go 通常采用曾经运行良好的想法,但它使用​​自有的日期和时间格式化语言​​。将日期和时间格式化为 "2006年1月2日 下午3点04分05秒 MST(山区标准时间)" 来指定格式。

**注:**为什么选择这个日期?因为其中的每个部分依次代表了数字 1 到 7,即 01/02 03:04:05PM '06 -0700(MST是UTC的7 小时前)。

例如,以下代码:

t, err := time.Parse("2006-02-01 15:04:05 -0700", "2016-13-03 00:00:00 +0000")
if err != nil {
    return err
}
fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))

会打印出:

March 13, 2016 at 12:00:00AM UTC

虽然用于格式化的日期和时间进行了巧妙的辅助记忆的设计,但依然很难记住,每次用的时候都要查阅(注:1.20中已内置了​​time.DateTime​​等常量,如​​time.DateTime​​表示​​2006-01-02 15:04:05​​)。所幸在 ​​time​​ 包中,最常用的日期和时间格式都有自己的常量。

就像在​​time.Duration​​上定义了部分提取的方法一样,对​​time.Time​​也定义了类似的方法,包括 ​​Day​​、​​Month​​、​​Year​​、​​Hour​​、​​Minute​​、​​Second​​、​​Weekday​​、​​Clock​​(将​​time.Time​​的以单独的小时、分钟和秒​​int​​值返回)和​​Date​​(将年、月和日以单独的​​int​​值返回)。可以使用 ​​After​​、​​Before​​和​​Equal​​方法比较两个​​time.Time​​实例。

​Sub​​方法返回一个​​time.Duration​​,表示两个​​time.Time​​实例之间经过的时间,而​​Add​​方法返回​​time.Duration​​时长之后的​​time.Time​​,​​AddDate​​方法返回一个新的 ​​time.Time​​实例,该实例按指定的年、月和日增加。与​​time.Duration​​一样,它也定义了​​Truncate​​和​​Round​​方法。所有这些方法都是在值接收器上定义的,因此它们不会修改​​time.Time​​实例。

单调时间

大多数操作系统会追踪两种不同类型的时间:墙上时钟(wall clock),对应于当前时间,和单调时钟(monotonic clock),它是从计算机启动时开始递增。之所以要跟踪两个不同的时钟是因为墙上时间不是统一递增的。夏令时、闰秒和 NTP(网络时间协议)更新可能会导致墙上时间意外地前后移动。这可能会在设置计时器或计算经过的时长时引发问题。

为了解决这个潜在问题,Go 在设置计时器或使用​​time.Now​​创建​​time.Time​​实例时使用单调时间来记录经过的时间。这种支持是隐式的,计时器会自动使用它。如果两个​​time.Time​​实例都设置了单调时间,​​Sub​​方法会使用单调时钟来计算​​time.Duration​​。如果它们没有设置单调时间(因为其中一个或两个实例没有使用​​time.Now​​创建),​​Sub​​方法会使用实例中指定的时间来计算​​time.Duration​​。

注: 如果想了解在未正确处理单调时间时会有什么问题,请参阅Cloudflare博客中详细介绍的早期 Go 版本中由于缺乏单调时间支持而引发的错误的​​文章​​。

计时器和超时

正如我们在​​如何让代码超时​​中介绍的那样,​​time​​包中包含了返回在指定时间后输出值的通道的函数。​​time.After​​函数返回一个仅输出一次的通道,而由​​time.Tick​​返回的通道在指定的​​time.Duration​​间隔后每次输出一个新值。这些与 Go 的并发支持一起使用,以实现超时或定期任务。你还可以使用​​time.AfterFunc​​函数在指定的时间间隔后触发某个函数的运行。不要在复杂程序中使用​​time.Tick​​,因为底层的​​time.Ticker​​无法关闭(因此无法进行垃圾回收)。而应使用​​time.NewTicker​​函数,它返回一个​​*time.Ticker​​,其中包含要监听的通道,以及重置和停止计时器的方法。

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语言&云原生自我提升系列​​,欢迎关注后续文章。

相关推荐
懒是一种态度2 分钟前
Golang 调用 mongodb 的函数
数据库·mongodb·golang
梅见十柒18 分钟前
wsl2中kali linux下的docker使用教程(教程总结)
linux·经验分享·docker·云原生
XINGTECODE31 分钟前
海盗王集成网关和商城服务端功能golang版
开发语言·后端·golang
入 梦皆星河33 分钟前
在 Ubuntu/Debian 上安装 Go
ubuntu·golang·debian
凡人的AI工具箱1 小时前
15分钟学 Go 第 60 天 :综合项目展示 - 构建微服务电商平台(完整示例25000字)
开发语言·后端·微服务·架构·golang
运维&陈同学2 小时前
【zookeeper01】消息队列与微服务之zookeeper工作原理
运维·分布式·微服务·zookeeper·云原生·架构·消息队列
O&REO3 小时前
单机部署kubernetes环境下Overleaf-基于MicroK8s的Overleaf应用部署指南
云原生·容器·kubernetes
运维小文3 小时前
K8S资源限制之LimitRange
云原生·容器·kubernetes·k8s资源限制
wuxingge12 小时前
k8s1.30.0高可用集群部署
云原生·容器·kubernetes
志凌海纳SmartX13 小时前
趋势洞察|AI 能否带动裸金属 K8s 强势崛起?
云原生·容器·kubernetes