使用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"。
- 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 小时前)。
例如,以下代码:
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
,其中包含要监听的通道,以及重置和停止计时器的方法。