本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
使用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
各自定义了一个方法:
1 2 3 4 5 6 7 |
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)
个字节写入到该切片中。该方法返回写入的字节数。这可能看起来有点奇怪。读者期望的可能是:
1 2 3 |
type NotHowReaderIsDefined interface { Read() (p []byte, err error) } |
标准库中定义io.Reader
的方式是有原因的。我们来编写一个函数说明如何使用io.Reader
方便大家理解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
:
1 2 3 4 5 6 7 |
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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
一起使用:
1 2 3 4 5 6 7 8 9 10 |
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
:
1 2 3 4 5 6 7 |
type Closer interface { Close() error } type Seeker interface { Seek(offset int64, whence int) (int64, error) } |
io.Closer
接口由像os.File
这样需要在读取或写入完成时进行清理的类型实现。通常,使用defer
调用Close
函数:
1 2 3 4 5 6 |
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
的类型。其实现非常简单:
1 2 3 4 5 6 7 8 9 |
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分钟的时长:
1 |
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”。
– Go 标准库文档
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 小时前)。
例如,以下代码:
1 2 3 4 5 |
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")) |
会打印出:
1 |
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:
1 2 3 4 5 6 |
{ "id":"12345", "date_ordered":"2020-05-01T13:01:02Z", "customer_id":"3", "items":[{"id":"xyz123","name":"Thing 1"},{"id":"abc789","name":"Thing 2"}] } |
我们定义映射该数据的类型:
1 2 3 4 5 6 7 8 9 10 11 |
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
类型结构体的代码:
1 2 3 4 5 |
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 的形式写回,并存储在一个字节切片中:
1 |
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
中的数据开始:
1 2 3 4 5 6 7 8 |
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
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
的变量来接收:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
处理这些情况。
假设有以下数据:
1 2 3 |
{"name": "Fred", "age": 40} {"name": "Mary", "age": 21} {"name": "Pat", "age": 30} |
对于我们的示例,假设数据存储在一个名为data
的字符串中,但它也可以是文件,甚至是传入的HTTP请求(我们稍后会了解HTTP服务端的原理)。
我们将该数据存在到变量t
中,每次一个JSON 对象。
和之前一样,我们使用数据源初始化json.Decoder
,但这次我们使用json.Decoder
的More
方法作为for
循环条件。这样可以逐个读取数据,每次一个JSON 对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
接口的类型都可以:
1 2 3 4 5 6 7 8 9 10 |
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
两个接口的新类型来进行处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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 格式的时间进行操作:
1 2 3 4 5 6 |
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
中的字段如下:
1 2 3 4 5 6 |
type Order struct { ID string `json:"id"` Items []Item `json:"items"` DateOrdered time.Time `json:"date_ordered"` CustomerID string `json:"customer_id"` } |
MarshalJSON
方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
中的逻辑类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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语言,如果希望服务有价值的话,就允许开发者使用其它语言调用它。
net/http
每种编程语言都自带标准库,但随着时间的推移对标准库包含内容的预期在发生变化。作为一个21世纪10年代发布的语言,Go标准库中包含了一些其它语言认为应由第三方库负责的部分:生产级的HTTP/2客户端和服务端。
客户端
net/http
包定义了一个Client
类型,发送HTTP请求及接收HTTP响应。net/http
包中有一个默认客户端实例(恰到好处地命名为DefaultClient
),但应当避免在生产应用中使用它,因为它默认不带超时。请实例化自己的客户端。在整个应用中只需要创建一个http.Client
,因为它处理好了跨协程的多并发请求:
1 2 3 |
client := &http.Client{ Timeout: 30 * time.Second, } |
在希望发送请求时,通过http.NewRequestWithContext
函数实例化一个新的*http.Request
实例,将上下文、方法和希望连接的URL发送给它。如果为PUT
、POST
或PATCH
请求,最后一个参数使用io.Reader
类型指定请求体。如果没有请求体,使用nil
:
1 2 3 4 5 |
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://jsonplaceholder.typicode.com/todos/1", nil) if err != nil { panic(err) } |
注:我们会在上下文一章中讨论上下文。
有了*http.Request
实例,就可通过实例的Headers
字段设置请求头。用http.Request
对http.Client
调用Do
方法,结果在http.Response
中返回。
1 2 3 4 5 |
req.Header.Add("X-My-Client", "Learning Go") res, err := client.Do(req) if err != nil { panic(err) } |
响应中有多个包含请求相应信息的字段。响应状态的数字码位于StatusCode
字段,响应码的文本位于Status
字段,响应头位于Header
字段,返回的内容都位于io.ReadCloser
类型的Body
字段中。这样我们就可以使用json.Decoder
来处理REST API响应了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
defer res.Body.Close() if res.StatusCode != http.StatusOK { panic(fmt.Sprintf("unexpected status: got %v", res.Status)) } fmt.Println(res.Header.Get("Content-Type")) var data struct { UserID int `json:"userId"` ID int `json:"id"` Title string `json:"title"` Completed bool `json:"completed"` } err = json.NewDecoder(res.Body).Decode(&data) if err != nil { panic(err) } fmt.Printf("%+v\n", data) |
可在GitHub仓库的sample_code/client目录中查看代码。
警告:
net/http
包中有方法处理GET
、HEAD
和POST
调用。避免使用这些函数,因为它们使用默认客户端,因此没有设置请求超时。
服务端
HTTP服务端是以http.Server
的概念和http.Handler
接口进行构建的。就像http.Client
是发送HTTP请求的,http.Server
负责监听HTTP的请求。它是一个支持TLS的高性能HTTP/2服务端。
对服务端的请求由赋值给Handler
字段的http.Handler
接口实现来处理。接口中定义了一个方法:
1 2 3 |
type Handler interface { ServeHTTP(http.ResponseWriter, *http.Request) } |
*http.Request
很眼熟,它和向HTTP服务端发送请求使用的同一种类型。http.ResponseWriter
是一个带有三个方法的接口:
1 2 3 4 5 |
type ResponseWriter interface { Header() http.Header Write([]byte) (int, error) WriteHeader(statusCode int) } |
这些方法必须按指定顺序调用。首先,调用Header
来获取一个http.Header
实例并设置所需响应头。如果无需设置头部,就不用调用它。接着, 使用响应的HTTP状态码调用WriteHeader
。(所有的状态码在net/http
包中以常量进行定义。这会是定义自定义类型的好地方,但并不完全,所有的状态码常量都是无类型整数。)如果想要发送状态码为200的响应,可以跳过WriteHeader
。最后,调用Write
方法来设置响应体。以下是小型handler的示例:
1 2 3 4 5 |
type HelloHandler struct{} func (hh HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello!\n")) } |
可以像其它结构体那样实例化一个新的http.Server
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
s := http.Server{ Addr: ":8080", ReadTimeout: 30 * time.Second, WriteTimeout: 90 * time.Second, IdleTimeout: 120 * time.Second, Handler: HelloHandler{}, } err := s.ListenAndServe() if err != nil { if err != http.ErrServerClosed { panic(err) } } |
Addr
字段指定了服务端监听的主机和端口。如不指定,服务端默认监听所有主机以及标准HTTP端口80。然后使用time.Duration
值指定服务端读取、写入及空闲的超时时间。确保设置这些值以规避恶意或崩溃的HTTP客户端,因为默认是一律不超时。最后,使用Handler
字段为服务端指定http.Handler
。
代码参见GitHub的sample_code/server目录。
处理单个请求的服务端并没有多大用处,因此Go标准库集成了请求路由*http.ServeMux
。我们通过http.NewServeMux
函数创建一个实例。它符合http.Handler
接口,因此可赋值给http.Server
的Handler
字段。它还包含两个方法可派发请求。第一个方法是Handle
,接收两个参数:路径和http.Handler
。若路径匹配,则调用http.Handler
。
虽然可以创建http.Handler
的实现,但更常见的模式是使用*http.ServeMux
的HandleFunc
方法:
1 2 3 |
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello!\n")) }) |
方法接收一个函数或闭包,将其转化为http.HandlerFunc
。我们已经在函数类型是接口的桥梁中探讨过http.HandlerFunc
类型。对于简单的handler,用闭包就够了。更复杂的依赖于其它业务逻辑的handler,使用方法或结构体,参见隐式接口让依赖注入更简单。
Go 1.22将路径的语法扩展为允许使用HTTP动词和路径通配符变量。通配符变量值使用http.Request
的PathValue
方法进行读取:
1 2 3 4 |
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") w.Write([]byte(fmt.Sprintf("Hello, %s!\n", name))) }) |
警告:有一些包级函数:
http.Handle
、http.HandleFunc
、http.ListenAndServe
和http.ListenAndServeTLS
配合*http.ServeMux
的包级实例http.DefaultServeMux
进行使用。不要在小测试程序之外使用它们。http.Server
实例在http.ListenAndServe
和http.ListenAndServeTLS
函数中创建,因而无法配置超时这样的服务端属性。此外,第三方库可以能通过http.DefaultServeMux
注册了自己的 handler,不扫描所有依赖(直接和间接依赖)就无法知晓。通过避免共享状态来让应用处于掌控之中。
因*http.ServeMux
将请求分发给http.Handler
,而*http.ServeMux
实现了http.Handler
,可以用多个关联请求创建一个*http.ServeMux
实例,并通过一个父*http.ServeMux
来注册它。
1 2 3 4 5 6 7 8 9 10 11 |
person := http.NewServeMux() person.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("greetings!\n")) }) dog := http.NewServeMux() dog.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("good puppy!\n")) }) mux := http.NewServeMux() mux.Handle("/person/", http.StripPrefix("/person", person)) mux.Handle("/dog/", http.StripPrefix("/dog", dog)) |
本例中,请求/person/greet
由附属于person
的handler处理,而/dog/greet
由附属于dog
的handler处理。在将person
和dog
注册到mux
上时,使用http.StripPrefix
帮助函数来删除已由mux
处理的路径部分。代码参见GitHub的sample_code/server_mux目录。
中间件
HTTP服务端最常见的一个要求是执行一组跨多个handler的操作,比如检查用户是否登录、对请求计时或检测请求头。Go通过中间件模式处理这类横向请求。中间并没有使用特定的类型,而是接收一个http.Handler
实例,再返回http.Handler
。通常返回的http.Handler
是一个转化为http.HandlerFunc
的闭包。这里有两个中间件生成器,一个提供请求用时,另一个使用最差情况访问控制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
func RequestTimer(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() h.ServeHTTP(w, r) dur := time.Since(start) slog.Info("request time", "path", r.URL.Path, "duration", dur) }) } var securityMsg = []byte("You didn't give the secret password\n") func TerribleSecurityProvider(password string) func(http.Handler) http.Handler { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Secret-Password") != password { w.WriteHeader(http.StatusUnauthorized) w.Write(securityMsg) return } h.ServeHTTP(w, r) }) } } |
这两个中间件实现演示了中间件的功能。首先是做配置操作或检测。如果检测不通过,在中间件中编写输出(通常带错误码)并返回。如果一切正常,则调用handler的ServeHTTP
方法。在返回时执行清理操作。
TerribleSecurityProvider
显示了如何创建可配置的中间件。传入配置信息(本例中为密码),函数使用该配置信息返回中间件。它有点不直观,因为返回了一个返回闭包的闭包。
注:读者可能会想如何透过一层层中间件传递值,这个可参见上下文。
我们通过链式调用对请求添加中间件:
1 2 3 4 5 6 |
terribleSecurity := TerribleSecurityProvider("GOPHER") mux.Handle("/hello", terribleSecurity(RequestTimer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello!\n")) })))) |
我们通过TerribleSecurityProvider
获取中间件,然后将handler封装到一系列的函数调用中。这会首先调用terribleSecurity
闭包,然后调用RequestTimer
,接着又调用实际的请求handler。
因为*http.ServeMux
实现了http.Handler
接口,可以单个请求路由注册的所有handler应用一组中间件:
1 2 3 4 5 6 |
terribleSecurity := TerribleSecurityProvider("GOPHER") wrappedMux := terribleSecurity(RequestTimer(mux)) s := http.Server{ Addr: ":8080", Handler: wrappedMux, } |
代码参见GitHub的sample_code/middleware目录。
使用第三方模型增强服务端
服务端达到生产品质并不意味着不应使用第三方模块来改善其功能。如果不喜欢中间件的链式函数调用,可以使用第三方模块alice
,通过它可以使用如下语法:
1 2 3 4 5 |
helloHandler := func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello!\n")) } chain := alice.New(terribleSecurity, RequestTimer).ThenFunc(helloHandler) mux.Handle("/hello", chain) |
虽然*http.ServeMux
在Go 1.22中获得了一些新特性,但其对路由和变量的支持还很基础。嵌套*http.ServeMux
实例也有些笨重。如果你需要更高级的特性,比如基于头部值的路由、使用正则表达式指定路径变量或更好的handler嵌套,可以用一些第三方请求路由。最知名的两个是gorilla mux和chi。两者都很地道,因为可配合http.Handler
和http.HandlerFunc
实例使用,并使用适配标准库的可组合库展示了Go的设计看哲学。它们也可以与原生的中间件相配合,并且这两个项目都提供了普遍关注的可选中间件实现。
还有一些知名的web框架,实现了自有的handler和中间件模式。最知名的两个是 Echo and Gin。它们通过集成了自动将请求或响应数据与JSON绑定等特性简化了web开发。它们还提供了适配器函数,让我们可以使用http.Handler
实现,给了我们另一种途径。
ResponseController
在接收接口,返回结构体一节中,我们学习到变更结果会打破向后兼容。我们还学习到解决方法是通过定义新接口渐进演变接口,使用类型开关和类型断言来查看是否实现了新接口。创建这些额外接口的缺点是知道它们的存在很困难,使用类型开关来检查非常的繁琐。这种示例可在http
包中看到。在设计这个包时,选择是将http.ResponseWriter
创建为接口。也就意味着在未来的版本中无法加入其它方法,否则Go语言的兼容承诺就会被打破。为使用http.ResponseWriter
实现加入新的功能,http
包带了一些可由http.ResponseWriter
实现、http.Flusher
和http.Hijacker
实现的接口。这些接口中的方法用于控制响应的输出。
在Go 1.20中,对http
包增加了新的实体类型http.ResponseController
。它展示了对已有API暴露新方法的另一种方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func handler(rw http.ResponseWriter, req *http.Request) { rc := http.NewResponseController(rw) for i := 0; i < 10; i++ { result := doStuff(i) _, err := rw.Write([]byte(result)) if err != nil { slog.Error("error writing", "msg", err) return } err = rc.Flush() if err != nil && !errors.Is(err, http.ErrNotSupported) { slog.Error("error flushing", "msg", err) return } } } |
本例中,若http.ResponseWriter
支持Flush
,则将计算的数据返回给客户端。若不支持,在所有部分完成计算后返回所有数据。工厂函数http.NewResponseController
接收http.ResponseWriter
并将指针返回给http.ResponseController
。这个实体类型具备http.ResponseWriter
可选功能的方法。我们使用errors.Is
将返回错误与http.ErrNotSupported
进行以比较检测底层http.ResponseWriter
是否实现了可选方法。代码参见GitHub仓库的sample_code/response_controller目录。
因为http.ResponseController
是一个封装了http.ResponseWriter
访问实现的实体类型,往后可对其添加新方法而不破坏已有的实现。这让新功能可被发现,提供一种方法使用标准错误检查检测是否存在可选方法。这种模式对于处理需演进的接口是一种有趣的方式。事实上,http.ResponseController
包含两个没有相应接口的方法,SetReadDeadline
和SetWriteDeadline
。很可能未来会使用这种技术对http.ResponseWriter
添加其它可选方法。
结构化日志
自发布起,Go标准库就自带了简单的日志包log
。虽足以应付小项目,但不太容易生成结构化的日志。现代web服务可能同时有几百万个用户,这种量级要求软件处理日志输出以便了解发生了什么。结构化日志对每个日志条目使用档案格式,让其更晚饭后地写入程序,以处理日志输出及发现模式和异常。JSON常用于结构化日志,但即使是空格分隔的键值对都比没将值分隔成字段的非结构化日志要易于处理。虽然可以使用log
包写入JSON,但log
包并没有提供简化结构化日志创建的支持。log/slog
包解决了这一问题。
在标准库中添加log/slog
展示了Go库设计的一些良好实践。第一个正确决策是在标准库中内置结构化日志。标准的结构化日志使得更易于编写共同协作的模块。有多个第三方结构化日志用于解决log
的问题,包括zap、logrus、go-kit log等等。碎片化日志生态的问题在于我们希望控制日志输出到哪里以及对何种级别的消息记录日志。如果代码依赖于使用不同日志工具的第三方模块,这就不太可能实现。通常避免日志碎片化的建议是不在规划为库的模块中打日志,这就使用得实施上不可能做到,并且监控第三方库所执行任务也变得困难。log/slog
包在Go 1.21中引入,但鉴于它可以解决这些不一致性,很可能在未来几年出现在大部分Go程序中。
第二个正确决策是让结构化日志独立成包,而不隶属于log
包。虽然两者目的相似,但设计哲学截然不同。将结构化日志添加到非结构化日志包中会让API变得有歧义。通过分离这两个包,我们马上就能知道slog.Info
是一个结构化日志,而log.Print
是非结构化的,你也不需要去记忆Info
是结构化还是非结构化日志。
另一个正确决策是让log/slog
API可扩展。很容易起步,通过函数提供默认日志工具:
1 2 3 4 5 6 |
func main() { slog.Debug("debug log message") slog.Info("info log message") slog.Warn("warning log message") slog.Error("error log message") } |
这些函数让我们可以对不同的日志级别打出简单的消息。输出类似下面这样:
1 2 3 |
2023/04/20 23:13:31 INFO info log message 2023/04/20 23:13:31 WARN warning log message 2023/04/20 23:13:31 ERROR error log message |
有两件需要注意的事情。第一是默认不输出调试级别的消息。稍后讨论如何创建自己的logger时会讲到如何控制日志级别。
第二个更加隐晦。虽然这是普通文本输出,它使用的是空格来对日志结构化。第一列是年/月/日格式的日期。第二列是24小时制的时间。第三列是日志级别。最后一列是消息。
结构化日志的强大来自于其可添加自定义值的能力。我们来使用自定义字段更新日志:
1 2 3 4 5 |
userID := "fred" loginCount := 20 slog.Info("user login", "id", userID, "login_count", loginCount) |
使用的函数与之前相同,但这里添加了可选参数。可选参数成对出现。第一部分是键,应为字符串。第二部分是值。输出的日志为:
1 |
2023/04/20 23:36:38 INFO user login id=fred login_count=20 |
消息之后为空格分隔的键值对。
虽然文本格式要远比非结构化日志更易于解析,但可能需要的是像JSON这样的格式。可能还会希望自定义日志的位置或是日志级别。这时,我们创建一个结构化日志实例:
1 2 3 4 5 6 7 |
options := &slog.HandlerOptions{Level: slog.LevelDebug} handler := slog.NewJSONHandler(os.Stderr, options) mySlog := slog.New(handler) lastLogin := time.Date(2023, 01, 01, 11, 50, 00, 00, time.UTC) mySlog.Debug("debug message", "id", userID, "last_login", lastLogin) |
这里使用了slog.HandlerOptions
结构体来对新logger定义了最低日志级别。然后使用slog.HandlerOptions
中的NewJSONHandler
方法创建一个使用JSON写入指定io.Writer
的slog.Handler
。本例中,我们使用的是标准错误输出。最后,我们使用slog.New
函数创建包装slog.Handler
的*slog.Logger
。然后我们创建需要与用户id一同打日志的lastLogin
值。输出如下:
1 |
{"time":"2023-04-22T23:30:01.170243-04:00","level":"DEBUG", "msg":"debug message","id":"fred","last_login":"2023-01-01T11:50:00Z"} |
如果JSON和文本都无法满足你的输出需求,可以自己实现slog.Handler
接口并将其传递给slog.New
。
最后,log/slog
考虑到了性能问题。如果你不小心,可能会导致写日志的时间多过所设计执行的任务。可以有多种方式将数据写入log/slog
。我们已经学习最简单(也最慢)的方法,即对Debug
、Info
、Warn
和Error
方法使用可选键值。要通过减少内存分配提升性能,可使用LogAttrs
方法:
1 2 3 |
mySlog.LogAttrs(ctx, slog.LevelInfo, "faster logging", slog.String("id", userID), slog.Time("last_login", lastLogin)) |
第一个参数是context.Context
,下一个是日志级别,接下来是零到多个slog.Attr
实例。有很多用于最常用类型的工厂函数,对没有现成函数的可以使用slog.Any
。
由于Go的兼容性承诺,log
依然存在。当前使用它的程序依然运行正常,使用第三方结构化日志的程序也是如此。如果代码中使用了log.Logger
,slog.NewLogLogger
函数为原来的log
包提供了一个桥梁。它创建了一个使用slog.Handler
写到输出的log.Logger
实例:
1 2 |
myLog := slog.NewLogLogger(mySlog.Handler(), slog.LevelDebug) myLog.Println("using the mySlog Handler") |
输出如下:
1 |
{"time":"2023-04-22T23:30:01.170269-04:00","level":"DEBUG", "msg":"using the mySlog Handler"} |
有关log/slog
的代码参见GitHub仓库的sample_code/structured_logging目录。
log/slog
API还包含其它功能,包含动态日志级别的支持、上下文支持(参见上下文一章),值分组以及创建值的通用头。可以查阅API文档学习更多知识。最重要的是学习log/slog
的结合方式用于未来自己API的构建。
小结
本文中我们学习了标准库中最常用的一些包并演示了如何在代码中临摹最佳实践。我们也学习到了其它不错的软件工程原则:根据经验如何做出不同的决策以及如何遵循向后兼容性以构建具有坚实基础的应用。