Unix shell就像魔法,通过正确的shell代码可以管理文件、处理文本、计算数据,并将任何程序的输出作为其他程序的输入。所谓一行正确的shell代码可实现魔法般的功能。系统程序
并不是shell本身很聪明。作为一种编程语言,至少对于复杂任务来说,它显然是笨拙的。但它优雅的设计使它成为完美的脚本语言:短小、专注于操作文件、进程或文本,为管理计算机系统服务。换句话说,写系统程序方便。目前有各种shell脚本,其中bash shell具有任务控制和基于文本的用户交互接口。这里存在的问题就是,有许多不同的、相互不兼容的shell脚本。本文我们将所有这些都称为“shell”。为什么使用Go写脚本?
如果shell是传统方式写系统程序的话, 那么使用Go这样的语言有什么意义呢?
Go有很多优势:快速、可伸缩、编写方便,也可以由大型团队长期维护。Go作为一种强类型编译语言,为我们编写正确的程序提供了很多支持。它还以一种难以忽视的方式暴露错误,提倡健壮的程序。然而,尽管shell针对脚本和控制特定任务进行了优化,但Go是一种通用语言,用于各种不同的应用程序。这并不意味着我们不能将go用于系统编程。只是因为它缺少很多内置的工具来方便编写这样的程序。至少,可能不像shell那么简单。例如,考虑一个典型的运维任务,计算日志文件中匹配某个字符串(比如error)的行数。大多数有经验的Unix用户会编写某种shell脚本来完成。例如:grep error log.txt |wc -l
该命令的总体效果是打印log.txt中匹配字符串"error"的行数。shell可以方便地组合多个命令,如grep和wc,来实现这一目标。一个典型任务
shell也可以执行一些复杂任务。假设我们有一个web服务器访问日志需要分析。下面就是一行访问日志:包含客户端IP地址、时间戳和各种请求信息。203.0.113.17 - - [30/Jun/2019:17:06:15 +0000] "GET / HTTP/1.1" 200 2028 "https://example.com/ "Mozilla/5.0..."
假设我们想找到访问服务器最多的10个用户。我们该如何实现?每一行访问日志表示一个请求,因此需要计算每个IP有多少条日志信息,然后将它们降序排列,找出前面10个。下面的shell脚本可以实现该功能:cut -d' ' -f 1 access.log |sort |uniq -c |sort -rn |head
上面的脚本使用cut命令从每行日志中提取IP地址,uniq -c计算每个不同IP数量,然后用sort -rn降排序,最后使用head命令打印前10条内容。由于几乎所有Unix命令都可以接受标准输入中的数据,并将结果写入标准输出,一些非常复杂命令可以用这种方式构造,只需使用shell的管道操作符即可。这是一种非常强大和灵活的编程范式,它在很大程度上解释了Unix模型今天的主导地位。因此值得花一点时间学习如何最大限度地发挥shell能力。那么我们是否可以使用Go来实现这个功能?试试用Go实现是否简单。复杂实现方式
虽然要实现的功能已经明确,但用Go编写这个程序并不容易。显然,很难做到像shell版本那样简洁。下面的go程序可以实现类似功能:func main() {
f, err := os.Open("log.txt")
if err != nil {
panic(err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
uniques := map[string]int{}
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 0 {
uniques[fields[0]]++
}
}
type freq struct {
addr string
count int
}
freqs := make([]freq, 0, len(uniques))
for addr, count := range uniques {
freqs = append(freqs, freq{addr, count})
}
sort.Slice(freqs, func(i, j int) bool {
return freqs[i].count > freqs[j].count
})
fmt.Printf("%-16s%s\n", "Address", "Requests")
for i, f := range freqs {
if i > 9 {
break
}
fmt.Printf("%-16s%d\n", f.addr, f.count)
}
}
这个程序有几个问题,尤其是它相当复杂。你可能想弄清楚它是如何工作的来测试自己的代码阅读能力,但我绝不推荐它作为Go的实现方式。它只是一个快速、未经测试的程序,而这只是部分原因。在devops中,我们经常被要求快速解决问题,而不是优雅地解决问题。服务器现在可能出问题了,我们得查出是哪个IP地址导致的。这不是一个令人满意的结果。如果Go这么棒,为什么它不适合解决这个问题?我们该怎么写代码才能和shell一样优雅?流水线方式
考虑到问题的本质,我们更倾向于将解决方案表示为流水线,就像shell程序一样。我们怎么在Go中表达类似shell中管道操作呢?File("log").Column(1).Freq().First(10).Stdout()
换句话说,读取文件日志,取其第一列,按频率排序,获得前10个结果,并将它们打印到标准输出。这不仅非常简洁,而且可以说比shell管道更清晰。例如,初学者不一定知道cut -d' ' - f1是干什么的。但如果他们看到Column(1),我想他们会理解的。这行代码实现了我们前面用笨拙的30多行代码完成相同的功能。不错。甚至经验丰富的shell开发者也开始认为用Go编写系统程序是值得的。脚本库
但是这个例子看起来甚至不像Go代码!如何用Go实现呢?答案是一个叫做script的库:import "github.com/bitfield/script"
script是一个Go库,用于完成shell脚本擅长的任务:读取文件、执行子进程、计算行数、匹配字符串等等。作者喜欢shell管道的优雅和简洁,但更喜欢Go。现在,您可以在Go中构建漂亮的脚本程序,而不必进行冗长的扫描、排序、切片和循环。让我们看几个例子。假设您想以字符串的形式读取文件的内容。这是它在脚本中的样子:data, err := script.File("test.txt").String()
这看起来非常简单,但是假设您现在想要计算该文件中的行数。n, err := script.File("test.txt").CountLines()
对于一些更具挑战性的任务,让我们试着计算文件中匹配字符串" Error "的行数:n, err := script.File("test.txt").Match("Error").CountLines()
但是,如果我们不读取特定的文件,而是简单地将输入输送到这个程序,并让它只输出匹配的行(如grep),该怎么办呢?script.Stdin().Match("Error").Stdout()
这简直太容易了!因此,让我们在命令行上传递一个文件列表,并让我们的程序依次读取它们,并输出匹配的行:script.Args().Concat().Match("Error").Stdout()
script.Args().Concat().Match("Error").First(10).Stdout()
您希望将输出重定向到文件中,而不是将其打印到终端?script.Args().Concat().Match("Error").First(10).AppendFile("/var/log/errors.txt")
用户工具
shell脚本强大的原因之一不仅仅是shell语言本身,shell是非常基础的。主要是包含丰富的可用工具,如grep、awk、cat、find、head等。但是我们可以使用script实现这些工具的大部分功能。下面是一个程序,它只是将输入转到输出,类似cat命令:下面是一个连接所有给出的文件,并将它们输出,同样像cat:script.Args().Concat().Stdout()
script.Args().Join().Stdout()
以这种方式可以实现大多数熟悉的Unix工具。对于脚本中没有提供的任何东西,我们可以使用工具本身:script.Exec("open info.pdf")
因为我们可以运行任何外部程序,所以我们也可以使用shell的工具。script.Exec("bash -c 'echo hello from bash'").Stdout()
shell脚本中的一个常见操作是使用find工具生成递归目录清单。我们也可以这样做:script.FindFiles("/backup").Stdout()
假设我们想要对每个以这种方式发现的文件做一些操作。该怎么处理呢?script.FindFiles("*.go").ExecForEach("gofmt -w ")
你可能会发现ExecForEach的参数是一个Go模板;FindFiles生成的每个文件名将依次被替换到该命令中。实现原理
上面这些链式函数调用看起来有点奇怪。实现原理是怎么样的呢?Unix shell和它的许多模仿者的优点之一是,你可以将操作组合到多个管道中。cat test.txt | grep Error | wc -l
管道的每个阶段的输出都提供给下一个阶段,您可以将每个阶段看作一个过滤器,只将其输入的某些部分转到输出。相比之下,在Go中编写类似shell的脚本就不那么方便了,因为您所做的所有操作都返回不同的数据类型,并且您必须(或至少应该)在每次操作之后要检查错误。在系统管理脚本中,我们经常希望以这样一种快捷方便的方式组合不同的操作。如果在管道的某个地方发生了错误,我们希望在最后检查一次,而不是在每个操作都做检查。一切皆管道
script库允许我们这样做,因为所有东西都是管道(特别是script.pipe)。要创建管道,请从File这样的源文件开始:p := script.File("test.txt")
如果打开文件有问题,您可能希望File返回一个错误,但它没有。我们想要对File的结果调用一个方法链,如果它也返回一个错误,那么这样做是不方便的。因此File返回一个管道。因为File返回一个管道,你可以在它上面调用任何你喜欢的方法。例如,匹配方法:p.Match("what I'm looking for")
结果是另一个管道(只包含来自test.txt的匹配行),依此类推。你不需要把所有的方法都链接到一行上,但是如果你想的话,也非常简洁的。错误处理
哇,哇!等等,我们还没有对错误做任何处理。我们知道优秀的Go程序员总是会检查错误的。这是因为我们在程序中所做的所有事情都可能出错,而程序的健壮性几乎都与它的错误处理有关。如果在创建管道时打开文件时出现错误,该怎么办?如果读取不存在的文件,Match不会panic吗?它不会。这是因为如果File遇到错误,它会在管道上设置一个标志,表示“有些地方出错了”。通常,当Go函数遇到错误时,它们会返回类似于nil对象和一个错误值来表明问题。相反,File返回一个有效的管道,一个设置了错误标志的管道。所有管道操作在执行任何操作之前都会检查这个错误标志。如果设置了,则它们没有有效数据,因此它们直接报错,不做任何工作就立即返回。只要任何管道阶段遇到错误,会在管道上设置错误标志,管道上的所有后续操作都停止。这意味着您不需要在每个阶段检查错误:相反,您可以在最后或任何您需要的时候检查。你可以通过调用一个管道的error方法来检查它的error状态:if err
:= p.Error(
); err != nil {
return fmt.Errorf("oh no: %w", err)
}
关闭管道
如果你在Go中处理过文件,就会知道需要在处理完一个文件后关闭它。否则,程序将保留所谓的文件句柄(表示打开文件的内核数据结构)。对于一个给定的程序和整个系统,打开的文件句柄总数是有限制的,因此泄漏文件句柄的程序最终会崩溃,并在此期间浪费资源。文件并不是惟一需要在读取后关闭的东西:网络连接、HTTP响应body等等也需要关闭。script如何处理这个问题的?简单。与管道相关联的数据源在被完全读取后将自动关闭。因此,调用任何读取管方法(如String)都将关闭其数据源。需要在管道上显式调用Close的唯一情况是,当您没有从管道读取数据,或者由于某些原因没有将其读取到输出的地方。如果管道是从不需要关闭的对象(比如字符串)创建的,那么调用Close不会执行任何操作。使用Go写脚本优点
Go在标准库中内置了很棒的测试框架。它有一个极好的标准库,以及数千个高质量的第三方包,几乎可以实现您所能想象到的任何功能。它是编译的,所以速度快,而且是静态类型的,所以是可靠。这是有效的和内存安全。Go程序可以作为单一的二进制文件发布。Go可以实现大规模项目(例如Kubernetes)。script完全使用Go实现,不需要任何外部其他程序。因此可以将script程序编译为一个单独的二进制文件快速构建、部署和运行,而且节省资源。但是这并不是说shell脚本已经过时了。我自己仍然使用很多shell脚本。在很多问题上,shell绝对是正确的选择。但是小程序往往会发展成大程序,当这种情况发生时,能够使用一种为大规模编程而设计的语言的功能是很好的。