8.4 输入和输出
类 UNIX 的操作系统如此伟大的一个原因是,一个程序的输出可以是另一个程序的输入这一理念。依照这个哲学,这类操作系统创建了一系列的简单程序,每个程序只做一件事,并把这件事做得非常好。之后,将这些程序组合在一起,可以创建一些脚本做一些很惊艳的事情。这些程序使用 stdin
和 stuout
设备作为通道,在进程之间传递数据。
同样的理念扩展到了标准库的 io
包,而且提供的功能很神奇。这个包可以以流的方式高效处理数据,而不用考虑数据是什么,数据来自哪里,以及数据要发送到哪里的问题。与 stuout
和 stdin
对应,这个包含有 io.Writer
和 io.Reader
两个接口。所有实现了这两个接口的类型的值,都可以使用 io
包提供的所有功能,也可以用于其他包里接受这两个接口的函数以及方法。这是用接口类型来构造函数和 API 最美妙的地方。开发人员可以基于这些现有功能进行组合,利用所有已经存在的实现,专注于解决业务问题。
有了这个概念,让我们先看一下 io.Wrtier
和 io.Reader
接口的声明,然后再来分析展示了 io
包神奇功能的代码。
8.4.1 Writer 和 Reader 接口
io
包是围绕着实现了 io.Writer
和 io.Reader
接口类型的值而构建的。由于 io.Writer
和 io.Reader
提供了足够的抽象,这些 io
包里的函数和方法并不知道数据的类型,也不知道这些数据在物理上是如何读和写的。让我们先来看一下 io.Writer
接口的声明,如代码清单 8-33 所示。
代码清单 8-33 io.Writer
接口的声明
type Writer interface {
Write(p []byte) (n int, err error)
}
代码清单 8-33 展示了 io.Writer
接口的声明。这个接口声明了唯一一个方法 Write
,这个方法接受一个 byte
切片,并返回两个值。第一个值是写入的字节数,第二个值是 error
错误值。代码清单 8-34 给出的是实现这个方法的一些规则。
代码清单 8-34 io.Writer
接口的文档
Write 从 p 里向底层的数据流写入 len(p) 字节的数据。这个方法返回从 p 里写出的字节
数(0 <= n <= len(p)),以及任何可能导致写入提前结束的错误。Write 在返回 n
< len(p) 的时候,必须返回某个非 nil 值的 error。Write 绝不能改写切片里的数据,
哪怕是临时修改也不行。
代码清单 8-34 中的规则来自标准库。这些规则意味着 Write
方法的实现需要试图写入被传入的 byte
切片里的所有数据。但是,如果无法全部写入,那么该方法就一定会返回一个错误。返回的写入字节数可能会小于 byte
切片的长度,但不会出现大于的情况。最后,不管什么情况,都不能修改 byte
切片里的数据。
让我们看一下 Reader
接口的声明,如代码清单 8-35 所示。
代码清单 8-35 io.Reader
接口的声明
type Reader interface {
Read(p []byte) (n int, err error)
}
代码清单 8-35 中的 io.Reader
接口声明了一个方法 Read
,这个方法接受一个 byte
切片,并返回两个值。第一个值是读入的字节数,第二个值是 error
错误值。代码清单 8-36 给出的是实现这个方法的一些规则。
代码清单 8-36 io.Reader
接口的文档
(1) Read 最多读入 len(p) 字节,保存到 p。这个方法返回读入的字节数(0 <= n
<= len(p))和任何读取时发生的错误。即便 Read 返回的 n < len(p),方法也可
能使用所有 p 的空间存储临时数据。如果数据可以读取,但是字节长度不足 len(p),
习惯上 Read 会立刻返回可用的数据,而不等待更多的数据。
(2) 当成功读取 n > 0 字节后,如果遇到错误或者文件读取完成,Read 方法会返回
读入的字节数。方法可能会在本次调用返回一个非 nil 的错误,或者在下一次调用时返
回错误(同时 n == 0)。这种情况的的一个例子是,在输入的流结束时,Read 会返回
非零的读取字节数,可能会返回 err == EOF,也可能会返回 err == nil。无论如何,
下一次调用 Read 应该返回 0, EOF。
(3) 调用者在返回的 n > 0 时,总应该先处理读入的数据,再处理错误 err。这样才
能正确操作读取一部分字节后发生的 I/O 错误。EOF 也要这样处理。
(4) Read 的实现不鼓励返回 0 个读取字节的同时,返回 nil 值的错误。调用者需要将
这种返回状态视为没有做任何操作,而不是遇到读取结束。
标准库里列出了实现 Read
方法的 4 条规则。第一条规则表明,该实现需要试图读取数据来填满被传入的 byte
切片。允许出现读取的字节数小于 byte
切片的长度,并且如果在读取时已经读到数据但是数据不足以填满 byte
切片时,不应该等待新数据,而是要直接返回已读数据。
第二条规则提供了应该如何处理达到文件末尾( EOF
)的情况的指导。当读到最后一个字节时,可以有两种选择。一种是 Read
返回最终读到的字节数,并且返回 EOF
作为错误值,另一种是返回最终读到的字节数,并返回 nil
作为错误值。在后一种情况下,下一次读取的时候,由于没有更多的数据可供读取,需要返回 0 作为读到的字节数,以及 EOF
作为错误值。
第三条规则是给调用 Read
的人的建议。任何时候 Read
返回了读取的字节数,都应该优先处理这些读取到的字节,再去检查 EOF 错误值或者其他错误值。最终,第四条约束建议 Read
方法的实现永远不要返回 0 个读取字节的同时返回 nil
作为错误值。如果没有读到值, Read
应该总是返回一个错误。
现在知道了 io.Writer
和 io.Reader
接口是什么样子的,以及期盼的行为是什么,让我们看一下如何在程序里使用这些接口以及 io
包。
8.4.2 整合并完成工作
这个例子展示标准库里不同包是如何通过支持实现了 io.Writer
接口类型的值来一起完成工作的。这个示例里使用了 bytes
、 fmt
和 os
包来进行缓冲、拼接和写字符串到 stuout
,如代码清单 8-37 所示。
代码清单 8-37 listing37.go
01 // 这个示例程序展示来自不同标准库的不同函数是如何
02 // 使用 io.Writer 接口的
03 package main
04
05 import (
06 "bytes"
07 "fmt"
08 "os"
09 )
10
11 // main 是应用程序的入口
12 func main() {
13 // 创建一个 Buffer 值,并将一个字符串写入 Buffer
14 // 使用实现 io.Writer 的 Write 方法
15 var b bytes.Buffer
16 b.Write([]byte("Hello "))
17
18 // 使用 Fprintf 来将一个字符串拼接到 Buffer 里
19 // 将 bytes.Buffer 的地址作为 io.Writer 类型值传入
20 fmt.Fprintf(&b, "World!")
21
22 // 将 Buffer 的内容输出到标准输出设备
23 // 将 os.File 值的地址作为 io.Writer 类型值传入
24 b.WriteTo(os.Stdout)
25 }
运行代码清单 8-37 中的程序会得到代码清单 8-38 所示的输出。
代码清单 8-38 listing37.go 的输出
Hello World!
这个程序使用了标准库的 3 个包来将 "Hello World!"
输出到终端窗口。一开始,程序在第 15 行声明了一个 bytes
包里的 Buffer
类型的变量,并使用零值初始化。在第 16 行创建了一个 byte
切片,并用字符串 "Hello"
初始化了这个切片。 byte
切片随后被传入 Write
方法,成为 Buffer
类型变量里的初始内容。
第 20 行使用 fmt
包里的 Fprintf
函数将字符串 "World!"
追加到 Buffer
类型变量里。让我们看一下 Fprintf
函数的声明,如代码清单 8-39 所示。
代码清单 8-39 golang.org/src/fmt/print.go
// Fprintf 根据格式化说明符来格式写入内容,并输出到 w
// 这个函数返回写入的字节数,以及任何遇到的错误
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
需要注意 Fprintf
函数的第一个参数。这个参数需要接收一个实现了 io.Writer
接口类型的值。因为我们传入了之前创建的 Buffer
类型值的地址,这意味着 bytes
包里的 Buffer
类型必须实现了这个接口。那么在 bytes
包的源代码里,我们应该能找到为 Buffer
类型声明的 Write
方法,如代码清单 8-40 所示。
代码清单 8-40 golang.org/src/bytes/buffer.go
// Write 将 p 的内容追加到缓冲区,如果需要,会增大缓冲区的空间。返回值 n 是
// p 的长度,err 总是 nil。如果缓冲区变得太大,Write 会引起崩溃…
func (b *Buffer) Write(p []byte) (n int, err error) {
b.lastRead = opInvalid
m := b.grow(len(p))
return copy(b.buf[m:], p), nil
}
代码清单 8-40 展示了 Buffer
类型的 Write
方法的当前版本的实现。由于实现了这个方法,指向 Buffer
类型的指针就满足了 io.Writer
接口,可以将指针作为第一个参数传入 Fprintf
。在这个例子里,我们使用 Fprintf
函数,最终通过 Buffer
实现的 Write
方法,将 "World!"
字符串追加到 Buffer
类型变量的内部缓冲区。
让我们看一下代码清单 8-37 的最后几行,如代码清单 8-41 所示,将整个 Buffer
类型变量的内容写到 stuout
。
代码清单 8-41 listing37.go:第 22 行到第 25 行
22 // 将 Buffer 的内容输出到标准输出设备
23 // 将 os.File 值的地址作为 io.Writer 类型值传入
24 b.WriteTo(os.Stdout)
25 }
在代码清单 8-37 的第 24 行,使用 WriteTo
方法将 Buffer
类型的变量的内容写到 stuout
设备。这个方法接受一个实现了 io.Writer
接口的值。在这个程序里,传入的值是 os
包的 Stdout
变量的值,如代码清单 8-42 所示。
代码清单 8-42 golang.org/src/os/file.go
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)
这些变量自动声明为 NewFile
函数返回的类型,如代码清单 8-43 所示。
代码清单 8-43 golang.org/src/os/file_unix.go
// NewFile 返回一个具有给定的文件描述符和名字的新 File
func NewFile(fd uintptr, name string) *File {
fdi := int(fd)
if fdi < 0 {
return nil
}
f := &File{&file{fd: fdi, name: name}}
runtime.SetFinalizer(f.file, (*file).close)
return f
}
就像在代码清单 8-43 里看到的那样, NewFile
函数返回一个指向 File
类型的指针。这就是 Stdout
变量的类型。既然我们可以将这个类型的指针作为参数传入 WriteTo
方法,那么这个类型一定实现了 io.Writer
接口。在 os
包的源代码里,我们应该能找到 Write
方法,如代码清单 8-44 所示。
代码清单 8-44 golang.org/src/os/file.go
// Write 将 len(b) 个字节写入 File
// 这个方法返回写入的字节数,如果有错误,也会返回错误
// 如果 n != len(b),Write 会返回一个非 nil 的错误
func (f *File) Write(b []byte) (n int, err error) {
if f == nil {
return 0, ErrInvalid
}
n, e := f.write(b)
if n < 0 {
n = 0
}
if n != len(b) {
err = io.ErrShortWrite
}
epipecheck(f, e)
if e != nil {
err = &PathError{"write", f.name, e}
}
return n, err
}
没错,代码清单 8-44 中的代码展示了 File
类型指针实现 io.Writer
接口类型的代码。让我们再看一下代码清单 8-37 的第 24 行,如代码清单 8-45 所示。
代码清单 8-45 listing37.go:第 22 行到第 25 行
22 // 将 Buffer 的内容输出到标准输出设备
23 // 将 os.File 值的地址作为 io.Writer 类型值传入
24 b.WriteTo(os.Stdout)
25 }
可以看到, WriteTo
方法可以将 Buffer
类型变量的内容写到 stuout
,结果就是在终端窗口上显示了 "Hello World!"
字符串。这个方法会通过接口值,调用 File
类型实现的 Write
方法。
这个例子展示了接口的优雅以及它带给语言的强大的能力。得益于 bytes.Buffer
和 os.File
类型都实现了 Writer
接口,我们可以使用标准库里已有的功能,将这些类型组合在一起完成工作。接下来让我们看一个更加实用的例子。
8.4.3 简单的 curl
在 Linux 和 MacOS(曾用名 Mac OS X)系统里可以找到一个名为 curl
的命令行工具。这个工具可以对指定的 URL 发起 HTTP 请求,并保存返回的内容。通过使用 http
、 io
和 os
包,我们可以用很少的几行代码来实现一个自己的 curl
工具。
让我们来看一下实现了基础 curl
功能的例子,如代码清单 8-46 所示。
代码清单 8-46 listing46.go
01 // 这个示例程序展示如何使用 io.Reader 和 io.Writer 接口
02 // 写一个简单版本的 curl
03 package main
04
05 import (
06 "io"
07 "log"
08 "net/http"
09 "os"
10 )
11
12 // main 是应用程序的入口
13 func main() {
14 // 这里的 r 是一个响应,r.Body 是 io.Reader
15 r, err := http.Get(os.Args[1])
16 if err != nil {
17 log.Fatalln(err)
18 }
19
20 // 创建文件来保存响应内容
21 file, err := os.Create(os.Args[2])
22 if err != nil {
23 log.Fatalln(err)
24 }
25 defer file.Close()
26
27 // 使用 MultiWriter,这样就可以同时向文件和标准输出设备
28 // 进行写操作
29 dest := io.MultiWriter(os.Stdout, file)
30
31 // 读出响应的内容,并写到两个目的地
32 io.Copy(dest, r.Body)
33 if err := r.Body.Close(); err != nil {
34 log.Println(err)
35 }
36 }
代码清单 8-46 展示了一个实现了基本骨架功能的 curl
,它可以下载、展示并保存任意的 HTTP Get
请求的内容。这个例子会将响应的结果同时写入文件以及 stuout
。为了让例子保持简单,这个程序没有检查命令行输入参数的有效性,也没有支持更高级的选项。
在这个程序的第 15 行,使用来自命令行的第一个参数来执行 HTTP Get
请求。如果这个参数是一个 URL,而且请求没有发生错误,变量 r
里就包含了该请求的响应结果。在第 21 行,我们使用命令行的第二个参数打开了一个文件。如果这个文件打开成功,那么在第 25 行会使用 defer
语句安排在函数退出时执行文件的关闭操作。
因为我们希望同时向 stuout
和指定的文件里写请求的内容,所以在第 29 行我们使用 io
包里的 MultiWriter
函数将文件和 stuout
整合为一个 io.Writer
值。在第 33 行,我们使用 io
包的 Copy
函数从响应的结果里读取内容,并写入两个目的地。由于有 MultiWriter
函数提供的值的支持,我们可使用一次 Copy
调用,将内容同时写到两个目的地。
利用 io
包里已经提供的支持,以及 http
和 os
包里已经实现了 io.Writer
和 io.Reader
接口类型的实现,我们不需要编写任何代码来完成这些底层的函数,借助已经存在的功能,将注意力集中在需要解决的问题上。如果我们自己的类型也实现了这些接口,就可以立刻支持已有的大量功能。
8.4.4 结论
可以在 io
包里找到大量的支持不同功能的函数,这些函数都能通过实现了 io.Writer
和 io.Reader
接口类型的值进行调用。其他包,如 http
包,也使用类似的模式,将接口声明为包的 API 的一部分,并提供对 io
包的支持。应该花时间看一下标准库中提供了些什么,以及它是如何实现的——不仅要防止重新造轮子,还要理解 Go 语言的设计者的习惯,并将这些习惯应用到自己的包和 API 的设计上。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论