Modernizing AWK, a 45-year old language, by adding CSV support

Ben Hoyt

通过添加 CSV 支持,让 45 岁的 AWK 焕发新生

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

我最近为GoAWK(我开发的兼容 POSIX 的 AWK 解释器)添加了对 CSV 文件的完善支持,我认为在这个数据无处不在的时代,这项功能将让 AWK 对开发者和数据分析人员更加实用。

无论是为电子表格生成输入、分析来自公共数据源的数据,还是编写脚本处理“大数据”,CSVTSV 文件如今都无处不在。至于它是不是最好的格式还有待商榷,但正如有人所说,数据格式只有两种:一种是被人抱怨的,另一种是没人用的。

早在 2018 年发布 GoAWK 后不久,我就在 Hacker News 上看到一条评论,开始考虑为 GoAWK 添加 CSV 支持:

哈哈,我刚才还在想,如果能在原生支持 CSV 文件的工具里用上 awk 编程语言该多好。突然间,这事好像变得可行多了!

当时我给了一个模棱两可的回复:

有意思。我也觉得 AWK 应该有个能正确解析 CSV 引号的模式。也许我会加一个 -csv 选项(或者当 FS 是‘,’时就自动启用——不过那样就不向后兼容了)。

好了,现在我们真的有了这样的模式!最终的命令行选项是 -i csv(“input CSV”),也算大差不差了。

非常感谢安特卫普大学图书馆对这项功能的赞助。据我所知,有两个主要团队或项目在使用 GoAWK,他们是其中之一,另一个是 Benthos 流处理平台。

但为什么需要它?

我们为什么需要这个功能呢?遗憾的是,标准 AWK 无法处理带引号字段的 CSV 文件,而这对于处理真实世界的 CSV 文件至关重要。

你可以把字段分隔符设为逗号(-F,FS=","),但这完全是权宜之计,只要一遇到带引号的字段就会出错。例如:

$ cat quoted.csv
"Smith, Bob",42
$ awk -F, '{ print $1 }' quoted.csv
"Smith    # you want to print the first field: Smith, Bob

目前已有几种用 AWK 处理 CSV 的变通方法,比如 Gawk 的 FPAT 功能、Gawk 的各种CSV 扩展,或是 Adam Gordon Bell 的 csvquote 工具——你需要先用它转换输入再运行 awk,之后再用 csvquote -u 还原。

还有由 Eli Rosenthal 开发的 frawk,它是一款原生支持 CSV 的出色工具,但遗憾的是它在一定程度上偏离了 POSIX 标准的 AWK。(Frawk 启发了 GoAWK 的部分 CSV 支持,包括 -i-o 命令行选项。)

所以我认为,在兼容 POSIX 的 AWK 中确实需要完善的 CSV 支持,而 GoAWK 正好提供了这一点。

在深入介绍之前,我们先用 GoAWK 的 CSV 输入模式来修正上面例子中的引号问题:

$ goawk -i csv '{ print $1 }' quoted.csv
Smith, Bob    # that's better!

功能特性

CSV 输入模式。 -i csv 选项会让 GoAWK 进入 CSV 输入模式:也就是说,忽略标准的字段和记录分隔符(FSRS),改用 CSV 解析。还有 TSV 模式,你也可以使用自定义分隔符。

此外,如果使用 -H 选项,GoAWK 会将输入的第一行作为字段名,并提供一个很有用的 @"named-field" 语法,让你可以通过字段名而非编号来获取字段。

例如,假设我们有一个 states.csv 文件,内容如下:

"State","Abbreviation"
"Alabama","AL"
"Alaska","AK"
"Arizona","AZ"
...

我们可以用下面的脚本只输出州的缩写:

$ goawk -i csv -H '{ print @"Abbreviation" }' states.csv
AL
AK
AZ
...

或者,统计州名中包含“New”的州的数量:

$ goawk -i csv -H '@"State" ~ /New/ { n++ } END { print n }' states.csv
4

其中 ~ 是标准 AWK 中的正则匹配运算符,所以这段代码的意思是:对于 State 字段匹配正则表达式 New 的记录,将 n 加一;最后打印 n

CSV 输出模式。 -o csv 命令行参数会让 GoAWK 进入 CSV 输出模式:这会使带有一个或多个参数的 print 语句以正确的 CSV 编码输出。

例如,要将 states.csv 转换为 TSV,可以这样做(这里没有使用 -H,因此会包含表头行):

$ goawk -i csv -o tsv '{ print $1, $2 }' states.csv
State   Abbreviation
Alabama AL
Alaska  AK
Arizona AZ
...

另外,我们拥有标准 AWK 的所有功能,所以能做的事情还有很多。

阅读 CSV 文档了解完整详情和更多示例。

实现说明

实现大约有 2000 行代码,包括大量的测试。

当然也提供了 Go API,你可以通过 interp.Config 结构体来配置选项,从而启用 CSV 输入或输出模式。你也可以在 BEGIN 块中设置 INPUTMODEOUTPUTMODE 这两个特殊变量。

出于各种原因,我无法在 CSV 输入模式中直接使用 encoding/csv.Reader,但我在自己的 csvSplitter 中复用了标准库代码的结构,并将其改造成一个能解析字段并将整行作为 token 返回的 bufio.SplitFunc

除了我自己的大量测试外,我还在自己的实现上运行了标准库 csv.Reader 测试中的相关子集,以确保没有出错,并且仍然符合 RFC 4180

对于 CSV 输出模式,我则可以直接使用 encoding/csv.Writer(不过写 CSV 比读要简单得多)。详情请参阅 writeCSV 函数。

性能

我还没有花太多时间优化性能。打算以后好好做一次性能分析,但就目前而言已经“够用”了:速度与直接使用 Go 的 encoding/csv 包相当,比 Python 的 csv 模块快得多。在我的笔记本上,它大约三秒就能读取 1GB 复杂的 CSV 输入。

frawk 相比,CSV 输入速度要慢不少,不过(有点出人意料的是)CSV 输出速度要快得多。

下面是使用 goawkfrawk 以及原生 Python 和 Go 进行的一些简单读写基准测试的结果。写入基准测试的输出是一个 1GB、350 万行、20 列(含带引号的列)的 CSV 文件;读取基准测试则使用同一个文件作为输入。时间单位为秒,取在配备 SSD 的 64 位 Linux 笔记本上三次运行中的最佳成绩:

测试goawkfrawkPythonGo
读取 1GB CSV3.181.0113.43.22
写入 1GB CSV5.6413.017.03.24

结语

如今的计算机速度已经非常快,因此你完全可以用 GoAWK 在自己的开发笔记本或一台相对小型的虚拟机上处理大多数 CSV 数据集,即使是 GB 级别的也不在话下。还要 Hadoop 做什么呢?

所以,请在下一次处理 CSV 的任务中试试 GoAWK。AWK 已经 45 岁了,依然被广泛使用,我希望这项功能能让它在开发者的工具箱中变得更加实用。

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

评论