原文:
zh.annas-archive.org/md5/5FC2C8948F5CEA11C4D0D293DBBCA039
译者:飞龙
第十章:Go 中的数据 IO
本书的前几章主要关注基础知识。在本章和以后的章节中,读者将介绍 Go 标准库提供的一些强大 API。本章详细讨论了如何使用标准库及其各自的包的 API 输入、处理、转换和输出数据的主题:
-
使用读取器和写入器的 IO
-
io.Reader 接口
-
io.Writer 接口
-
使用 io 包
-
使用文件
-
使用 fmt 进行格式化 IO
-
缓冲 IO
-
内存 IO
-
编码和解码数据
使用读取器和写入器的 IO
与其他语言类似,如 Java,Go 将数据输入和输出建模为从源到目标的流。数据资源,如文件、网络连接,甚至一些内存对象,都可以被建模为字节流,从中可以读取或写入数据,如下图所示:
数据流表示为可以访问以进行读取或写入的字节切片([]byte)。正如我们将在本章中探讨的,*io*
包提供了io.Reader
接口来实现从源到字节流的数据传输和读取的代码。相反,io.Writer
接口让实现者创建从提供的字节流中读取数据并将其作为输出写入到目标资源的代码。这两个接口在 Go 中被广泛使用,作为表达 IO 操作的标准习语。这使得可以在不同实现和上下文中交换读取器和写入器,并获得可预测的结果。
io.Reader 接口
如下列表所示,io.Reader
接口很简单。它由一个方法Read([]byte)(int, error)
组成,旨在让程序员实现从任意源读取数据,并将其传输到提供的字节切片中。
type Reader interface {
Read(p []byte) (n int, err error)
}
Read
方法返回传输到提供的切片中的总字节数和错误值(如果有必要)。作为指导,io.Reader
的实现应在读取器没有更多数据传输到流p
中时返回io.EOF
的错误值。以下显示了类型alphaReader
,这是io.Reader
的一个简单实现,它从其字符串源中过滤掉非字母字符:
type alphaReader string
func (a alphaReader) Read(p []byte) (int, error) {
count := 0
for i := 0; i < len(a); i++ {
if (a[i] >= 'A' && a[i] <= 'Z') ||
(a[i] >= 'a' && a[i] <= 'z') {
p[i] = a[i]
}
count++
}
return count, io.EOF
}
func main() {
str := alphaReader("Hello! Where is the sun?")
io.Copy(os.Stdout, &str)
fmt.Println()
}
golang.fyi/ch10/reader0.go
由于alphaReader
类型的值实现了io.Reader
接口,它们可以在需要读取器的任何地方参与,如在对io.Copy(os.Stdout, &str)
的调用中所示。这将alphaReader
变量发出的字节流复制到写入器接口os.Stdout
(稍后介绍)。
链接读取器
标准库中很可能已经有一个可以重用的读取器,因此常见的做法是包装现有的读取器,并使用其流作为新实现的源。以下代码片段显示了alphaReader
的更新版本。这次,它以io.Reader
作为其源,如下所示:
type alphaReader struct {
src io.Reader
}
func NewAlphaReader(source io.Reader) *alphaReader {
return &alphaReader{source}
}
func (a *alphaReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
count, err := a.src.Read(p) // p has now source data
if err != nil {
return count, err
}
for i := 0; i < len(p); i++ {
if (p[i] >= 'A' && p[i] <= 'Z') ||
(p[i] >= 'a' && p[i] <= 'z') {
continue
} else {
p[i] = 0
}
}
return count, io.EOF
}
func main() {
str := strings.NewReader("Hello! Where is the sun?")
alpha := NewAlphaReader(str)
io.Copy(os.Stdout, alpha)
fmt.Println()
}
golang.fyi/ch10/reader1.go
此版本代码的主要变化是alphaReader
类型现在是一个嵌入了io.Reader
值的结构。当调用alphaReader.Read()
时,它会调用包装的读取器,如a.src.Read(p)
,这将把源数据注入到字节切片p
中。然后该方法循环遍历p
并对数据应用过滤器。现在,要使用alphaReader
,必须首先提供一个现有的读取器,这由NewAlphaReader()
构造函数来实现。
这种方法的优点一开始可能并不明显。然而,通过使用io.Reader
作为底层数据源,alphaReader
类型能够从任何读取器实现中读取。例如,以下代码片段显示了如何将alphaReader
类型与os.File
结合使用,以过滤文件中的非字母字符(Go 源代码本身):
...
func main() {
file, _ := os.Open("./reader2.go")
alpha := NewAlphaReader(file)
io.Copy(os.Stdout, alpha)
fmt.Println()
}
golang.fyi/ch10/reader2.go
io.Writer 接口
io.Writer
接口,如下代码所示,与其读取器对应的接口一样简单:
type Writer interface {
Write(p []byte) (n int, err error)
}
该接口要求实现一个单一方法,即Write(p []byte)(c int, e error)
,该方法从提供的流p
中复制数据并将该数据写入到诸如内存结构、标准输出、文件、网络连接或任何 Go 标准库提供的io.Writer
实现等汇聚资源。Write
方法返回从p
中复制的字节数,然后是遇到的error
值。
以下代码片段显示了channelWriter
类型的实现,它是一个将其流分解并序列化为连续字节发送到 Go 通道的写入器:
type channelWriter struct {
Channel chan byte
}
func NewChannelWriter() *channelWriter {
return &channelWriter{
Channel: make(chan byte, 1024),
}
}
func (c *channelWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
go func() {
defer close(c.Channel) // when done
for _, b := range p {
c.Channel <- b
}
}()
return len(p), nil
}
golang.fyi/ch10/writer1.go
Write
方法使用 goroutine 从p
中复制每个字节,并将其发送到c.Channel
。完成后,goroutine 关闭通道,以便在何时停止从通道中消耗时通知消费者。作为实现约定,写入器不应修改切片p
或保留它。发生错误时,写入器应返回已处理的当前字节数和错误。
使用channelWriter
类型很简单。您可以直接调用Write()
方法,或者更常见的是,使用 API 中的其他 IO 原语与channelWriter
一起使用。例如,以下代码片段使用fmt.Fprint
函数将"Stream me!"
字符串序列化为一系列字节,并使用channelWriter
将其发送到通道:
func main() {
cw := NewChannelWriter()
go func() {
fmt.Fprint(cw, "Stream me!")
}()
for c := range cw.Channel {
fmt.Printf("%c\n", c)
}
}
golang.fyi/ch10/writer1.go
在前面的代码片段中,通过for…range
语句连续打印序列化的字节,排队在通道中。以下代码片段显示了另一个示例,其中文件的内容使用相同的channelWriter
序列化到通道上。在此实现中,使用io.File
值和io.Copy
函数来源数据,而不是使用fmt.Fprint
函数:
func main() {
cw := NewChannelWriter()
file, err := os.Open("./writer2.go")
if err != nil {
fmt.Println("Error reading file:", err)
os.Exit(1)
}
_, err = io.Copy(cw, file)
if err != nil {
fmt.Println("Error copying:", err)
os.Exit(1)
}
// consume channel
for c := range cw.Channel {
fmt.Printf("%c\n", c)
}
}
golang.fyi/ch10/writer2.go.
使用 io 包
IO 的明显起点是,嗯,io
包(golang.org/pkg/io
)。正如我们已经看到的,io
包定义了输入和输出原语,如io.Reader
和io.Writer
接口。以下表格总结了io
包中可用的其他函数和类型,这些函数和类型有助于流式 IO 操作。
功能 | 描述 |
---|
| io.Copy()
| io.Copy
函数(以及其变体io.CopyBuffer
和io.CopyN
)使得从任意io.Reader
源复制数据到同样任意的io.Writer
汇聚变得容易,如下代码片段所示:
data := strings.NewReader("Write me down.")
file, _ := os.Create("./iocopy.data")
io.Copy(file, data)
golang.fyi/ch10/iocopy.go |
| PipeReader PipeWriter
| io
包包括PipeReader和PipeWriter类型,将 IO 操作建模为内存管道。数据被写入管道的io.Writer
,并且可以独立地从管道的io.Reader
读取。以下简略代码片段说明了一个简单的管道,将字符串写入写入器pw
。然后,数据通过读取器pr
消耗,并复制到文件中:
file, _ := os.Create("./iopipe.data")
pr, pw := io.Pipe()
go func() {
fmt.Fprint(pw, "Pipe streaming")
pw.Close()
}()
wait := make(chan struct{})
go func() {
io.Copy(file, pr)
pr.Close()
close(wait)
}()
<-wait //wait for pr to finish
golang.fyi/ch10/iopipe.go 请注意,管道写入器将阻塞,直到读取器完全消耗管道内容或遇到错误。因此,读取器和写入器都应包装在 goroutine 中,以避免死锁。|
| io.TeeReader()
| 与io.Copy
函数类似,io.TeeReader
将内容从读取器传输到写入器。但是,该函数还通过返回的io.Reader
发出复制的字节(未更改)。TeeReader 非常适用于组合多步 IO 流处理。以下简略代码片段首先使用TeeReader
计算文件内容的 SHA-1 哈希。然后,结果读取器data
被流式传输到 gzip 写入器zip
:
fin, _ := os.Open("./ioteerdr.go")
defer fin.Close()
fout, _ := os.Create("./teereader.gz")
defer fout.Close()
zip := gzip.NewWriter(fout)
defer zip.Close()
sha := sha1.New()
data := io.TeeReader(fin, sha)
io.Copy(zip, data)
fmt.Printf("SHA1 hash %x\n", sha.Sum(nil))
golang.fyi/ch10/ioteerdr0.go 如果我们想要计算 SHA-1 和 MD5,可以更新代码以嵌套两个 TeeReader
值,如下面的代码片段所示:
sha := sha1.New()
md := md5.New()
data := io.TeeReader(
io.TeeReader(fin, md), sha,
)
io.Copy(zip, data)
golang.fyi/ch10/ioteerdr1.go |
| io.WriteString()
| io.WriteString
函数将字符串的内容写入指定的写入器。以下代码将字符串的内容写入文件:
fout, err := os.Create("./iowritestr.data")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fout.Close()
io.WriteString(fout, "Hello there!\n")
golang.fyi/ch10/iowritestr.go |
| io.LimitedReader
| 正如其名称所示,io.LimitedReader
结构是一个从指定的 io.Reader
中仅读取 N 个字节的读取器。以下代码段将打印字符串的前 19 个字节:
str := strings.NewReader("The quick brown " +
"fox jumps over the lazy dog")
limited := &io.LimitedReader{R: str, N: 19}
io.Copy(os.Stdout, limited)
golang.fyi/ch10/iolimitedrdr.go
$> go run iolimitedrd.go
The quick brown fox
|
| io.SectionReader
| io.SectionReader
类型通过指定索引(从零开始)来实现 seek 和 skip 原语,指示从哪里开始读取和偏移值,指示要读取的字节数,如下面的代码片段所示:
str := strings.NewReader("The quick brown"+
"fox jumps over the lazy dog")
section := io.NewSectionReader(str, 19, 23)
io.Copy(os.Stdout, section)
golang.fyi/ch10/iosectionrdr.go 这个例子将打印 jumps over the lazy dog
。
包 io/ioutil | io/ioutil 子包实现了一小部分函数,提供了 IO 原语的实用快捷方式,如文件读取、目录列表、临时目录创建和文件写入。 |
---|
处理文件
os
包 (golang.org/pkg/os/
) 暴露了 os.File
类型,它表示系统上的文件句柄。os.File
类型实现了几个 IO 原语,包括 io.Reader
和 io.Writer
接口,允许使用标准的流式 IO API 处理文件内容。
创建和打开文件
os.Create
函数创建具有指定路径的新文件。如果文件已经存在,os.Create
将覆盖它。另一方面,os.Open
函数打开现有文件进行读取。
以下源代码片段打开现有文件并使用 io.Copy
函数创建其内容的副本。一个常见且推荐的做法是在文件上调用 Close
方法的延迟调用。这确保了在函数退出时对 OS 资源的优雅释放:
func main() {
f1, err := os.Open("./file0.go")
if err != nil {
fmt.Println("Unable to open file:", err)
os.Exit(1)
}
defer f1.Close()
f2, err := os.Create("./file0.bkp")
if err != nil {
fmt.Println("Unable to create file:", err)
os.Exit(1)
}
defer f2.Close()
n, err := io.Copy(f2, f1)
if err != nil {
fmt.Println("Failed to copy:", err)
os.Exit(1)
}
fmt.Printf("Copied %d bytes from %s to %s\n",
n, f1.Name(), f2.Name())
}
golang.fyi/ch10/file0.go
函数 os.OpenFile
os.OpenFile
函数提供了通用的低级功能,用于创建新文件或以细粒度控制文件的行为和权限打开现有文件。然而,通常使用 os.Open
和 os.Create
函数,因为它们提供了比 os.OpenFile
函数更简单的抽象。
os.OpenFile
函数有三个参数。第一个是文件的路径,第二个参数是一个掩码位字段值,用于指示操作的行为(例如,只读、读写、截断等),最后一个参数是文件的 posix 兼容权限值。
以下缩写的源代码片段重新实现了之前的文件复制代码。然而,这次它使用 os.FileOpen
函数来演示它的工作原理:
func main() {
f1, err := os.OpenFile("./file0.go", os.O_RDONLY, 0666)
if err != nil {...}
defer f1.Close()
f2, err := os.OpenFile("./file0.bkp", os.O_WRONLY, 0666)
if err != nil {...}
defer f2.Close()
n, err := io.Copy(f2, f1)
if err != nil {...}
fmt.Printf("Copied %d bytes from %s to %s\n",
n, f1.Name(), f2.Name())
}
golang.fyi/ch10/file1.go
注意
如果您已经有一个对操作系统文件描述符的引用,还可以使用 os.NewFile
函数在程序中创建文件句柄。os.NewFile
函数很少使用,因为文件通常是使用前面讨论过的文件函数进行初始化的。
文件写入和读取
我们已经看到如何使用 os.Copy
函数将数据移入或移出文件。然而,有时需要完全控制写入或读取文件数据的逻辑。例如,以下代码片段使用 os.File
变量 fout
的 WriteString
方法创建文本文件:
func main() {
rows := []string{
"The quick brown fox",
"jumps over the lazy dog",
}
fout, err := os.Create("./filewrite.data")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fout.Close()
for _, row := range rows {
fout.WriteString(row)
}
}
golang.fyi/ch10/filewrite0.go
然而,如果您的数据源不是文本,可以直接将原始字节写入文件,如下面的源代码片段所示:
func main() {
data := [][]byte{
[]byte("The quick brown fox\n"),
[]byte("jumps over the lazy dog\n"),
}
fout, err := os.Create("./filewrite.data")
if err != nil { ... }
defer fout.Close()
for _, out := range data {
fout.Write(out)
}
}
golang.fyi/ch10/filewrite0.go
作为io.Reader
,可以直接使用Read方法从io.File
类型读取。这样可以访问文件的内容,将其作为原始的字节片流。以下代码片段将文件../ch0r/dict.txt
的内容作为原始字节读取,并分配给切片p
,每次最多 1024 字节:
func main() {
fin, err := os.Open("../ch05/dict.txt")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fin.Close()
p := make([]byte, 1024)
for {
n, err := fin.Read(p)
if err == io.EOF {
break
}
fmt.Print(string(p[:n]))
}
}
golang.fyi/ch10/fileread.go
标准输入、输出和错误
os
包包括三个预声明变量,os.Stdin
、os.Stdout
和os.Stderr
,它们分别表示操作系统的标准输入、输出和错误的文件句柄。以下代码片段读取文件f1
,并使用os.Copy
函数将其内容写入io.Stdout
,即标准输出(标准输入稍后介绍):
func main() {
f1, err := os.Open("./file0.go")
if err != nil {
fmt.Println("Unable to open file:", err)
os.Exit(1)
}
defer f1.Close()
n, err := io.Copy(os.Stdout, f1)
if err != nil {
fmt.Println("Failed to copy:", err)
os.Exit(1)
}
fmt.Printf("Copied %d bytes from %s \n", n, f1.Name())
}
golang.fyi/ch10/osstd.go
使用fmt
进行格式化 IO
用于 IO 的最常用的包之一是fmt
(golang.org/pkg/fmt
)。它带有一系列函数,用于格式化输入和输出。fmt
包最常见的用法是写入标准输出和从标准输入读取。本节还突出了使fmt
成为 IO 工具的其他函数。
向io.Writer
接口打印
fmt
包提供了几个函数,用于将文本数据写入任意io.Writer
的实现。fmt.Fprint
和fmt.Fprintln
函数使用默认格式写入文本,而fmt.Fprintf
支持格式说明符。以下代码片段使用fmt.Fprintf
函数将metalloid
数据的列格式化列表写入指定的文本文件:
type metalloid struct {
name string
number int32
weight float64
}
func main() {
var metalloids = []metalloid{
{"Boron", 5, 10.81},
...
{"Polonium", 84, 209.0},
}
file, _ := os.Create("./metalloids.txt")
defer file.Close()
for _, m := range metalloids {
fmt.Fprintf(
file,
"%-10s %-10d %-10.3f\n",
m.name, m.number, m.weight,
)
}
}
golang.fyi/ch10/fmtfprint0.go
在先前的示例中,fmt.Fprintf
函数使用格式说明符将格式化文本写入io.File
变量file
。fmt.Fprintf
函数支持大量格式说明符,其正确处理超出了本文的范围。请参阅在线文档,了解这些说明符的完整覆盖范围。
打印到标准输出
fmt.Print
、fmt.Printf
和fmt.Println
具有与先前Fprint
系列函数完全相同的特性。但是,它们不是向任意的io.Writer
写入文本,而是将文本写入标准输出文件句柄os.Stdout
(请参阅前面介绍的标准输出、输入和错误部分)。
以下是更新后的代码片段,显示了先前示例的更新版本,它将 metalloid 的列表写入标准输出而不是常规文件。请注意,除了使用fmt.Printf
而不是fmt.Fprintf
函数之外,它与相同的代码:
type metalloid struct { ... }
func main() {
var metalloids = []metalloid{
{"Boron", 5, 10.81},
...
{"Polonium", 84, 209.0},
}
for _, m := range metalloids {
fmt.Printf(
"%-10s %-10d %-10.3f\n",
m.name, m.number, m.weight,
)
}
}
golang.fyi/ch10/fmtprint0.go
从io.Reader
读取
fmt
包还支持从io.Reader
接口格式化读取文本数据。fmt.Fscan
和fmt.Fscanln
函数可用于将多个值(以空格分隔)读入指定的参数。fmt.Fscanf
函数支持格式说明符,用于从io.Reader
实现中解析数据输入。
以下是使用函数fmt.Fscanf
对包含行星数据的以空格分隔的文件(planets.txt
)进行格式化输入的缩写代码片段:
func main() {
var name, hasRing string
var diam, moons int
// read data
data, err := os.Open("./planets.txt")
if err != nil {
fmt.Println("Unable to open planet data:", err)
return
}
defer data.Close()
for {
_, err := fmt.Fscanf(
data,
"%s %d %d %s\n",
&name, &diam, &moons, &hasRing,
)
if err != nil {
if err == io.EOF {
break
} else {
fmt.Println("Scan error:", err)
return
}
}
fmt.Printf(
"%-10s %-10d %-6d %-6s\n",
name, diam, moons, hasRing,
)
}
golang.fyi/ch10/fmtfscan0.go
该代码从io.File
变量data
读取,直到遇到表示文件结束的io.EOF
错误。它读取的每行文本都使用格式说明符"%s %d %d %s\n"
进行解析,该格式与文件中存储的记录的以空格分隔的布局匹配。然后,将每个解析的标记分配给其相应的变量name
、diam
、moons
和hasRing
,并使用fm.Printf
函数将其打印到标准输出。
从标准输入读取
不是从任意的io.Reader
读取,而是使用fmt.Scan
、fmt.Scanf
和fmt.Scanln
从标准输入文件句柄os.Stdin
读取数据。以下代码片段显示了从控制台读取文本输入的简单程序:
func main() {
var choice int
fmt.Println("A square is what?")
fmt.Print("Enter 1=quadrilateral 2=rectagonal:")
n, err := fmt.Scanf("%d", &choice)
if n != 1 || err != nil {
fmt.Println("Follow directions!")
return
}
if choice == 1 {
fmt.Println("You are correct!")
} else {
fmt.Println("Wrong, Google it.")
}
}
golang.fyi/ch10/fmtscan1.go
在前面的程序中,fmt.Scanf
函数使用格式说明符"%d"
从标准输入中读取整数值。如果读取的值与指定的格式不完全匹配,该函数将抛出错误。例如,以下显示了当读取字符D
而不是整数时会发生什么:
$> go run fmtscan1.go
A square is what?
Enter 1=quadrilateral 2=rectagonal: D
Follow directions!
缓冲 IO
到目前为止,大多数 IO 操作都是无缓冲的。这意味着每个读取和写入操作都可能受到底层操作系统处理 IO 请求的延迟的负面影响。另一方面,缓冲操作通过在 IO 操作期间在内部存储器中缓冲数据来减少延迟。bufio
包(golang.org/pkg/bufio
/)提供了用于缓冲读写 IO 操作的函数。
缓冲写入器和读取器
bufio
包提供了几个函数,使用io.Writer
接口对 IO 流进行缓冲写入。以下代码片段创建一个文本文件,并使用缓冲 IO 进行写入:
func main() {
rows := []string{
"The quick brown fox",
"jumps over the lazy dog",
}
fout, err := os.Create("./filewrite.data")
writer := bufio.NewWriter(fout)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer fout.Close()
for _, row := range rows {
writer.WriteString(row)
}
writer.Flush()
}
golang.fyi/ch10/bufwrite0.go
通常,bufio
包中的构造函数通过包装现有的io.Writer
来创建缓冲写入器。例如,前面的代码使用bufio.NewWriter
函数通过包装 io.File 变量fout
创建了一个缓冲写入器。
要影响内部缓冲区的大小,可以使用构造函数bufio.NewWriterSize(w io.Writer, n int)
来指定内部缓冲区的大小。bufio.Writer
类型还提供了Write
和WriteByte
方法用于写入原始字节,以及WriteRune
方法用于写入 Unicode 编码字符。
通过调用构造函数bufio.NewReader简单地对缓冲流进行读取,以包装现有的io.Reader
。以下代码片段通过包装file
变量作为其底层源创建了一个bufio.Reader
变量reader
:
func main() {
file, err := os.Open("./bufread0.go")
if err != nil {
fmt.Println("Unable to open file:", err)
return
}
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
} else {
fmt.Println("Error reading:, err")
return
}
}
fmt.Print(line)
}
}
golang.fyi/ch10/bufread0.go
前面的代码使用reader.ReadString
方法使用'\n'
字符作为内容分隔符读取文本文件。要影响内部缓冲区的大小,可以使用构造函数bufio.NewReaderSize(w io.Reader, n int)
来指定内部缓冲区的大小。bufio.Reader
类型还提供了Read、ReadByte和ReadBytes方法用于从流中读取原始字节,以及ReadRune方法用于读取 Unicode 编码字符。
扫描缓冲区
bufio
包还提供了用于从io.Reader
源扫描和标记缓冲输入数据的原语。bufio.Scanner
类型使用Split方法扫描输入数据以定义标记化策略。以下代码片段显示了对行星示例(之前的示例)的重新实现。这次,代码使用bufio.Scanner
(而不是fmt.Fscan
函数)来扫描文本文件的内容,使用bufio.ScanLines
函数:
func main() {
file, err := os.Open("./planets.txt")
if err != nil {
fmt.Println("Unable to open file:", err)
return
}
defer file.Close()
fmt.Printf(
"%-10s %-10s %-6s %-6s\n",
"Planet", "Diameter", "Moons", "Ring?",
)
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), " ")
fmt.Printf(
"%-10s %-10s %-6s %-6s\n",
fields[0], fields[1], fields[2], fields[3],
)
}
}
golang.fyi/ch10/bufscan0.go
使用bufio.Scanner
有四个步骤,如前面的示例所示:
-
首先,使用
bufio.NewScanner(io.Reader)
创建一个扫描器 -
调用
scanner.Split
方法来配置内容的标记化方式 -
使用
scanner.Scan
方法遍历生成的标记 -
使用
scanner.Text
方法读取标记化数据
该代码使用预定义的函数bufio.ScanLines
来使用行分隔符解析缓冲内容。bufio
包提供了几个预定义的分隔函数,包括ScanBytes用于将每个字节作为标记扫描,ScanRunes用于扫描 UTF-8 编码的标记,以及ScanWords用于将每个以空格分隔的单词作为标记扫描。
内存 IO
bytes
包提供了常见的原语,用于在内存中存储的字节块上进行流式 IO,由bytes.Buffer
类型表示。由于bytes.Buffer
类型实现了io.Reader
和io.Writer
接口,因此它是将数据流入或流出内存的流式 IO 原语的绝佳选择。
以下代码片段将几个字符串值存储在byte.Buffer
变量book
中,然后将缓冲区流式传输到os.Stdout
:
func main() {
var books bytes.Buffer
books.WriteString("The Great Gatsby")
books.WriteString("1984")
books.WriteString("A Tale of Two Cities")
books.WriteString("Les Miserables")
books.WriteString("The Call of the Wild")
books.WriteTo(os.Stdout)
}
golang.fyi/ch10/bytesbuf0.go
同样的示例很容易更新,以将内容流式传输到常规文件,如下面的简短代码片段所示:
func main() {
var books bytes.Buffer
books.WriteString("The Great Gatsby\n")
books.WriteString("1984\n")
books.WriteString("A Take of Two Cities\n")
books.WriteString("Les Miserables\n")
books.WriteString("The Call of the Wild\n")
file, err := os.Create("./books.txt")
if err != nil {
fmt.Println("Unable to create file:", err)
return
}
defer file.Close()
books.WriteTo(file)
}
golang.fyi/ch10/bytesbuf1.go
编码和解码数据
Go 中 IO 的另一个常见方面是对数据进行编码,从一种表示形式转换为另一种表示形式,因为它正在被流式传输。标准库中的编码器和解码器,位于encoding包中(golang.org/pkg/encoding/
),使用io.Reader
和io.Writer
接口来利用 IO 原语作为在编码和解码过程中流式传输数据的一种方式。
Go 支持多种编码格式,用于各种目的,包括数据转换、数据压缩和数据加密。本章将重点介绍使用Gob和JSON格式进行数据转换的编码和解码。在第十一章中,编写网络程序,我们将探讨使用编码器将数据转换为客户端和服务器通信的远程过程调用(RPC)。
使用 gob 进行二进制编码
gob
包(https://golang.org/pkg/encoding/gob)提供了一种编码格式,可用于将复杂的 Go 数据类型转换为二进制数据。Gob 是自描述的,这意味着每个编码的数据项都附带有类型描述。编码过程涉及将 gob 编码的数据流式传输到 io.Writer,以便将其写入资源以供将来使用。
以下代码片段显示了一个示例代码,将变量books
(一个包含嵌套值的Book
类型的切片)编码为gob
格式。编码器将其生成的二进制数据写入到一个 os.Writer 实例,本例中是*os.File
类型的变量file
:
type Name struct {
First, Last string
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main() {
books := []Book{
Book{
Title: "Leaning Go",
PageCount: 375,
ISBN: "9781784395438",
Authors: []Name{{"Vladimir", "Vivien"}},
Publisher: "Packt",
PublishDate: time.Date(
2016, time.July,
0, 0, 0, 0, 0, time.UTC,
),
},
Book{
Title: "The Go Programming Language",
PageCount: 380,
ISBN: "9780134190440",
Authors: []Name{
{"Alan", "Donavan"},
{"Brian", "Kernighan"},
},
Publisher: "Addison-Wesley",
PublishDate: time.Date(
2015, time.October,
26, 0, 0, 0, 0, time.UTC,
),
},
...
}
// serialize data structure to file
file, err := os.Create("book.dat")
if err != nil {
fmt.Println(err)
return
}
enc := gob.NewEncoder(file)
if err := enc.Encode(books); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch10/gob0.go
尽管前面的示例很长,但它主要是由分配给变量books
的嵌套数据结构的定义组成。最后的半打行或更多行是编码发生的地方。gob 编码器是通过enc := gob.NewEncoder(file)
创建的。通过简单调用enc.Encode(books)
来对数据进行编码,这将将编码的数据流式传输到提供的文件。
解码过程通过使用io.Reader
流式传输 gob 编码的二进制数据,并自动将其重构为强类型的 Go 值来进行反向操作。以下代码片段解码了在上一个示例中编码并存储在books.data
文件中的 gob 数据。解码器从io.Reader
读取数据,在本例中是*os.File
类型的变量file
:
type Name struct {
First, Last string
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main() {
file, err := os.Open("book.dat")
if err != nil {
fmt.Println(err)
return
}
var books []Book
dec := gob.NewDecoder(file)
if err := dec.Decode(&books); err != nil {
fmt.Println(err)
return
}
}
golang.fyi/ch10/gob1.go
解码以前编码的 gob 数据是通过使用dec := gob.NewDecoder(file)
创建解码器来完成的。下一步是声明将存储解码数据的变量。在我们的示例中,books
变量,类型为[]Book
,被声明为解码数据的目标。实际解码是通过调用dec.Decode(&books)
来完成的。请注意,Decode()
方法将其目标变量的地址作为参数。一旦解码完成,books
变量将包含从文件流式传输的重构数据结构。
注意
截至目前,gob 编码器和解码器 API 仅在 Go 编程语言中可用。这意味着以 gob 编码的数据只能被 Go 程序使用。
将数据编码为 JSON
编码包还带有一个json编码器子包(golang.org/pkg/encoding/json/
),用于支持 JSON 格式的数据。这极大地扩展了 Go 程序可以交换复杂数据结构的语言数量。JSON 编码与 gob 包的编码器和解码器类似。不同之处在于生成的数据采用明文 JSON 编码格式,而不是二进制。以下代码更新了前一个示例,将数据编码为 JSON:
type Name struct {
First, Last string
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main() {
books := []Book{
Book{
Title: "Leaning Go",
PageCount: 375,
ISBN: "9781784395438",
Authors: []Name{{"Vladimir", "Vivien"}},
Publisher: "Packt",
PublishDate: time.Date(
2016, time.July,
0, 0, 0, 0, 0, time.UTC),
},
...
}
file, err := os.Create("book.dat")
if err != nil {
fmt.Println(err)
return
}
enc := json.NewEncoder(file)
if err := enc.Encode(books); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch10/json0.go
代码与之前完全相同。它使用分配给books
变量的相同嵌套结构的切片。唯一的区别是创建了一个编码器enc := json.NewEncoder(file)
,它创建一个 JSON 编码器,将file
变量作为其io.Writer
目标。当执行enc.Encode(books)
时,变量books
的内容将被序列化为 JSON,显示在以下代码中(格式化以便阅读):
[
{
"Title":"Leaning Go",
"PageCount":375,
"ISBN":"9781784395438",
"Authors":[{"First":"Vladimir","Last":"Vivien"}],
"Publisher":"Packt",
"PublishDate":"2016-06-30T00:00:00Z"
},
{
"Title":"The Go Programming Language",
"PageCount":380,
"ISBN":"9780134190440",
"Authors":[
{"First":"Alan","Last":"Donavan"},
{"First":"Brian","Last":"Kernighan"}
],
"Publisher":"Addison-Wesley",
"PublishDate":"2015-10-26T00:00:00Z"
},
...
]
文件 books.dat(格式化)
默认情况下,生成的 JSON 编码内容使用结构字段的名称作为 JSON 对象键的名称。这种行为可以使用结构标签来控制(参见使用结构标签控制 JSON 映射部分)。
在 Go 中使用 JSON 解码器从io.Reader
流式传输其源来消耗 JSON 编码的数据。以下代码片段解码了在前一个示例中生成的 JSON 编码数据,存储在文件book.dat
中。请注意,数据结构(未在以下代码中显示)与之前相同:
func main() {
file, err := os.Open("book.dat")
if err != nil {
fmt.Println(err)
return
}
var books []Book
dec := json.NewDecoder(file)
if err := dec.Decode(&books); err != nil {
fmt.Println(err)
return
}
}
golang.fyi/ch10/json1.go
books.dat 文件中的数据存储为 JSON 对象的数组。因此,代码必须声明一个能够存储嵌套结构值的索引集合的变量。在前一个示例中,类型为[]Book
的books
变量被声明为解码数据的目标。实际解码是通过调用dec.Decode(&books)
来完成的。请注意,Decode()
方法将其目标变量的地址作为参数。一旦解码完成,books
变量将包含从文件流式传输的重构数据结构。
使用结构标签控制 JSON 映射
默认情况下,结构字段的名称用作生成的 JSON 对象的键。这可以使用struct
类型标签来控制,以指定在编码和解码数据时如何映射 JSON 对象键名称。例如,以下代码片段声明了带有json:
标签前缀的结构字段,以指定如何对对象键进行编码和解码:
type Book struct {
Title string `json:"book_title"`
PageCount int `json:"pages,string"`
ISBN string `json:"-"`
Authors []Name `json:"auths,omniempty"`
Publisher string `json:",omniempty"`
PublishDate time.Time `json:"pub_date"`
}
golang.fyi/ch10/json2.go
标签及其含义总结如下表:
标签 | 描述 |
---|---|
Title string json:“book_title”`` | 将Title 结构字段映射到 JSON 对象键"book_title" 。 |
PageCount int json:“pages,string”`` | 将PageCount 结构字段映射到 JSON 对象键"pages" ,并将值输出为字符串而不是数字。 |
ISBN string json:“-”`` | 破折号导致在编码和解码过程中跳过ISBN 字段。 |
Authors []Name json:“auths,omniempty”`` | 将Authors 字段映射到 JSON 对象键"auths" 。注释omniempty 导致如果其值为 nil,则省略该字段。 |
Publisher string json:“,omniempty”`` | 将结构字段名Publisher 映射为 JSON 对象键名。注释omniempty 导致字段在为空时被省略。 |
PublishDate time.Time json:“pub_date”`` | 将字段名PublishDate 映射到 JSON 对象键"pub_date" 。 |
当编码前一个结构时,在books.dat
文件中生成以下 JSON 输出(格式化以便阅读):
...
{
"book_title":"The Go Programming Language",
"pages":"380",
"auths":[
{"First":"Alan","Last":"Donavan"},
{"First":"Brian","Last":"Kernighan"}
],
"Publisher":"Addison-Wesley",
"pub_date":"2015-10-26T00:00:00Z"
}
...
请注意,JSON 对象键的标题与struct
标签中指定的相同。对象键"pages"
(映射到结构字段PageCount
)被编码为字符串。最后,结构字段“ISBN”被省略,如在struct
标签中注释的那样。
自定义编码和解码
JSON 包使用两个接口,“Marshaler”和“Unmarshaler”,分别用于编码和解码事件。当编码器遇到一个类型实现了json.Marshaler
的值时,它将值的序列化委托给MarshalJSON
方法,在 Marshaller 接口中定义。以下是一个缩写的代码片段,其中类型Name
更新为实现json.Marshaller
的示例:
type Name struct {
First, Last string
}
func (n *Name) MarshalJSON() ([]byte, error) {
return []byte(
fmt.Sprintf(""%s, %s"", n.Last, n.First)
), nil
}
type Book struct {
Title string
PageCount int
ISBN string
Authors []Name
Publisher string
PublishDate time.Time
}
func main(){
books := []Book{
Book{
Title: "Leaning Go",
PageCount: 375,
ISBN: "9781784395438",
Authors: []Name{{"Vladimir", "Vivien"}},
Publisher: "Packt",
PublishDate: time.Date(
2016, time.July,
0, 0, 0, 0, 0, time.UTC),
},
...
}
...
enc := json.NewEncoder(file)
if err := enc.Encode(books); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch10/json3.go
在前面的例子中,Name
类型的值被序列化为 JSON 字符串(而不是之前的对象)。序列化由方法Name.MarshallJSON
处理,该方法返回一个包含姓和名用逗号分隔的字节数组。前面的代码生成以下 JSON 输出:
[
...
{
"Title":"Leaning Go",
"PageCount":375,
"ISBN":"9781784395438",
"Authors":["Vivien, Vladimir"],
"Publisher":"Packt",
"PublishDate":"2016-06-30T00:00:00Z"
},
...
]
对于反向操作,当解码器遇到映射到实现json.Unmarshaler
的类型的 JSON 文本时,它将解码委托给类型的UnmarshalJSON
方法。例如,以下是实现json.Unmarshaler
以处理Name
类型的 JSON 输出的缩写代码片段:
type Name struct {
First, Last string
}
func (n *Name) UnmarshalJSON(data []byte) error {
var name string
err := json.Unmarshal(data, &name)
if err != nil {
fmt.Println(err)
return err
}
parts := strings.Split(name, ", ")
n.Last, n.First = parts[0], parts[1]
return nil
}
golang.fyi/ch10/json4.go
Name
类型是json.Unmarshaler
的实现。当解码器遇到具有键"Authors"
的 JSON 对象时,它使用方法Name.Unmarshaler
从 JSON 字符串重新构建 Go 结构Name
类型。
注意
Go 标准库提供了其他编码器(此处未涵盖),包括base32
、bas364
、binary
、csv
、hex
、xml
、gzip
和众多加密格式编码器。
摘要
本章提供了 Go 数据输入和输出习惯用法的高层视图,以及实现 IO 原语的包。本章首先介绍了 Go 中基于流的 IO 的基础知识,包括io.Reader
和io.Writer
接口。读者将了解io.Reader
和io.Writer
的实现策略和示例。
本章继续介绍支持流式 IO 机制的包、类型和函数,包括处理文件、格式化 IO、缓冲和内存 IO。本章的最后部分涵盖了在数据流传输过程中转换数据的编码器和解码器。在下一章中,当讨论转向使用 IO 通过网络进行通信的程序时,IO 主题将进一步展开。
第十一章:编写网络服务
作为系统语言,Go 流行的原因之一是它固有的支持创建网络程序。标准库提供了从低级套接字原语到更高级服务抽象(如 HTTP 和 RPC)的 API。本章探讨了创建连接应用程序的基本主题,包括以下内容:
-
网络包
-
TCP API 服务器
-
HTTP 包
-
JSON API 服务器
网络包
Go 中所有网络程序的起点是net包(golang.org/pkg/net
)。它提供了丰富的 API 来处理低级网络原语以及应用级协议,如 HTTP。网络的每个逻辑组件都由 Go 类型表示,包括硬件接口、网络、数据包、地址、协议和连接。此外,每种类型都公开了大量方法,使得 Go 成为支持 IPv4 和 IPv6 的最完整的网络编程标准库之一。
无论是创建客户端还是服务器程序,Go 程序员至少需要以下部分涵盖的网络原语。这些原语作为函数和类型提供,以便客户端连接到远程服务和服务器处理传入请求。
寻址
在进行网络编程时,基本原语之一是地址。net
包的类型和函数使用字符串文字表示地址,例如"127.0.0.1"
。地址还可以包括由冒号分隔的服务端口,例如"74.125.21.113:80"
。net
包中的函数和方法还支持 IPv6 地址的字符串文字表示,例如"::1"
或"[2607:f8b0:4002:c06::65]:80"
,用于带有服务端口 80 的地址。
net.Conn 类型
net.Conn
接口表示在网络上建立的两个节点之间的通用连接。它实现了io.Reader
和io.Writer
接口,允许连接的节点使用流式 IO 原语交换数据。net
包提供了net.Conn
接口的网络协议特定实现,如IPConn、UDPConn和TCPConn。每个实现都公开了特定于其各自网络和协议的附加方法。然而,正如我们将在本章中看到的,net.Conn 中定义的默认方法集对于大多数用途都是足够的。
拨号连接
客户端程序使用net.Dial
函数连接到网络上的主机服务,该函数具有以下签名:
func Dial(network, address string) (Conn, error)
该函数接受两个参数,其中第一个参数network指定连接的网络协议,可以是:
-
tcp
,tcp4
,tcp6
:tcp
默认为tcp4
-
udp
,udp4
,udp6
:udp
默认为udp4
-
ip
,ip4
,ip6
:ip
默认为ip4
-
unix
,unixgram
,unixpacket
:用于 Unix 域套接字
net.Dial
函数的后一个参数指定要连接的主机地址的字符串值。如前所述,地址可以提供为 IPv4 或 IPv6 地址。net.Dial
函数返回与指定网络参数匹配的net.Conn
接口的实现。
例如,以下代码片段拨号到主机地址的"tcp"
网络,www.gutenberg.org:80,返回*net.TCPConn
类型的 TCP 连接。简写代码使用 TCP 连接发出"HTTP GET"
请求,以从 Project Gutenberg 的网站(gutenberg.org/
)检索文学经典《贝奥武夫》的完整文本。然后将原始和未解析的 HTTP 响应写入本地文件beowulf.txt
:
func main() {
host, port := "www.gutenberg.org", "80"
addr := net.JoinHostPort(host, port)
httpRequest:="GET /cache/epub/16328/pg16328.txt HTTP/1.1\n" +
"Host: " + host + "\n\n"
conn, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
if _, err = conn.Write([]byte(httpRequest)); err != nil {
fmt.Println(err)
return
}
file, err := os.Create("beowulf.txt")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
io.Copy(file, conn)
fmt.Println("Text copied to file", file.Name())
}
golang.fyi/ch11/dial0.go
因为net.Conn
类型实现了io.Reader
和io.Writer
,它可以用于使用流式 IO 语义发送数据和接收数据。在前面的例子中,conn.Write([]byte(httpRequest))
将 HTTP 请求发送到服务器。主机返回的响应从conn
变量复制到file
变量,使用io.Copy(file, conn)
。
注意
请注意,前面的例子说明了如何使用原始 TCP 连接到 HTTP 服务器。Go 标准库提供了一个专门设计用于 HTTP 编程的单独包,它抽象了低级协议细节(在本章后面介绍)。
net
包还提供了网络特定的拨号函数,如DialUDP
,DiapTCP
或DialIP
,每个函数返回其相应的连接实现。在大多数情况下,net.Dial
函数和net.Conn
接口提供了连接和管理远程主机连接的足够能力。
监听传入的连接
创建服务程序时,首先要做的一步是宣布服务将用于监听来自网络的传入请求的端口。这是通过调用net.Listen
函数来完成的,该函数具有以下签名:
func Listen(network, laddr string) (net.Listener, error)
它需要两个参数,第一个参数指定了一个协议,有效的值为"tcp"
, "tcp4"
, "tcp6"
, "unix"
, 或 "unixpacket"
。
第二个参数是服务的本地主机地址。本地地址可以不带 IP 地址指定,如":4040"
。省略主机的 IP 地址意味着服务绑定到主机上安装的所有网络卡接口。作为替代,服务可以绑定到主机上特定的网络硬件接口,通过在网络上指定其 IP 地址,即"10.20.130.240:4040"
。
对net.Listen
函数的成功调用返回一个net.Listener
类型的值(或者如果失败,则返回一个非 nil 的错误)。net.Listener
接口公开了用于管理传入客户端连接的生命周期的方法。根据network
参数的值("tcp"
, "tcp4"
, "tcp6"
等),net.Listen
将返回net.TCPListener
或net.UnixListener
,它们都是net.Listener
接口的具体实现。
接受客户端连接
net.Listener
接口使用Accept方法无限期地阻塞,直到从客户端接收到一个新的连接。下面的简化代码片段显示了一个简单的服务器,它向每个客户端连接返回字符串"Nice to meet you!",然后立即断开连接:
func main() {
listener, err := net.Listen("tcp", ":4040")
if err != nil {
fmt.Println(err)
return
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println(err)
return
}
conn.Write([]byte("Nice to meet you!"))
conn.Close()
}
}
golang.fyi/ch11/listen0.go
在代码中,listener.Accept
方法返回一个net.Conn
类型的值,用于处理服务器和客户端之间的数据交换(或者如果失败,则返回一个非 nil 的error
)。conn.Write([]byte("Nice to meet you!"))
方法调用用于向客户端写入响应。当服务器程序正在运行时,可以使用telnet客户端进行测试,如下面的输出所示:
$> go run listen0.go &
[1] 83884
$> telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Nice to meet you! Connection closed by foreign host.
为了确保服务器程序继续运行并处理后续的客户端连接,Accept
方法的调用被包裹在一个无限的 for 循环中。一旦连接关闭,循环重新开始等待下一个客户端连接。还要注意,当服务器进程关闭时,最好调用Listener.Close()
关闭监听器。
注意
敏锐的读者可能会注意到,这个简单的服务器无法扩展,因为它一次无法处理多个客户端请求。在下一节中,我们将看到创建可扩展服务器的技术。
一个 TCP API 服务器
到目前为止,本章已经涵盖了创建客户端和服务程序所需的最小网络组件。本章的其余部分将讨论实现货币信息服务的服务器的不同版本。该服务在每个请求中返回 ISO 4217 货币信息。目的是展示使用不同应用级协议创建网络服务及其客户端的影响。
之前我们介绍了一个非常简单的服务器,以演示设置网络服务所需的必要步骤。本节通过创建一个能够处理许多并发连接的 TCP 服务器,深入探讨了网络编程。本节中呈现的服务器代码具有以下设计目标:
-
使用原始 TCP 在客户端和服务器之间进行通信
-
开发一个简单的基于文本的协议,通过 TCP 进行通信
-
客户端可以使用文本命令查询全球货币信息
-
使用 goroutine 处理每个连接以处理连接并发
-
保持连接直到客户端断开连接
以下是服务器代码的简化版本列表。该程序使用curr
包(在github.com/vladimirvivien/learning-go/ch11/curr0
找到),这里不讨论,用于将货币数据从本地 CSV 文件加载到切片currencies
中。
成功连接到客户端后,服务器使用简单的文本协议解析传入的客户端命令,格式为GET ,其中**指定用于搜索货币信息的字符串值:
import (
"net"
...
curr "https://github.com/vladimirvivien/learning-go/ch11/curr0"
)
var currencies = curr.Load("./data.csv")
func main() {
ln, _ := net.Listen("tcp", ":4040")
defer ln.Close()
// connection loop
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println(err)
conn.Close()
continue
}
go handleConnection(conn)
}
}
// handle client connection
func handleConnection(conn net.Conn) {
defer conn.Close()
// loop to stay connected with client
for {
cmdLine := make([]byte, (1024 * 4))
n, err := conn.Read(cmdLine)
if n == 0 || err != nil {
return
}
cmd, param := parseCommand(string(cmdLine[0:n]))
if cmd == "" {
continue
}
// execute command
switch strings.ToUpper(cmd) {
case "GET":
result := curr.Find(currencies, param)
// stream result to client
for _, cur := range result {
_, err := fmt.Fprintf(
conn,
"%s %s %s %s\n",
cur.Name, cur.Code,
cur.Number, cur.Country,
)
if err != nil {
return
}
// reset deadline while writing,
// closes conn if client is gone
conn.SetWriteDeadline(
time.Now().Add(time.Second * 5))
}
// reset read deadline for next read
conn.SetReadDeadline(
time.Now().Add(time.Second * 300))
default:
conn.Write([]byte("Invalid command\n"))
}
}
}
func parseCommand(cmdLine string) (cmd, param string) {
parts := strings.Split(cmdLine, " ")
if len(parts) != 2 {
return "", ""
}
cmd = strings.TrimSpace(parts[0])
param = strings.TrimSpace(parts[1])
return
}
golang.fyi/ch11/tcpserv0.go
与上一节介绍的简单服务器不同,这个服务器能够同时为多个客户端连接提供服务。在接受新连接时,使用ln.Accept()
委托新客户端连接的处理给一个 goroutine,使用go handleConnection(conn)
。连接循环立即继续,并等待下一个客户端连接。
handleConnection
函数管理与连接的客户端的服务器通信。它首先读取并解析来自客户端的字节片段,将其转换为命令字符串,使用cmd, param := parseCommand(string(cmdLine[0:n]))
。接下来,代码使用switch
语句测试命令。如果cmd
等于"GET"
,则代码使用curr.Find(currencies, param)
搜索切片currencies
以匹配param
的值。最后,它使用fmt.Fprintf(conn, "%s %s %s %s\n", cur.Name, cur.Code, cur.Number, cur.Country)
将搜索结果流式传输到客户端的连接。
服务器支持的简单文本协议不包括任何会话控制或控制消息。因此,代码使用conn.SetWriteDeadline
方法来确保与客户端的连接不会在长时间内不必要地挂起。该方法在向客户端流出响应的循环中调用。它设置了一个 5 秒的截止期限,以确保客户端始终准备好在该时间内接收下一块字节,否则它会超时连接。
使用 telnet 连接到 TCP 服务器
因为之前介绍的货币服务器使用了简单的基于文本的协议,所以可以使用 telnet 客户端进行测试,假设服务器代码已经编译并运行(并监听在端口4040
上)。以下是 telnet 会话查询服务器货币信息的输出:
$> telnet localhost 4040
Trying ::1...
Connected to localhost.
Escape character is '^]'.
GET Gourde
Gourde HTG 332 HAITI
GET USD
US Dollar USD 840 AMERICAN SAMOA
US Dollar USD 840 BONAIRE, SINT EUSTATIUS AND SABA
US Dollar USD 840 GUAM
US Dollar USD 840 HAITI
US Dollar USD 840 MARSHALL ISLANDS (THE)
US Dollar USD 840 UNITED STATES OF AMERICA (THE)
...
get india
Indian Rupee INR 356 BHUTAN
US Dollar USD 840 BRITISH INDIAN OCEAN TERRITORY (THE)
Indian Rupee INR 356 INDIA
如您所见,您可以使用get
命令查询服务器,后面跟随一个过滤参数,如前面所述。telnet 客户端将原始文本发送到服务器,服务器解析后以原始文本作为响应发送回来。您可以打开多个 telnet 会话与服务器连接,并且所有请求都在各自的 goroutine 中同时处理。
使用 Go 连接到 TCP 服务器
可以使用 Go 编写一个简单的 TCP 客户端来连接 TCP 服务器。客户端从控制台的标准输入中捕获命令,并将其发送到服务器,如下面的代码片段所示:
var host, port = "127.0.0.1", "4040"
var addr = net.JoinHostPort(host, port)
const prompt = "curr"
const buffLen = 1024
func main() {
conn, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
var cmd, param string
// repl - interactive shell for client
for {
fmt.Print(prompt, "> ")
_, err = fmt.Scanf("%s %s", &cmd, ¶m)
if err != nil {
fmt.Println("Usage: GET <search string or *>")
continue
}
// send command line
cmdLine := fmt.Sprintf("%s %s", cmd, param)
if n, err := conn.Write([]byte(cmdLine));
n == 0 || err != nil {
fmt.Println(err)
return
}
// stream and display response
conn.SetReadDeadline(
time.Now().Add(time.Second * 5))
for {
buff := make([]byte, buffLen)
n, err := conn.Read(buff)
if err != nil { break }
fmt.Print(string(buff[0:n]))
conn.SetReadDeadline(
time.Now().Add(time.Millisecond * 700))
}
}
}
golang.fyi/ch11/tcpclient0.go
Go 客户端的源代码遵循与之前客户端示例中相同的模式。代码的第一部分使用net.Dial()
拨号到服务器。一旦获得连接,代码设置了一个事件循环来捕获标准输入中的文本命令,解析它,并将其作为请求发送到服务器。
设置了一个嵌套循环来处理从服务器接收的响应(参见代码注释)。它不断将传入的字节流到buff
变量中,使用conn.Read(buff)
。这将一直持续,直到Read
方法遇到错误。以下列出了客户端执行时产生的示例输出:
$> Connected to Global Currency Service
curr> get pound
Egyptian Pound EGP 818 EGYPT
Gibraltar Pound GIP 292 GIBRALTAR
Sudanese Pound SDG 938 SUDAN (THE)
...
Syrian Pound SYP 760 SYRIAN ARAB REPUBLIC
Pound Sterling GBP 826 UNITED KINGDOM OF GREAT BRITAIN (THE)
curr>
从服务器流式传输传入的字节的更好方法是使用缓冲 IO,就像下面的代码片段中所做的那样。在更新的代码中,conbuf
变量,类型为bufio.Buffer
,用于使用conbuf.ReadString
方法读取和拆分从服务器传入的流:
conbuf := bufio.NewReaderSize(conn, 1024)
for {
str, err := conbuf.ReadString('\n')
if err != nil {
break
}
fmt.Print(str)
conn.SetReadDeadline(
time.Now().Add(time.Millisecond * 700))
}
golang.fyi/ch11/tcpclient1.go
正如您所看到的,直接在原始 TCP 之上编写网络服务会产生一些成本。虽然原始 TCP 使程序员完全控制应用程序级协议,但它也要求程序员仔细处理所有数据处理,这可能容易出错。除非绝对必要实现自定义协议,否则更好的方法是利用现有和经过验证的协议来实现服务器程序。本章的其余部分将继续探讨这个主题,使用基于 HTTP 的服务作为应用级协议。
HTTP 包
由于其重要性和普遍性,HTTP 是 Go 中直接实现的少数协议之一。net/http
包(golang.org/pkg/net/http/
)提供了实现 HTTP 客户端和 HTTP 服务器的代码。本节探讨了使用net/http
包创建 HTTP 客户端和服务器的基础知识。稍后,我们将把注意力转回使用 HTTP 构建货币服务的版本。
http.Client 类型
http.Client
结构表示一个 HTTP 客户端,用于创建 HTTP 请求并从服务器检索响应。以下说明了如何使用http.Client
类型的client
变量从 Project Gutenberg 网站的gutenberg.org/cache/epub/16328/pg16328.txt
检索 Beowulf 的文本内容,并将其内容打印到标准输出:
func main() {
client := http.Client{}
resp, err := client.Get(
" http://gutenberg.org/cache/epub/16328/pg16328.txt")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient1.go
前面的示例使用client.Get
方法使用 HTTP 协议的GET
方法从远程服务器检索内容。GET
方法是Client
类型提供的几种方便方法之一,用于与 HTTP 服务器交互,如下表所总结的。请注意,所有这些方法都返回*http.Response
类型的值(稍后讨论),以处理 HTTP 服务器返回的响应。
方法 | 描述 |
---|
| Client.Get
| 正如前面讨论的,Get
是一个方便的方法,用于向服务器发出 HTTP GET
方法,以从服务器检索由url
参数指定的资源:
Get(url string,
) (resp *http.Response, err error)
|
| Client.Post
| Post
方法是一个方便的方法,用于向服务器发出 HTTP POST
方法,以将body
参数指定的内容发送到url
参数指定的服务器:
Post(
url string,
bodyType string,
body io.Reader,
) (resp *http.Response, err error)
|
| Client.PostForm
| PostForm
方法是一个方便的方法,使用 HTTP POST
方法将表单data
作为映射的键/值对发送到服务器:
PostForm(
url string,
data url.Values,
) (resp *http.Response, err error)
|
| Client.Head
| Head
方法是一个方便的方法,用于向由url
参数指定的远程服务器发出 HTTP 方法HEAD
:
Head(url string,
)(resp *http.Response, err error)
|
Client.Do | 该方法概括了与远程 HTTP 服务器的请求和响应交互。它在内部被表中列出的方法包装。处理客户端请求和响应部分讨论了如何使用该方法与服务器通信。 |
---|
应该注意的是,HTTP 包使用内部的http.Client
变量,设计为将前述方法作为包函数进一步方便地进行镜像。它们包括http.Get
、*http.Post*
、http.PostForm
和http.Head
。以下代码片段显示了前面的示例,使用http.Get
而不是http.Client
的方法:
func main() {
resp, err := http.Get(
"http://gutenberg.org/cache/epub/16328/pg16328.txt")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient1a.go
配置客户端
除了与远程服务器通信的方法之外,http.Client
类型还公开了其他属性,可用于修改和控制客户端的行为。例如,以下源代码片段使用Client
类型的Timeout
属性将超时设置为 21 秒,以处理客户端请求的完成:
func main() {
client := &http.Client{
Timeout: 21 * time.Second
}
resp, err := client.Get(
"http://tools.ietf.org/rfc/rfc7540.txt")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient2.go
Client
类型的Transport
字段提供了进一步控制客户端设置的手段。例如,以下代码片段创建了一个禁用连续 HTTP 请求之间连接重用的客户端,使用了DisableKeepAlive
字段。该代码还使用Dial
函数来进一步精细控制底层客户端使用的 HTTP 连接,将其超时值设置为 30 秒:
func main() {
client := &http.Client{
Transport: &http.Transport{
DisableKeepAlives: true,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
}).Dial,
},
}
...
}
处理客户端请求和响应
可以使用http.NewRequest
函数显式创建一个http.Request
值。请求值可用于配置 HTTP 设置,添加头部并指定请求的内容主体。以下源代码片段使用http.Request
类型创建一个新请求,该请求用于指定发送到服务器的头部:
func main() {
client := &http.Client{}
req, err := http.NewRequest(
"GET", "http://tools.ietf.org/rfc/rfc7540.txt", nil,
)
req.Header.Add("Accept", "text/plain")
req.Header.Add("User-Agent", "SampleClient/1.0")
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
}
golang.fyi/ch11/httpclient3.go
http.NewRequest
函数具有以下签名:
func NewRequest(method, uStr string, body io.Reader) (*http.Request, error)
它以一个字符串作为第一个参数,该字符串指定了 HTTP 方法。下一个参数指定了目标 URL。最后一个参数是一个io.Reader
,用于指定请求的内容(如果请求没有内容,则设置为 nil)。该函数返回一个指向http.Request
结构值的指针(如果发生错误,则返回非 nil 的error
)。一旦请求值被创建,代码就可以使用Header
字段向请求添加 HTTP 头,以便发送到服务器。
一旦请求准备好(如前面源代码片段所示),就可以使用http.Client
类型的Do方法将其发送到服务器,该方法具有以下签名:
Do(req *http.Request) (*http.Response, error)
该方法接受一个指向http.Request
值的指针,如前一节所述。然后返回一个指向http.Response
值的指针,或者如果请求失败则返回一个错误。在前面的源代码中,使用resp, err := client.Do(req)
将请求发送到服务器,并将响应分配给resp
变量。
服务器的响应封装在http.Response
结构中,其中包含几个字段来描述响应,包括 HTTP 响应状态、内容长度、头部和响应体。响应体作为http.Response.Body
字段公开,实现了io.Reader
,可以使用流式 IO 原语来消耗响应内容。
Body
字段还实现了*io.Closer*
,允许关闭 IO 资源。前面的源代码使用defer resp.Body.Close()
来关闭与响应体关联的 IO 资源。当服务器预期返回非 nil 主体时,这是一个推荐的习惯用法。
一个简单的 HTTP 服务器
HTTP 包提供了两个主要组件来接受 HTTP 请求和提供响应:
-
http.Handler
接口 -
http.Server
类型
http.Server
类型使用http.Handler
接口类型,如下列表所示,用于接收请求和服务器响应:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
任何实现http.Handler
的类型都可以注册(下面会解释)为有效的处理程序。Go http.Server
类型用于创建一个新的服务器。它是一个结构体,其值可以被配置,至少包括服务的 TCP 地址和一个将响应传入请求的处理程序。以下代码片段显示了一个简单的 HTTP 服务器,将msg
类型定义为注册的处理程序来处理传入的客户端请求:
type msg string
func (m msg) ServeHTTP(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, m)
}
func main() {
msgHandler := msg("Hello from high above!")
server := http.Server{Addr: ":4040", Handler: msgHandler}
server.ListenAndServe()
}
golang.fyi/ch11/httpserv0.go
在前面的代码中,msg
类型使用字符串作为其基础类型,实现了ServeHTTP()
方法,使其成为有效的 HTTP 处理程序。它的ServeHTTP
方法使用响应参数resp
来打印响应头"200 OK"
和"Content-Type: text/html"
。该方法还使用fmt.Fprint(resp, m)
将字符串值m
写入响应变量,然后发送回客户端。
在代码中,变量server
被初始化为http.Server{Addr: ":4040", Handler: msgHandler}
。这意味着服务器将在端口4040
上监听所有网络接口,并将使用变量msgHandler
作为其http.Handler
实现。一旦初始化,服务器就会使用server.ListenAndServe()
方法调用来启动,该方法用于阻塞并监听传入的请求。
除了Addr
和Handler
之外,http.Server
结构还公开了几个额外的字段,可以用来控制 HTTP 服务的不同方面,例如连接、超时值、标头大小和 TLS 配置。例如,以下代码片段显示了一个更新后的示例,其中指定了服务器的读取和写入超时:
type msg string
func (m msg) ServeHTTP(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, m)
}
func main() {
msgHandler := msg("Hello from high above!")
server := http.Server{
Addr: ":4040",
Handler: msgHandler,
ReadTimeout: time.Second * 5,
WriteTimeout: time.Second * 3,
}
server.ListenAndServe()
}
golang.fyi/ch11/httpserv1.go
默认服务器
值得注意的是,HTTP 包包括一个默认服务器,可以在不需要配置服务器的简单情况下使用。以下简化的代码片段启动了一个简单的服务器,而无需显式创建服务器变量:
type msg string
func (m msg) ServeHTTP(
resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, m)
}
func main() {
msgHandler := msg("Hello from high above!")
http.ListenAndServe(":4040", msgHandler)
}
golang.fyi/ch11/httpserv2.go
在代码中,使用http.ListenAndServe(":4040", msgHandler)
函数来启动一个服务器,该服务器被声明为 HTTP 包中的一个变量。服务器配置为使用本地地址":4040"
和处理程序msgHandler
(与之前一样)来处理所有传入的请求。
使用 http.ServeMux 路由请求
在上一节介绍的http.Handler
实现并不复杂。无论请求中发送了什么 URL 路径,它都会向客户端发送相同的响应。这并不是很有用。在大多数情况下,您希望将请求 URL 的每个路径映射到不同的响应。
幸运的是,HTTP 包带有http.ServeMux
类型,它可以根据 URL 模式复用传入的请求。当http.ServeMux
处理程序接收到与 URL 路径关联的请求时,它会分派一个映射到该 URL 的函数。以下简化的代码片段显示了http.ServeMux
变量mux
配置为处理两个 URL 路径"/hello"
和"/goodbye"
:
func main() {
mux := http.NewServeMux()
hello := func(resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, "Hello from Above!")
}
goodbye := func(resp http.ResponseWriter, req *http.Request) {
resp.Header().Add("Content-Type", "text/html")
resp.WriteHeader(http.StatusOK)
fmt.Fprint(resp, "Goodbye, it's been real!")
}
mux.HandleFunc("/hello", hello)
mux.HandleFunc("/goodbye", goodbye)
http.ListenAndServe(":4040", mux)
}
golang.fyi/ch11/httpserv3.go
该代码声明了两个分配给变量hello
和goodbye
的函数。每个函数分别映射到路径"/hello"
和"/goodbye"
,使用mux.HandleFunc("/hello", hello)
和mux.HandleFunc("/goodbye", goodbye)
方法调用。当服务器启动时,使用http.ListenAndServe(":4040", mux)
,其处理程序将将请求"http://localhost:4040/hello"
路由到hello
函数,并将路径为"http://localhost:4040/goodbye"
的请求路由到goodbye
函数。
默认的 ServeMux
值得指出的是,HTTP 包在内部提供了一个默认的 ServeMux。当使用时,不需要显式声明 ServeMux 变量。相反,代码使用包函数http.HandleFunc
将路径映射到处理程序函数,如下面的代码片段所示:
func main() {
hello := func(resp http.ResponseWriter, req *http.Request) {
...
}
goodbye := func(resp http.ResponseWriter, req *http.Request) {
...
}
http.HandleFunc("/hello", hello)
http.HandleFunc("/goodbye", goodbye)
http.ListenAndServe(":4040", nil)
}
golang.fyi/ch11/httpserv4.go
要启动服务器,代码调用http.ListenAndServe(":4040", nil)
,其中 ServerMux 参数设置为nil
。这意味着服务器将默认使用预声明的 http.ServeMux 包实例来处理传入的请求。
一个 JSON API 服务器
有了上一节的信息,可以使用 HTTP 包在 HTTP 上创建服务。早些时候,我们讨论了使用原始 TCP 直接创建服务的危险,当时我们为全球货币服务创建了一个服务器。在本节中,我们将探讨如何使用 HTTP 作为底层协议为相同的服务创建 API 服务器。新的基于 HTTP 的服务具有以下设计目标:
-
使用 HTTP 作为传输协议
-
使用 JSON 进行客户端和服务器之间的结构化通信
-
客户端使用 JSON 格式的请求查询服务器的货币信息
-
服务器使用 JSON 格式的响应
以下显示了实现新服务所涉及的代码。这次,服务器将使用curr1
包(参见github.com/vladimirvivien/learning-go/ch11/curr1)从本地 CSV 文件加载和查询 ISO 4217 货币数据。
curr1 包中的代码定义了两种类型,CurrencyRequest
和Currency
,分别用于表示客户端请求和服务器返回的货币数据,如下所示:
type Currency struct {
Code string `json:"currency_code"`
Name string `json:"currency_name"`
Number string `json:"currency_number"`
Country string `json:"currency_country"`
}
type CurrencyRequest struct {
Get string `json:"get"`
Limit int `json:limit`
}
golang.fyi/ch11/curr1/currency.go
请注意,上述显示的结构类型带有标签,描述了每个字段的 JSON 属性。这些信息由 JSON 编码器用于编码 JSON 对象的键名(有关编码的详细信息,请参见第十章,“Go 中的数据 IO”)。以下代码的其余部分定义了设置服务器和处理传入请求的函数:
import (
"encoding/json"
"fmt"
"net/http"
" github.com/vladimirvivien/learning-go/ch11/curr1"
)
var currencies = curr1.Load("./data.csv")
func currs(resp http.ResponseWriter, req *http.Request) {
var currRequest curr1.CurrencyRequest
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&currRequest); err != nil {
resp.WriteHeader(http.StatusBadRequest)
fmt.Println(err)
return
}
result := curr1.Find(currencies, currRequest.Get)
enc := json.NewEncoder(resp)
if err := enc.Encode(&result); err != nil {
fmt.Println(err)
resp.WriteHeader(http.StatusInternalServerError)
return
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/currency", get)
if err := http.ListenAndServe(":4040", mux); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch11/jsonserv0.go
由于我们正在利用 HTTP 作为服务的传输协议,可以看到代码现在比之前使用纯 TCP 的实现要小得多。currs
函数实现了处理传入请求的处理程序。它设置了一个解码器,用于将传入的 JSON 编码请求解码为curr1.CurrencyRequest
类型的值,如下面的代码片段所示:
var currRequest curr1.CurrencyRequest
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&currRequest); err != nil { ... }
接下来,该函数通过调用curr1.Find(currencies, currRequest.Get)
执行货币搜索,该函数返回分配给result
变量的[]Currency
切片。然后,代码创建一个编码器,将result
编码为 JSON 有效载荷,如下面的代码片段所示:
result := curr1.Find(currencies, currRequest.Get)
enc := json.NewEncoder(resp)
if err := enc.Encode(&result); err != nil { ... }
最后,处理程序函数在main
函数中通过调用mux.HandleFunc("/currency", currs)
映射到"/currency"
路径。当服务器收到该路径的请求时,它会自动执行currs
函数。
使用 cURL 测试 API 服务器
由于服务器是通过 HTTP 实现的,因此可以使用支持 HTTP 的任何客户端工具轻松测试。例如,以下显示了如何使用cURL
命令行工具(curl.haxx.se/
)连接到 API 端点并检索有关Euro
的货币信息:
$> curl -X POST -d '{"get":"Euro"}' http://localhost:4040/currency
[
...
{
"currency_code": "EUR",
"currency_name": "Euro",
"currency_number": "978",
"currency_country": "BELGIUM"
},
{
"currency_code": "EUR",
"currency_name": "Euro",
"currency_number": "978",
"currency_country": "FINLAND"
},
{
"currency_code": "EUR",
"currency_name": "Euro",
"currency_number": "978",
"currency_country": "FRANCE"
},
...
]
cURL
命令使用-X POST -d '{"get":"Euro"}'
参数向服务器发布 JSON 格式的请求对象。服务器的输出(格式化以便阅读)由前述货币项目的 JSON 数组组成。
Go 中的 API 服务器客户端
HTTP 客户端也可以在 Go 中构建,以最小的努力来消耗服务。如下面的代码片段所示,客户端代码使用http.Client
类型与服务器通信。它还使用encoding/json
子包来解码传入的数据(请注意,客户端还使用了之前显示的curr1
包,其中包含与服务器通信所需的类型):
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
" github.com/vladimirvivien/learning-go/ch11/curr1"
)
func main() {
var param string
fmt.Print("Currency> ")
_, err := fmt.Scanf("%s", ¶m)
buf := new(bytes.Buffer)
currRequest := &curr1.CurrencyRequest{Get: param}
err = json.NewEncoder(buf).Encode(currRequest)
if err != nil {
fmt.Println(err)
return
}
// send request
client := &http.Client{}
req, err := http.NewRequest(
"POST", "http://127.0.0.1:4040/currency", buf)
if err != nil {
fmt.Println(err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
// decode response
var currencies []curr1.Currency
err = json.NewDecoder(resp.Body).Decode(¤cies)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(currencies)
}
golang.fyi/ch11/jsonclient0.go
在前面的代码中,创建了一个 HTTP 客户端以将 JSON 编码的请求值发送为currRequest := &curr1.CurrencyRequest{Get: param}
,其中param
是要检索的货币字符串。服务器的响应是表示 JSON 编码对象数组的有效负载(请参见使用 cURL 测试 API 服务器部分中的 JSON 数组)。然后代码使用 JSON 解码器json.NewDecoder(resp.Body).Decode(¤cies)
将响应体中的有效负载解码为切片[]curr1.Currency
。
JavaScript API 服务器客户端
到目前为止,我们已经看到了如何使用cURL
命令行工具和本机 Go 客户端使用 API 服务。本节展示了使用 HTTP 实现网络服务的多功能性,通过展示基于 Web 的 JavaScript 客户端。在这种方法中,客户端是一个基于 Web 的 GUI,使用现代 HTML、CSS 和 JavaScript 创建一个与 API 服务器交互的界面。
首先,服务器代码更新为添加一个处理程序,以提供在浏览器上呈现 GUI 的静态 HTML 文件。以下是示例代码:
// serves HTML gui
func gui(resp http.ResponseWriter, req *http.Request) {
file, err := os.Open("./currency.html")
if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
fmt.Println(err)
return
}
io.Copy(resp, file)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", gui)
mux.HandleFunc("/currency", currs)
if err := http.ListenAndServe(":4040", mux); err != nil {
fmt.Println(err)
}
}
golang.fyi/ch11/jsonserv1.go
前面的代码片段显示了gui
处理程序函数的声明,负责提供用于为客户端呈现 GUI 的静态 HTML 文件。然后将根 URL 路径映射到该函数,使用mux.HandleFunc("/", gui)
。因此,除了"/currency"
路径(托管 API 端点),"/"
路径将返回以下截图中显示的网页:
下一个 HTML 页面(golang.fyi/ch11/currency.html)负责显示货币搜索结果。它使用 JavaScritpt 函数以及jQuery.js
库(此处未涵盖)来将 JSON 编码的请求发送到后端 Go 服务,如下所示的缩写 HTML 和 JavaScript 片段:
<body>
<div class="container">
<h2>Global Currency Service</h2>
<p>Enter currency search string: <input id="in">
<button type="button" class="btn btn-primary" onclick="doRequest()">Search</button>
</p>
<table id="tbl" class="table table-striped">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Number</th>
<th>Country</th>
</tr>
</thead>
<tbody/>
</table>
</div>
<script>
var tbl = document.getElementById("tbl");
function addRow(code, name, number, country) {
var rowCount = tbl.rows.length;
var row = tbl.insertRow(rowCount);
row.insertCell(0).innerHTML = code;
row.insertCell(1).innerHTML = name;
row.insertCell(2).innerHTML = number;
row.insertCell(3).innerHTML = country;
}
function doRequest() {
param = document.getElementById("in").value
$.ajax('/currency', {
method: 'PUT',
contentType: 'application/json',
processData: false,
data: JSON.stringify({get:param})
}).then(
function success(currencies) {
currs = JSON.parse(currencies)
for (i=0; i < currs.length; i++) {
addRow(
currs[i].currency_code,
currs[i].currency_name,
currs[i].currency_number,
currs[i].currency_country
);
}
});
}
</script>
golang.fyi/ch11/currency.html
对于本示例中 HTML 和 JavaScript 代码的逐行分析超出了本书的范围;然而,值得指出的是,JavaScript 的doRequest
函数是客户端和服务器之间交互发生的地方。它使用 jQuery 的$.ajax
函数构建一个带有PUT
方法的 HTTP 请求,并指定一个 JSON 编码的货币请求对象JSON.stringify({get:param})
发送到服务器。then
方法接受回调函数success(currencies)
,处理来自服务器的响应并在 HTML 表格中显示。
当在 GUI 的文本框中提供搜索值时,页面会动态地在表格中显示其结果,如下截图所示:
摘要
本章节总结了关于在 Go 中创建网络服务的几个重要概念。它从 Go 的net
包开始,包括net.Conn
类型用于在网络节点之间创建连接,net.Dial
函数用于连接到远程服务,以及net.Listen
函数用于处理来自客户端的传入连接。本章继续涵盖了客户端和服务器程序的不同实现,并展示了直接在原始 TCP 上创建自定义协议与使用现有协议(如带有 JSON 数据格式的 HTTP)的影响。
下一章采取了不同的方向。它探讨了在 Go 中可用的包、类型、函数和工具,以便进行源代码测试。