Prig: like AWK, but uses Go for "scripting"

Ben Hoyt

Prig:类似 AWK,但用 Go 来“写脚本”

原文由 Ben Hoyt 发布,订阅该博客

摘要:本文介绍 Prig——一个类似 AWK、但以 Go 作为脚本语言的工具。我会将 Prig 与 AWK 进行对比,深入讲解 Prig 的工作原理,最后简要介绍 Prig 的 Sort 和 SortMap 内置函数(如果使用 Go 1.18,则会用到 Go 新的泛型特性)。

最近在 Hacker News 上的一条评论中,我了解到 Charles Blake 开发的一个小文本处理工具 rp,它有点像 AWK,但使用 Nim 作为脚本语言。之所以能这么做,是因为 Nim 编译器速度很快,而且 Nim 语言本身很简洁,所以可以用来编写一次性的脚本。

Go 具备其中一个优势:编译速度快。它算不上简洁,因此不太适合用来写一行式脚本。不过话说回来,也没那么糟:Prig 脚本的字符数大约是对应 AWK 版本的两倍。

Charles 提出,像 Nim 和 Go 这样编译速度快的语言非常适合做这类工具。实现起来几乎不费什么事:Prig 只有约 200 行直截了当的 Go 代码,它会把用户在命令行中传入的“脚本”插入到一个 Go 源码模板中,编译,然后运行生成的可执行文件。

在我的 Linux 机器上,go build 编译一个仅使用标准库的程序大约只需 200 毫秒,因此启动时间非常合理——几乎在你松开回车键之前,Go 就已经完成了编译和运行。相比之下,Nim 让 rp 在我系统上的启动时间约为 1.4 秒(如果使用 tcc 后端则是 0.8 秒)。

于是我决定用 Go 做一个与 rp 等价的工具,最终就有了 Prig,名字取自 Processing Records In Go(用 Go 处理记录)。可以说,Prig 就像 AWK,只不过有点“高冷”——它对动态类型不屑一顾。

Prig 与 AWK 的对比

首先,如果你之前没用过 AWK,这里用一段话来概括。AWK 是一个逐行处理输入的语言解释器。它会先执行可选的 BEGIN 块,然后对每一行输入执行 pattern { action } 块:如果该行匹配该模式,就执行对应的动作。如果不指定模式,则每一行都会匹配;如果不指定动作,则默认动作是打印该行。处理完所有输入后,它会执行可选的 END 块。我们很快会看到一些示例。

那么 Prig 看起来是什么样子,和 AWK 相比又如何呢?我们来看几个示例脚本。假设你有一个包含 HTTP 请求行的日志文件,内容如下:

$ cat logs.txt
GET /robots.txt HTTP/1.1
HEAD /README.md HTTP/1.1
GET /wp-admin/ HTTP/1.0

你想提取第二个字段(相对 URL),并为每个请求打印出站点的完整 URL。用 Prig 可以这样做:

$ prig 'Println("https://example.com" + S(2))' <logs.txt
https://example.com/robots.txt
https://example.com/README.md
https://example.com/wp-admin/

Println 函数其实就是 Go 的 fmt.Println,只是为了效率使用了带缓冲的写入器。它相当于 AWK 中的 print 语句。S(i) 函数以字符串形式返回第 i 个字段,因此 S(2) 返回第二个字段,类似于 AWK 中的 $2。其余的语义就是普通的 Go 语义。

同样的脚本用 AWK 写出来是这样:

$ awk '{ print "https://example.com" $2 }' <logs.txt
https://example.com/robots.txt
...

只短了 3 个字符——到目前为止还不错。

接下来的情况对 Go 就开始不利了。下面这个脚本分别用 Prig 和 AWK 实现,功能是打印最后一个字段的平均值,做法是先累加该字段,最后除以记录数:

$ cat average.txt 
a b 400
c d 200
e f 200
g h 200

$ prig -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))' \
  <average.txt
250

$ awk '{ s += $NF } END { print s / NR }' <average.txt
250

Prig 版本 60 个字符,AWK 版本 35 个字符——几乎长了一倍。Go(以及许多静态类型语言)在这里处于劣势。首先,我们必须把求和变量初始化为 0;而在 AWK 中这是隐式的。

然后,相比 AWK 简洁的 $NFF(NF()) 多了一层括号。我早期做了一个设计决定,让 Prig 的所有内置都是函数——最初我曾把 NFNR 设为变量,但全部改成函数后,代码就可以按需惰性地进行字段切分(有些简单脚本根本不需要切分)。

还有 float64() 转换,再加上 NR()Println() 的括号,使得 Prig 在某些情况下看起来有点像 Lisp。AWK 的 print s / NR 显然要顺眼得多!

第三个例子是,如果输入行包含字符串 GETHEAD,就将每行的第三个字段乘以 1000(即以毫秒为单位)后打印出来。下面是该脚本的 Prig 版本与 AWK 版本的对比:

$ cat millis.txt 
1 GET 3.14159
2 HEAD 4.0
3 GET 1.0

$ prig 'if Match(`GET|HEAD`, S(0)) { Printf("%.0fms\n", F(3)*1000) }' \
  <millis.txt
3142ms
4000ms
1000ms

$ awk '/GET|HEAD/ { printf "%.0fms\n", $3*1000 }' <millis.txt
3142ms
4000ms
1000ms

Prig 版本 62 个字符,AWK 版本 43 个字符——还不算差。这里的主要区别是 AWK 的 /regex/ 简写。我曾考虑在 Prig 中为此添加特殊处理,但最终还是决定坚持简单、一致的 Go 风格,而不是走捷径——因此在 Prig 中你必须显式地写出 ifMatch

再来看一个更长的例子。这个脚本会统计输入中不同单词的出现频率,然后按出现次数从高到低打印单词及其频次。

$ cat words.txt 
The foo barfs
foo the the the

$ prig -b 'freqs := map[string]int{}' \
       'for i := 1; i <= NF(); i++ { freqs[strings.ToLower(S(i))]++ }' \
       -e 'for _, f := range SortMap(freqs, ByValue, Reverse) { ' \
       -e 'Println(f.K, f.V) }' \
       <words.txt 
the 4
foo 2
barfs 1

$ awk '{ for (i = 1; i <= NF; i++) freqs[tolower($i)]++ }
      END { for (k in freqs) print k, freqs[k] | "sort -nr -k2,1" }' \
      <words.txt

这段相当冗长,尤其是 Prig 版本。首先我们初始化一个以单词为键的频次映射(在 AWK 中这同样是隐式的)。逐行处理的代码非常相似,只是在 Go 中由于要加上 strings 包前缀而稍显啰嗦。

两者的排序方式截然不同:在 Prig 中,我定义了两个排序函数,Sort 接收一个由整数、浮点数或字符串组成的切片并返回一个新的已排序切片,SortMap 则返回映射中键值对的有序切片(可选择按值排序,也可选择逆序排序)。

POSIX AWK 没有内置排序(只有 Gawk 有),因此我们使用 AWK 的管道重定向语法将其通过 sort 工具进行排序。我们本也可以在 Prig 中用 shell 管道实现同样的效果,但这里是为了展示 SortMap 函数的用法。

在大多数示例中,AWK 显然更清晰、更简洁——这也正是 Aho、Weinberger 和 Kernighan 当初为 AWK 设计一门新语言,而不是直接以 C(或类似语言)为基础的原因。

另一方面,如果你很熟悉 Go 而不了解 AWK,Prig 可能会对你有用。它的速度也明显更快,因为 Go 会编译成经过优化的机器码,而 AWK 是解释执行的。

一些简要的性能数据:对于上面“统计词频”的例子,Prig 大约比 AWK(使用 Gawk)快三倍:Prig 处理一个 43MB 的文件用时 1.1 秒,Gawk 则需要 3.1 秒。当然,这时我们实际上是在比较 Go 和 Gawk(更多细节可参见这篇性能对比)。

对于像累加数字这样的 CPU 密集型任务,Go 当然要快得多,在这个例子中大约快了 20 倍(别忘了,这 274 毫秒中有 200 毫秒是 Go 用来编译的):

$ time gawk 'BEGIN { for (i=0; i<100000000; i++) s+=i; print s }'
4999999950000000

real    0m5.698s
...
$ time ./prig -b 's:=0; for i:=0; i<100000000; i++ { s+=i }; Println(s)'
4999999950000000

real    0m0.274s
...

生成的 Go 程序

prig.go 本身的代码非常简单:约 200 行 Go 代码,其中约三分之一用于解析命令行参数。其余部分只是把你的脚本放入 Go 源码模板中,运行 go build 进行编译,然后执行生成的结果。

生成的 Go 程序的基本结构正如你所预期的那样:一些初始化代码、“begin”代码、通过 bufio.Scanner 逐行循环并执行“逐行”代码,最后是“end”代码。此外还有 Prig 的内置函数。

你可以用 prig -s 查看生成的 Go 源码。下面是上文“最后一个字段平均值”示例生成的代码。为了简洁,我省略了一些未使用的部分,并非逐字原文:

$ prig -s -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))'
// ... package and import ...
var (
    _output *bufio.Writer
    _record string
    _nr     int
    _fields []string
)

func main() {
    _output = bufio.NewWriter(os.Stdout)
    defer _output.Flush()

    // begin
    s := 0.0

    _scanner := bufio.NewScanner(os.Stdin)
    for _scanner.Scan() {
        _record = _scanner.Text()
        _nr++
        _fields = nil

        // per-record
        s += F(NF())
    }
    if _scanner.Err() != nil {
        _errorf("error reading stdin: %v", _scanner.Err())
    }

    // end
    Println(s / float64(NR()))
}

func Println(args ...interface{}) {
    _, err := fmt.Fprintln(_output, args...)
    if err != nil {
        _errorf("error writing output: %v", err)
    }
}

func NR() int {
    return _nr
}

func S(i int) string {
    if i == 0 {
        return _record
    }
    _ensureFields()
    if i < 1 || i > len(_fields) {
        return ""
    }
    return _fields[i-1]
}

func F(i int) float64 {
    s := S(i)
    f, _ := strconv.ParseFloat(s, 64)
    return f
}

func _ensureFields() {
    if _fields != nil {
        return
    }
    _fields = strings.Fields(_record)
}

func NF() int {
    _ensureFields()
    return len(_fields)
}
// ... other Prig builtin functions ...

注意,我给 Prig 内部的名称都加了下划线前缀,以避免与用户定义的变量发生冲突。这远非万无一失,但对于这个用例来说已经足够。

主循环基本上就是你手动用 Go 会写成的样子(不过你可能会用局部变量而不是全局变量)。然而,在常规的 Go 代码中,你可能会把 F(NF()) 连同边界检查一起内联展开,在主循环中写成这样:

if len(fields) > 0 {
    last := fields[len(fields)-1]
    f, err := strconv.ParseFloat(last, 64)
    if err == nil {
        s += f
    }
}

在这种情况下,让 Prig 的 F() 帮你做边界检查就很方便了:s += F(NF()) 要比那冗长的 7 行代码简洁得多。Go 本身比较啰嗦,但配上几个恰到好处的辅助函数,也能变得非常简洁!

测试中的乐趣

Prig 的测试(位于 prig_test.go 中)有点不太常规,因为它们直接运行 prig 二进制文件。有些开发者可能会对此不以为然,但这让 Prig 保持了简单。主要测试是“表格驱动测试”,这是 Go 测试的常用模式,你可以在别处了解相关内容

由于存在 go build 的编译过程,每个测试都相对较慢(大约 200 毫秒),但整个测试套件在我的系统上仍然只需 7 到 8 秒就能跑完。在 Windows 上则要慢得多,因为在那里启动新进程的开销要大得多。

不过,我做的一件比较巧妙的事是,对 prig --help 中展示的示例进行了测试。在编写 Prig 的使用说明时,我总是在示例中出现一些小拼写错误,不得不反复复制粘贴到终端里手动测试。

后来我想,为什么不用 go test 自动测试这些示例呢?于是我把命令行示例提取为独立的字符串,并在 TestExamples 中对它们进行测试。我用一个临时写的小解析器将每个示例命令行转换为参数列表,然后用这些参数去调用 prig

这类似于 Go 中出色的可测试示例,只不过针对的是命令行示例,而不是 Go 代码示例。

泛型试验

Prig 中较难设计的部分之一是排序辅助函数,到现在我也完全不确定自己是否设计对了。API 设计在很多时候更像是艺术而非科学。

不管怎样,我最终提供了两个在我看来对 Prig 常用数据类型很有用的函数。以下是相当简洁的使用说明中的描述:

Sort[T int|float64|string](s []T) []T
  // return new sorted slice; also Sort(s, Reverse) to sort descending
SortMap[T int|float64|string](m map[string]T) []KV[T]
  // return sorted slice of key-value pairs
  // also Sort(s[, Reverse][, ByValue]) to sort descending or by value

在 Go 1.18(应该很快就会发布)中,它们利用了新的泛型特性,因此会进行类型检查并返回具体的切片类型。由于存在可选参数,实际的 Go 函数签名(以及 KV 类型)定义如下:

type _sortOption int

const (
    Reverse _sortOption = iota
    ByValue
)

func Sort[T int|float64|string](s []T, options ..._sortOption) []T {
    // ... implementation ...
}

type KV[T int|float64|string] struct {
    K string
    V T
}

func SortMap[T int|float64|string](m map[string]T,
        options ..._sortOption) []KV[T] {
    // ... implementation ...
}

Sort 很简单:它接收一个切片并返回一个新的已排序切片。默认按从小到大排序,如果传入 Reverse 选项则按从大到小排序。我本可以使用比 intfloat64string 更广泛的类型集合,但这样能让 Prig 保持简单(对于下面会看到的非泛型版本也是如此)。

SortMap 的 API 设计要棘手一些。你不能直接对 Go 的 map 进行排序,因此需要将其转换为键值对切片:这就是 KV 类型。默认按键排序,如果传入 ByValue 选项则按值排序。

这些都能正常工作,我对 Go 1.18 泛型非常有限的尝试也算成功了。

但对于我们大多数仍在使用 1.18 之前、不支持泛型的 Go 版本的人来说呢?嗯,我让同样的 API 在没有泛型的情况下也能工作……算是吧。非泛型版本使用 interface{},所以当然不是类型安全的。而且它之所以能在没有类型转换的情况下工作,仅仅是因为你通常只是打印结果;而 Print 系列函数本来就接受任意类型的参数(通过 interface{})。

因此,词频统计的示例代码在 Go 1.18(有泛型)和 Go 1.17(无泛型)上都能同样正常工作:

for _, f := range SortMap(freqs, ByValue, Reverse) {
    Println(f.K, f.V)
}

Prig 会通过运行 go version 来检测你安装的 Go 版本,如果是 1.17 或更低版本,就会使用非泛型版本。以下是在非泛型版本中 SortSortMap 的定义:

func Sort(s interface{}, options ..._sortOption) []interface{} {
    // ... implementation ...
}

type KV struct {
    K string
    V interface{}
}

func SortMap(m interface{}, options ..._sortOption) []KV {
    // ... implementation ...
}

疯狂吗?也许吧。大多数库永远不可能用这种偷梁换柱的手法蒙混过关,因为对于很多任务来说,这些 API 根本不兼容。但对于 Prig 中的这个试验来说,似乎效果还不错。

结论:值得吗?

从骨子里说我就是个不折不扣的技术宅,所以答案是肯定的,我很享受构建 Prig 的过程(大部分是在从基督城飞往法兰克福的航班上完成的)。我喜欢它的代码如此简单:约 200 行 Go 代码、300 行模板代码……以及 400 行测试。Go 及其标准库承担了所有繁重的工作!

我真的会用 Prig 吗?也许会,如果我要处理大文件并且需要比 AWK 稍高的性能。我也可能会用它来测试 Go 的小段代码——比如,“Printf 的宽度是怎么用的来着?啊,对了,用 prig 试一下”:

$ prig -b 'Printf("%3.5s\n", "hi")'
 hi
$ prig -b 'Printf("%3.5s\n", "hello world")'
hello

你该用 Prig 吗?我不会拦着你!但说实话,你可能还是更适合去学无处不在(而且要简洁得多)的 AWK 语言。它是一个出色的、已有 45 年历史的工具,到 2022 年仍被广泛用于文本和数据处理。由 A、W、K 合著的原版书The AWK Programming Language非常值得一读。

如果你需要一个用于某些数据处理的可执行文件,比如在一个没有安装 awk 的轻量容器中,你也可能会用到它。对于这种情况,你可以用 prig -s 打印源码,再用 go build 编译结果,并将可执行文件复制到目标环境——不需要任何其他依赖。

如果你想在 Go 程序中集成 AWK,或者只是想了解 AWK 解释器是如何工作的,可以看看我的 GoAWK 项目。

我很期待听到你对 Prig 的反馈:如果你有任何改进建议,或者你用其他语言做了 rp 或 Prig 的变体,欢迎来打个招呼!

本文章由 muse-spark-1.2-contributor 进行翻译

评论