Go-系统编程(全)

Go 系统编程(全)

原文:zh.annas-archive.org/md5/2DB8F67A356AEFD794B578E9C4995B3C

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

《Go 系统编程》是一本将帮助您使用 Go 开发系统软件的书,它是一种系统编程语言,最初是作为谷歌内部项目开始的,后来变得很受欢迎。Go 之所以如此受欢迎,是因为它让开发人员感到愉快,易于编写、易于阅读、易于理解,并且有一个编译器可以帮助您。这本书并未涵盖 Go 编程语言的每一个可能方面和特性,只涉及与系统编程相关的内容。如果您希望了解更多关于 Go 编程语言的知识,您可以期待我的下一本书《精通 Go》,它将于 2018 年出版!

你即将阅读的书是一本诚实的书,它将呈现工作中的 Go 代码,而不会忽视其潜在缺陷、限制和逻辑错误,这将使您能够自行改进并在未来创建更好的版本。您将无法改进的是将呈现的基本信息,这是 Unix 系统工作方式的基础。如果这本书能帮助您理解系统编程的重要性以及如何开始使用 Go 开发系统软件,我将认为这本书是成功的。如果 Go 成为您最喜欢的编程语言,我同样会感到高兴!

本书涵盖的内容

《第一章》《开始使用 Go 和 Unix 系统编程》首先定义了系统编程是什么,然后讨论了 Go 的优缺点、Go 版本 1.8 的特性,两个方便的 Go 工具gofmtgodoc,以及 Unix 进程的各种状态。

《第二章》《使用 Go 编写程序》帮助您学习如何编译 Go 代码以及如何使用 Go 支持的环境变量,并了解 Go 如何读取程序的命令行参数。然后,我们将讨论获取用户输入和输出,这是基本任务,向您展示如何在 Go 中定义函数,本书中首次提到defer关键字,并继续讨论 Go 提供的数据结构,使用方便的代码示例。在本章的其余部分,我们将讨论 Go 接口和随机数生成。我相信您会喜欢这一章节!

《第三章》《高级 Go 特性》深入探讨了一些高级 Go 特性,包括错误处理,在开发系统软件和错误记录时至关重要。然后介绍了模式匹配和正则表达式、Go 反射,并讨论了不安全的代码。之后,它将 Go 与其他编程语言进行了比较,并介绍了两个实用程序,名为dtrace(1)strace(1),它们可以让您在执行程序时看到幕后发生的事情。最后,它讨论了如何使用go tool检测不可达代码以及如何避免一些常见的 Go 错误。

《第四章》《Go 包、算法和数据结构》讨论了 Go 中的算法和排序,以及需要 Go 版本 1.8 或更新版本的sort.Slice()函数。然后展示了链表、二叉树和哈希表的 Go 实现。之后,它讨论了 Go 包,并教您如何创建和使用自己的 Go 包。本章的最后部分讨论了 Go 中的垃圾回收。

第五章,“文件和目录”,是本书中首个涉及系统编程主题的章节,涉及文件、符号链接和目录的处理。在本章中,你将找到 Unix 工具的核心功能的 Go 实现,比如which(1)pwd(1)find(1),但首先你将学习如何使用flag包来解析 Go 程序的命令行参数和选项。此外,你还将学习如何删除、重命名和移动文件,以及如何以 Go 方式遍历目录结构。本章的最后部分实现了一个实用程序,用于创建目录结构的所有目录的副本!

第六章,“文件输入和输出”,向你展示如何读取文件的内容,如何更改文件内容,以及如何将自己的数据写入文件!在本章中,你将了解io包、io.Writerio.Reader接口,以及用于缓冲输入和输出的bufio包。你还将创建cp(1)wc(1)dd(1)实用程序的 Go 版本。最后,你将了解稀疏文件,如何在 Go 中创建稀疏文件,如何从文件中读取和写入记录,以及如何在 Go 中锁定文件。

第七章,“处理系统文件”,教你如何处理 Unix 系统文件,包括向 Unix 日志文件写入数据、向现有文件追加数据以及修改文本文件的数据。在本章中,你还将了解标准的 Go 包loglog/syslog,Unix 文件权限,以及使用实际示例进一步学习模式匹配和正则表达式知识。你还将了解如何找到用户的用户 ID 以及用户所属的 Unix 组。最后,你将了解如何使用time包在 Go 中处理日期和时间,以及如何自己创建和旋转日志文件。

第八章,“进程和信号”,首先讨论了在 Go 中如何处理 Unix 信号,借助os/signal包展示了三个 Go 程序。然后展示了一个可以使用信号和信号处理来旋转其日志文件的 Go 程序,以及另一个使用信号来展示文件复制操作进度的 Go 程序。本章还将教你如何在 Go 中绘制数据以及如何在 Go 中实现 Unix 管道。然后将在 Go 中实现cat(1)实用程序,然后简要介绍 Unix 套接字客户端的 Go 代码。本章的最后一部分快速讨论了如何在 Go 中编写 Unix shell。

第九章,“Goroutines - 基本特性”,讨论了一个非常重要的 Go 主题,即 goroutines,讨论了如何创建 goroutines 以及如何同步它们并在结束程序之前等待它们完成。然后讨论了通道和管道,这有助于 goroutines 以安全的方式进行通信和交换数据。本章的最后部分呈现了一个使用 goroutines 实现的wc(1)实用程序的版本。然而,由于 goroutines 是一个庞大的主题,下一章将继续讨论它们。

第十章,“Goroutines - 高级特性”,讨论了与 goroutines 和通道相关的更高级的主题,包括缓冲通道、信号通道、空通道、通道的通道、超时和select关键字。然后讨论了与共享内存和互斥锁相关的问题,然后呈现了两个使用通道和共享内存的wc(1)实用程序的更多 Go 版本。最后,本章将讨论竞争条件和GOMAXPROCS环境变量。

第十一章,使用 Go 编写 Web 应用程序,讨论了在 Go 中开发 Web 应用程序和 Web 服务器以及客户端。此外,它还讨论了使用 Go 代码与 MongoDB 和 MySQL 数据库进行通信。然后,它说明了如何使用html/template包,该包是 Go 标准库的一部分,允许您使用 Go HTML 模板文件生成 HTML 输出。最后,它讨论了读取和写入 JSON 数据,然后呈现了一个实用程序,该实用程序读取多个网页并返回在这些网页中找到给定关键字的次数。

第十二章,网络编程,讨论了使用net Go 标准包涉及的 TCP/IP 及其协议的主题。它向您展示了如何创建 TCP 和 UDP 客户端和服务器,如何执行各种类型的 DNS 查找,以及如何使用 Wireshark 检查网络流量。此外,它还讨论了在 Go 中开发 RPC 客户端和服务器,以及开发 Unix 套接字服务器和 Unix 套接字客户端。

正如您将看到的,每章结束时都有一些练习供您完成,以便获取有关重要 Go 包的更多信息并编写自己的 Go 程序。请尝试完成本书的所有练习。

您需要为本书做些什么

这本书需要一台运行 Unix 变种的计算机,其中包括运行 Mac OS X、macOS 或 Linux 的任何机器上都有相对较新的 Go 版本。

苹果过去将其操作系统称为 Mac OS X,后面跟着版本号;然而,在 Mac OS X 10.11(El Capitan)之后,苹果进行了更改,现在 Mac OS X 10.12 被称为 macOS 10.12(Sierra)-在本书中,Mac OS X 和 macOS 这两个术语是可以互换使用的。此外,很有可能在您阅读本书时,最新版本的 macOS 将是 macOS 10.13(High Sierra)。您可以通过访问en.wikipedia.org/wiki/MacOS了解更多关于各个版本 macOS 的信息。

本书中的所有 Go 代码都经过了在运行 macOS 10.12 Sierra 的 iMac 上运行 Go 1.8.x 以及在运行 Debian Linux 机器上运行 Go 版本 1.3.3 的测试。大部分代码可以在这两个 Go 版本上运行而无需任何代码更改。然而,当使用较新的 Go 功能时,代码将无法在 Go 1.3.3 上编译:本书指出了不会在 Go 版本 1.3.3 上编译或需要 Go 版本 1.8 或更新的 Go 程序。

请注意,在撰写本文时,最新的 Go 版本是 1.9。鉴于 Go 的工作方式,您将能够在更新的 Go 版本中编译本书中的所有 Go 代码而无需任何更改。

这本书是为谁准备的

这本书适用于 Unix 用户、高级 Unix 用户、Unix 系统管理员和 Unix 系统开发人员,他们在一个或多个 Unix 变种上使用 Go,并希望开始使用 Go 编程语言开发系统软件。

尽管这本书可能不适合对 Unix 操作系统不太熟悉或没有编程经验的人,但业余程序员将会找到大量关于 Unix 的实用信息,这可能会激发他们开始开发自己的系统实用程序。

惯例

在本书中,您会发现一些区分不同类型信息的文本样式。以下是一些这些样式的示例以及它们的含义解释。文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“这是因为main()函数是程序执行开始的地方。”

代码块设置如下:

package main 

import "fmt" 
import "os" 

func main() { 
   arguments := os.Args 
   for i := 0; i < len(arguments); i++ { 
         fmt.Println(arguments[i]) 
   } 
} 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

package main 

import "fmt" 
import "os" 

func main() { 
   arguments := os.Args 
   for i := 0; i < len(arguments); i++ { 
         fmt.Println(arguments[i]) 
   } 
} 

任何命令行输入或输出都以以下形式书写:

$ go run hw.go
Hello World!  

新术语重要单词以粗体显示。

警告或重要说明会出现在这样的形式。

提示和技巧会出现在这样的形式。

第一章:使用 Go 和 Unix 系统编程入门

操作系统是一种允许您与硬件通信的软件,这意味着没有操作系统,您无法使用硬件。Unix 是一种具有许多变体的操作系统,它们有许多共同点,包括它们的编程接口。

Unix 操作系统主要是用 C 语言编程的,而不是完全用汇编语言,这使得它可以在其他计算机架构上移植,而无需从头开始重写所有内容。重要的是要理解,即使您在 Unix 机器上开发 Go 程序,最终您的代码也会被翻译成 C 函数和系统调用,因为这是直接与 Unix 内核通信的唯一方式。与编写 C 代码相比,编写 Go 代码的主要好处是程序更小,bug 更少。您将在第三章中了解更多关于这个的内容,高级 Go 特性

由于本书将使用 Go 语言,您需要在 Unix 机器上安装 Go 的一个版本。好消息是,几乎所有现代 Unix 系统,包括 macOS、Linux 和 FreeBSD 都有 Go 编程语言的端口。Windows 也有一个 Go 端口,但本书不涉及 Microsoft Windows。

尽管您的 Unix 变体很可能有一个 Go 软件包,但您也可以从golang.org/dl/获取 Go。

在本章中,您将学习以下主题:

  • 系统编程

  • Go 的优缺点

  • Unix 进程的状态

  • 两个 Go 工具:gofmtgodoc

  • 最新 Go 版本(1.8)的特性

本书的结构

本书分为三个部分。第一部分,包括本章,是关于 Go 和在开发系统软件时可能有用的 Go 特性:这并不意味着您在开发程序时应该使用所有这些特性。第二部分是关于文件、目录和进程编程,这是最常见的系统软件类型。第三部分探讨了在 Go 中使用 goroutines、Web 应用程序和网络编程,这是最高级的系统软件类型。好消息是,您不需要立即阅读本书的第三部分。

什么是系统编程?

系统编程是 Unix 机器上的一个特殊编程领域。请注意,系统编程并不局限于 Unix 机器:本书只涉及 Unix 操作系统。大多数与系统管理任务有关的命令,如磁盘格式化、网络接口配置、模块加载和内核性能跟踪,都是使用系统编程技术实现的。此外,在所有 Unix 系统上都可以找到的/etc目录包含处理 Unix 机器及其服务配置的纯文本文件,也是使用系统软件进行操作的。

您可以将系统软件的各个领域和相关系统调用分为以下几类:

  • 文件 I/O:这个领域涉及文件读写操作,这是操作系统最重要的任务。文件输入和输出必须快速高效,最重要的是可靠。

  • 高级文件 I/O:除了基本的输入和输出系统调用外,还有更高级的读写文件的方法,包括异步 I/O 和非阻塞 I/O。

  • 系统文件和配置:这组系统软件包括允许您处理系统文件(如/etc/passwd)并获取系统特定信息(如系统时间和 DNS 配置)的函数。

  • 文件和目录:这个集群包括允许程序员创建和删除目录以及获取文件或目录的所有者和权限等信息的函数和系统调用。

  • 进程控制:这组软件允许您创建和与 Unix 进程交互。

  • 线程:当一个进程有多个线程时,它可以执行多个任务。然而,线程必须被创建、终止和同步,这就是这组函数和系统调用的目的。

  • 服务器进程:这一集合包括允许您开发服务器进程的技术,这些进程可以在后台执行,而无需活动终端。Go 在传统的 Unix 方式下编写服务器进程方面并不那么出色:但让我稍微解释一下。像 Apache 这样的 Unix 服务器使用fork(2)来创建一个或多个子进程(这个过程称为forking,指的是将父进程克隆成子进程),并继续从同一点执行相同的可执行文件,最重要的是,共享内存。虽然 Go 没有提供与fork(2)函数等效的功能,但这并不是问题,因为您可以使用 goroutines 来覆盖大部分fork(2)的用途。

  • 进程间通信:这组函数允许在同一台 Unix 机器上运行的进程使用管道、FIFO、消息队列、信号量和共享内存等特性进行通信。

  • 信号处理:信号为进程提供了处理异步事件的方法,这可能非常方便。几乎所有服务器进程都有额外的代码,允许它们使用该组的系统调用来处理 Unix 信号。

  • 网络编程:这是开发利用 TCP/IP 在计算机网络上工作的应用程序的艺术,并不是系统编程本身。然而,大多数 TCP/IP 服务器和客户端都涉及系统资源、用户、文件和目录。因此,大多数情况下,您不能创建网络应用程序而不进行某种形式的系统编程。

系统编程的挑战在于您不能容忍不完整的程序;您要么有一个完全可用、安全的程序,可以在生产系统上使用,要么什么都没有。这主要是因为您不能信任最终用户和黑客。系统编程的关键困难在于错误的系统调用可能会使您的 Unix 机器行为异常,甚至更糟糕的是崩溃!

Unix 系统上的大多数安全问题通常来自错误实现的系统软件,因为系统软件中的错误可能会危及整个系统的安全。最糟糕的是,这可能会在使用某个特定软件多年后发生。

在编写系统软件时,您应该特别注意错误消息和警告,因为它们是帮助您理解发生了什么以及为什么您的程序没有按预期行为的朋友。简而言之,文件未找到没有足够的权限读取文件错误消息之间存在着很大的区别。

在 Unix 首次引入时,编写系统软件的唯一方法是使用 C;如今,您可以使用包括 Go 在内的编程语言来编写系统软件,本书将介绍 Go。

您应该明白,使用 C 以外的编程语言开发系统软件的两个主要好处如下:

  • 使用现代编程语言及其工具

  • 简单性,通常您需要编写、调试和维护更少的代码

除了 Go,用于开发系统工具的其他良好选择包括 Python、Perl、Rust 和 Ruby。

学习系统编程

学习系统编程的唯一方法是使用本书作为参考和教程,开发自己的实用程序。起初,你会犯很多荒谬的错误,但随着你的进步,你会犯更少但更聪明和难以调试的错误!然而,在学习时尝试新事物是可以的。事实上,尝试新事物并失败是必要的,因为这意味着你真的在学习新东西。只要确保你不要使用生产 Web 服务器来学习系统编程。

如果你不知道要开发什么,可以从创建自己的版本开始,比如ls(1)mkdir(1)ln(1)wc(1)which(1)等现有的 Unix 命令行实用程序。你不必为每个实用程序创建一个功能齐全的版本,支持所有命令行选项;重要的是开发一个稳定和安全的版本,实现主要功能并且没有问题地运行。

能够教你在 C 中进行 Unix 系统编程的最好书籍是W. Richard StevensAdvanced Unix Programming in the Unix Environment。它的第三版现在已经可以获取,但所有版本都很有用,包含大量宝贵的细节。

关于 Go

Go 是一种现代通用开源编程语言,于 2009 年底正式宣布。它起初是一个谷歌内部项目,受到了包括 C、Pascal、Alef 和 Oberon 在内的许多其他编程语言的启发。它的精神领袖是Robert GriesemerKen ThomsonRob Pike,他们设计 Go 作为专业程序员构建可靠和健壮软件的语言。除了其语法和标准函数外,Go 还配备了一个相当丰富的标准库。

在撰写本书时,最新的稳定 Go 版本是 1.8,其中包括一些方便的新功能,包括以下内容:如果你以前没有使用过 Go,可以跳过这部分。

  • 现在存在新的转换规则,允许你在满足一些条件的情况下轻松地在几乎相等的类型之间进行转换。你可以使用go tool命令修复golang.org/x/net/name形式的导入路径,而无需自己打开源文件。

  • 该工具在某些情况下更加严格,在以前会产生误报的情况下更加宽松。

  • 当 GOPATH 未定义时,现在有一个 GOPATH 环境变量的默认值。对于 Unix 系统,默认值是$HOME/go。

  • Go 运行时有各种改进,加快了 Go 的速度。

  • 有一个sort.slice()函数,允许你通过提供比较器回调来对切片进行排序,而不是实现sort.Interface

  • 现在http.Server有一个Shutdown方法。

  • database/sql包有各种小改动,让开发人员对查询有更多控制。

  • 你可以使用go bug命令创建 bug。

准备开始 Go

你可以使用这个命令轻松找到你的 Go 版本:

$ go version
go version go1.7.5 darwin/amd64  

前面的输出来自 macOS 机器,因此有darwin字符串。Linux 机器会给出以下类型的输出:

$ go version
go version go1.3.3 linux/amd64

在接下来的章节中,你将学到更多关于go tool的知识,你将一直使用它。

我可以想象,你一定迫不及待地想看一些 Go 代码;所以这里是著名的 Hello World 程序的 Go 版本:

package main 

import "fmt" 

// This is a demonstrative comment! 
func main() { 
   fmt.Println("Hello World!") 
} 

如果你熟悉 C 或 C++,你会发现 Go 代码非常容易理解。包含 Go 代码的每个文件都以包声明开头,后面是所需的导入声明。包声明显示了该文件所属的包。请注意,除非你想在同一行上放置两个或更多个 Go 语句,否则不需要为成功终止 Go 语句使用分号。

在第二章中,使用 Go 编写程序,你将了解如何编译和执行 Go 代码。现在,只需记住 Go 源文件使用.go文件扩展名存储:你的任务是选择一个描述性的文件名。

在搜索与 Go 相关的信息时,使用Golanggolang作为 Go 编程语言的关键词,因为单词 Go 几乎可以在英语中的任何地方找到,这不会帮助你的搜索!

两个有用的 Go 工具

Go 发行版附带了大量工具,可以让你作为程序员的生活更轻松。其中最有用的两个是gofmtgodoc

请注意,go tool本身也可以调用各种工具:你可以通过执行go tool来查看它们的列表。

gofmt实用程序以给定的方式格式化 Go 程序,这在不同的人要为一个大项目使用相同的代码时非常重要。你可以在golang.org/cmd/gofmt/找到更多关于gofmt的信息。

以下是hw.go程序的格式不佳的版本,很难阅读和理解:

$ cat unglyHW.go
package main

import
    "fmt"

// This is a demonstrative comment!
        func main() {
  fmt.Println("Hello World!")

}

处理前面的代码,保存为unglyHW.go并使用gofmt,会生成以下易于阅读和理解的输出:

$ gofmt unglyHW.go
package main

import "fmt"

// This is a demonstrative comment!
func main() {
      fmt.Println("Hello World!")

}

记住gofmt实用程序不会自动保存生成的输出很重要,这意味着你应该使用-w选项后跟有效的文件名,或者将gofmt的输出重定向到一个新文件。

godoc实用程序允许你查看现有 Go 包和函数的文档。你可以在godoc.org/golang.org/x/tools/cmd/godoc找到更多关于godoc的信息。

你将经常使用godoc,因为它是学习 Go 函数细节的好工具。

以下截图显示了在终端上生成的godoc命令的输出,当要求有关fmt包的Println()函数的信息时:

godoc 命令的输出

godoc的另一个方便功能是它可以启动自己的 web 服务器,并允许你使用 web 浏览器查看它的文档:

$ godoc -http=:8080  

以下截图显示了在运行前一个命令时,通过访问http://localhost:8080/pkg/在 web 浏览器上获得的输出类型。你可以使用任何你想要的端口号,只要它还没有被使用:

使用 web 浏览器中的 godoc 实用程序

程序员最重要的工具是他们用来编写源代码的编辑器。当我在 Mac 上时,我通常使用 TextMate 编辑器,但当我在不同的 Unix 机器上时,我更喜欢 vi。选择编辑器并不是一件容易的事,因为你将花费很多时间在它上面。然而,只要不在源代码文件中放入任何控制字符,任何文本编辑器都可以胜任。以下截图显示了 TextMate 编辑器的操作:

TextMate 编辑器显示了一些 Go 代码的外观

Go 的优缺点

Go 并不完美,但它有一些非常有趣的特性。Go 强大特性的列表包括以下内容:

  • Go 代码易于阅读和理解。

  • Go 希望开发者快乐,因为快乐的开发者写出更好的代码!

  • Go 编译器打印实用的警告和错误消息,帮助你解决实际问题。简而言之,Go 编译器是为了帮助你而不是让你的生活困难!

  • Go 代码是可移植的。

  • Go 是一种现代编程语言。

  • Go 支持过程化、并发和分布式编程。

  • Go 支持垃圾回收GC),因此你不必处理内存分配和释放。然而,GC 可能会稍微减慢你的程序。

  • Go 没有预处理器,编译速度很快。因此,Go 可以用作脚本语言。

  • Go 可以构建 Web 应用程序。在 C 中构建 Web 应用程序除非使用非标准的外部库,否则效率不高。此外,Go 为程序员提供了一个简单的 Web 服务器用于测试目的。

  • 标准的 Go 库提供了许多简化程序员工作的包。此外,标准的 Go 库中的方法事先经过测试和调试,这意味着它们大多数时间不包含错误。

  • Go 默认使用静态链接,这意味着生成的二进制文件可以轻松地传输到具有相同操作系统的其他计算机上。因此,开发人员不需要担心库、依赖项和不同的库版本。

  • 您不需要 GUI 来开发、调试和测试 Go 应用程序,因为可以从命令行中使用 Go。

  • Go 支持 Unicode。这意味着您不需要任何额外的代码来打印多种人类语言的字符。

  • Go 保持概念正交,因为少量正交特性比许多重叠特性更好。

Go 的缺点列表包括以下内容:

  • 嗯,Go 不是 C,这意味着您或您的团队应该学习一种新的编程语言来开发系统软件。

  • Go 没有直接支持面向对象的编程,这对于习惯以面向对象方式编写代码的程序员可能是一个问题。尽管如此,您可以在 Go 中使用组合来模拟继承。

  • Unix 首次推出时,C 是编写系统软件的唯一编程语言。如今,您还可以使用 Rust、C++和 Swift 来编写系统软件,这意味着不是每个人都会使用 Go。

  • C 仍然是系统编程中比其他任何编程语言都要快的主要原因是 Unix 是用 C 编写的。

无论编程语言的优点还是缺点,您都可以决定是否喜欢它。重要的是选择一种您喜欢并且能够完成您想要的工作的编程语言!就个人口味而言,我不喜欢 C++,尽管它是一种非常有能力的编程语言,我曾经用 C++编写过 FTP 客户端!此外,我从来不喜欢 Java。在个人口味上没有对错之分,所以不要为自己的选择感到内疚。

Unix 进程的各种状态

严格来说,进程是一个包含指令、用户数据和系统数据部分以及在运行时获得的其他类型资源的执行环境。程序是一个包含指令和数据的文件,用于初始化进程的指令和用户数据部分。

Unix 操作系统首次推出时,计算机只有单个 CPU,没有多个核心和少量的 RAM。然而,Unix 是一个多用户和多任务操作系统。为了实际上成为一个多用户和多任务系统,它必须能够周期性地运行每个单独的进程,这意味着一个进程应该有多个状态。下图显示了进程的可能状态以及从一个状态到另一个状态的正确路径:

Unix 进程的各种状态

有三种进程类别:用户进程、内核进程和守护进程:

  • 用户进程在用户空间中运行,通常没有特殊的访问权限

  • 内核进程仅在内核空间中执行,并且可以完全访问所有内核数据结构

  • 守护进程是可以在用户空间中找到并在后台运行而无需终端的程序

意识到你无法控制进程的状态是非常重要的,因为这是运行在内核中的操作系统的调度程序的工作。简单来说,你无法预测进程的状态何时会改变,或者进程何时会进入运行状态,所以你的代码不能依赖任何这样的假设!

创建新进程的 C 方式涉及调用fork()系统调用。fork()的返回值允许程序员区分父进程和子进程。然而,Go 不支持类似的功能。

练习

  1. 访问 Go 网站:golang.org/

  2. 在你的系统上安装 Go 并找出它的版本。

  3. 自己输入 Hello World 程序的代码并将其保存到文件中。

  4. 如果你使用的是 Mac,可以从macromates.com/下载 TextMate。

  5. 如果你使用的是 Mac,可以从www.barebones.com/products/TextWrangler/下载 TextWrangler 编辑器并尝试使用它。

  6. 如果你还不熟悉其他 Unix 文本编辑器,可以尝试自己学习 vi 或 Emacs。

  7. 查看任何你能找到的 Go 代码,并尝试对其进行小的更改。

总结

在本章中,你学会了如何在你的计算机上安装 Go,最新 Go 版本的特性,Go 的优缺点,以及gofmtgodoc Go 工具,以及关于 Unix 操作系统的一些重要内容。

下一章不仅会告诉你如何编译你的 Go 代码,还会讨论其他重要的 Go 主题,比如读取和使用命令行参数,环境变量,编写函数,数据结构,接口,获取用户输入和打印输出。

第二章:在 Go 中编写程序

本章将讨论许多重要、有趣和实用的 Go 主题,这将帮助您更加高效。我认为从编译和运行上一章的hw.go程序的 Go 代码开始本章是一个不错的主意。然后,您将学习如何处理 Go 可以使用的环境变量,如何处理 Go 程序的命令行参数,以及如何在屏幕上打印输出并从用户那里获取输入。最后,您将了解如何在 Go 中定义函数,学习极其重要的defer关键字,查看 Go 提供的数据结构,并了解 Go 接口,然后再查看生成随机数的代码。

因此,在本章中,您将熟悉许多 Go 概念,包括以下内容:

  • 编译您的 Go 程序

  • Go 环境变量

  • 使用传递给 Go 程序的命令行参数

  • 获取用户输入并在屏幕上打印输出

  • Go 函数和defer关键字

  • Go 数据结构和接口

  • 生成随机数

编译 Go 代码

只要包名是main并且其中有main()函数,Go 就不在乎一个独立程序的源文件的名称。这是因为main()函数是程序执行的起点。这也意味着在单个项目的文件中不能有多个main()函数。

有两种运行 Go 程序的方式:

  • 第一个是go run,只是执行 Go 代码而不生成任何新文件,只会生成一些临时文件,之后会被删除

  • 第二种方式,go build,编译代码,生成可执行文件,并等待您运行可执行文件

本书是在使用 Homebrew (brew.sh/)版本的 Go 的 Apple Mac OS Sierra 系统上编写的。但是,只要您有一个相对较新的 Go 版本,您应该不会在大多数 Linux 和 FreeBSD 系统上编译和运行所提供的 Go 代码时遇到困难。

因此,第一种方式如下:

$ go run hw.go
Hello World!  

上述方式允许 Go 用作脚本语言。以下是第二种方式:

$ go build hw.go
$ file hw
hw: Mach-O 64-bit executable x86_64

生成的可执行文件以 Go 源文件的名称命名,这比a.out要好得多,后者是 C 编译器生成的可执行文件的默认文件名。

如果您的代码中有错误,比如在调用 Go 函数时拼错了 Go 包名,您将会得到以下类型的错误消息:

$ go run hw.go
# command-line-arguments
./hw.go:3: imported and not used: "fmt"
./hw.go:7: undefined: mt in mt.Println

如果您意外地拼错了main()函数,您将会得到以下错误消息,因为独立的 Go 程序的执行是从main()函数开始的:

$ go run hw.go
# command-line-arguments
runtime.main_main f: relocation target main.main not defined
runtime.main_main f: undefined: "main.main"

最后,我想向您展示一个错误消息,它将让您对 Go 的格式规则有一个很好的了解:

$ cat hw.gocat 
package main

import "fmt"

func main()
{
      fmt.Println("Hello World!")
}
$ go run hw.go
# command-line-arguments
./hw.go:6: syntax error: unexpected semicolon or newline before {

前面的错误消息告诉我们,Go 更喜欢以一种特定的方式放置大括号,这与大多数编程语言(如 Perl、C 和 C++)不同。这一开始可能看起来令人沮丧,但它可以节省您一行额外的代码,并使您的程序更易读。请注意,前面的代码使用了Allman 格式样式,而 Go 不接受这种格式。

对于这个错误的官方解释是,Go 在许多情况下要求使用分号作为语句终止符,并且编译器会在它认为必要时自动插入所需的分号,这种情况是在非空行的末尾。因此,将开括号({)放在自己的一行上会让 Go 编译器在前一行末尾加上一个分号,从而产生错误消息。

如果您认为gofmt工具可以帮您避免类似的错误,您将会感到失望:

$ gofmt hw.go
hw.go:6:1: expected declaration, found '{'

正如您在以下输出中所看到的,Go 编译器还有另一条规则:

$ go run afile.go
# command-line-arguments
./afile.go:4: imported and not used: "net"

这意味着你不应该在程序中导入包而不实际使用它们。虽然这可能是一个无害的警告消息,但你的 Go 程序将无法编译。请记住,类似的警告和错误消息是你遗漏了某些东西的一个很好的指示,你应该尝试纠正它们。如果你对警告和错误采取相同的态度,你将创建更高质量的代码。

检查可执行文件的大小

因此,在成功编译hw.go之后,你可能想要检查生成的可执行文件的大小:

$ ls -l hw
-rwxr-xr-x  1 mtsouk  staff  1628192 Feb  9 22:29 hw
$ file hw
hw: Mach-O 64-bit executable x86_64  

在 Linux 机器上编译相同的 Go 程序将创建以下文件:

$ go versiongo 
go version go1.3.3 linux/amd64
$ go build hw.go
$ ls -l hw
-rwxr-xr-x 1 mtsouk mtsouk 1823712 Feb 18 17:35 hw
$ file hw
hw: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

为了更好地了解 Go 可执行文件的大小,考虑一下,同样的程序用 C 编写的可执行文件大约为 8432 字节!

因此,你可能会问为什么一个如此小的程序会生成一个如此庞大的可执行文件?主要原因是 Go 可执行文件是静态构建的,这意味着它们不需要外部库来运行。使用strip(1)命令可以使生成的可执行文件稍微变小,但不要期望奇迹发生:

$ strip hw
$ ls -l hw
-rwxr-xr-x  1 mtsouk  staff  1540096 Feb 18 17:41 hw

前面的过程与 Go 本身无关,因为strip(1)是一个 Unix 命令,它删除或修改文件的符号表,从而减小它们的大小。Go 可以自行执行strip(1)命令的工作并创建更小的可执行文件,但这种方法并不总是有效:

$ ls -l hw
-rwxr-xr-x 1 mtsouk mtsouk 1823712 Feb 18 17:35 hw
$ CGO_ENABLED=0 go build -ldflags "-s" -a hw.go
$ ls -l hw
-rwxr-xr-x 1 mtsouk mtsouk 1328032 Feb 18 17:44 hw
$ file hw
hw: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped

上述输出来自 Linux 机器;当在 macOS 机器上使用相同的编译命令时,对可执行文件的大小不会有任何影响。

Go 环境变量

go tool可以使用许多专门用于 Go 的 Unix shell 环境变量,包括GOROOTGOHOMEGOBINGOPATH。最重要的 Go 环境变量是GOPATH,它指定了你的工作空间的位置。通常,这是你在开发 Go 代码时需要定义的唯一环境变量;它与项目文件的组织方式有关。这意味着每个项目将被组织成三个主要目录,名为srcpkgbin。然而,包括我在内的许多人更喜欢不使用GOPATH,而是手动组织他们的项目文件。

因此,如果你是 shell 变量的忠实粉丝,你可以将所有这些定义放在.bashrc.profile中,这意味着这些环境变量将在每次登录到 Unix 机器时都处于活动状态。如果你没有使用 Bash shell(默认的 Linux 和 macOS shell),那么你可能需要使用另一个启动文件。查看你喜欢的 Unix shell 的文档,找出要使用哪个文件。

下面的截图显示了以下命令的部分输出,该命令显示了 Go 使用的所有环境变量:

$ go help environment

“go help environment”命令的输出

你可以通过执行下一个命令并将NAME替换为你感兴趣的环境变量来找到关于特定环境变量的额外信息:

$ go env NAME  

所有这些环境变量与实际的 Go 代码或程序的执行无关,但它们可能会影响开发环境;因此,如果在尝试编译 Go 程序时遇到任何奇怪的行为,检查你正在使用的环境变量。

使用命令行参数

命令行参数允许你的程序获取输入,比如你想要处理的文件的名称,而不必编写程序的不同版本。因此,如果你无法处理传递给它的命令行参数,你将无法创建任何有用的系统软件。

这里有一个天真的 Go 程序,名为cla.go,它打印出所有的命令行参数,包括可执行文件的名称:

package main 

import "fmt" 
import "os" 

func main() { 
   arguments := os.Args 
   for i := 0; i < len(arguments); i++ { 
         fmt.Println(arguments[i]) 
   } 
} 

正如您所看到的,Go 需要一个名为os的额外包,以便读取存储在os.Args数组中的程序的命令行参数。如果您不喜欢有多个导入语句,您可以将两个导入语句重写如下,我觉得这样更容易阅读:

import ( 
   "fmt" 
   "os" 
)

当您使用单个导入块导入所有包时,gofmt实用程序会按字母顺序排列包名。

cla.go的 Go 代码很简单,它将所有命令行参数存储在一个数组中,并使用for循环进行打印。正如您将在接下来的章节中看到的,os包可以做更多的事情。如果您熟悉 C 语言,您应该知道在 C 中,命令行参数会自动传递给程序,而您无需包含任何额外的头文件来读取它们。Go 使用了一种不同的方法,这样可以给您更多的控制,但需要稍微更多的代码。

在构建后执行cla.go将创建以下类型的输出:

$ ./cla 1 2 three
./cla
1
2
three

找到命令行参数的总和

现在,让我们尝试一些不同和棘手的事情:您将尝试找到给定给 Go 程序的命令行参数的总和。因此,您将把命令行参数视为数字。尽管主要思想保持不变,但实现完全不同,因为您将不得不将命令行参数转换为数字。Go 程序的名称将是addCLA.go,它可以分为两部分。

第一部分是程序的序言:

package main 

import ( 
   "fmt" 
   "os" 
   "strconv" 
) 

您需要fmt包来打印输出和os包来读取命令行参数。由于命令行参数存储为字符串,您还需要srtconv包将其转换为整数。

第二部分是main()函数的实现:

func main() { 
   arguments := os.Args 
   sum := 0 
   for i := 1; i < len(arguments); i++ { 
         temp, _ := strconv.Atoi(arguments[i]) 
         sum = sum + temp 
   } 
   fmt.Println("Sum:", sum) 
} 

strconv.Atoi()函数返回两个值:第一个是整数,前提是转换成功,第二个是错误变量。

请注意,大多数 Go 函数都会返回一个错误变量,这个错误变量应该始终被检查,特别是在生产软件中。

如果您不使用strconv.Atoi()函数,那么您将会遇到两个问题:

  • 第一个问题是程序将尝试使用字符串执行加法,这是数学运算。

  • 第二个问题是您将无法判断命令行参数是否是有效的整数,这可以通过检查strconv.Atoi()的返回值来完成

因此,strconv.Atoi()不仅可以完成所需的工作,而且还可以告诉我们给定参数是否是有效的整数,这同样重要,因为它允许我们以不同的方式处理不合适的参数。

addCLA.go中的另一个关键的 Go 代码是忽略strconv.Atoi()函数的错误变量的值,使用模式匹配。_字符在 Go 模式匹配术语中表示“匹配所有”,但不要将其保存在任何变量中。

Go 支持四种不同大小的有符号和无符号整数,分别命名为 int8、int16、int32、int64、uint8、uint16、uint32 和 uint64。然而,Go 还有intuint,它们是当前平台上最有效的有符号和无符号整数。因此,当有疑问时,请使用intuint

使用正确类型的命令行参数执行addCLA.go将创建以下输出:

$ go run addCLA.go 1 2 -1 -3
Sum: -1
$ go run addCLA.go
Sum: 0

addCLA.go的好处是,如果没有参数,它不会崩溃,而无需您担心。然而,看到程序如何处理错误输入会更有趣,因为您永远不能假设会得到正确类型的输入:

$ go run addCLA.go !
Sum: 0
$ go run addCLA.go ! -@
Sum: 0
$ go run addCLA.go ! -@ 1 2
Sum: 3

正如您所看到的,如果程序得到错误类型的输入,它不会崩溃,并且不会在其计算中包含错误的输入。这里的一个主要问题是addCLA.go不会打印任何警告消息,以让用户知道它们的某些输入被忽略。这种危险的代码会创建不稳定的可执行文件,当给出错误类型的输入时可能会产生安全问题。因此,这里的一般建议是,您永远不应该期望或依赖 Go 编译器,或任何其他编译器或程序,来处理这些事情,因为这是您的工作。

第三章《高级 Go 功能》将更详细地讨论 Go 中的错误处理,并将介绍上一个程序的更好和更安全的版本。目前,我们都应该高兴地证明我们的程序不会因任何输入而崩溃。

尽管这并不是一个完美的情况,但如果您知道您的程序对某些特定类型的输入不起作用,那也不是那么糟糕。糟糕的是,当开发人员不知道存在某些类型的输入可能会导致程序失败时,因为您无法纠正您不相信或认为是错误的东西。

尽管处理命令行参数看起来很容易,但如果您的命令行实用程序支持大量选项和参数,它可能会变得非常复杂。第五章《文件和目录》将更多地讨论使用flag标准 Go 包处理命令行选项、参数和参数。

用户输入和输出

根据 Unix 哲学,当程序成功完成其工作时,它不会生成任何输出。然而,出于许多原因,并非所有程序都能成功完成,并且它们需要通过打印适当的消息来通知用户其问题。此外,一些系统工具需要从用户那里获取输入,以决定如何处理可能出现的情况。

Go 用户输入和输出的英雄是fmt包,本节将向您展示如何通过从最简单的任务开始来执行这两个任务。

了解有关fmt包的更多信息的最佳位置是其文档页面,该页面可以在golang.org/pkg/fmt/找到。

获取用户输入

除了使用命令行参数来获取用户输入(这是系统编程中的首选方法),还有其他方法可以要求用户输入。

当使用-i选项时,两个示例是rm(1)mv(1)命令:

$ touch aFile
$ rm -i aFile
remove aFile? y
$ touch aFile
$ touch ../aFile
$ mv -i ../aFile .
overwrite ./aFile? (y/n [n]) y

因此,本节将向您展示如何在您的 Go 代码中模仿先前的行为,使您的程序能够理解-i参数,而不实际实现rm(1)mv(1)的功能。

用于获取用户输入的最简单函数称为fmt.Scanln(),并读取整行。其他用于获取用户输入的函数包括fmt.Scan()fmt.Scanf()fmt.Sscanf()fmt.Sscanln()fmt.Sscan()

然而,在 Go 中存在一种更高级的方式来从用户那里获取输入;它涉及使用bufio包。然而,使用bufio包从用户那里获取简单的响应有点过度。

parameter.go的 Go 代码如下:

package main 

import ( 
   "fmt" 
   "os" 
   "strings" 
) 

func main() { 
   arguments := os.Args 
   minusI := false 
   for i := 0; i < len(arguments); i++ { 
         if strings.Compare(arguments[i], "-i") == 0 { 
               minusI = true 
               break 
         } 
   } 

   if minusI { 
         fmt.Println("Got the -i parameter!") 
         fmt.Print("y/n: ") 
         var answer string 
         fmt.Scanln(&answer) 
         fmt.Println("You entered:", answer) 
   } else { 
         fmt.Println("The -i parameter is not set") 
   } 
} 

所呈现的代码并不特别聪明。它只是使用for循环访问所有命令行参数,并检查当前参数是否等于-i字符串。一旦它通过strings.Compare()函数找到匹配,它就会将minusI变量的值从 false 更改为 true。然后,因为它不需要再查找,它使用break语句退出for循环。如果给出了-i参数,带有if语句的块将要求用户使用fmt.Scanln()函数输入yn

请注意,fmt.Scanln() 函数使用了指向 answer 变量的指针。由于 Go 通过值传递变量,我们必须在这里使用指针引用,以便将用户输入保存到 answer 变量中。一般来说,从用户读取数据的函数往往是这样工作的。

执行 parameter.go 会产生以下类型的输出:

$ go run parameter.go
The -i parameter is not set
$ go run parameter.go -i
Got the -i parameter!
y/n: y
You entered: y

打印输出

在 Go 中打印东西的最简单方法是使用 fmt.Println()fmt.Printf() 函数。fmt.Printf() 函数与 C 的 printf(3) 函数有许多相似之处。你也可以使用 fmt.Print() 函数来代替 fmt.Println()

fmt.Print()fmt.Println() 之间的主要区别是,后者每次调用时自动打印一个换行符。fmt.Println()fmt.Printf() 之间的最大区别是,后者需要为它将打印的每样东西提供一个格式说明符,就像 C 的 printf(3) 函数一样。这意味着你可以更好地控制你在做什么,但你需要写更多的代码。Go 将这些说明符称为动词,你可以在 golang.org/pkg/fmt/ 找到更多关于支持的动词的信息。

Go 函数

函数是每种编程语言的重要元素,因为它们允许你将大型程序分解为更小更易管理的部分,但它们必须尽可能独立,并且只能完成一项任务。因此,如果你发现自己编写了多个任务的函数,可能需要考虑编写多个函数。然而,Go 不会拒绝编译长、复杂或者做多个任务的函数。

一个安全的指示,你需要创建一个新函数的时候是,当你发现自己在程序中多次使用相同的 Go 代码。同样,一个安全的指示,你需要将一些函数放在一个模块中的时候是,当你发现自己在大多数程序中一直使用相同的函数。

最受欢迎的 Go 函数是 main(),它可以在每个独立的 Go 程序中找到。如果你看一下 main() 函数的定义,你很快就会意识到 Go 中的函数声明以 func 关键字开头。

一般来说,你必须尽量编写少于 20-30 行 Go 代码的函数。拥有更小的函数的一个好的副作用是,它们可以更容易地进行优化,因为你可以清楚地找出瓶颈在哪里。

给 Go 函数的返回值命名

与 C 不同,Go 允许你给函数的返回值命名。此外,当这样的函数有一个没有参数的返回语句时,函数会自动返回每个命名返回值的当前值。请注意,这样的函数按照它们在函数定义中声明的顺序返回它们的值。

给返回值命名是一个非常方便的 Go 特性,可以帮助你避免各种类型的错误,所以要使用它。

我的个人建议是:给你的函数的返回值命名,除非有非常好的理由不这样做。

匿名函数

匿名函数可以在一行内定义,无需名称,它们通常用于实现需要少量代码的事物。在 Go 中,一个函数可以返回一个匿名函数,或者将一个匿名函数作为其参数之一。此外,匿名函数可以附加到 Go 变量上。

对于匿名函数来说,最好的做法是有一个小的实现和局部使用。如果一个匿名函数没有局部使用,那么你可能需要考虑将其变成一个常规函数。

当匿名函数适合一项任务时,它非常方便,可以让你的生活更轻松;只是不要在程序中没有充分理由的情况下使用太多匿名函数。

说明 Go 函数

本小节将展示使用functions.go程序的 Go 代码来演示前面类型的函数的示例。程序的第一部分包含了预期的序言和unnamedMinMax()函数的实现:

package main 

import ( 
   "fmt" 
) 

func unnamedMinMax(x, y int) (int, int) { 
   if x > y { 
         min := y 
         max := x 
         return min, max 
   } else { 
         min := x 
         max := y 
         return min, max 
   } 
} 

unnamedMinMax()函数是一个常规函数,它以两个整数作为输入,分别命名为xy。它使用return语句返回两个整数。

functions.go的下一部分定义了另一个函数,但这次使用了命名返回值,它们被称为minmax

func minMax(x, y int) (min, max int) { 
   if x > y { 
         min = y 
         max = x 
   } else { 
         min = x 
         max = y 
   } 
   return min, max 
} 

下一个函数是minMax()的改进版本,因为你不必显式定义返回语句的返回变量:

func namedMinMax(x, y int) (min, max int) { 
   if x > y { 
         min = y 
         max = x 
   } else { 
         min = x 
         max = y 
   } 
   return 
} 

然而,你可以通过查看namedMinMax()函数的定义轻松地发现将返回哪些值。namedMinMax()函数将以此顺序返回minmax的当前值。

下一个函数展示了如何对两个整数进行排序,而不必使用临时变量:

func sort(x, y int) (int, int) { 
   if x > y { 
         return x, y 
   } else { 
         return y, x 
   } 
} 

前面的代码还展示了 Go 函数可以返回多个值的便利之处。functions.go的最后一部分包含了main()函数;这可以分为两部分来解释。

第一部分涉及匿名函数:

 func main() {
   y := 4 
   square := func(s int) int { 
         return s * s 
   } 
   fmt.Println("The square of", y, "is", square(y)) 

   square = func(s int) int { 
         return s + s 
   } 
   fmt.Println("The square of", y, "is", square(y)) 

在这里,你定义了两个匿名函数:第一个计算给定整数的平方,而第二个则是给定整数的两倍。重要的是,它们都分配给了同一个变量,这是完全错误的,也是一种危险的做法。因此,不正确使用匿名函数可能会产生严重的错误,所以要格外小心,不要将同一个变量分配给不同的匿名函数。

请注意,即使将函数分配给变量,它仍然被视为匿名函数。

main()的第二部分使用了一些已定义的函数:

   fmt.Println(minMax(15, 6)) 
   fmt.Println(namedMinMax(15, 6)) 
   min, max := namedMinMax(12, -1) 
   fmt.Println(min, max) 
} 

有趣的是,你可以使用两个变量在一个语句中获取namedMinMax()函数的两个返回值。

执行functions.go生成以下输出:

$ go run functions.go
The square of 4 is 16
The square of 4 is 8
6 15
6 15
-1 12

下一部分展示了更多匿名函数与defer关键字结合的例子。

defer 关键字

defer关键字推迟了函数的执行,直到包围函数返回,并且在文件 I/O 操作中被广泛使用。这是因为它可以让你不必记住何时关闭打开的文件。

展示defer的 Go 代码文件名为defer.go,包含四个主要部分。

第一部分是预期的序言,以及a1()函数的定义:

package main 

import ( 
   "fmt" 
) 

func a1() { 
   for i := 0; i < 3; i++ { 
         defer fmt.Print(i, " ") 
   } 
} 

在前面的例子中,defer关键字与简单的fmt.Print()语句一起使用。

第二部分是a2()函数的定义:

func a2() { 
   for i := 0; i < 3; i++ { 
         defer func() { fmt.Print(i, " ") }() 
   } 
} 

defer关键字之后,有一个未附加到变量的匿名函数,这意味着在for循环终止后,匿名函数将自动消失。所呈现的匿名函数不带参数,但在fmt.Print()语句中使用了i局部变量。

下一部分定义了a3()函数,并包含以下 Go 代码:

func a3() { 
   for i := 0; i < 3; i++ { 
         defer func(n int) { fmt.Print(n, " ") }(i) 
   } 
} 

这次,匿名函数需要一个名为n的整数参数,并从变量i中取其值。

defer.go的最后一部分是main()函数的实现:

func main() { 
   a1() 
   fmt.Println() 
   a2() 
   fmt.Println() 
   a3() 
   fmt.Println() 
} 

执行defer.go将打印以下内容,这可能会让你感到惊讶:

$ go run defer.go
2 1 0
3 3 3
2 1 0

因此,现在是时候通过检查a1()a2()a3()执行其代码的方式来解释defer.go的输出。输出的第一行验证了在包围函数返回后,延迟函数以后进先出LIFO)的顺序执行。a1()中的for循环延迟了一个使用i变量当前值的函数调用。结果,所有数字都以相反的顺序打印,因为i的最后使用值是2a2()函数比较棘手,因为由于defer,函数体在for循环结束后被评估,而它仍在引用局部i变量,这时对于所有评估的函数体来说,i变量的值都等于3。结果,a2()打印数字3三次。简而言之,您有三个使用变量的最后值的函数调用,因为这是传递给函数的内容。但是,a3()函数不是这种情况,因为i的当前值作为参数传递给延迟的函数,这是由a3()函数定义末尾的(i)代码决定的。因此,每次执行延迟的函数时,它都有一个不同的i值要处理。

由于使用defer可能会很复杂,您应该编写自己的示例,并在执行实际的 Go 代码之前尝试猜测它们的输出,以确保您的程序表现如预期。尝试能够判断函数参数何时被评估以及函数体何时实际执行。

您将在第六章 文件输入和输出中再次看到defer关键字的作用。

在函数中使用指针变量

指针是内存地址,以提高速度为代价,但代码难以调试且容易出现错误。C 程序员对此了解更多。在 Go 函数中使用指针变量的示例在pointers.go文件中进行了说明,可以分为两个主要部分。第一部分包含两个函数的定义和一个名为complex的新结构。

func withPointer(x *int) { 
   *x = *x * *x 
} 

type complex struct { 
   x, y int 
} 

func newComplex(x, y int) *complex { 
   return &complex{x, y} 
} 

第二部分说明了在main()函数中使用先前定义的内容:

func main() { 
   x := -2 
   withPointer(&x) 
   fmt.Println(x) 

   w := newComplex(4, -5) 
   fmt.Println(*w) 
   fmt.Println(w) 
} 

由于withPointer()函数使用指针变量,您不需要返回任何值,因为对传递给函数的变量的任何更改都会自动存储在传递的变量中。请注意,您需要在变量名前面加上&,以便将其作为指针而不是作为值传递。complex结构有两个成员,名为xy,它们都是整数变量。

另一方面,newComplex()函数返回了一个指向先前在pointers.go中定义的complex结构的指针,需要存储在一个变量中。为了打印newComplex()函数返回的复杂变量的内容,您需要在其前面加上一个*字符。

执行pointers.go会生成以下输出:

$ go run pointers.go
4
{4 -5}
&{4 -5} 

我不建议业余程序员在使用库所需之外使用指针,因为它们可能会引起问题。然而,随着经验的增加,您可能希望尝试使用指针,并根据您尝试解决的问题决定是否使用它们。

Go 数据结构

Go 带有许多方便的数据结构,可以帮助您存储自己的数据,包括数组、切片和映射。您应该能够在任何数据结构上执行的最重要的任务是以某种方式访问其所有元素。第二个重要任务是在知道其索引或键后直接访问特定元素。最后两个同样重要的任务是向数据结构中插入元素和删除元素。一旦您知道如何执行这四个任务,您将完全控制数据结构。

数组

由于其速度快,并且几乎所有编程语言都支持,数组是最受欢迎的数据结构。您可以在 Go 中声明数组如下:

myArray := [4]int{1, 2, 4, -4} 

如果您希望声明具有两个或三个维度的数组,可以使用以下表示法:

twoD := [3][3]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}} 
threeD := [2][2][2]int{{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}} 

数组每个维度的第一个元素的索引是 0,每个维度的第二个元素的索引是 1,依此类推。可以轻松地访问、赋值或打印前三个数组中的单个元素。

myArray[0] 
twoD[1][2] = 15 
threeD[0][1][1] = -1

访问数组所有元素的最常见方法是使用len()函数找到其大小,然后使用for循环。然而,还有更酷的方法可以访问数组的所有元素,这涉及在for循环中使用range关键字,并允许您绕过len()函数的使用,当您必须处理两个或更多维数组时,这是非常方便的。

这个小节中的所有代码都保存在arrays.go中,你应该自己看一下。运行arrays.go会生成以下输出:

$ go run arrays.go
1 2 4 -4
0 2 -2 6 7 8
1 2 3 4 5 15 7 8 9
[[1 2] [3 -1]] [[5 6] [7 8]]

现在让我们尝试通过尝试访问一些奇怪的数组元素来破坏事物,比如访问一个不存在的索引号的元素或者访问一个负索引号的元素,使用以下名为breakMe.go的 Go 程序:

package main 

import "fmt" 

func main() { 
   myArray := [4]int{1, 2, 4, -4} 
   threeD := [2][2][2]int{{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}} 
   fmt.Println("myArray[-1]:", myArray[-1])
   fmt.Println("myArray[10]:", myArray[10]) 
   fmt.Println("threeD[-1][20][0]:", threeD[-1][20][0]) 
} 

执行breakMe.go将生成以下输出:

$ go run breakMe.go
# command-line-arguments
./breakMe.go:8: invalid array index -1 (index must be non-negative)
./breakMe.go:9: invalid array index 10 (out of bounds for 4-element array)
./breakMe.go:10: invalid array index -1 (index must be non-negative)
./breakMe.go:10: invalid array index 20 (out of bounds for 2-element array)

Go 认为可以检测到的编译器问题是编译器错误,因为这有助于开发工作流程,这就是为什么要打印breakMe.go的所有越界数组访问错误的原因。

尝试破坏事物是一个非常有教育意义的过程,你应该一直尝试。简而言之,知道某些事情不起作用的时候同样有用,就像知道什么时候起作用一样有用。

尽管 Go 数组很简单,但存在许多严重的缺点:

  • 首先,一旦定义了数组,就不能改变其大小,这意味着 Go 数组不是动态的。简而言之,如果您想要在没有空间的现有数组中包含额外的元素,您将需要创建一个更大的数组,并将所有元素从旧数组复制到新数组中。

  • 其次,当你将数组传递给函数时,实际上是传递了数组的副本,这意味着你在函数内部对数组所做的任何更改在函数结束后都会丢失。

  • 最后,将大数组传递给函数可能会非常慢,主要是因为 Go 必须创建数组的第二个副本。解决所有这些问题的方法是使用切片。

切片

在许多编程语言中,你不会找到切片的概念,尽管它既聪明又方便。切片与数组有许多相似之处,并且允许您克服数组的缺点。

切片有容量和长度属性,它们并不总是相同的。切片的长度与具有相同数量元素的数组的长度相同,并且可以使用len()函数找到。切片的容量是为该特定切片分配的当前空间,并可以使用cap()函数找到。由于切片的大小是动态的,如果切片的空间不足,Go 会自动将其当前长度加倍以为更多元素腾出空间。

切片作为引用传递给函数,你在函数内部对切片所做的任何修改在函数结束后都不会丢失。此外,将大切片传递给函数比传递相同数组要快得多,因为 Go 不必复制切片,它只会传递切片变量的内存地址。

这个小节的代码保存在slices.go中,可以分为三个主要部分。

第一部分是序言以及定义两个以slice作为输入的函数:

package main 

import ( 
   "fmt" 
) 

func change(x []int) { 
   x[3] = -2 
} 

func printSlice(x []int) { 
   for _, number := range x {

         fmt.Printf("%d ", number) 
   } 
   fmt.Println() 
} 

请注意,当您在切片上使用range时,您会在其迭代中得到一对值。第一个是索引号,第二个是元素的值。当您只对存储的元素感兴趣时,您可以忽略索引号,就像printSlice()函数一样。

change()函数只更改输入切片的第四个元素,而printSlice()是一个实用函数,用于打印其切片输入变量的内容。在这里,您还可以看到使用fmt.Printf()函数打印整数。

第二部分创建了一个名为aSlice的新切片,并使用第一部分中看到的change()函数对其进行更改:

func main() { 
   aSlice := []int{-1, 4, 5, 0, 7, 9} 
   fmt.Printf("Before change: ") 
   printSlice(aSlice) 
   change(aSlice) 
   fmt.Printf("After change: ") 
   printSlice(aSlice) 

尽管您定义填充切片的方式与定义数组的方式有一些相似之处,但最大的区别在于您不必声明切片将具有的元素数量。

最后一部分说明了 Go 切片的容量属性以及make()函数:

   fmt.Printf("Before. Cap: %d, length: %d\n", cap(aSlice), len(aSlice)) 
   aSlice = append(aSlice, -100) 
   fmt.Printf("After. Cap: %d, length: %d\n", cap(aSlice), len(aSlice)) 
   printSlice(aSlice) 
   anotherSlice := make([]int, 4) 
   fmt.Printf("A new slice with 4 elements: ") 
   printSlice(anotherSlice) 
} 

make()函数会自动将切片的元素初始化为该类型的零值,可以通过printSliceanotherSlice)语句的输出进行验证。请注意,使用make()函数创建切片时需要指定元素的数量。

执行slices.go生成以下输出:

$ go run slices.go 
Before change: -1 4 5 0 7 9 
After change: -1 4 5 -2 7 9 
Before. Cap: 6, length: 6 
After. Cap: 12, length: 7 
-1 4 5 -2 7 9 -100 
A new slice with 4 elements: 0 0 0 0 

从输出的第三行可以看出,切片的容量和长度在定义时是相同的。但是,使用append()向切片添加新元素后,其长度从6变为7,但其容量翻倍,从6变为12。将切片的容量翻倍的主要优势是性能更好,因为 Go 不必一直分配内存空间。

您可以从现有数组的元素创建一个切片,并使用copy()函数将现有切片复制到另一个切片。这两个操作都有一些棘手的地方,您应该进行实验。

第六章,文件输入和输出,将讨论一种特殊类型的切片,称为字节切片,可用于文件 I/O 操作。

映射

Go 中的 Map 数据类型等同于其他编程语言中的哈希表。映射的主要优势是它们可以使用几乎任何数据类型作为其索引,这种情况下称为key。要将数据类型用作键,它必须是可比较的。

因此,让我们看一个示例 Go 程序,名为maps.go,我们将用它进行说明。maps.go的第一部分包含您期望的 Go 代码前言:

package main 

import ( 
   "fmt" 
) 

func main() { 

然后,您可以定义一个新的空映射,其中字符串作为键,整数作为值,如下所示:

   aMap := make(map[string]int) 

之后,您可以向aMap映射添加新的键值对,如下所示:

   aMap["Mon"] = 0 
   aMap["Tue"] = 1 
   aMap["Wed"] = 2 
   aMap["Thu"] = 3 
   aMap["Fri"] = 4 
   aMap["Sat"] = 5 
   aMap["Sun"] = 6 

然后,您可以获取现有键的值:

   fmt.Printf("Sunday is the %dth day of the week.\n", aMap["Sun"]) 

然而,您可以对现有map执行的最重要的操作在以下 Go 代码中进行了说明:

   _, ok := aMap["Tuesday"] 
   if ok { 
         fmt.Printf("The Tuesday key exists!\n") 
   } else { 
         fmt.Printf("The Tuesday key does not exist!\n") 
   } 

上述 Go 代码的作用是利用 Go 的错误处理能力,以验证映射的键在尝试获取其值之前是否已存在。这是尝试获取map键的值的正确和安全方式,因为要求一个不存在的key的值将导致返回零。这样就无法确定结果是零,是因为您请求的key不存在,还是因为相应键的元素实际上具有零值。

以下 Go 代码显示了如何遍历现有映射的所有键:

   count := 0 
   for key, _ := range aMap { 
         count++ 
         fmt.Printf("%s ", key) 
   } 
   fmt.Printf("\n") 
   fmt.Printf("The aMap has %d elements\n", count) 

如果您对访问映射的键和值没有兴趣,只想计算其对数,那么您可以使用前面for循环的下一个更简单的变体:

   count = 0 
   delete(aMap, "Fri") 
   for _, _ = range aMap { 
         count++ 
   } 
   fmt.Printf("The aMap has now %d elements\n", count) 

main()函数的最后一部分包含以下 Go 代码,用于说明定义和初始化映射的另一种方式:

   anotherMap := map[string]int{ 
         "One":   1, 
         "Two":   2, 
         "Three": 3, 
         "Four":  4, 
   } 
   anotherMap["Five"] = 5 
   count = 0 
   for _, _ = range anotherMap { 
         count++ 
   } 
   fmt.Printf("anotherMap has %d elements\n", count) 
} 

但是,除了不同的初始化之外,所有其他map操作都完全相同。执行maps.go生成以下输出:

$ go run maps.go
Sunday is the 6th day of the week.
The Tuesday key does not exist!
Wed Thu Fri Sat Sun Mon Tue
The aMap has 7 elements
The aMap has now 6 elements
anotherMap has 5 elements

映射是一种非常方便的数据结构,当开发系统软件时,您很有可能会需要它们。

将数组转换为地图

这个小节将执行一个实际的操作,即在不提前知道array大小的情况下将数组转换为地图。array2map.go的 Go 代码可以分为三个主要部分。第一部分是标准的 Go 代码,包括所需的包和main()函数的开始:

package main 

import ( 
   "fmt" 
   "strconv" 
) 

func main() { 

实现核心功能的第二部分如下:

anArray := [4]int{1, -2, 14, 0} 
aMap := make(map[string]int) 

length := len(anArray) 
for i := 0; i < length; i++ { 
   fmt.Printf("%s ", strconv.Itoa(i)) 
   aMap[strconv.Itoa(i)] = anArray[i] 
} 

首先定义array变量和将要使用的map变量。for循环用于访问所有数组元素并将它们添加到map中。strconv.Itoa()函数将array的索引号转换为字符串。

请记住,如果你知道地图的所有键都将是连续的正整数,你可能会考虑使用数组或切片而不是地图。实际上,即使键不是连续的,数组和切片也比地图更便宜,所以你最终可能会得到一个稀疏矩阵。

最后一部分仅用于打印生成的地图的内容,使用了for循环的预期范围形式:

for key, value := range aMap {
    fmt.Printf("%s: %d\n", key, value) 
   } 
} 

正如您可以轻松猜到的那样,开发逆操作并不总是可能的,因为map是比array更丰富的数据结构。但是,使用更强大的数据结构所付出的代价是时间,因为数组操作通常更快。

结构

尽管数组、切片和地图都非常有用,但它们不能在同一个位置保存多个值。当您需要对各种类型的变量进行分组并创建一个新的方便类型时,可以使用结构--结构的各个元素称为字段。

这个小节的代码保存为dataStructures.go,可以分为三部分。第一部分包含序言和一个名为message的新结构的定义:

package main 

import ( 
   "fmt" 
   "reflect" 
) 

func main() { 

   type message struct {
         X     int 
         Y     int 
         Label string 
   } 

消息结构有三个字段,名为XYLabel。请注意,结构通常在程序开头和main()函数之外定义。

第二部分使用消息结构定义了两个名为p1p2的新消息变量,然后使用反射获取有关消息结构的p1p2变量的信息:

   p1 := message{23, 12, "A Message"} 
   p2 := message{} 
   p2.Label = "Message 2" 

   s1 := reflect.ValueOf(&p1).Elem() 
   s2 := reflect.ValueOf(&p2).Elem() 
   fmt.Println("S2= ", s2) 

最后一部分展示了如何使用for循环和Type()函数打印结构的所有字段而不知道它们的名称:

   typeOfT := s1.Type() 
   fmt.Println("P1=", p1) 
   fmt.Println("P2=", p2) 

   for i := 0; i < s1.NumField(); i++ {
         f := s1.Field(i)

         fmt.Printf("%d: %s ", i, typeOfT.Field(i).Name) 
         fmt.Printf("%s = %v\n", f.Type(), f.Interface()) 
   } 

} 

运行dataStructures.go将生成以下类型的输出:

$ go run dataStructures.go
S2=  {0 0 Message 2}
P1= {23 12 A Message}
P2= {0 0 Message 2}
0: X int = 23
1: Y int = 12
2: Label string = A Message

如果struct定义的字段名称以小写字母开头(x而不是X),上一个程序将失败,并显示以下错误消息:

panic: reflect.Value.Interface: cannot return value obtained from unexported field or method

这是因为小写字段不会被导出;因此,它们不能被reflect.Value.Interface()方法使用。您将在下一章中了解更多关于reflection的内容。

接口

接口是 Go 的高级功能,这意味着如果您对 Go 不太熟悉,可能不希望在程序中使用它们。但是,在开发大型 Go 程序时,接口可能非常实用,这是本书讨论接口的主要原因。

但首先,我将讨论方法,这些是带有特殊接收器参数的函数。您将方法声明为普通函数,并在函数名称之前添加一个额外的参数。这个特殊的参数将函数连接到该额外参数的类型。因此,该参数被称为方法的接收器。您一会儿会看到这样的函数。

简而言之,接口是定义一组需要实现的函数的抽象类型,以便将类型视为接口的实例。当这种情况发生时,我们说该类型满足此接口。因此,接口是两种东西--一组方法和一种类型--它用于定义类型的行为。

让我们用一个例子来描述接口的主要优势。想象一下,你有一个名为 ATYPE 的类型和一个适用于 ATYPE 类型的接口。接受一个 ATYPE 变量的任何函数都可以接受实现了 ATYPE 接口的任何其他变量。

interfaces.go的 Go 代码可以分为三部分。第一部分如下所示:

package main 

import ( 
   "fmt" 
) 

type coordinates interface { 
   xaxis() int 
   yaxis() int 
} 

type point2D struct { 
   X int 
   Y int 
} 

在这一部分中,你定义了一个名为 coordinates 的接口和一个名为point2D的新结构。接口有两个函数,名为xaxis()yaxis()。坐标接口的定义表示,如果要转换为坐标接口,必须实现这两个函数。

重要的是注意,接口除了接口本身不声明任何其他特定类型。另一方面,接口的两个函数应声明它们返回值的类型。

第二部分包含以下 Go 代码:

func (s point2D) xaxis() int { 
   return s.X 
} 

func (s point2D) yaxis() int { 
   return s.Y 
} 

func findCoordinates(a coordinates) { 
   fmt.Println("X:", a.xaxis(), "Y:", a.yaxis()) 
} 

type coordinate int 

func (s coordinate) xaxis() int { 
   return int(s) 
} 

func (s coordinate) yaxis() int { 
   return 0 
} 

在第二部分中,首先为point2D类型实现坐标接口的两个函数。然后开发一个名为findCoordinates()的函数,该函数接受一个实现坐标接口的变量。findCoordinates()函数只是使用简单的fmt.Println()函数调用打印点的两个坐标。然后,定义一个名为 coordinate 的新类型,用于属于x轴的点。最后,为 coordinate 类型实现坐标接口。

在编写interfaces.go代码时,我认为coordinatescoordinate这两个名称还不错。在写完上一段之后,我意识到coordinate类型本可以改名为xpoint以提高可读性。我保留了coordinatescoordinate这两个名称,以指出每个人都会犯错误,你使用的变量和类型名称必须明智选择。

最后一部分包含以下 Go 代码:

func main() { 

   x := point2D{X: -1, Y: 12}
   fmt.Println(x) 
   findCoordinates(x) 

   y := coordinate(10) 
   findCoordinates(y) 
} 

在这一部分中,首先创建一个point2D变量,并使用findCoordinates()函数打印其坐标,然后创建一个名为y的坐标变量,它保存一个单一的坐标值。最后,使用与打印point2D变量相同的findCoordinates()函数打印y变量。

尽管 Go 不是一种面向对象的编程语言,但我将在这里使用一些面向对象的术语。因此,在面向对象的术语中,这意味着point2Dcoordinate类型都是坐标对象。但是,它们都不是只是coordinate对象。

执行interfaces.go会创建以下输出:

$ go run interfaces.go
{-1 12}
X: -1 Y: 12
X: 10 Y: 0

我认为在开发系统软件时,Go 接口并不是必需的,但它们是一个方便的 Go 特性,可以使系统应用程序的开发更易读和更简单,所以不要犹豫使用它们。

创建随机数

作为一个实际的编程示例,本节将讨论在 Go 中创建随机数。随机数有许多用途,包括生成良好的密码以及创建具有随机数据的文件,这些文件可用于测试其他应用程序。但是,请记住,通常编程语言生成伪随机数,这些数近似于真随机数生成器的属性。

Go 使用math/rand包生成随机数,并需要一个种子来开始生成随机数。种子用于初始化整个过程,非常重要,因为如果始终使用相同的种子开始,将始终得到相同的随机数序列。

random.go程序有三个主要部分。第一部分是程序的序言:

package main 

import ( 
   "fmt" 
   "math/rand" 
   "os" 
   "strconv" 
   "time" 
) 

第二部分是定义random()函数,每次调用该函数都会返回一个随机数,使用rand.Intn() Go 函数:

func random(min, max int) int { 
   return rand.Intn(max-min) + min 
} 

random() 函数的两个参数定义了生成的随机数的下限和上限。random.go 的最后部分是 main() 函数的实现,主要用于调用 random() 函数:

func main() { 
   MIN := 0 
   MAX := 0 
   TOTAL := 0 
   if len(os.Args) > 3 { 
         MIN, _ = strconv.Atoi(os.Args[1]) 
         MAX, _ = strconv.Atoi(os.Args[2]) 
         TOTAL, _ = strconv.Atoi(os.Args[3]) 
   } else { 
         fmt.Println("Usage:", os.Args[0], "MIX MAX TOTAL") 
         os.Exit(-1) 
   } 

   rand.Seed(time.Now().Unix()) 
   for i := 0; i < TOTAL; i++ { 
         myrand := random(MIN, MAX) 
         fmt.Print(myrand) 
         fmt.Print(" ") 
   } 
   fmt.Println() 
} 

main() 函数的一个重要部分涉及处理命令行参数作为整数,并在没有获得正确数量的命令行参数时打印描述性错误消息。这是本书中我们将遵循的标准做法。random.go 程序使用 Unix 纪元时间作为随机数生成器的种子,通过调用 time.Now().Unix() 函数。要记住的重要事情是,你不必多次调用 rand.Seed()。最后,random.go 不检查 strconv.Atoi() 返回的错误变量以节省书本空间,而不是因为它不必要。

执行 random.go 会生成以下类型的输出:

$ go run random.go 12 32 20
29 27 20 23 22 28 13 16 22 26 12 29 22 30 15 19 26 24 20 29

如果你希望在 Go 中生成更安全的随机数,你应该使用 crypto/rand 包,它实现了一个密码学安全的伪随机数生成器。你可以通过访问其文档页面 golang.org/pkg/crypto/rand/ 获取有关 crypto/rand 包的更多信息。

如果你真的对随机数感兴趣,那么随机数理论的权威参考书是 Donald Knuth 的《计算机编程艺术》第二卷。

练习

  1. 浏览 Go 文档网站:golang.org/doc/

  2. 编写一个 Go 程序,它会一直读取整数,直到你输入数字 0 为止,然后打印输入中的最小和最大整数。

  3. 编写与之前相同的 Go 程序,但这次,你将使用命令行参数获取输入。你认为哪个版本更好?为什么?

  4. 编写一个支持两个命令行选项(-i-k)的 Go 程序,使用 if 语句可以随机顺序。现在将你的程序更改为支持三个命令行参数。正如你将看到的,后一个程序的复杂性太大,无法使用 if 语句处理。

  5. 如果映射的索引是自然数,是否有任何情况下使用映射而不是数组是明智且有效的?

  6. 尝试将 array2map.go 的功能放入一个单独的函数中。

  7. 尝试在 Go 中开发自己的随机数生成器,它仍然使用当前时间作为种子,但不使用 math/rand 包。

  8. 学习如何从现有数组创建切片。当你对切片进行更改时会发生什么?

  9. 使用 copy() 函数复制现有切片。当目标切片小于源切片时会发生什么?当目标切片大于源切片时会发生什么?

  10. 尝试编写一个支持 3D 空间中的点的接口。然后,使用这个接口来支持位于 x 轴上的点。

总结

在本章中,你学到了很多东西,包括获取用户输入和处理命令行参数。你熟悉了基本的 Go 结构,并创建了一个生成随机数的 Go 程序。尝试做提供的练习,如果在某些练习中失败,不要灰心。

下一章将讨论许多高级的 Go 特性,包括错误处理、模式匹配、正则表达式、反射、不安全代码、从 Go 调用 C 代码以及 strace(1) 命令行实用程序。我将把 Go 与其他编程语言进行比较,并给出实用建议,以避免一些常见的 Go 陷阱。

第三章:高级 Go 特性

在上一章中,您学习了如何编译 Go 代码,如何从用户那里获取输入并在屏幕上打印输出,如何创建自己的 Go 函数,Go 支持的数据结构以及如何处理命令行参数。

本章将讨论许多有趣的事情,因此您最好为许多有趣且实用的 Go 代码做好准备,这些代码将帮助您执行许多不同但非常重要的任务,从错误处理开始,以避免一些常见的 Go 错误结束。如果您熟悉 Go,可以跳过您已经知道的内容,但请不要跳过建议的练习。

因此,本章将讨论一些高级的 Go 特性,包括:

  • 错误处理

  • 错误日志记录

  • 模式匹配和正则表达式

  • 反射

  • 如何使用strace(1)dtrace(1)工具来监视 Go 可执行文件的系统调用

  • 如何检测不可达的 Go 代码

  • 如何避免各种常见的 Go 错误

Go 中的错误处理

错误经常发生,因此我们的工作是捕捉并处理它们,特别是在编写处理敏感系统信息和文件的代码时。好消息是,Go 有一种特殊的数据类型叫做error,可以帮助表示错误状态;如果error变量的值为nil,则没有错误情况。

正如您在上一章中开发的addCLA.go程序中看到的,您可以使用_字符忽略大多数 Go 函数返回的error变量:

temp, _ := strconv.Atoi(arguments[i]) 

然而,这并不被认为是良好的做法,应该避免,特别是在系统软件和其他类型的关键软件(如服务器进程)上。

正如您将在第六章中看到的,文件输入和输出,即使是文件结束EOF)也是一种错误类型,在从文件中没有剩余内容可读时返回。由于EOFio包中定义,您可以按以下方式处理它:

if err == io.EOF {

    // Do something 
} 

然而,学习如何开发返回error变量的函数以及如何处理它们是最重要的任务,下面将对此进行解释。

函数可以返回错误变量

Go 函数可以返回error变量,这意味着错误条件可以在函数内部、函数外部或者函数内外都可以处理;后一种情况并不经常发生。因此,本小节将开发一个返回错误消息的函数。相关的 Go 代码可以在funErr.go中找到,并将分为三部分呈现。

第一部分包含以下 Go 代码:

package main 

import ( 
   "errors" 
   "fmt" 
   "log" 
) 

func division(x, y int) (int, error, error) { 
   if y == 0 { 
         return 0, nil, errors.New("Cannot divide by zero!") 
   } 
   if x%y != 0 { 
         remainder := errors.New("There is a remainder!") 
         return x / y, remainder, nil 
   } else { 
         return x / y, nil, nil 
   } 

} 

除了预期的前言之外,上述代码定义了一个名为division()的新函数,该函数返回一个整数和两个error变量。如果您还记得您的数学课,当您除两个整数时,除法运算并不总是完美的,这意味着您可能会得到一个不为零的余数。您在funErr.go中看到的errors Go 包中的errors.New()函数创建一个新的error变量,使用提供的字符串作为错误消息。

funErr.go的第二部分包含以下 Go 代码:

func main() { 
   result, rem, err := division(2, 2) 
   if err != nil { 
         log.Fatal(err) 
   } else { 
         fmt.Println("The result is", result) 
   } 

   if rem != nil { 
         fmt.Println(rem) 
   } 

error变量与nil进行比较是 Go 中非常常见的做法,可以快速判断是否存在错误条件。

funErr.go的最后一部分如下:

   result, rem, err = division(12, 5) 
   if err != nil { 
         log.Fatal(err) 
   } else { 
         fmt.Println("The result is", result) 
   } 

   if rem != nil { 
         fmt.Println(rem) 
   } 

   result, rem, err = division(2, 0) 
   if err != nil { 
         log.Fatal(err) 
   } else { 
         fmt.Println("The result is", result) 
   } 

   if rem != nil { 
         fmt.Println(rem) 
   } 
} 

本部分展示了两种错误条件。第一种是具有余数的整数除法,而第二种是无效的除法,因为您不能将一个数除以零。正如名称log.Fatal()所暗示的,这个日志函数应该仅用于关键错误,因为当调用时,它会自动终止您的程序。然而,正如您将在下一小节中看到的,存在其他更温和的方式来记录您的错误消息。

执行funErr.go会生成以下输出:

$ go run funErr.go
The result is 1
The result is 2
There is a remainder!
2017/03/07 07:39:19 Cannot divide by zero!
exit status 1

最后一行是由log.Fatal()函数自动生成的,在终止程序之前。重要的是要理解,在调用log.Fatal()之后的任何 Go 代码都不会被执行。

关于错误记录

Go 提供了可以帮助您以各种方式记录错误消息的函数。您已经在funErr.go中看到了log.Fatal(),这是一种处理简单错误的相当残酷的方式。简单地说,您应该有充分的理由在代码中使用log.Fatal()。一般来说,应该使用log.Fatal()而不是os.Exit()函数,因为它允许您使用一个函数调用打印错误消息并退出程序。

Go 在log标准包中提供了更温和地根据情况行为的附加错误记录函数,包括log.Printf()log.Print()log.Println()log.Fatalf()log.Fatalln()log.Panic()log.Panicln()log.Panicf()。请注意,记录函数对于调试目的可能会很有用,因此不要低估它们的作用。

logging.go程序使用以下 Go 代码说明了所提到的两个记录函数:

package main 

import ( 
   "log" 
) 

func main() { 
   x := 1 
   log.Printf("log.Print() function: %d", x) 
   x = x + 1 
   log.Printf("log.Print() function: %d", x) 
   x = x + 1 
   log.Panicf("log.Panicf() function: %d", x) 
   x = x + 1 
   log.Printf("log.Print() function: %d", x) 
} 

正如您所看到的,logging.go不需要fmt包,因为它有自己的函数来打印输出。执行logging.go将产生以下输出:

$ go run logging.go
2017/03/10 16:51:56 log.Print() function: 1
2017/03/10 16:51:56 log.Print() function: 2
2017/03/10 16:51:56 log.Panicf() function: 3
panic: log.Panicf() function: 3

goroutine 1 [running]:
log.Panicf(0x10b78d0, 0x19, 0xc42003df48, 0x1, 0x1)
      /usr/local/Cellar/go/1.8/libexec/src/log/log.go:329 +0xda
main.main()
      /Users/mtsouk/ch3/code/logging.go:14 +0x1af
exit status 2

尽管log.Printf()函数的工作方式与fmt.Printf()相同,但它会自动打印日志消息打印的日期和时间,就像funErr.go中的log.Fatal()函数一样。此外,log.Panicf()函数的工作方式与log.Fatal()类似--它们都会终止当前程序。但是,log.Panicf()会打印一些额外的信息,用于调试目的。

Go 还提供了log/syslog包,它是 Unix 机器上运行的系统日志服务的简单接口。第七章,使用系统文件,将更多地讨论log/syslog包。

重新审视 addCLA.go 程序

本小节将介绍在前一章中开发的addCLA.go程序的改进版本,以使其能够处理任何类型的用户输入。新程序将被称为addCLAImproved.go,但是,您将只看到addCLAImproved.goaddCLA.go之间的差异,使用diff(1)命令行实用程序:

$ diff addCLAImproved.go addCLA.go
13,18c13,14
<           temp, err := strconv.Atoi(arguments[i])
<           if err == nil {
<                 sum = sum + temp
<           } else {
<                 fmt.Println("Ignoring", arguments[i])
<           }
---
>           temp, _ := strconv.Atoi(arguments[i])
>           sum = sum + temp

这个输出基本上告诉我们的是,在addCLA.go中找到的最后两行代码,以>字符开头,被addCLAImproved.go中以<字符开头的代码替换了。两个文件的剩余代码完全相同。

diff(1)实用程序逐行比较文本文件,是发现同一文件不同版本之间代码差异的一种方便方法。

执行addCLAImproved.go将生成以下类型的输出:

$ go run addCLAImproved.go
Sum: 0
$ go run addCLAImproved.go 1 2 -3
Sum: 0
$ go run addCLAImproved.go 1 a 2 b 3.2 @
Ignoring a
Ignoring b
Ignoring 3.2
Ignoring @
Sum: 3

因此,新的改进版本按预期工作,表现可靠,并允许我们区分有效和无效的输入。

模式匹配和正则表达式

模式匹配在 Go 中扮演着关键角色,它是一种基于正则表达式的搜索字符串的技术,用于根据特定的搜索模式搜索一组字符。如果模式匹配成功,它允许您从字符串中提取所需的数据,或者替换或删除它。语法是形式语言中字符串的一组生成规则。生成规则描述如何根据语言的语法创建有效的字符串。语法不描述字符串的含义或在任何上下文中可以对其进行的操作,只描述其形式。重要的是要意识到语法是正则表达式的核心,因为没有它,您无法定义或使用正则表达式。

正则表达式和模式匹配并非万能良药,因此不应尝试使用正则表达式解决每个问题,因为它们并不适用于您可能遇到的每种问题。此外,它们可能会给您的软件引入不必要的复杂性。

负责 Go 模式匹配功能的 Go 包称为regexp,您可以在regExp.go中看到其运行情况。regExp.go的代码将分为四部分呈现。

第一部分是预期的序言:

package main 

import ( 
   "fmt" 
   "regexp" 
) 

第二部分如下:

func main() { 
match, _ := regexp.MatchString("Mihalis", "Mihalis Tsoukalos") 
   fmt.Println(match) 
   match, _ = regexp.MatchString("Tsoukalos", "Mihalis tsoukalos") 
   fmt.Println(match) 

regexp.MatchString()的两次调用都尝试在给定的字符串(第二个参数)中查找静态字符串(第一个参数)。

第三部分包含一行 Go 代码,但至关重要:

   parse, err := regexp.Compile("[Mm]ihalis") 

regexp.Compile()函数读取提供的正则表达式并尝试解析它。如果成功解析正则表达式,则regexp.Compile()返回regexp.Regexp变量类型的值,您随后可以使用它。regexp.Compile()函数中的[Mm]表达式表示您要查找的内容可以以大写M或小写m开头。[]都是特殊字符,不是正则表达式的一部分。因此,提供的语法是天真的,只匹配单词Mihalismihalis

最后一部分使用存储在parse变量中的先前正则表达式:

   if err != nil { 
         fmt.Printf("Error compiling RE: %s\n", err) 
   } else { 
         fmt.Println(parse.MatchString("Mihalis Tsoukalos")) 
         fmt.Println(parse.MatchString("mihalis Tsoukalos")) 
         fmt.Println(parse.MatchString("M ihalis Tsoukalos")) 
         fmt.Println(parse.ReplaceAllString("mihalis Mihalis", "MIHALIS")) 
   } 
} 

运行regExp.go会生成以下输出:

$ go run regExp.go
true
false
true
true
false
MIHALIS MIHALIS

因此,对regexp.MatchString()的第一次调用是匹配的,但第二次调用不是,因为模式匹配是区分大小写的,Tsoukalostsoukalos不匹配。最后的parse.ReplaceAllString()函数搜索给定的字符串("mihalis Mihalis")并用其第二个参数("MIHALIS")替换每个匹配项。

本节的其余部分将使用静态文本呈现各种示例,因为您还不知道如何读取文本文件。但是,由于静态文本将存储在数组中并逐行处理,因此所呈现的代码可以轻松修改以支持从外部文本文件获取输入。

打印行的给定列的所有值

这是一个非常常见的情景,因为您经常需要从结构化文本文件的给定列中获取所有数据,以便随后进行分析。将呈现readColumn.go的代码,该代码将在两部分中呈现,打印第三列中的值。

第一部分如下:

package main 

import ( 
   "fmt" 
   "strings" 
) 

func main() { 
   var s [3]string 
   s[0] = "1 2 3" 
   s[1] = "11 12 13 14 15 16" 
   s[2] = "-1 2 -3 -4 -5 6" 

在这里,您导入所需的 Go 包并使用包含三个元素的数组定义了一个包含三行的字符串。

第二部分包含以下 Go 代码:

   column := 2 

   for i := 0; i < len(s); i++ { 
         data := strings.Fields(s[i]) 
         if len(data) >= column { 
               fmt.Println((data[column-1])) 
         } 
   } 
} 

首先,您定义您感兴趣的列。然后,您开始迭代存储在数组中的字符串。这类似于逐行读取文本文件。for循环内的 Go 代码拆分输入行的字段,将它们存储在data数组中,验证所需列的值是否存在,并在屏幕上打印它。所有繁重的工作都由方便的strings.Fields()函数完成,该函数根据空格字符拆分字符串,如unicode.IsSpace()中定义的,并返回一个字符串切片。虽然readColumn.go没有使用regexp.Compile()函数,但其实现逻辑仍然基于正则表达式的原则,使用了strings.Fields()

要记住的一件重要的事情是,您永远不应信任您的数据。简而言之,始终验证您期望获取的数据是否存在。

执行readColumn.go将生成以下类型的输出:

$ go run readColumn.go
2
12
2

第六章,文件输入和输出,将展示readColumn.go的改进版本,您可以将其用作起点,以便修改所示示例的其余部分。

创建摘要

在本节中,我们将开发一个程序,它将添加多行文本中给定列的所有值。为了使事情更有趣,列号将作为程序的参数给出。本小节的程序与上一小节的readColumn.go的主要区别在于,您需要将每个值转换为整数。

将开发的程序的名称是summary.go,可以分为三部分。

第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "strconv" 
   "strings" 
) 

func main() { 
   var s [3]string 
   s[0] = "1 b 3" 
   s[1] = "11 a 1 14 1 1" 
   s[2] = "-1 2 -3 -4 -5" 

第二部分包含以下 Go 代码:

   arguments := os.Args 
   column, err := strconv.Atoi(arguments[1]) 
   if err != nil { 
         fmt.Println("Error reading argument") 
         os.Exit(-1) 
   } 
   if column == 0 { 
         fmt.Println("Invalid column") 
         os.Exit(1) 
   } 

前面的代码读取您感兴趣的列的索引。如果要使summary.go更好,可以检查column变量中的负值,并打印适当的错误消息。

summary.go的最后一部分如下:

   sum := 0 
   for i := 0; i < len(s); i++ { 
         data := strings.Fields(s[i]) 
         if len(data) >= column { 
               temp, err := strconv.Atoi(data[column-1]) 
               if err == nil { 
                     sum = sum + temp 
               } else { 
                     fmt.Printf("Invalid argument: %s\n", data[column-1]) 
               } 
         } else { 
               fmt.Println("Invalid column!") 
         } 
   } 
   fmt.Printf("Sum: %d\n", sum) 
} 

正如您所看到的,summary.go中的大部分 Go 代码都是关于处理异常和潜在错误。summary.go的核心功能是用几行 Go 代码实现的。

执行summary.go将给出以下输出:

$ go run summary.go 0
Invalid column
exit status 1
$ go run summary.go 2
Invalid argument: b
Invalid argument: a
Sum: 2
$ go run summary.go 1
Sum: 11

查找出现次数

一个非常常见的编程问题是找出 IP 地址在日志文件中出现的次数。因此,本小节中的示例将向您展示如何使用方便的映射结构来做到这一点。occurrences.go程序将分为三部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
   "strings" 
) 

func main() { 

   var s [3]string 
   s[0] = "1 b 3 1 a a b" 
   s[1] = "11 a 1 1 1 1 a a" 
   s[2] = "-1 b 1 -4 a 1" 

第二部分如下:

   counts := make(map[string]int) 

   for i := 0; i < len(s); i++ { 
         data := strings.Fields(s[i]) 
         for _, word := range data { 
               _, ok := counts[word] 
               if ok { 
                     counts[word] = counts[word] + 1 
               } else { 
                     counts[word] = 1 
               } 
         } 
   } 

在这里,我们使用上一章的知识创建了一个名为counts的映射,并使用两个for循环将所需的数据填充到其中。

最后一部分非常小,因为它只是打印counts映射的内容:

   for key, _ := range counts {

         fmt.Printf("%s -> %d \n", key, counts[key]) 
   } 
} 

执行occurrences.go并使用sort(1)命令行实用程序对occurrences.go的输出进行排序将生成以下类型的输出:

$ go run occurrences.go | sort -n -r -t\  -k3,3
1 -> 8
a -> 6
b -> 3
3 -> 1
11 -> 1
-4 -> 1
-1 -> 1

正如你所看到的,传统的 Unix 工具仍然很有用。

查找和替换

本小节中的示例将搜索提供的文本,查找给定字符串的两种变体,并用另一个字符串替换它。程序将被命名为findReplace.go,实际上将使用 Go 正则表达式。在这种情况下使用regexp.Compile()函数的主要原因是它极大地简化了事情,并允许您只访问文本一次。

findReplace.go程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "regexp" 
) 

下一部分如下:

func main() { 

   var s [3]string 
   s[0] = "1 b 3" 
   s[1] = "11 a B 14 1 1" 
   s[2] = "b 2 -3 B -5" 

   parse, err := regexp.Compile("[bB]")

   if err != nil { 
         fmt.Printf("Error compiling RE: %s\n", err) 
         os.Exit(-1) 
   } 

前面的 Go 代码将找到大写B或小写b[bB])的每个出现。请注意,还有regexp.MustCompile(),它的工作方式类似于regexp.Compile()。但是,regexp.MustCompile()不会返回一个error变量;如果给定的表达式错误并且无法解析,它会直接 panic。因此,regexp.Compile()是一个更好的选择。

最后一部分如下:

   for i := 0; i < len(s); i++ { 
         temp := parse.ReplaceAllString(s[i], "C") 
         fmt.Println(temp) 
   } 
} 

在这里,您可以使用parse.ReplaceAllString()将每个匹配项替换为大写的 C。

执行findReplace.go将生成预期的输出:

$ go run findReplace.go
1 C 3
11 a C 14 1 1
C 2 -3 C -5

awk(1)sed(1)命令行工具可以更轻松地完成大部分以前的任务,但sed(1)awk(1)不是通用的编程语言。

反射

反射是 Go 的一个高级特性,它允许您动态了解任意对象的类型以及有关其结构的信息。您应该回忆起第二章中的dataStructures.go程序,在 Go 中编写程序,它使用反射来查找数据结构的字段以及每个字段的类型。所有这些都是在reflect Go 包和reflect.TypeOf()函数的帮助下完成的,该函数返回一个Type变量。

反射在reflection.go Go 程序中得到了展示,将分为四部分呈现。

第一个是 Go 程序的序言,代码如下:

package main 

import ( 
   "fmt" 
   "reflect" 
) 

第二部分如下:

func main() { 

   type t1 int 
   type t2 int 

   x1 := t1(1) 
   x2 := t2(1) 
   x3 := 1 

在这里,您创建了两种新类型,名为t1t2,它们都是int,以及三个变量,名为x1x2x3

第三部分包含以下 Go 代码:

   st1 := reflect.ValueOf(&x1).Elem() 
   st2 := reflect.ValueOf(&x2).Elem() 
   st3 := reflect.ValueOf(&x3).Elem() 

   typeOfX1 := st1.Type() 
   typeOfX2 := st2.Type() 
   typeOfX3 := st3.Type() 

   fmt.Printf("X1 Type: %s\n", typeOfX1) 
   fmt.Printf("X2 Type: %s\n", typeOfX2) 
   fmt.Printf("X3 Type: %s\n", typeOfX3) 

在这里,您可以使用reflect.ValueOf()Type()找到x1x2x3变量的类型。

reflection.go的最后一部分涉及struct变量:

   type aStructure struct { 
         X    uint 
         Y    float64 
         Text string 
   } 

   x4 := aStructure{123, 3.14, "A Structure"} 
   st4 := reflect.ValueOf(&x4).Elem() 
   typeOfX4 := st4.Type() 

   fmt.Printf("X4 Type: %s\n", typeOfX4) 
   fmt.Printf("The fields of %s are:\n", typeOfX4) 

   for i := 0; i < st4.NumField(); i++ { 
         fmt.Printf("%d: Field name: %s ", i, typeOfX4.Field(i).Name) 
         fmt.Printf("Type: %s ", st4.Field(i).Type()) 
         fmt.Printf("and Value: %v\n", st4.Field(i).Interface()) 
   } 
} 

Go 中存在一些管理反射的规则,但讨论它们超出了本书的范围。您应该记住的是,您的程序可以使用反射来检查自己的结构,这是一种非常强大的能力。

执行reflection.go打印以下输出:

$ go run reflection.go
X1 Type: main.t1
X2 Type: main.t2
X3 Type: int
X4 Type: main.aStructure
The fields of main.aStructure are:
0: Field name: X Type: uint and Value: 123
1: Field name: Y Type: float64 and Value: 3.14
2: Field name: Text Type: string and Value: A Structure

输出的前两行显示,Go 不认为类型t1t2相等,尽管t1t2都是int类型的别名。

旧习惯难改!

尽管 Go 试图成为一种安全的编程语言,但有时它被迫忘记安全性,并允许程序员做任何他/她想做的事情。

从 Go 调用 C 代码

Go 允许您调用 C 代码,因为有时执行某些任务的唯一方法,例如与硬件设备或数据库服务器通信,是使用 C。然而,如果您发现自己在同一个项目中多次使用此功能,您可能需要重新考虑您的方法和编程语言的选择。

在本书的范围之外更多地讨论 Go 中的这一功能。您应该记住的是,您很可能永远不需要从 Go 程序中调用 C 代码。然而,如果您希望探索这一 Go 功能,可以首先访问cgo 工具的文档,并查看github.com/golang/go/blob/master/misc/cgo/gmp/gmp.go中的代码。

不安全的代码

不安全的代码是绕过 Go 的类型安全和内存安全的 Go 代码,需要使用unsafe包。您很可能永远不需要在 Go 程序中使用不安全的代码,但如果出于某种奇怪的原因您确实需要使用它,那可能与指针有关。

对于您的程序来说,使用不安全的代码可能是危险的,因此只有在绝对必要时才使用它。如果您不完全确定需要它,那么就不要使用它。

本小节中的示例代码保存为unsafe.go,将分两部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
   "unsafe" 
) 

func main() { 
   var value int64 = 5

   var p1 = &value 
   var p2 = (*int32)(unsafe.Pointer(p1)) 

首先创建一个名为value的新int64变量。然后,创建一个指向它的指针命名为p1。接下来,创建另一个指针指向p1。然而,指向p1p2指针是指向int64变量的指针,尽管p1指向int64变量。尽管这违反了 Go 的规则,但unsafe.Pointer()函数使这成为可能。

第二部分如下:

   fmt.Println("*p1: ", *p1) 
   fmt.Println("*p2: ", *p2) 
   *p1 = 312121321321213212 
   fmt.Println(value) 
   fmt.Println("*p2: ", *p2) 
   *p1 = 31212132 
   fmt.Println(value) 
   fmt.Println("*p2: ", *p2) 
} 

执行unsafe.go将创建以下输出:

$ go run unsafe.go
*p1:  5
*p2:  5
312121321321213212
*p2:  606940444
31212132
*p2:  31212132

输出显示了不安全指针有多危险。当value变量的值适合于int32内存空间(531212132)时,p2运行正常并显示正确的结果。然而,当value变量持有一个不适合int32内存空间的值(312121321321213212)时,p2显示了错误的结果(606940444),而没有提供警告或错误消息。

将 Go 与其他编程语言进行比较

Go 并不完美,但其他编程语言也不完美。本节将简要讨论其他编程语言,并将它们与 Go 进行比较,以便让您更好地了解您的选择。因此,可以与 Go 进行比较的编程语言列表包括:

  • C:C 是开发系统软件最流行的编程语言,因为每个 Unix 操作系统的可移植部分都是用 C 编写的。然而,它也有一些关键缺点,包括 C 指针,它们很棒也很快,但可能导致难以检测的错误和内存泄漏。此外,C 不提供垃圾回收;在 C 创建时,垃圾回收是一种可能会减慢计算机速度的奢侈品。然而,如今的计算机非常快,垃圾回收不再拖慢速度。此外,与其他系统编程语言相比,C 程序需要更多的代码来开发给定的任务。最后,C 是一种不支持现代编程范式的旧编程语言,比如面向对象和函数式编程。

  • C++:如前所述,我不再喜欢 C++。如果你认为应该使用 C++,那么你可能想考虑使用 C。然而,C++相对于 Go 的主要优势在于,如果需要,C++可以像 C 一样使用。然而,无论是 C 还是 C++都不支持并发编程。

  • Rust:Rust 是一种新的系统编程语言,试图避免由不安全代码引起的不愉快的错误。目前,Rust 的语法变化太快,但这将在不久的将来结束。如果出于某种原因你不喜欢 Go,你应该尝试 Rust。

  • Swift:在目前的状态下,Swift 更适合开发 macOS 系统的系统软件。然而,我相信在不久的将来,Swift 将在 Linux 机器上更受欢迎,所以你应该留意它。

  • Python:Python 是一种脚本语言,这是它的主要缺点。这是因为通常情况下,你不希望将系统软件的源代码公开给所有人。

  • Perl:关于 Python 所说的也适用于 Perl。然而,这两种编程语言都有大量的模块,可以让你的生活变得更轻松,你的代码变得更简洁。

如果你问我的意见,我认为 Go 是一种现代、可移植、成熟和安全的编程语言,用于编写系统软件。在寻找其他选择之前,你应该先尝试 Go。然而,如果你是一名 Go 程序员,想尝试其他东西,我建议你选择 Rust 或 Swift。然而,如果你需要编写可靠的并发程序,Go 应该是你的首选。

如果你无法在 Go 和 Rust 之间做出选择,那就试试 C。学习系统编程的基础知识比你选择的编程语言更重要。

尽管它们有缺点,但请记住,所有脚本编程语言都非常适合编写原型,并且它们的优势在于可以为软件创建图形界面。然而,使用脚本语言交付系统软件很少被接受,除非有一个真正的好理由这样做。

分析软件

有时程序因某种未知原因失败或性能不佳,你希望找出原因,而不必重写代码并添加大量的调试语句。因此,本节将讨论strace(1)dtrace(1),它们允许你在 Unix 机器上执行程序时看到幕后发生了什么。虽然这两个工具都可以与go run命令一起使用,但如果你首先使用go build创建可执行文件并使用该文件,你将获得更少的无关输出。这主要是因为go run在实际运行 Go 代码之前会生成临时文件,而你想调试的是实际程序,而不是用于构建程序的编译器。

请记住,尽管dtrace(1)strace(1)更强大,并且有自己的编程语言,但strace(1)更适用于观察程序所做的系统调用。

使用 strace(1)命令行实用程序

strace(1)命令行实用程序允许您跟踪系统调用和信号。由于 Mac 机器上没有strace(1),因此本节将使用 Linux 机器来展示strace(1)。但是,正如您将在稍后看到的那样,macOS 机器有dtrace(1)命令行实用程序,可以做更多的事情。

程序名称后面的数字指的是其页面所属的手册部分。尽管大多数名称只能找到一次,这意味着不必放置部分编号,但是有些名称可能位于多个部分,因为它们具有多重含义,例如crontab(1)crontab(5)。因此,如果尝试检索此类页面而没有明确指定部分编号,将会得到手册中具有最小部分编号的条目。

要对strace(1)生成的输出有一个良好的感觉,请查看以下图,其中strace(1)用于检查addCLAImproved.go的可执行文件:

在 Linux 机器上使用 strace(1)命令

strace(1)输出的真正有趣的部分是以下行,这在前面的图中看不到:

$ strace ./addCLAImproved 1 2 2>&1 | grep write
write(1, "Sum: 3\n", 7Sum: 3

我们使用grep(1)命令行实用程序提取包含我们感兴趣的 C 系统调用的行,这种情况下是write(2)。这是因为我们已经知道write(2)用于打印输出。因此,您了解到在这种情况下,单个write(2) C 系统调用用于在屏幕上打印所有输出;它的第一个参数是文件描述符,第二个参数是要打印的文本。

请注意,您可能希望使用strace(1)-f选项,以便还跟踪在程序执行期间可能创建的任何子进程。

请记住,还存在write(2)的另外两个变体,名为pwrite(2)writev(2),它们提供与write(2)相同的核心功能,但方式略有不同。

前一个命令的以下变体需要更多对write(2)的调用,因为它生成更多的输出:

$ strace ./addCLAImproved 1 a b 2>&1 | grep write
write(1, "Ignoring a\n", 11Ignoring a
write(1, "Ignoring b\n", 11Ignoring b
write(1, "Sum: 1\n", 7Sum: 1

Unix 使用文件描述符作为访问其所有文件的内部表示,这些文件描述符是正整数值。默认情况下,所有 Unix 系统都支持三个特殊和标准的文件名:/dev/stdin/dev/stdout/dev/stderr。它们也可以使用文件描述符 0、1 和 2 进行访问。这三个文件描述符也分别称为标准输入、标准输出和标准错误。此外,文件描述符 0 可以在 Mac 机器上作为/dev/fd/0进行访问,在 Debian Linux 机器上可以作为/dev/pts/0进行访问,因为 Unix 中的一切都是文件。

因此,需要在命令的末尾放置2>&1的原因是将所有输出,从标准错误(文件描述符 2)重定向到标准输出(文件描述符 1),以便能够使用grep(1)命令进行搜索,该命令仅搜索标准输出。请注意,存在许多grep(1)的变体,包括zegrep(1)fgrep(1)fgrep(1),当它们需要处理大型或巨大的文本文件时,可能会更快地工作。

您在这里看到的是,即使您在 Go 中编写,生成的可执行文件也使用 C 系统调用和函数,因为除了使用机器语言外,C 是与 Unix 内核通信的唯一方式。

DTrace 实用程序

尽管在 FreeBSD 上工作的调试实用程序,如strace(1)truss(1),可以跟踪进程产生的系统调用,但它们可能会很慢,因此不适合解决繁忙的 Unix 系统上的性能问题。另一个名为dtrace(1)的工具使用DTrace设施,允许您在系统范围内看到幕后发生的事情,而无需修改或重新编译任何内容。它还允许您在生产系统上工作,并动态地观察运行的程序或服务器进程,而不会引入大量开销。

本小节将使用dtruss(1)命令行实用程序,它只是一个dtrace(1)脚本,显示进程的系统调用。当在 macOS 机器上检查addCLAImproved.go可执行文件时,dtruss(1)生成的输出看起来与以下截图中看到的类似:

在 macOS 机器上使用 dtruss(1)命令

再次,输出的以下部分验证了在 Unix 机器上,最终一切都被转换成 C 系统调用和函数,因为这是与 Unix 内核通信的唯一方式。您可以显示对write(2)系统调用的所有调用如下:

$ sudo dtruss -c ./addCLAImproved 2000 2>&1 | grep write

然而,这一次你会得到大量的输出,因为 macOS 可执行文件多次使用write(2)而不是只使用一次来打印相同的输出。

开始意识到并非所有的 Unix 系统都以相同的方式工作,尽管它们有许多相似之处,这是很奇妙的。但这也意味着你不应该对 Unix 系统在幕后的工作方式做任何假设。

真正有趣的是以下命令的输出的最后部分:

$ sudo dtruss -c ./addCLAImproved 2000
CALL                                        COUNT
__pthread_sigmask                               1
exit                                            1
getpid                                          1
ioctl                                           1
issetugid                                       1
read                                            1
thread_selfid                                   1
ulock_wake                                      1
bsdthread_register                              2
close                                           2
csops                                           2
open                                            2
select                                          2
sysctl                                          3
mmap                                            7
mprotect                                        8
stat64                                         41
write                                          83

你得到这个输出的原因是-c选项告诉dtruss(1)统计所有系统调用并打印它们的摘要,这种情况下显示write(2)被调用了 83 次,stat64(2)被调用了 41 次。

dtrace(1)实用程序比strace(1)更强大,并且有自己的编程语言,但学习起来更困难。此外,尽管有 Linux 版本的dtrace(1),但在 Linux 系统上,strace(1)更加成熟,以更简单的方式跟踪系统调用。

您可以通过阅读 Brendan Gregg 和 Jim Mauro 的DTrace: Dynamic Tracing in Oracle Solaris, Mac OS X, and FreeBSD以及访问dtrace.org/了解更多关于dtrace(1)实用程序的信息。

在 macOS 上禁用系统完整性保护

第一次尝试在 Mac OS X 机器上运行dtrace(1)dtruss(1)可能会遇到麻烦,并收到以下错误消息:

$ sudo dtruss ./addCLAImproved 1 2 2>&1 | grep -i write
dtrace: error on enabled probe ID 2132 (ID 156: syscall::write:return): invalid kernel access in action #12 at DIF offset 92

在这种情况下,你可能需要禁用 DTrace 的限制,但仍然保持系统完整性保护对其他所有内容有效。您可以通过访问support.apple.com/en-us/HT204899了解更多关于系统完整性保护的信息。

无法到达的代码

无法到达的代码是永远不会被执行的代码,是一种逻辑错误。由于 Go 编译器本身无法捕捉这种逻辑错误,因此您需要使用go tool vet命令来帮助。

你不应该将无法到达的代码与从未被有意执行的代码混淆,比如不需要的函数的代码,因此在程序中从未被调用。

本节的示例代码保存为cannotReach.go,可以分为两部分。

第一部分包含以下 Go 代码:

package main 

import ( 
   "fmt" 
) 

func x() int {

   return -1 
   fmt.Println("Exiting x()") 
   return -1 
} 

func y() int { 
   return -1 
   fmt.Println("Exiting y()") 
   return -1 
} 

第二部分如下:

func main() { 
   fmt.Println(x()) 
   fmt.Println("Exiting program...") 
} 

正如你所看到的,无法到达的代码在第一部分。x()y()函数都有无法到达的代码,因为它们的return语句放错了位置。然而,我们还没有完成,因为我们将让go tool vet工具发现无法到达的代码。这个过程很简单,包括执行以下命令:

$ go tool vet cannotReach.go
cannotReach.go:9: unreachable code
cannotReach.go:14: unreachable code

此外,您可以看到go tool vet即使周围的函数根本不会被执行,也会检测到无法到达的代码,就像y()一样。

避免常见的 Go 错误

本节将简要讨论一些常见的 Go 错误,以便您在程序中避免它们:

  • 如果在 Go 函数中出现错误,要么记录下来,要么返回错误;除非你有一个非常好的理由,否则不要两者都做。

  • Go 接口定义行为,而不是数据和数据结构。

  • 使用io.Readerio.Writer接口,因为它们使您的代码更具可扩展性。

  • 确保只在需要时将变量的指针传递给函数。其余时间,只传递变量的值。

  • 错误变量不是字符串;它们是error值。

  • 如果你害怕犯错,你很可能最终什么有用的事情都不会做。所以尽量多实验。

以下是可以应用于每种编程语言的一般建议:

  • 在小型和独立的 Go 程序中测试您的 Go 代码和函数,以确保它们表现出您认为应该有的行为方式。

  • 如果你不太了解 Go 的某个特性,在第一次使用之前先进行测试,特别是如果你正在开发系统实用程序。

  • 不要在生产机器上测试系统软件

  • 在将系统软件部署到生产机器上时,要在生产机器不忙的时候进行,并确保您有备份计划

练习

  1. 查找并访问log包的文档页面。

  2. 使用strace(1)来检查上一章中的hw.go

  3. 如果您使用 Mac,尝试使用dtruss(1)检查hw.go可执行文件。

  4. 编写一个从用户那里获取输入并使用strace(1)dtruss(1)检查其可执行文件的程序。

  5. 访问 Rust 的网站www.rust-lang.org/

  6. 访问 Swift 的网站swift.org/

  7. 访问io包的文档页面golang.org/pkg/io/

  8. 自己使用diff(1)命令行实用程序,以便更好地学习如何解释其输出。

  9. 访问并阅读write(2)的主页。

  10. 访问grep(1)的主页。

  11. 通过检查自己的结构来自己玩反射。

  12. 编写一个改进版本的occurrences.go,它只会显示高于已知数值阈值的频率,该阈值将作为命令行参数给出。

总结

本章教会了您一些高级的 Go 特性,包括错误处理、模式匹配和正则表达式、反射和不安全的代码。还讨论了strace(1)dtrace(1)工具。

下一章将涵盖许多有趣的内容,包括使用最新 Go 版本(1.8)中提供的新sort.slice() Go 函数,以及大 O 符号、排序算法、Go 包和垃圾回收。

第四章:Go 包、算法和数据结构

本章的主要主题将是 Go 包、算法和数据结构。如果您将所有这些结合起来,您将得到一个完整的程序,因为 Go 程序以包的形式提供,其中包含处理数据的算法。这些包包括 Go 自带的包和您自己创建的包,以便操作您的数据。

因此,在本章中,您将学习以下内容:

  • 大 O 符号

  • 两种排序算法

  • sort.Slice()函数

  • 链表

  • 在 Go 中创建自己的哈希表数据结构

  • Go 包

  • Go 中的垃圾回收(GC)

关于算法

了解算法及其工作方式肯定会在您需要处理大量数据时帮助您。此外,如果您选择对于特定工作使用错误的算法,可能会减慢整个过程并使您的软件无法使用。

传统的 Unix 命令行实用程序,如awk(1)sed(1)vi(1)tar(1)cp(1),是好算法如何帮助的很好的例子,这些实用程序可以处理比机器内存大得多的文件。这在早期的 Unix 时代非常重要,因为当时 Unix 机器上的总 RAM 量大约为 64K 甚至更少!

大 O 符号

大 O 符号用于描述算法的复杂性,这与其性能直接相关。算法的效率是通过其计算复杂性来判断的,这主要与算法需要访问其输入数据的次数有关。通常,您会想了解最坏情况和平均情况。

因此,O(n)算法(其中 n 是输入的大小)被认为比 O(n²)算法更好,后者又比 O(n³)算法更好。然而,最糟糕的算法是具有 O(n!)运行时间的算法,因为这使得它们几乎无法用于超过 300 个元素的输入。请注意,大 O 符号更多地是关于估计而不是给出精确值。因此,它主要用作比较值而不是绝对值。

此外,大多数内置类型的 Go 查找操作,比如查找地图键的值或访问数组元素,都具有常数时间,用 O(1)表示。这意味着内置类型通常比自定义类型更快,通常应该优先选择它们,除非你想完全控制后台发生的事情。另外,并非所有数据结构都是平等的。一般来说,数组操作比地图操作更快,而地图比数组更灵活!

排序算法

最常见的算法类别涉及对数据进行排序,即将其放置在给定顺序中。最著名的两种排序算法如下:

  • 快速排序:这被认为是最快的排序算法之一。快速排序对其数据进行排序所需的平均时间为 O(n log n),但在最坏情况下可能增长到 O(n²),这主要与数据呈现方式有关。

  • 冒泡排序:这个算法非常容易实现,平均复杂度为 O(n²)。如果您想开始学习排序,可以先从冒泡排序开始,然后再研究更难开发的算法。

尽管每种算法都有其缺点,但如果您没有大量数据,那么只要它能完成工作,算法就不是真正重要的。

您应该记住的是,Go 内部实现排序的方式无法由开发人员控制,并且将来可能会发生变化;因此,如果您想完全控制排序,应该编写自己的实现。

sort.Slice()函数

本节将说明首次出现在 Go 版本 1.8 中的sort.Slice()函数的用法。该函数的用法将在sortSlice.go中进行说明,该文件将分为三部分呈现。

第一部分是程序的预期序言和新结构类型的定义,如下所示:

package main 

import ( 
   "fmt" 
   "sort" 
) 

type aStructure struct { 
   person string 
   height int 
   weight int 
} 

正如您所期望的,您必须导入sort包才能使用其Slice()函数。

第二部分包含了切片的定义,其中包含四个元素:

func main() { 

   mySlice := make([]aStructure, 0) 
   a := aStructure{"Mihalis", 180, 90}

   mySlice = append(mySlice, a) 
   a = aStructure{"Dimitris", 180, 95} 
   mySlice = append(mySlice, a) 
   a = aStructure{"Marietta", 155, 45} 
   mySlice = append(mySlice, a) 
   a = aStructure{"Bill", 134, 40} 
   mySlice = append(mySlice, a)

因此,在第一部分中,您声明了一个结构的切片,该切片将在程序的其余部分中以两种方式进行排序,其中包含以下代码:

   fmt.Println("0:", mySlice) 
   sort.Slice(mySlice, func(i, j int) bool { 
         return mySlice[i].weight <mySlice[j].weight 
   }) 
   fmt.Println("<:", mySlice) 
   sort.Slice(mySlice, func(i, j int) bool { 
         return mySlice[i].weight >mySlice[j].weight 
   }) 
   fmt.Println(">:", mySlice) 
} 

这段代码包含了所有的魔法:您只需定义您想要对slice进行sort的方式,Go 就会完成其余工作。sort.Slice()函数将匿名排序函数作为其参数之一;另一个参数是您想要sortslice变量的名称。请注意,排序后的切片保存在slice变量中。

执行sortSlice.go将生成以下输出:

$ go run sortSlice.go
0: [{Mihalis 180 90} {Dimitris 180 95} {Marietta 155 45} {Bill 134 40}]
<: [{Bill 134 40} {Marietta 155 45} {Mihalis 180 90} {Dimitris 180 95}]
>: [{Dimitris 180 95} {Mihalis 180 90} {Marietta 155 45} {Bill 134 40}]

如您所见,您可以通过在 Go 代码中更改一个字符来轻松地按升序或降序进行sort

此外,如果您的 Go 版本不支持sort.Slice(),您将收到类似以下的错误消息:

$ go version
go version go1.3.3 linux/amd64
$ go run sortSlice.go
# command-line-arguments
./sortSlice.go:27: undefined: sort.Slice
./sortSlice.go:31: undefined: sort.Slice

Go 中的链表

链表是具有有限元素集的结构,其中每个元素使用至少两个内存位置:一个用于存储数据,另一个用于将当前元素链接到构成链表的元素序列中的下一个元素的指针。链表的最大优势是易于理解和实现,并且足够通用,可用于许多不同情况并模拟许多不同类型的数据。

链表的第一个元素称为头部,而列表的最后一个元素通常称为尾部。定义链表时,首先要做的是将列表的头部保留在单独的变量中,因为头部是您需要访问整个链表的唯一内容。

请注意,如果丢失单链表的第一个节点的指针,将无法再次找到它。

以下图显示了链表和双向链表的图形表示。双向链表更灵活,但需要更多的维护:

链表和双向链表的图形表示

因此,在本节中,我们将介绍在linkedList.go中保存的 Go 中链表的简单实现。

当创建自己的数据结构时,最重要的元素是节点的定义,通常使用结构来实现。

linkedList.go的代码将分为四部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
) 

第二部分包含以下 Go 代码:

type Node struct { 
   Value int 
   Next  *Node 
} 

func addNode(t *Node, v int) int { 
   if root == nil { 
         t = &Node{v, nil} 
         root = t 
         return 0 
   } 

   if v == t.Value { 
         fmt.Println("Node already exists:", v) 
         return -1 
   } 

   if t.Next == nil { 
         t.Next = &Node{v, nil} 
         return -2 
   } 

   return addNode(t.Next, v)

} 

在这里,您定义了将保存列表中每个元素的结构以及允许您向列表添加新节点的函数。为了避免重复条目,您应该检查值是否已经存在于列表中。请注意,addNode()是一个递归函数,因为它调用自身,这种方法可能比迭代稍慢,需要更多的内存。

代码的第三部分是traverse()函数:

func traverse(t *Node) { 
   if t == nil { 
         fmt.Println("-> Empty list!") 
         return 
   } 

   for t != nil {

         fmt.Printf("%d -> ", t.Value) 
         t = t.Next 
   } 
   fmt.Println() 
} 

for循环实现了访问链表中所有节点的迭代方法。

最后一部分如下:

var root = new(Node)
func main() { 
   fmt.Println(root) 
   root = nil 
   traverse(root) 
   addNode(root, 1) 
   addNode(root, 1) 
   traverse(root) 
   addNode(root, 10) 
   addNode(root, 5) 
   addNode(root, 0) 
   addNode(root, 0) 
   traverse(root) 
   addNode(root, 100) 
   traverse(root) 
}

在本书中首次看到不是常量的全局变量的使用。全局变量可以从程序的任何地方访问和更改,这使得它们的使用既实用又危险。使用名为root的全局变量来保存链表的root是为了显示链表是否为空。这是因为在 Go 中,整数值被初始化为0;因此new(Node)实际上是{0 <nil>},这使得在不传递额外变量给每个操作链表的函数的情况下,无法判断列表的头部是否为空。

执行linkedList.go将生成以下输出:

$ go run linkedList.go
&{0 <nil>}
-> Empty list!
Node already exists: 1
1 ->
Node already exists: 0
1 -> 10 -> 5 -> 0 ->
1 -> 10 -> 5 -> 0 -> 100 ->

Go 中的树

是一个有限且非空的顶点和边的集合。有向图是一个边带有方向的图。有向无环图是一个没有循环的有向图。是一个满足三个原则的有向无环图:首先,它有一个根节点:树的入口点;其次,除了根之外,每个顶点只有一个入口点;第三,存在一条连接根和每个顶点的路径,并且属于树。

因此,根是树的第一个节点。每个节点可以连接到一个或多个节点,具体取决于树的类型。如果每个节点只指向一个其他节点,那么树就是一个链表!

最常用的树类型是二叉树,因为每个节点最多可以有两个子节点。下图显示了二叉树数据结构的图形表示:

二叉树

所呈现的代码只会向您展示如何创建二叉树以及如何遍历它以打印出所有元素,以证明 Go 可以用于创建树数据结构。因此,它不会实现二叉树的完整功能,其中还包括删除树节点和平衡树。

tree.go的代码将分为三部分呈现。

第一部分是预期的序言以及节点的定义,如下所示:

package main 

import ( 
   "fmt" 
   "math/rand" 
   "time" 
) 
type Tree struct { 
   Left  *Tree 
   Value int 
   Right *Tree 
} 

第二部分包含允许您遍历树以打印所有元素、使用随机生成的数字创建树以及将节点插入其中的函数:

func traverse(t *Tree) { 
   if t == nil { 
         return 
   } 
   traverse(t.Left) 
   fmt.Print(t.Value, " ") 
   traverse(t.Right) 
} 

func create(n int) *Tree { 
   var t *Tree 
   rand.Seed(time.Now().Unix()) 
   for i := 0; i< 2*n; i++ { 
         temp := rand.Intn(n) 
         t = insert(t, temp) 
   } 
   return t 
} 

func insert(t *Tree, v int) *Tree { 
   if t == nil { 
         return&Tree{nil, v, nil} 
   } 
   if v == t.Value { 
         return t 
   } 
   if v <t.Value { 
         t.Left = insert(t.Left, v) 
         return t 
   } 
   t.Right = insert(t.Right, v) 
   return t 
} 

insert()的第二个if语句检查树中是否已经存在值,以免重复添加。第三个if语句标识新元素将位于当前节点的左侧还是右侧。

最后一部分是main()函数的实现:

func main() { 
   tree := create(30) 
   traverse(tree) 
   fmt.Println() 
   fmt.Println("The value of the root of the tree is", tree.Value) 
} 

执行tree.go将生成以下输出:

$ go run tree.go
0 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 21 22 23 24 25 26 27 28 29
The value of the root of the tree is 16

请注意,由于树的节点的值是随机生成的,程序的输出每次运行时都会不同。如果您希望始终获得相同的元素,则在create()函数中使用种子值的常量。

在 Go 中开发哈希表

严格来说,哈希表是一种数据结构,它存储一个或多个键值对,并使用键的hashFunction计算出数组中的桶或槽的索引,从中可以检索到正确的值。理想情况下,hashFunction应该将每个键分配到一个唯一的桶中,前提是您有所需数量的桶。

一个良好的hashFunction必须能够产生均匀分布的哈希值,因为拥有未使用的桶或桶的基数差异很大是低效的。此外,hashFunction应该能够一致地工作,并为相同的键输出相同的哈希值,否则将无法找到所需的信息!如果你认为哈希表并不那么有用、方便或聪明,你应该考虑以下:当哈希表有n个键和k个桶时,其搜索速度从线性搜索的 O(n)变为 O(n/k)!虽然改进看起来很小,但你应该意识到,对于只有 20 个插槽的哈希数组,搜索时间将减少 20 倍!这使得哈希表非常适用于诸如字典或任何其他类似的应用程序,其中需要搜索大量数据。尽管使用大量桶会增加程序的复杂性和内存使用量,但有时这是值得的。

下图显示了一个具有 10 个桶的简单哈希表的图形表示。很容易理解hashFunction是取模运算符:

一个简单的哈希表

尽管所呈现的哈希表版本使用数字,因为它们更容易实现和理解,但只要你能找到合适的hashFunction来处理输入,你可以使用任何数据类型。hash.go的源代码将分为三部分呈现。

第一个是以下内容:

package main 

import ( 
   "fmt" 
) 

type Node struct { 
   Value int 
   Next  *Node 
} 

type HashTablestruct { 
   Table map[int]*Node

   Size  int 
} 

Node struct的定义取自您之前看到的链表的实现。使用map变量的Table而不是切片的原因是,切片的索引只能是自然数,而map的键可以是任何东西。

第二部分包含以下 Go 代码:

func hashFunction(i, size int) int { 
   return (i % size) 
} 

func insert(hash *HashTable, value int) int { 
   index := hashFunction(value, hash.Size) 
   element := Node{Value: value, Next: hash.Table[index]} 
   hash.Table[index] = &element 
   return index 
} 

func traverse(hash *HashTable) { 
   for k := range hash.Table { 
         if hash.Table[k] != nil { 
               t := hash.Table[k] 
               for t != nil { 
                     fmt.Printf("%d -> ", t.Value) 
                     t = t.Next 
               } 
               fmt.Println() 
         } 
   } 
}

请注意,traverse()函数使用linkedList.go中的 Go 代码来遍历哈希表中每个桶的元素。另外,请注意,insert函数不会检查值是否已经存在于哈希表中,以节省空间,但通常情况下并非如此。另外,出于速度和简单性的考虑,新元素被插入到每个列表的开头。

最后一部分包含了main()函数的实现:

func main() { 
   table := make(map[int]*Node, 10) 
   hash := &HashTable{Table: table, Size: 10} 
   fmt.Println("Number of spaces:", hash.Size) 
   for i := 0; i< 95; i++ { 
         insert(hash, i) 
   } 
   traverse(hash) 
} 

执行hash.go将生成以下输出,证明哈希表按预期工作:

$ go run hash.go
Number of spaces: 10 89 -> 79 -> 69 -> 59 -> 49 -> 39 -> 29 -> 19 -> 9 ->
86 -> 76 -> 66 -> 56 -> 46 -> 36 -> 26 -> 16 -> 6 ->
92 -> 82 -> 72 -> 62 -> 52 -> 42 -> 32 -> 22 -> 12 -> 2 ->
94 -> 84 -> 74 -> 64 -> 54 -> 44 -> 34 -> 24 -> 14 -> 4 ->
85 -> 75 -> 65 -> 55 -> 45 -> 35 -> 25 -> 15 -> 5 ->
87 -> 77 -> 67 -> 57 -> 47 -> 37 -> 27 -> 17 -> 7 ->
88 -> 78 -> 68 -> 58 -> 48 -> 38 -> 28 -> 18 -> 8 ->
90 -> 80 -> 70 -> 60 -> 50 -> 40 -> 30 -> 20 -> 10 -> 0 ->
91 -> 81 -> 71 -> 61 -> 51 -> 41 -> 31 -> 21 -> 11 -> 1 ->
93 -> 83 -> 73 -> 63 -> 53 -> 43 -> 33 -> 23 -> 13 -> 3 ->

如果你多次执行hash.go,你会发现打印行的顺序会变化。这是因为traverse()函数中range hash.Table的输出是无法预测的,这是因为 Go 对哈希的返回顺序没有指定。

关于 Go 包

包用于将相关函数和常量分组,以便您可以轻松地传输它们并在自己的 Go 程序中使用。因此,除了主包之外,包不是独立的程序。

每个 Go 发行版都附带许多有用的 Go 包,包括以下内容:

  • net包:这支持可移植的 TCP 和 UDP 连接

  • http包:这是 net 包的一部分,提供了 HTTP 服务器和客户端的实现

  • math包:这提供了数学函数和常量

  • io包:这处理原始的输入和输出操作

  • os包:这为您提供了一个便携式的操作系统功能接口

  • time包:这允许您处理时间和日期

有关标准 Go 包的完整列表,请参阅golang.org/pkg/。我强烈建议您在开始开发自己的函数和包之前,先了解 Go 提供的所有包,因为你要寻找的功能很可能已经包含在标准 Go 包中。

使用标准 Go 包

您可能已经知道如何使用标准的 Go 包。但是,您可能不知道的是,一些包有一个结构。例如,net包有几个子目录,命名为httpmailrpcsmtptextprotourl,应该分别导入为net/httpnet/mailnet/rpcnet/smtpnet/textprotonet/url。Go 在这些情况下对包进行分组,但是如果它们是为了分发而不是功能而分组,这些包也可以是独立的包。

您可以使用godoc实用程序查找有关 Go 标准包的信息。因此,如果您正在寻找有关net包的信息,您应该执行godoc net

创建您自己的包

包使得大型软件系统的设计、实现和维护更加简单和容易。此外,它们允许多个程序员在同一个项目上工作而不会发生重叠。因此,如果您发现自己一直在使用相同的函数,您应该认真考虑将它们包含在您自己的 Go 包中。

Go 包的源代码,可以包含多个文件,可以在一个目录中找到,该目录以包的名称命名,除了主包,主包可以有任何名称。

在本节中将开发的aSimplePackage.go文件的 Go 代码将分为两部分呈现。

第一部分是以下内容:

package aSimplePackage 

import ( 
   "fmt" 
) 

这里没有什么特别的;您只需定义包的名称并包含必要的导入语句,因为一个包可以依赖于其他包。

第二部分包含以下 Go 代码:

const Pi = "3.14159" 

func Add(x, y int) int { 
   return x + y 
} 

func Println(x int) { 
   fmt.Println(x) 
} 

因此,aSimplePackage包提供了两个函数和一个常量。

完成aSimplePackage.go的代码编写后,您应该执行以下命令,以便能够在其他 Go 程序或包中使用该包:

$ mkdir ~/go
$ mkdir ~/go/src
$ mkdir ~/go/src/aSimplePackage
$ export GOPATH=~/go
$ vi ~/go/src/aSimplePackage/aSimplePackage.go
$ go install aSimplePackage 

除了前两个mkdir命令,您应该为您创建的每个 Go 包执行所有这些操作,这两个命令只需要执行一次。

如您所见,每个包都需要在~/go/src目录下有自己的文件夹。在执行上述命令后,go tool将自动生成一个 Go 包的ar(1)存档文件,该文件刚刚在pkg目录中编译完成:

$ ls -lR ~/go
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:35 pkg
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:35 src

/Users/mtsouk/go/pkg:
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:35 darwin_amd64

/Users/mtsouk/go/pkg/darwin_amd64:
total 8
-rw-r--r--  1 mtsouk  staff  2918 Apr  4 22:35 aSimplePackage.a

/Users/mtsouk/go/src:
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:35 aSimplePackage

/Users/mtsouk/go/src/aSimplePackage:
total 8
-rw-r--r--  1 mtsouk  staff  148 Apr  4 22:30 aSimplePackage.go

尽管您现在已经准备好使用aSimplePackage包,但是没有一个独立的程序,您无法看到包的功能。

私有变量和函数

私有变量和函数与公共变量和函数不同,它们只能在包内部使用和调用。控制哪些函数和变量是公共的或不公共的也被称为封装。

Go 遵循一个简单的规则,即以大写字母开头的函数、变量、类型等都是公共的,而以小写字母开头的函数、变量、类型等都是私有的。但是,这个规则不影响包名。

现在您应该明白为什么fmt.Printf()函数的命名是这样的,而不是fmt.printf()

为了说明这一点,我们将对aSimplePackage.go模块进行一些更改,并添加一个私有变量和一个私有函数。新的独立包的名称将是anotherPackage.go。您可以使用diff(1)命令行实用程序查看对其所做的更改:

$ diff aSimplePackage.go anotherPackage.go
1c1
<packageaSimplePackage
---
>packageanotherPackage
7a8
>const version = "1.1"
15a17,20
>
>func Version() {
>     fmt.Println("The version of the package is", version)
> }

init()函数

每个 Go 包都可以有一个名为init()的函数,在执行开始时自动执行。因此,让我们在anotherPackage.go包的代码中添加以下init()函数:

func init() { 
   fmt.Println("The init function of anotherPackage") 
} 

init()函数的当前实现是简单的,没有特殊操作。但是,有时您希望在开始使用包之前执行重要的初始化操作,例如打开数据库和网络连接:在这些相对罕见的情况下,init()函数是非常宝贵的。

使用您自己的 Go 包

本小节将向你展示如何在你自己的 Go 程序中使用aSimplePackageanotherPackage包,通过展示两个名为usePackage.goprivateFail.go的小型 Go 程序。

为了使用GOPATH目录下的aSimplePackage包,你需要在另一个 Go 程序中编写以下 Go 代码:

package main 

import ( 
   "aSimplePackage" 
   "fmt" 
) 

func main() { 
   temp := aSimplePackage.Add(5, 10) 
   fmt.Println(temp)

   fmt.Println(aSimplePackage.Pi) 
} 

首先,如果aSimplePackage尚未编译并位于预期位置,编译过程将失败,并显示类似以下的错误消息:

$ go run usePackage.go
usePackage.go:4:2: cannot find package "aSimplePackage" in any of:
      /usr/local/Cellar/go/1.8/libexec/src/aSimplePackage (from $GOROOT)
      /Users/mtsouk/go/src/aSimplePackage (from $GOPATH)

然而,如果aSimplePackage可用,usePackage.go将会被成功执行:

$ go run usePackage.go
15
3.14159

现在,让我们看看另一个使用anotherPackage的小程序的 Go 代码:

package main 

import ( 
   "anotherPackage" 
   "fmt" 
) 

func main() { 
   anotherPackage.Version() 
   fmt.Println(anotherPackage.version) 
   fmt.Println(anotherPackage.Pi) 
} 

如果你尝试从anotherPackage调用私有函数或使用私有变量,你的 Go 程序privateFail.go将无法运行,并显示以下错误消息:

$ go run privateFail.go
# command-line-arguments
./privateFail.go:10: cannot refer to unexported name anotherPackage.version
./privateFail.go:10: undefined: anotherPackage.version

我真的很喜欢显示错误消息,因为大多数书籍都试图隐藏它们,好像它们不存在一样。当我学习 Go 时,我花了大约 3 个小时的调试,直到我发现一个我无法解释的错误消息的原因是一个变量的名字!

然而,如果你从privateFail.go中删除对私有变量的调用,程序将在没有错误的情况下执行。此外,你会看到init()函数实际上会自动执行:

$ go run privateFail.go
The init function of anotherPackage
The version of the package is 1.1
3.14159

使用外部 Go 包

有时候,包可以在互联网上找到,并且你希望通过指定它们的互联网地址来使用它们。一个这样的例子是 Go 的MySQL驱动程序,可以在github.com/go-sql-driver/mysql找到。

看看以下的 Go 代码,保存为useMySQL.go

package main 

import ( 
   "fmt" 
   _ "github.com/go-sql-driver/mysql"
) 

func main() { 
   fmt.Println("Using the MySQL Go driver!") 
} 

使用_作为包标识符将使编译器忽略包未被使用的事实:绕过编译器的唯一合理理由是当你的未使用包中有一个你想要执行的init函数时。另一个合理的理由是为了说明一个 Go 概念!

如果你尝试执行useMySQL.go,编译过程将失败:

$ go run useMySQL.go
useMySQL.go:5:2: cannot find package "github.com/go-sql-driver/mysql" in any of:
      /usr/local/Cellar/go/1.8/libexec/src/github.com/go-sql-driver/mysql (from $GOROOT)
      /Users/mtsouk/go/src/github.com/go-sql-driver/mysql (from $GOPATH)

为了编译useMySQL.go,你应该首先执行以下步骤:

$ go get github.com/go-sql-driver/mysql
$ go run useMySQL.go
Using the MySQL Go driver!

成功下载所需的包后,~/go目录的内容将验证所需的 Go 包已被下载:

$ ls -lR ~/go
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:35 pkg
drwxr-xr-x  5 mtsouk  staff  170 Apr  6 21:32 src

/Users/mtsouk/go/pkg:
total 0
drwxr-xr-x  5 mtsouk  staff  170 Apr  6 21:32 darwin_amd64

/Users/mtsouk/go/pkg/darwin_amd64:
total 24
-rw-r--r--  1 mtsouk  staff  2918 Apr  4 23:07 aSimplePackage.a
-rw-r--r--  1 mtsouk  staff  6102 Apr  4 22:50 anotherPackage.a
drwxr-xr-x  3 mtsouk  staff   102 Apr  6 21:32 github.com

/Users/mtsouk/go/pkg/darwin_amd64/github.com:
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  6 21:32 go-sql-driver

/Users/mtsouk/go/pkg/darwin_amd64/github.com/go-sql-driver:
total 728
-rw-r--r--  1 mtsouk  staff  372694 Apr  6 21:32 mysql.a

/Users/mtsouk/go/src:
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:35 aSimplePackage
drwxr-xr-x  3 mtsouk  staff  102 Apr  4 22:50 anotherPackage
drwxr-xr-x  3 mtsouk  staff  102 Apr  6 21:32 github.com

/Users/mtsouk/go/src/aSimplePackage:
total 8
-rw-r--r--  1 mtsouk  staff  148 Apr  4 22:30 aSimplePackage.go

/Users/mtsouk/go/src/anotherPackage:
total 8
-rw-r--r--@ 1 mtsouk  staff  313 Apr  4 22:50 anotherPackage.go

/Users/mtsouk/go/src/github.com:
total 0
drwxr-xr-x  3 mtsouk  staff  102 Apr  6 21:32 go-sql-driver

/Users/mtsouk/go/src/github.com/go-sql-driver:
total 0
drwxr-xr-x  35 mtsouk  staff  1190 Apr  6 21:32 mysql

/Users/mtsouk/go/src/github.com/go-sql-driver/mysql:
total 584
-rw-r--r--  1 mtsouk  staff   2066 Apr  6 21:32 AUTHORS
-rw-r--r--  1 mtsouk  staff   5581 Apr  6 21:32 CHANGELOG.md
-rw-r--r--  1 mtsouk  staff   1091 Apr  6 21:32 CONTRIBUTING.md
-rw-r--r--  1 mtsouk  staff  16726 Apr  6 21:32 LICENSE
-rw-r--r--  1 mtsouk  staff  18610 Apr  6 21:32 README.md
-rw-r--r--  1 mtsouk  staff    470 Apr  6 21:32 appengine.go
-rw-r--r--  1 mtsouk  staff   4965 Apr  6 21:32 benchmark_test.go
-rw-r--r--  1 mtsouk  staff   3339 Apr  6 21:32 buffer.go
-rw-r--r--  1 mtsouk  staff   8405 Apr  6 21:32 collations.go
-rw-r--r--  1 mtsouk  staff   8525 Apr  6 21:32 connection.go
-rw-r--r--  1 mtsouk  staff   1831 Apr  6 21:32 connection_test.go
-rw-r--r--  1 mtsouk  staff   3111 Apr  6 21:32 const.go
-rw-r--r--  1 mtsouk  staff   5036 Apr  6 21:32 driver.go
-rw-r--r--  1 mtsouk  staff   4246 Apr  6 21:32 driver_go18_test.go
-rw-r--r--  1 mtsouk  staff  47090 Apr  6 21:32 driver_test.go
-rw-r--r--  1 mtsouk  staff  13046 Apr  6 21:32 dsn.go
-rw-r--r--  1 mtsouk  staff   7872 Apr  6 21:32 dsn_test.go
-rw-r--r--  1 mtsouk  staff   3798 Apr  6 21:32 errors.go
-rw-r--r--  1 mtsouk  staff    989 Apr  6 21:32 errors_test.go
-rw-r--r--  1 mtsouk  staff   4571 Apr  6 21:32 infile.go
-rw-r--r--  1 mtsouk  staff  31362 Apr  6 21:32 packets.go
-rw-r--r--  1 mtsouk  staff   6453 Apr  6 21:32 packets_test.go
-rw-r--r--  1 mtsouk  staff    600 Apr  6 21:32 result.go
-rw-r--r--  1 mtsouk  staff   3698 Apr  6 21:32 rows.go
-rw-r--r--  1 mtsouk  staff   3609 Apr  6 21:32 statement.go
-rw-r--r--  1 mtsouk  staff    729 Apr  6 21:32 transaction.go
-rw-r--r--  1 mtsouk  staff  17924 Apr  6 21:32 utils.go
-rw-r--r--  1 mtsouk  staff   5784 Apr  6 21:32 utils_test.go

go clean 命令

有时候,你正在开发一个使用大量非标准 Go 包的大型 Go 程序,并且希望从头开始编译过程。Go 允许你清理一个包的文件,以便稍后重新创建它。以下命令清理一个包,而不影响包的代码:

$ go clean -x -i aSimplePackage
cd /Users/mtsouk/go/src/aSimplePackage
rm -f aSimplePackage.test aSimplePackage.test.exe
rm -f /Users/mtsouk/go/pkg/darwin_amd64/aSimplePackage.a

同样,你也可以清理从互联网下载的包,这也需要使用其完整路径:

$ go clean -x -i github.com/go-sql-driver/mysql
cd /Users/mtsouk/go/src/github.com/go-sql-driver/mysql
rm -f mysql.test mysql.test.exe appengine appengine.exe
rm -f /Users/mtsouk/go/pkg/darwin_amd64/github.com/go-sql-driver/mysql.a

请注意,当你想将项目转移到另一台机器上而不包括不必要的文件时,go clean命令也特别有用。

垃圾收集

在本节中,我们将简要讨论 Go 如何处理 GC,它试图高效地释放未使用的内存。garbageCol.go的 Go 代码可以分为两部分。

第一部分如下:

package main 

import ( 
   "fmt" 
   "runtime" 
   "time" 
) 

func printStats(mem runtime.MemStats) { 
   runtime.ReadMemStats(&mem) 
   fmt.Println("mem.Alloc:", mem.Alloc) 
   fmt.Println("mem.TotalAlloc:", mem.TotalAlloc) 
   fmt.Println("mem.HeapAlloc:", mem.HeapAlloc) 
   fmt.Println("mem.NumGC:", mem.NumGC) 
   fmt.Println("-----") 
} 

每当你想要读取最新的内存统计信息时,你应该调用runtime.ReadMemStats()函数。

第二部分包含了main()函数的实现,其中包含以下 Go 代码:

func main() { 
   var memruntime.MemStats 
   printStats(mem) 

   for i := 0; i< 10; i++ { 
         s := make([]byte, 100000000) 
         if s == nil { 
               fmt.Println("Operation failed!") 
         } 
   } 
   printStats(mem) 

   for i := 0; i< 10; i++ { 
         s := make([]byte, 100000000) 
         if s == nil { 
               fmt.Println("Operation failed!") 
         } 
         time.Sleep(5 * time.Second) 
   } 
   printStats(mem)

} 

在这里,你尝试获取大量内存,以触发垃圾收集器的使用。

执行garbageCol.go会生成以下输出:

$ go run garbageCol.go
mem.Alloc: 53944
mem.TotalAlloc: 53944
mem.HeapAlloc: 53944
mem.NumGC: 0
-----
mem.Alloc: 100071680
mem.TotalAlloc: 1000146400
mem.HeapAlloc: 100071680
mem.NumGC: 10
-----
mem.Alloc: 66152
mem.TotalAlloc: 2000230496
mem.HeapAlloc: 66152
mem.NumGC: 20
-----

因此,输出呈现了与garbageCol.go程序使用的内存相关的属性信息。如果你想获得更详细的输出,可以执行garbageCol.go,如下所示:

$ GODEBUG=gctrace=1 go run garbageCol.go

这个命令的版本将以以下格式给出信息:

gc 11 @0.101s 0%: 0.003+0.083+0.020 ms clock, 0.030+0.059/0.033/0.006+0.16 mscpu, 95->95->0 MB, 96 MB goal, 8 P

95->95->0 MB 部分包含有关各种堆大小的信息,还显示了垃圾收集器的表现如何。第一个值是 GC 开始时的堆大小,而中间值显示了 GC 结束时的堆大小。第三个值是活动堆的大小。

您的环境

在本节中,我们将展示如何使用 runtime 包查找有关您的环境的信息:当您必须根据操作系统和您使用的 Go 版本采取某些操作时,这可能很有用。

使用 runtime 包查找有关您的环境的信息是直接的,并在 runTime.go 中有所说明:

package main 

import ( 
   "fmt" 
   "runtime" 
) 

func main() { 
   fmt.Print("You are using ", runtime.Compiler, " ") 
   fmt.Println("on a", runtime.GOARCH, "machine") 
   fmt.Println("with Go version", runtime.Version()) 
   fmt.Println("Number of Goroutines:", runtime.NumGoroutine())
} 

只要您知道要从 runtime 包中调用什么,就可以获取所需的信息。这里的最后一个 fmt.Println() 命令显示有关 goroutines 的信息:您将在第九章, Goroutines - Basic Features 中了解更多关于 goroutines 的信息。

在 macOS 机器上执行 runTime.go 会生成以下输出:

$ go run runTime.go
You are using gc on a amd64 machine
with Go version go1.8
Number of Goroutines: 1  

在使用旧版 Go 的 Linux 机器上执行 runTime.go 会得到以下结果:

$ go run runTime.go
You are using gc on a amd64 machine
with Go version go1.3.3
Number of Goroutines: 4

Go 经常更新!

在写完本章的最后,Go 进行了一点更新。因此,我决定在本书中包含这些信息,以便更好地了解 Go 的更新频率:

$ date
Sat Apr  8 09:16:46 EEST 2017
$ go version
go version go1.8.1 darwin/amd64

练习

  1. 访问 runtime 包的文档。

  2. 创建您自己的结构,创建一个切片,并使用 sort.Slice() 对您创建的切片的元素进行排序。

  3. 在 Go 中实现快速排序算法,并对一些随机生成的数字数据进行排序。

  4. 实现一个双向链表。

  5. tree.go 的实现远未完成!尝试实现一个检查树中是否可以找到值的函数,以及一个允许您删除树节点的函数。

  6. 同样,linkedList.go 文件的实现也是不完整的。尝试实现一个用于删除节点的函数,以及另一个用于在链表中某个位置插入节点的函数。

  7. 再次,hash.go 的哈希表实现是不完整的,因为它允许重复条目。因此,在插入之前,实现一个在哈希表中搜索键的函数。

总结

在本章中,您学到了许多与算法和数据结构相关的知识。您还学会了如何使用现有的 Go 包以及如何开发自己的 Go 包。本章还讨论了 Go 中的垃圾收集以及如何查找有关您的环境的信息。

在下一章中,我们将开始讨论系统编程,并呈现更多的 Go 代码。更确切地说,第五章,文件和目录,将讨论如何在 Go 中处理文件和目录,如何轻松地遍历目录结构,以及如何使用 flag 包处理命令行参数。但更重要的是,我们将开始开发各种 Unix 命令行实用程序的 Go 版本。

第五章:文件和目录

在上一章中,我们谈到了许多重要的主题,包括开发和使用 Go 包,Go 数据结构,算法和 GC。然而,直到现在,我们还没有开发任何实际的系统实用程序。这很快就会改变,因为从这一非常重要的章节开始,我们将开始学习如何使用 Go 来开发真正的系统实用程序,以便处理文件系统的各种类型的文件和目录。

您应该始终记住,Unix 将一切都视为文件,包括符号链接、目录、网络设备、网络套接字、整个硬盘驱动器、打印机和纯文本文件。本章的目的是说明 Go 标准库如何允许我们了解路径是否存在,以及如何搜索目录结构以检测我们想要的文件类型。此外,本章将通过 Go 代码作为证据证明,许多传统的 Unix 命令行实用程序在处理文件和目录时并不难实现。

在本章中,您将学习以下主题:

  • 将帮助您操作目录和文件的 Go 包

  • 使用flag包轻松处理命令行参数和选项

  • 在 Go 中开发which(1)命令行实用程序的版本

  • 在 Go 中开发pwd(1)命令行实用程序的版本

  • 删除和重命名文件和目录

  • 轻松遍历目录树

  • 编写find(1)实用程序的版本

  • 在另一个地方复制目录结构

有用的 Go 包

允许您将文件和目录视为实体的最重要的包是os包,在本章中我们将广泛使用它。如果您将文件视为带有内容的盒子,os包允许您移动它们,将它们放入废纸篓,更改它们的名称,访问它们,并决定您想要使用哪些文件,而io包,将在下一章中介绍,允许您操作盒子的内容,而不必太担心盒子本身!

flag包,您将很快看到,让您定义和处理自己的标志,并操作 Go 程序的命令行参数。

filepath包非常方便,因为它包括filepath.Walk()函数,允许您以简单的方式遍历整个目录结构。

重新审视命令行参数!

正如我们在第二章中所看到的,使用 Go 编写程序,使用if语句无法高效处理多个命令行参数和选项。解决这个问题的方法是使用flag包,这将在这里解释。

记住flag包是一个标准的 Go 包,您不必在其他地方搜索标志的功能非常重要。

flag 包

flag包为我们解析命令行参数和选项做了脏活,因此无需编写复杂和令人困惑的 Go 代码。此外,它支持各种类型的参数,包括字符串、整数和布尔值,这样可以节省时间,因为您不必执行任何数据类型转换。

usingFlag.go程序演示了flagGo 包的使用,并将分为三个部分呈现。第一部分包含以下 Go 代码:

package main 

import ( 
   "flag" 
   "fmt" 
) 

程序的最重要的 Go 代码在第二部分中,如下所示:

func main() { 
   minusO := flag.Bool("o", false, "o") 
   minusC := flag.Bool("c", false, "c") 
   minusK := flag.Int("k", 0, "an int") 

   flag.Parse() 

在这部分,您可以看到如何定义您感兴趣的标志。在这里,您定义了-o-c-k。虽然前两个是布尔标志,但-k标志需要一个整数值,可以写成-k=123

最后一部分包含以下 Go 代码:

   fmt.Println("-o:", *minusO) 
   fmt.Println("-c:", *minusC) 
   fmt.Println("-K:", *minusK) 

   for index, val := range flag.Args() { 
         fmt.Println(index, ":", val) 
   } 
} 

在这部分中,您可以看到如何读取选项的值,这也允许您判断选项是否已设置。另外,flag.Args()允许您访问程序未使用的命令行参数。

usingFlag.go的使用和输出在以下输出中展示:

$ go run usingFlag.go
-o: false
-c: false
-K: 0
$ go run usingFlag.go -o a b
-o: true
-c: false
-K: 0
0 : a
1 : b

但是,如果您忘记输入命令行选项(-k)的值,或者提供的值类型错误,您将收到以下消息,并且程序将终止:

$ ./usingFlag -k
flag needs an argument: -k
Usage of ./usingFlag:
  -c  c
  -k int
      an int
  -o  o $ ./usingFlag -k=abc invalid value "abc" for flag -k: strconv.ParseInt: parsing "abc": invalid syntax
Usage of ./usingFlag:
  -c  c
  -k int
      an int
  -o  o

如果您不希望程序在出现解析错误时退出,可以使用flag包提供的ErrorHandling类型,它允许您通过NewFlagSet()函数更改flag.Parse()在错误时的行为。但是,在系统编程中,通常希望在一个或多个命令行选项出现错误时退出实用程序。

处理目录

目录允许您创建一个结构,并以便于您组织和搜索文件的方式存储文件。实际上,目录是文件系统上包含其他文件和目录列表的条目。这是通过inode的帮助发生的,inode 是保存有关文件和目录的信息的数据结构。

如下图所示,目录被实现为分配给 inode 的名称列表。因此,目录包含对自身、其父目录和其每个子目录的条目,其中其他内容可以是常规文件或其他目录:

您应该记住的是,inode 保存有关文件的元数据,而不是文件的实际数据。

inode 的图形表示

关于符号链接

符号链接是指向文件或目录的指针,在访问时解析。符号链接,也称为软链接,不等同于它们所指向的文件或目录,并且允许指向无处,这有时可能会使事情复杂化。

保存在symbLink.go中并分为两部分的以下 Go 代码允许您检查路径或文件是否是符号链接。第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
) 

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide an argument!") 
         os.Exit(1) 
   } 
   filename := arguments[1] 

这里没有发生什么特别的事情:您只需要确保获得一个命令行参数,以便有东西可以测试。第二部分是以下 Go 代码:

   fileinfo, err := os.Lstat(fil /etcename) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 

   if fileinfo.Mode()&os.ModeSymlink != 0 { 
         fmt.Println(filename, "is a symbolic link") 
         realpath, err := filepath.EvalSymlinks(filename) 
         if err == nil { 
               fmt.Println("Path:", realpath) 
         } 
   } 

}

SymbLink.go的前述代码比通常更加神秘,因为它使用了更低级的函数。确定路径是否为真实路径的技术涉及使用os.Lstat()函数,该函数提供有关文件或目录的信息,并在os.Lstat()调用的返回值上使用Mode()函数,以将结果与os.ModeSymlink常量进行比较,该常量是符号链接位。

此外,还存在filepath.EvalSymlinks()函数,允许您评估任何存在的符号链接并返回文件或目录的真实路径,这也在symbLink.go中使用。这可能会让您认为我们在为这样一个简单的任务使用大量的 Go 代码,这在一定程度上是正确的,但是当您开发系统软件时,您必须考虑所有可能性并保持谨慎。

执行symbLink.go,它只需要一个命令行参数,会生成以下输出:

$ go run symbLink.go /etc
/etc is a symbolic link
Path: /private/etc

在本章的其余部分,您还将看到一些前面提到的 Go 代码作为更大程序的一部分。

实现 pwd(1)命令

当我开始考虑如何实现一个程序时,我的脑海中涌现了很多想法,有时决定要做什么变得太困难了!关键在于做一些事情,而不是等待,因为当您编写代码时,您将能够判断您所采取的方法是好还是不好,以及您是否应该尝试另一种方法。

pwd(1)命令行实用程序非常简单,但工作得很好。如果您编写大量 shell 脚本,您应该已经知道pwd(1),因为当您想要获取与正在执行的脚本位于同一目录中的文件或目录的完整路径时,它非常方便。

pwd.go的 Go 代码将分为两部分,并且只支持-P命令行选项,该选项解析所有符号链接并打印物理当前工作目录。pwd.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
) 

func main() { 
   arguments := os.Args 

   pwd, err := os.Getwd() 
   if err == nil { 
         fmt.Println(pwd) 
   } else { 
         fmt.Println("Error:", err) 
   } 

第二部分如下:

   if len(arguments) == 1 { 
         return 
   } 

   if arguments[1] != "-P" { 
         return 
   } 

   fileinfo, err := os.Lstat(pwd) 
   if fileinfo.Mode()&os.ModeSymlink != 0 { 
         realpath, err := filepath.EvalSymlinks(pwd) 
         if err == nil { 
               fmt.Println(realpath) 
         } 
   } 
} 

请注意,如果当前目录可以由多个路径描述,这可能发生在使用符号链接时,os.Getwd()可以返回其中任何一个。此外,如果给出了-P选项并且正在处理一个目录是符号链接,您需要重用symbolLink.go中找到的一些 Go 代码来发现物理当前工作目录。此外,不在pwd.go中使用flag包的原因是我发现代码现在的方式更简单。

执行pwd.go将生成以下输出:

$ go run pwd.go
/Users/mtsouk/Desktop/goBook/ch/ch5/code

在 macOS 机器上,/tmp目录是一个符号链接,这可以帮助我们验证pwd.go是否按预期工作:

$ go run pwd.go
/tmp
$ go run pwd.go -P
/tmp
/private/tmp

使用 Go 开发which(1)实用程序

which(1)实用程序搜索PATH环境变量的值,以找出可执行文件是否存在于PATH变量的一个目录中。以下输出显示了which(1)实用程序的工作方式:

$ echo $PATH
/home/mtsouk/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
$ which ls
/home/mtsouk/bin/ls
code$ which -a ls
/home/mtsouk/bin/ls
/bin/ls

我们的 Unix 实用程序的实现将支持 macOS 版本的which(1)支持的两个命令行选项-a-s,并借助flag包:Linux 版本的which(1)不支持-s选项。-a选项列出可执行文件的所有实例,而不仅仅是第一个,而-s返回0如果找到了可执行文件,否则返回1:这与使用fmt包打印01不同。

为了检查 Unix 命令行实用程序在 shell 中的返回值,您应该执行以下操作:

$ which -s ls $ echo $?
0

请注意,go run会打印出非零的退出代码。

which(1)的 Go 代码将保存在which.go中,并将分为四个部分呈现。which.go的第一部分包含以下 Go 代码:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "strings" 
) 

需要strings包来分割读取PATH变量的内容。which.go的第二部分处理了flag包的使用:

func main() { 
   minusA := flag.Bool("a", false, "a") 
   minusS := flag.Bool("s", false, "s") 

   flag.Parse() 
   flags := flag.Args() 
   if len(flags) == 0 { 
         fmt.Println("Please provide an argument!") 
         os.Exit(1) 
   } 
   file := flags[0] 
   fountIt := false 

which.go的一个非常重要的部分是读取PATH shell 环境变量以分割并使用它的部分,这在这里的第三部分中呈现:

   path := os.Getenv("PATH") 
   pathSlice := strings.Split(path, ":") 
   for _, directory := range pathSlice { 
         fullPath := directory + "/" + file 

这里的最后一条语句构造了我们正在搜索的文件的完整路径,就好像它存在于PATH变量的每个单独目录中,因为如果你有文件的完整路径,你就不必再去搜索它了!

which.go的最后一部分如下:

         fileInfo, err := os.Stat(fullPath) 
         if err == nil { 
               mode := fileInfo.Mode() 
               if mode.IsRegular() { 
                     if mode&0111 != 0 { 
                           fountIt = true 
                           if *minusS == true { 
                                 os.Exit(0) 
                           } 
                           if *minusA == true {

                                 fmt.Println(fullPath) 
                           } else { 
                                 fmt.Println(fullPath) 
                                 os.Exit(0) 
                           } 
                     } 
               } 
         } 
   } 
   if fountIt == false { 
         os.Exit(1) 
   } 
} 

在这里,对os.Stat()的调用告诉我们正在寻找的文件是否实际存在。在成功的情况下,mode.IsRegular()函数检查文件是否是常规文件,因为我们不寻找目录或符号链接。但是,我们还没有完成!which.go程序执行了一个测试,以找出找到的文件是否确实是可执行文件:如果不是可执行文件,它将不会被打印。因此,if mode&0111 != 0语句使用二进制操作验证文件实际上是可执行文件。

接下来,如果-s标志设置为*minusS == true,那么-a标志就不太重要了,因为一旦找到匹配项,程序就会终止。

正如您所看到的,在which.go中涉及许多测试,这对于系统软件来说并不罕见。尽管如此,您应该始终检查所有可能性,以避免以后出现意外。好消息是,这些测试中的大多数将在find(1)实用程序的 Go 实现中稍后使用:通过编写小程序来测试一些功能,然后将它们全部组合成更大的程序,这是一个很好的实践,因为这样做可以更好地学习技术,并且可以更容易地检测愚蠢的错误。

执行which.go将产生以下输出:

$ go run which.go ls
/home/mtsouk/bin/ls
$ go run which.go -s ls
$ echo $?
0
$ go run which.go -s ls123123
exit status 1
$ echo $?
1
$ go run which.go -a ls
/home/mtsouk/bin/ls
/bin/ls

打印文件或目录的权限位

借助ls(1)命令,您可以找出文件的权限:

$ ls -l /bin/ls
-rwxr-xr-x  1 root  wheel  38624 Mar 23 01:57 /bin/ls

在本小节中,我们将展示如何使用 Go 打印文件或目录的权限:Go 代码将保存在permissions.go中,并将分为两部分呈现。第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
) 

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide an argument!") 
         os.Exit(1) 
   } 

   file := arguments[1] 

第二部分包含重要的 Go 代码:

   info, err := os.Stat(file) 
   if err != nil { 
         fmt.Println("Error:", err) 
         os.Exit(1) 
   } 
   mode := info.Mode() 
   fmt.Print(file, ": ", mode, "\n") 
} 

再次强调,大部分的 Go 代码用于处理命令行参数并确保您有一个!实际工作的 Go 代码主要是调用os.Stat()函数,该函数返回一个描述os.Stat()检查的文件或目录的FileInfo结构。通过FileInfo结构,您可以调用Mode()函数来发现文件的权限。

执行permissions.go会产生以下输出:

$ go run permissions.go /bin/ls
/bin/ls: -rwxr-xr-x
$ go run permissions.go /usr
/usr: drwxr-xr-x
$ go run permissions.go /us
Error: stat /us: no such file or directory
exit status 1

在 Go 中处理文件

操作系统的一个极其重要的任务是处理文件,因为所有数据都存储在文件中。在本节中,我们将向您展示如何删除和重命名文件,在下一节在 Go 中开发 find(1)中,我们将教您如何搜索目录结构以找到所需的文件。

删除文件

在本节中,我们将说明如何使用os.Remove() Go 函数删除文件和目录。

在测试删除文件和目录的程序时,请格外小心并且要有常识!

rm.go文件是rm(1)工具的 Go 实现,说明了您如何在 Go 中删除文件。尽管rm(1)的核心功能已经存在,但缺少rm(1)的选项:尝试实现其中一些选项将是一个很好的练习。在实现-f-R选项时要特别注意。

rm.go的 Go 代码如下:

package main 
import ( 
   "fmt" 
   "os" 
) 

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide an argument!") 
         os.Exit(1) 
   } 

   file := arguments[1] 
   err := os.Remove(file) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
} 

如果rm.go在没有任何问题的情况下执行,将不会产生任何输出,这符合 Unix 哲学。因此,有趣的是观察当您尝试删除的文件不存在时可以获得的错误消息:当您没有必要的权限删除它时以及当目录不为空时:

$ go run rm.go 123
remove 123: no such file or directory
$ ls -l /tmp/AlTest1.err
-rw-r--r--  1 root  wheel  1278 Apr 17 20:13 /tmp/AlTest1.err
$ go run rm.go /tmp/AlTest1.err
remove /tmp/AlTest1.err: permission denied
$ go run rm.go test
remove test: directory not empty

重命名和移动文件

在本小节中,我们将向您展示如何使用 Go 代码重命名和移动文件:Go 代码将保存为rename.go。尽管相同的代码可以用于重命名或移动目录,但rename.go只允许处理文件。

在执行一些无法轻易撤消的操作时,例如覆盖文件时,您应该格外小心,也许通知用户目标文件已经存在,以避免不愉快的意外。尽管传统的mv(1)实用程序的默认操作会自动覆盖目标文件(如果存在),但我认为这并不是很安全。因此,默认情况下,rename.go不会覆盖目标文件。

在开发系统软件时,您必须处理所有细节,否则这些细节将在最不经意的时候显露为错误!广泛的测试将使您能够找到您错过的细节并加以纠正。

rename.go的代码将分为四部分呈现。第一部分包括预期的序言以及处理flag包设置的 Go 代码:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "path/filepath" 
) 

func main() { 
   minusOverwrite := flag.Bool("overwrite", false, "overwrite") 

   flag.Parse() 
   flags := flag.Args() 

   if len(flags) < 2 { 
         fmt.Println("Please provide two arguments!") 
         os.Exit(1) 
   } 

第二部分包含以下 Go 代码:

   source := flags[0] 
   destination := flags[1] 
   fileInfo, err := os.Stat(source) 
   if err == nil { 
         mode := fileInfo.Mode() 
         if mode.IsRegular() == false { 
               fmt.Println("Sorry, we only support regular files as source!") 
               os.Exit(1) 
         } 
   } else { 
         fmt.Println("Error reading:", source) 
         os.Exit(1) 
   } 

这部分确保源文件存在,是一个普通文件,并且不是一个目录或者其他类似网络套接字或管道的东西。再次,你在which.go中看到的os.Stat()的技巧在这里被使用了。

rename.go的第三部分如下:

   newDestination := destination 
   destInfo, err := os.Stat(destination) 
   if err == nil { 
         mode := destInfo.Mode() 
         if mode.IsDir() { 
               justTheName := filepath.Base(source) 
               newDestination = destination + "/" + justTheName 
         } 
   } 

这里还有另一个棘手的地方;你需要考虑源文件是普通文件而目标是目录的情况,这是通过newDestination变量的帮助实现的。

另一个你应该考虑的特殊情况是,当源文件以包含绝对或相对路径的格式给出时,比如./aDir/aFile。在这种情况下,当目标是一个目录时,你应该获取路径的基本名称,即跟在最后一个/字符后面的内容,在这种情况下是aFile,并将其添加到目标目录中,以正确构造newDestination变量。这是通过filepath.Base()函数的帮助实现的,它返回路径的最后一个元素。

最后,rename.go的最后部分包含以下 Go 代码:

   destination = newDestination 
   destInfo, err = os.Stat(destination) 
   if err == nil { 
         if *minusOverwrite == false { 
               fmt.Println("Destination file already exists!") 
               os.Exit(1) 
         } 
   } 

   err = os.Rename(source, destination) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
} 

rename.go最重要的 Go 代码与识别目标文件是否存在有关。再次,这是通过os.Stat()函数的支持实现的。如果os.Stat()返回一个错误消息,这意味着目标文件不存在;因此,你可以调用os.Rename()。如果os.Stat()返回nil,这意味着os.Stat()调用成功,并且目标文件存在。在这种情况下,你应该检查overwrite标志的值,以查看是否允许覆盖目标文件。

当一切正常时,你可以自由地调用os.Rename()并执行所需的任务!

如果rename.go被正确执行,它将不会产生任何输出。然而,如果有问题,rename.go将生成一些输出:

$ touch newFILE
$ ./rename newFILE regExpFind.go
Destination file already exists!
$ ./rename -overwrite newFILE regExpFind.go
$

在 Go 中开发 find(1)

这一部分将教你开发一个简化版本的find(1)命令行实用程序所需的必要知识。开发的版本将不支持find(1)支持的所有命令行选项,但它将有足够的选项来真正有用。

在接下来的子章节中,你将看到整个过程分为小步骤。因此,第一个子章节将向你展示访问给定目录树中的所有文件和目录的 Go 方式。

遍历目录树

find(1)最重要的任务是能够访问从给定目录开始的所有文件和子目录。因此,这一部分将在 Go 中实现这个任务。traverse.go的 Go 代码将分为三部分呈现。第一部分是预期的序言:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
) 

第二部分是关于实现一个名为walkFunction()的函数,该函数将用作 Go 函数filepath.Walk()的参数:

func walkFunction(path string, info os.FileInfo, err error) error { 
   _, err = os.Stat(path) 
   if err != nil { 
         return err 
   } 

   fmt.Println(path) 
   return nil 
} 

再次,os.Stat()函数被使用是因为成功的os.Stat()函数调用意味着我们正在处理实际存在的东西(文件、目录、管道等)!

不要忘记,在调用filepath.Walk()和调用执行walkFunction()之间,活跃和繁忙的文件系统中可能会发生许多事情,这是调用os.Stat()的主要原因。

代码的最后部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(1) 
   } 

   Path := arguments[1] 
   err := filepath.Walk(Path, walkFunction) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
} 

所有这些繁琐的工作都是由filepath.Walk()函数自动完成的,借助于之前定义的walkFunction()函数。filepath.Walk()函数接受两个参数:一个目录的路径和它将使用的遍历函数。

执行traverse.go将生成以下类型的输出:

$ go run traverse.go ~/code/C/cUNL
/home/mtsouk/code/C/cUNL
/home/mtsouk/code/C/cUNL/gpp
/home/mtsouk/code/C/cUNL/gpp.c
/home/mtsouk/code/C/cUNL/sizeofint
/home/mtsouk/code/C/cUNL/sizeofint.c
/home/mtsouk/code/C/cUNL/speed
/home/mtsouk/code/C/cUNL/speed.c
/home/mtsouk/code/C/cUNL/swap
/home/mtsouk/code/C/cUNL/swap.c

正如你所看到的,traverse.go的代码相当天真,因为它除其他事情外,无法区分目录、文件和符号链接。然而,它完成了访问给定目录树下的每个文件和目录的繁琐工作,这是find(1)实用程序的基本功能。

仅访问目录!

虽然能够访问所有内容是很好的,但有时您只想访问目录而不是文件。因此,在本小节中,我们将修改traverse.go以仍然访问所有内容,但只打印目录名称。新程序的名称将是traverseDir.go。需要更改的是traverse.go的唯一部分是walkFunction()的定义:

func walkFunction(path string, info os.FileInfo, err error) error { 
   fileInfo, err := os.Stat(path) 
   if err != nil { 
         return err 
   } 

   mode := fileInfo.Mode() 
   if mode.IsDir() { 
         fmt.Println(path) 
   } 
   return nil 
} 

如您所见,您需要使用os.Stat()函数调用返回的信息来检查您是否正在处理目录。如果您有一个目录,那么打印其路径,您就完成了。

执行traverseDir.go将生成以下输出:

$ go run traverseDir.go ~/code
/home/mtsouk/code
/home/mtsouk/code/C
/home/mtsouk/code/C/cUNL
/home/mtsouk/code/C/example
/home/mtsouk/code/C/sysProg
/home/mtsouk/code/C/system
/home/mtsouk/code/Haskell
/home/mtsouk/code/aLink
/home/mtsouk/code/perl
/home/mtsouk/code/python  

find(1)的第一个版本

本节中的 Go 代码保存为find.go,将分为三部分呈现。正如您将看到的,find.go使用了在traverse.go中找到的大量代码,这是您逐步开发程序时获得的主要好处。

find.go的第一部分是预期的序言:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "path/filepath" 
) 

由于我们已经知道将来会改进find.go,因此即使这是find.go的第一个版本并且没有任何标志,这里也使用了flag包!

Go 代码的第二部分包含了walkFunction()的实现:

func walkFunction(path string, info os.FileInfo, err error) error { 

   fileInfo, err := os.Stat(path) 
   if err != nil { 
         return err 
   } 

   mode := fileInfo.Mode() 
   if mode.IsDir() || mode.IsRegular() { 
         fmt.Println(path) 
   } 
   return nil 
} 

walkFunction()的实现中,您可以轻松理解find.go只打印常规文件和目录,没有其他内容。这是一个问题吗?不是,如果这是您想要的。一般来说,这不是好的。尽管如此,尽管存在一些限制,但拥有一个能够工作的东西的第一个版本是一个很好的起点!下一个版本将被命名为improvedFind.go,将通过向其添加各种命令行选项来改进find.go

find.go的最后一部分包含实现main()函数的代码:

func main() { 
   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(1) 
   } 

   Path := flags[0]

   err := filepath.Walk(Path, walkFunction) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
} 

执行find.go将创建以下输出:

$ go run find.go ~/code/C/cUNL
/home/mtsouk/code/C/cUNL
/home/mtsouk/code/C/cUNL/gpp
/home/mtsouk/code/C/cUNL/gpp.c
/home/mtsouk/code/C/cUNL/sizeofint
/home/mtsouk/code/C/cUNL/sizeofint.c
/home/mtsouk/code/C/cUNL/speed
/home/mtsouk/code/C/cUNL/speed.c
/home/mtsouk/code/C/cUNL/swap
/home/mtsouk/code/C/cUNL/swap.c

添加一些命令行选项

本小节将尝试改进您之前创建的find(1)的 Go 版本。请记住,这是开发真实程序使用的过程,因为您不会在程序的第一个版本中实现每个可能的命令行选项。

新版本的 Go 代码将保存为improvedFind.go。新版本将能够忽略符号链接:只有在使用适当的命令行选项运行improvedFind.go时,才会打印符号链接。为此,我们将使用symbolLink.go的一些 Go 代码。

improvedFind.go程序是一个真正的系统工具,您可以在自己的 Unix 机器上使用。

支持的标志将是以下内容:

  • -s:这是用于打印套接字文件的

  • -p:这是用于打印管道的

  • -sl:这是用于打印符号链接的

  • -d:这是用于打印目录的

  • -f:这是用于打印文件的

正如您将看到的,大部分新的 Go 代码是为了支持添加到程序中的标志。此外,默认情况下,improvedFind.go打印每种类型的文件或目录,并且您可以组合任何前述标志以打印您想要的文件类型。

除了在实现main()函数中进行各种更改以支持所有这些标志之外,大部分其余更改将发生在walkFunction()函数的代码中。此外,walkFunction()函数将在main()函数内部定义,这是为了避免使用全局变量。

improvedFind.go的第一部分如下:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "path/filepath" 
) 

func main() { 

   minusS := flag.Bool("s", false, "Sockets") 
   minusP := flag.Bool("p", false, "Pipes") 
   minusSL := flag.Bool("sl", false, "Symbolic Links") 
   minusD := flag.Bool("d", false, "Directories") 
   minusF := flag.Bool("f", false, "Files") 

   flag.Parse() 
   flags := flag.Args() 

   printAll := false 
   if *minusS && *minusP && *minusSL && *minusD && *minusF { 
         printAll = true 
   } 

   if !(*minusS || *minusP || *minusSL || *minusD || *minusF) { 
         printAll = true 
   } 

   if len(flags) == 0 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(1) 
   } 

   Path := flags[0] 

因此,如果所有标志都未设置,程序将打印所有内容,这由第一个if语句处理。同样,如果所有标志都设置了,程序也将打印所有内容。因此,需要一个名为printAll的新布尔变量。

improvedFind.go的第二部分包含以下 Go 代码,主要是walkFunction变量的定义,实际上是一个函数:

   walkFunction := func(path string, info os.FileInfo, err error) error { 
         fileInfo, err := os.Stat(path) 
         if err != nil { 
               return err 
         } 

         if printAll { 
               fmt.Println(path) 
               return nil 
         } 

         mode := fileInfo.Mode() 
         if mode.IsRegular() && *minusF { 
               fmt.Println(path) 
               return nil 
         } 

         if mode.IsDir() && *minusD { 
               fmt.Println(path) 
               return nil 
         } 

         fileInfo, _ = os.Lstat(path)

         if fileInfo.Mode()&os.ModeSymlink != 0 { 
               if *minusSL { 
                     fmt.Println(path) 
                     return nil 
               } 
         } 

         if fileInfo.Mode()&os.ModeNamedPipe != 0 { 
               if *minusP { 
                     fmt.Println(path) 
                     return nil 
               } 
         } 

         if fileInfo.Mode()&os.ModeSocket != 0 { 
               if *minusS { 
                     fmt.Println(path) 
                     return nil 
               } 
         } 

         return nil 
   } 

在这里,好处是一旦找到匹配并打印文件,你就不必访问if语句的其余部分,这是将minusF检查放在第一位,minusD检查放在第二位的主要原因。调用os.Lstat()用于找出我们是否正在处理符号链接。这是因为os.Stat()会跟随符号链接并返回有关链接引用的文件的信息,而os.Lstat()不会这样做:stat(2)lstat(2)也是如此。

你应该对improvedFind.go的最后部分非常熟悉:

   err := filepath.Walk(Path, walkFunction) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
} 

执行improvedFind.go生成以下输出,这是find.go输出的增强版本:

$ go run improvedFind.go -d ~/code/C
/home/mtsouk/code/C
/home/mtsouk/code/C/cUNL
/home/mtsouk/code/C/example
/home/mtsouk/code/C/sysProg
/home/mtsouk/code/C/system
$ go run improvedFind.go -sl ~/code
/home/mtsouk/code/aLink

从查找输出中排除文件名

有时你不需要显示find(1)的输出中的所有内容。因此,在这一小节中,你将学习一种技术,允许你根据文件名手动排除improvedFind.go的输出中的文件。

请注意,该程序的这个版本不支持正则表达式,只会排除文件名的精确匹配。

因此,improvedFind.go的改进版本将被命名为excludeFind.godiff(1)实用程序的输出可以揭示improvedFind.goexcludeFind.go之间的代码差异:

$ diff excludeFind.go improvedFind.go
10,19d9
< func excludeNames(name string, exclude string) bool {`
<     if exclude == "" {
<           return false
<     }
<     if filepath.Base(name) == exclude {
<           return true
<     }
<     return false
< }
<
27d16
<     minusX := flag.String("x", "", "Files")
54,57d42
<           if excludeNames(path, *minusX) {
<                 return nil
<           }
<

最重要的变化是引入了一个名为excludeNames()的新的 Go 函数,处理文件名的排除以及-x标志的添加,用于设置要从输出中排除的文件名。所有的工作都由文件路径完成。Base()函数找到路径的最后一部分,即使路径不是文件而是目录,也会将其与-x标志的值进行比较。

请注意,excludeNames()函数的更合适的名称可能是isExcluded()或类似的,因为-x选项接受单个值。

使用excludeFind.go执行并不带-x标志的命令将证明新的 Go 代码实际上是有效的。

$ go run excludeFind.go -x=dT.py ~/code/python
/home/mtsouk/code/python
/home/mtsouk/code/python/dataFile.txt
/home/mtsouk/code/python/python
$ go run excludeFind.go ~/code/python
/home/mtsouk/code/python
/home/mtsouk/code/python/dT.py
/home/mtsouk/code/python/dataFile.txt
/home/mtsouk/code/python/python

从查找输出中排除文件扩展名

文件扩展名是最后一个点(.)字符之后的文件名的一部分。因此,image.png文件的文件扩展名是 png,这适用于文件和目录。

再次,为了实现这个功能,你需要一个单独的命令行选项,后面跟着你想要排除的文件扩展名:新的标志将被命名为-ext。这个find(1)实用程序的版本将基于excludeFind.go的代码,并将被命名为finalFind.go。你们中的一些人可能会说,这个选项更合适的名称应该是-xext,你们是对的!

再次,diff(1)实用程序将帮助我们发现excludeFind.gofinalFind.go之间的代码差异:新功能是在名为excludeExtensions()的 Go 函数中实现的,这使得理解更加容易。

$ diff finalFind.go excludeFind.go
8d7
<     "strings"
21,34d19
< func excludeExtensions(name string, extension string) bool {
<     if extension == "" {
<           return false
<     }
<     basename := filepath.Base(name)
<     s := strings.Split(basename, ".")
<     length := len(s)
<     basenameExtension := s[length-1]
<     if basenameExtension == extension {
<           return true
<     }
<     return false
< }
<
43d27
<     minusEXT := flag.String("ext", "", "Extensions")
74,77d57
<           if excludeExtensions(path, *minusEXT) {
<                 return nil
<           }
< 

由于我们正在寻找路径中最后一个点后的字符串,我们使用strings.Split()根据路径中包含的点字符来分割路径。然后,我们取strings.Split()的返回值的最后一部分,并将其与使用-ext标志给定的扩展名进行比较。因此,这里没有什么特别的,只是一些字符串操作代码。再次强调,excludeExtensions()更合适的名称应该是isExcludedExtension()

执行finalFind.go将生成以下输出:

$ go run finalFind.go -ext=py ~/code/python
/home/mtsouk/code/python
/home/mtsouk/code/python/dataFile.txt
/home/mtsouk/code/python/python
$ go run finalFind.go ~/code/python
/home/mtsouk/code/python
/home/mtsouk/code/python/dT.py
/home/mtsouk/code/python/dataFile.txt
/home/mtsouk/code/python/python

使用正则表达式

这一部分将说明如何在finalFind.go中添加对正则表达式的支持:工具的最新版本的名称将是regExpFind.go。新的标志将被称为-re,它将需要一个字符串值:与此字符串值匹配的任何内容都将包含在输出中,除非它被另一个命令行选项排除。此外,由于标志提供的灵活性,我们不需要删除任何以前的选项来添加另一个选项!

再次,diff(1)命令将告诉我们regExpFind.gofinalFind.go之间的代码差异:

$ diff regExpFind.go finalFind.go
8d7
<     "regexp"
36,44d34
< func regularExpression(path, regExp string) bool {
<     if regExp == "" {
<           return true
<     }
<     r, _ := regexp.Compile(regExp)
<     matched := r.MatchString(path)
<     return matched
< }
<
54d43
<     minusRE := flag.String("re", "", "Regular Expression")
71a61
>
75,78d64
<           if regularExpression(path, *minusRE) == false {
<                 return nil
<           }
< 

在第七章, 处理系统文件中,我们将更多地讨论 Go 中的模式匹配和正则表达式:现在,理解regexp.Compile()创建正则表达式,MatchString()尝试在regularExpression()函数中进行匹配就足够了。

执行regExpFind.go将生成以下输出:

$ go run regExpFind.go -re=anotherPackage /Users/mtsouk/go
/Users/mtsouk/go/pkg/darwin_amd64/anotherPackage.a
/Users/mtsouk/go/src/anotherPackage
/Users/mtsouk/go/src/anotherPackage/anotherPackage.go
$ go run regExpFind.go -ext=go -re=anotherPackage /Users/mtsouk/go
/Users/mtsouk/go/pkg/darwin_amd64/anotherPackage.a
/Users/mtsouk/go/src/anotherPackage 

可以使用以下命令验证先前的输出:

$ go run regExpFind.go /Users/mtsouk/go | grep anotherPackage
/Users/mtsouk/go/pkg/darwin_amd64/anotherPackage.a
/Users/mtsouk/go/src/anotherPackage
/Users/mtsouk/go/src/anotherPackage/anotherPackage.go

创建目录结构的副本

凭借您在前几节中获得的知识,我们现在将开发一个 Go 程序,该程序在另一个目录中创建目录结构的副本:这意味着输入目录中的任何文件都不会复制到目标目录,只会复制目录。当您想要将有用的文件从一个目录结构保存到其他位置并保持相同的目录结构时,或者当您想要手动备份文件系统时,这可能会很方便。

由于您只对目录感兴趣,因此cpStructure.go的代码基于本章前面看到的traverseDir.go的代码:再次,为了学习目的而开发的小程序帮助您实现更大的程序!此外,test选项将显示程序的操作,而不会实际创建任何目录。

cpStructure.go的代码将分为四部分呈现。第一部分如下:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "path/filepath" 
   "strings" 
) 

这里没有什么特别的,只是预期的序言。第二部分如下:

func main() { 
   minusTEST := flag.Bool("test", false, "Test run!") 

   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 || len(flags) == 1 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(1) 
   } 

   Path := flags[0] 
   NewPath := flags[1] 

   permissions := os.ModePerm 
   _, err := os.Stat(NewPath) 
   if os.IsNotExist(err) { 
         os.MkdirAll(NewPath, permissions) 
   } else { 
         fmt.Println(NewPath, "already exists - quitting...") 
         os.Exit(1) 
   } 

cpStructure.go程序要求预先不存在目标目录,以避免后续不必要的意外和错误。

第三部分包含walkFunction变量的代码:

   walkFunction := func(currentPath string, info os.FileInfo, err error) error { 
         fileInfo, _ := os.Lstat(currentPath) 
         if fileInfo.Mode()&os.ModeSymlink != 0 { 
               fmt.Println("Skipping", currentPath) 
               return nil 
         } 

         fileInfo, err = os.Stat(currentPath) 
         if err != nil { 
               fmt.Println("*", err) 
               return err 
         } 

         mode := fileInfo.Mode() 
         if mode.IsDir() { 
               tempPath := strings.Replace(currentPath, Path, "", 1) 
               pathToCreate := NewPath + "/" + filepath.Base(Path) + tempPath 

               if *minusTEST { 
                     fmt.Println(":", pathToCreate) 
                     return nil 
               } 

               _, err := os.Stat(pathToCreate) 
               if os.IsNotExist(err) { 
                     os.MkdirAll(pathToCreate, permissions) 
               } else { 
                     fmt.Println("Did not create", pathToCreate, ":", err) 
               } 
         } 
         return nil 
   } 

在这里,第一个if语句确保我们将处理符号链接,因为符号链接可能很危险并且会造成问题:始终尝试处理特殊情况以避免问题和令人讨厌的错误。

os.IsNotExist()函数允许您确保您要创建的目录尚不存在。因此,如果目录不存在,您可以使用os.MkdirAll()创建它。os.MkdirAll()函数创建包括所有必要父目录的目录路径,这对开发人员来说更简单。

然而,walkFunction变量的代码必须处理的最棘手的部分是删除源路径的不必要部分并正确构造新路径。程序中使用的strings.Replace()函数用其第二个参数(Path)替换其第一个参数(currentPath)中可以找到的出现,用其第三个参数("")替换其最后一个参数(1)的次数。如果最后一个参数是负数,这里不是这种情况,那么将没有限制地进行替换。在这种情况下,它会从currentPath变量(正在检查的目录)中删除Path变量的值,这是源目录。

程序的最后部分如下:

   err = filepath.Walk(Path, walkFunction) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
} 

执行cpStructure.go将生成以下输出:

$ go run cpStructure.go ~/code /tmp/newCode
Skipping /home/mtsouk/code/aLink
$ ls -l /home/mtsouk/code/aLink
lrwxrwxrwx 1 mtsouk mtsouk 14 Apr 21 18:10 /home/mtsouk/code/aLink -> /usr/local/bin 

以下图显示了前述示例中使用的源目录和目标目录结构的图形表示:

两个目录结构及其文件的图形表示

练习

  1. 阅读golang.org/pkg/os/上的os包的文档页面。

  2. 访问golang.org/pkg/path/filepath/了解更多关于filepath.Walk()函数的信息。

  3. 更改rm.go的代码以支持多个命令行参数,然后尝试实现rm(1)实用程序的-v命令行选项。

  4. which.go的 Go 代码进行必要的更改,以支持多个命令行参数。

  5. 开始在 Go 中实现ls(1)实用程序的版本。不要一次性尝试支持每个ls(1)选项。

  6. 修改traverseDir.go的代码,以便只打印常规文件。

  7. 查看find(1)的手册页面,并尝试在regExpFind.go中添加对其某些选项的支持。

摘要

在本章中,我们讨论了许多内容,包括使用flag标准包,允许您使用目录和文件以及遍历目录结构的 Go 函数,并且我们开发了各种 Unix 命令行实用程序的 Go 版本,包括pwd(1)which(1)rm(1)find(1)

在下一章中,我们将继续讨论文件操作,但这次您将学习如何在 Go 中读取文件和写入文件:正如您将看到的,有许多方法可以做到这一点。虽然这给了您灵活性,但它也要求您能够选择尽可能高效地完成工作的正确技术!因此,您将首先学习更多关于io包以及bufio包,到本章结束时,您将拥有wc(1)dd(1)实用程序的 Go 版本!

第六章:文件输入和输出

在上一章中,我们谈到了将文件和目录作为实体进行操作,而不查看其内容。但是,在本章中,我们将采取不同的方法,查看文件的内容:您可能认为本章是本书中最重要的章节之一,因为文件输入文件输出是任何操作系统的主要任务。

本章的主要目的是教授 Go 标准库如何允许我们打开文件,读取其内容,如果需要,对其进行处理,创建新文件,并将所需数据放入其中。读取和写入文件的两种主要方法是:使用io包和使用bufio包的函数。但是,这两个包的工作方式是相似的。

本章将告诉您以下内容:

  • 打开文件进行写入和读取

  • 使用io包进行文件输入和输出

  • 使用io.Writerio.Reader接口

  • 使用bufio包进行缓冲输入和输出

  • 在 Go 中复制文件

  • 在 Go 中实现wc(1)实用程序的版本

  • 在 Go 中开发dd(1)命令的版本

  • 创建稀疏文件

  • 字节切片在文件输入和输出中的重要性:字节切片首次在第二章中提到,使用 Go 编写程序

  • 将结构化数据存储在文件中,并在以后读取它们

  • 将制表符转换为空格字符,反之亦然

本章不会讨论向现有文件追加数据:您将不得不等到第七章,使用系统文件,以了解如何在不破坏现有数据的情况下将数据放在文件末尾。

关于文件输入和输出

文件输入和输出包括与读取文件数据和将所需数据写入文件有关的一切。没有一个操作系统不支持文件,因此也不支持文件输入和输出。

由于本章内容较多,我将停止讲话,开始向您展示将使事情更清晰的实际 Go 代码。因此,您将在本章中学到的第一件事是字节切片,在涉及文件输入和输出的应用程序中非常重要。

字节切片

字节切片是一种用于文件读写的切片。简单来说,它们是用作文件读写操作期间的缓冲区的字节切片。本节将介绍一个小的 Go 示例,其中使用字节切片进行文件写入和读取。正如您将在本章中看到的字节切片一样,请确保您理解所呈现的示例。相关的 Go 代码保存为byteSlice.go,将分为三个部分。

第一部分如下:

package main 

import ( 
   "fmt" 
   "io/ioutil" 
   "os" 
) 

byteSlice.go的第二部分如下:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide a filename") 
         os.Exit(1) 
   } 
   filename := os.Args[1] 

   aByteSlice := []byte("Mihalis Tsoukalos!\n") 
   ioutil.WriteFile(filename, aByteSlice, 0644) 

在这里,您使用aByteSlice字节切片将一些文本保存到由filename变量标识的文件中。byteSlice.go的最后一部分是以下 Go 代码:

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
   defer f.Close() 

   anotherByteSlice := make([]byte, 100) 
   n, err := f.Read(anotherByteSlice) 
   fmt.Printf("Read %d bytes: %s", n, anotherByteSlice)

} 

在这里,您定义了另一个名为anotherByteSlice的字节切片,其中有100个位置,将用于从先前创建的文件中读取。请注意,fmt.Printf()中使用的%s强制anotherByteSlice作为字符串打印:使用Println()将产生完全不同的输出。

请注意,由于文件较小,f.Read()调用将向anotherByteSlice中放入较少的数据。

anotherByteSlice的大小表示在单次调用Read()或任何其他类似从文件读取数据的操作之后可以存储在其中的最大数据量。

执行byteSlice.go将生成以下输出:

$ go run byteSlice.go usingByteSlices
Read 19 bytes: Mihalis Tsoukalos!

检查usingByteSlices文件的大小将验证是否已将正确的数据量写入其中:

$ wc usingByteSlices
   1   2  19 usingByteSlices

关于二进制文件

在 Go 中,读取和写入二进制和纯文本文件没有区别。因此,在处理文件时,Go 不会对其格式做出任何假设。但是,Go 提供了一个名为 binary 的包,允许您在不同的编码之间进行转换,例如小端大端

readBinary.go文件简要说明了如何将整数转换为小端数和大端数,当您要处理的文件包含某些类型的数据时可能会有用;这主要发生在处理原始设备和原始数据包操作时:记住一切都是文件!readBinary.go的源代码将分两部分呈现。

第一部分如下:

package main 

import ( 
   "bytes" 
   "encoding/binary" 
   "fmt" 
   "os" 
   "strconv" 
) 

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide an integer") 
         os.Exit(1) 
   } 
   aNumber, _ := strconv.ParseInt(os.Args[1], 10, 64) 

程序的这一部分没有什么特别之处。第二部分如下:

   buf := new(bytes.Buffer) 
   err := binary.Write(buf, binary.LittleEndian, aNumber) 
   if err != nil { 
         fmt.Println("Little Endian:", err) 
   } 

   fmt.Printf("%d is %x in Little Endian\n", aNumber, buf) 
   buf.Reset() 
   err = binary.Write(buf, binary.BigEndian, aNumber)

   if err != nil { 
         fmt.Println("Big Endian:", err) 
   } 
   fmt.Printf("And %x in Big Endian\n", buf) 
} 

第二部分包含了所有重要的 Go 代码:转换是通过binary.Write()方法和适当的写入参数(binary.LittleEndianbinary.BigEndian)进行的。bytes.Buffer变量用于程序的io.Readerio.Writer接口。最后,buf.Reset()语句重置缓冲区,以便之后用于存储大端数。

执行readBinary.go将生成以下输出:

$ go run readBinary.go 1
1 is 0100000000000000 in Little Endian
And 0000000000000001 in Big Endian

您可以通过访问其文档页面golang.org/pkg/encoding/binary/找到有关二进制包的更多信息。

Go 中有用的 I/O 包

io包用于执行原始文件 I/O 操作,而bufio包用于执行缓冲 I/O。

在缓冲 I/O 中,操作系统在文件读写操作期间使用中间缓冲区,以减少文件系统调用的次数。因此,缓冲输入和输出更快更高效。

此外,您可以使用fmt包的一些函数将文本写入文件。请注意,flag包也将在本章以及所有需要支持命令行标志的后续章节中使用。

io 包

io包提供了允许您向文件写入或从文件读取的函数。其用法将在usingIO.go文件中进行演示,该文件将分三部分呈现。程序的作用是从文件中读取8个字节并将它们写入标准输出。

Go 程序的序言是第一部分:

package main 

import ( 
   "fmt" 
   "io" 
   "os" 
) 

第二部分是以下 Go 代码:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide a filename") 
         os.Exit(1) 
   } 

   filename := os.Args[1] 
   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening %s: %s", filename, err) 
         os.Exit(1) 
   } 
   defer f.Close() 

该程序还使用了方便的defer命令,它推迟了函数的执行,直到周围的函数返回。因此,在文件 I/O 操作中经常使用defer,因为它可以让您不必记住在完成文件处理或在使用return语句或os.Exit()离开函数时执行Close()调用。

程序的最后一部分如下:

   buf := make([]byte, 8) 
   if _, err := io.ReadFull(f, buf); err != nil { 
         if err == io.EOF { 
               err = io.ErrUnexpectedEOF 
         } 
   } 
   io.WriteString(os.Stdout, string(buf)) 
   fmt.Println() 
} 

这里的io.ReadFull()函数从打开文件的读取器中读取数据,并将数据放入一个有 8 个位置的字节切片中。您还可以在这里看到使用io.WriteString()函数将数据打印到标准输出(os.Stdout),这也是一个文件。但是,这不是一个很常见的做法,因为您可以简单地使用fmt.Println()

执行usingIO.go会生成以下输出:

$ go run usingIO.go usingByteSlices
Mihalis

bufio 包

bufio包的函数允许您执行缓冲文件操作,这意味着尽管其操作看起来类似于io中找到的操作,但它们的工作方式略有不同。

bufio实际上的作用是将io.Readerio.Writer对象包装成一个实现所需接口的新值,并为新值提供缓冲。bufio包的一个方便功能是它允许您轻松地逐行、逐词和逐字符读取文本文件。

再次,一个示例将尝试澄清事情:展示了bufio使用的 Go 文件的名称是bufIO.go,将分为四个部分呈现。

第一部分是预期的序文:

package main 

import ( 
   "bufio" 
   "fmt" 
   "os" 
) 

第二部分是以下内容:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide a filename") 
         os.Exit(1) 
   } 

   filename := os.Args[1] 

在这里,你只需尝试获取要使用的文件的名称。

bufIO.go的第三部分包含以下 Go 代码:

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening %s: %s", filename, err) 
         os.Exit(1) 
   } 
   defer f.Close() 

   scanner := bufio.NewScanner(f) 

bufio.NewScanner的默认行为是逐行读取其输入,这意味着每次调用Scan()方法读取下一个标记时,都会返回一个新行。最后一部分是你实际调用Scan()方法以读取文件的全部内容的地方:

   for scanner.Scan() { 
         line := scanner.Text() 

         if scanner.Err() != nil { 
               fmt.Printf("error reading file %s", err) 
               os.Exit(1) 
         } 
         fmt.Println(line) 
   } 
}

Text()方法将Scan()方法的最新标记作为字符串返回,这种情况下将是一行。但是,如果你在尝试逐行读取文件时遇到奇怪的结果,那很可能是你的文件如何结束一行的方式,这通常是来自 Windows 机器的文本文件的情况。

执行bufIO.go并将其输出提供给wc(1)可以帮助你验证bufIO.go是否按预期工作:

$ go run bufIO.go inputFile | wc
      11      12      62
$ wc inputFile
      11      12      62 inputFile

文件 I/O 操作

现在你已经了解了iobufio包的基础知识,是时候学习更详细的关于它们的用法以及它们如何帮助你处理文件的信息了。但首先,我们将讨论fmt.Fprintf()函数。

使用 fmt.Fprintf()向文件写入

使用fmt.Fprintf()函数允许你以类似于fmt.Printf()函数的方式向文件写入格式化文本。请注意,fmt.Fprintf()可以写入任何io.Writer接口,并且我们的文件将满足io.Writer接口。

展示了fmt.Fprintf()的 Go 代码可以在fmtF.go中找到,该文件将分为三个部分呈现。第一部分是预期的序文:

package main 

import ( 
   "fmt" 
   "os" 
) 

第二部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide a filename") 
         os.Exit(1) 
   } 

   filename := os.Args[1] 
   destination, err := os.Create(filename) 
   if err != nil { 
         fmt.Println("os.Create:", err) 
         os.Exit(1) 
   } 
   defer destination.Close() 

请注意,如果文件已经存在,os.Create()函数将截断该文件。

最后一部分是以下内容:

   fmt.Fprintf(destination, "[%s]: ", filename) 
   fmt.Fprintf(destination, "Using fmt.Fprintf in %s\n", filename) 
} 

在这里,你可以使用fmt.Fprintf()将所需的文本数据写入由目标变量标识的文件,就像你使用fmt.Printf()方法一样。

执行fmtF.go将生成以下输出:

$ go run fmtF.go test
$ cat test
[test]: Using fmt.Fprintf in test 

换句话说,你可以使用fmt.Fprintf()创建纯文本文件。

关于 io.Writer 和 io.Reader

io.Writerio.Reader都是嵌入io.Write()io.Read()方法的接口。io.Writerio.Reader的使用将在readerWriter.go中进行说明,该文件将分为四个部分呈现。该程序计算其输入文件的字符数,并将字符数写入另一个文件:如果你处理的是每个字符占用多个字节的 Unicode 字符,你可能会考虑该程序正在读取字节。输出文件名为原始文件名加上.Count扩展名。

第一部分是以下内容:

package main 

import ( 
   "fmt" 
   "io" 
   "os" 
) 

第二部分是以下内容:

func countChars(r io.Reader) int { 
   buf := make([]byte, 16) 
   total := 0 
   for { 
         n, err := r.Read(buf) 
         if err != nil && err != io.EOF { 
               return 0 
         } 
         if err == io.EOF { 
               break 
         } 
         total = total + n 
   } 
   return total 
} 

再次,在读取过程中使用了字节切片。break语句允许你退出for循环。第三部分是以下代码:

func writeNumberOfChars(w io.Writer, x int) { 
   fmt.Fprintf(w, "%d\n", x) 
} 

在这里你可以看到如何使用fmt.Fprintf()向文件写入数字:我没有成功使用字节切片做同样的事情!另外,请注意,所呈现的代码使用io.Writer变量(w)向文件写入文本。

readerWriter.go的最后一部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide a filename") 
         os.Exit(1) 
   } 

   filename := os.Args[1] 
   _, err := os.Stat(filename)

   if err != nil { 
         fmt.Printf("Error on file %s: %s\n", filename, err) 
         os.Exit(1) 
   } 

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Println("Cannot open file:", err) 
         os.Exit(-1) 
   } 
   defer f.Close() 

   chars := countChars(f) 
   filename = filename + ".Count" 
   f, err = os.Create(filename) 
   if err != nil { 
         fmt.Println("os.Create:", err) 
         os.Exit(1) 
   } 
   defer f.Close() 
   writeNumberOfChars(f, chars) 
} 

执行readerWriter.go不会生成任何输出;因此,你需要检查其正确性,这在本例中是通过wc(1)的帮助来实现的:

$ go run readerWriter.go /tmp/swtag.log
$ wc /tmp/swtag.log
     119     635    7780 /tmp/swtag.log
$ cat /tmp/swtag.log.Count
7780

查找行的第三列

现在您已经知道如何读取文件,是时候介绍您在第三章中看到的readColumn.go程序的修改版本了,高级 Go 功能。新版本也被命名为readColumn.go,但有两个主要改进。第一个是您可以将所需的列作为命令行参数提供,第二个是如果它获得多个命令行参数,它可以读取多个文件。

readColumn.go文件将分为三部分。readColumn.go的第一部分如下:

package main 

import ( 
   "bufio" 
   "flag" 
   "fmt" 
   "io" 
   "os" 
   "strings" 
) 

readColumn.go的下一部分包含以下 Go 代码:

func main() { 
   minusCOL := flag.Int("COL", 1, "Column") 
   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 { 
         fmt.Printf("usage: readColumn <file1> [<file2> [... <fileN]]\n") 
         os.Exit(1) 
   } 

   column := *minusCOL 

   if column < 0 { 
         fmt.Println("Invalid Column number!") 
         os.Exit(1) 
   } 

minusCOL变量的定义中,您将了解到,如果用户不使用此标志,程序将打印它读取的每个文件的第一列的内容。

readColumn.go的最后部分如下:

   for _, filename := range flags { 
         fmt.Println("\t\t", filename) 
         f, err := os.Open(filename) 
         if err != nil { 
               fmt.Printf("error opening file %s", err) 
               continue 
         } 
         defer f.Close() 

         r := bufio.NewReader(f)

         for { 
               line, err := r.ReadString('\n') 

               if err == io.EOF { 
                     break 
               } else if err != nil { 
                     fmt.Printf("error reading file %s", err) 
               } 

               data := strings.Fields(line) 
               if len(data) >= column { 
                     fmt.Println((data[column-1])) 
               } 
         } 
   } 
} 

前面的代码没有做任何您以前没有见过的事情。for循环用于处理所有命令行参数。但是,如果由于某种原因文件无法打开,程序将不会停止执行,而是会继续处理其余的文件(如果存在)。但是,程序期望其输入文件以换行符结尾,如果输入文件以不同的方式结束,您可能会看到奇怪的结果。

执行readColumn.go会生成以下输出,为了节省一些书籍空间,输出进行了缩写:

$ go run readColumn.go -COL=3 pF.data isThereAFile up.data
            pF.data
            isThereAFile
error opening file open isThereAFile: no such file or directory
            up.data
0.05
0.05
0.05
0.05
0.05
0.05

在这种情况下,没有名为isThereAFile的文件,pF.data文件也没有第三列。但是,程序尽力打印了它能够打印的内容!

在 Go 中复制文件

每个操作系统都允许您复制文件,因为这是非常重要和必要的操作。现在您知道如何读取文件,本节将向您展示如何在 Go 中复制文件!

复制文件有多种方法!

大多数编程语言提供了多种创建文件副本的方法,Go 也不例外。由开发人员决定要实现哪种方法。

有多种方法可以做到这一规则几乎适用于本书中实现的所有内容,但是文件复制是这一规则的最典型的例子,因为您可以逐行、逐字节或一次性复制文件!但是,这一规则不适用于 Go 喜欢格式化其代码的方式!

复制文本文件

处理文本文件的复制没有特殊的意义,除非你想要检查或修改它们的内容。因此,这里介绍的三种技术不会区分纯文本和二进制文件的复制。

第七章,处理系统文件,将讨论文件权限,因为有时您希望使用您选择的文件权限创建新文件。

使用 io.Copy

本小节将介绍一种使用io.Copy()函数复制文件的技术。io.Copy()函数的特殊之处在于它在过程中不提供任何灵活性。程序的名称将是notGoodCP.go,将分为三部分呈现。请注意,notGoodCP.go的更合适的文件名可能是copyEntireFileAtOnce.gocopyByReadingInputFileAllAtOnce.go

notGoodCP.go的 Go 代码的第一部分如下:

package main 

import ( 
   "fmt" 
   "io" 
   "os" 
) 

第二部分如下:

func Copy(src, dst string) (int64, error) { 
   sourceFileStat, err := os.Stat(src) 
   if err != nil { 
         return 0, err 
   } 

   if !sourceFileStat.Mode().IsRegular() { 
         return 0, fmt.Errorf("%s is not a regular file", src) 
   } 

   source, err := os.Open(src) 
   if err != nil { 
         return 0, err 
   } 
   defer source.Close() 

   destination, err := os.Create(dst) 
   if err != nil { 
         return 0, err 
   } 
   defer destination.Close() 
   nBytes, err := io.Copy(destination, source) 
   return nBytes, err 

}

在这里,我们定义了自己的函数,该函数使用io.Copy()来复制文件。Copy()函数在尝试复制文件之前会检查源文件是否是常规文件,这是非常合理的。

main()函数的最后部分是实现:

func main() { 
   if len(os.Args) != 3 { 
         fmt.Println("Please provide two command line arguments!") 
         os.Exit(1) 
   } 

   sourceFile := os.Args[1] 
   destinationFile := os.Args[2] 
   nBytes, err := Copy(sourceFile, destinationFile) 

   if err != nil { 
         fmt.Printf("The copy operation failed %q\n", err) 
   } else { 
         fmt.Printf("Copied %d bytes!\n", nBytes) 
   } 
} 

测试文件是否是另一个文件的精确副本的最佳工具是diff(1)实用程序,它也适用于二进制文件。您可以通过阅读其主页了解有关diff(1)的更多信息。

执行notGoodCP.go将生成以下结果:

$ go run notGoodCP.go testFile aCopy
Copied 871 bytes!
$ diff aCopy testFile
$ wc testFile aCopy
      51     127     871 testFile
      51     127     871 aCopy
     102     254    1742 total

一次性读取文件!

本节中的技术将使用ioutil.WriteFile()ioutil.ReadFile()函数。请注意,ioutil.ReadFile()没有实现io.Reader接口,因此有一定的限制。

这一部分的 Go 代码名为readAll.go,将分为三部分呈现。

第一部分包含以下 Go 代码:

package main 

import ( 
   "fmt" 
   "io/ioutil" 
   "os" 
) 

第二部分如下:

func main() { 
   if len(os.Args) != 3 { 
         fmt.Println("Please provide two command line arguments!") 
         os.Exit(1) 
   } 

   sourceFile := os.Args[1] 
   destinationFile := os.Args[2] 

最后一部分如下:

   input, err := ioutil.ReadFile(sourceFile) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 

   err = ioutil.WriteFile(destinationFile, input, 0644) 
   if err != nil { 
         fmt.Println("Error creating the new file", destinationFile) 
         fmt.Println(err) 
         os.Exit(1) 
   } 
} 

请注意,ioutil.ReadFile()函数读取整个文件,当你想要复制大文件时可能不是高效的。同样,ioutil.WriteFile()函数将所有给定的数据写入由其第一个参数标识的文件。

执行readAll.go将生成以下输出:

$ go run readAll.go testFile aCopy
$ diff aCopy testFile
$ ls -l testFile aCopy
-rw-r--r--  1 mtsouk  staff  871 May  3 21:07 aCopy
-rw-r--r--@ 1 mtsouk  staff  871 May  3 21:04 testFile
$ go run readAll.go doesNotExist aCopy
open doesNotExist: no such file or directory
exit status 1

一个更好的文件复制程序

本节将介绍一个使用更传统方法的程序,其中使用缓冲区进行读取和复制到新文件。

尽管传统的 Unix 命令行实用程序在没有错误时是静默的,但在自己的工具中打印一些信息,比如读取的字节数,也不是坏事。然而,正确的做法是遵循 Unix 的方式。

存在两个主要原因使cp.gonotGoodCP.go更好。第一个是开发者可以更多地控制这个过程,但需要编写更多的 Go 代码;第二个是cp.go允许你定义缓冲区的大小,这是复制操作中最重要的参数。

cp.go的代码将分为五部分呈现。第一部分是预期的序文,以及一个保存读取缓冲区大小的全局变量:

package main 

import ( 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "strconv" 
) 

var BUFFERSIZE int64 

第二部分如下:

func Copy(src, dst string, BUFFERSIZE int64) error { 
   sourceFileStat, err := os.Stat(src) 
   if err != nil { 
         return err 
   } 

   if !sourceFileStat.Mode().IsRegular() { 
         return fmt.Errorf("%s is not a regular file.", src) 
   } 

   source, err := os.Open(src) 
   if err != nil { 
         return err 
   } 
   defer source.Close() 

如你所见,缓冲区的大小作为参数传递给了Copy()函数。另外两个命令行参数是输入文件名和输出文件名。

第三部分包含了Copy()函数的剩余 Go 代码:

   _, err = os.Stat(dst) 
   if err == nil { 
         return fmt.Errorf("File %s already exists.", dst) 
   } 

   destination, err := os.Create(dst) 
   if err != nil { 
         return err 
   } 
   defer destination.Close() 

   if err != nil { 
         panic(err) 
   } 

   buf := make([]byte, BUFFERSIZE) 
   for { 
         n, err := source.Read(buf) 
         if err != nil && err != io.EOF { 
               return err 
         } 
         if n == 0 { 
               break 
         } 

         if _, err := destination.Write(buf[:n]); err != nil { 
               return err 
         } 
   } 
   return err 
} 

这里没有什么特别的:你只需不断调用源文件的Read(),直到达到输入文件的末尾。每次读取内容时,你都要调用目标文件的Write()来保存到输出文件。buf[:n]的表示法允许你从buf切片中读取前n个字符。

第四部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 4 { 
         fmt.Printf("usage: %s source destination BUFFERSIZE\n", 
filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   source := os.Args[1] 
   destination := os.Args[2] 
   BUFFERSIZE, _ = strconv.ParseInt(os.Args[3], 10, 64) 

filepath.Base()用于获取可执行文件的名称。

最后一部分如下:

   fmt.Printf("Copying %s to %s\n", source, destination) 
   err := Copy(source, destination, BUFFERSIZE) 
   if err != nil { 
         fmt.Printf("File copying failed: %q\n", err) 
   } 
}

执行cp.go将生成以下输出:

$ go run cp.go inputFile aCopy 2048
Copying inputFile to aCopy
$ diff inputFile aCopy

如果copy操作出现问题,你将得到一个描述性的错误消息。

因此,如果程序找不到输入文件,它将打印以下内容:

$ go run cp.go A /tmp/myCP 1024
Copying A to /tmp/myCP
File copying failed: "stat A: no such file or directory"

如果程序无法读取输入文件,你将得到以下消息:

$ go run cp.go inputFile /tmp/myCP 1024
Copying inputFile to /tmp/myCP
File copying failed: "open inputFile: permission denied"

如果程序无法创建输出文件,它将打印以下错误消息:

$ go run cp.go inputFile /usr/myCP 1024
Copying inputFile to /usr/myCP
File copying failed: "open /usr/myCP: operation not permitted"

如果目标文件已经存在,你将得到以下输出:

$ go run cp.go inputFile outputFile 1024
Copying inputFile to outputFile
File copying failed: "File outputFile already exists."

文件复制操作的基准测试

在文件操作中使用的缓冲区的大小真的很重要,它会影响你的系统工具的性能,特别是当你处理非常大的文件时。

尽管开发可靠的软件应该是你的主要关注点,但你不应该忘记让你的系统软件快速高效!

因此,本节将尝试通过使用不同的缓冲区大小执行cp.go,并将其性能与readAll.gonotGoodCP.go以及cp(1)进行比较,以查看缓冲区大小如何影响文件复制操作。

在旧的 Unix 时代,当 Unix 机器上的 RAM 数量太小时,不建议使用大缓冲区。然而,如今,使用大小为100 MB的缓冲区并不被认为是不好的做法,特别是当你事先知道你要复制大量的大文件,比如数据库服务器的数据文件。

我们将在测试中使用三个不同大小的文件:这三个文件将使用dd(1)实用程序生成,如下所示:

$dd if=/dev/urandom of=100MB count=100000 bs=1024
100000+0 records in
100000+0 records out
102400000 bytes transferred in 6.800277 secs (15058210 bytes/sec)
$ dd if=/dev/urandom of=1GB count=1000000 bs=1024
1000000+0 records in
1000000+0 records out
1024000000 bytes transferred in 68.887482 secs (14864820 bytes/sec)
$ dd if=/dev/urandom of=5GB count=5000000 bs=1024
5000000+0 records in
5000000+0 records out
5120000000 bytes transferred in 339.357738 secs (15087324 bytes/sec)
$ ls -l 100MB 1GB 5GB
-rw-r--r--  1 mtsouk  staff   102400000 May  4 10:30 100MB
-rw-r--r--  1 mtsouk  staff  1024000000 May  4 10:32 1GB
-rw-r--r--  1 mtsouk  staff  5120000000 May  4 10:38 5GB

第一个文件大小为100 MB,第二个文件大小为1 GB,第三个文件大小为5 GB

现在,是时候使用time(1)实用程序进行实际测试了。首先,我们将测试notGoodCP.goreadAll.go的性能:

$ time ./notGoodCP 100MB copy
Copied 102400000 bytes!

real  0m0.153s
user  0m0.003s
sys   0m0.084s
$ time ./notGoodCP 1GB copy
Copied 1024000000 bytes!

real  0m1.461s
user  0m0.029s
sys   0m0.833s
$ time ./notGoodCP 5GB copy
Copied 5120000000 bytes!

real  0m12.193s
user  0m0.161s
sys   0m5.251s
$ time ./readAll 100MB copy

real  0m0.249s
user  0m0.003s
sys   0m0.138s
$ time ./readAll 1GB copy

real  0m3.117s
user  0m0.639s
sys   0m1.644s
$ time ./readAll 5GB copy

real  0m28.918s
user  0m8.106s
sys   0m21.364s

现在,您将看到cp.go程序使用四种不同的缓冲区大小16102410485761073741824的结果。首先,让我们复制100 MB文件:

$ time ./cp 100MB copy 16
Copying 100MB to copy

real  0m13.240s
user  0m2.699s
sys   0m10.530s
$ time ./cp 100MB copy 1024
Copying 100MB to copy

real  0m0.386s
user  0m0.053s
sys   0m0.303s
$ time ./cp 100MB copy 1048576
Copying 100MB to copy

real  0m0.135s
user  0m0.001s
sys   0m0.075s
$ time ./cp 100MB copy 1073741824
Copying 100MB to copy

real  0m0.390s
user  0m0.011s
sys   0m0.136s

然后,我们将复制1 GB文件:

$ time ./cp 1GB copy 16
Copying 1GB to copy

real  2m10.054s
user  0m26.497s
sys   1m43.411s
$ time ./cp 1GB copy 1024
Copying 1GB to copy

real  0m3.520s
user  0m0.533s
sys   0m2.944s
$ time ./cp 1GB copy 1048576
Copying 1GB to copy

real  0m1.431s
user  0m0.006s
sys   0m0.749s
$ time ./cp 1GB copy 1073741824
Copying 1GB to copy

real  0m2.033s
user  0m0.012s
sys   0m1.310s

接下来,我们将复制5 GB文件:

$ time ./cp 5GB copy 16
Copying 5GB to copy

real  10m41.551s
user  2m11.695s
sys   8m29.248s
$ time ./cp 5GB copy 1024
Copying 5GB to copy

real  0m16.558s
user  0m2.415s
sys   0m13.597s
$ time ./cp 5GB copy 1048576
Copying 5GB to copy

real  0m7.172s
user  0m0.028s
sys   0m3.734s
$ time ./cp 5GB copy 1073741824
Copying 5GB to copy

real  0m8.612s
user  0m0.011s
sys   0m4.536s

最后,让我们展示 macOS Sierra 附带的cp(1)实用程序的结果:

$ time cp 100MB copy

real  0m0.274s
user  0m0.002s
sys   0m0.105s
$ time cp 1GB copy

real  0m2.735s
user  0m0.003s
sys   0m1.014s
$ time cp 5GB copy

real  0m12.199s
user  0m0.012s
sys   0m5.050s

下图显示了time(1)实用程序输出的实际字段值的图表,显示了所有上述结果:

各种复制实用程序的基准测试结果

从结果中可以看出,cp(1)实用程序表现得相当不错。但是,cp.go更加灵活,因为它允许您定义缓冲区的大小。另一方面,如果您使用缓冲区大小较小(16 字节)的cp.go,那么整个过程将完全失败!此外,有趣的是readAll.go在处理相对较小的文件时表现相当不错,只有在复制5 GB文件时才会变慢,对于这样一个小程序来说并不糟糕:您可以将readAll.go视为一个快速而粗糙的解决方案!

在 Go 中开发 wc(1)

wc.go程序的代码的主要思想是,您可以逐行读取文本文件,直到没有内容可读为止。对于每行读取的内容,您可以找出它包含的字符数和单词数。由于需要逐行读取输入,因此最好使用bufio而不是普通的io,因为它可以简化代码。但是,尝试使用io自己实现wc.go将是一个非常有教育意义的练习。

但首先,您将看到wc(1)实用程序生成以下输出:

$ wc wc.go cp.go
      68     160    1231 wc.go
      45     112     755 cp.go
     113     272    1986 total

因此,如果wc(1)必须处理多个文件,它会自动生成摘要信息。

在第九章中,Goroutines - 基本特性,您将学习如何使用 Go routines 创建wc.go的版本。但是,两个版本的核心功能将完全相同!

计算单词

代码实现中最棘手的部分是单词计数,它使用了正则表达式:

r := regexp.MustCompile("[^\\s]+") 
for range r.FindAllString(line, -1) { 
numberOfWords++ 
} 

在这里,提供的正则表达式根据空白字符分隔行的单词,以便之后对它们进行计数!

wc.go 代码!

在这个小介绍之后,是时候看看wc.go的 Go 代码了,它将分为五个部分呈现。第一部分是预期的序言:

package main 

import ( 
   "bufio" 
   "flag" 
   "fmt" 
   "io" 
   "os" 
   "regexp" 
) 

第二部分是countLines()函数的实现,其中包括程序的核心功能。请注意,countLines()的命名可能不太合适,因为countLines()还会计算文件的单词和字符数:

func countLines(filename string) (int, int, int) { 
   var err error 
   var numberOfLines int 
   var numberOfCharacters int 
   var numberOfWords int 
   numberOfLines = 0

   numberOfCharacters = 0 
   numberOfWords = 0 

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening file %s", err) 
         os.Exit(1) 
   } 
   defer f.Close() 

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 

         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s", err)
                break 
         } 

         numberOfLines++ 
         r := regexp.MustCompile("[^\\s]+") 
         for range r.FindAllString(line, -1) { 
               numberOfWords++ 
         } 
         numberOfCharacters += len(line) 
   } 

   return numberOfLines, numberOfWords, numberOfCharacters 
} 

这里有很多有趣的事情。首先,您可以看到前一节中呈现的 Go 代码,用于计算每行的单词。计算行数很容易,因为每次bufio读取器读取新行时,numberOfLines变量的值就会增加一。ReadString()函数告诉程序读取输入直到第一个'\n'的出现:多次调用ReadString()意味着您正在逐行读取文件。

接下来,您可以看到countLines()函数返回三个整数值。最后,计算字符数是通过len()函数实现的,该函数返回给定字符串中的字符数,在这种情况下是读取的行。for循环在获得io.EOF错误消息时终止,这表示从输入文件中没有剩余内容可读取。

wc.go的第三部分从main()函数的实现开始,其中还包括flag包的配置:

func main() { 
   minusC := flag.Bool("c", false, "Characters") 
   minusW := flag.Bool("w", false, "Words") 
   minusL := flag.Bool("l", false, "Lines") 

   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 { 
         fmt.Printf("usage: wc <file1> [<file2> [... <fileN]]\n") 
         os.Exit(1) 
   } 

   totalLines := 0 
   totalWords := 0 
   totalCharacters := 0 
   printAll := false 

   for _, filename := range flag.Args() { 

最后的for语句用于处理程序给定的所有输入文件。wc.go程序支持三个标志:-c标志用于打印字符计数,-w标志用于打印单词计数,-l标志用于打印行计数。

第四部分是以下内容:

         numberOfLines, numberOfWords, numberOfCharacters := countLines(filename) 

         totalLines = totalLines + numberOfLines 
         totalWords = totalWords + numberOfWords 
         totalCharacters = totalCharacters + numberOfCharacters 

         if (*minusC && *minusW && *minusL) || (!*minusC && !*minusW && !*minusL) { 
               fmt.Printf("%d", numberOfLines) 
               fmt.Printf("\t%d", numberOfWords) 
               fmt.Printf("\t%d", numberOfCharacters) 
               fmt.Printf("\t%s\n", filename) 
               printAll = true 
               continue 
         } 

         if *minusL { 
               fmt.Printf("%d", numberOfLines) 
         } 

         if *minusW { 
               fmt.Printf("\t%d", numberOfWords) 
         } 

         if *minusC { 
               fmt.Printf("\t%d", numberOfCharacters) 
         } 

         fmt.Printf("\t%s\n", filename) 
   } 

这部分涉及根据命令行标志在每个文件基础上打印信息。正如您所看到的,这里的大部分 Go 代码是用于根据命令行标志处理输出。

最后一部分是以下内容:

   if (len(flags) != 1) && printAll { 
         fmt.Printf("%d", totalLines) 
         fmt.Printf("\t%d", totalWords) 
         fmt.Printf("\t%d", totalCharacters) 
         fmt.Println("\ttotal") 
return 
   } 

   if (len(flags) != 1) && *minusL { 
         fmt.Printf("%d", totalLines) 
   } 

   if (len(flags) != 1) && *minusW { 
         fmt.Printf("\t%d", totalWords) 
   } 

   if (len(flags) != 1) && *minusC { 
         fmt.Printf("\t%d", totalCharacters) 
   } 

   if len(flags) != 1 { 
         fmt.Printf("\ttotal\n") 
   } 
} 

这是您根据程序的标志打印的总行数、单词数和字符数。再次强调,这里的大部分 Go 代码是用于根据命令行标志修改输出。

执行wc.go将生成以下输出:

$ go build wc.go
$ ls -l wc
-rwxr-xr-x  1 mtsouk  staff  2264384 Apr 29 21:10 wc
$ ./wc wc.go sparse.go notGoodCP.go
120   280   2319  wc.go
44    98    697   sparse.go
27    61    418   notGoodCP.go
191   439   3434  total
$ ./wc -l wc.go sparse.go
120   wc.go
44    sparse.go
164   total
$ ./wc -w -l wc.go sparse.go
120   280   wc.go
44    98    sparse.go
164   378   total

这里有一个微妙的地方:使用 Go 源文件作为go run wc.go命令的命令行参数将失败。这将发生是因为编译器将尝试编译 Go 源文件,而不是将它们视为go run wc.go命令的命令行参数。以下输出证明了这一点:

$ go run wc.go sparse.go
# command-line-arguments
./sparse.go:11: main redeclared in this block
      previous declaration at ./wc.go:49
$ go run wc.go wc.go
package main: case-insensitive file name collision:
"wc.go" and "wc.go"
$ go run wc.go cp.go sparse.go
# command-line-arguments
./cp.go:35: main redeclared in this block
      previous declaration at ./wc.go:49
./sparse.go:11: main redeclared in this block
      previous declaration at ./cp.go:35

此外,尝试在 Go 版本 1.3.3 的 Linux 系统上执行wc.go将失败,并显示以下错误消息:

$ go version
go version go1.3.3 linux/amd64
$ go run wc.go
# command-line-arguments
./wc.go:40: syntax error: unexpected range, expecting {
./wc.go:46: non-declaration statement outside function body
./wc.go:47: syntax error: unexpected }

比较wc.gowc(1)的性能

在这一小节中,我们将比较我们版本的wc(1)与 macOS Sierra 10.12.6 自带的wc(1)的性能。首先,我们将执行wc.go

$ file wc
wc: Mach-O 64-bit executable x86_64
$ time ./wc *.data
672320      3361604     9413057     connections.data
269123      807369      4157790     diskSpace.data
672040      1344080     8376070     memory.data
1344533     2689066     5378132     pageFaults.data
269465      792715      4068250     uptime.data
3227481     8994834     31393299    total

real  0m17.467s
user  0m22.164s
sys   0m3.885s

然后,我们将执行 macOS 版本的wc(1)来处理相同的文件:

$ file `which wc`
/usr/bin/wc: Mach-O 64-bit executable x86_64
$ time wc *.data
672320 3361604 9413057 connections.data
269123  807369 4157790 diskSpace.data
672040 1344080 8376070 memory.data
1344533 2689066 5378132 pageFaults.data
269465  792715 4068250 uptime.data
3227481 8994834 31393299 total

real  0m0.086s
user  0m0.076s
sys   0m0.007s

让我们先看看好消息;这两个实用程序生成了完全相同的输出,这意味着我们的wc(1)的 Go 版本效果很好,可以处理大型文本文件!

现在,坏消息是,wc.go很慢!wc(1)处理所有五个文件不到一秒,而wc.go执行相同的任务几乎需要 18 秒!

在开发任何类型的软件时,无论在任何平台上,使用任何编程语言,一般的想法是,您应该在尝试优化之前先尝试拥有一个没有错误的工作版本,而不是反过来!

逐个字符读取文本文件

尽管不需要逐个字符读取文本文件来开发wc(1)实用程序,但了解如何在 Go 中实现它是很好的。文件的名称将是charByChar.go,并将分为四部分呈现。

第一部分是以下 Go 代码:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io/ioutil" 
   "os" 
   "strings" 
) 

尽管charByChar.go的 Go 代码行数不多,但它需要大量的 Go 标准包,这是一个天真的迹象,表明它实现的任务并不是微不足道的。第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(1) 
   } 
   input := arguments[1] 

第三部分是以下内容:

   buf, err := ioutil.ReadFile(input) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 

最后一部分包含以下 Go 代码:

   in := string(buf) 
   s := bufio.NewScanner(strings.NewReader(in)) 
   s.Split(bufio.ScanRunes) 

   for s.Scan() { 
         fmt.Print(s.Text()) 
   } 
} 

在这里,ScanRunes是一个分割函数,它将每个字符(rune)作为一个标记返回。然后,调用Scan()允许我们逐个处理每个字符。还有ScanWordsScanLines用于分别获取单词和行。如果您在程序的最后一条语句中使用fmt.Println(s.Text())而不是fmt.Print(s.Text()),那么每个字符将被单独打印在自己的行上,程序的任务也将更加明显。

执行charByChar.go将生成以下输出:

$ go run charByChar.go test
package main
...

wc(1)命令可以通过比较输入文件和charByChar.go生成的输出来验证charByChar.go的 Go 代码的正确性:

$ go run charByChar.go test | wc
      32      54     439
$ wc test
      32      54     439 test

进行一些文件编辑!

本节将介绍一个将文件中的制表符转换为空格字符,反之亦然的 Go 程序!这通常是文本编辑器的工作,但了解如何自行执行此操作也是很好的。

代码将保存在tabSpace.go中,并将分为四部分呈现。

请注意,tabSpace.go按行读取文本文件,但您也可以开发一个按字符读取文本文件的版本。

在当前的实现中,所有工作都是通过正则表达式、模式匹配和搜索替换操作完成的。

第一部分是预期的序言:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "strings" 
) 

第二部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 3 { 
         fmt.Printf("Usage: %s [-t|-s] filename!\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 
   convertTabs := false 
   convertSpaces := false 
   newLine := "" 

   option := os.Args[1] 
   filename := os.Args[2] 
   if option == "-t" { 
         convertTabs = true 
   } else if option == "-s" { 
         convertSpaces = true 
   } else { 
         fmt.Println("Unknown option!") 
         os.Exit(1) 
   } 

第三部分包含以下 Go 代码:

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening %s: %s", filename, err) 
         os.Exit(1) 
   } 
   defer f.Close() 

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 

         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s", err) 
               os.Exit(1) 
         } 

最后一部分如下:

         if convertTabs == true { 
               newLine = strings.Replace(line, "\t", "    ", -1) 
         } else if convertSpaces == true { 
               newLine = strings.Replace(line, "    ", "\t", -1) 
         } 

         fmt.Print(newLine) 
   } 
} 

这部分是使用适当的strings.Replace()调用发生魔术的地方。在当前的实现中,每个制表符都被四个空格字符替换,反之亦然,但您可以通过修改 Go 代码来更改这一点。

再次,tabSpace.go的很大一部分涉及错误处理,因为当您尝试打开文件进行读取时,可能会发生许多奇怪的事情!

根据 Unix 哲学,tabSpace.go的输出将打印在屏幕上,并且不会保存在新的文本文件中。使用tabSpace.gowc(1)可以证明其正确性:

$ go run tabSpace.go -t cp.go > convert
$ wc convert cp.go
    76     192    1517 convert 
      76     192    1286 cp.go
     152     384    2803 total
$ go run tabSpace.go -s convert | wc
      76     192    1286

进程间通信

进程间通信IPC),简单地说,就是允许 Unix 进程相互通信。存在各种技术允许进程和程序相互通信。在 Unix 系统中使用最广泛的技术是管道,自早期 Unix 以来就存在。第八章,进程和信号,将更多地讨论如何在 Go 中实现 Unix 管道。另一种 IPC 形式是 Unix 域套接字,也将在第八章,进程和信号中讨论。

第十二章,网络编程,将讨论另一种进程间通信形式,即网络套接字。共享内存也存在,但 Go 反对使用共享内存作为通信手段。第九章,Goroutines - 基本特性,和第十章,Goroutines - 高级特性,将展示允许 goroutines 与其他 goroutines 通信并共享和交换数据的各种技术。

Go 中的稀疏文件

使用os.Seek()函数创建的大文件可能存在空洞,并且占用的磁盘块比没有空洞的相同大小的文件少;这样的文件称为稀疏文件。本节将开发一个创建稀疏文件的程序。

sparse.go的 Go 代码将分为三部分呈现。第一部分如下:

package main 

import ( 
   "fmt" 
   "log" 
   "os" 
   "path/filepath" 
   "strconv" 
) 

sparse.go的第二部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 3 { 
         fmt.Printf("usage: %s SIZE filename\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   SIZE, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   filename := os.Args[2] 

   _, err := os.Stat(filename) 
   if err == nil { 
         fmt.Printf("File %s already exists.\n", filename) 
         os.Exit(1) 
   } 

strconv.ParseInt()函数用于将定义稀疏文件大小的命令行参数从其字符串值转换为其整数值。此外,os.Stat()调用确保您不会意外覆盖现有文件。

最后一部分是发生操作的地方:

   fd, err := os.Create(filename) 
   if err != nil { 
         log.Fatal("Failed to create output") 
   } 

   _, err = fd.Seek(SIZE-1, 0) 
   if err != nil { 
         fmt.Println(err) 
         log.Fatal("Failed to seek") 
   } 

   _, err = fd.Write([]byte{0}) 
   if err != nil { 
         fmt.Println(err) 
         log.Fatal("Write operation failed") 
   } 

   err = fd.Close() 
   if err != nil { 
         fmt.Println(err) 
         log.Fatal("Failed to close file") 
   } 
} 

首先,您尝试使用os.Create()创建所需的稀疏文件。然后,您调用fd.Seek()以使文件变大而不添加实际数据。最后,您使用fd.Write()向其写入一个字节。由于您没有其他事情要做,因此调用fd.Close(),完成操作。

执行sparse.go会生成以下输出:

$ go run sparse.go 1000 test
$ go run sparse.go 1000 test
File test already exists.
exit status 1

您如何判断文件是否是稀疏文件?您将在一会儿学到这一点,但首先让我们创建一些文件:

$ go run sparse.go 100000 testSparse $ dd if=/dev/urandom  bs=1 count=100000 of=noSparseDD
100000+0 records in
100000+0 records out
100000 bytes (100 kB) copied, 0.152511 s, 656 kB/s
$ dd if=/dev/urandom seek=100000 bs=1 count=0 of=sparseDD
0+0 records in
0+0 records out
0 bytes (0 B) copied, 0.000159399 s, 0.0 kB/s
$ ls -l noSparseDD sparseDD testSparse
-rw-r--r-- 1 mtsouk mtsouk 100000 Apr 29 21:43 noSparseDD
-rw-r--r-- 1 mtsouk mtsouk 100000 Apr 29 21:43 sparseDD
-rw-r--r-- 1 mtsouk mtsouk 100000 Apr 29 21:40 testSparse

请注意,某些 Unix 变体将不会创建稀疏文件:我想到的第一个这样的 Unix 变体是使用 HFS 文件系统的 macOS。因此,为了获得更好的结果,您可以在 Linux 机器上执行所有这些命令。

那么,您如何判断这三个文件中的任何一个是否是稀疏文件?ls(1)实用程序的-s标志显示文件实际使用的文件系统块数。因此,ls -ls命令的输出允许您检测是否正在处理稀疏文件:

$ ls -ls noSparseDD sparseDD testSparse
104 -rw-r--r-- 1 mtsouk mtsouk 100000 Apr 29 21:43 noSparseDD
      0 -rw-r--r-- 1 mtsouk mtsouk 100000 Apr 29 21:43 sparseDD
      8 -rw-r--r-- 1 mtsouk mtsouk 100000 Apr 29 21:40 testSparse

现在看输出的第一列。使用dd(1)实用程序生成的noSparseDD文件不是稀疏文件。sparseDD文件是使用dd(1)实用程序生成的稀疏文件。最后,testSparse也是使用sparse.go创建的稀疏文件。

读取和写入数据记录

本节将教你如何处理写入和读取数据记录。记录与其他类型的文本数据的不同之处在于记录具有特定数量的字段的给定结构:可以将其视为关系数据库中的表中的一行。实际上,如果你想在 Go 中开发自己的数据库服务器,记录可以非常有用来在表中存储数据!

records.go的 Go 代码将以 CSV 格式保存数据,并将分为四个部分呈现。第一部分包含以下 Go 代码:

package main 

import ( 
   "encoding/csv" 
   "fmt" 
   "os" 
) 

因此,这是你必须声明你要以 CSV 格式读取或写入数据的地方。第二部分如下:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Need just one filename!") 
         os.Exit(-1) 
   } 

   filename := os.Args[1] 
   _, err := os.Stat(filename) 
   if err == nil { 
         fmt.Printf("File %s already exists.\n", filename) 
         os.Exit(1) 
   } 

程序的第三部分如下:

   output, err := os.Create(filename) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(-1) 
   } 
   defer output.Close() 

   inputData := [][]string{{"M", "T", "I."}, {"D", "T", "I."}, 
{"M", "T", "D."}, {"V", "T", "D."}, {"A", "T", "D."}} 
   writer := csv.NewWriter(output) 
   for _, record := range inputData { 
         err := writer.Write(record) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(-1) 
         } 
   } 
   writer.Flush() 

你应该熟悉这部分的操作;与本章迄今为止所见的最大不同之处在于写入器来自csv包。

records.go的最后一部分包含以下 Go 代码:

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer f.Close() 

   reader := csv.NewReader(f) 
   reader.FieldsPerRecord = -1 
   allRecords, err := reader.ReadAll() 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 

   for _, rec := range allRecords { 
         fmt.Printf("%s:%s:%s\n", rec[0], rec[1], rec[2]) 
   } 
} 

reader一次性读取整个文件,以使整个操作更快。但是,如果你处理大型数据文件,你可能需要每次读取文件的较小部分,直到你读取完整个文件。使用的reader来自csv包。

执行records.go将创建以下输出:

$ go run records.go recordsDataFile
M:T:I. 
D:T:I.
M:T:D.
V:T:D.
A:T:D.
$ ls -l recordsDataFile
-rw-r--r--  1 mtsouk  staff  35 May  2 19:20 recordsDataFile

名为recordsDataFile的 CSV 文件包含以下数据:

$ cat recordsDataFile
M,T,I.
D,T,I.
M,T,D.
V,T,D.
A,T,D.

Go 中的文件锁定

有时,你不希望同一进程的任何其他子进程更改文件甚至访问文件,因为你正在更改其数据,你不希望其他进程读取不完整或不一致的数据。尽管你将在第九章和第十章中学到更多关于文件锁定和 go 例程的知识,Goroutines - 基本特性Goroutines - 高级特性,但本章将呈现一个简单的 Go 示例,没有详细的解释,以便让你了解事情是如何工作的:你应该等到第九章和第十章中学到更多。

所呈现的技术将使用Mutex,这是一种通用的同步机制。Mutex锁将允许我们从同一 Go 进程中锁定文件。因此,这种技术与使用flock(2)系统调用无关。

存在各种文件锁定技术。其中之一是通过创建一个额外的文件来表示另一个程序或进程正在使用给定的资源。所呈现的技术更适用于使用多个 go 例程的程序。

写入的文件锁定技术将在fileLocking.go中进行演示,该文件将分为四个部分呈现。第一部分如下:

package main 

import ( 
   "fmt" 
   "math/rand" 
   "os" 
   "sync" 
   "time" 
) 

var mu sync.Mutex 

func random(min, max int) int { 
   return rand.Intn(max-min) + min 
} 

第二部分如下:

func writeDataToFile(i int, file *os.File, w *sync.WaitGroup) { 
   mu.Lock() 
   time.Sleep(time.Duration(random(10, 1000)) * time.Millisecond) 
   fmt.Fprintf(file, "From %d, writing %d\n", i, 2*i) 
   fmt.Printf("Wrote from %d\n", i) 
   w.Done() 
mu.Unlock() 
} 

使用mu.Lock()语句锁定文件,使用mu.Unlock()语句解锁文件。

第三部分如下:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Println("Please provide one command line argument!") 
         os.Exit(-1) 
   } 

   filename := os.Args[1] 
   number := 3 

   file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 

最后一部分是以下 Go 代码:

   var w *sync.WaitGroup = new(sync.WaitGroup) 
   w.Add(number) 

   for r := 0; r < number; r++ { 
         go writeDataToFile(r, file, w) 
   } 

   w.Wait() 
} 

执行fileLocking.go将创建以下输出:

$ go run fileLocking.go 123
Wrote from 0
Wrote from 2
Wrote from 1
$ cat /tmp/swtag.log
From 0, writing 0
From 2, writing 4
From 1, writing 2

正确的fileLocking.gowriteDataToFile()函数的末尾调用了mu.Unlock(),这允许所有 goroutines 使用该文件。如果你从writeDataToFile()函数中删除对mu.Unlock()的调用,并执行fileLocking.go,你将得到以下输出:

$ go run fileLocking.go 123
Wrote from 2
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc42001024c)
      /usr/local/Cellar/go/1.8.1/libexec/src/runtime/sema.go:47 +0x34
sync.(*WaitGroup).Wait(0xc420010240)
      /usr/local/Cellar/go/1.8.1/libexec/src/sync/waitgroup.go:131 +0x7a
main.main()
     /Users/mtsouk/Desktop/goBook/ch/ch6/code/fileLocking.go:47 +0x172

goroutine 5 [semacquire]:
sync.runtime_SemacquireMutex(0x112dcbc)
     /usr/local/Cellar/go/1.8.1/libexec/src/runtime/sema.go:62 +0x34
sync.(*Mutex).Lock(0x112dcb8)
      /usr/local/Cellar/go/1.8.1/libexec/src/sync/mutex.go:87 +0x9d
main.writeDataToFile(0x0, 0xc42000c028, 0xc420010240)
      /Users/mtsouk/Desktop/goBook/ch/ch6/code/fileLocking.go:18 +0x3f
created by main.main
      /Users/mtsouk/Desktop/goBook/ch/ch6/code/fileLocking.go:44 +0x151

goroutine 6 [semacquire]:
sync.runtime_SemacquireMutex(0x112dcbc)
      /usr/local/Cellar/go/1.8.1/libexec/src/runtime/sema.go:62 +0x34
sync.(*Mutex).Lock(0x112dcb8)
      /usr/local/Cellar/go/1.8.1/libexec/src/sync/mutex.go:87 +0x9d
main.writeDataToFile(0x1, 0xc42000c028, 0xc420010240)
      /Users/mtsouk/Desktop/goBook/ch/ch6/code/fileLocking.go:18 +0x3f
created by main.main
    /Users/mtsouk/Desktop/goBook/ch/ch6/code/fileLocking.go:44 +0x151 exit status 2
$ cat 123
From 2, writing 4

得到这个输出的原因是,除了第一个 goroutine 能够执行mu.Lock()语句之外,其他的都无法获得Mutex。因此,它们无法写入文件,这意味着它们永远无法完成工作并永远等待,这就是 Go 生成上述错误消息的原因。

如果您对这个例子不完全理解,您应该等到第九章,“Goroutines - Basic Features”和第十章,“Goroutines - Advanced Features”。

dd实用程序的简化版 Go 版本

dd(1)工具可以做很多事情,但本节将实现其功能的一小部分。我们的dd(1)版本将包括对两个命令行标志的支持:一个用于指定以字节为单位的块大小(-bs),另一个用于指定将要写入的块的总数(-count)。将这两个值相乘将给出生成文件的大小(以字节为单位)。

Go 代码保存为ddGo.go,将分为四部分呈现给您。第一部分是预期的序言:

package main 

import ( 
   "flag" 
   "fmt" 
   "math/rand" 
   "os" 
   "time" 
) 

第二部分包含两个函数的 Go 代码:

func random(min, max int) int { 
   return rand.Intn(max-min) + min 
} 

func createBytes(buf *[]byte, count int) { 
   if count == 0 { 
         return 
   } 
   for i := 0; i < count; i++ { 
         intByte := byte(random(0, 9)) 
         *buf = append(*buf, intByte) 
   } 
} 

第一个函数是用于获取随机数的,第二个函数是用于创建一个带有所需大小的随机数填充的字节片。

ddGo.go的第三部分如下:

func main() { 
   minusBS := flag.Int("bs", 0, "Block Size") 
   minusCOUNT := flag.Int("count", 0, "Counter") 
   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(-1) 
   } 

   if *minusBS < 0 || *minusCOUNT < 0 { 
         fmt.Println("Count or/and Byte Size < 0!") 
         os.Exit(-1) 
   } 

   filename := flags[0] 
   rand.Seed(time.Now().Unix()) 

   _, err := os.Stat(filename) 
   if err == nil { 
         fmt.Printf("File %s already exists.\n", filename) 
         os.Exit(1) 
   } 

   destination, err := os.Create(filename) 
   if err != nil { 
         fmt.Println("os.Create:", err) 
         os.Exit(1) 
   } 

在这里,您主要处理程序的命令行参数。

最后一部分如下:

   buf := make([]byte, *minusBS) 
   buf = nil 
   for i := 0; i < *minusCOUNT; i++ { 
         createBytes(&buf, *minusBS) 
         if _, err := destination.Write(buf); err != nil { 
               fmt.Println(err) 
               os.Exit(-1) 
         } 
         buf = nil 
   } 
} 

每次调用createBytes()时清空buf字节片的原因是,您不希望buf字节片每次调用createBytes()函数时都变得越来越大。这是因为append()函数会在不触及现有数据的情况下在切片的末尾添加数据。

在我写的ddGo.go的第一个版本中,我忘记在每次调用createBytes()之前清空buf字节片。因此,生成的文件比预期的要大!我花了一段时间和几个fmt.Println(buf)语句才找到这种意外行为的原因!

执行ddGo.go将快速生成您想要的文件:

$ time go run ddGo.go -bs=10000 -count=5000 test3

real  0m1.655s
user  0m1.576s
sys   0m0.104s
$ ls -l test3
-rw-r--r--  1 mtsouk  staff  50000000 May  6 15:27 test3

此外,使用随机数使得生成的文件大小彼此不同:

$ go run ddGo.go -bs=100 -count=50 test1
$ go run ddGo.go -bs=100 -count=50 test2
$ ls -l test1 test2
-rw-r--r--  1 mtsouk  staff  5000 May  6 15:26 test1
-rw-r--r--  1 mtsouk  staff  5000 May  6 15:26 test2
$ diff test1 test2
Binary files test1 and test2 differ

练习

  1. 访问golang.org/pkg/bufio/上的bufio包的文档页面。

  2. 访问golang.org/pkg/io/上的io包的文档。

  3. 尝试使wc.go更快。

  4. 实现tabSpace.go的功能,但尝试逐个字符而不是逐行读取输入文本文件。

  5. 更改tabSpace.go的代码,以便能够将替换制表符的空格数作为命令行参数。

  6. 了解有关小端和大端表示的更多信息。

总结

在本章中,我们讨论了 Go 中的文件输入和输出。在其他事情中,我们开发了wc(1)dd(1)cp(1) Unix 命令行实用程序的 Go 版本,同时更多地了解了 Go 标准库的iobufio包,它们允许您从文件中读取和写入。

在下一章中,我们将讨论另一个重要主题,即 Go 如何处理 Unix 机器的系统文件的方式。此外,您将学习如何读取和更改 Unix 文件权限,以及如何找到文件的所有者和组。此外,我们将讨论日志文件以及如何使用模式匹配从日志文件中获取所需的信息。

第七章:处理系统文件

在上一章中,我们讨论了在 Go 中的文件输入和输出,并创建了wc(1)dd(1)cp(1)实用程序的 Go 版本。

虽然本章的主要主题是 Unix 系统文件和日志文件,但你还将学到许多其他内容,包括模式匹配、文件权限、与用户和组的工作,以及在 Go 中处理日期和时间。对于所有这些主题,你将看到方便的 Go 代码,这些代码将解释所呈现的技术,并且可以在你自己的 Go 程序中使用,而不需要太多更改。

因此,本章将讨论以下主题:

  • 向现有文件追加数据

  • 读取文件并修改每一行

  • 在 Go 中进行正则表达式和模式匹配

  • 将信息发送到 Unix 日志文件

  • 在 Go 中处理日期和时间

  • 处理 Unix 文件权限

  • 处理用户 ID 和组 ID

  • 了解有关文件和目录的更多信息

  • 处理日志文件并从中提取有用信息

  • 使用随机数生成难以猜测的密码

哪些文件被视为系统文件?

每个 Unix 操作系统都包含负责系统配置和各种服务的文件。大多数这些文件位于/etc目录中。我也喜欢将日志文件视为系统文件,尽管有些人可能不同意。通常,大多数系统日志文件可以在/var/log目录中找到。然而,Apache 和 nginx web 服务器的日志文件可能会根据其配置而放在其他位置。

在 Go 中记录

log包提供了在 Unix 机器上记录信息的一般方法,而log/syslog Go 包允许你使用所需的日志级别和日志设施将信息发送到系统日志服务。此外,time包可以帮助你处理日期和时间。

将数据放在文件末尾

如第六章中所讨论的文件输入和输出,在本章中,我们将讨论如何在不破坏现有数据的情况下打开文件进行写入。

将演示技术的 Go 程序appendData.go将接受两个命令行参数:要追加的消息和将存储文本的文件名。这个程序将分为三部分呈现。

appendData.go的第一部分包含以下 Go 代码:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
) 

如预期的那样,程序的第一部分包含将在程序中使用的 Go 包。

第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) != 3 { 
         fmt.Printf("usage: %s message filename\n", filepath.Base(arguments[0])) 
         os.Exit(1) 
   } 
   message := arguments[1] 
   filename := arguments[2] 

   f, err := os.OpenFile(filename, 
os.O_RDWR|os.O_APPEND|os.O_CREATE, 0660) 

os.OpenFile()函数的os.O_APPEND标志告诉 Go 在文件末尾进行写入。此外,os.O_CREATE标志将使os.OpenFile()在文件不存在时创建文件,这非常方便,因为它可以避免你编写测试文件是否已存在的 Go 代码。

程序的最后一部分如下:

   if err != nil { 
         fmt.Println(err) 
         os.Exit(-1) 
   } 
   defer f.Close() 

   fmt.Fprintf(f, "%s\n", message) 
} 

fmt.Fprintf()函数在这里用于将消息以纯文本形式写入文件。正如你所看到的,appendData.go是一个相对较小的 Go 程序,没有任何意外。

执行appendData.go不会产生输出,但它会完成它的工作,你可以从appendData.go执行前后的cat(1)实用程序的输出中看到这一点:

$ cat test
[test]: test
: test
$ go run appendData.go test test
$ cat test
[test]: test
: test
test 

修改现有数据

本节将教你如何修改文件的内容。将开发的程序将完成一个非常方便的工作:在文本文件的每一行前添加行号。这意味着你需要逐行读取输入文件,保持一个变量来保存行号值,并使用原始名称保存它。此外,保存行号值的变量的初始值可以在启动程序时定义。Go 程序的名称将是insertLineNumber.go,它将分为四部分呈现。

首先,你会看到预期的序言:

package main 

import ( 
   "flag" 
   "fmt" 
   "io/ioutil" 
   "os" 
   "strings" 
) 

第二部分主要是flag包的配置:

func main() { 
   minusINIT := flag.Int("init", 1, "Initial Value") 
   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 { 
         fmt.Printf("usage: insertLineNumber <files>\n") 
         os.Exit(1) 
   } 

   lineNumber := *minusINIT
   for _, filename := range flags { 
         fmt.Println("Processing:", filename) 

lineNumber变量由minusINIT标志的值初始化。此外,该实用程序可以使用for循环处理多个文件。

程序的第三部分如下:

         input, err := ioutil.ReadFile(filename) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(-1) 
         } 

         lines := strings.Split(string(input), "\n") 

正如您所看到的,insertLineNumber.go使用ioutil.ReadFile()一次性读取其输入文件,当处理大型文本文件时可能效率不高。但是,使用今天的计算机,这不应该是问题。更好的方法是逐行读取输入文件,将每个更改后的行写入临时文件,然后用临时文件替换原始文件。

实用程序的最后部分如下:

         for i, line := range lines { 
               lines[i] = fmt.Sprintf("%d: %s ", lineNumber, line) 
               lineNumber = lineNumber + 1
         } 

         lines[len(lines)-1] = "" 
         output := strings.Join(lines, "\n") 
         err = ioutil.WriteFile(filename, []byte(output), 0644) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(-1) 
         } 
   } 
   fmt.Println("Processed", lineNumber-*minusINIT, "lines!") 
}

由于range循环会在文件末尾引入额外的一行,因此您必须使用lines[len(lines)-1] = ""语句删除行切片中的最后一行,这意味着程序假定它处理的所有文件都以换行符结尾。如果您的文本文件没有这样做,那么您可能需要更改insertLineNumber.go的代码或在文本文件末尾添加一个新行。

运行insertLineNumber.go除了处理的每个文件的文件名和处理的总行数之外,不会生成任何可见的输出。但是,您可以通过查看您处理的文件的内容来查看其执行的结果:

$ cat test
a

b
$ go run insertLineNumber.go -init=10 test
Processing: test
Processed 4 lines!
$ cat test
10: a
11:
12: b

如果尝试多次处理相同的输入文件,如以下示例,将会发生有趣的事情:

$ cat test
a

b
$ go run insertLineNumber.go -init=10 test test test
Processing: test
Processing: test
Processing: test
Processed 12 lines!
$ cat test
18: 14: 10: a
19: 15: 11:
20: 16: 12: b

关于日志文件

这部分将教您如何将信息从 Go 程序发送到日志服务,从而发送到系统日志文件。尽管保留信息很重要,但对于服务器进程来说,日志文件是必需的,因为服务器进程没有其他方式将信息发送到外部世界,因为它没有终端来发送任何输出。

日志文件很重要,您不应该低估其中存储的信息的价值。当 Unix 机器上发生奇怪的事情时,日志文件应该是寻求帮助的第一地方。

一般来说,使用日志文件比在屏幕上显示输出更好,原因有两个:首先,输出不会丢失,因为它存储在文件中;其次,您可以使用 Unix 工具(如grep(1)awk(1)sed(1))搜索和处理日志文件,而在终端窗口上打印消息时无法做到这一点。

关于日志记录

所有 Unix 机器都有一个单独的服务器进程用于记录日志文件。在 macOS 机器上,该进程的名称是syslogd(8)。另一方面,大多数 Linux 机器使用rsyslogd(8),这是syslogd(8)的改进和更可靠的版本,后者是用于消息记录的原始 Unix 系统实用程序。

然而,无论您使用的 Unix 变体是什么,或者用于记录日志的服务器进程的名称是什么,日志记录在每台 Unix 机器上的工作方式都是相同的,因此不会影响您将编写的 Go 代码。

观看一个或多个日志文件的最佳方法是使用tail(1)实用程序的帮助,后跟-f标志和您想要观看的日志文件的名称。-f标志告诉tail(1)等待额外的数据。您将需要通过按Ctrl + C来终止这样的tail(1)命令。

日志设施

日志设施就像用于记录信息的类别。日志设施部分的值可以是authauthprivcrondaemonkernlprmailmarknewssysloguserUUCPlocal0local1local2local3local4local5local6local7中的任何一个;这在/etc/syslog.conf/etc/rsyslog.conf或其他适当的文件中定义,具体取决于您的 Unix 机器上用于系统日志记录的服务器进程。这意味着如果未定义和处理日志设施,则您发送到其中的日志消息可能会丢失。

日志级别

日志级别优先级是指定日志条目严重性的值。存在各种日志级别,包括debuginfonoticewarningerrcritalertemerg,按严重性的相反顺序。

查看 Linux 机器的/etc/rsyslog.conf文件,了解如何控制日志设施和日志级别。

syslog Go 包

本小节将介绍一个在所有 Unix 机器上运行并以各种方式向日志服务发送数据的 Go 程序。程序的名称是useSyslog.go,将分为四个部分。

首先,您将看到预期的序言:

package main 

import ( 
   "fmt" 
   "log" 
   "log/syslog" 
   "os" 
   "path/filepath" 
) 

您必须使用log包进行日志记录,使用log/syslog包定义程序的日志设施和日志级别。

第二部分如下:

func main() { 
   programName := filepath.Base(os.Args[0]) 
   sysLog, e := syslog.New(syslog.LOG_INFO|syslog.LOG_LOCAL7, programName) 
   if e != nil { 
         log.Fatal(e) 
   } 
   sysLog.Crit("Crit: Logging in Go!") 

syslog.New()函数调用返回一个写入器,告诉您的程序将所有日志消息定向到何处。好消息是您已经知道如何使用写入器!

请注意,开发人员应定义程序使用的优先级和设施。

然而,即使有了定义的优先级和设施,log/syslog包也允许您使用诸如sysLog.Crit()之类的函数将直接日志消息发送到其他优先级。

程序的第三部分如下:

   sysLog, e = syslog.New(syslog.LOG_ALERT|syslog.LOG_LOCAL7, "Some program!") 
   if e != nil { 
         log.Fatal(sysLog) 
   } 
sysLog.Emerg("Emerg: Logging in Go!") 

这部分显示您可以在同一个程序中多次调用syslog.New()。再次调用Emerg()函数允许您绕过syslog.New()函数定义的内容。

最后一部分如下:

   fmt.Fprintf(sysLog, "log.Print: Logging in Go!") 
} 

这是唯一使用由syslog.New()定义的日志优先级和日志设施的调用,直接写入sysLog写入器。

执行useLog.go将在屏幕上生成一些输出,但也会将数据写入适当的日志文件。在 macOS Sierra 或 Mac OS X 机器上,您将看到以下内容:

$ go run useSyslog.go

Broadcast Message from _iconservices@iMac.local
        (no tty) at 18:01 EEST...

Emerg: Logging in Go!
$ grep "Logging in Go" /var/log/* 2>/dev/null
/var/log/system.log:May 19 18:01:31 iMac useSyslog[22608]: Crit: Logging in Go!
/var/log/system.log:May 19 18:01:31 iMac Some program![22608]: Emerg: Logging in Go!
/var/log/system.log:May 19 18:01:31 iMac Some program![22608]: log.Print: Logging in Go!

在 Debian Linux 机器上,您将看到以下结果:

$ go run useSyslog.go

Message from syslogd@mail at May 19 18:03:00 ...
Some program![1688]: Emerg: Logging in Go!
$
Broadcast message from systemd-journald@mail (Fri 2017-05-19 18:03:00 EEST):

useSyslog[1688]: Some program![1688]: Emerg: Logging in Go!
$ tail -5 /var/log/syslog
May 19 18:03:00 mail useSyslog[1688]: Crit: Logging in Go!
May 19 18:03:00 mail Some program![1688]: Emerg: Logging in Go!
May 19 18:03:00 mail Some program![1688]: log.Print: Logging in Go!
$ grep "Logging in Go" /var/log/* 2>/dev/null
/var/log/cisco.log:May 19 18:03:00 mail useSyslog[1688]: Crit: Logging in Go!
/var/log/cisco.log:May 19 18:03:00 mail Some program![1688]: Emerg: Logging in Go!
/var/log/cisco.log:May 19 18:03:00 mail Some program![1688]: log.Print: Logging in Go!
/var/log/syslog:May 19 18:03:00 mail useSyslog[1688]: Crit: Logging in Go!
/var/log/syslog:May 19 18:03:00 mail Some program![1688]: Emerg: Logging in Go!
/var/log/syslog:May 19 18:03:00 mail Some program![1688]: log.Print: Logging in Go!

两台机器的输出显示,Linux 机器具有不同的syslog配置,这就是useLog.go的消息也被写入/var/log/cisco.log的原因。

然而,您的主要关注点不应该是日志消息是否会被写入太多文件,而是您是否能够找到它们!

处理日志文件

本小节将处理一个包含客户端 IP 地址的日志文件,以创建它们的摘要。Go 文件的名称将是countIP.go,并将分为四个部分呈现。请注意,countIP.go需要两个参数:日志文件的名称和包含所需信息的字段。由于countIP.go不检查给定字段是否包含 IP 地址,因此如果删除其中的一些代码,它也可以用于其他类型的数据。

首先,您将看到程序的预期序言:

package main 

import ( 
   "bufio" 
   "flag" 
   "fmt" 
   "io" 
   "net" 
   "os" 
   "path/filepath" 
   "strings" 
) 

第二部分带有以下 Go 代码,这是main()函数实现的开始:

func main() { 
   minusCOL := flag.Int("COL", 1, "Column") 
   flag.Parse() 
   flags := flag.Args() 

   if len(flags) == 0 { 
         fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   column := *minusCOL 
   if column < 0 {
         fmt.Println("Invalid Column number!") 
         os.Exit(1) 
   } 

countIP.go实用程序使用flag包,可以处理多个文件。

程序的第三部分如下:

   myIPs := make(map[string]int) 
   for _, filename := range flags { 
         fmt.Println("\t\t", filename) 
         f, err := os.Open(filename) 
         if err != nil { 
               fmt.Printf("error opening file %s\n", err) 
               continue 
         } 
         defer f.Close() 

         r := bufio.NewReader(f) 
         for { 
               line, err := r.ReadString('\n') 

               if err == io.EOF { 
                     break 
               } else if err != nil { 
                     fmt.Printf("error reading file %s", err) 
                     continue 
               } 

每个输入文件都是逐行读取的,而myIPs映射变量用于保存每个 IP 地址的计数。

countIP.go的最后一部分如下:

               data := strings.Fields(line) 
               ip := data[column-1] 
               trial := net.ParseIP(ip) 
               if trial.To4() == nil { 
                     continue 
               } 

               _, ok := myIPs[ip] 
               if ok { 
                     myIPs[ip] = myIPs[ip] + 1 
               } else { 
                     myIPs[ip] = 1 
               } 
         } 
   } 

   for key, _ := range myIPs { 
         fmt.Printf("%s %d\n", key, myIPs[key]) 
   } 
} 

这里是魔术发生的地方:首先,您从工作行中提取所需的字段。然后,您使用net.ParseIP()函数确保您正在处理有效的 IP 地址:如果您希望程序处理其他类型的数据,应删除使用net.ParseIP()函数的 Go 代码。之后,根据当前 IP 地址是否可以在映射中找到,更新myIPs映射的内容:您在第二章,Go 编程中看到了该代码。最后,您在屏幕上打印myIPs映射的内容,完成!

执行countIP.go会生成以下输出:

$ go run countIP.go /tmp/log.1 /tmp/log.2
             /tmp/log.1
             /tmp/log.2
164.132.161.85 4
66.102.8.135 17
5.248.196.10 15
180.76.15.10 12
66.249.69.40 142
51.255.65.35 7
95.158.53.56 1
64.183.178.218 31
$ go run countIP.go /tmp/log.1 /tmp/log.2 | wc
    1297    2592   21266

然而,如果输出按每个 IP 地址关联的计数排序,将会更好,你可以很容易地通过sort(1)Unix 实用程序来实现:

$ go run countIP.go /tmp/log.1 /tmp/log.2 | sort -rn -k2
45.55.38.245 979
159.203.126.63 976
130.193.51.27 698
5.9.63.149 370
77.121.238.13 340
46.4.116.197 308
51.254.103.60 302
51.255.194.31 277
195.74.244.47 201
61.14.225.57 179
69.30.198.242 152
66.249.69.40 142
2.86.9.124 140
2.86.27.46 127
66.249.69.18 125

如果你想要前 10 个 IP 地址,你可以使用head(1)实用程序过滤前面的输出,如下所示:

$ go run countIP.go /tmp/log.1 /tmp/log.2 | sort -rn -k2 | head
45.55.38.245 979
159.203.126.63 976
130.193.51.27 698
5.9.63.149 370
77.121.238.13 340
46.4.116.197 308
51.254.103.60 302
51.255.194.31 277
195.74.244.47 201
61.14.225.57 179

文件权限重访

有时我们需要查找文件的 Unix 权限的详细信息。filePerm.go Go 实用程序将教你如何读取文件或目录的 Unix 文件权限,并将其打印为二进制数、十进制数和字符串。该程序将分为三部分。第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
) 

第二部分如下:

func tripletToBinary(triplet string) string { 
   if triplet == "rwx" { 
         return "111" 
   } 
   if triplet == "-wx" { 
         return "011" 
   } 
   if triplet == "--x" { 
         return "001" 
   } 
   if triplet == "---" { 
         return "000" 
   } 
   if triplet == "r-x" { 
         return "101" 
   } 
   if triplet == "r--" { 
         return "100" 
   } 
   if triplet == "--x" { 
         return "001" 
   } 
   if triplet == "rw-" { 
         return "110" 
   } 
   if triplet == "-w-" { 
         return "010" 
   } 
   return "unknown" 
} 

func convertToBinary(permissions string) string { 
   binaryPermissions := permissions[1:] 
   p1 := binaryPermissions[0:3] 
   p2 := binaryPermissions[3:6] 
   p3 := binaryPermissions[6:9] 
   return tripletToBinary(p1) + tripletToBinary(p2) + tripletToBinary(p3) 
} 

在这里,你实现了两个函数,它们将帮助你将一个包含文件权限的九个字符的字符串转换为一个二进制数。例如,rwxr-x---字符串将被转换为111101000。初始字符串是从os.Stat()函数调用中提取的。

最后一部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Printf("usage: %s filename\n", filepath.Base(arguments[0])) 
         os.Exit(1) 
   } 

   filename := arguments[1] 
   info, _ := os.Stat(filename) 
   mode := info.Mode() 

   fmt.Println(filename, "mode is", mode) 
   fmt.Println("As string is", mode.String()[1:10]) 
   fmt.Println("As binary is", convertToBinary(mode.String())) 
} 

执行filePerm.go将生成以下输出:

$ go run filePerm.go .
. mode is drwxr-xr-x
As string is rwxr-xr-x
As binary is 111101101
$ go run filePerm.go /tmp/swtag.log
/tmp/swtag.log mode is -rw-rw-rw-
As string is rw-rw-rw-
As binary is 110110110

更改文件权限

本节将解释如何将文件或目录的 Unix 权限更改为所需的值;但是,它不会处理粘性位、设置用户 ID 位或设置组 ID 位:不是因为它们难以实现,而是因为在处理系统文件时通常不需要这些功能。

该实用程序的名称将是setFilePerm.go,它将分为四个部分呈现。新的文件权限将以rwxrw-rw-等九个字符的字符串形式给出。

setFilePerm.go的第一部分包含了预期的前言 Go 代码:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
   "strconv" 
) 

第二部分是tripletToBinary()函数的实现,你在上一节中看到了:

func tripletToBinary(triplet string) string { 
   if triplet == "rwx" { 
         return "111" 
   } 
   if triplet == "-wx" { 
         return "011" 
   } 
   if triplet == "--x" { 
         return "001" 
   } 
   if triplet == "---" { 
         return "000" 
   } 
   if triplet == "r-x" { 
         return "101" 
   } 
   if triplet == "r--" { 
         return "100" 
   } 
   if triplet == "--x" { 
         return "001" 
   } 
   if triplet == "rw-" { 
         return "110" 
   } 
   if triplet == "-w-" { 
         return "010" 
   } 
   return "unknown" 
} 

第三部分包含以下 Go 代码:

func convertToBinary(permissions string) string { 
   p1 := permissions[0:3] 
   p2 := permissions[3:6] 
   p3 := permissions[6:9] 

   p1 = tripletToBinary(p1) 
   p2 = tripletToBinary(p2) 
   p3 = tripletToBinary(p3) 

   p1Int, _ := strconv.ParseInt(p1, 2, 64) 
   p2Int, _ := strconv.ParseInt(p2, 2, 64) 
   p3Int, _ := strconv.ParseInt(p3, 2, 64) 

   returnValue := p1Int*100 + p2Int*10 + p3Int 
   tempReturnValue := int(returnValue) 
   returnString := "0" + strconv.Itoa(tempReturnValue) 
   return returnString 
} 

在这里,函数的名称是误导性的,因为它并不返回一个二进制数:这是我的错。

最后一部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) != 3 { 
         fmt.Printf("usage: %s filename permissions\n",  
filepath.Base(arguments[0])) 
         os.Exit(1) 
   } 

   filename, _ := filepath.EvalSymlinks(arguments[1]) 
   permissions := arguments[2] 
   if len(permissions) != 9 { 
         fmt.Println("Permissions should be 9 characters  
(rwxrwxrwx):", permissions) 
         os.Exit(-1) 
   } 

   bin := convertToBinary(permissions) 
   newPerms, _ := strconv.ParseUint(bin, 0, 32) 
   newMode := os.FileMode(newPerms) 
   os.Chmod(filename, newMode) 
} 

在这里,你获取convertToBinary()的返回值,并将其转换为os.FileMode()变量,以便与os.Chmod()函数一起使用。

运行setFilePerm.go将生成以下结果:

$ go run setFilePerm.go /tmp/swtag.log rwxrwxrwx
$ ls -l /tmp/swtag.log
-rwxrwxrwx  1 mtsouk  wheel  7066 May 22 19:17 /tmp/swtag.log
$ go run setFilePerm.go /tmp/swtag.log rwxrwx---
$ ls -l /tmp/swtag.log
-rwxrwx---  1 mtsouk  wheel  7066 May 22 19:17 /tmp/swtag.log

查找文件的其他信息

Unix 文件的最重要信息是它的所有者和它的组,本节将教你如何使用 Go 代码找到它们。findOG.go实用程序接受文件列表作为其命令行参数,并返回每个文件的所有者和组。它的 Go 代码将分为三部分。

第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
   "syscall" 
) 

第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Printf("usage: %s <files>\n", filepath.Base(arguments[0])) 
         os.Exit(1) 
   } 

   for _, filename := range arguments[1:] { 
         fileInfo, err := os.Stat(filename) 
         if err != nil { 
               fmt.Println(err) 
               continue 
         } 

在这一部分,你调用os.Stat()函数来确保你要处理的文件存在。

findOG.go的最后一部分带有以下 Go 代码:

         fmt.Printf("%+v\n", fileInfo.Sys()) 
         fmt.Println(fileInfo.Sys().(*syscall.Stat_t).Uid) 
         fmt.Println(fileInfo.Sys().(*syscall.Stat_t).Gid) 
   } 
} 

是的,这是你在本书中迄今为止看到的最神秘的代码,它使用os.Stat()的返回值来提取所需的信息。此外,它也不是可移植的,这意味着它可能在你的 Unix 变体上无法工作,也不能保证它将在 Go 的未来版本中继续工作!

有时看起来很容易的任务可能会花费比预期更多的时间。其中一个任务就是findOG.go程序。这主要是因为 Go 没有一种简单且可移植的方法来找出文件的所有者和组。希望这在未来会有所改变。

在 macOS Sierra 或 Mac OS X 上执行findOG.go将生成以下输出:

$ go run findOG.go /tmp/swtag.log
&{Dev:16777218 Mode:33206 Nlink:1 Ino:50547755 Uid:501 Gid:0 Rdev:0 Pad_cgo_0:[0 0 0 0] Atimespec:{Sec:1495297106 Nsec:0} Mtimespec:{Sec:1495297106 Nsec:0} Ctimespec:{Sec:1495297106 Nsec:0} Birthtimespec:{Sec:1495044975 Nsec:0} Size:2586 Blocks:8 Blksize:4096 Flags:0 Gen:0 Lspare:0 Qspare:[0 0]}
501
0
$ ls -l /tmp/swtag.log
-rw-rw-rw-  1 mtsouk  wheel  2586 May 20 19:18 /tmp/swtag.log
$ grep wheel /etc/group
wheel:*:0:root 

在这里,你可以看到fileInfo.Sys()调用以某种令人困惑的格式返回了大量文件信息:这些信息类似于对stat(2)的 C 调用的信息。输出的第一行是os.Stat.Sys()调用的内容,而第二行是文件所有者的用户 ID(501),第三行是文件所有者的组 ID(0)。

在 Debian Linux 机器上执行findOG.go将生成以下输出:

$ go run findOG.go /home/mtsouk/connections.data
&{Dev:2048 Ino:1196167 Nlink:1 Mode:33188 Uid:1000 Gid:1000 X__pad0:0 Rdev:0 Size:9626800 Blksize:4096 Blocks:18840 Atim:{Sec:1412623801 Nsec:0} Mtim:{Sec:1495307521 Nsec:929812185} Ctim:{Sec:1495307521 Nsec:929812185} X__unused:[0 0 0]}
1000
1000
$ ls -l /home/mtsouk/connections.data
-rw-r--r-- 1 mtsouk mtsouk 9626800 May 20 22:12 /home/mtsouk/connections.data
code$ grep ^mtsouk /etc/group
mtsouk:x:1000:

好消息是,findOG.go在 macOS Sierra 和 Debian Linux 上都可以工作,尽管 macOS Sierra 使用的是 Go 版本 1.8.1,而 Debian Linux 使用的是 Go 版本 1.3.3!

大部分呈现的 Go 代码将在本章后面用于实现userFiles.go实用程序。

更多模式匹配示例

本节将介绍与本书迄今为止所见模式更困难的正则表达式。只需记住,正则表达式和模式匹配是您应该通过实验和有时失败来学习的实用主题,而不是通过阅读来学习。

如果您在 Go 中非常小心地处理正则表达式,您几乎可以读取或更改 Unix 系统中几乎所有以纯文本格式存在的系统文件。只是在修改系统文件时要特别小心!

一个简单的模式匹配示例

本节的示例将改进countIP.go实用程序的功能,通过开发一个自动检测具有 IP 地址的字段的程序;因此,它不需要用户定义包含 IP 地址的每个日志条目的字段。为了简化事情,创建的程序将只处理每行的第一个 IP 地址:findIP.go接受一个命令行参数,即要处理的日志文件的名称。程序将分为四个部分。

findIP.go的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "net" 
   "os" 
   "path/filepath" 
   "regexp" 
) 

第二部分是在一个函数的帮助下发生大部分魔术的地方:

func findIP(input string) string { 
   partIP := "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])" 
   grammar := partIP + "\\." + partIP + "\\." + partIP + "\\." + partIP 
   matchMe := regexp.MustCompile(grammar) 
   return matchMe.FindString(input) 
} 

考虑到我们只想匹配由点分隔的 0-255 范围内的四个十进制数,正则表达式非常复杂,这主要表明当您想要有条不紊地进行时,正则表达式可能非常复杂。

但让我更详细地解释一下。IP 地址由四部分组成,用点分隔。每个部分的值可以在 0 到 255 之间,这意味着数字 257 不是可接受的值:这是正则表达式如此复杂的主要原因。第一种情况是介于 250 和 255 之间的数字。第二种情况是介于 200 和 249 之间的数字,第三种情况是介于 100 和 199 之间的数字。最后一种情况是捕获 0 到 99 之间的值。

findIP.go的第三部分如下:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("usage: %s logFile\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 
   filename := os.Args[1] 

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening file %s\n", err) 
         os.Exit(-1) 
   } 
   defer f.Close() 

   myIPs := make(map[string]int) 
   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 
         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s", err) 
               break 
         } 

在这里,您使用bufio.NewReader()逐行读取输入日志文件。

最后一部分包含以下 Go 代码,用于处理正则表达式的匹配项:

         ip := findIP(line) 
         trial := net.ParseIP(ip) 
         if trial.To4() == nil { 
               continue 
         } else { 
               _, ok := myIPs[ip] 
               if ok { 
                     myIPs[ip] = myIPs[ip] + 1 
               } else { 
                     myIPs[ip] = 1 
               } 
         } 
   } 
   for key, _ := range myIPs { 
         fmt.Printf("%s %d\n", key, myIPs[key]) 
   } 
} 

正如您所看到的,findIP.go对由执行模式匹配操作的函数找到的 IP 执行了额外的检查,使用net.ParseIP();这主要是因为 IP 地址非常棘手,因此最好是再次检查它们!此外,这会捕获findIP()返回空值的情况,因为在处理的行中未找到有效的 IP。程序在退出之前做的最后一件事是打印myIPs映射的内容。

考虑一下,您可以用少量的 Go 代码开发多少令人难以置信和有用的实用程序:这真是令人惊讶!

在 Linux 机器上执行findIP.go以处理/var/log/auth.log日志文件将创建以下输出:

$ wc /var/log/auth.log
  1499647  20313719 155224677 /var/log/auth.log
$ go run findIP.go /var/log/auth.log
39.114.101.107 1003
111.224.233.41 10
189.41.147.179 306
55.31.112.181 1
5.141.131.102 10
171.60.251.143 30
218.237.65.48 1
24.16.210.120 8
199.115.116.50 3
139.160.113.181 1

您可以按 IP 被发现的次数对先前的输出进行排序,并显示前 10 个最受欢迎的 IP 地址,如下所示:

$ go run findIP.go /var/log/auth.log | sort -nr -k2 | head
218.65.30.156 102533
61.177.172.27 37746
218.65.30.43 34640
109.74.11.18 32870
61.177.172.55 31968
218.65.30.124 31649
59.63.188.3 30970
61.177.172.28 30023
116.31.116.30 29314
61.177.172.14 28615

因此,在这种情况下,findIP.go实用程序用于检查您的 Linux 机器的安全性!

模式匹配的高级示例

在本节中,您将学习如何交换文本文件每行的两个字段的值,前提是它们的格式正确。这主要发生在日志文件或其他文本文件中,您希望扫描某种类型的数据的行,如果找到数据,可能需要对其进行某些操作:在这种情况下,您将更改两个值的位置。

程序的名称将是swapRE.go,它将分为四个部分。再次,该程序将逐行读取文本文件,并尝试匹配所需的字符串后进行交换。该实用程序将在屏幕上打印新文件的内容;将结果保存到新文件是用户的责任。swapRE.go期望处理的日志条目格式类似于以下内容:

127.0.0.1 - - [24/May/2017:06:41:11 +0300] "GET /contact HTTP/1.1" 200 6048 "http://www.mtsoukalos.eu/" "Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko" 132953

程序将交换的上一行条目是[24/May/2017:06:41:11 +0300]和132953,它们分别是日期和时间以及浏览器获取所需信息所花费的时间;程序期望在每行末尾找到这些内容。但是,正则表达式还检查日期和时间是否以正确的格式以及每个日志条目的最后一个字段是否确实是数字。

正如您将看到的,有时在 Go 中使用正则表达式可能会令人困惑,主要是因为正则表达式通常相对难以构建。

swapRE.go的第一部分将是预期的序言:

package main 

import ( 
   "bufio" 
   "flag" 
   "fmt" 
   "io" 
   "os" 
   "regexp" 
) 

第二部分包括以下 Go 代码:

func main() { 
   flag.Parse() 
   if flag.NArg() != 1 { 
         fmt.Println("Please provide one log file to process!") 
         os.Exit(-1) 
   } 
   numberOfLines := 0 
   numberOfLinesMatched := 0 

   filename := flag.Arg(0) 
   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening file %s", err) 
         os.Exit(1) 
   } 
   defer f.Close() 

这里没有什么特别有趣或新的。

第三部分如下:

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 
         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s", err) 
         } 

这是允许您逐行处理输入文件的 Go 代码。

swapRE.go的最后一部分如下:

         numberOfLines++ 
         r := regexp.MustCompile(`(.*) (\[\d\d\/(\w+)/\d\d\d\d:\d\d:\d\d:\d\d(.*)\]) (.*) (\d+)`) 
         if r.MatchString(line) { 
               numberOfLinesMatched++ 
               match := r.FindStringSubmatch(line) 
               fmt.Println(match[1], match[6], match[5], match[2]) 
         } 
   } 
   fmt.Println("Line processed:", numberOfLines) 
   fmt.Println("Line matched:", numberOfLinesMatched) 
} 

正如您可以想象的那样,像这里呈现的复杂正则表达式一样,是逐步构建的,而不是一次性完成的。即使在这种情况下,您可能仍然会在过程中多次失败,因为即使在复杂正则表达式中出现最微小的错误也会导致它不符合您的期望:在这里,广泛的测试是关键!

正则表达式中使用的括号允许您在之后引用每个匹配项,并且在您想要处理已匹配内容时非常方便。在这里,您想要找到一个[字符,然后是两位数字,它们将是月份的日期,然后是一个单词,它将是月份的名称,然后是四位数字,它们将是年份。接下来,匹配任何其他内容,直到找到一个]字符。然后匹配每行末尾的所有数字。

请注意,可能存在编写相同正则表达式的替代方法。这里的一般建议是以清晰且易于理解的方式编写它。

执行swapRE.go,一个小的测试日志文件将生成以下输出:

$ go run swapRE.go /tmp/log.log
127.0.0.1 - - 28787 "GET /taxonomy/term/35/feed HTTP/1.1" 200 2360 "-" "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" [24/May/2017:07:04:48 +0300]
- - 32145 HTTP/1.1" 200 2616 "http://www.mtsoukalos.eu/" "Mozilla/5.0 (compatible; inoreader.com-like FeedFetcher-Google)" [24/May/2017:07:09:24 +0300]
Line processed: 3
Line matched: 2

使用正则表达式重命名多个文件

最后一节关于模式匹配和正则表达式将处理文件名,并允许您重命名多个文件。正如您可以猜到的那样,在程序中将使用 walk 函数,而正则表达式将匹配您想要重命名的文件名。

处理文件时,您应该特别小心,因为您可能会意外地破坏东西!简而言之,不要在生产服务器上测试这样的实用程序。

该实用程序的名称将是multipleMV.go,它将分为三个部分。multipleMV.go将做的是在与给定正则表达式匹配的每个文件名前插入一个字符串。

第一部分是预期的序言:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "path/filepath" 
   "regexp" 
) 

var RE string
var renameString string 

这两个全局变量可以避免在函数中使用许多参数。另外,由于walk()函数的签名在一段时间内不会改变,所以无法将它们作为参数传递给walk()。因此,在这种情况下,有两个全局参数会使事情变得更容易和简单。

第二部分包含以下 Go 代码:

func walk(path string, f os.FileInfo, err error) error { 
   regex, err := regexp.Compile(RE) 
   if err != nil { 
         fmt.Printf("Error in RE: %s\n", RE) 
         return err 
   } 

   if path == "." { 
         return nil 
   } 
   nameOfFile := filepath.Base(path) 
   if regex.MatchString(nameOfFile) { 
         newName := filepath.Dir(path) + "/" + renameString + "_" + nameOfFile 
         os.Rename(path, newName) 
   } 
   return nil 
} 

程序的所有功能都嵌入在walk()函数中。成功匹配后,新文件名将存储在newName变量中,然后执行os.Rename()函数。

multipleMV.go的最后一部分是main()函数的实现:

func main() { 
   flag.Parse() 
   if flag.NArg() != 3 { 
         fmt.Printf("Usage: %s REGEXP RENAME Path", filepath.Base(os.Args[0])) 
         os.Exit(-1) 
   } 

   RE = flag.Arg(0) 
   renameString = flag.Arg(1) 
   Path := flag.Arg(2) 
   Path, _ = filepath.EvalSymlinks(Path) 
   filepath.Walk(Path, walk) 
} 

在这里,没有什么是你以前没有见过的:唯一有趣的是调用filepath.EvalSymlinks(),以便不必处理符号链接。

使用 multipleMV.go 就像运行以下命令一样简单:

$ ls -l /tmp/swtag.log
-rw-rw-rw-  1 mtsouk  wheel  446 May 22 09:18 /tmp/swtag.log
$ go run multipleMV.go 'log$' new /tmp
$ ls -l /tmp/new_swtag.log
-rw-rw-rw-  1 mtsouk  wheel  446 May 22 09:18 /tmp/new_swtag.log
$ go run multipleMV.go 'log$' new /tmp
$ ls -l /tmp/new_new_swtag.log
-rw-rw-rw-  1 mtsouk  wheel  446 May 22 09:18 /tmp/new_new_swtag.log
$ go run multipleMV.go 'log$' new /tmp
$ ls -l /tmp/new_new_new_swtag.log
-rw-rw-rw-  1 mtsouk  wheel  446 May 22 09:18 /tmp/new_new_new_swtag.log 

重新访问搜索文件

本节将教你如何使用用户 ID、组 ID 和文件权限等条件来查找文件。尽管这一节本来可以包含在第五章 文件和目录 中,但我决定把它放在这里,因为有时你会想要使用这种信息来通知系统管理员系统出了问题。

查找用户的用户 ID

这一小节将介绍一个程序,它显示给定用户名的用户 ID,这或多或少是 id -u 实用程序的输出:

$ id -u
33
$ id -u root
0

存在一个名为 user 的 Go 包,可以在 os 包下找到,可以帮助你实现所需的任务,这一点不应该让你感到惊讶。程序的名称将是 userID.go,它将分为两部分。如果你没有给 userID.go 传递命令行参数,它将打印当前用户的用户 ID;否则,它将打印给定用户名的用户 ID。

userID.go 的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "os/user" 
) 

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         uid := os.Getuid() 
         fmt.Println(uid) 
         return 
   } 

os.Getuid() 函数返回当前用户的用户 ID。

userID.go 的第二部分包含以下 Go 代码:

   username := arguments[1] 
   u, err := user.Lookup(username) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   fmt.Println(u.Uid) 
}

给定用户名,user.Lookup() 函数返回一个 user.User 复合值。我们只会使用该复合值的 Uid 字段来查找给定用户名的用户 ID。

执行 userID.go 将生成以下输出:

$ go run userID.go
501
$ go run userID.go root
0
$ go run userID.go doesNotExist
user: unknown user doesNotExist

查找用户所属的所有组

每个用户可以属于多个组:本节将展示如何找出给定用户名的用户属于哪些组的列表。

实用程序的名称将是 listGroups.go,它将分为四部分。listGroups.go 的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "os/user" 
) 

第二部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   var u *user.User 
   var err error 
   if len(arguments) == 1 { 
         u, err = user.Current() 
         if err != nil { 
               fmt.Println(err) 
               return 
         } 

当没有命令行参数时,listGroups.go 采用的方法与 userID.go 中找到的方法类似。然而,有一个很大的区别,这一次你不需要当前用户的用户 ID,而是需要当前用户的用户名;所以你调用 user.Current(),它返回一个 user.User 值。

第三部分包含以下 Go 代码:

   } else { 
         username := arguments[1] 
         u, err = user.Lookup(username) 
         if err != nil { 
               fmt.Println(err) 
               return 
         } 
   } 

因此,如果给程序传递了命令行参数,它将通过 user.Lookup() 函数处理前面的代码,该函数还返回一个 user.User 值。

最后一部分包含以下 Go 代码:

   gids, _ := u.GroupIds() 
   for _, gid := range gids { 
         group, err := user.LookupGroupId(gid) 
         if err != nil { 
               fmt.Println(err) 
               continue 
         } 
         fmt.Printf("%s(%s) ", group.Gid, group.Name) 
   } 
   fmt.Println() 
} 

在这里,通过调用 u.GroupIds() 函数,你可以获得用户(由 u 变量表示)所属的组 ID 列表。然后,你需要一个 for 循环来遍历所有列表元素并打印它们。应该明确指出,这个列表存储在 u 中;也就是说,一个 user.User 值。

执行 listGroups.go 将生成以下输出:

$ go run listGroups.go
    20(staff) 701(com.apple.sharepoint.group.1) 12(everyone) 61(localaccounts) 79(_appserverusr) 80(admin) 81(_appserveradm) 98(_lpadmin) 33(_appstore) 100(_lpoperator) 204(_developer) 395(com.apple.access_ftp) 398(com.apple.access_screensharing) 399(com.apple.access_ssh)
$ go run listGroups.go www
70(_www) 12(everyone) 61(localaccounts) 701(com.apple.sharepoint.group.1) 100(_lpoperator)

listGroups.go 的输出比 id -G -ngroups 命令的输出要丰富得多:

$ id -G -n
staff com.apple.sharepoint.group.1 everyone localaccounts _appserverusr admin _appserveradm _lpadmin _appstore _lpoperator _developer com.apple.access_ftp com.apple.access_screensharing com.apple.access_ssh
$ groups
staff com.apple.sharepoint.group.1 everyone localaccounts _appserverusr admin _appserveradm _lpadmin _appstore _lpoperator _developer com.apple.access_ftp com.apple.access_screensharing com.apple.access_ssh

查找属于给定用户或不属于给定用户的文件

这一小节将创建一个 Go 程序,扫描目录树并显示属于给定用户或不属于给定用户的文件。程序的名称将是 userFiles.go。在其默认操作模式下,userFiles.go 将显示所有属于给定用户名的文件;当使用 -no 标志时,它将只显示不属于给定用户名的文件。

userFiles.go 的代码将分为四部分。

第一部分如下:

package main 

import ( 
   "flag" 
   "fmt" 
   "os" 
   "os/user" 
   "path/filepath" 
   "strconv" 
   "syscall" 
) 

var uid int32 = 0
var INCLUDE bool = true 

INCLUDEuid 声明为全局变量的原因是你希望它们都可以从程序的任何地方访问。此外,由于 walkFunction() 的签名不能改变:只有它的名称可以改变:使用全局变量对开发人员更方便。

第二部分包含以下 Go 代码:

func userOfFIle(filename string) int32 { 
   fileInfo, err := os.Stat(filename) 
   if err != nil { 
         fmt.Println(err) 
         return 1000000 
   } 
   UID := fileInfo.Sys().(*syscall.Stat_t).Uid 
   return int32(UID) 
} 

使用名为 UID 的局部变量可能是一个不好的选择,因为有一个名为 uid 的全局变量!全局变量的更好名称应该是 gUID。请注意,关于返回 UID 变量的调用方式的解释,您应该搜索 Go 中的接口和类型转换,因为讨论这个超出了本书的范围。

第三部分包含以下 Go 代码:

func walkFunction(path string, info os.FileInfo, err error) error { 
   _, err = os.Lstat(path) 
   if err != nil { 
         return err 
   } 

   if userOfFIle(path) == uid && INCLUDE { 
         fmt.Println(path) 
   } else if userOfFIle(path) != uid && !(INCLUDE) { 
         fmt.Println(path) 
   } 

   return err 
} 

在这里,您可以看到一个遍历函数的实现,该函数将访问给定目录树中的每个文件和目录,以便仅打印所需的文件名。

实用程序的最后部分包含以下 Go 代码:

func main() { 
   minusNO := flag.Bool("no", true, "Include") 
   minusPATH := flag.String("path", ".", "Path to Search") 
   flag.Parse() 
   flags := flag.Args() 

   INCLUDE = *minusNO 
   Path := *minusPATH 

   if len(flags) == 0 { 
         uid = int32(os.Getuid()) 
   } else { 
         u, err := user.Lookup(flags[0]) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(1) 
         } 
         temp, err := strconv.ParseInt(u.Uid, 10, 32) 
         uid = int32(temp) 
   } 

   err := filepath.Walk(Path, walkFunction) 
   if err != nil { 
         fmt.Println(err) 
   } 
} 

在调用 filepath.Walk() 函数之前,您需要处理 flag 包的配置。

执行 userFiles.go 会生成以下输出:

$ go run userFiles.go -path=/tmp www-data
/tmp/.htaccess
/tmp/update-cache-2a113cac
/tmp/update-extraction-2a113cac

如果您没有给出任何命令行参数或标志,userFiles.go 实用程序将假定您想要搜索当前目录中属于当前用户的文件:

$ go run userFiles.go
.
appendData.go
countIP.go

因此,为了找到 /srv/www/www.highiso.net 目录中不属于 www-data 用户的所有文件,您应该执行以下命令:

$ go run userFiles.go -no=false -path=/srv/www/www.highiso.net www-data
/srv/www/www.highiso.net/list.files
/srv/www/www.highiso.net/public_html/wp-content/.htaccess
/srv/www/www.highiso.net/public_html.UnderCon/.htaccess

根据权限查找文件

现在您知道如何找到文件的 Unix 权限后,可以改进上一章中的 regExpFind.go 实用程序,以支持基于文件权限的搜索;但是,为了避免在这里没有任何实际原因的情况下呈现一个非常大的 Go 程序,所以呈现的程序将是自主的,并且只支持根据权限查找文件。新实用程序的名称将是 findPerm.go,将分为四部分呈现。权限将以 ls(1) 命令返回的格式作为字符串在命令行中给出(rwxr-xr--)。

实用程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
) 

var PERMISSIONS string

PERMISSIONS 变量是全局的,以便从程序的任何地方访问,并且因为 walkFunction() 的签名不能更改。

findPerm.go 的第二部分包含以下代码:

func permissionsOfFIle(filename string) string { 
   info, err := os.Stat(filename) 
   if err != nil { 
         return "-1" 
   } 
   mode := info.Mode() 
   return mode.String()[1:10] 
} 

第三部分是 walkFunction() 的实现:

func walkFunction(path string, info os.FileInfo, err error) error { 
   _, err = os.Lstat(path) 
   if err != nil { 
         return err 
   } 

   if permissionsOfFIle(path) == PERMISSIONS { 
         fmt.Println(path) 
   } 
   return err 
} 

findPerm.go 的最后部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) != 3 { 
         fmt.Printf("usage: %s RootDirectory permissions\n",  
filepath.Base(arguments[0])) 
         os.Exit(1) 
   } 

   Path := arguments[1] 
   Path, _ = filepath.EvalSymlinks(Path) 
   PERMISSIONS = arguments[2] 

   err := filepath.Walk(Path, walkFunction) 
   if err != nil { 
         fmt.Println(err) 
   } 
} 

执行 findPerm.go 会生成以下输出:

$ go run findPerm.go /tmp rw-------
/private/tmp/.adobeLockFile
$ ls -l /private/tmp/.adobeLockFile
-rw-------  1 mtsouk  wheel  0 May 19 14:36 /private/tmp/.adobeLockFile

日期和时间操作

本节将向您展示如何在 Go 中处理日期和时间。这项任务可能看起来微不足道,但当您想要同步诸如日志条目和错误消息之类的事物时,它可能非常重要。我们将首先说明 time 包的一些功能。

玩转日期和时间

本节将介绍一个名为 dateTime.go 的小型 Go 程序,展示了如何在 Go 中处理时间和日期。dateTime.go 的代码将分为三部分呈现。第一部分如下:

package main 

import ( 
   "fmt" 
   "time" 
) 

func main() { 

   fmt.Println("Epoch time:", time.Now().Unix()) 
   t := time.Now() 
   fmt.Println(t, t.Format(time.RFC3339)) 
   fmt.Println(t.Weekday(), t.Day(), t.Month(), t.Year()) 
   time.Sleep(time.Second) 
   t1 := time.Now() 
   fmt.Println("Time difference:", t1.Sub(t)) 

   formatT := t.Format("01 January 2006") 
   fmt.Println(formatT) 
   loc, _ := time.LoadLocation("Europe/London") 
   londonTime := t.In(loc) 
   fmt.Println("London:", londonTime) 

在这部分中,您可以看到如何将日期从一种格式转换为另一种格式,以及如何在不同时区找到日期和时间。main() 函数开头使用的 time.Now() 函数返回当前时间。

第二部分如下:

   myDate := "23 May 2017" 
   d, _ := time.Parse("02 January 2006", myDate) 
   fmt.Println(d) 

   myDate1 := "23 May 2016" 
   d1, _ := time.Parse("02 February 2006", myDate1) 
   fmt.Println(d1)

可以在 golang.org/src/time/format.go 找到用于创建自己的解析格式的常量列表。Go 不像其他编程语言那样以 DDYYYYMM 或 %D %Y %M 的形式定义日期或时间的格式,而是使用自己的方法。

在这里,您可以看到如何读取一个字符串并尝试将其转换为有效的日期,成功地(d)和不成功地(d1)。d1 变量的问题在于 format 字符串中使用了 February:您应该改用 January

dateTime.go 的最后部分带有以下 Go 代码:

   myDT := "Tuesday 23 May 2017 at 23:36" 
   dt, _ := time.Parse("Monday 02 January 2006 at 15:04", myDT) 
   fmt.Println(dt) 
} 

本部分还展示了如何将字符串转换为日期和时间,前提是它是预期的格式。

执行 dateTime.go 会生成以下输出:

$ go run dateTime.go
Epoch time: 1495572122
2017-05-23 23:42:02.459713551 +0300 EEST 2017-05-23T23:42:02+03:00
Tuesday 23 May 2017
Time difference: 1.001749054s
05 May 2017
London: 2017-05-23 21:42:02.459713551 +0100 BST
2017-05-23 00:00:00 +0000 UTC
0001-01-01 00:00:00 +0000 UTC
2017-05-23 23:36:00 +0000 UTC

重新格式化日志文件中的时间

本节将展示如何实现一个程序,该程序读取包含日期和时间信息的日志文件,以便转换每个日志条目中找到的时间格式。当您有来自不同时区的不同服务器的日志文件,并且希望同步它们的时间以便从它们的数据创建报告或将它们存储到数据库中以便以后处理它们时,可能需要执行此操作。

所呈现的程序的名称将是dateTimeLog.go,并且将分为四个部分。

第一部分如下:

package main 

import ( 
   "bufio" 
   "flag" 
   "fmt" 
   "io" 
   "os" 
   "regexp" 
   "strings" 
   "time" 
) 

第二部分包含以下 Go 代码:

func main() { 
   flag.Parse() 
   if flag.NArg() != 1 { 
         fmt.Println("Please provide one log file to process!") 
         os.Exit(-1) 
   } 

   filename := flag.Arg(0) 
   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("error opening file %s", err) 
         os.Exit(1) 
   } 
   defer f.Close() 

在这里,您只需配置flag包并打开输入文件以进行读取。

程序的第三部分如下:

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 
         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s", err) 
         } 

在这里,您逐行读取输入文件。

最后一部分如下:

         r := regexp.MustCompile(`.*\[(\d\d\/\w+/\d\d\d\d:\d\d:\d\d:\d\d.*)\] .*`) 
         if r.MatchString(line) { 
               match := r.FindStringSubmatch(line) 
               d1, err := time.Parse("02/Jan/2006:15:04:05 -0700", match[1]) 
               if err != nil { 
                     fmt.Println(err) 
               } 
               newFormat := d1.Format(time.RFC3339) 
               fmt.Print(strings.Replace(line, match[1], newFormat, 1)) 
         } 
   } 
} 

这里的基本思想是,一旦找到匹配项,就使用time.Parse()解析找到的日期和时间,然后使用time.Format()函数将其转换为所需的格式。此外,在使用strings.Replace()打印之前,您将初始匹配项替换为time.Format()函数的输出。

执行dateTimeLog.go将生成以下输出:

$ go run dateTimeLog.go /tmp/log.log
127.0.0.1 - - [2017-05-24T07:04:48+03:00] "GET /taxonomy/term/35/feed HTTP/1.1" 200 2360 "-" "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" 28787
- - [2017-05-24T07:09:24+03:00] HTTP/1.1" 200 2616 "http://www.mtsoukalos.eu/" "Mozilla/5.0 (compatible; inoreader.com-like FeedFetcher-Google)" 32145
[2017-05-24T07:38:08+03:00] "GET /tweets?page=181 HTTP/1.1" 200 8605 "-" "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" 100531

旋转日志文件

日志文件由于不断写入数据而不断变得越来越大;最好有一种旋转它们的技术。本节将介绍这样的技术。Go 程序的名称将是rotateLog.go,并且将分为三个部分。请注意,要旋转日志文件,进程必须是打开该日志文件进行写入的进程。尝试旋转您不拥有的日志可能会在您的 Unix 机器上创建问题,应该避免!

在这里,您还将看到另一种技术,即使用自己的日志文件存储日志条目,借助log.SetOutput()的帮助:成功调用log.SetOutput()后,对log.Print()的每个函数调用都将使输出转到用作log.SetOutput()参数的日志文件。

rotateLog.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "log" 
   "os" 
   "strconv" 
   "time" 
) 

var TOTALWRITES int = 0 
var ENTRIESPERLOGFILE int = 100 
var WHENTOSTOP int = 230 
var openLogFile os.File 

使用硬编码变量来定义程序何时停止被认为是一种良好的做法:这是因为您没有其他方法告诉rotateLog.go停止。但是,如果您在编译的程序中使用rotateLog.go实用程序的功能,则此类变量应作为命令行参数给出,因为您不应该重新编译程序以更改程序的行为方式!

rotateLog.go的第二部分如下:

func rotateLogFile(filename string) error { 
   openLogFile.Close() 
   os.Rename(filename, filename+"."+strconv.Itoa(TOTALWRITES)) 
   err := setUpLogFile(filename) 
   return err 
} 

func setUpLogFile(filename string) error { 
   openLogFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) 
   if err != nil { 
         return err 
   } 
   log.SetOutput(openLogFile) 
   return nil 
} 

在这里,您定义了名为rotateLogFile()的 Go 函数,用于旋转所需的日志文件,这是程序的最重要部分。setUpLogFile()函数在旋转日志文件后帮助您重新启动日志文件。这里还展示了使用log.SetOutput()告诉程序在哪里写入日志条目。请注意,您应该使用os.OpenFile()打开日志文件,因为os.Open()对于log.SetOutput()不起作用,而os.Open()会打开文件进行写入!

最后一部分如下:

func main() { 
   numberOfLogEntries := 0 
   filename := "/tmp/myLog.log" 
   err := setUpLogFile(filename) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(-1) 
   } 

   for { 
         log.Println(numberOfLogEntries, "This is a test log entry") 
         numberOfLogEntries++ 
         TOTALWRITES++ 
         if numberOfLogEntries > ENTRIESPERLOGFILE { 
               rotateLogFile(filename)
               numberOfLogEntries = 0 
         } 
         if TOTALWRITES > WHENTOSTOP { 
               rotateLogFile(filename)
               break 
         } 
         time.Sleep(time.Second) 
   } 
   fmt.Println("Wrote", TOTALWRITES, "log entries!") 
} 

在这部分中,main()函数在计算到目前为止已写入的条目数的同时继续向日志文件写入数据。当达到定义的条目数(ENTRIESPERLOGFILE)时,main()函数将调用rotateLogFile()函数,该函数将为我们完成繁重的工作。在真实的程序中,您很可能不需要调用time.Sleep()来延迟程序的执行。对于这个特定的程序,time.Sleep()将为您提供时间来使用tail -f检查日志文件,如果您选择这样做的话。

运行rotateLog.go将在屏幕上和/tmp目录中生成以下输出:

$ go run rotateLog.go
Wrote 231 log entries!
$ wc /tmp/myLog.log*
   0       0       0 /tmp/myLog.log
 101     909    4839 /tmp/myLog.log.101
 101     909    4839 /tmp/myLog.log.202
  29     261    1382 /tmp/myLog.log.231
 231    2079   11060 total

第八章,进程和信号,将介绍基于 Unix 信号的日志旋转的更好方法。

创建好的随机密码

本节将说明如何在 Go 中创建良好的随机密码,以保护您的 Unix 机器的安全。将其包含在这里的主要原因是,所呈现的 Go 程序将使用您的 Unix 系统定义的/dev/random设备来获取随机数生成器的种子。

Go 程序的名称将是goodPass.go,它将只需要一个可选参数,即生成的密码的长度:生成的密码的默认大小将为 10 个字符。此外,该程序将生成 ASCII 字符,从!z。感叹号的 ASCII 码是 33,而小写 z 的 ASCII 码是 122。

goodPass.go的第一部分是必需的序言:

package main 

import ( 
   "encoding/binary" 
   "fmt" 
   "math/rand" 
   "os" 
   "path/filepath" 
   "strconv" 
) 

程序的第二部分如下:

var MAX int = 90 
var MIN int = 0 
var seedSize int = 10 

func random(min, max int) int { 
   return rand.Intn(max-min) + min 
} 

您已经在第二章中看到了random()函数,所以这里没有什么特别有趣的地方。

goodPass.go的第三部分是main()函数的实现开始的地方:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("usage: %s length\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   LENGTH, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   f, _ := os.Open("/dev/random") 
   var seed int64 
   binary.Read(f, binary.LittleEndian, &seed) 
   rand.Seed(seed) 
   f.Close() 
   fmt.Println("Seed:", seed) 

在这里,除了读取命令行参数之外,您还打开了/dev/random设备进行读取,这是通过调用binary.Read()函数并将读取的内容存储在seed变量中实现的。使用binary.Read()的原因是您需要指定使用的字节顺序(binary.LittleEndian),并且您需要构建一个 int64 而不是一系列字节。这是一个从二进制文件读取到 Go 类型的示例。

程序的最后部分包含以下 Go 代码:

   startChar := "!" 
   var i int64 
   for i = 0; i < LENGTH; i++ { 
         anInt := int(random(MIN, MAX)) 
         newChar := string(startChar[0] + byte(anInt)) 
         if newChar == " " { 
               i = i - i 
               continue 
         } 
         fmt.Print(newChar) 
   } 
   fmt.Println() 
} 

正如您所看到的,Go 处理 ASCII 字符的方式很奇怪,因为 Go 默认支持 Unicode 字符。但是,您仍然可以将整数转换为 ASCII 字符,如您在定义newChar变量的方式所示。

执行goodPass.go将生成以下输出:

$ go run goodPass.go 1
Seed: -5195038511418503382
b
$ go run goodPass.go 10
Seed: 8492864627151568776
k43Ve`+YD)
$ go run goodPass.go 50
Seed: -4276736612056007162
!=Gy+;XV>6eviuR=ST\u:Mk4Q875Y4YZiZhq&q_4Ih/]''`2:x

另一个 Go 更新

在我写这一章的时候,Go 得到了更新。以下输出显示了相关信息:

$ date
Wed May 24 13:35:36 EEST 2017
$ go version
go version go1.8.2 darwin/amd64 

练习

  1. 查找并阅读time包的文档。

  2. 尝试更改userFiles.go的 Go 代码,以支持多个用户。

  3. 更改insertLineNumber.go的 Go 代码,以便逐行读取输入文件,将每行写入临时文件,然后用临时文件替换原始文件。如果您不知道如何在哪里创建临时文件,可以使用随机数生成器获取临时文件名和/tmp目录进行临时保存。

  4. multipleMV.go进行必要的更改,以便打印与给定正则表达式匹配的文件,而不实际重命名它们。

  5. 尝试创建一个匹配PNG文件的正则表达式,并使用它来处理日志文件的内容。

  6. 创建一个正则表达式,以捕获日期和时间字符串,以便仅打印日期部分并删除时间部分。

摘要

在本章中,我们谈到了许多内容,包括处理日志文件,处理 Unix 文件权限,用户和组,创建正则表达式以及处理文本文件。

在下一章中,我们将讨论 Unix 信号,它允许您以异步方式与运行中的程序进行通信。此外,我们将告诉您如何在 Go 中绘图。

f

第八章:进程和信号

在上一章中,我们讨论了许多有趣的主题,包括处理 Unix 系统文件,处理 Go 中的日期和时间,查找有关文件权限和用户的信息,以及正则表达式和模式匹配。

本章的核心主题是开发能够处理 Unix 信号的 Go 应用程序。Go 提供了os/signal包来处理信号,它使用 Go 通道。尽管通道在下一章中得到了充分的探讨,但这并不妨碍你学习如何在 Go 程序中处理 Unix 信号。

此外,你将学习如何创建可以与 Unix 管道一起工作的 Go 命令行实用程序,如何在 Go 中绘制条形图,以及如何实现cat(1)实用程序的 Go 版本。因此,在本章中,你将学习以下主题:

  • 列出 Unix 机器的进程

  • Go 中的信号处理

  • Unix 机器支持的信号以及如何使用kill(1)命令发送这些信号

  • 让信号做你想要的工作

  • 在 Go 中实现cat(1)实用程序的简单版本

  • 在 Go 中绘制数据

  • 使用管道将一个程序的输出发送到另一个程序

  • 将一个大程序转换为两个较小的程序,它们将通过 Unix 管道协作

  • 为 Unix 套接字创建一个客户端

关于 Unix 进程和信号

严格来说,进程是包含指令、用户数据和系统数据部分以及在运行时获得的其他类型资源的执行环境,而程序是一个包含指令和数据的文件,用于初始化进程的指令和用户数据部分。

进程管理

总的来说,Go 在处理进程和进程管理方面并不那么擅长。尽管如此,本节将介绍一个小的 Go 程序,通过执行 Unix 命令并获取其输出来列出 Unix 机器的所有进程。程序的名称将是listProcess.go。它适用于 Linux 和 macOS 系统,并将分为三个部分。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "os/exec" 
   "syscall" 
) 

listProcess.go的第二部分包含以下 Go 代码:

func main() { 

   PS, err := exec.LookPath("ps") 
   if err != nil { 
         fmt.Println(err) 
   } 
fmt.Println(PS) 

   command := []string{"ps", "-a", "-x"} 
   env := os.Environ() 
   err = syscall.Exec(PS, command, env) 

正如你所看到的,你首先需要使用exec.LookPath()获取可执行文件的路径,以确保你不会意外地执行另一个二进制文件,然后使用切片定义你想要执行的命令,包括命令的参数。接下来,你将需要使用os.Environ()读取 Unix 环境。此外,你可以使用syscall.Exec()执行所需的命令,它将自动打印其输出,这并不是一个非常优雅的执行命令的方式,因为你无法控制任务,并且因为你是在最低级别调用进程,而不是使用更高级别的库,比如os/exec

程序的最后一部分是用于打印前面代码的错误消息,如果有的话:

   if err != nil { 
         fmt.Println(err) 
   } 
} 

执行listProcess.go将生成以下输出:使用head(1)实用程序来获取较小的输出:

$ go run listProcess.go | head -3
/bin/ps
  PID TTY           TIME CMD
    1 ??         0:30.72 /sbin/launchd
signal: broken pipe

关于 Unix 信号

你是否曾经按下Ctrl + C来停止程序运行?如果是的话,那么你已经熟悉信号,因为Ctrl + C会向程序发送SIGINT信号。

严格来说,Unix信号是可以通过名称或数字访问的软件中断,提供了处理异步事件的方式,例如当子进程退出或在 Unix 系统上暂停进程时。

程序无法处理所有信号;其中一些信号是不可捕获和不可忽略的。SIGKILLSIGSTOP信号无法被捕获、阻塞或忽略。原因是它们为内核和 root 用户提供了一种停止任何进程的方式。SIGKILL信号,也称为数字 9,通常在需要迅速采取行动的极端情况下调用;因此,它通常按数字调用,因为这样做更快。在这里要记住的最重要的事情是,并非所有的 Unix 信号都可以被处理!

Go 中的 Unix 信号

Go 为程序员提供了os/signal包,以帮助他们处理传入的信号。但是,我们将从介绍kill(1)实用程序开始讨论处理。

kill(1)命令

kill(1)命令用于终止进程或向其发送一个不那么残酷的信号。请记住,您可以向进程发送信号并不意味着该进程可以或者有代码来处理此信号。

默认情况下,kill(1)发送SIGTERM信号。如果要查找 Unix 机器支持的所有信号,应执行kill -l命令。在 macOS Sierra 机器上,kill -l的输出如下:

$ kill -l
1) SIGHUP   2) SIGINT        3) SIGQUIT   4) SIGILL
5) SIGTRAP  6) SIGABRT       7) SIGEMT    8) SIGFPE
9) SIGKILL 10) SIGBUS        11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM       15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP       19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU       23) SIGIO   24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM     27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1       31) SIGUSR2

如果您在 Debian Linux 机器上执行相同的命令,您将获得更丰富的输出:

$ kill -l
 1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
 6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT     17) SIGCHLD 
18) SIGCONT       19) SIGSTOP 20) SIGTSTP
21) SIGTTIN       22) SIGTTOU 
23) SIGURG        24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM     27) SIGPROF 28) SIGWINCH 
29) SIGIO         30) SIGPWR
31) SIGSYS        34) SIGRTMIN 
35) SIGRTMIN+1    36) SIGRTMIN+2    37) SIGRTMIN+3
38) SIGRTMIN+4    39) SIGRTMIN+5 
40) SIGRTMIN+6    41) SIGRTMIN+7    42) SIGRTMIN+8
43) SIGRTMIN+9    44) SIGRTMIN+10 
45) SIGRTMIN+11   46) SIGRTMIN+12   47) SIGRTMIN+13
48) SIGRTMIN+14   49) SIGRTMIN+15 
50) SIGRTMAX-14   51) SIGRTMAX-13   52) SIGRTMAX-12
53) SIGRTMAX-11   54) SIGRTMAX-10 
55) SIGRTMAX-9    56) SIGRTMAX-8    57) SIGRTMAX-7
58) SIGRTMAX-6    59) SIGRTMAX-5 
60) SIGRTMAX-4    61) SIGRTMAX-3    62) SIGRTMAX-2
63) SIGRTMAX-1    64) SIGRTMAX

如果您尝试杀死或向另一个用户的进程发送另一个信号而没有所需的权限,这很可能会发生,如果您不是root用户,kill(1)将无法完成任务,并且您将收到类似以下的错误消息:

$ kill 2908
-bash: kill: (2908) - Operation not permitted

Go 中的简单信号处理程序

本小节将介绍一个简单的 Go 程序,仅处理SIGTERMSIGINT信号。h1s.go的 Go 代码将分为三部分呈现;第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "os/signal" 
   "syscall" 
   "time" 
) 

func handleSignal(signal os.Signal) { 
   fmt.Println("Got", signal) 
} 

除了程序的序言之外,还有一个名为handleSignal()的函数,当程序接收到两个支持的信号中的任何一个时,将调用该函数。

h1s.go的第二部分包含以下 Go 代码:

func main() { 
   sigs := make(chan os.Signal, 1) 
   signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) 
   go func() { 
         for { 
               sig := <-sigs 
               fmt.Println(sig) 
               handleSignal(sig) 
         } 
   }() 

先前的代码使用了goroutine和 Gochannel,这是本书中尚未讨论的 Go 功能。不幸的是,您必须等到第九章 Goroutines - Basic Features,才能了解更多关于它们的信息。请注意,尽管os.Interruptsyscall.SIGTERM属于不同的 Go 包,但它们都是信号。

目前,理解这种技术很重要;它包括三个步骤:

  1. 通道的定义,作为传递数据的方式,对于技术(sigs)是必需的。

  2. 调用signal.Notify()以定义您希望能够捕获的信号列表。

  3. 定义一个匿名函数,它在signal.Notify()之后的 goroutine(go func())中运行,用于决定在收到所需信号时要执行的操作。

在这种情况下,将调用handleSignal()函数。匿名函数内部的for循环用于使程序保持处理所有信号,并在接收到第一个信号后不停止。

h1s.go的最后部分如下:

   for { 
         fmt.Printf(".") 
         time.Sleep(10 * time.Second) 
   } 
} 

这是一个无限的for循环,它永远延迟程序的结束:在其位置上,您很可能会放置程序的实际代码。执行h1s.go并从另一个终端向其发送信号将使h1s.go生成以下输出:

$ ./h1s
......................^Cinterrupt
Got interrupt
^Cinterrupt
Got interrupt
.Hangup: 1

这里的坏处是,当接收到SIGHUP信号时,h1s.go将停止,因为当程序没有专门处理SIGHUP时,默认操作是杀死进程!下一小节将展示如何更好地处理三个信号,之后的小节将教您如何处理所有可处理的信号。

处理三种不同的信号!

这一小节将教您如何创建一个可以处理三种不同信号的 Go 应用程序:程序的名称将是h2s.go,它将处理SIGTERMSIGINTSIGHUP信号。

h2s.go的 Go 代码将分为四部分呈现。

程序的第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "os" 
   "os/signal" 
   "syscall" 
   "time" 
) 

第二部分包含以下 Go 代码:

func handleSignal(signal os.Signal) { 
   fmt.Println("* Got:", signal) 
} 

func main() { 
   sigs := make(chan os.Signal, 1) 
   signal.Notify(sigs, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) 

在这里,最后一句告诉您,程序只会处理os.Interruptsyscall.SIGTERMsyscall.SIGHUP信号。

h2s.go的第三部分如下:

   go func() { 
         for { 
               sig := <-sigs 
               switch sig { 
               case os.Interrupt: 
                     handleSignal(sig) 
               case syscall.SIGTERM: 
                     handleSignal(sig) 
               case syscall.SIGHUP: 
                     fmt.Println("Got:", sig) 
                     os.Exit(-1) 
               } 
         } 
   }() 

在这里,您可以看到,当捕获到特定信号时,不一定要调用单独的函数;也可以在for循环内处理它,就像syscall.SIGHUP一样。但是,我认为使用命名函数更好,因为它使 Go 代码更易于阅读和修改。好处是 Go 有一个处理所有信号的中心位置,这使得很容易找出程序的运行情况。

此外,h2s.go专门处理SIGHUP信号,尽管SIGHUP信号仍将终止程序;但是,这次是我们的决定。

请记住,通常最好让一个信号处理程序来停止程序,否则您将不得不通过发出kill -9命令来终止它。

h2s.go的最后一部分如下:

   for { 
         fmt.Printf(".") 
         time.Sleep(10 * time.Second) 
   } 
}

执行h2s.go并从另一个 shell 发送四个信号(SIGINTSIGTERMSIGHUPSIGKILL)给它将生成以下输出:

$ go build h2s.go
$ ./h2s
..* Got: interrupt
* Got: terminated
.Got: hangup
.Killed: 9

构建h2s.go的原因是更容易找到自主程序的进程 ID:go run命令在后台构建了一个临时可执行程序,这种情况下提供的灵活性较少。如果要改进h2s.go,可以让它调用os.Getpid()来打印其进程 ID,这样就不必自己查找了。

程序在收到无法处理的SIGKILL信号之前处理了三个信号,因此终止了!

捕获每个可以处理的信号

这一小节将介绍一种简单的技术,允许您捕捉每个可以处理的信号:再次强调,您不能处理所有信号!程序将在收到SIGTERM信号后停止运行。

程序的名称将是catchAll.go,将分为三部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "os/signal" 
   "syscall" 
   "time" 
) 

func handleSignal(signal os.Signal) { 
   fmt.Println("* Got:", signal) 
} 

程序的第二部分如下:

func main() { 
   sigs := make(chan os.Signal, 1) 
   signal.Notify(sigs) 
   go func() { 
         for { 
               sig := <-sigs 
               switch sig { 
               case os.Interrupt: 
                     handleSignal(sig) 
               case syscall.SIGTERM: 
                     handleSignal(sig) 
                     os.Exit(-1) 
               case syscall.SIGUSR1: 
                     handleSignal(sig) 
               default: 
                     fmt.Println("Ignoring:", sig) 
               } 
         } 
   }() 

在这种情况下,调用signal.Notify()的方式对您的代码产生了影响。如果您没有定义任何特定的信号,程序将能够处理任何可以处理的信号。但是,匿名函数内的for循环只处理了三个信号,而忽略了其余的!请注意,我认为这是在 Go 中处理信号的最佳方式:捕获一切,同时只处理您感兴趣的信号。但是,有些人认为明确处理您处理的内容是更好的方法。这里没有对错之分。

catchAll.go程序在收到SIGHUP时不会终止,因为switch块的default情况处理了它。

最后一部分是对time.Sleep()函数的预期调用:

   for { 
         fmt.Printf(".") 
         time.Sleep(10 * time.Second) 
   } 
} 

执行catchAll.go将产生以下输出:

$ ./catchAll
.Ignoring: hangup
.......................................* Got: interrupt
* Got: user defined signal 1
.Ignoring: user defined signal 2
Ignoring: hangup
.* Got: terminated
$

重新审视旋转日志文件!

正如我在第七章中告诉过您,本章将向您介绍一种技术,可以让您以更常规的方式结束程序并旋转日志文件,这是通过信号和信号处理来实现的。

rotateLog.go的新版本名称将是rotateSignals.go,将分为四个部分呈现。此外,当实用程序接收os.Interrupt时,它将旋转当前日志文件,而当它接收syscall.SIGTERM时,它将终止执行。可以处理的任何其他信号都将创建一个日志条目,而不会执行其他操作。

rotateSignals.go的第一部分是预期的序言:

package main 

import ( 
   "fmt" 
   "log" 
   "os" 
   "os/signal" 
   "strconv" 
   "syscall" 
   "time" 
) 

var TOTALWRITES int = 0 
var openLogFile os.File 

rotateSignals.go的第二部分包含以下 Go 代码:

func rotateLogFile(filename string) error { 
   openLogFile.Close() 
   os.Rename(filename, filename+"."+strconv.Itoa(TOTALWRITES)) 
   err := setUpLogFile(filename) 
   return err 
} 

func setUpLogFile(filename string) error { 
   openLogFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) 
   if err != nil { 
         return err 
   } 
   log.SetOutput(openLogFile) 
   return nil 
} 

您刚刚在这里定义了两个执行两项任务的函数。rotateSignals.go的第三部分包含以下 Go 代码:

func main() { 
   filename := "/tmp/myLog.log" 
   err := setUpLogFile(filename) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(-1) 
   } 

   sigs := make(chan os.Signal, 1) 
   signal.Notify(sigs) 

再次,所有信号都将被捕获。rotateSignals.go的最后一部分如下:

   go func() { 
         for { 
               sig := <-sigs 
               switch sig { 
               case os.Interrupt: 
                     rotateLogFile(filename) 
                     TOTALWRITES++ 
               case syscall.SIGTERM: 
                     log.Println("Got:", sig) 
                     openLogFile.Close() 
                     TOTALWRITES++ 
                     fmt.Println("Wrote", TOTALWRITES, "log entries in total!") 
                     os.Exit(-1) 
               default: 
                     log.Println("Got:", sig) 
                     TOTALWRITES++ 
               } 
         } 
   }() 

   for { 
         time.Sleep(10 * time.Second) 
   } 
} 

正如您所看到的,rotateSignals.go通过为每个信号编写一个日志条目记录了它接收到的信号的信息。虽然呈现rotateSignals.go的整个代码是不错的,但是看到diff(1)实用程序的输出以显示rotateLog.gorotateSignals.go之间的代码差异将是非常有教育意义的:

$ diff rotateLog.go rotateSignals.go
6a7
>     "os/signal"
7a9
>     "syscall"
12,13d13
< var ENTRIESPERLOGFILE int = 100
< var WHENTOSTOP int = 230
33d32
<     numberOfLogEntries := 0
41,51c40,59
<     for {
<           log.Println(numberOfLogEntries, "This is a test log entry")
<           numberOfLogEntries++
<           TOTALWRITES++
<           if numberOfLogEntries > ENTRIESPERLOGFILE {
<                 _ = rotateLogFile(filename)
<                 numberOfLogEntries = 0
<           }
<           if TOTALWRITES > WHENTOSTOP {
<                 _ = rotateLogFile(filename)
<                 break
---
>     sigs := make(chan os.Signal, 1)
>     signal.Notify(sigs)
>
>     go func() {
>           for {
>                 sig := <-sigs
>                 switch sig {
>                 case os.Interrupt:
>                       rotateLogFile(filename)
>                       TOTALWRITES++
>                 case syscall.SIGTERM:
>                       log.Println("Got:", sig)
>                       openLogFile.Close()
>                       TOTALWRITES++
>                       fmt.Println("Wrote", TOTALWRITES, "log entries in total!")
>                       os.Exit(-1)
>                 default:
>                       log.Println("Got:", sig)
>                       TOTALWRITES++
>                 }
53c61,64
<           time.Sleep(time.Second)
---
>     }()
>
>     for {
>           time.Sleep(10 * time.Second)
55d65
<     fmt.Println("Wrote", TOTALWRITES, "log entries!")

这里的好处是,在rotateSignals.go中使用信号使得rotateLog.go中使用的大多数全局变量变得不必要,因为现在您可以通过发送信号来控制实用程序。此外,rotateSignals.go的设计和结构比rotateLog.go更简单,因为您只需要理解匿名函数的功能。

执行rotateSignals.go并向其发送一些信号后,/tmp/myLog.log的内容将如下所示:

$ cat /tmp/myLog.log
2017/06/03 14:53:33 Got: user defined signal 1
2017/06/03 14:54:08 Got: user defined signal 1
2017/06/03 14:54:12 Got: user defined signal 2
2017/06/03 14:54:19 Got: terminated

此外,您将在/tmp目录下有以下文件:

$ ls -l /tmp/myLog.log*
-rw-r--r--  1 mtsouk  wheel  177 Jun  3 14:54 /tmp/myLog.log
-rw-r--r--  1 mtsouk  wheel  106 Jun  3 13:42 /tmp/myLog.log.0

改进文件复制

cp(1)实用程序接收SIGINFO信号时,它会打印有用的信息,如下所示:

$ cp FileToCopy /tmp/copy
FileToCopy -> /tmp/copy  26%
FileToCopy -> /tmp/copy  29%
FileToCopy -> /tmp/copy  31%

因此,本节的其余部分将为cp(1)命令的 Go 实现实现相同的功能。本节中的 Go 代码将基于cp.go程序,因为当使用较小的缓冲区大小时,它可能非常慢,从而为我们提供测试时间。新的复制实用程序的名称将是cpSignal.go,将分为四个部分呈现。

cpSignal.gocp.go之间的基本区别在于cpSignal.go应该找到输入文件的大小,并在给定点保持已写入的字节数。除了这些修改之外,您不必担心其他任何事情,因为两个版本的核心功能,即复制文件,完全相同。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "io" 
   "os" 
   "os/signal" 
   "path/filepath" 
   "strconv" 
   "syscall" 
) 

var BUFFERSIZE int64 
var FILESIZE int64 
var BYTESWRITTEN int64 

为了使开发人员更容易,程序引入了两个名为FILESIZEBYTESWRITTEN的全局变量,它们分别保持输入文件的大小和已写入的字节数。这两个变量都被处理SIGINFO信号的函数使用。

第二部分如下:

func Copy(src, dst string, BUFFERSIZE int64) error { 
   sourceFileStat, err := os.Stat(src) 
   if err != nil { 
         return err 
   } 

   FILESIZE = sourceFileStat.Size() 

   if !sourceFileStat.Mode().IsRegular() { 
         return fmt.Errorf("%s is not a regular file.", src) 
   } 

   source, err := os.Open(src) 
   if err != nil { 
         return err 
   } 
   defer source.Close() 

   _, err = os.Stat(dst) 
   if err == nil { 
         return fmt.Errorf("File %s already exists.", dst) 
   } 

   destination, err := os.Create(dst) 
   if err != nil { 
         return err 
   } 
   defer destination.Close() 

   if err != nil { 
         panic(err) 
   } 

   buf := make([]byte, BUFFERSIZE) 
   for { 
         n, err := source.Read(buf) 
         if err != nil && err != io.EOF { 
               return err 
         } 
         if n == 0 { 
               break 
         } 
         if _, err := destination.Write(buf[:n]); err != nil { 
               return err 
         } 
         BYTESWRITTEN = BYTESWRITTEN + int64(n) 
   } 
   return err 
} 

在这里,您使用sourceFileStat.Size()函数获取输入文件的大小,并设置FILESIZE全局变量的值。

第三部分是您定义信号处理的地方:

func progressInfo() { 
   progress := float64(BYTESWRITTEN) / float64(FILESIZE) * 100 
   fmt.Printf("Progress: %.2f%%\n", progress) 
} 

func main() { 
   if len(os.Args) != 4 { 
         fmt.Printf("usage: %s source destination BUFFERSIZE\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   source := os.Args[1] 
   destination := os.Args[2] 
   BUFFERSIZE, _ = strconv.ParseInt(os.Args[3], 10, 64) 
   BYTESWRITTEN = 0 

   sigs := make(chan os.Signal, 1) 
   signal.Notify(sigs) 

在这里,您选择捕获所有信号。但是,匿名函数的 Go 代码只会在接收到syscall.SIGINFO信号后调用progressInfo()

如果您想要一种优雅地终止程序的方法,您可能希望使用SIGINT信号,因为当捕获所有信号时,优雅地终止程序将不再可能:您将需要发送SIGKILL来终止程序,这有点残酷。

cpSignal.go的最后一部分如下:

   go func() { 
         for {
               sig := <-sigs 
               switch sig { 
               case syscall.SIGINFO:
                     progressInfo() 
               default: 
                     fmt.Println("Ignored:", sig) 
               } 
         } 
   }() 

   fmt.Printf("Copying %s to %s\n", source, destination) 
   err := Copy(source, destination, BUFFERSIZE) 
   if err != nil { 
         fmt.Printf("File copying failed: %q\n", err) 
   } 
} 

执行cpSignal.go并向其发送两个SIGINFO信号将生成以下输出:

$ ./cpSignal FileToCopy /tmp/copy 2
Copying FileToCopy to /tmp/copy
Ignored: user defined signal 1
Progress: 21.83%
^CIgnored: interrupt
Progress: 29.78%

绘制数据

本节将开发一个实用程序,它将读取多个日志文件,并将创建一个图像,其中每个条将表示在日志文件中找到给定 IP 地址的次数。

然而,Unix 哲学告诉我们,我们应该制作两个不同的实用程序,而不是开发一个单一的实用程序:一个用于处理日志文件并创建报告,另一个用于绘制第一个实用程序生成的数据:这两个实用程序将使用 Unix 管道进行通信。尽管本节将实现第一种方法,但您将在本章的*The * plotIP.go utility revisited部分中看到第二种方法的实现。

所提供实用程序的想法来自我为一本杂志撰写的教程,我在其中开发了一个小型的 Go 程序进行绘图:即使是小型和天真的程序也可以激发您开发更大的东西,因此不要低估它们的力量。

实用程序的名称将是plotIP.go,并且将分为七个部分:好处是plotIP.go将重用countIP.gofindIP.go的一些代码。plotIP.go唯一不做的事情就是将文本写入图像,因此您只能绘制条形图,而不知道实际值或特定条形图的相应日志文件:您可以尝试将文本功能添加到程序中作为练习。

此外,plotIP.go将需要至少三个参数,即图像的宽度和高度以及将要使用的日志文件的名称:为了使plotIP.go更小,plotIP.go将不使用flag包,并假定您将按正确的顺序提供其参数。如果您提供更多的参数,它将把它们视为日志文件。

plotIP.go的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "image" 
   "image/color" 
   "image/png" 
   "io" 
   "os" 
   "path/filepath" 
   "regexp" 
   "strconv" 
) 

var m *image.NRGBA
var x int 
var y int 
var barWidth int 

这些全局变量与图像的尺寸(xy)、图像作为 Go 变量(m)以及其中一个条形图的宽度(barWidth)有关,该宽度取决于图像的大小和将要绘制的条形图的数量。请注意,在这里使用xy作为变量名而不是像IMAGEWIDTHIMAGEHEIGHT之类的名称可能有点错误和危险。

第二部分是以下内容:

func findIP(input string) string { 
   partIP := "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])" 
   grammar := partIP + "\\." + partIP + "\\." + partIP + "\\." + partIP 
   matchMe := regexp.MustCompile(grammar) 
   return matchMe.FindString(input) 
} 

func plotBar(width int, height int, color color.RGBA) { 
   xx := 0
   for xx < barWidth { 
         yy := 0 
         for yy < height { 
               m.Set(xx+width, y-yy, color) 
               yy = yy + 1 
         } 
         xx = xx + 1 
   } 
} 

在这里,您实现了一个名为plotBar()的 Go 函数,该函数根据条形图的高度、宽度和颜色进行绘制。这个函数是plotIP.go中最具挑战性的部分。

第三部分包含以下 Go 代码:

func getColor(x int) color.RGBA { 
   switch {

   case x == 0: 
         return color.RGBA{0, 0, 255, 255} 
   case x == 1: 
         return color.RGBA{255, 0, 0, 255} 
   case x == 2: 
         return color.RGBA{0, 255, 0, 255} 
   case x == 3: 
         return color.RGBA{255, 255, 0, 255} 
   case x == 4: 
         return color.RGBA{255, 0, 255, 255} 
   case x == 5: 
         return color.RGBA{0, 255, 255, 255} 
   case x == 6: 
         return color.RGBA{255, 100, 100, 255} 
   case x == 7: 
         return color.RGBA{100, 100, 255, 255} 
   case x == 8: 
         return color.RGBA{100, 255, 255, 255} 
   case x == 9: 
         return color.RGBA{255, 255, 255, 255} 
   } 
   return color.RGBA{0, 0, 0, 255} 
} 

此函数允许您定义输出中将出现的颜色:如果需要,可以更改它们。

第四部分包含以下 Go 代码:

func main() { 
   var data []int 
   arguments := os.Args 
   if len(arguments) < 4 { 
         fmt.Printf("%s X Y IP input\n", filepath.Base(arguments[0])) 
         os.Exit(0) 
   } 

   x, _ = strconv.Atoi(arguments[1]) 
   y, _ = strconv.Atoi(arguments[2]) 
   WANTED := arguments[3] 
   fmt.Println("Image size:", x, y) 

在这里,您可以读取所需的 IP 地址,该地址保存在WANTED变量中,并读取生成的 PNG 图像的尺寸。

第五部分包含以下 Go 代码:

   for _, filename := range arguments[4:] { 
         count := 0 
         fmt.Println(filename) 
         f, err := os.Open(filename) 
         if err != nil { 
               fmt.Fprintf(os.Stderr, "Error: %s\n", err) 
               continue 
         } 
         defer f.Close() 

         r := bufio.NewReader(f) 
         for { 
               line, err := r.ReadString('\n') 
               if err == io.EOF { 
                     break 
               } 

if err != nil { 
                fmt.Fprintf(os.Stderr, "Error in file: %s\n", err) 
                     continue 
               } 
               ip := findIP(line) 
               if ip == WANTED { 
                     count++

               } 
         } 
         data = append(data, count) 
   } 

在这里,您逐个处理输入的日志文件,并将计算的值存储在data切片中。错误消息将打印到os.Stderr:从将错误消息打印到os.Stderr中获得的主要优势是,您可以轻松地将错误消息重定向到文件,同时以不同的方式使用写入到os.Stdout的数据。

plotIP.go的第六部分包含以下 Go 代码:

   fmt.Println("Slice length:", len(data)) 
   if len(data)*2 > x { 
         fmt.Println("Image size (x) too small!") 
         os.Exit(-1) 
   } 

   maxValue := data[0] 
   for _, temp := range data { 
         if maxValue < temp { 
               maxValue = temp 
         } 
   } 

   if maxValue > y { 
         fmt.Println("Image size (y) too small!") 
         os.Exit(-1) 
   } 
   fmt.Println("maxValue:", maxValue) 
   barHeighPerUnit := int(y / maxValue) 
   fmt.Println("barHeighPerUnit:", barHeighPerUnit) 
   PNGfile := WANTED + ".png" 
   OUTPUT, err := os.OpenFile(PNGfile, os.O_CREATE|os.O_WRONLY, 0644) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(-1) 
   } 
   m = image.NewNRGBA(image.Rectangle{Min: image.Point{0, 0}, Max: image.Point{x, y}}) 

在这里,您可以计算有关绘图的事项,并使用os.OpenFile()创建输出图像文件。由plotIP.go实用程序生成的 PNG 文件以给定的 IP 地址命名,以使事情变得更简单。

plotIP.go的 Go 代码的最后一部分如下:

   i := 0 
   barWidth = int(x / len(data)) 
   fmt.Println("barWidth:", barWidth) 
   for _, v := range data { 
         c := getColor(v % 10) 
         yy := v * barHeighPerUnit 
         plotBar(barWidth*i, yy, c) 
         fmt.Println("plotBar", barWidth*i, yy) 
         i = i + 1 
   } 
   png.Encode(OUTPUT, m) 
} 

在这里,您可以读取data切片的值,并通过调用plotBar()函数为每个值创建一个条形图。

执行plotIP.go将生成以下输出:

$ go run plotIP.go 1300 1500 127.0.0.1 /tmp/log.*
Image size: 1300 1500
/tmp/log.1
/tmp/log.2
/tmp/log.3
Slice length: 3
maxValue: 1500
barHeighPerUnit: 1
barWidth: 433
plotBar 0 1500
plotBar 433 1228
plotBar 866 532
$  ls -l 127.0.0.1.png
-rw-r--r-- 1 mtsouk mtsouk 11023 Jun  5 18:36 127.0.0.1.png

然而,除了生成的文本输出之外,重要的是生成的 PNG 文件,可以在以下图中看到:

由 plotIP.go 实用程序生成的输出

如果要将错误消息保存到不同的文件中,可以使用以下命令的变体:

$ go run plotIP.go 130 150 127.0.0.1 doNOTExist 2> err
Image size: 130 150
doNOTExist
Slice length: 0
$ cat err
Error: open doNOTExist: no such file or directory
panic: runtime error: index out of range

goroutine 1 [running]:
main.main()
     /Users/mtsouk/Desktop/goBook/ch/ch8/code/plotIP.go:112 +0x12de
exit status 2

以下命令通过将其发送到/dev/null来丢弃所有错误消息:

$ go run plotIP.go 1300 1500 127.0.0.1 doNOTExist 2>/dev/null
Image size: 1300 1500
doNOTExist
Slice length: 0  

在 Go 中的 Unix 管道

我们在第六章文件输入和输出中首次讨论了管道。管道有两个严重的限制:首先,它们通常是单向通信的,其次,它们只能在具有共同祖先的进程之间使用。

管道背后的一般思想是,如果您没有要处理的文件,应该等待从标准输入获取输入。同样,如果没有要求将输出保存到文件,应该将输出写入标准输出,供用户查看或供其他程序处理。因此,管道可用于在两个进程之间流式传输数据,而不创建任何临时文件。

本节将呈现一些使用 Unix 管道编写的简单实用程序,以增加清晰度。

从标准输入读取

为了开发支持 Unix 管道的 Go 应用程序,您需要知道如何从标准输入读取。

开发的程序名为readSTDIN.go,将分为三部分呈现。

程序的第一部分是预期的序言:

package main 

import ( 
   "bufio" 
   "fmt" 
   "os" 
) 

readSTDIN.go的第二部分包含以下 Go 代码:

func main() { 
   filename := "" 
   var f *os.File 
   arguments := os.Args 
   if len(arguments) == 1 { 
         f = os.Stdin 
   } else { 
         filename = arguments[1] 
         fileHandler, err := os.Open(filename) 
         if err != nil { 
               fmt.Printf("error opening %s: %s", filename, err) 
               os.Exit(1) 
         } 
         f = fileHandler 
   } 
   defer f.Close() 

在这里,您可以确定是否有实际文件要处理,这可以通过程序的命令行参数数量来确定。如果没有要处理的文件,您将尝试从os.Stdin读取数据。确保您理解所呈现的技术,因为在本章中将多次使用它。

readSTDIN.go的最后一部分如下:

   scanner := bufio.NewScanner(f) 
   for scanner.Scan() { 
         fmt.Println(">", scanner.Text()) 
   } 
} 

这段代码无论是处理实际文件还是os.Stdin都是一样的,这是因为在 Unix 中一切都是文件。请注意,程序输出以>字符开头。

执行readSTDIN.go将生成以下输出:

$ cat /tmp/testfile
1
2
$ go run readSTDIN.go /tmp/testFile
> 1
> 2
$ cat /tmp/testFile | go run readSTDIN.go
> 1
> 2
$ go run readSTDIN.go
3
> 3
2
> 2
1
> 1

在最后一种情况下,readSTDIN.go会回显它读取的每一行,因为输入是逐行读取的:cat(1)实用程序的工作方式相同。

将数据发送到标准输出

本小节将向您展示如何以比仅使用fmt.Println()fmt标准 Go 包中的任何其他函数更好的方式将数据发送到标准输出。Go 程序将被命名为writeSTDOUT.go,并将分为三部分呈现给您。

第一部分如下:

package main 

import ( 
   "io" 
   "os" 
) 

writeSTDOUT.go的第二部分包含以下 Go 代码:

func main() { 
   myString := "" 
   arguments := os.Args 
   if len(arguments) == 1 { 
         myString = "You did not give an argument!" 
   } else { 
         myString = arguments[1] 
   } 

writeSTDOUT.go的最后一部分如下:

   io.WriteString(os.Stdout, myString) 
   io.WriteString(os.Stdout, "\n") 
} 

唯一微妙的是,在使用io.WriteString()将数据写入os.Stdout之前,您需要将文本放入一个切片中。

执行writeSTDOUT.go将生成以下输出:

$ go run writeSTDOUT.go 123456
123456
$ go run writeSTDOUT.go
You do not give an argument!

在 Go 中实现 cat(1)

本小节将呈现cat(1)命令行实用程序的 Go 版本。如果您向cat(1)提供一个或多个命令行参数,那么cat(1)将在屏幕上打印它们的内容。但是,如果您只在 Unix shell 中键入cat(1),那么cat(1)将等待您的输入,当您键入Ctrl + D时输入将终止。

Go 实现的名称将是cat.go,将分为三部分呈现。

cat.go的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
) 

第二部分如下:

func catFile(filename string) error { 
   f, err := os.Open(filename) 
   if err != nil { 
         return err 
   } 
   defer f.Close() 
   scanner := bufio.NewScanner(f) 
   for scanner.Scan() { 
         fmt.Println(scanner.Text()) 
   } 
   return nil 
} 

cat.go实用程序需要处理真实文件时,将调用catFile()函数。有一个函数来完成您的工作可以使程序设计更好。

最后一部分包含以下 Go 代码:

func main() { 
   filename := "" 
   arguments := os.Args 
   if len(arguments) == 1 { 
         io.Copy(os.Stdout, os.Stdin) 
         os.Exit(0) 
   } 

   filename = arguments[1] 
   err := catFile(filename) 
   if err != nil { 
         fmt.Println(err) 
   } 
} 

因此,如果程序没有参数,则假定它必须从os.Stdin读取。在这种情况下,它只会回显您给它的每一行。如果程序有参数,则它将使用catFile()函数处理第一个参数作为文件。

执行cat.go将生成以下输出:

$ go run cat.go /tmp/testFile  |  go run cat.go
1
2
$ go run cat.go
Mihalis
Mihalis
Tsoukalos
Tsoukalos $ echo "Mihalis Tsoukalos" | go run cat.go
Mihalis Tsoukalos

重新审视 plotIP.go 实用程序

正如本章的前一节所承诺的,本节将创建两个单独的实用程序,结合起来将实现plotIP.go的功能。个人而言,我更喜欢有两个单独的实用程序,并在需要时将它们结合起来,而不是只有一个实用程序可以执行两个或更多任务。

这两个实用程序的名称将是extractData.goplotData.go。正如您可以轻松理解的那样,只有第二个实用程序才能够从标准输入获取输入,只要第一个实用程序将其输出打印在标准输出上,要么使用os.Stdout,这是正确的方式,要么使用fmt.Println(),通常可以完成任务。

我认为我现在应该告诉您我的小秘密:我首先创建了extractData.goplotData.go,然后开发了plotIP.go,因为开发两个单独的实用程序比开发一个做所有事情的大型实用程序更容易!此外,使用两个不同的实用程序允许您使用标准 Unix 实用程序(如tail(1)sort(1)head(1))过滤extractData.go的输出,这意味着您可以以不同的方式修改数据,而无需编写任何额外的 Go 代码。

将两个命令行实用程序并创建一个实用程序来实现这两个实用程序的功能要比将一个大型实用程序分割成两个或更多不同实用程序的功能更容易,因为后者通常需要更多的变量和更多的错误检查。

extractData.go实用程序将分为四个部分;第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "regexp" 
) 

extractData.go的第二部分包含以下 Go 代码:

func findIP(input string) string { 
   partIP := "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])" 
   grammar := partIP + "\\." + partIP + "\\." + partIP + "\\." + partIP 
   matchMe := regexp.MustCompile(grammar) 
   return matchMe.FindString(input) 
} 

您应该熟悉findIP()函数,您在第七章中看到了findIP.go

extractData.go的第三部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) < 3 { 
         fmt.Printf("%s IP <files>\n", filepath.Base(os.Args[0])) 
         os.Exit(-1) 
   } 

   WANTED := arguments[1] 
   for _, filename := range arguments[2:] { 
         count := 0 
         buf := []byte(filename)
         io.WriteString(os.Stdout, string(buf)) 
         f, err := os.Open(filename) 
         if err != nil { 
               fmt.Fprintf(os.Stderr, "Error: %s\n", err) 
               continue 
         } 
         defer f.Close() 

这里使用buf变量是多余的,因为filename是一个字符串,io.WriteString()期望一个字符串:这只是我的习惯,将filename的值放入字节片中。如果您愿意,可以将其删除。

再次,大部分 Go 代码来自plotIP.go实用程序。extractData.go的最后一部分如下:

         r := bufio.NewReader(f) 
         for { 
               line, err := r.ReadString('\n') 
               if err == io.EOF { 
                     break 
               } else if err != nil { 
                     fmt.Fprintf(os.Stderr, "Error in file: %s\n", err) 
                     continue 
               } 

               ip := findIP(line) 
               if ip == WANTED { 
                     count = count + 1 
               } 
         } 
         buf = []byte(strconv.Itoa(count))
         io.WriteString(os.Stdout, " ") 
         io.WriteString(os.Stdout, string(buf)) 
         io.WriteString(os.Stdout, "\n") 
   } 
} 

在这里,extractData.go将其输出写入标准输出(os.Stdout),而不是使用fmt包的函数,以便更兼容管道。extractData.go实用程序至少需要两个参数:IP 地址和日志文件,但它可以处理任意数量的日志文件。

您可能希望将第三部分中的filename值的打印移至此处,以便将所有打印命令放在同一位置。

执行extractData.go将生成以下输出:

$ ./extractData 127.0.0.1 access.log{,.1}
access.log 3099
access.log.1 6333

虽然extractData.go在每行打印两个值,但plotData.go只会使用第二个字段。最好的方法是使用awk(1)过滤extractData.go的输出:

$ ./extractData 127.0.0.1 access.log{,.1} | awk '{print $2}'
3099
6333

正如您所理解的,awk(1)允许您对生成的值进行更多操作。

plotData.go实用程序也将分为六个部分;它的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "image" 
   "image/color" 
   "image/png" 
   "os" 
   "path/filepath" 
   "strconv" 
) 

var m *image.NRGBA 
var x int 
var y int 
var barWidth int 

再次,使用全局变量是为了避免向实用程序的某些函数传递太多参数。

plotData.go的第二部分包含以下 Go 代码:

func plotBar(width int, height int, color color.RGBA) { 
   xx := 0
   for xx < barWidth { 
         yy := 0 
         for yy < height { 
               m.Set(xx+width, y-yy, color) 
               yy = yy + 1 
         } 
         xx = xx + 1 
   } 
} 

plotData.go的第三部分包含以下 Go 代码:

func getColor(x int) color.RGBA { 
   switch {
   case x == 0: 
         return color.RGBA{0, 0, 255, 255} 
   case x == 1: 
         return color.RGBA{255, 0, 0, 255} 
   case x == 2: 
         return color.RGBA{0, 255, 0, 255} 
   case x == 3: 
         return color.RGBA{255, 255, 0, 255} 
   case x == 4: 
         return color.RGBA{255, 0, 255, 255} 
   case x == 5: 
         return color.RGBA{0, 255, 255, 255} 
   case x == 6: 
         return color.RGBA{255, 100, 100, 255} 
   case x == 7: 
         return color.RGBA{100, 100, 255, 255} 
   case x == 8: 
         return color.RGBA{100, 255, 255, 255} 
   case x == 9: 
         return color.RGBA{255, 255, 255, 255} 
   } 
   return color.RGBA{0, 0, 0, 255} 
} 

plotData.go的第四部分包含以下 Go 代码:

func main() { 
   var data []int 
   var f *os.File 
   arguments := os.Args 
   if len(arguments) < 3 { 
         fmt.Printf("%s X Y input\n", filepath.Base(arguments[0])) 
         os.Exit(0) 
   } 

   if len(arguments) == 3 { 
         f = os.Stdin 
   } else { 
         filename := arguments[3] 
         fTemp, err := os.Open(filename) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(0) 
         } 
         f = fTemp 
   } 
   defer f.Close() 

   x, _ = strconv.Atoi(arguments[1]) 
   y, _ = strconv.Atoi(arguments[2]) 
   fmt.Println("Image size:", x, y) 

plotData.go的第五部分如下:

   scanner := bufio.NewScanner(f) 
   for scanner.Scan() { 
         value, err := strconv.Atoi(scanner.Text()) 
         if err == nil { 
               data = append(data, value) 
         } else { 
               fmt.Println("Error:", value) 
         } 
   } 

   fmt.Println("Slice length:", len(data)) 
   if len(data)*2 > x { 
         fmt.Println("Image size (x) too small!") 
         os.Exit(-1) 
   } 

   maxValue := data[0] 
   for _, temp := range data { 
         if maxValue < temp { 
               maxValue = temp 
         } 
   } 

   if maxValue > y { 
         fmt.Println("Image size (y) too small!") 
         os.Exit(-1) 
   } 
   fmt.Println("maxValue:", maxValue) 
   barHeighPerUnit := int(y / maxValue) 
   fmt.Println("barHeighPerUnit:", barHeighPerUnit) 

plotData.go的最后一部分如下:

   PNGfile := arguments[1] + "x" + arguments[2] + ".png" 
   OUTPUT, err := os.OpenFile(PNGfile, os.O_CREATE|os.O_WRONLY, 0644) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(-1) 
   } 
   m = image.NewNRGBA(image.Rectangle{Min: image.Point{0, 0}, Max: image.Point{x, y}}) 

   i := 0 
   barWidth = int(x / len(data)) 
   fmt.Println("barWidth:", barWidth) 
   for _, v := range data { 
         c := getColor(v % 10) 
         yy := v * barHeighPerUnit 
         plotBar(barWidth*i, yy, c) 
         fmt.Println("plotBar", barWidth*i, yy) 
         i = i + 1 
   } 

   png.Encode(OUTPUT, m) 
} 

虽然您可以单独使用plotData.go,但使用extractData.go的输出作为plotData.go的输入就像执行以下命令一样简单:

$ ./extractData.go 127.0.0.1 access.log{,.1} | awk '{print $2}' | ./plotData 6000 6500
Image size: 6000 6500
Slice length: 2
maxValue: 6333
barHeighPerUnit: 1
barWidth: 3000
plotBar 0 3129
plotBar 3000 6333
$ ls -l 6000x6500.png
-rw-r--r-- 1 mtsouk mtsouk 164915 Jun  5 18:25 6000x6500.png

前一个命令的图形输出可以是一个图像,就像您在以下图中看到的那样:

plotData.go 实用程序生成的输出

在 Go 中使用 Unix 套接字

存在两种类型的套接字:Unix 套接字和网络套接字。网络套接字将在第十二章,网络编程中解释,而 Unix 套接字将在本节中简要解释。然而,由于所呈现的 Go 函数也适用于 TCP/IP 套接字,因此您仍需等待第十二章,网络编程,以充分理解它们,因为它们在这里不会被解释。因此,本节将仅呈现 Unix 套接字客户端的 Go 代码,这是一个使用 Unix 套接字(一种特殊的 Unix 文件)来读取和写入数据的程序。该程序的名称将是readUNIX.go,将分为三部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
   "io" 
   "net" 
   "strconv" 
   "time" 
) 

readUNIX.go的第二部分如下:

func readSocket(r io.Reader) { 
   buf := make([]byte, 1024) 
   for { 
         n, _ := r.Read(buf[:]) 
         fmt.Print("Read: ", string(buf[0:n])) 
   } 
} 

最后一部分包含以下 Go 代码:

func main() { 
   c, _ := net.Dial("unix", "/tmp/aSocket.sock") 
   defer c.Close() 

   go readSocket(c) 
   n := 0 
   for { 
         message := []byte("Hi there: " + strconv.Itoa(n) + "\n") 
         _, _ = c.Write(message) 
         time.Sleep(5 * time.Second) 
         n = n + 1 
   } 
} 

使用readUNIX.go需要另一个进程的存在,该进程也读取和写入同一个套接字文件(/tmp/aSocket.sock)。

生成的输出取决于另一部分的实现:在这种情况下,输出如下:

$ go run readUNIX.go
Read: Hi there: 0
Read: Hi there: 1

如果找不到套接字文件或没有程序在监听它,您将收到以下错误消息:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x10cfe77]

goroutine 1 [running]:
main.main()
      /Users/mtsouk/Desktop/goBook/ch/ch8/code/readUNIX.go:21 +0x67
exit status 2

Go 中的 RPC

RPC 代表远程过程调用,是一种执行对远程服务器的函数调用并在客户端获取答案的方式。再次,您将不得不等到第十二章,网络编程,以了解如何在 Go 中开发 RPC 服务器和 RPC 客户端。

在 Go 中编程 Unix shell

本节将简要而天真地呈现可以用作 Unix shell 开发基础的 Go 代码。除了exit命令外,程序能识别的唯一其他命令是version命令,它只是打印程序的版本。所有其他用户输入都将在屏幕上回显。

UNIXshell.go的 Go 代码将分为三部分呈现。然而,在此之前,我将向您展示 shell 的第一个版本,其中主要包含注释,以更好地理解我通常如何开始实现一个相对具有挑战性的程序:

package main 

import ( 
   "fmt" 
) 

func main() { 

   // Present prompt 

   // Read a line 

   // Get the first word of the line 

   // If it is a built-in shell command, execute the command 

   // otherwise, echo the command 

} 

这更多或多少是我作为起点使用的算法:好处是注释简要地展示了程序的操作方式。请记住,算法不依赖于编程语言。之后,开始实现事物会更容易,因为你知道你想要做什么。

因此,shell 最终版本的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "os" 
   "strings" 
) 

var VERSION string = "0.2" 

第二部分如下:

func main() { 
   scanner := bufio.NewScanner(os.Stdin) 
   fmt.Print("> ") 
   for scanner.Scan() { 

         line := scanner.Text() 
         words := strings.Split(line, " ") 
         command := words[0] 

在这里,您只需逐行从用户那里读取输入并找出输入的第一个单词。

UNIXshell.go的最后一部分如下:

         switch command { 
         case "exit": 
               fmt.Println("Exiting...") 
               os.Exit(0) 
         case "version": 
               fmt.Println(VERSION) 
         default: 
               fmt.Println(line) 
         } 

         fmt.Print("> ") 
   } 
} 

上述的 Go 代码检查用户给出的命令并相应地采取行动。

执行UNIXshell.go并与其交互将生成以下输出:

$ go run UNIXshell.go
> version
0.2
> ls -l
ls -l
> exit
Exiting...

如果你想了解如何在 Go 中创建自己的 Unix shell,可以访问github.com/elves/elvish

另一个小的 Go 更新

在我写这一章时,Go 已经更新:这是一个小更新,主要是修复了一些错误:

$ date
Thu May 25 06:30:53 EEST 2017
$ go version
go version go1.8.3 darwin/amd64

练习

  1. plotIP.go的绘图功能放入一个 Go 包中,并使用该包重写plotIP.goplotData.go

  2. 查看第六章的ddGo.go Go 代码,文件输入和输出,以便在接收SIGINFO信号时打印有关其进度的信息。

  3. 更改cat.go的 Go 代码以支持多个输入文件。

  4. 更改plotData.go的代码,以便在生成的图像上打印网格线。

  5. 更改plotData.go的代码,以便在图表的条之间留出一点空间。

  6. 尝试通过为其添加新功能使UNIXshell.go程序变得更好一点。

摘要

在本章中,我们讨论了许多有趣和方便的主题,包括信号处理和在 Go 中创建图形图像。此外,我们还教会了您如何在 Go 程序中添加对 Unix 管道的支持。

在下一章中,我们将讨论 Go 最独特的特性,即 goroutines。您将学习什么是 goroutine,如何创建和同步它们,以及如何创建通道和管道。请记住,许多人来学习现代和安全的编程语言,但留下来是因为它的 goroutines!

第九章:Goroutines - 基本特性

在上一章中,您学习了 Unix 信号处理,以及在 Go 中添加管道支持和创建图形图像。

这个非常重要的章节的主题是 goroutines。Go 使用 goroutines 和通道来以自己的方式编写并发应用程序,同时提供对传统并发技术的支持。Go 中的所有内容都使用 goroutines 执行;当程序开始执行时,其单个 goroutine 会自动调用main()函数,以开始程序的实际执行。

在本章中,我们将介绍 goroutines 的简单部分,并提供易于遵循的代码示例。然而,在接下来的第十章 Goroutines - 高级特性中,我们将讨论与 goroutines 和通道相关的更重要和高级的技术,因此,请确保在阅读下一章之前充分理解本章。

因此,本章将告诉您以下内容:

  • 创建 goroutines

  • 同步 goroutines

  • 关于通道以及如何使用它们

  • 读取和写入通道

  • 创建和使用管道

  • 更改wc.go实用程序的 Go 代码,以便在新实现中使用 goroutines

  • 进一步改进wc.go的 goroutine 版本

关于 goroutines

goroutine是可以并发执行的最小 Go 实体。请注意,这里使用“最小”一词非常重要,因为 goroutines 不是自主实体。Goroutines 存在于 Unix 进程中的线程中。简单来说,进程可以是自主的并独立存在,而 goroutines 和线程都不行。因此,要创建 goroutine,您需要至少有一个带有线程的进程。好处是 goroutines 比线程轻,线程比进程轻。Go 中的所有内容都使用 goroutines 执行,这是合理的,因为 Go 是一种并发编程语言。正如您刚刚了解的那样,当 Go 程序开始执行时,它的单个 goroutine 调用main()函数,从而启动实际的程序执行。

您可以使用go关键字后跟函数名或匿名函数的完整定义来定义新的 goroutine。go关键字在新的 goroutine 中启动函数参数,并允许调用函数自行继续。

然而,正如您将看到的,您无法控制或做出任何关于 goroutines 将以何种顺序执行的假设,因为这取决于操作系统的调度程序以及操作系统的负载。

并发和并行

一个非常常见的误解是并发并行指的是同一件事,这与事实相去甚远!并行是多个事物同时执行,而并发是一种构造组件的方式,使它们在可能的情况下可以独立执行。

只有在并发构建时,您才能安全地并行执行它们:当且如果您的操作系统和硬件允许。很久以前,Erlang 编程语言就已经做到了这一点,早在 CPU 拥有多个核心和计算机拥有大量 RAM 之前。

在有效的并发设计中,添加并发实体使整个系统运行更快,因为更多的事情可以并行运行。因此,期望的并行性来自于对问题的更好并发表达和实现。开发人员在系统设计阶段负责考虑并发,并从系统组件的潜在并行执行中受益。因此,开发人员不应该考虑并行性,而应该考虑将事物分解为独立组件,这些组件在组合时解决最初的问题。

即使在 Unix 机器上无法并行运行函数,有效的并发设计仍将改善程序的设计和可维护性。换句话说,并发比并行更好!

同步 Go 包

sync Go 包包含可以帮助您同步 goroutines 的函数;sync的最重要的函数是sync.Addsync.Donesync.Wait。对于每个程序员来说,同步 goroutines 是一项必不可少的任务。

请注意,goroutines 的同步与共享变量和共享状态无关。共享变量和共享状态与您希望用于执行并发交互的方法有关。

一个简单的例子

在这一小节中,我们将介绍一个简单的程序,它创建了两个 goroutines。示例程序的名称将是aGoroutine.go,将分为三个部分;第一部分如下:

package main 

import ( 
   "fmt" 
   "time" 
) 

func namedFunction() { 
   time.Sleep(10000 * time.Microsecond) 
   fmt.Println("Printing from namedFunction!") 
} 

除了预期的packageimport语句之外,您还可以看到一个名为namedFunction()的函数的实现,在打印屏幕上的消息之前会休眠一段时间。

aGoroutine.go的第二部分包含以下 Go 代码:

func main() { 
   fmt.Println("Chapter 09 - Goroutines.") 
   go namedFunction() 

在这里,您创建了一个执行namedFunction()函数的 goroutine。这个天真程序的最后部分如下:

   go func() { 
         fmt.Println("An anonymous function!") 
   }() 

   time.Sleep(10000 * time.Microsecond) 
   fmt.Println("Exiting...") 
} 

在这里,您创建了另一个 goroutine,它执行一个包含单个fmt.Println()语句的匿名函数。

正如您所看到的,以这种方式运行的 goroutines 是完全隔离的,彼此之间无法交换任何类型的数据,这并不总是所期望的操作风格。

如果您忘记在main()函数中调用time.Sleep()函数,或者time.Sleep()睡眠了很短的时间,那么main()将会过早地结束,两个 goroutines 将没有足够的时间开始和完成它们的工作;结果,您将无法在屏幕上看到所有预期的输出!

执行aGoroutine.go将生成以下输出:

$ go run aGoroutine.go
Chapter 09 - Goroutines.
Printing from namedFunction!
Exiting... 

创建多个 goroutines

这一小节将向您展示如何创建许多 goroutines 以及处理更多 goroutines 所带来的问题。程序的名称将是moreGoroutines.go,将分为三个部分。

moreGoroutines.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "time" 
) 

程序的第二部分包含以下 Go 代码:

func main() { 
   fmt.Println("Chapter 09 - Goroutines.") 

   for i := 0; i < 10; i++ { 
         go func(x int) { 
               time.Sleep(10) 
               fmt.Printf("%d ", x) 
         }(i) 
   } 

这次,匿名函数接受一个名为x的参数,其值为变量i。使用变量ifor循环依次创建十个 goroutines。

程序的最后部分如下:

   time.Sleep(10000) 
   fmt.Println("Exiting...") 
} 

再次,如果您将较小的值作为time.Sleep()的参数,当您执行程序时将会看到不同的结果。

执行moreGoroutines.go将生成一个有些奇怪的输出:

$ go run moreGoroutines.go
Chapter 09 - Goroutines.
1 7 Exiting...
2 3

然而,当您多次执行moreGoroutines.go时,大惊喜来了:

$ go run moreGoroutines.go
Chapter 09 - Goroutines.
Exiting...
$ go run moreGoroutines.go
Chapter 09 - Goroutines.
3 1 0 9 2 Exiting...
4 5 6 8 7
$ go run moreGoroutines.go
Chapter 09 - Goroutines.
2 0 1 8 7 3 6 5 Exiting...
4

正如您所看到的,程序的所有先前输出都与第一个不同!因此,输出不仅不协调,而且并不总是有足够的时间让所有 goroutines 执行;您无法确定 goroutines 将以何种顺序执行。然而,尽管您无法解决后一个问题,因为 goroutines 的执行顺序取决于开发人员无法控制的各种参数,下一小节将教您如何同步 goroutines 并为它们提供足够的时间完成,而无需调用time.Sleep()

等待 goroutines 完成它们的工作

这一小节将向您演示正确的方法来创建一个等待其 goroutines 完成工作的调用函数。程序的名称将是waitGR.go,将分为四个部分;第一部分如下:

package main 

import ( 
   "fmt" 
   "sync" 
) 

除了time包的缺失和sync包的添加之外,这里没有什么特别的。

第二部分包含以下 Go 代码:

func main() { 
   fmt.Println("Waiting for Goroutines!") 

   var waitGroup sync.WaitGroup 
   waitGroup.Add(10) 

在这里,您创建了一个新变量,类型为sync.WaitGroup,它等待一组 goroutines 完成。属于该组的 goroutines 的数量由一个或多个对sync.Add()函数的调用定义。

在 Go 语句之前调用sync.Add()以防止竞争条件是很重要的。

另外,sync.Add(10)的调用告诉我们的程序,我们将等待十个 goroutines 完成。

程序的第三部分如下:

   var i int64 
   for i = 0; i < 10; i++ { 

         go func(x int64) { 
               defer waitGroup.Done() 
               fmt.Printf("%d ", x) 
         }(i) 
   } 

在这里,您可以使用for循环创建所需数量的 goroutines,但也可以使用多个顺序的 Go 语句。当每个 goroutine 完成其工作时,将执行sync.Done()函数:在函数定义之后立即使用defer关键字告诉匿名函数在完成之前自动调用sync.Done()

waitGR.go的最后一部分如下:

   waitGroup.Wait() 
   fmt.Println("\nExiting...") 
} 

这里的好处是不需要调用time.Sleep(),因为sync.Wait()会为我们做必要的等待。

再次应该注意的是,您不应该对 goroutines 的执行顺序做任何假设,这也由以下输出验证:

$ go run waitGR.go
Waiting for Goroutines!
9 0 5 6 7 8 2 1 3 4
Exiting...
$ go run waitGR.go
Waiting for Goroutines!
9 0 5 6 7 8 3 1 2 4
Exiting...
$ go run waitGR.go
Waiting for Goroutines!
9 5 6 7 8 1 0 2 3 4
Exiting...

如果您调用waitGroup.Add()的次数超过所需次数,当执行waitGR.go时,将收到以下错误消息:

Waiting for Goroutines!
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc42000e28c)
      /usr/local/Cellar/go/1.8.3/libexec/src/runtime/sema.go:47 +0x34
sync.(*WaitGroup).Wait(0xc42000e280)
      /usr/local/Cellar/go/1.8.3/libexec/src/sync/waitgroup.go:131 +0x7a
main.main()
      /Users/mtsouk/ch/ch9/code/waitGR.go:22 +0x13c
exit status 2
9 0 1 2 6 7 8 3 4 5

这是因为当您告诉程序通过调用sync.Add(1) n+1 次来等待 n+1 个 goroutines 时,您的程序不能只有 n 个 goroutines(或更少)!简单地说,这将使sync.Wait()无限期地等待一个或多个 goroutines 调用sync.Done()而没有任何运气,这显然是一个死锁的情况,阻止您的程序完成。

创建动态数量的 goroutines

这次,将作为命令行参数给出要创建的 goroutines 的数量:程序的名称将是dynamicGR.go,并将分为四个部分。

dynamicGR.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
   "strconv" 
   "sync" 
) 

dynamicGR.go的第二部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("usage: %s integer\n",filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   numGR, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   fmt.Printf("Going to create %d goroutines.\n", numGR) 
   var waitGroup sync.WaitGroup 

   var i int64 
   for i = 0; i < numGR; i++ { 
         waitGroup.Add(1) 

正如您所看到的,waitGroup.Add(1)语句是在创建新的 goroutine 之前调用的。

dynamicGR.go的 Go 代码的第三部分如下:

         go func(x int64) { 
               defer waitGroup.Done() 
               fmt.Printf(" %d ", x) 
         }(i) 
   } 

在前面的部分中,创建了每个简单的 goroutine。

程序的最后一部分如下:

   waitGroup.Wait() 
   fmt.Println("\nExiting...") 
} 

在这里,您只需告诉程序使用waitGroup.Wait()语句等待所有 goroutines 完成。

执行dynamicGR.go需要一个整数参数,这是您想要创建的 goroutines 的数量:

$ go run dynamicGR.go 15
Going to create 15 goroutines.
 0  2  4  1  3  5  14  10  8  9  12  11  6  13  7
Exiting...
$ go run dynamicGR.go 15
Going to create 15 goroutines.
 5  3  14  4  10  6  7  11  8  9  12  2  13  1  0
Exiting...
$ go run dynamicGR.go 15
Going to create 15 goroutines.
 4  2  3  6  5  10  9  7  0  12  11  1  14  13  8
Exiting...

可以想象,您想要创建的 goroutines 越多,输出就会越多样化,因为没有办法控制程序的 goroutines 执行顺序。

关于通道

通道,简单地说,是一种通信机制,允许 goroutines 交换数据。但是,这里存在一些规则。首先,每个通道允许特定数据类型的交换,这也称为通道的元素类型,其次,为了使通道正常运行,您需要使用一些 Go 代码来接收通过通道发送的内容。

您应该使用chan关键字声明一个新的通道,并且可以使用close()函数关闭一个通道。此外,由于每个通道都有自己的类型,开发人员应该定义它。

最后,一个非常重要的细节:当您将通道用作函数参数时,可以指定其方向,即它将用于写入还是读取。在我看来,如果您事先知道通道的目的,请使用此功能,因为它将使您的程序更健壮,更安全:否则,只需不定义通道函数参数的目的。因此,如果您声明通道函数参数仅用于读取,并尝试向其写入,您将收到一个错误消息,这很可能会使您免受讨厌的错误。

当你尝试从写通道中读取时,你将得到以下类似的错误消息:

# command-line-arguments
./writeChannel.go:13: invalid operation: <-c (receive from send-only type chan<- int)

向通道写入

在本小节中,你将学习如何向通道写入。所呈现的程序将被称为writeChannel.go,并分为三个部分。

第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "time" 
) 

正如你所理解的,使用通道不需要任何额外的 Go 包。

writeChannel.go的第二部分如下:

func writeChannel(c chan<- int, x int) { 
   fmt.Println(x) 
   c <- x 
   close(c) 
   fmt.Println(x) 
} 

尽管writeChannel()函数向通道写入数据,但由于当前没有人从程序中读取通道,数据将丢失。

程序的最后一部分包含以下 Go 代码:

func main() { 
   c := make(chan int) 
   go writeChannel(c, 10) 
   time.Sleep(2 * time.Second) 
} 

在这里,你可以看到使用chan关键字定义了一个名为c的通道变量,用于int数据。

执行writeChannel.go将创建以下输出:

 $ go run writeChannel.go
 10

这不是你期望看到的!这个意外的输出的原因是第二个fmt.Println(x)语句没有被执行。原因很简单:c <- x语句阻塞了writeChannel()函数的其余部分的执行,因为没有人从c通道中读取。

从通道中读取

本小节将通过允许你从通道中读取来改进writeChannel.go的 Go 代码。所呈现的程序将被称为readChannel.go,并分为四个部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
   "time" 
) 

readChannel.go的第二部分包含以下 Go 代码:

func writeChannel(c chan<- int, x int) { 
   fmt.Println(x) 
   c <- x 
   close(c) 
   fmt.Println(x) 
} 

再次注意,如果没有人收集写入通道的数据,发送数据的函数将在等待有人读取其数据时停滞。然而,在第十章 Goroutines - Advanced Features中,你将看到这个问题的一个非常好的解决方案。

第三部分包含以下 Go 代码:

func main() { 
   c := make(chan int) 
   go writeChannel(c, 10) 
   time.Sleep(2 * time.Second) 
   fmt.Println("Read:", <-c) 
   time.Sleep(2 * time.Second) 

在这里,fmt.Println()函数中的<-c语句用于从通道中读取单个值:相同的语句也可以用于将通道的值存储到变量中。然而,如果你不存储从通道中读取的值,它将会丢失。

readChannel.go的最后一部分如下:

   _, ok := <-c 
   if ok { 
         fmt.Println("Channel is open!") 
   } else { 
         fmt.Println("Channel is closed!") 
   } 
} 

在这里,你看到了一种技术,可以让你知道你想要从中读取的通道是否已关闭。然而,如果通道是打开的,所呈现的 Go 代码将因为在赋值中使用了_字符而丢弃通道的读取值。

执行readChannel.go将创建以下输出:

$ go run readChannel.go
10
Read: 10
10
Channel is closed!
$ go run readChannel.go
10
10
Read: 10
Channel is closed!

解释 h1s.go

在第八章 Processes and Signals中,你看到了 Go 如何使用许多示例处理 Unix 信号,包括h1s.go。然而,现在你更了解 goroutines 和通道,是时候更详细地解释一下h1s.go的 Go 代码了。

正如你已经知道的,h1s.go使用通道和 goroutines,现在应该清楚了,作为 goroutine 执行的匿名函数使用无限的for循环从sigs通道读取。这意味着每次有我们感兴趣的信号时,goroutine 都会从sigs通道中读取并处理它。

管道

Go 程序很少使用单个通道。一个非常常见的使用多个通道的技术称为pipeline。因此,pipeline 是一种连接 goroutines 的方法,使得一个 goroutine 的输出成为另一个 goroutine 的输入,借助通道。使用 pipeline 的好处如下:

  • 使用 pipeline 的好处之一是程序中有一个恒定的流动,因为没有人等待所有事情都完成才开始执行程序的 goroutines 和通道

  • 此外,你使用的变量更少,因此占用的内存空间也更少,因为你不必保存所有东西。

  • 最后,使用管道简化了程序的设计并提高了可维护性

pipelines.go的代码将以五个部分呈现;第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
   "strconv" 
) 

第二部分包含以下 Go 代码:

func genNumbers(min, max int64, out chan<- int64) { 

   var i int64 
   for i = min; i <= max; i++ { 
         out <- i 
   } 
   close(out) 
} 

在这里,您定义了一个函数,它接受三个参数:两个整数和一个输出通道。输出通道将用于写入将在另一个函数中读取的数据:这就是创建管道的方式。

程序的第三部分如下:

func findSquares(out chan<- int64, in <-chan int64) { 
   for x := range in { 
         out <- x * x 
   } 
   close(out) 
} 

这次,函数接受两个都是通道的参数。但是,out是一个输出通道,而in是一个用于读取数据的输入通道。

第四部分包含另一个函数的定义:

func calcSum(in <-chan int64) { 
   var sum int64 
   sum = 0 
   for x2 := range in { 
         sum = sum + x2 
   } 
   fmt.Printf("The sum of squares is %d\n", sum) 
} 

pipelines.go的最后一个函数只接受一个用于读取数据的通道作为参数。

pipelines.go的最后一部分是main()函数的实现:

func main() { 
   if len(os.Args) != 3 { 
         fmt.Printf("usage: %s n1 n2\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 
   n1, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   n2, _ := strconv.ParseInt(os.Args[2], 10, 64) 

   if n1 > n2 { 
         fmt.Printf("%d should be smaller than %d\n", n1, n2) 
         os.Exit(10) 
   } 

   naturals := make(chan int64) 
   squares := make(chan int64) 
   go genNumbers(n1, n2, naturals) 
   go findSquares(squares, naturals) 
   calcSum(squares) 
} 

在这里,main()函数首先读取其两个命令行参数并创建必要的通道变量(naturalssquares)。然后,它调用管道的函数:请注意,通道的最后一个函数不会作为 goroutine 执行。

以下图显示了pipelines.go中使用的管道的图形表示,以说明特定管道的工作方式:

pipelines.go 中使用的管道结构的图形表示

运行pipelines.go将生成以下输出:

$ go run pipelines.go
usage: pipelines n1 n2
exit status 1
$ go run pipelines.go 3 2
3 should be smaller than 2
exit status 10
$ go run pipelines.go 3 20
The sum of squares is 2865
$ go run pipelines.go 1 20
The sum of squares is 2870
$ go run pipelines.go 20 20
The sum of squares is 400

wc.go 的更好版本

正如我们在第六章中讨论的,在本章中,您将学习如何创建一个使用 goroutines 的wc.go的版本。新实用程序的名称将是dWC.go,将分为四个部分。请注意,dWC.go的当前版本将每个命令行参数都视为一个文件。

实用程序的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "regexp" 
   "sync" 
) 

第二部分包含以下 Go 代码:

func count(filename string) { 
   var err error 
   var numberOfLines int = 0 
   var numberOfCharacters int = 0 
   var numberOfWords int = 0 

   f, err := os.Open(filename) 
   if err != nil { 
         fmt.Printf("%s\n", err) 
         return 
   } 
   defer f.Close() 

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 

         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s\n", err) 
         } 
         numberOfLines++ 
         r := regexp.MustCompile("[^\\s]+") 
         for range r.FindAllString(line, -1) { 
               numberOfWords++ 
         } 
         numberOfCharacters += len(line) 
   } 

   fmt.Printf("\t%d\t", numberOfLines) 
   fmt.Printf("%d\t", numberOfWords) 
   fmt.Printf("%d\t", numberOfCharacters) 
   fmt.Printf("%s\n", filename) 
} 

count()函数完成所有处理,而不向main()函数返回任何信息:它只是打印其输入文件的行数、单词数和字符数,然后退出。尽管count()函数的当前实现完成了所需的工作,但这并不是设计程序的正确方式,因为无法控制程序的输出。

实用程序的第三部分如下:

func main() { 
   if len(os.Args) == 1 { 
         fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n", 
               filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

dWC.go的最后一部分如下:

   var waitGroup sync.WaitGroup 
   for _, filename := range os.Args[1:] { 
         waitGroup.Add(1) 
         go func(filename string) { 
               count(filename) 
               defer waitGroup.Done() 
         }(filename) 
   } 
   waitGroup.Wait() 
} 

正如您所看到的,每个输入文件都由不同的 goroutine 处理。如预期的那样,您无法对输入文件的处理顺序做出任何假设。

执行dWC.go将生成以下输出:

$ go run dWC.go /tmp/swtag.log /tmp/swtag.log doesnotExist
open doesnotExist: no such file or directory
          48    275   3571  /tmp/swtag.log
          48    275   3571  /tmp/swtag.log

在这里,您可以看到,尽管doesnotExist文件名是最后一个命令行参数,但它是dWC.go输出中的第一个命令行参数!

尽管dWC.go使用了 goroutines,但其中并没有巧妙之处,因为 goroutines 在没有相互通信和执行任何其他任务的情况下运行。此外,输出可能会混乱,因为无法保证count()函数的fmt.Printf()语句不会被中断。

因此,即将呈现的部分以及将在第十章中呈现的一些技术,即Goroutines - 高级特性,将改进dWC.go

计算总数

dWC.go的当前版本无法计算总数,可以通过使用awk处理dWC.go的输出来轻松解决:

$ go run dWC.go /tmp/swtag.log /tmp/swtag.log | awk '{sum1+=$1; sum2+=$2; sum3+=$3} END {print "\t", sum1, "\t", sum2, "\t", sum3}'
       96    550   7142

然而,这离完美和优雅还有很大差距!

dWC.go的当前版本无法计算总数的主要原因是其 goroutines 无法相互通信。这可以通过通道和管道的帮助轻松解决。新版本的dWC.go将被称为dWCtotal.go,将分为五个部分呈现。

dWCtotal.go的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "regexp" 
) 

type File struct { 
   Filename   string 
   Lines      int 
   Words      int 
   Characters int 
   Error      error 
} 

在这里,定义了一个新的struct类型。新结构称为File,有四个字段和一个额外的字段用于保存错误消息。这是管道循环多个值的正确方式。有人可能会认为File结构的更好名称应该是CountsResultsFileCountsFileResults

程序的第二部分如下:

func process(files []string, out chan<- File) { 
   for _, filename := range files { 
         var fileToProcess File 
         fileToProcess.Filename = filename 
         fileToProcess.Lines = 0 
         fileToProcess.Words = 0 
         fileToProcess.Characters = 0 
         out <- fileToProcess 
   } 
   close(out) 
} 

process()函数的更好名称应该是beginProcess()processResults()。您可以尝试在整个dWCtotal.go程序中自行进行更改。

dWCtotal.go的第三部分包含以下 Go 代码:

func count(in <-chan File, out chan<- File) { 
   for y := range in { 
         filename := y.Filename 
         f, err := os.Open(filename) 
         if err != nil { 
               y.Error = err 
               out <- y 
               continue 
         } 
         defer f.Close() 
         r := bufio.NewReader(f) 
         for { 
               line, err := r.ReadString('\n') 
               if err == io.EOF { 
                     break 
               } else if err != nil { 
                     fmt.Printf("error reading file %s", err) 
                     y.Error = err 
                     out <- y 
                     continue 
               } 
               y.Lines = y.Lines + 1 
               r := regexp.MustCompile("[^\\s]+") 
               for range r.FindAllString(line, -1) { 
                     y.Words = y.Words + 1 
               } 
               y.Characters = y.Characters + len(line) 
         } 
         out <- y 
   } 
   close(out) 
} 

尽管count()函数仍然计算计数,但它不会打印它们。它只是使用File类型的struct变量将行数、单词数、字符数以及文件名发送到另一个通道。

这里有一个非常重要的细节,就是count()函数的最后一条语句:为了正确结束管道,您应该关闭所有涉及的通道,从第一个开始。否则,程序的执行将以类似以下的错误消息失败:

fatal error: all goroutines are asleep - deadlock!

然而,就关闭管道的管道而言,您还应该注意不要过早关闭通道,特别是在管道中存在分支时。

程序的第四部分包含以下 Go 代码:

func calculate(in <-chan File) { 
   var totalWords int = 0 
   var totalLines int = 0 
   var totalChars int = 0 
   for x := range in { 
         totalWords = totalWords + x.Words 
         totalLines = totalLines + x.Lines 
         totalChars = totalChars + x.Characters 
         if x.Error == nil { 
               fmt.Printf("\t%d\t", x.Lines) 
               fmt.Printf("%d\t", x.Words) 
               fmt.Printf("%d\t", x.Characters) 
               fmt.Printf("%s\n", x.Filename) 
         } 
   } 

   fmt.Printf("\t%d\t", totalLines) 
   fmt.Printf("%d\t", totalWords) 
   fmt.Printf("%d\ttotal\n", totalChars) 
} 

这里没有什么特别的:calculate()函数负责打印程序的输出。

dWCtotal.go的最后部分如下:

func main() { 
   if len(os.Args) == 1 { 
         fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n", 
               filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   files := make(chan File)
   values := make(chan File) 

   go process(os.Args[1:], files) 
   go count(files, values) 
   calculate(values) 
} 

由于files通道仅用于传递文件名,它本可以是一个string通道,而不是一个File通道。但是,这样代码更一致。

现在dWCtotal.go即使只处理一个文件也会自动生成总数:

$ go run dWCtotal.go /tmp/swtag.log
      48    275   3571  /tmp/swtag.log
      48    275   3571  total
$ go run dWCtotal.go /tmp/swtag.log /tmp/swtag.log doesNotExist
      48    275   3571  /tmp/swtag.log
      48    275   3571  /tmp/swtag.log
      96    550   7142  total

请注意,dWCtotal.godWC.go都实现了相同的核心功能,即计算文件的单词、字符和行数:不同之处在于信息处理的方式不同,因为dWCtotal.go使用了管道而不是孤立的 goroutines。

第十章 Goroutines - Advanced Features,将使用其他技术来实现dWCtotal.go的功能。

进行一些基准测试

在本节中,我们将比较第六章 文件输入和输出中的wc.gowc(1)dWC.godWCtotal.go的性能。为了使结果更准确,所有三个实用程序将处理相对较大的文件:

$ wc /tmp/*.data
  712804 3564024 9979897 /tmp/connections.data
  285316  855948 4400685 /tmp/diskSpace.data
  712523 1425046 8916670 /tmp/memory.data
 1425500 2851000 5702000 /tmp/pageFaults.data
  285658  840622 4313833 /tmp/uptime.data
 3421801 9536640 33313085 total

因此,time(1)实用程序将测量以下命令:

$ time wc /tmp/*.data /tmp/*.data
$ time wc /tmp/uptime.data /tmp/pageFaults.data
$ time ./dWC /tmp/*.data /tmp/*.data
$ time ./dWC /tmp/uptime.data /tmp/pageFaults.data
$ time ./dWCtotal /tmp/*.data /tmp/*.data
$ time ./dWCtotal /tmp/uptime.data /tmp/pageFaults.data
$ time ./wc /tmp/uptime.data /tmp/pageFaults.data
$ time ./wc /tmp/*.data /tmp/*.data

以下图显示了使用time(1)实用程序测量上述命令时的实际领域的图形表示:

绘制time(1)实用程序的实际领域

原始的wc(1)实用程序是迄今为止最快的。此外,dWC.godWCtotal.gowc.go都要快。除了dWC.go,其余两个 Go 版本的性能相同。

练习

  1. 创建一个管道,读取文本文件,找到给定单词的出现次数,并计算所有文件中该单词的总出现次数。

  2. 尝试让dWCtotal.go更快。

  3. 创建一个简单的 Go 程序,使用通道进行乒乓球比赛。您应该使用命令行参数定义乒乓球的总数。

总结

在本章中,我们讨论了创建和同步 goroutines,以及创建和使用管道和通道,以使 goroutines 能够相互通信。此外,我们开发了两个使用 goroutines 处理其输入文件的wc(1)实用程序的版本。

在继续下一章之前,请确保您充分理解本章的概念,因为在下一章中,我们将讨论与 goroutines 和通道相关的更高级特性,包括共享内存、缓冲通道、select关键字、GOMAXPROCS环境变量和信号通道。

第十章:Goroutines-高级功能

这是本书的第二章,涉及 goroutines:Go 编程语言的最重要特性,以及大大改进 goroutines 功能的通道,我们将从第九章, Goroutines-基本功能中停止的地方继续进行。

因此,您将学习如何使用各种类型的通道,包括缓冲通道、信号通道、空通道和通道的通道!此外,您还将学习如何在 goroutines 中利用共享内存和互斥锁,以及如何在程序运行时间过长时设置超时。

具体来说,本章将讨论以下主题:

  • 缓冲通道

  • select关键字

  • 信号通道

  • 空通道

  • 通道的通道

  • 设置程序超时并避免无限等待其结束

  • 共享内存和 goroutines

  • 使用sync.Mutex来保护共享数据

  • 使用sync.RWMutex来保护您的共享数据

  • 更改dWC.go代码,以支持缓冲通道和互斥锁

Go 调度程序

在上一章中,我们说内核调度程序负责执行 goroutines 的顺序,这并不完全准确。内核调度程序负责执行程序的线程。Go 运行时有自己的调度程序,负责使用一种称为m:n 调度的技术执行 goroutines,其中m个 goroutines 使用n个操作系统线程进行多路复用。由于 Go 调度程序必须处理单个程序的 goroutines,其操作比内核调度程序的操作要便宜和快得多。

sync Go 包

在本章中,我们将再次使用sync包中的函数和数据类型。特别是,您将了解sync.Mutexsync.RWMutex类型及支持它们的函数的用处。

select 关键字

在 Go 中,select语句类似于通道的switch语句,并允许 goroutine 等待多个通信操作。因此,使用select关键字的主要优势是,同一个函数可以使用单个select语句处理多个通道!此外,您可以在通道上进行非阻塞操作。

用于说明select关键字的程序的名称将是useSelect.go,并将分为五个部分。useSelect.go程序允许您生成您想要的随机数,这是在第一个命令行参数中定义的,直到达到某个限制,这是第二个命令行参数。

useSelect.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "math/rand" 
   "os" 
   "path/filepath" 
   "strconv" 
   "time" 
) 

useSelect.go的第二部分如下:

func createNumber(max int, randomNumberChannel chan<- int, finishedChannel chan bool) { 
   for { 
         select { 
         case randomNumberChannel <- rand.Intn(max): 
         case x := <-finishedChannel: 
               if x { 
                     close(finishedChannel) 
                     close(randomNumberChannel) 
                     return 
               } 
         } 
   } 
}

在这里,您可以看到select关键字如何允许您同时监听和协调两个通道(randomNumberChannelfinishedChannel)。select语句等待通道解除阻塞,然后在该通道上执行。

createNumber()函数的for循环将不会自行结束。因此,只要select语句的randomNumberChannel分支被使用,createNumber()将继续生成随机数。当finishedChannel通道中获取到布尔值true时,createNumber()函数将退出。

finishedChannel通道的更好名称可能是done甚至是noMoreData

程序的第三部分包含以下 Go 代码:

func main() { 
   rand.Seed(time.Now().Unix()) 
   randomNumberChannel := make(chan int) 
   finishedChannel := make(chan bool) 

   if len(os.Args) != 3 { 
         fmt.Printf("usage: %s count max\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   n1, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   count := int(n1) 
   n2, _ := strconv.ParseInt(os.Args[2], 10, 64) 
   max := int(n2) 

   fmt.Printf("Going to create %d random numbers.\n", count) 

这里没有什么特别的:你只是在启动所需的 goroutine 之前读取命令行参数。

useSelect.go的第四部分是您将启动所需的 goroutine 并创建一个for循环以生成所需数量的随机数:

   go createNumber(max, randomNumberChannel, finishedChannel) 
   for i := 0; i < count; i++ { 
         fmt.Printf("%d ", <-randomNumberChannel) 
   } 

   finishedChannel <- false 
   fmt.Println() 
   _, ok := <-randomNumberChannel 
   if ok { 
         fmt.Println("Channel is open!") 
   } else { 
         fmt.Println("Channel is closed!") 
   } 

在这里,您还可以向finishedChannel发送一条消息,并在向finishedChannel发送消息后检查randomNumberChannel通道是open还是closed。由于您向finishedChannel发送了false,因此finishedChannel通道将保持open。请注意,向closed通道发送消息会导致 panic,而从closed通道接收消息会立即返回零值。

请注意,一旦关闭通道,就无法向该通道写入。但是,您仍然可以从该通道读取!

useSelect.go的最后一部分包含以下 Go 代码:

   finishedChannel <- true
   _, ok = <-randomNumberChannel 
   if ok { 
         fmt.Println("Channel is open!") 
   } else { 
         fmt.Println("Channel is closed!") 
   } 
} 

在这里,您向finishedChannel发送了true值,因此您的通道将关闭,createNumber() goroutine 将退出。

运行useSelect.go将创建以下输出:

$ go run useSelect.go 2 100
Going to create 2 random numbers.
19 74
Channel is open!
Channel is closed!

正如您将在解释缓冲通道的bufChannels.go程序中看到的,select语句也可以防止缓冲通道溢出。

信号通道

信号通道是仅用于发出信号的通道。将使用signalChannel.go程序来说明信号通道,该程序将使用一个相当不寻常的示例来呈现五个部分。该程序执行四个 goroutines:当第一个完成时,它通过关闭信号通道向信号通道发送信号,这将解除第二个 goroutine 的阻塞。当第二个 goroutine 完成其工作时,它关闭另一个通道,解除其余两个 goroutine 的阻塞。请注意,信号通道与携带os.Signal值的通道不同。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "time" 
) 

func A(a, b chan struct{}) { 
   <-a 
   fmt.Println("A!") 
   time.Sleep(time.Second) 
   close(b) 
} 

A()函数被存储在a参数中定义的通道阻塞。这意味着在关闭此通道之前,A()函数无法继续执行。函数的最后一条语句关闭了存储在b变量中的通道,该通道将用于解除其他 goroutines 的阻塞。

程序的第二部分是B()函数的实现:

func B(b, c chan struct{}) { 
   <-b 
   fmt.Println("B!") 
   close(c) 
} 

同样,B()函数被存储在b参数中的通道阻塞,这意味着在关闭b通道之前,B()函数将在其第一条语句中等待。

signalChannel.go的第三部分如下:

func C(a chan struct{}) { 
   <-a 
   fmt.Println("C!") 
} 

再次,C()函数被存储在其a参数中的通道阻塞。

程序的第四部分如下:

func main() { 
   x := make(chan struct{}) 
   y := make(chan struct{}) 
   z := make(chan struct{})

将信号通道定义为空struct而不带任何字段是一种非常常见的做法,因为空结构不占用内存空间。在这种情况下,您可以使用bool通道。

signalChannel.go的最后一部分包含以下 Go 代码:

   go A(x, y) 
   go C(z) 
   go B(y, z) 
   go C(z) 

   close(x) 
   time.Sleep(2 * time.Second) 
} 

在这里,您启动了四个 goroutines。但是,在关闭a通道之前,它们都将被阻塞!此外,A()将首先完成并解除B()的阻塞,然后解除两个C() goroutine 的阻塞。因此,这种技术允许您定义 goroutines 的执行顺序。

如果您执行signalChannel.go,您将获得以下输出:

$ go run signalChannel.go
A!
B!
C!
C!

正如您所看到的,尽管A()函数由于time.Sleep()函数调用而花费更多时间来执行,但 goroutines 正在按预期顺序执行。

缓冲通道

缓冲通道允许 Go 调度程序快速将作业放入队列,以便能够处理更多请求。此外,您可以使用缓冲通道作为信号量,以限制吞吐量。该技术的工作原理如下:传入的请求被转发到一个通道,该通道一次处理一个请求。当通道完成时,它向原始调用者发送一条消息,表明它已准备好处理新的请求。因此,通道缓冲区的容量限制了它可以保留和处理的同时请求的数量:这可以很容易地使用for循环和在其末尾调用time.Sleep()来实现。

缓冲通道将在bufChannels.go中进行说明,该程序将分为四个部分。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
) 

序言证明了您在 Go 程序中不需要任何额外的包来支持缓冲通道。

程序的第二部分包含以下 Go 代码:

func main() { 
   numbers := make(chan int, 5) 

在这里,您创建了一个名为numbers的新通道,它有5个位置,这由make语句的最后一个参数表示。这意味着您可以向该通道写入五个整数,而无需读取其中任何一个以为其他整数腾出空间。但是,您不能将六个整数放在具有五个整数位置的通道上!

bufChannels.go的第三部分如下:

   counter := 10 
   for i := 0; i < counter; i++ { 
         select { 
         case numbers <- i: 
         default: 
               fmt.Println("Not enough space for", i) 
         } 
   } 

在这里,您尝试将10个整数放入具有5个位置的缓冲通道。但是,使用select语句可以让您知道是否有足够的空间来存储所有整数,并相应地采取行动!

bufChannels.go的最后一部分如下:

   for i := 0; i < counter*2; i++ { 
         select { 
         case num := <-numbers: 
               fmt.Println(num) 
         default:
               fmt.Println("Nothing more to be done!")    
               break 
         } 
   } 
} 

在这里,您还使用了select语句,尝试从一个通道中读取 20 个整数。但是,一旦从通道中读取失败,for循环就会使用break语句退出。这是因为当从numbers通道中没有剩余内容可读时,num := <-numbers语句将被阻塞,这使得case语句转到default分支。

从代码中可以看出,bufChannels.go中没有 goroutine,这意味着缓冲通道可以自行工作。

执行bufChannels.go将生成以下输出:

$ go run bufChannels.go
Not enough space for 5
Not enough space for 6
Not enough space for 7
Not enough space for 8
Not enough space for 9
0
1
2
3
4
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!

关于超时

您能想象永远等待某件事执行动作吗?我也不能!因此,在本节中,您将学习如何使用select语句在 Go 中实现超时

具有示例代码的程序将被命名为timeOuts.go,并将分为四个部分进行介绍;第一部分如下:

package main 

import ( 
   "fmt" 
   "time" 
) 

timeOuts.go的第二部分如下:

func main() { 
   c1 := make(chan string) 
   go func() { 
         time.Sleep(time.Second * 3) 
         c1 <- "c1 OK" 
   }() 

goroutine 中的time.Sleep()语句用于模拟 goroutine 执行其真正工作所需的时间。

timeOuts.go的第三部分包含以下代码:

   select { 
   case res := <-c1: 
         fmt.Println(res) 
   case <-time.After(time.Second * 1): 
         fmt.Println("timeout c1") 
   } 

这次,使用time.After()是为了声明您希望在超时之前等待的时间。这里的奇妙之处在于,如果time.After()的时间到期,而select语句没有从c1通道接收到任何数据,那么time.After()case分支将被执行。

程序的最后一部分将包含以下 Go 代码:

   c2 := make(chan string) 
   go func() { 
         time.Sleep(time.Second * 3) 
         c2 <- "c2 OK" 
   }() 

   select { 
   case res := <-c2: 
         fmt.Println(res) 
   case <-time.After(time.Second * 4): 
         fmt.Println("timeout c2") 
   } 
} 

在前面的代码中,您会看到一个操作,它不会超时,因为它在期望的时间内完成了,这意味着select块的第一个分支将被执行,而不是表示超时的第二个分支。

执行timeOuts.go将生成以下输出:

$ go run timeOuts.go
timeout c1
c2 OK

实现超时的另一种方法

本小节的技术将让您不必等待任何顽固的 goroutines 完成它们的工作。因此,本小节将向您展示如何通过timeoutWait.go程序来设置 goroutines 的超时,该程序将分为四个部分进行介绍。尽管timeoutWait.gotimeOuts.go之间存在代码差异,但总体思想是完全相同的。

timeoutWait.go的第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "sync" 
   "time" 
) 

timeoutWait.go的第二部分如下:

func timeout(w *sync.WaitGroup, t time.Duration) bool { 
   temp := make(chan int) 
   go func() { 
         defer close(temp) 
         w.Wait() 
   }() 

   select { 
   case <-temp: 
         return false 
   case <-time.After(t): 
         return true 
   } 
} 

在这里,您声明了一个执行整个工作的函数。函数的核心是select块,其工作方式与timeOuts.go中的相同。timeout()的匿名函数将在w.Wait()语句返回时成功结束,这将在执行适当数量的sync.Done()调用时发生,这意味着所有 goroutines 都将完成。在这种情况下,select语句的第一个case将被执行。

请注意,temp通道在select块中是必需的,而在其他地方则不需要。此外,temp通道的元素类型可以是任何类型,包括bool

timeOuts.go的第三部分包含以下代码:

func main() { 
   var w sync.WaitGroup 
   w.Add(1) 

   t := 2 * time.Second 
   fmt.Printf("Timeout period is %s\n", t) 

   if timeout(&w, t) { 
         fmt.Println("Timed out!") 
   } else { 
         fmt.Println("OK!") 
   } 

程序的最后一个片段包含以下 Go 代码:

   w.Done() 
   if timeout(&w, t) { 
         fmt.Println("Timed out!") 
   } else { 
         fmt.Println("OK!") 
   } 
} 

在预期的w.Done()调用执行后,timeout()函数将返回true,这将防止超时发生。

正如在本小节开头提到的,timeoutWait.go实际上可以防止您的程序无限期地等待一个或多个 goroutine 结束。

执行timeoutWait.go将生成以下输出:

$ go run timeoutWait.go
Timeout period is 2s
Timed out!
OK!

通道的通道

在本节中,我们将讨论创建和使用通道的通道。使用这样的通道的两个可能原因如下:

  • 用于确认操作已完成其工作

  • 用于创建许多由相同通道变量控制的工作进程

在本节中将开发的简单程序的名称是cOfC.go,将分为四个部分呈现。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
) 

var numbers = []int{0, -1, 2, 3, -4, 5, 6, -7, 8, 9, 10} 

程序的第二部分如下:

func f1(cc chan chan int, finished chan struct{}) { 
   c := make(chan int) 
   cc <- c 
   defer close(c) 

   total := 0 
   i := 0 
   for { 
         select { 
         case c <- numbers[i]: 
               i = i + 1 
               i = i % len(numbers) 
               total = total + 1 
         case <-finished: 
               c <- total 
               return 
         } 
   } 
} 

f1()函数返回属于numbers变量的整数。当它即将结束时,它还使用c <- total语句将发送回到caller函数的整数数量。

由于您不能直接使用通道的通道,因此您应该首先从中读取(cc <- c)并获取实际可以使用的通道。这里方便的是,尽管您可以关闭c通道,但通道的通道(cc)仍将保持运行。

cOfC.go的第三部分如下:

func main() { 
   c1 := make(chan chan int) 
   f := make(chan struct{}) 

   go f1(c1, f) 
   data := <-c1 

在这段 Go 代码中,您可以看到可以使用chan关键字连续两次声明通道的通道。

cOfC.go的最后一部分包含以下 Go 代码:

   i := 0 
   for integer := range data { 
         fmt.Printf("%d ", integer) 
         i = i + 1 
         if i == 100 { 
               close(f) 
         } 
   } 
   fmt.Println() 
} 

在这里,通过关闭f通道,您限制了将创建的整数数量,当您拥有所需的整数数量时。

执行cOfC.go将生成以下输出:

$ go run cOfC.go
0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 100

通道的通道是 Go 的高级功能,您可能不需要在系统软件中使用。但是,了解其存在是很好的。

空通道

本节将讨论nil 通道,这是一种特殊类型的通道,它将始终阻塞。程序的名称将是nilChannel.go,将分为四个部分呈现。

程序的第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "math/rand" 
   "time" 
) 

第二部分包含addIntegers()函数的实现:

func addIntegers(c chan int) { 
   sum := 0 
   t := time.NewTimer(time.Second) 

   for { 
         select { 
         case input := <-c: 
               sum = sum + input 
         case <-t.C: 
               c = nil 
               fmt.Println(sum) 
         } 
   } 
} 

addIntegers()函数在time.NewTimer()函数定义的时间过去后停止,并将转到case语句的相关分支。在那里,它将使c成为 nil 通道,这意味着通道将停止接收新数据,并且函数将在那里等待。

nilChannel.go的第三部分如下:

func sendIntegers(c chan int) { 
   for { 
         c <- rand.Intn(100) 
   } 
} 

在这里,sendIntegers()函数会继续生成随机数并将它们发送到c通道,只要c通道是打开的。但是,这里还有一个永远不会被清理的 goroutine。

程序的最后一部分包含以下 Go 代码:

func main() { 
   c := make(chan int) 
   go addIntegers(c) 
   go sendIntegers(c) 
   time.Sleep(2 * time.Second) 
} 

执行nilChannel.go将生成以下输出:

$ go run nilChannel.go
162674704
$ go run nilChannel.go
165021841

共享内存

共享内存是线程之间进行通信的传统方式。Go 具有内置的同步功能,允许单个 goroutine 拥有共享数据的一部分。这意味着其他 goroutine 必须向拥有共享数据的单个 goroutine 发送消息,这可以防止数据的损坏!这样的 goroutine 称为监视器 goroutine。在 Go 术语中,这是通过通信进行共享,而不是通过共享进行通信。

这种技术将在sharedMem.go程序中进行演示,该程序将分为五个部分呈现。sharedMem.go的第一部分包含以下 Go 代码:

package main 

import ( 
   "fmt" 
   "math/rand" 
   "sync" 
   "time" 
) 

第二部分如下:

var readValue = make(chan int) 
var writeValue = make(chan int) 

func SetValue(newValue int) { 
   writeValue <- newValue 
} 

func ReadValue() int { 
   return <-readValue 
} 

ReadValue()函数用于读取共享变量,而SetValue()函数用于设置共享变量的值。此外,程序中使用的两个通道需要是全局变量,以避免将它们作为程序所有函数的参数传递。请注意,这些全局变量通常被封装在一个 Go 库或带有方法的struct中。

sharedMem.go的第三部分如下:

func monitor() { 
   var value int 
   for { 
         select { 
         case newValue := <-writeValue: 
               value = newValue 
               fmt.Printf("%d ", value) 
         case readValue <- value: 
         } 
   } 
} 

sharedMem.go的逻辑可以在monitor()函数的实现中找到。当你有一个读取请求时,ReadValue()函数尝试从readValue通道读取。然后,monitor()函数返回value参数中保存的当前值。同样,当你想要改变存储的值时,你调用SetValue(),它会写入writeValue通道,也由select语句处理。再次,select块起着关键作用,因为它协调了monitor()函数的操作。

程序的第四部分包含以下 Go 代码:

func main() { 
   rand.Seed(time.Now().Unix()) 
   go monitor() 
   var waitGroup sync.WaitGroup 

   for r := 0; r < 20; r++ { 
         waitGroup.Add(1) 
         go func() { 
               defer waitGroup.Done() 
               SetValue(rand.Intn(100)) 
         }() 
   } 

程序的最后部分如下:

   waitGroup.Wait() 
   fmt.Printf("\nLast value: %d\n", ReadValue()) 
} 

执行sharedMem.go将生成以下输出:

$ go run sharedMem.go
33 45 67 93 33 37 23 85 87 23 58 61 9 57 20 61 73 99 42 99
Last value: 99
$ go run sharedMem.go
71 66 58 83 55 30 61 73 94 19 63 97 12 87 59 38 48 81 98 49
Last value: 49

如果你想共享更多的值,你可以定义一个新的结构,用来保存所需的变量和你喜欢的数据类型。

使用 sync.Mutex

Mutexmutual exclusion的缩写;Mutex变量主要用于线程同步和保护共享数据,当多个写操作可能同时发生时。互斥锁的工作原理类似于容量为 1 的缓冲通道,最多允许一个 goroutine 同时访问共享变量。这意味着没有两个或更多的 goroutine 可以同时尝试更新该变量。虽然这是一种完全有效的技术,但一般的 Go 社区更倾向于使用前一节介绍的monitor goroutine 技术。

为了使用sync.Mutex,你必须首先声明一个sync.Mutex变量。你可以使用Lock方法锁定该变量,并使用Unlock方法释放它。sync.Lock()方法为你提供了对共享变量的独占访问,这段代码区域在调用Unlock()方法时结束,被称为关键部分

程序的每个关键部分在使用sync.Lock()进行锁定之前都不能执行。然而,如果锁已经被占用,每个人都应该等待其释放。虽然多个函数可能会等待获取锁,但只有当它被释放时,其中一个函数才会获取到它。

你应该尽量将关键部分设计得尽可能小;换句话说,不要延迟释放锁,因为其他 goroutines 可能想要使用它。此外,忘记解锁Mutex很可能会导致死锁。

用于演示sync.Mutex的 Go 程序的名称将是mutexSimple.go,并将以五个部分呈现。

mutexSimple.go的第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
   "strconv" 
   "sync" 
) 

程序的第二部分如下:

var aMutex sync.Mutex 
var sharedVariable string = "" 

func addDot() { 
   aMutex.Lock() 
   sharedVariable = sharedVariable + "." 
   aMutex.Unlock() 
} 

请注意,关键部分并不总是显而易见,你在指定时应该非常小心。还要注意,当两个关键部分使用相同的Mutex变量时,一个关键部分不能嵌套在另一个关键部分中!简单地说,几乎要以所有的代价避免在函数之间传递互斥锁,因为这样很难看出你是否嵌套了互斥锁!

在这里,addDot()sharedVariable字符串的末尾添加一个点字符。但是,由于字符串应该同时被多个 goroutine 改变,所以您使用sync.Mutex变量来保护它。由于关键部分只包含一个命令,获取对互斥体的访问的等待时间将非常短,甚至是瞬时的。但是,在现实世界的情况下,等待时间可能会更长,特别是在诸如数据库服务器之类的软件上,成千上万的进程同时发生许多事情:您可以通过在关键部分添加对time.Sleep()的调用来模拟这一点。

请注意,将互斥体与一个或多个共享变量相关联是开发人员的责任!

mutexSimple.go的第三个代码段是另一个使用互斥体的函数的实现:

func read() string { 
   aMutex.Lock() 
   a := sharedVariable 
   aMutex.Unlock() 
   return a 
} 

尽管在读取共享变量时锁定共享变量并不是绝对必要的,但这种锁定可以防止在读取时共享变量发生更改。在这里可能看起来像一个小问题,但想象一下读取您的银行账户余额!

第四部分是您定义要启动的 goroutine 数量的地方:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("usage: %s n\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   numGR, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   var waitGroup sync.WaitGroup 

mutexSimple.go的最后一部分包含以下 Go 代码:

   var i int64 
   for i = 0; i < numGR; i++ { 
         waitGroup.Add(1) 
         go func() { 
               defer waitGroup.Done() 
               addDot() 
         }() 
   } 
   waitGroup.Wait() 
   fmt.Printf("-> %s\n", read()) 
   fmt.Printf("Length: %d\n", len(read())) 
} 

在这里,您启动所需数量的 goroutine。每个 goroutine 调用addDot()函数来访问共享变量:然后等待它们完成,然后使用read()函数读取共享变量的值。

执行mutexSimple.go将生成类似以下的输出:

$ go run mutexSimple.go 20
-> ....................
Length: 20
$ go run mutexSimple.go 30
-> ..............................
Length: 30

使用 sync.RWMutex

Go 提供了另一种类型的互斥体,称为sync.RWMutex,它允许多个读取者持有锁,但只允许单个写入者 - sync.RWMutexsync.Mutex的扩展,添加了两个名为sync.RLocksync.RUnlock的方法,用于读取目的的锁定和解锁。对于独占写入,应分别使用Lock()Unlock()来锁定和解锁sync.RWMutex

这意味着要么一个写入者可以持有锁,要么多个读取者可以持有锁:不能同时两者都有!当大多数 goroutine 想要读取一个变量而您不希望 goroutine 等待以获取独占锁时,您很可能会使用这样的互斥体。

为了让sync.RWMutex变得更加透明,您应该发现sync.RWMutex类型是一个 Go 结构,当前定义如下:

type RWMutex struct { 
   w           Mutex 
   writerSem   uint32 
   readerSem   uint32  
   readerCount int32 
   readerWait  int32 
}                

所以,这里没有什么可害怕的!现在,是时候看一个使用sync.RWMutex的 Go 程序了。该程序将被命名为mutexRW.go,并将分为五个部分呈现。

mutexRW.go的第一部分包含预期的序言以及全局变量和新的struct类型的定义:

package main 

import ( 
   "fmt" 
   "sync" 
   "time" 
) 

var Password = secret{counter: 1, password: "myPassword"} 

type secret struct { 
   sync.RWMutex 
   counter  int 
   password string 
} 

secret结构嵌入了sync.RWMutex,因此它可以调用sync.RWMutex的所有方法。

mutexRW.go的第二部分包含以下 Go 代码:

func Change(c *secret, pass string) { 
   c.Lock() 
   fmt.Println("LChange") 
   time.Sleep(20 * time.Second) 
   c.counter = c.counter + 1 
   c.password = pass 
   c.Unlock() 
} 

此函数对其一个参数进行更改,这意味着它需要一个独占锁,因此使用了Lock()Unlock()函数。

示例代码的第三部分如下:

func Show(c *secret) string { 
   fmt.Println("LShow") 
   time.Sleep(time.Second)

   c.RLock() 
   defer c.RUnlock() 
   return c.password 
} 

func Counts(c secret) int { 
   c.RLock() 
   defer c.RUnlock() 
   return c.counter 
} 

在这里,您可以看到使用sync.RWMutex进行读取的两个函数的定义。这意味着它们的多个实例可以获取sync.RWMutex锁。

程序的第四部分如下:

func main() { 
   fmt.Println("Pass:", Show(&Password)) 
   for i := 0; i < 5; i++ { 
         go func() { 
               fmt.Println("Go Pass:", Show(&Password)) 
         }() 
   } 

在这里,您启动五个 goroutine 以使事情更有趣和随机。

mutexRW.go的最后一部分如下:

   go func() { 
         Change(&Password, "123456") 
   }() 

   fmt.Println("Pass:", Show(&Password)) 
   time.Sleep(time.Second) 
   fmt.Println("Counter:", Counts(Password)) 
} 

尽管共享内存和使用互斥体仍然是并发编程的有效方法,但使用 goroutine 和通道是一种更现代的方式,符合 Go 的哲学。因此,如果可以使用通道和管道解决问题,您应该优先选择这种方式,而不是使用共享变量。

执行mutexRW.go将生成以下输出:

$ go run mutexRW.go
LShow
Pass: myPassword
LShow
LShow
LShow
LShow
LShow
LShow
LChange
Go Pass: 123456
Go Pass: 123456
Pass: 123456
Go Pass: 123456
Go Pass: 123456
Go Pass: 123456
Counter: 2

如果Change()的实现也使用了RLock()调用以及RUnlock()调用,那将是完全错误的,那么程序的输出将如下所示:

$ go run mutexRW.go
LShow
Pass: myPassword
LShow
LShow
LShow
LShow
LShow
LShow
LChange
Go Pass: myPassword
Pass: myPassword
Go Pass: myPassword
Go Pass: myPassword
Go Pass: myPassword
Go Pass: myPassword
Counter: 1

简而言之,你应该充分了解你正在使用的锁定机制以及它的工作方式。在这种情况下,决定Counts()将返回什么的是时间:时间取决于Change()函数中的time.Sleep()调用,它模拟了实际函数中将发生的处理。问题在于,在Change()中使用RLock()RUnlock()允许多个 goroutine 读取共享变量,因此从Counts()函数中获得错误的输出。

重新审视 dWC.go 实用程序

在本节中,我们将改变在上一章中开发的dWC.go实用程序的实现。

程序的第一个版本将使用缓冲通道,而程序的第二个版本将使用共享内存来保持你处理的每个文件的计数。

使用缓冲通道

这个实现的名称将是WCbuffered.go,并将分为五个部分呈现。

实用程序的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "regexp" 
) 

type File struct { 
   Filename   string 
   Lines      int 
   Words      int 
   Characters int 
   Error      error 
} 

File结构将为每个输入文件保留计数。WCbuffered.go的第二部分包含以下 Go 代码:

func monitor(values <-chan File, count int) { 
   var totalWords int = 0 
   var totalLines int = 0 
   var totalChars int = 0 
   for i := 0; i < count; i++ { 
         x := <-values 
         totalWords = totalWords + x.Words 
         totalLines = totalLines + x.Lines 
         totalChars = totalChars + x.Characters 
         if x.Error == nil { 
               fmt.Printf("\t%d\t", x.Lines) 
               fmt.Printf("%d\t", x.Words) 
               fmt.Printf("%d\t", x.Characters) 
               fmt.Printf("%s\n", x.Filename) 
         } else { 
               fmt.Printf("\t%s\n", x.Error) 
         } 
   } 
   fmt.Printf("\t%d\t", totalLines) 
   fmt.Printf("%d\t", totalWords) 
   fmt.Printf("%d\ttotal\n", totalChars) 
} 

monitor()函数收集所有信息并打印出来。monitor()内部的for循环确保它将收集到正确数量的数据。

程序的第三部分包含了count()函数的实现:

func count(filename string, out chan<- File) { 
   var err error 
   var nLines int = 0 
   var nChars int = 0 
   var nWords int = 0 

   f, err := os.Open(filename) 
   defer f.Close() 
   if err != nil { 
         newValue := File{ 
Filename: filename, 
Lines: 0, 
Characters: 0, 
Words: 0, 
Error: err } 
         out <- newValue 
         return 
   } 

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 

         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s\n", err) 
         } 
         nLines++ 
         r := regexp.MustCompile("[^\\s]+") 
         for range r.FindAllString(line, -1) { 
               nWords++ 
         } 
         nChars += len(line) 
   } 
   newValue := File { 
Filename: filename, 
Lines: nLines, 
Characters: nChars, 
Words: nWords, 
Error: nil }

   out <- newValue

} 

count()函数完成时,它会将信息发送到缓冲通道,因此这里没有什么特别的。

WCbuffered.go的第四部分如下:

func main() { 
   if len(os.Args) == 1 { 
         fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n", 
               filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   values := make(chan File, len(os.Args[1:])) 

在这里,你创建了一个名为values的缓冲通道,其位置数与你将处理的文件数相同。

实用程序的最后一部分如下:

   for _, filename := range os.Args[1:] {
         go func(filename string) { 
               count(filename, values) 
         }(filename) 
   } 
   monitor(values, len(os.Args[1:])) 
} 

使用共享内存

共享内存和互斥锁的好处在于,理论上它们通常只占用很小一部分代码,这意味着其余的代码可以在没有其他延迟的情况下并发工作。然而,只有在你实现了某些东西之后,你才能看到真正发生了什么!

这个实现的名称将是WCshared.go,并将分为五个部分:实用程序的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "io" 
   "os" 
   "path/filepath" 
   "regexp" 
   "sync" 
) 

type File struct { 
   Filename   string 
   Lines      int 
   Words      int 
   Characters int 
   Error      error 
} 

var aM sync.Mutex 
var values = make([]File, 0) 

values切片将是程序的共享变量,而互斥变量的名称将是aM

WCshared.go的第二部分包含以下 Go 代码:

func count(filename string) { 
   var err error 
   var nLines int = 0 
   var nChars int = 0 
   var nWords int = 0 

   f, err := os.Open(filename) 
   defer f.Close() 
   if err != nil { 
         newValue := File{Filename: filename, Lines: 0, Characters: 0, Words: 0, Error: err} 
         aM.Lock() 
         values = append(values, newValue) 
         aM.Unlock() 
         return 
   } 

   r := bufio.NewReader(f) 
   for { 
         line, err := r.ReadString('\n') 

         if err == io.EOF { 
               break 
         } else if err != nil { 
               fmt.Printf("error reading file %s\n", err) 
         } 
         nLines++ 
         r := regexp.MustCompile("[^\\s]+") 
         for range r.FindAllString(line, -1) { 
               nWords++ 
         } 
         nChars += len(line) 
   } 

   newValue := File{Filename: filename, Lines: nLines, Characters: nChars, Words: nWords, Error: nil} 
   aM.Lock() 
   values = append(values, newValue) 
   aM.Unlock() 
} 

因此,在count()函数退出之前,它会使用临界区向values切片添加一个元素。

WCshared.go的第三部分如下:

func main() { 
   if len(os.Args) == 1 { 
         fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n", 
               filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

在这里,你只需要处理实用程序的命令行参数。

WCshared.go的第四部分包含以下 Go 代码:

   var waitGroup sync.WaitGroup 
   for _, filename := range os.Args[1:] { 
         waitGroup.Add(1) 
         go func(filename string) { 
               defer waitGroup.Done() 
               count(filename) 
         }(filename) 
   } 

   waitGroup.Wait()

在这里,你只需启动所需数量的 goroutine,并等待它们完成工作。

实用程序的最后一部分如下:

   var totalWords int = 0 
   var totalLines int = 0 
   var totalChars int = 0 
   for _, x := range values { 
         totalWords = totalWords + x.Words 
         totalLines = totalLines + x.Lines 
         totalChars = totalChars + x.Characters 
         if x.Error == nil { 
               fmt.Printf("\t%d\t", x.Lines) 
               fmt.Printf("%d\t", x.Words) 
               fmt.Printf("%d\t", x.Characters) 
               fmt.Printf("%s\n", x.Filename) 
         } 
   } 
   fmt.Printf("\t%d\t", totalLines) 
   fmt.Printf("%d\t", totalWords) 
   fmt.Printf("%d\ttotal\n", totalChars) 
}

当所有 goroutine 都完成时,就该处理共享变量的内容,计算总数,并打印所需的输出。请注意,在这种情况下,没有任何类型的共享变量,因此不需要互斥锁:你只需等待收集所有结果并打印它们。

更多的基准测试

本节将使用方便的time(1)实用程序来测量WCbuffered.goWCshared.go的性能。然而,这一次,我不会呈现图表,而是会给你time(1)实用程序的实际输出:

$ time go run WCshared.go /tmp/*.data /tmp/*.data
real  0m31.836s
user  0m31.659s
sys   0m0.165s
$ time go run WCbuffered.go /tmp/*.data /tmp/*.data
real  0m31.823s
user  0m31.656s
sys   0m0.171s

正如你所看到的,这两个实用程序的性能都很好,或者如果你愿意的话,也可以说都很糟糕!然而,除了程序的速度之外,还有其设计的清晰度以及对其进行代码更改的易用性也很重要!此外,所呈现的方式还会计算这两个实用程序的编译时间,这可能会使结果不太准确。

这两个程序之所以能够轻松生成总数,是因为它们都有一个控制点。对于WCshared.go实用程序,控制点是共享变量,而对于WCbuffered.go,控制点是在monitor()函数内收集所需信息的缓冲通道。

检测竞争条件

如果在运行或构建 Go 程序时使用-race标志,将启用 Go 竞争检测器,这将使编译器创建典型可执行文件的修改版本。这个修改版本可以记录对共享变量的访问以及发生的所有同步事件,包括对sync.Mutexsync.WaitGroup等的调用。在对事件进行一些分析后,竞争检测器会打印一个报告,可以帮助您识别潜在问题,以便您可以纠正它们。

为了展示竞争检测器的操作,我们将使用rd.go程序的代码,它将分为四个部分呈现。对于这个特定的程序,数据竞争将会发生,因为两个或更多的 goroutine 同时访问同一个变量,并且其中至少一个以某种方式改变了变量的值。

请注意,main()程序在 Go 中也是一个 goroutine!

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "os" 
   "path/filepath" 
   "strconv" 
   "sync" 
) 

这里没有什么特别的,所以如果程序有问题,那就不是在前言中。

rd.go的第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) != 2 { 
         fmt.Printf("usage: %s number\n", filepath.Base(arguments[0])) 
         os.Exit(1) 
   } 
   numGR, _ := strconv.ParseInt(os.Args[1], 10, 64) 
   var waitGroup sync.WaitGroup 
   var i int64 

在这个特定的代码中,没有任何问题。

rd.go的第三部分具有以下 Go 代码:

   for i = 0; i < numGR; i++ { 
         waitGroup.Add(1) 
         go func() { 
               defer waitGroup.Done() 
               fmt.Printf("%d ", i) 
         }() 
   } 

这段代码非常可疑,因为您试图打印一个由于for循环而不断变化的变量的值。

rd.go的最后一部分如下:

   waitGroup.Wait() 
   fmt.Println("\nExiting...") 
} 

最后一部分代码中没有什么特别的。

rd.go启用 Go 竞争检测器将生成以下输出:

$ go run -race rd.go 10 ================== WARNING: DATA RACE
Read at 0x00c420074168 by goroutine 6:
  main.main.func1()
      /Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:25 +0x6c

Previous write at 0x00c420074168 by main goroutine:
  main.main()
      /Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:21 +0x30c

Goroutine 6 (running) created at:
  main.main()
      /Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:26 +0x2e2
==================
==================
WARNING: DATA RACE
Read at 0x00c420074168 by goroutine 7:
 main.main.func1()
     /Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:25 +0x6c

Previous write at 0x00c420074168 by main goroutine:
 main.main()
     /Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:21 +0x30c

Goroutine 7 (running) created at:
  main.main()
      /Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:26 +0x2e2
==================
2 3 4 4 5 6 7 8 9 10
Exiting...
Found 2 data race(s)
exit status 66 

因此,竞争检测器发现了两个数据竞争。第一个发生在数字1根本没有被打印出来时,第二个发生在数字4被打印两次时。此外,尽管i的初始值是数字0,但数字0并没有被打印出来。最后,你不应该在输出中得到数字10,但你确实得到了,因为i的最后一个值确实是10。请注意,在前面的输出中找到的main.main.func1()表示 Go 谈论的是一个匿名函数。

简而言之,前两条消息告诉您的是,i变量有问题,因为当程序的 goroutine 尝试读取它时,它一直在变化。此外,您无法确定地告诉会先发生什么。

在没有竞争检测器的情况下运行相同的程序将生成以下输出:

$ go run rd.go 10
10 10 10 10 10 10 10 10 10 10
Exiting...

rd.go中的问题可以在匿名函数中找到。由于匿名函数不带参数,它使用i的当前值,这个值无法确定,因为它取决于操作系统和 Go 调度程序:这就是竞争情况发生的地方!因此,请记住,最容易出现竞争条件的地方之一是在从匿名函数生成的 goroutine 内部!因此,如果您必须解决这种情况,请首先将匿名函数转换为具有定义参数的常规函数!

使用竞争检测器的程序比没有竞争检测器的程序更慢,需要更多的 RAM。最后,如果竞争检测器没有任何报告,它将不会生成任何输出。

关于 GOMAXPROCS

GOMAXPROCS环境变量(和 Go 函数)允许您限制可以同时执行用户级 Go 代码的操作系统线程的数量。

从 Go 版本 1.5 开始,默认值GOMAXPROCS应该是您的 Unix 系统上可用的核心数。

尽管在 Unix 机器上使用小于核心数的GOMAXPROCS值可能会影响程序的性能,但指定大于可用核心数的GOMAXPROCS值不会使程序运行更快!

goMaxProcs.go的代码允许您确定GOMAXPROCS的值-它将分为两部分呈现。

第一部分如下:

package main 

import ( 
   "fmt" 
   "runtime" 
) 

func getGOMAXPROCS() int {
   return runtime.GOMAXPROCS(0) 
} 

第二部分如下:

func main() { 
   fmt.Printf("GOMAXPROCS: %d\n", getGOMAXPROCS()) 
} 

在支持超线程的 Intel i7 机器上执行goMaxProcs.go并使用最新的 Go 版本会得到以下输出:

$ go run goMaxProcs.go 
GOMAXPROCS: 8 

然而,如果您在运行旧版 Go 的 Debian Linux 机器上执行goMaxProcs.go并且有一个旧处理器,它将生成以下输出:

$ go version 
go version go1.3.3 linux/amd64 
$ go run goMaxProcs.go 
GOMAXPROCS: 1 

动态更改GOMAXPROCS的值的方法如下:

$ export GOMAXPROCS=80; go run goMaxProcs.go 
GOMAXPROCS: 80 

但是,设置大于256的值将不起作用:

$ export GOMAXPROCS=800; go run goMaxProcs.go 
GOMAXPROCS: 256 

最后,请记住,如果您使用单个核心运行诸如dWC.go之类的并发程序,则并发版本的程序可能不会比没有 goroutines 的程序版本运行得更快!在某些情况下,这是因为 goroutines 的使用以及对sync.Addsync.Waitsync.Done函数的各种调用会减慢程序的性能。可以通过以下输出来验证:

$ export GOMAXPROCS=8; time go run dWC.go /tmp/*.data

real  0m10.826s
user  0m31.542s
sys   0m5.043s
$ export GOMAXPROCS=1; time go run dWC.go /tmp/*.data

real  0m15.362s
user  0m15.253s
sys   0m0.103s
$ time go run wc.go /tmp/*.data

real  0m15.158sexit
user  0m15.023s
sys   0m0.120s

练习

  1. 仔细阅读可以在golang.org/pkg/sync/找到的sync包的文档页面。

  2. 尝试使用与本章节中使用的不同的共享内存技术来实现dWC.go

  3. 实现一个struct数据类型,它保存您的账户余额,并创建读取您拥有的金额并对金额进行更改的函数。创建一个使用sync.RWMutex和另一个使用sync.Mutex的实现。

  4. 如果你在mutexRW.go中到处使用Lock()Unlock()而不是RLock()RUnlock(),会发生什么?

  5. 尝试使用 goroutines 从第五章, 文件和目录中实现traverse.go

  6. 尝试使用 goroutines 从第五章, 文件和目录中创建improvedFind.go的实现。

摘要

本章讨论了与 goroutines、通道和并发编程相关的一些高级 Go 特性。然而,本章的教训是通道可以做很多事情,并且可以在许多情况下使用,这意味着开发人员必须能够根据自己的经验选择适当的技术来实现任务。

下一章的主题将是 Go 中的 Web 开发,其中将包含非常有趣的材料,包括发送和接收 JSON 数据,开发 Web 服务器和 Web 客户端,以及从您的 Go 代码与 MongoDB 数据库交互。

第十一章:在 Go 中编写 Web 应用程序

在上一章中,我们讨论了许多与 goroutines 和通道相关的高级主题,以及共享内存和互斥锁。

本章的主要内容是在 Go 中开发 Web 应用程序。然而,本章还将讨论如何在 Go 程序中与两个流行的数据库进行交互。Go 标准库提供了可以帮助你使用更高级函数开发 Web 应用程序的包,这意味着你可以通过调用几个带有正确参数的 Go 函数来做复杂的事情,比如读取网页。虽然这种编程方式隐藏了请求背后的复杂性,并且对细节的控制较少,但它允许你使用更少的代码开发复杂的应用程序,这也导致程序中的错误更少。

然而,由于本书是关于系统编程的,本章不会深入讨论:你可以将所呈现的信息视为任何想学习在 Go 中进行 Web 开发的人的良好起点。

更具体地说,本章将讨论以下主题:

  • 为 MySQL 数据库管理员创建一个 Go 实用程序

  • 管理 MongoDB 数据库

  • 使用 Go MongoDB 驱动程序与 MongoDB 数据库通信

  • 在 Go 中创建 Web 服务器

  • 在 Go 中创建 Web 客户端

  • http.ServeMux类型

  • 处理 Go 中的 JSON 数据

  • net/http

  • html/template Go 标准包

  • 开发一个在给定关键字中搜索网页的命令行实用程序

什么是 Web 应用程序?

Web 应用程序是一个客户端-服务器软件应用程序,其中客户端部分在 Web 浏览器上运行。Web 应用程序包括网络邮件、即时通讯服务和在线商店。

关于 net/http Go 包

本章的主角将是net/http包,它可以帮助你在 Go 中编写 Web 应用程序。然而,如果你对在较低级别处理 TCP/IP 连接感兴趣,那么你应该去第十二章,网络编程,它讨论使用较低级别函数调用开发 TCP/IP 应用程序。

net/http包提供了一个内置的 Web 服务器和一个内置的 Web 客户端,它们都非常强大。http.Get()方法可用于发出 HTTP 和 HTTPS 请求,而http.ListenAndServe()函数可用于通过指定服务器将监听的 IP 地址和 TCP 端口以及处理传入请求的函数来创建简单的 Web 服务器。

另一个非常方便的包是html/template,它是 Go 标准库的一部分,允许你使用 Go HTML 模板文件生成 HTML 输出。

在 Go 中开发 Web 客户端

在本节中,你将学习如何在 Go 中开发 Web 客户端,以及如何超时处理需要太长时间才能完成的 Web 连接。

获取单个 URL

在本小节中,你将学习如何使用http.Get()函数读取单个网页,这将在getURL.go程序中进行演示。该实用程序将分为四个部分;程序的第一部分是预期的序言:

package main 

import ( 
   "fmt" 
   "io" 
   "net/http" 
   "os" 
   "path/filepath" 
) 

虽然这里没有什么新东西,但你可能会发现令人印象深刻的是,即使你从互联网读取数据,你也会使用与文件输入和输出操作相关的 Go 包。这背后的解释非常简单:Go 具有统一的接口,用于读取和写入数据,无论数据所在的介质如何。

getURL.go的第二部分包含以下 Go 代码:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("Usage: %s URL\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   URL :=os.Args[1] 
   data, err := http.Get(URL) 

你想获取的 URL 作为程序的命令行参数给出。此外,你可以看到对http.Get()的调用,它完成了所有的脏活!http.Get()返回的是一个Response变量,实际上是一个具有各种属性和方法的 Go 结构。

第三部分如下:

   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } else { 

调用http.Get()后如果出现错误,这是检查错误的地方。

第四部分包含以下 Go 代码:

         defer data.Body.Close() 
         _, err := io.Copy(os.Stdout, data.Body) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
   } 
}

正如您所看到的,URL的数据是使用os.Stdout写入标准输出的,这是在屏幕上打印数据的首选方式。此外,数据保存在http.Get()调用的返回值的Body属性中。然而,并非所有的 HTTP 请求都是简单的。如果响应流式传输视频或类似内容,逐段读取它而不是一次性获取所有内容是有意义的。您可以使用io.Reader和响应的Body部分来实现这一点。

执行getURL.go将生成以下原始结果,这就是 Web 浏览器将获得并呈现的内容:

$ go run getURL.go http://www.mtsoukalos.eu/ | head
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.0//EN"
  "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-1.dtd">
<html xml:lang="en" version="XHTML+RDFa 1.0" dir="ltr"
xmlns:content=http://purl.org/rss/1.0/modules/content/
. . .
</script>
</body>
</html>

一般来说,虽然getURL.go可以完成所需的工作,但它的工作方式并不那么复杂,因为它不提供灵活性或创造性的方式。

设置超时

在本小节中,您将学习如何为http.Get()请求设置超时。出于简单起见,它将基于getURL.go的 Go 代码。程序的名称将是timeoutHTTP.go,并将以五个部分的形式呈现。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "io" 
   "net" 
   "net/http" 
   "os" 
   "path/filepath" 
   "time" 
) 

var timeout = time.Duration(time.Second) 

在这里,您将所需的超时时间声明为全局参数,即 1 秒。

timeoutHTTP.go的第二部分包含以下 Go 代码:

func Timeout(network, host string) (net.Conn, error) { 
   conn, err := net.DialTimeout(network, host, timeout) 
   if err != nil { 
         return nil, err 
   } 
   conn.SetDeadline(time.Now().Add(timeout)) 
   return conn, nil 
} 

在这里,您定义了两种类型的超时,第一种是使用net.DialTimeout()定义的,用于客户端连接到服务器所需的时间。第二种是读/写超时,与连接到 Web 服务器后等待获取响应的时间有关:这是使用conn.SetDeadline()函数定义的。

所呈现程序的第三部分如下:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("Usage: %s URL\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   URL :=os.Args[1] 

程序的第四部分如下:

   t := http.Transport{ 
         Dial: Timeout, 
   } 

   client := http.Client{ 
         Transport: &t, 
   } 
   data, err := client.Get(URL) 

在这里,您可以使用http.Transport变量定义连接的所需参数。

程序的最后部分包含以下 Go 代码:

   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } else { 
         deferdata.Body.Close() 
         _, err := io.Copy(os.Stdout, data.Body) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
   } 
} 

该程序的这一部分都是关于错误处理的!

执行timeoutHTTP.go将在超时的情况下生成以下输出:

$ go run timeoutHTTP.go http://localhost:8001
Get http://localhost:8001: read tcp [::1]:58018->[::1]:8001: i/o timeout
exit status 100

故意在 Web 连接期间创建超时的最简单方法是在 Web 服务器的处理程序函数中调用time.Sleep()函数。

开发更好的网络客户端

虽然getURL.go可以很快地完成所需的工作,并且不需要编写太多的 Go 代码,但它在某种程度上不够灵活或信息丰富。它只是打印一堆原始的 HTML 代码,没有其他信息,也没有将 HTML 代码分成逻辑部分的能力。因此,需要改进getURL.go

新实用程序的名称将是webClient.go,并将以五个 Go 代码段的形式呈现给您。

该实用程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "net/http" 
   "net/http/httputil" 
   "net/url" 
   "os" 
   "path/filepath" 
   "strings" 
) 

webClient.go中的 Go 代码的第二部分如下:

func main() { 
   if len(os.Args) != 2 { 
         fmt.Printf("Usage: %s URL\n", filepath.Base(os.Args[0])) 
         os.Exit(1) 
   } 

   URL, err :=url.Parse(os.Args[1]) 
   if err != nil { 
         fmt.Println("Parse:", err) 
         os.Exit(100) 
   } 

这里唯一的新内容是使用url.Parse()函数,它从给定的字符串创建一个URL结构。

webClient.go的第三部分包含以下 Go 代码:

   c := &http.Client{} 

   request, err := http.NewRequest("GET", URL.String(), nil) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   httpData, err := c.Do(request) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

在这段 Go 代码中,您首先创建一个http.Client变量。然后,您使用http.NewRequest()构造一个GET HTTP 请求。最后,您使用Do()函数发送 HTTP 请求,该函数返回保存在httpData变量中的实际响应数据。

该实用程序的第四部分代码如下:

   fmt.Println("Status code:", httpData.Status) 
   header, _ := httputil.DumpResponse(httpData, false) 
   fmt.Print(string(header)) 

   contentType := httpData.Header.Get("Content-Type") 
   characterSet := strings.SplitAfter(contentType, "charset=") 
   fmt.Println("Character Set:", characterSet[1]) 

   if httpData.ContentLength == -1 { 
         fmt.Println("ContentLength in unknown!") 
   } else { 
         fmt.Println("ContentLength:", httpData.ContentLength) 
   } 

在这里,您可以使用Status属性找到 HTTP 请求的状态代码。然后,您可以对响应的Header部分进行一些挖掘,以找到响应的字符集。最后,您可以检查ContentLength属性的值,对于动态页面,它等于-1:这意味着您事先不知道页面的大小。

程序的最后部分包含以下 Go 代码:

   length := 0 
   var buffer [1024]byte

   r := httpData.Body 
   for { 
         n, err := r.Read(buffer[0:]) 
         if err != nil { 
               fmt.Println(err) 
               break 
         } 
         length = length + n 
   } 
   fmt.Println("Response data length:", length) 
} 

在这里,您通过从Body读取器中读取数据并计算其数据长度来找到响应的长度。如果要打印响应的内容,这是正确的位置。

执行webClient.go将创建以下输出:

$ go run webClient.go invalid
Get invalid: unsupported protocol scheme ""
exit status 100
$ go run webClient.go https://www.mtsoukalos.eu/
Get https://www.mtsoukalos.eu/: dial tcp 109.74.193.253:443: getsockopt: connection refused
exit status 100
$ go run webClient.go http://www.mtsoukalos.eu/
Status code: 200 OK
HTTP/1.1 200 OK
Accept-Ranges: bytes
Age: 0
Cache-Control: no-cache, must-revalidate
Connection: keep-alive
Content-Language: en
Content-Type: text/html; charset=utf-8
Date: Mon, 10 Jul 2017 07:29:48 GMT
Expires: Sun, 19 Nov 1978 05:00:00 GMT
Server: Apache/2.4.10 (Debian) PHP/5.6.30-0+deb8u1 mod_wsgi/4.3.0 Python/2.7.9
Vary: Accept-Encoding
Via: 1.1 varnish-v4
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-Generator: Drupal 7 (http://drupal.org)
X-Powered-By: PHP/5.6.30-0+deb8u1
X-Varnish: 6922264

Character Set: utf-8
ContentLength in unknown!
EOF
Response data length: 50176

一个小型的 web 服务器

够了,关于 Web 客户端的内容:在本节中,您将学习如何在 Go 中开发 Web 服务器!

可以在webServer.go中找到一个简单 Web 服务器实现的 Go 代码,并且将以四部分呈现;第一部分如下:

package main 

import ( 
   "fmt" 
   "net/http" 
   "os" 
) 

第二部分是事情开始变得棘手和奇怪的地方:

func myHandler(w http.ResponseWriter, r *http.Request) { 
   fmt.Fprintf(w, "Serving: %s\n", r.URL.Path) 
   fmt.Printf("Served: %s\n", r.Host) 
} 

这是一种处理 HTTP 请求的函数:该函数接受两个参数,一个http.ResponseWriter变量和一个指向http.Request变量的指针。第一个参数将用于构造 HTTP 响应,而http.Request变量保存了服务器接收到的 HTTP 请求的详细信息,包括请求的 URL 和客户端的 IP 地址。

webServer.go的第三部分包含以下 Go 代码:

func main() { 
   PORT := ":8001" 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Using default port number: ", PORT) 
   } else { 
         PORT = ":" + arguments[1] 
   } 

在这里,您只需处理 web 服务器的端口号:默认端口号是8001,除非有命令行参数。

webServer.go的最后一部分 Go 代码如下:

   http.HandleFunc("/", myHandler) 
   err := http.ListenAndServe(PORT, nil) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(10) 
   } 
} 

http.HandleFunc()调用定义了处理程序函数的名称(myHandler)以及它将支持的 URL:您可以多次调用http.HandleFunc()。当前处理程序支持/URL,在 Go 中匹配所有 URL!

在完成http.HandleFunc()调用后,您可以准备调用http.ListenAndServe()并开始等待传入的连接!如果在http.ListenAndServe()函数调用中未指定 IP 地址,则 Web 服务器将侦听计算机的所有配置的网络接口。

执行webServer.go将不会生成任何输出,除非您尝试从中获取一些数据:在这种情况下,它将在您的终端上打印日志信息,显示请求的服务器名称(localhost)和端口号(8001),如下所示:

$ go run webServer.go
Using default port number:  :8001 
Served: localhost:8001 Served: localhost:8001
Served: localhost:8001

以下屏幕截图显示了在 Web 浏览器上webServer.go的三个输出:

使用 webServer.go

但是,如果您使用wget(1)getURL.go等命令行实用程序而不是 Web 浏览器,当您尝试连接到 Go Web 服务器时,您将获得以下输出:

$ go run getURL.go http://localhost:8001/
Serving: /

您从自定义的 web 服务器中获得的最大优势是安全性,因为当以安全性以及更容易的定制为目标开发时,它们真的很难被黑客攻击。

下一小节将展示如何使用http.ServeMux创建 Web 服务器。

http.ServeMux 类型

在本小节中,您将学习如何使用http.ServeMux类型来改进 Go Web 服务器的操作方式。简单地说,http.ServeMux是一个 HTTP 请求路由器。

使用 http.ServeMux

本节的 Web 服务器实现将使用http.ServeMux来支持多个路径,这将在将显示为四部分的serveMux.go程序中进行说明。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "net/http" 
   "time" 
) 

serveMux.go的第二部分包含以下 Go 代码:

func about(w http.ResponseWriter, r *http.Request) { 
   fmt.Fprintf(w, "This is the /about page at %s\n", r.URL.Path) 
   fmt.Printf("Served: %s\n", r.Host) 
} 

func cv(w http.ResponseWriter, r *http.Request) { 
   fmt.Fprintf(w, "This is the /CV page at %s\n", r.URL.Path) 
   fmt.Printf("Served: %s\n", r.Host) 
} 

func timeHandler(w http.ResponseWriter, r *http.Request) { 
   currentTime := time.Now().Format(time.RFC1123) 
   title := currentTime 
   Body := "The current time is:" 
   fmt.Fprintf(w, "<h1 align=\"center\">%s</h1><h2 align=\"center\">%s</h2>", Body, title) 
   fmt.Printf("Served: %s for %s\n", r.URL.Path, r.Host) 
} 

在这里,您有三个 HTTP 处理程序函数的实现。前两个显示静态页面,而第三个显示当前时间,这是一个动态文本。

程序的第三部分如下:

func home(w http.ResponseWriter, r *http.Request) { 
   ifr.URL.Path == "/" { 
         fmt.Fprintf(w, "Welcome to my home page!\n") 
   } else { 
         fmt.Fprintf(w, "Unknown page: %s from %s\n", r.URL.Path, r.Host) 
   } 
   fmt.Printf("Served: %s for %s\n", r.URL.Path, r.Host) 
} 

home()处理程序函数将必须确保它实际上正在服务于/Path,因为/Path会捕捉一切!

serveMux.go的最后部分包含以下 Go 代码:

func main() { 
   m := http.NewServeMux() 
   m.HandleFunc("/about", about) 
   m.HandleFunc("/CV", cv) 
   m.HandleFunc("/time", timeHandler) 
   m.HandleFunc("/", home) 

   http.ListenAndServe(":8001", m) 
} 

在这里,您定义了您的 Web 服务器将支持的路径。请注意,路径区分大小写,并且在前面的代码中最后一个路径会捕捉一切。这意味着如果您首先放置m.HandleFunc("/", home),您将无法匹配其他任何内容。简单地说,m.HandleFunc()语句的顺序很重要。还要注意,如果您想同时支持/about/about/,您应该同时拥有m.HandleFunc("/about", about)m.HandleFunc("/about/", about)

运行serveMux.go将生成以下输出:

$ go run serveMux.go Served: / for localhost:8001 Served: /123 for localhost:8001
Served: localhost:8001
Served: /cv for localhost:8001

以下截图显示了serveMux.go在 Web 浏览器上生成的各种输出类型:请注意,浏览器输出与go run serveMux.go命令之前的输出无关:

使用 serveMux.go

如果你使用wget(1)而不是 Web 浏览器,你将得到以下输出:

$ wget -qO- http://localhost:8001/CV
This is the /CV page at /CV
$ wget -qO- http://localhost:8001/cv
Unknown page: /cv from localhost:8001
$ wget -qO- http://localhost:8001/time
<h1 align="center">The current time is:</h1><h2 align="center">Mon, 10 Jul 2017 13:13:27 EEST</h2>
$ wget -qO- http://localhost:8001/time/
Unknown page: /time/ from localhost:8001

因此,http.HandleFunc()是库中默认调用的函数,将用于首次实现,而http.NewServeMux()HandleFunc()函数则用于其他情况。简单来说,除了在最简单的情况下,最好使用http.NewServeMux()版本而不是默认版本。

html/template 包

模板主要用于分离输出的格式和数据部分。请注意,Go 模板可以是文件或字符串:一般的想法是对较小的模板使用字符串,对较大的模板使用文件。

在本节中,我们将通过一个示例来讨论html/template包,该示例可以在template.go文件中找到,并将分为六部分呈现。template.go背后的一般思想是,你正在读取一个包含你想要以 HTML 格式呈现的记录的文本文件。鉴于包的名称是html/template,程序的更好名称应该是genHTML.gogenTemplate.go

还有text/template包,更适用于创建纯文本输出。但是,你不能在同一个 Go 程序中导入text/templatehtml/template,除非采取一些额外的步骤来消除歧义,因为这两个包具有相同的包名(template)。这两个包之间的关键区别在于,html/template对 HTML 注入的数据进行了消毒处理,这意味着它更安全。

源文件的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "html/template" 
   "net/http" 
   "os" 
   "strings" 
) 

type Entry struct { 
   WebSite string 
   WebName string 
   Quality string 
} 

var filename string 

结构的定义非常重要,因为这是数据传递到template文件的方式。

template.go的第二部分包含以下 Go 代码:

func dynamicContent(w http.ResponseWriter, r *http.Request) { 
   var Data []Entry 
   var f *os.File 
   if filename == "" { 
         f = os.Stdin 
   } else { 
         fileHandler, err := os.Open(filename) 
         if err != nil { 
               fmt.Printf("error opening %s: %s", filename, err) 
               os.Exit(1) 
         } 
         f = fileHandler 
   } 
   defer f.Close() 
   scanner := bufio.NewScanner(f) 
   myT := template.Must(template.ParseGlob("template.gohtml")) 

template.ParseGlob()函数用于读取外部模板文件,它可以有任何你想要的文件扩展名。在项目中查找 Go 模板文件时,使用.gohtml扩展名可能会让你的生活更简单。

尽管我个人更喜欢使用.gohtml扩展名来命名 Go 模板文件,但.tpl是一个非常常见的扩展名,被广泛使用。你可以选择你喜欢的任何一个。

template.go的第三部分代码如下:

       for scanner.Scan() { 

         parts := strings.Fields(scanner.Text()) 
         if len(parts) == 3 { 
               temp := Entry{WebSite: parts[0], WebName: parts[1], Quality: parts[2]} 
               Data = append(Data, temp) 
         } 
   } 

   fmt.Println("Serving", r.Host, "for", r.URL.Path) 
   myT.ExecuteTemplate(w, "template.gohtml", Data) 
} 

ExecuteTemplate()函数的第三个参数是你要处理的数据。在这种情况下,你将一个记录的切片传递给它。

程序的第四部分如下:

func staticPage(w http.ResponseWriter, r *http.Request) { 
   fmt.Println("Serving", r.Host, "for", r.URL.Path) 
   myT := template.Must(template.ParseGlob("static.gohtml")) 
   myT.ExecuteTemplate(w, "static.gohtml", nil) 
} 

这个函数显示一个静态的 HTML 页面,我们将通过模板引擎传递nil数据,这由ExecuteTemplate()函数的第三个参数表示。如果你有相同的函数处理不同的数据片段,可能会出现没有内容可渲染的情况,但保留它是为了保持通用的代码结构。

template.go的第五部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 

   if len(arguments) == 1 { 
         filename = "" 
   } else { 
         filename = arguments[1] 
   } 

template.go中的最后一部分 Go 代码是你定义支持的路径并使用端口号8001启动 Web 服务器的地方:

   http.HandleFunc("/static", staticPage) 
   http.HandleFunc("/dynamic", dynamicContent) 
   http.ListenAndServe(":8001", nil) 
} 

template.gohtml文件的内容如下:

<!doctype html> 
<htmllang="en"> 
<head> 
   <meta charset="UTF-8"> 
   <title>Using Go HTML Templates</title> 
   <style> 
         html { 
               font-size: 16px; 
         } 
         table, th, td { 
         border: 3px solid gray; 
         } 
   </style> 
</head> 
<body> 

<h2 alight="center">Presenting Dynamic content!</h2> 

<table> 
   <thead> 
         <tr> 
               <th>Web Site</th> 
               <th>Quality</th> 
         </tr> 
   </thead> 
   <tbody> 
{{ range . }} 
<tr> 
   <td><a href="{{ .WebSite }}">{{ .WebName }}</a></td> 
   <td> {{ .Quality }} </td> 
</tr> 
{{ end }} 
   </tbody> 
</table> 

</body> 
</html> 

句点(.)字符代表当前正在处理的数据:简单来说,句点(.)字符是一个变量。{{ range . }}语句相当于一个for循环,遍历输入切片的所有元素,在这种情况下是结构。你可以访问每个结构的字段,如.WebSite.WebName.Quality

static.gohtml文件的内容如下:

<!doctype html> 
<htmllang="en"> 
<head> 
   <meta charset="UTF-8"> 
   <title>A Static HTML Template</title> 
</head> 
<body> 

<H1>Hello there!</H1> 

</body> 
</html> 

如果你执行template.go,你将在屏幕上看到以下输出:

$ go run template.go /tmp/sites.html
Serving localhost:8001 for /dynamic
Serving localhost:8001 for /static

以下屏幕截图显示了template.go的两个输出,显示在 Web 浏览器上。sites.html文件有三列,分别是 URL、名称和质量,可以有多行。好处在于,如果更改/tmp/sites.html文件的内容并重新加载网页,您将看到更新后的内容!

使用 template.go

关于 JSON

JSON代表 JavaScript 对象表示法。这是一种基于文本的格式,旨在作为在 JavaScript 系统之间传递信息的一种简单轻便的方式。

一个简单的 JSON 文档具有以下格式:

{ "name":"Mihalis", 
"surname":"Tsoukalos",
"country":"Greece" }

前面的 JSON 文档有三个字段,分别命名为namesurnamecountry。每个字段都有一个单一值。

然而,JSON 文档可以具有更复杂的结构,具有多个深度级别。

在看一些代码之前,我认为首先讨论encoding/json Go 包将非常有用。encoding/json包提供了Encode()Decode()函数,允许将 Go 对象转换为 JSON 文档,反之亦然。此外,encoding/json包还提供了Marshal()Unmarshal()函数,其工作方式类似于Encode()Decode(),并且基于Encode()Decode()方法。

Marshal()-Unmarshal()Encode()-Decode()之间的主要区别在于前者函数适用于单个对象,而后者函数可以处理多个对象以及字节流。

最后,encoding/json Go 包包括两个名为MarshalerUnmarshaler的接口:它们每个都需要实现一个单一方法,分别命名为MarshalJSON()UnmarshalJSON()。这两个接口允许您在 Go 中执行自定义 JSON 编组解组。不幸的是,这两个接口将不在本书中介绍。

保存 JSON 数据

本小节将教您如何将常规数据转换为 JSON 格式,以便通过网络连接发送。本小节的 Go 代码将保存为writeJSON.go,并将分为四个部分呈现。

Go 代码的第一部分是程序的预期序文,以及分别命名为RecordTelephone的两个新struct类型的定义:

package main 

import ( 
   "encoding/json" 
   "fmt" 
   "os" 
) 

type Record struct { 
   Name    string 
   Surname string 
   Tel     []Telephone 
} 

type Telephone struct { 
   Mobile bool 
   Number string 
} 

请注意,结构的成员只有以大写字母开头的成员才会出现在 JSON 输出中,因为以小写字母开头的成员被视为私有:在这种情况下,RecordTelephone结构的所有成员都是公共的,并将被导出。

第二部分是定义名为saveToJSON()的函数:

funcsaveToJSON(filename string, key interface{}) { 
   out, err := os.Create(filename) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   encodeJSON := json.NewEncoder(out) 
   err = encodeJSON.Encode(key) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   out.Close() 
} 

saveToJSON()函数为我们完成所有工作,因为它创建了一个名为encodeJSON的 JSON 编码器变量,它与文件名相关联,数据将保存在那里。然后,调用Encode()将记录的数据保存到相关的文件名,我们就完成了!正如您将在下一节中看到的那样,类似的过程将帮助您读取 JSON 文件并将其转换为 Go 变量。

程序的第三部分具有以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a filename!") 
         os.Exit(100) 
   } 

   filename := arguments[1] 

这里没有什么特别的:您只需获取程序的第一个命令行参数。

该实用程序的最后一部分如下:

   myRecord := Record{ 
         Name:    "Mihalis", 
         Surname: "Tsoukalos", 
         Tel: []Telephone{Telephone{Mobile: true, Number: "1234-567"}, 
               Telephone{Mobile: true, Number: "1234-abcd"}, 
               Telephone{Mobile: false, Number: "abcc-567"}, 
         }} 

   saveToJSON(filename, myRecord) 
} 

在这里,我们做了两件事。第一件事是定义一个新的Record变量并填充它的数据。第二件事是调用saveToJSON()myRecord变量以 JSON 格式保存到所选文件中。

执行writeJSON.go将生成以下输出:

$ go run writeJSON.go /tmp/SavedFile

之后,/tmp/SavedFile的内容将如下所示:

$ cat /tmp/SavedFile
{"Name":"Mihalis","Surname":"Tsoukalos","Tel":[{"Mobile":true,"Number":"1234-567"},{"Mobile":true,"Number":"1234-abcd"},{"Mobile":false,"Number":"abcc-567"}]}

通过网络发送 JSON 数据需要使用 net Go 标准包,这将在下一章中讨论。

解析 JSON 数据

本小节将说明如何读取 JSON 记录并将其转换为一个可以在您自己的程序中使用的 Go 变量。所呈现的程序的名称将是readJSON.go,并将分为四个部分呈现给您。

该实用程序的第一部分与writeJSON.go实用程序的第一部分相同:

package main 

import ( 
   "encoding/json" 
   "fmt" 
   "os" 
) 

type Record struct { 
   Name    string 
   Surname string 
   Tel     []Telephone 
} 

type Telephone struct { 
   Mobile bool 
   Number string 
} 

Go 代码的第二部分如下:

funcloadFromJSON(filename string, key interface{}) error { 
   in, err := os.Open(filename) 
   if err != nil { 
         return err 
   } 

   decodeJSON := json.NewDecoder(in) 
   err = decodeJSON.Decode(key) 
   if err != nil { 
         return err 
   } 
   in.Close() 
   return nil 
} 

在这里,您定义了一个名为loadFromJSON()的新函数,用于根据作为第二个参数给出的数据结构解码 JSON 文件。您首先调用json.NewDecoder()函数创建一个与文件关联的新 JSON 解码变量,然后调用Decode()函数来实际解码文件的内容。

readJSON.go的第三部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   iflen(arguments) == 1 { 
         fmt.Println("Please provide a filename!") 
         os.Exit(100) 
   } 

   filename := arguments[1] 

程序的最后部分如下:

   var myRecord Record 
   err := loadFromJSON(filename, &myRecord) 
   if err == nil { 
         fmt.Println(myRecord) 
   } else { 
         fmt.Println(err) 
   } 
} 

如果运行readJSON.go,将得到以下输出:

$ go run readJSON.go /tmp/SavedFile
{Mihalis Tsoukalos [{true 1234-567} {true 1234-abcd} {false abcc-567}]}

从网络读取 JSON 数据将在下一章讨论,因为 JSON 记录在网络上传输时与任何其他类型的数据没有区别。

使用 Marshal()和 Unmarshal()

在本小节中,您将看到如何使用Marshal()Unmarshal()来实现readJSON.gowriteJSON.go的功能。展示Marshal()Unmarshal()函数的 Go 代码可以在marUnmar.go中找到,并将分为四部分呈现。

marUnmar.go的第一部分是预期的序言:

package main 

import ( 
   "encoding/json" 
   "fmt" 
   "os" 
) 

type Record struct { 
   Name    string 
   Surname string 
   Tel     []Telephone 
} 

type Telephone struct { 
   Mobile bool 
   Number string 
} 

程序的第二部分包含以下 Go 代码:

func main() { 
   myRecord := Record{ 
         Name:    "Mihalis", 
         Surname: "Tsoukalos", 
         Tel: []Telephone{Telephone{Mobile: true, Number: "1234-567"}, 
               Telephone{Mobile: true, Number: "1234-abcd"}, 
               Telephone{Mobile: false, Number: "abcc-567"}, 
         }} 

这是在writeJSON.go程序中使用的相同记录。因此,到目前为止没有什么特别的。

marUnmar.go的第三部分是编组发生的地方:

   rec, err := json.Marshal(&myRecord) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   fmt.Println(string(rec)) 

请注意,json.Marshal()需要一个指针来传递数据,即使值是一个 map、数组或切片。

程序的最后部分包含以下执行解组操作的 Go 代码:

   var unRec Record 
   err1 := json.Unmarshal(rec, &unRec) 
   if err1 != nil { 
         fmt.Println(err1) 
         os.Exit(100) 
   } 
   fmt.Println(unRec) 
} 

从代码中可以看出,json.Unmarshal()需要使用指针来保存数据,即使值是一个 map、数组或切片。

执行marUnmar.go将创建以下输出:

$ go run marUnmar.go
{"Name":"Mihalis","Surname":"Tsoukalos","Tel":[{"Mobile":true,"Number":"1234-567"},{"Mobile":true,"Number":"1234-abcd"},{"Mobile":false,"Number":"abcc-567"}]}
{Mihalis Tsoukalos [{true 1234-567} {true 1234-abcd} {false abcc-567}]}

如您所见,Marshal()Unmarshal()函数无法帮助您将数据存储到文件中:您需要自己实现。

使用 MongoDB

关系数据库是严格组织成表的结构化数据的集合。查询数据库的主要语言是 SQL。NoSQL 数据库,如MongoDB,不使用 SQL,而是使用各种其他查询语言,并且在其表中没有严格的结构,这在 NoSQL 术语中称为集合

您可以根据其数据模型将 NoSQL 数据库分类为文档、键值、图形和列族。MongoDB 是最流行的面向文档的 NoSQL 数据库,适用于 Web 应用程序。

文档数据库并不是用来处理 Microsoft Word 文档的,而是用来存储半结构化数据的。

基本的 MongoDB 管理

如果您想在 Go 应用程序中使用 MongoDB,了解如何在 MongoDB 数据库上执行一些基本的管理任务将非常实用。

本节中介绍的大多数任务将从 Mongo shell 执行,该 shell 通过执行mongo命令启动。如果您的 Unix 机器上没有运行 MongoDB 实例,将得到以下输出:

$ mongo
MongoDB shell version v3.4.5
connecting to: mongodb://127.0.0.1:27017
2017-07-06T19:37:38.291+0300 W NETWORK  [thread1] Failed to connect to 127.0.0.1:27017, in(checking socket for error after poll), reason: Connection refused
2017-07-06T19:37:38.291+0300 E QUERY    [thread1] Error: couldn't connect to server 127.0.0.1:27017, connection attempt failed :
connect@src/mongo/shell/mongo.js:237:13
@(connect):1:6
exception: connect failed

前面的输出告诉我们两件事:

  • MongoDB 服务器进程的默认 TCP 端口号为27017

  • mongo 可执行文件尝试连接到127.0.0.1 IP 地址,这是本地机器的 IP 地址

为了执行以下命令,您应该在本地机器上启动一个 MongoDB 服务器实例。一旦 MongoDB 服务器进程启动并运行,执行mongo将创建以下输出:

$ mongo
MongoDB shell version: 2.4.10
connecting to: test
>

以下命令将向您展示如何创建一个新的 MongoDB 数据库和一个新的 MongoDB 集合,以及如何向该集合插入一些文档:

>use go;
switched to db go
>db.someData.insert({x:0, y:1})
>db.someData.insert({x:1, y:2})
>db.someData.insert({x:2, y:3})
>db.someData.count()
3

一旦您尝试使用db.someData.insert()将文档插入到集合中,如果该集合(someData)不存在,它将被自动创建。最后一个命令计算了当前数据库的someData集合中存储的记录数。

MongoDB 不会通知您可能存在的任何拼写错误。简单地说,如果您错误地输入了数据库或集合的名称,MongoDB 将在您试图找出问题所在时创建一个全新的数据库或新集合!此外,如果您在文档中放入更多、更少或不同的字段并尝试保存它,MongoDB 也不会抱怨!

您可以使用find()函数找到集合的记录:

>db.someData.find()
{ "_id" : ObjectId("595e84cd63883cb3fe7f42f3"), "x" : 0, "y" : 1 }
{ "_id" : ObjectId("595e84d263883cb3fe7f42f4"), "x" : 1, "y" : 2 }
{ "_id" : ObjectId("595e84d663883cb3fe7f42f5"), "x" : 2, "y" : 3 }

您可以按如下方式找到运行中的 MongoDB 实例上的数据库列表:

>show databases;
LXF   0.203125GB
go    0.0625GB
local 0.078125GB

类似地,您可以按如下方式找到当前 MongoDB 数据库中存储的集合的名称:

>db.getCollectionNames()
[ "someData", "system.indexes" ]

您可以按如下方式删除 MongoDB 集合的所有记录:

>db.someData.remove()
>show collections
someData
system.indexes

最后,您可以按如下方式删除整个集合,包括其中的记录:

>db.someData.drop()
true
>show collections
system.indexes

上述信息暂时可以帮助您入门,但如果您想了解更多关于 MongoDB 的信息,您应该访问 MongoDB 的文档网站docs.mongodb.com/

使用 MongoDB Go 驱动程序

为了在您的 Go 程序中使用 MongoDB,您应该首先在您的 Unix 机器上安装 MongoDB Go 驱动程序。MongoDB Go 驱动程序的名称是mgo,您可以通过访问github.com/go-mgo/mgolabix.org/mgodocs.mongodb.com/ecosystem/drivers/go/了解更多关于 MongoDB Go 驱动程序的信息。

由于驱动程序不是 Go 标准库的一部分,您应该首先使用以下两个命令下载所需的软件包:

$ go get labix.org/v2/mgo
$ go get labix.org/v2/mgo/bson

之后,您将可以在自己的 Go 实用程序中使用它。如果您尝试在 Unix 系统上执行该程序而没有这两个软件包,您将收到类似以下的错误消息:

$ go run testMongo.go
testMongo.go:5:2: cannot find package "labix.org/v2/mgo" in any of:
      /usr/local/Cellar/go/1.8.3/libexec/src/labix.org/v2/mgo (from $GOROOT)
      /Users/mtsouk/go/src/labix.org/v2/mgo (from $GOPATH)
testMongo.go:6:2: cannot find package "labix.org/v2/mgo/bson" in any of:
      /usr/local/Cellar/go/1.8.3/libexec/src/labix.org/v2/mgo/bson (from $GOROOT)
      /Users/mtsouk/go/src/labix.org/v2/mgo/bson (from $GOPATH)

请注意,您可能需要在您的 Unix 系统上安装 Bazaar 才能执行这两个go get命令。您可以在bazaar.canonical.com/获取有关 Bazaar 版本控制系统的更多信息。

因此,您应该首先尝试运行一个简单的 Go 程序,该程序连接到 MongoDB 数据库,创建一个新的数据库和一个新的集合,并向其中添加新的文档,以确保一切都按预期工作:程序的名称将是testMongo.go,并将分为四个部分呈现。

程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "labix.org/v2/mgo" 
   "labix.org/v2/mgo/bson" 
   "os" 
   "time" 
) 

type Record struct { 
   Xvalueint 
   Yvalueint 
} 

在这里,您可以看到在导入块中使用了 Go MongoDB 驱动程序。此外,您还可以看到定义了一个名为Record的新 Go 结构,它将保存每个 MongoDB 文档的数据。

testMongo.go的第二部分包含以下 Go 代码:

func main() { 
   mongoDBDialInfo := &mgo.DialInfo{ 
         Addrs:   []string{"127.0.0.1:27017"}, 
         Timeout: 20 * time.Second, 
   } 

   session, err := mgo.DialWithInfo(mongoDBDialInfo) 
   if err != nil { 
         fmt.Printf("DialWithInfo: %s\n", err) 
         os.Exit(100) 
   } 
   session.SetMode(mgo.Monotonic, true) 

   collection := session.DB("goDriver").C("someData") 

现在,collection变量将用于处理goDriver数据库的someData集合:数据库的更好名称应该是myDB。请注意,在运行 Go 程序之前,MongoDB 实例中没有goDriver数据库;这也意味着someData集合也不存在。

程序的第三部分如下:

   err = collection.Insert(&Record{1, 0}) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   err = collection.Insert(&Record{-1, 0}) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

在这里,您可以使用Insert()函数将两个文档插入到 MongoDB 数据库中。

testMongo.go的最后一部分包含以下 Go 代码:

   var recs []Record 
   err = collection.Find(bson.M{"yvalue": 0}).All(&recs) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   for x, y := range recs { 
         fmt.Println(x, y) 
   } 
   fmt.Println("Found:", len(recs), "results!") 
} 

由于您不知道从Find()查询中会得到多少文档,因此您应该使用记录的切片来存储它们。

另外,请注意,当您存储时,您应该在Find()函数中将yvalue字段小写,因为 MongoDB 在存储时会自动将Record结构的字段转换为小写!

现在,按照这里所示执行testMongo.go

$ go run testMongo.go
0 {1 0}
1 {-1 0}
Found: 2 results!

请注意,如果多次执行testMongo.go,您会发现相同的文档多次插入到someData集合中。但是,MongoDB 不会有任何问题区分所有这些文档,因为每个文档的键是_id字段,这是由 MongoDB 自动插入的,每次您向集合插入新文档时都会插入。

之后,使用MongoDB shell 命令连接到 MongoDB 实例,以确保一切按预期工作:

$ mongo
MongoDB shell version v3.4.5
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.5
>use goDriver
switched to db goDriver
>show collections
someData
>db.someData.find()
{ "_id" : ObjectId("595f88593fb7048f4846e555"), "xvalue" : 1, "yvalue" : 0 }
{ "_id" : ObjectId("595f88593fb7048f4846e557"), "xvalue" : -1, "yvalue" : 0 }
>

在这里,重要的是要理解 MongoDB 文档以 JSON 格式呈现,这是您已经知道如何在 Go 中处理的。

另外,请注意,Go MongoDB 驱动程序具有比此处介绍的更多功能。不幸的是,更多讨论超出了本书的范围,但您可以通过访问github.com/go-mgo/mgolabix.org/mgodocs.mongodb.com/ecosystem/drivers/go/来了解更多信息。

创建一个显示 MongoDB 数据的 Go 应用程序

实用程序的名称将是showMongo.go,它将分为三部分呈现。该实用程序将连接到 MongoDB 实例,读取一个集合,并将集合的文档显示为网页。请注意,showMongo.go基于template.go的 Go 代码。

Web 应用程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "html/template" 
   "labix.org/v2/mgo" 
   "net/http" 
   "os" 
   "time" 
) 

var DatabaseName string 
var collectionName string 

type Document struct { 
   P1 int 
   P2 int 
   P3 int 
   P4 int 
   P5 int 
}

您应该提前了解要检索的 MongoDB 文档的结构,因为字段名称在struct类型中是硬编码的,并且需要匹配。

程序的第二部分如下:

func content(w http.ResponseWriter, r *http.Request) { 
   var Data []Document 
   myT := template.Must(template.ParseGlob("mongoDB.gohtml")) 

   mongoDBDialInfo := &mgo.DialInfo{ 
         Addrs:   []string{"127.0.0.1:27017"}, 
         Timeout: 20 * time.Second, 
   } 

   session, err := mgo.DialWithInfo(mongoDBDialInfo) 
   if err != nil { 
         fmt.Printf("DialWithInfo: %s\n", err) 
         return 
   } 
   session.SetMode(mgo.Monotonic, true) 
   c := session.DB(DatabaseName).C(collectionName) 

   err = c.Find(nil).All(&Data) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   fmt.Println("Found:", len(Data), "results!") 
   myT.ExecuteTemplate(w, "mongoDB.gohtml", Data) 
} 

与以前一样,使用在mgo.DialInfo结构中定义的参数,使用mgo.DialWithInfo()连接到 MongoDB。

Web 应用程序的最后部分如下:

func main() { 
   arguments := os.Args 

   iflen(arguments) <= 2 { 
         fmt.Println("Please provide a Database and a Collection!") 
         os.Exit(100) 
   } else { 
         DatabaseName = arguments[1] 
         collectionName = arguments[2] 
   } 

   http.HandleFunc("/", content) 
   http.ListenAndServe(":8001", nil) 
} 

MongoDB.gohtml的内容与template.gohtml的内容类似,这里不会呈现。您可以参考html/template 包部分了解template.gohtml的内容。

执行showMongo.go不会在屏幕上显示实际数据:您需要使用 Web 浏览器进行查看:

$ go run showMongo.go goDriver Numbers
Found: 0 results!
Found: 10 results!
Found: 14 results!

好处是,如果集合的数据发生了变化,您无需重新编译 Go 代码即可查看更改:您只需要重新加载网页。

以下屏幕截图显示了在 Web 浏览器上显示的showMongo.go的输出:

使用 showMongo.go

请注意,Numbers集合包含以下文档:

>db.Numbers.findOne() 
{ 
      "_id" : ObjectId("596530aeaab5252f5c1ab100"),
      "p1" : -10,
      "p2" : -20,
      "p3" : 100,
      "p4" : -1000,
      "p5" : 10000
}

请记住,MongoDB 结构中的额外数据,如果在 Go 结构中没有相应的字段,则会被忽略。

创建一个显示 MySQL 数据的应用程序

在本小节中,我们将介绍一个在 MySQL 表上执行查询的 Go 实用程序。新的命令行实用程序的名称将是showMySQL.go,将分为五部分呈现。

请注意,showMySQL.go将使用database/sql包,该包为查询 MySQL 数据库提供了通用的 SQL 接口。

所提供的实用程序需要两个参数:具有管理权限的用户名及其密码。

showMySQL.go的第一部分如下:

package main 

import ( 
   "database/sql"  
   "fmt" 
   _ "github.com/go-sql-driver/mysql" 
   "os" 
   "text/template" 
)

这里有一个小变化,因为showMySQL.go使用text/template而不是html/template。请注意,符合database/sql接口的驱动程序在代码中实际上从未直接引用,但它们仍然需要被初始化和导入。通过在"github.com/go-sql-driver/mysql"前面加上_字符,Go 会忽略"github.com/go-sql-driver/mysql"包实际上未在代码中使用的事实。

您还需要下载 MySQL Go 驱动程序:

$ go get github.com/go-sql-driver/mysql

实用程序的第二部分包含以下 Go 代码:

func main() { 
   var username string 
   var password string 

   arguments := os.Args 
   if len(arguments) == 3 { 
         username = arguments[1] 
         password = arguments[2] 
   } else { 
         fmt.Println("programName Username Password!") 
         os.Exit(100) 
   } 

来自showMySQL.go的第三个 Go 代码块如下:

   connectString := username + ":" + password + "@unix(/tmp/mysql.sock)/information_schema" 
   db, err := sql.Open("mysql", connectString) 

   rows, err := db.Query("SELECT DISTINCT(TABLE_SCHEMA) FROM TABLES;") 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

在这里,您手动构建了到 MySQL 的连接字符串。出于安全原因,默认的 MySQL 安装使用套接字(/tmp/mysql.sock)而不是网络连接。将使用的数据库名称是连接字符串的最后一部分(information_schema)。

您很可能需要调整这些参数以适应自己的数据库。

showMySQL.go的第四部分如下:

   var DATABASES []string 
   for rows.Next() { 
         var databaseName string 
         err := rows.Scan(&databaseName) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
         DATABASES = append(DATABASES, databaseName) 
   } 
   db.Close()

Next()函数遍历从select查询返回的所有记录,并借助for循环逐个返回它们。

程序的最后一部分如下:

   t := template.Must(template.New("t1").Parse(` 
   {{range $k := .}} {{ printf "\tDatabase Name: %s" $k}} 
   {{end}} 
   `)) 
   t.Execute(os.Stdout, DATABASES) 
   fmt.Println() 
} 

这一次,您将以纯文本形式接收数据,而不是以网页形式呈现数据。此外,由于文本模板很小,因此可以使用t变量的帮助在一行中定义它。

这里是否需要使用模板?当然不需要!但是学习如何定义 Go 模板而不使用外部模板文件是很好的。

因此,showMySQL.go的输出将类似于以下内容:

$ go run showMySQL.go root 12345

    Database Name: information_schema
    Database Name: mysql
    Database Name: performance_schema
    Database Name: sys

前面的输出显示了当前 MySQL 实例的可用数据库信息,这是一种在不使用 MySQL 客户端连接的情况下获取 MySQL 数据库信息的好方法。

一个方便的命令行实用程序

在本节中,我们将开发一个方便的命令行实用程序,该实用程序读取一些网页,这些网页可以在文本文件中找到或从标准输入中读取,并返回在这些网页中找到给定关键字的次数。为了更快,该实用程序将使用 goroutines 来获取所需的数据,并使用监控进程来收集数据并在屏幕上呈现。该实用程序的名称将是findKeyword.go,并将分为五个部分进行介绍。

实用程序的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "net/http" 
   "net/url" 
   "os" 
   "regexp" 
) 

type Data struct { 
   URL     string 
   Keyword string 
   Times   int 
   Error   error 
} 

Data struct类型将用于在通道之间传递信息。

findKeyword.go的第二部分包含以下 Go 代码:

func monitor(values <-chan Data, count int) { 
   fori := 0; i< count; i++ { 
         x := <-values 
         if x.Error == nil { 
               fmt.Printf("\t%s\t", x.Keyword) 
               fmt.Printf("\t%d\t in\t%s\n", x.Times, x.URL) 
         } else { 
               fmt.Printf("\t%s\n", x.Error) 
         } 
   } 
} 

monitor()函数是收集和在屏幕上打印所有信息的地方。

第三部分如下:

func processPage(myUrl, keyword string, out chan<- Data) { 
   var err error 
   times := 0 

   URL, err :=url.Parse(myUrl) 
   if err != nil { 
         out<- Data{URL: myUrl, Keyword: keyword, Times: 0, Error: err} 
         return 
   } 

   c := &http.Client{} 
   request, err := http.NewRequest("GET", URL.String(), nil) 
   if err != nil { 
         out<- Data{URL: myUrl, Keyword: keyword, Times: 0, Error: err} 
         return 
   } 

   httpData, err := c.Do(request) 
   if err != nil { 
         out<- Data{URL: myUrl, Keyword: keyword, Times: 0, Error: err} 
         return 
   } 

   bodyHTML := ""

   var buffer [1024]byte 
   reader := httpData.Body 
   for { 
         n, err := reader.Read(buffer[0:]) 
         if err != nil { 
               break 
         } 
         bodyHTML = bodyHTML + string(buffer[0:n]) 
   } 

   regExpr := keyword

   r := regexp.MustCompile(regExpr) 
   matches := r.FindAllString(bodyHTML, -1) 
   times = times + len(matches) 

   newValue := Data{URL: myUrl, Keyword: keyword, Times: times, Error: nil} 
   out<- newValue 
} 

在这里,您可以看到processPage()函数的实现,该函数在 goroutine 中执行。如果Data结构的Error字段不是nil,则表示出现了错误。

使用bodyHTML变量保存 URL 的整个内容是为了避免关键字在两次连续调用reader.Read()之间被分割。之后,使用正则表达式(r)在bodyHTML变量中搜索所需的关键字。

第四部分包含以下 Go 代码:

func main() { 
   filename := "" 
   var f *os.File 
   var keyword string 

   arguments := os.Args 
   iflen(arguments) == 1 { 
         fmt.Println("Not enough arguments!") 
         os.Exit(-1) 
   } 

   iflen(arguments) == 2 { 
         f = os.Stdin 
         keyword = arguments[1] 
   } else { 
         keyword = arguments[1] 
         filename = arguments[2] 
         fileHandler, err := os.Open(filename) 
         if err != nil { 
               fmt.Printf("error opening %s: %s", filename, err) 
               os.Exit(1) 
         } 
         f = fileHandler 
   } 

   deferf.Close() 

正如您所看到的,findKeyword.go 期望从文本文件或标准输入中获取输入,这是常见的 Unix 做法:这种技术最早在第八章中的进程和信号部分进行了说明。

findKeyword.go的最后一部分 Go 代码如下:

   values := make(chan Data, len(os.Args[1:])) 

   scanner := bufio.NewScanner(f) 
   count := 0 
   forscanner.Scan() { 
         count = count + 1 
         gofunc(URL string) { 
               processPage(URL, keyword, values) 
         }(scanner.Text()) 
   } 

   monitor(values, count) 
} 

这里没有什么特别的:您只需启动所需的 goroutines 和monitor()函数来管理它们。

执行findKeyword.go将创建以下输出:

$ go run findKeyword.go Tsoukalos /tmp/sites.html
  Get http://really.doesnotexist.com: dial tcp: lookup really.doesnotexist.com: no such host
  Tsoukalos         8      in   http://www.highiso.net/
  Tsoukalos         4      in   http://www.mtsoukalos.eu/
  Tsoukalos         3      in   https://www.packtpub.com/networking-and-servers/go-systems-programming
  Tsoukalos         0      in   http://cnn.com/
  Tsoukalos         0      in   http://doesnotexist.com

有趣的是,doesnotexist.com域实际上是存在的!

练习

  1. 在您的 Unix 机器上下载并安装 MongoDB。

  2. 访问net/http Go 标准包的文档页面,网址为golang.org/pkg/net/http/

  3. 访问html/template Go 标准包的文档页面,网址为golang.org/pkg/html/template/

  4. 更改getURL.go的 Go 代码,以使其能够获取多个网页。

  5. 阅读encoding/json包的文档,网址为golang.org/pkg/encoding/json/

  6. 访问 MongoDB 网站,网址为www.mongodb.org/

  7. 通过开发自己的示例来学习如何使用text/template

  8. 修改findKeyword.go的 Go 代码,以便能够搜索多个关键字。

总结

在本章中,我们讨论了 Go 中的 Web 开发,包括解析、编组和解组 JSON 数据,与 MongoDB 数据库交互;从 MySQL 数据库读取数据;在 Go 中创建 Web 服务器;在 Go 中创建 Web 客户端;以及使用http.ServeMux类型。

在下一章中,我们将讨论 Go 中的网络编程,其中包括使用低级命令创建 TCP 和 UDP 客户端和服务器。我们还将教你如何在 Go 中开发 RCP 客户端和 RCP 服务器。如果你喜欢开发 TCP/IP 应用程序,那么本书的最后一章就是为你准备的!

第十二章:网络编程

在上一章中,我们讨论了在 Go 中开发 Web 应用程序、与数据库通信以及处理 JSON 数据。

本章的主题是开发在 TCP/IP 网络上运行的 Go 应用程序。此外,您还将学习如何创建 TCP 和 UDP 客户端和服务器。本章的核心 Go 包将是net包:它的大多数函数都是相当低级的,需要对 TCP/IP 及其协议家族有很好的了解。

然而,请记住,网络编程是一个庞大的主题,无法在单独的一章中涵盖。本章将为您提供如何在 Go 中创建 TCP/IP 应用程序的基本方向。

更具体地说,本章将讨论以下主题:

  • TCP/IP 的操作方式

  • Go 标准包net

  • 开发 TCP 客户端和服务器

  • 编程 UDP 客户端和服务器

  • 开发 RPC 客户端

  • 实现 RPC 服务器

  • Wireshark 和tshark(1)网络流量分析器

  • Unix 套接字

  • 从 Go 程序执行 DNS 查找

关于网络编程

网络编程是开发可以使用 TCP/IP 在计算机网络上运行的应用程序。因此,如果不了解 TCP/IP 及其协议的工作方式,就无法创建网络应用程序和开发 TCP/IP 服务器。

我可以给网络应用程序开发人员的最好的两个建议是了解他们想要执行的任务背后的理论,并且知道网络由于多种原因而经常失败。网络故障中最恶劣的类型与故障或配置错误的 DNS 服务器有关,因为这类问题很难找到并且难以纠正。

关于 TCP/IP

TCP/IP是一组协议,帮助互联网运行。它的名称来自其两个最著名的协议:TCPIP

每个使用 TCP/IP 的设备必须具有 IP 地址,至少在其本地网络中是唯一的。它还需要一个与当前网络相关的网络掩码(用于将大型 IP 网络划分为较小的网络),一个或多个DNS 服务器(用于将 IP 地址转换为人类可记忆的格式,反之亦然),以及如果要与本地网络之外的设备通信,则需要一个将充当默认网关(当 TCP/IP 找不到其他发送位置时,将网络数据包发送到的网络设备)的设备的 IP 地址。

每个 TCP/IP 服务实际上是一个 Unix 进程,监听一个对每台机器都是唯一的端口号。请注意,端口号 0-1023 受限制,只能由 root 用户使用,因此最好避免使用它们,并选择其他内容,前提是它尚未被不同进程使用。

关于 TCP

TCP代表传输 控制 协议。TCP 软件使用称为 TCP 数据包的段在机器之间传输数据。TCP 的主要特点是它是一种可靠的协议,这意味着它试图确保数据包已传送。如果没有数据包传送的证据,TCP 会重新发送该特定数据包。除其他事项外,TCP 数据包可用于建立连接、传输数据、发送确认和关闭连接。

当两台机器之间建立 TCP 连接时,类似于电话呼叫的全双工虚拟电路将在这两台机器之间创建。这两台机器不断通信以确保数据正确发送和接收。如果由于某种原因连接失败,这两台机器会尝试找到问题并向相关应用程序报告。

TCP 为每个传输的数据包分配一个序列号,并期望接收 TCP 堆栈的正面确认(ACK)。如果在超时间隔内未收到 ACK,则数据将被重新传输,因为原始数据包被视为未传递。当数据包以无序方式到达时,接收 TCP 堆栈使用序列号重新排列段,这也消除了重复的段。

每个数据包的 TCP 头包括源端口和目标端口字段。这两个字段加上源和目标 IP 地址被组合在一起,以唯一标识每个 TCP 连接。TCP 头还包括一个 6 位标志字段,用于在 TCP 对等方之间传递控制信息。可能的标志包括 SYN,FIN,RESET,PUSH,URG 和 ACK。SYN 和 ACK 标志用于初始 TCP 3 次握手。RESET 标志表示接收方希望中止连接。

TCP 握手!

当建立连接时,客户端向服务器发送 TCP SYN 数据包。TCP 头还包括一个序列号字段,在 SYN 数据包中具有任意值。服务器发送回一个 TCP [SYN,ACK]数据包,其中包括相反方向的序列号和对先前序列号的确认。最后,为了真正建立 TCP 连接,客户端发送 TCP ACK 数据包以确认服务器的序列号。

尽管所有这些操作都是自动进行的,但了解幕后发生的事情是很好的!

关于 UDP 和 IP

IP代表Internet Protocol。IP 的主要特点是它本质上不是一种可靠的协议。IP 封装了在 TCP/IP 网络中传输的数据,因为它负责根据 IP 地址将数据包从源主机传递到目标主机。IP 必须找到一种寻址方法,以有效地将数据包发送到其目的地。尽管存在称为路由器的专用设备来执行 IP 路由,但每个 TCP/IP 设备都必须执行一些基本路由。

UDP用户数据报协议的缩写)基于 IP,这意味着它也是不可靠的。一般来说,UDP 比 TCP 简单,主要是因为 UDP 本身设计上就不可靠。因此,UDP 消息可能会丢失、重复或无序到达。此外,数据包可能比接收方处理它们的速度更快。因此,当速度比可靠性更重要时,使用 UDP!一个例子是实时视频和音频应用程序,其中追赶速度比缓冲和不丢失任何数据更重要!

因此,当您不需要太多的网络数据包来传输所需的信息时,使用基于 IP 的协议可能比使用 TCP 更有效,即使您必须重新传输网络数据包,因为没有来自 TCP 握手的流量开销。

关于 Wireshark 和 tshark

Wireshark是一款用于分析几乎任何类型的网络流量的图形应用程序。然而,有时您需要一些更轻便的东西,可以在没有图形用户界面的情况下远程执行。在这种情况下,您可以使用tshark,这是 Wireshark 的命令行版本。

为了帮助您找到真正想要的网络数据,Wireshark 和tshark支持捕获过滤器和显示过滤器。

捕获过滤器是在网络数据捕获过程中应用的过滤器;因此,它们使 Wireshark 丢弃不符合过滤条件的网络流量。显示过滤器是在数据包捕获后应用的过滤器;因此,它们只是隐藏一些网络流量而不是删除它:您可以随时禁用显示过滤器并恢复隐藏的数据。一般来说,显示过滤器被认为比捕获过滤器更有用和更灵活,因为通常情况下,您事先不知道要捕获或要检查什么。然而,在捕获时应用过滤器可以节省时间和磁盘空间,这是使用它们的主要原因。

以下屏幕截图显示了 Wireshark 捕获的 TCP 握手流量的更详细信息。客户端 IP 地址为10.0.2.15,目标 IP 地址为80.244.178.150。此外,简单的显示过滤器(tcp && !http)使 Wireshark 显示更少的数据包,并使输出更清晰,因此更容易阅读:

TCP 握手!

可以使用tshark(1)以文本格式查看相同的信息:

$ tshark -r handshake.pcap -Y '(tcp.flags.syn==1 ) || (tcp.flags == 0x0010 && tcp.seq==1 && tcp.ack==1)'
       18   5.144264    10.0.2.15 → 80.244.178.150 TCP 74 59897 → 80 [SYN] Seq=0 Win=29200 Len=0 MSS=1460 SACK_PERM=1 TSval=1585402 TSecr=0 WS=128
       19   5.236792 80.244.178.150 → 10.0.2.15    TCP 60 80 → 59897 [SYN, ACK] Seq=0 Ack=1 Win=65535 Len=0 MSS=1460
       20   5.236833    10.0.2.15 → 80.244.178.150 TCP 54 59897 → 80 [ACK] Seq=1 Ack=1 Win=29200 Len=0

-r参数后跟一个现有的文件名,允许您在屏幕上重放先前捕获的数据文件,而更复杂的显示过滤器在-Y参数之后定义,完成其余工作!

您可以在www.wireshark.org/了解更多关于 Wireshark 的信息,并通过查看其文档www.wireshark.org/docs/

关于 netcat 实用程序

有时您需要测试 TCP/IP 客户端或 TCP/IP 服务器:netcat(1)实用程序可以通过在 TCP 或 UDP 应用程序中扮演客户端或服务器的角色来帮助您。

您可以使用netcat(1)作为 TCP 服务的客户端,该服务在具有192.168.1.123 IP 地址的计算机上运行,并侦听端口号1234,如下所示:

$ netcat 192.168.1.123 1234

同样,您可以使用netcat(1)作为运行在名为amachine.com的 Unix 机器上并侦听端口号2345的 UDP 服务的客户端,如下所示:

$ netcat -vv -u amachine.com 2345

-l选项告诉netcat(1)监听传入连接,这使netcat(1)充当 TCP 或 UDP 服务器。如果尝试使用netcat(1)作为具有已在使用的端口的服务器,则将获得以下输出:

$ netcat -vv -l localhost -p 80
Can't grab 0.0.0.0:80 with bind : Permission denied

net Go 标准包

用于创建 TCP/IP 应用程序的最有用的 Go 包是net Go 标准包。net.Dial()函数用于作为客户端连接到网络,net.Listen()函数用于作为服务器接受连接。这两个函数的第一个参数都是网络类型,但相似之处就到此为止了。

对于net.Dial()函数,网络类型可以是 tcp、tcp4(仅限 IPv4)、tcp6(仅限 IPv6)、udp、udp4(仅限 IPv4)、udp6(仅限 IPv6)、ip、ip4(仅限 IPv4)、ip6(仅限 IPv6)、Unix、Unixgram 或 Unixpacket。对于net.Listen()函数,第一个参数可以是 tcp、tcp4、tcp6、Unix 或 Unixpacket。

net.Dial()函数的返回值是net.Conn接口类型,该接口实现了io.Readerio.Writer接口!这意味着您已经知道如何访问net.Conn接口的变量!

因此,尽管创建网络连接的方式与创建文本文件的方式不同,但它们的访问方法是相同的,因为net.Conn接口实现了io.Readerio.Writer接口。因此,由于网络连接被视为文件,您可能需要在此时查看第六章 文件输入和输出

Unix 套接字重温

回到第八章 进程和信号,我们简要讨论了 Unix 套接字,并介绍了一个作为 Unix 套接字客户端的小型 Go 程序。本节还将创建一个 Unix 套接字服务器,以便更清楚地说明问题。但是,Unix 套接字客户端的 Go 代码也将在此处更详细地解释,并丰富了错误处理代码。

一个 Unix 套接字服务器

Unix 套接字服务器将充当 Echo 服务器,这意味着它将将接收到的消息发送回客户端。程序的名称将是socketServer.go,将分为四部分介绍给您。

socketServer.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

Unix 套接字服务器的第二部分如下:

func echoServer(c net.Conn) { 
   for { 
         buf := make([]byte, 1024) 
         nr, err := c.Read(buf) 
         if err != nil { 
               return 
         } 

         data := buf[0:nr] 
         fmt.Printf("->: %v\n", string(data)) 
         _, err = c.Write(data) 
         if err != nil { 
               fmt.Println(err) 
         } 
   } 
} 

这是实现服务传入连接的函数所在之处。

程序的第三部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a socket file.") 
         os.Exit(100) 
   } 
   socketFile := arguments[1] 

   l, err := net.Listen("unix", socketFile) 
   if err != nil { 
         fmt.Println(err) 
os.Exit(100) 
   } 

在这里,您可以看到使用net.Listen()函数和unix参数创建所需的套接字文件。

最后,最后一部分包含以下 Go 代码:

   for { 
         fd, err := l.Accept() 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
         go echoServer(fd) 
   } 
} 

如您所见,每个连接首先由Accept()函数处理,并由其自己的 goroutine 提供服务。

socketServer.go为客户端提供服务时,它会生成以下输出:

$ go run socketServer.go /tmp/aSocket
->: Hello Server!

如果无法创建所需的套接字文件,例如,如果它已经存在,您将收到类似以下的错误消息:

$ go run socketServer.go /tmp/aSocket
listen unix /tmp/aSocket: bind: address already in use
exit status 100

一个 Unix 套接字客户端

Unix 套接字客户端程序的名称是socketClient.go,将分为四部分介绍。

实用程序的第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "io" 
   "log" 
   "net" 
   "os" 
   "time" 
) 

这里没有什么特别的,只是所需的 Go 包。第二部分包含了一个 Go 函数的定义:

func readSocket(r io.Reader) {

   buf := make([]byte, 1024) 
   for { 
         n, err := r.Read(buf[:]) 
         if err != nil { 
               fmt.Println(err) 
               return 
         } 
         fmt.Println("-> ", string(buf[0:n])) 
   } 
} 

readSocket()函数使用Read()从套接字文件中读取数据。请注意,尽管socketClient.go只是从套接字文件中读取数据,但套接字是双向的,这意味着您也可以向其写入数据。

第三部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a socket file.") 
         os.Exit(100) 
   } 
   socketFile := arguments[1] 

   c, err := net.Dial("unix", socketFile) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   defer c.Close() 

使用正确的第一个参数的net.Dial()函数允许您在尝试从中读取之前连接到套接字文件。

socketClient.go的最后一部分如下:

   go readSocket(c) 
   for { 
         _, err := c.Write([]byte("Hello Server!")) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
         time.Sleep(1 * time.Second) 
   } 
} 

要使用socketClient.go,您必须有另一个处理 Unix 套接字文件的程序,在本例中将是socketServer.go。因此,如果socketServer.go已经在运行,您将从socketClient.go获得以下输出:

$ go run socketClient.go /tmp/aSocket
->: Hello Server!

如果您没有足够的 Unix 文件权限来读取所需的套接字文件,那么socketClient.go将失败,并显示以下错误消息:

$ go run socketClient.go /tmp/aSocket
dial unix /tmp/aSocket: connect: permission denied
exit status 100

同样,如果您要读取的套接字文件不存在,socketClient.go将失败,并显示以下错误消息:

$ go run socketClient.go /tmp/aSocket
dial unix /tmp/aSocket: connect: no such file or directory
exit status 100

执行 DNS 查找

存在许多类型的 DNS 查找,但其中两种最受欢迎。在第一种类型中,您希望从 IP 地址转到域名,而在第二种类型中,您希望从域名转到 IP 地址。

以下输出显示了第一种类型的 DNS 查找的示例:

$ host 109.74.193.253
253.193.74.109.in-addr.arpa domain name pointer li140-253.members.linode.com.

以下输出显示了第二种类型的 DNS 查找的三个示例:

$ host www.mtsoukalos.eu
www.mtsoukalos.eu has address 109.74.193.253
$ host www.highiso.net
www.highiso.net has address 109.74.193.253
$ host -t a cnn.com
cnn.com has address 151.101.1.67
cnn.com has address 151.101.129.67
cnn.com has address 151.101.65.67
cnn.com has address 151.101.193.67

正如您在上述示例中所看到的,一个 IP 地址可以为多个主机提供服务,一个主机名可以有多个 IP 地址。

Go 标准库提供了net.LookupHost()net.LookupAddr()函数,可以为您回答 DNS 查询。但是,它们都不允许您定义要查询的 DNS 服务器。虽然使用标准的 Go 库是理想的,但存在外部的 Go 库,允许您选择所需的 DNS 服务器,这在排除 DNS 配置问题时是非常重要的。

使用 IP 地址作为输入

将返回 IP 地址的主机名的 Go 实用程序的名称将是lookIP.go,将分为三部分介绍。

第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

第二部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide an IP address!") 
         os.Exit(100) 
   } 

   IP := arguments[1] 
   addr := net.ParseIP(IP) 
   if addr == nil { 
         fmt.Println("Not a valid IP address!") 
         os.Exit(100) 
   } 

net.ParseIP()函数允许您验证给定 IP 地址的有效性,并且对于捕获诸如288.8.8.88.288.8.8之类的非法 IP 地址非常方便。

实用程序的最后部分如下:

   hosts, err := net.LookupAddr(IP) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   for _, hostname := range hosts { 
         fmt.Println(hostname) 
   } 
} 

正如您所看到的,net.LookupAddr()函数返回一个字符串切片,其中包含与给定 IP 地址匹配的名称列表。

执行lookIP.go将生成以下输出:

$ go run lookIP.go 288.8.8.8
Not a valid IP address!
exit status 100
$ go run lookIP.go 8.8.8.8
google-public-dns-a.google.com.

您可以使用host(1)dig(1)验证dnsLookup.go的输出:

$ host 8.8.8.8
8.8.8.8.in-addr.arpa domain name pointer google-public-dns-a.google.com.

使用主机名作为输入

此 DNS 实用程序的名称将是lookHost.go,并将分为三部分呈现。lookHost.go实用程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

程序的第二部分有以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide an argument!") 
         os.Exit(100) 
   } 

   hostname := arguments[1] 
   IPs, err := net.LookupHost(hostname) 

同样,net.LookupHost()函数也返回一个包含所需信息的字符串切片。

程序的第三部分包含以下代码,用于错误检查和打印net.LookupHost()的输出:

   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   for _, IP := range IPs { 
         fmt.Println(IP) 
   } 
} 

执行lookHost.go将生成以下输出:

$ go run lookHost.go www.google
lookup www.google: no such host
exit status 100
$ go run lookHost.go www.google.com
2a00:1450:4001:81f::2004
172.217.16.164

输出的第一行是 IPv6 地址,而第二行输出是www.google.com的 IPv4 地址。

您可以通过将其输出与host(1)实用程序的输出进行比较来验证lookHost.go的操作:

$ host www.google.com
www.google.com has address 172.217.16.164
www.google.com has IPv6 address 2a00:1450:4001:81a::2004

获取域的 NS 记录

本小节将介绍另一种返回给定域的域名服务器的 DNS 查找。这对于解决与 DNS 相关的问题并了解域的状态非常方便。所呈现的程序将被命名为lookNS.go,并将分为三部分呈现。

实用程序的第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

第二部分有以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a domain!") 
         os.Exit(100) 
   } 

   domain := arguments[1] 

   NSs, err := net.LookupNS(domain) 

net.LookupNS()函数通过返回NS元素的切片为我们完成所有工作。

代码的最后部分主要用于打印结果:

   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   for _, NS := range NSs { 
         fmt.Println(NS.Host) 
   } 
} 

执行lookNS.go将生成以下输出:

$ go run lookNS.go mtsoukalos.eu
ns5.linode.com.
ns2.linode.com.
ns3.linode.com.
ns1.linode.com.
ns4.linode.com.

以下查询失败的原因是www.mtsoukalos.eu不是一个域,而是一个单个主机,这意味着它没有与之关联的NS记录:

$ go run lookNS.go www.mtsoukalos.eu
lookup www.mtsoukalos.eu on 8.8.8.8:53: no such host
exit status 100

您可以使用host(1)实用程序验证先前的输出:

$ host -t ns mtsoukalos.eu
mtsoukalos.eu name server ns5.linode.com.
mtsoukalos.eu name server ns4.linode.com.
mtsoukalos.eu name server ns3.linode.com.
mtsoukalos.eu name server ns1.linode.com.
mtsoukalos.eu name server ns2.linode.com.
$ host -t ns www.mtsoukalos.eu
www.mtsoukalos.eu has no NS record

开发一个简单的 TCP 服务器

本节将开发一个实现Echo服务的 TCP 服务器。Echo 服务通常使用 UDP 协议实现,因为它简单,但也可以使用 TCP 实现。Echo 服务通常使用端口号7,但我们的实现将使用其他端口号:

$ grep echo /etc/services
echo        7/tcp
echo        7/udp

TCPserver.go文件将保存本节的 Go 代码,并将分为六部分呈现。出于简单起见,每个连接都在main()函数中处理,而不调用单独的函数。但是,这不是推荐的做法。

第一部分包含预期的序言:

package main 

import ( 
   "bufio" 
   "fmt" 
   "net" 
   "os" 
   "strings" 
) 

TCP 服务器的第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide port number") 
         os.Exit(100) 
   } 

TCPserver.go的第三部分包含以下 Go 代码:

   PORT := ":" + arguments[1] 
   l, err := net.Listen("tcp", PORT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   defer l.Close() 

在这里需要记住的重要一点是,net.Listen()返回一个Listener变量,这是一个用于面向流的协议的通用网络监听器。此外,Listen()函数可以支持更多格式:查看net包的文档以获取更多信息。

TCP 服务器的第四部分有以下 Go 代码:

   c, err := l.Accept() 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

只有在成功调用Accept()之后,TCP 服务器才能开始与 TCP 客户端交互。尽管如此,当前版本的TCPserver.go有一个非常严重的缺点:它只能为单个 TCP 客户端提供服务,即连接到它的第一个客户端。

TCPserver.go代码的第五部分如下:

   for { 
         netData, err := bufio.NewReader(c).ReadString('\n') 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 

在这里,您可以使用bufio.NewReader().ReadString()从客户端读取数据。上述调用允许您逐行读取输入。此外,for循环允许您从 TCP 客户端持续读取数据,直到您希望停止为止。

Echo TCP 服务器的最后部分如下:

         fmt.Print("-> ", string(netData)) 
         c.Write([]byte(netData)) 
         if strings.TrimSpace(string(netData)) == "STOP" { 
               fmt.Println("Exiting TCP server!") 
               return 
         } 
   } 
} 

当前版本的TCPserver.go在接收到STOP字符串作为输入时停止。虽然 TCP 服务器通常不会以这种方式终止,但这是终止仅为单个客户端提供服务的 TCP 服务器进程的一种非常方便的方式!

接下来,我们将使用netcat(1)测试TCPserver.go

$ go run TCPserver.go 1234
-> Hi!
-> STOP
Exiting TCP server!

netcat(1)部分如下:

$ nc localhost 1234 
Hi!
Hi!
STOP
STOP

这里,第一行和第三行是我们的输入,而第二行和第四行是 Echo 服务器的响应。

如果您尝试使用不正确的端口号,TCPserver.go将生成以下错误消息并退出:

$ go run TCPserver.go 123456
listen tcp: address 123456: invalid port
exit status 100

开发一个简单的 TCP 客户端

在本节中,我们将开发一个名为TCPclient.go的 TCP 客户端。客户端将尝试连接的端口号以及服务器地址将作为程序的命令行参数给出。TCP 客户端的 Go 代码将分为五个部分进行介绍;第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "net" 
   "os" 
   "strings" 
) 

TCPclient.go的第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide host:port.") 
         os.Exit(100) 
   } 

TCPclient.go的第三部分包含以下 Go 代码:

   CONNECT := arguments[1] 
   c, err := net.Dial("tcp", CONNECT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

再次,您使用net.Dial()函数尝试连接到所需 TCP 服务器的所需端口。

TCP 客户端的第四部分如下:

   for { 
         reader := bufio.NewReader(os.Stdin) 
         fmt.Print(">> ") 
         text, _ := reader.ReadString('\n') 
         fmt.Fprintf(c, text+"\n") 

在这里,您从用户那里读取数据,然后使用fmt.Fprintf()将其发送到 TCP 服务器。

TCPclient.go的最后部分如下:

         message, _ := bufio.NewReader(c).ReadString('\n') 
         fmt.Print("->: " + message) 
         if strings.TrimSpace(string(text)) == "STOP" { 
               fmt.Println("TCP client exiting...") 
               return 
         } 
   } 
} 

在这部分中,您将使用bufio.NewReader().ReadString()从 TCP 服务器获取数据。使用strings.TrimSpace()函数的原因是从要与静态字符串(STOP)进行比较的变量中删除任何空格和换行符。

所以,现在是时候验证TCPclient.go是否按预期工作,使用它连接到TCPserver.go

$ go run TCPclient.go localhost:1024
>> 123
->: 123
>> Hello server!
->: Hello server!
>> STOP
->: STOP
TCP client exiting...

如果在指定的主机上指定的 TCP 端口没有进程在监听,那么您将收到类似以下的错误消息:

$ go run TCPclient.go localhost:1024
dial tcp [::1]:1024: getsockopt: connection refused
exit status 100

使用其他函数来实现 TCP 服务器

在这个小节中,我们将使用一些略有不同的函数来开发TCPserver.go的功能。新的 TCP 服务器的名称将是TCPs.go,将分为四个部分进行介绍。

TCPs.go的第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

TCP 服务器的第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a port number!") 
         os.Exit(100) 
   } 

   SERVER := "localhost" + ":" + arguments[1] 

到目前为止,与TCPserver.go的代码没有区别。

区别在TCPs.go的第三部分开始,如下:

   s, err := net.ResolveTCPAddr("tcp", SERVER) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   l, err := net.ListenTCP("tcp", s) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

在这里,您使用net.ResolveTCPAddr()net.ListenTCP()函数。这个版本比TCPserver.go更好吗?实际上并不是。但是 Go 代码可能看起来更清晰一些,这对一些人来说是一个很大的优势。另外,net.ListenTCP()返回一个TCPListener值,当与net.Accept()而不是net.Accept()一起使用时,将返回TCPConn,它提供了更多的方法,允许您更改更多的套接字选项。

TCPs.go的最后部分包含以下 Go 代码:

   buffer := make([]byte, 1024) 

   for { 
         conn, err := l.Accept() 
         n, err := conn.Read(buffer) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 

         fmt.Print("> ", string(buffer[0:n]))

         _, err = conn.Write(buffer) 

         conn.Close() 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
   } 
} 

这里没有什么特别的。您仍然使用Accept()来获取和处理客户端请求。但是,这个版本使用Read()一次性获取客户端数据,这在您不必处理大量输入时非常方便。

TCPs.go的操作与TCPserver.go的操作相同,因此这里不会展示。

如果您尝试使用无效的端口号创建 TCP 服务器,TCPs.go将生成如下信息的错误消息:

$ go run TCPs.go 123456
address 123456: invalid port
exit status 100

使用替代函数来实现 TCP 客户端

再次,我们将使用一些略有不同的函数来实现TCPclient.go,这些函数由net Go 标准包提供。新版本的名称将是TCPc.go,将分为四个代码段进行展示。

第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

程序的第二个代码段如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a server:port string!") 
         os.Exit(100) 
   } 

   CONNECT := arguments[1] 
   myMessage := "Hello from TCP client!\n" 

这一次,我们将向 TCP 服务器发送一个静态消息。

TCPc.go的第三部分如下:

   tcpAddr, err := net.ResolveTCPAddr("tcp", CONNECT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   conn, err := net.DialTCP("tcp", nil, tcpAddr) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

在这部分中,您将看到net.ResolveTCPAddr()net.DialTCP()的使用,这是TCPc.goTCPclient.go之间的区别所在。

TCP 客户端的最后部分如下:

   _, err = conn.Write([]byte(myMessage)) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   fmt.Print("-> ", myMessage) 
   buffer := make([]byte, 1024)

   n, err := conn.Read(buffer) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   fmt.Print(">> ", string(buffer[0:n])) 
   conn.Close() 
} 

您可能会问是否可以将TCPc.goTCPserver.goTCPs.goTCPclient.go一起使用。答案是肯定的,因为实现和函数名称与实际进行的 TCP/IP 操作无关。

开发一个简单的 UDP 服务器

本节还将开发一个 Echo 服务器。但是,这次 Echo 服务器将使用 UDP 协议。程序的名称将是UDPserver.go,并将分为五个部分呈现给您。

第一部分包含了预期的序言:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
   "strings" 
) 

第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a port number!") 
         os.Exit(100) 
   } 
   PORT := ":" + arguments[1] 

UDPserver.go的第三部分如下:

   s, err := net.ResolveUDPAddr("udp", PORT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   connection, err := net.ListenUDP("udp", s) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

UDP 方法与 TCP 方法类似:只需调用不同名称的函数。

程序的第四部分包含以下 Go 代码:

   defer connection.Close() 
   buffer := make([]byte, 1024) 

   for { 
         n, addr, err := connection.ReadFromUDP(buffer) 
         fmt.Print("-> ", string(buffer[0:n])) 
         data := []byte(buffer[0:n]) 
         _, err = connection.WriteToUDP(data, addr) 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 

在 UDP 情况下,您使用ReadFromUDP()从 UDP 连接读取数据,并使用WriteToUDP()向 UDP 连接写入数据。此外,UDP 连接不需要调用类似于net.Accept()的函数。

UDP 服务器的最后一部分如下:

         if strings.TrimSpace(string(data)) == "STOP" { 
               fmt.Println("Exiting UDP server!") 
               return 
         } 
   } 
} 

我们将再次使用netcat(1)测试UDPserver.go

$ go run UDPserver.go 1234
-> Hi!
-> Hello!
-> STOP
Exiting UDP server!

开发一个简单的 UDP 客户端

在本节中,我们将开发一个 UDP 客户端,我们将命名为UDPclient.go并分为五个部分。

正如您将看到的,UDPclient.goTCPc.go的 Go 代码之间的代码差异基本上是所使用函数名称的差异:总体思路是完全相同的。

UDP 客户端的第一部分如下:

package main 

import ( 
   "fmt" 
   "net" 
   "os" 
) 

实用程序的第二部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a host:port string") 
         os.Exit(100) 
   } 
   CONNECT := arguments[1] 

UDPclient.go的第三部分如下:

   s, err := net.ResolveUDPAddr("udp", CONNECT) 
   c, err := net.DialUDP("udp", nil, s) 

   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   fmt.Printf("The UDP server is %s\n", c.RemoteAddr().String()) 
   defer c.Close() 

这里没有什么特别的:只是使用net.ResolveUDPAddr()net.DialUDP()来连接到 UDP 服务器。

UDP 客户端的第四部分如下:

   data := []byte("Hello UDP Echo server!\n") 
   _, err = c.Write(data) 

   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

这次,您将使用Write()将数据发送到 UDP 服务器,尽管您将使用ReadFromUDP()从 UDP 服务器读取数据。

UDPclient.go的最后一部分如下:

   buffer := make([]byte, 1024) 
   n, _, err := c.ReadFromUDP(buffer) 
   fmt.Print("Reply: ", string(buffer[:n])) 
} 

由于我们有UDPserver.go并且知道它可以工作,我们可以使用UDPserver.go来测试UDPclient.go的操作:

$ go run UDPclient.go localhost:1234
The UDP server is 127.0.0.1:1234
Reply: Hello UDP Echo server!

如果您在没有 UDP 服务器监听所需端口的情况下执行UDPclient.go,您将获得以下输出,其中并未明确说明它无法连接到 UDP 服务器:它只显示了一个空回复:

$ go run UDPclient.go localhost:1024
The UDP server is 127.0.0.1:1024
Reply:

一个并发的 TCP 服务器

在本节中,您将学习如何开发一个并发的 TCP 服务器:每个客户端连接将被分配给一个新的 goroutine 来为客户端请求提供服务。请注意,尽管 TCP 客户端最初连接到相同的端口,但它们使用的端口号与服务器的主端口号不同:这是由 TCP 自动处理的,也是 TCP 的工作方式。

虽然创建一个并发的 UDP 服务器也是可能的,但由于 UDP 的工作方式,这可能并不是绝对必要的。但是,如果您有一个非常繁忙的 UDP 服务,那么您可能需要考虑开发一个并发的 UDP 服务器。

程序的名称将是concTCP.go,并将分为五个部分呈现。好处是,一旦您定义了一个处理传入连接的函数,您所需要做的就是将该函数作为 goroutine 执行,其余的工作将由 Go 处理!

concTCP.go的第一部分如下:

package main 

import ( 
   "bufio" 
   "fmt" 
   "net" 
   "os" 
   "strings" 
   "time" 
) 

并发 TCP 服务器的第二部分如下:

func handleConnection(c net.Conn) { 
   for { 
         netData, err := bufio.NewReader(c).ReadString('\n') 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 

         fmt.Print("-> ", string(netData)) 
         c.Write([]byte(netData)) 
         if strings.TrimSpace(string(netData)) == "STOP" { 
               break 
         } 
   } 
   time.Sleep(3 * time.Second) 
   c.Close() 
} 

这是处理每个 TCP 请求的函数的实现。最后的时间延迟用于给您足够的时间与另一个 TCP 客户端连接并证明concTCP.go可以为多个 TCP 客户端提供服务。

程序的第三部分包含以下 Go 代码:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a port number!") 
         os.Exit(100) 
   } 

   PORT := ":" + arguments[1] 

concTCP.go的第四部分包含以下 Go 代码:

   l, err := net.Listen("tcp", PORT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   defer l.Close() 

到目前为止,main()函数中没有什么特别的,因为尽管concTCP.go将处理多个请求,但它只需要一次调用net.Listen()

最后一部分 Go 代码如下:

   for { 
         c, err := l.Accept() 
         if err != nil { 
               fmt.Println(err) 
               os.Exit(100) 
         } 
         go handleConnection(c) 
   } 
} 

concTCP.go处理其请求的所有差异都可以在 Go 代码的最后几行找到。每次程序使用Accept()接受新的网络请求时,都会启动一个新的 goroutine,并且concTCP.go立即准备好接受更多的请求。请注意,为了终止concTCP.go,您将需要按下Ctrl + C,因为STOP关键字用于终止程序的每个 goroutine。

执行concTCP.go并使用各种 TCP 客户端连接到它,将生成以下输出:

$ go run concTCP.go 1234
-> Hi!
-> Hello!
-> STOP
...

远程过程调用(RPC)

远程过程调用RPC)是一种用于进程间通信的客户端-服务器机制。请注意,RPC 客户端和 RPC 服务器使用 TCP/IP 进行通信,这意味着它们可以存在于不同的机器上。

为了开发 RPC 客户端或 RPC 服务器的实现,您需要按照一定的步骤调用一些函数。这两种实现都不难;您只需要遵循一定的步骤。

此外,请访问https://golang.org/pkg/net/rpc/上可以找到的net/rpc Go 标准包的文档页面。

请注意,所呈现的 RPC 示例将使用 TCP 进行客户端-服务器交互。但是,您也可以使用 HTTP 进行客户端-服务器通信。

一个 RPC 服务器

本小节将介绍一个名为RPCserver.go的 RPC 服务器。正如您将在RPCserver.go程序的前言中看到的那样,RPC 服务器导入了一个名为sharedRPC的包,该包在sharedRPC.go文件中实现:包的名称是任意的。其内容如下:

package sharedRPC 

type MyInts struct { 
   A1, A2 uint 
   S1, S2 bool 
} 

type MyInterface interface {

   Add(arguments *MyInts, reply *int) error 
   Subtract(arguments *MyInts, reply *int) error 
} 

因此,在这里,您定义了一个新的结构,其中包含两个无符号整数的符号和值,并定义了一个名为MyInterface的新接口。

然后,您应该安装sharedRPC.go,这意味着您应该在尝试在程序中使用sharedRPC包之前执行以下命令:

$ mkdir ~/go
$ mkdir ~/go/src
$ mkdir ~/go/src/sharedRPC
$ export GOPATH=~/go
$ vi ~/go/src/sharedRPC/sharedRPC.go
$ go install sharedRPC

如果您使用的是 macOS 机器(darwin_amd64)并且希望确保一切正常,您可以执行以下两个命令:

$ cd ~/go/pkg/darwin_amd64/
$ ls -l sharedRPC.a
-rw-r--r--  1 mtsouk  staff  4698 Jul 27 11:49 sharedRPC.a

您真正需要记住的是,归根结底,RPC 服务器和 RPC 客户端之间交换的是函数名称及其参数。只有在sharedRPC.go接口中定义的函数才能在 RPC 交互中使用:RPC 服务器将需要实现MyInterface接口的函数。RPCserver.go的 Go 代码将分为五部分呈现;RPC 服务器的第一部分具有预期的前言,其中还包括我们制作的sharedRPC包:

package main 

import ( 
   "fmt" 
   "net" 
   "net/rpc" 
   "os" 
   "sharedRPC" 
) 

RPCserver.go的第二部分如下:

type MyInterface int 

func (t *MyInterface) Add(arguments *sharedRPC.MyInts, reply *int) error { 
   s1 := 1 
   s2 := 1 

   if arguments.S1 == true { 
         s1 = -1 
   } 

   if arguments.S2 == true { 
         s2 = -1 
   } 

   *reply = s1*int(arguments.A1) + s2*int(arguments.A2) 
   return nil 
} 

这是将要提供给 RPC 客户端的第一个函数的实现:您可以拥有尽可能多的函数,只要它们包含在接口中。

RPCserver.go的第三部分包含以下 Go 代码:

func (t *MyInterface) Subtract(arguments *sharedRPC.MyInts, reply *int) error { 
   s1 := 1 
   s2 := 1 

   if arguments.S1 == true { 
         s1 = -1 
   } 

   if arguments.S2 == true { 
         s2 = -1 
   } 

   *reply = s1*int(arguments.A1) - s2*int(arguments.A2) 
   return nil 
} 

这是 RPC 服务器向 RPC 客户端提供的第二个函数。

RPCserver.go的第四部分包含以下 Go 代码:

func main() { 
   PORT := ":1234" 

   myInterface := new(MyInterface) 
   rpc.Register(myInterface) 

   t, err := net.ResolveTCPAddr("tcp", PORT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   l, err := net.ListenTCP("tcp", t) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

由于我们的 RPC 服务器使用 TCP,您需要调用net.ResolveTCPAddr()net.ListenTCP()来进行调用。但是,您首先需要调用rpc.Register()以便能够提供所需的接口。

程序的最后部分如下:

   for { 
         c, err := l.Accept() 
         if err != nil { 
               continue 
         } 
         rpc.ServeConn(c) 
   } 
} 

在这里,您可以像往常一样使用Accept()接受新的 TCP 连接,但是使用rpc.ServeConn()来提供服务。

您将需要等待下一节和 RPC 客户端的开发,以便测试RPCserver.go的操作。

一个 RPC 客户端

在本节中,我们将开发一个名为RPCclient.go的 RPC 客户端。RPCclient.go的 Go 代码将分为五部分呈现;第一部分如下:

package main 

import ( 
   "fmt" 
   "net/rpc" 
   "os" 
   "sharedRPC" 
) 

请注意 RPC 客户端中sharedRPC包的使用。

RPCclient.go的第二部分如下:

func main() { 
   arguments := os.Args 
   if len(arguments) == 1 { 
         fmt.Println("Please provide a host:port string!") 
         os.Exit(100) 
   } 

   CONNECT := arguments[1] 

程序的第三部分包含以下 Go 代码:

   c, err := rpc.Dial("tcp", CONNECT) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 

   args := sharedRPC.MyInts{17, 18, true, false} 
   var reply int 

由于MyInts结构在sharedRPC.go中定义,因此您需要在 RPC 客户端中将其用作sharedRPC.MyInts。此外,您调用rpc.Dial()来连接到 RPC 服务器,而不是net.Dial()

RPC 客户端的第四部分包含以下 Go 代码:

   err = c.Call("MyInterface.Add", args, &reply) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   fmt.Printf("Reply (Add): %d\n", reply) 

在这里,您使用Call()函数来执行 RPC 服务器中的所需函数。MyInterface.Add()函数的结果存储在先前声明的reply变量中。

RPCclient.go的最后部分如下:

   err = c.Call("MyInterface.Subtract", args, &reply) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(100) 
   } 
   fmt.Printf("Reply (Subtract): %d\n", reply) 
} 

在这里,您执行MyInterface.Subtract()函数的方式与之前相同。

正如您可以猜到的,您无法在没有 RCP 服务器的情况下测试 RPC 客户端,反之亦然:netcat(1)不能用于 RPC。

首先,您需要启动RPCserver.go进程:

$ go run RPCserver.go

然后,您将执行RPCclient.go程序:

$ go run RPCclient.go localhost:1234
Reply (Add): 1
Reply (Subtrack): -35

如果RPCserver.go进程没有运行,而您尝试执行RPCclient.go,您将收到以下错误消息:

$ go run RPCclient.go localhost:1234
dial tcp [::1]:1234: getsockopt: connection refused
exit status 100

当然,RPC 不是用于添加整数或自然数,而是用于执行更复杂的操作,您希望从一个中心点进行控制。

练习

  1. 阅读 net 包的文档,以了解其可用函数列表:golang.org/pkg/net/

  2. Wireshark 是分析任何类型网络流量的好工具:尝试更多地使用它。

  3. 修改socketClient.go的代码,以便从用户那里读取输入。

  4. 修改socketServer.go的代码,以便向客户端返回一个随机数。

  5. 修改TCPserver.go的代码,以便在接收到用户给定的 Unix 信号时停止。

  6. 修改concTCP.go的 Go 代码,以便跟踪它服务过的客户端数量,并在退出之前打印该数字。

  7. RPCserver.go添加一个quit()函数,执行其名称所暗示的操作。

  8. 开发您自己的 RPC 示例。

总结

在本章中,我们向您介绍了 TCP/IP,并讨论了如何在 Go 中开发 TCP 和 UDP 服务器和客户端,以及创建 RPC 客户端和服务器。

在这一点上,没有下一章,因为这是本书的最后一章!恭喜您阅读了整本书!您现在已经准备好开始在 Go 中开发有用的 Unix 命令行实用程序了;所以,继续并立即开始编程您自己的工具!

posted @ 2024-05-04 22:35  绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报