Awk和Groovy相辅相成,创造出强大、有用的脚本。
最近我写了一个关于使用Groovy脚本来清理音乐文件中的标签的系列文章。我开发了一个框架,可以识别我的音乐目录的结构,并使用它来迭代内容文件。在该系列的最后一篇文章中,我把这个框架分离成一个实用类,我的脚本可以用它来处理内容文件。
我从1984年开始广泛使用awk,当时我们的小公司买了它的第一台 "真正的 "计算机,运行System V Unix。对我来说,awk是一个启示。它有关联内存--认为数组是以字符串而不是数字为索引的。它内置了正则表达式,似乎是为处理数据而设计的,特别是在列中,而且结构紧凑,易于学习。最后,它被设计用来在Unix管道中工作,从标准输入或文件中读取数据并写入输出,这样做不需要任何仪式,数据只是出现在输入流中。
说awk是我日常计算工具箱中的一个重要部分,是一种轻描淡写的说法。然而,在我使用awk的过程中,有一些事情让我感到不满意。
最主要的问题可能是awk善于处理以分隔字段呈现的数据,但奇怪的是,它不善于处理逗号分隔值文件,而逗号分隔值文件可以在一个字段中嵌入字段分隔符,只要该字段有引号。另外,自从awk发明以来,正则表达式已经有了很大的发展,需要记住两套正则表达式的语法规则并不利于编写无错误的代码。一套这样的规则已经很糟糕了。
因为awk是一种小语言,它缺少一些我有时觉得有用的东西,比如更丰富的基础类型、结构、开关语句等等。
相比之下,Groovy拥有所有这些好东西:可以访问OpenCSV库,这有利于处理CSV文件、Java正则表达式和伟大的匹配运算符、丰富的基础类型、类、开关语句等。
Groovy所缺乏的是面向管道的简单观点,即把数据作为一个传入流,把处理过的数据作为一个传出流。
但我的音乐目录处理框架让我想到,也许我可以创建一个Groovy版的awk "引擎"。这就是我写这篇文章的目的。
安装Java和Groovy
Groovy是基于Java的,需要安装Java。最近的、像样的Java和Groovy版本可能都在你的Linux发行版的软件库中。Groovy也可以按照Groovy主页上的说明进行安装。对于Linux用户来说,一个不错的选择是SDKMan,它可以用来获取多个版本的Java、Groovy和许多其他相关工具。在这篇文章中,我使用了SDK的以下版本。
- Java:OpenJDK 11的11.0.12-open版本。
- Groovy:3.0.8版。
用Groovy创建awk
这里的基本思想是将打开一个或多个文件进行处理、将行分成字段以及提供对数据流的访问这三个部分的复杂性封装起来。
- 在任何数据被处理之前
- 在每一行的数据上
- 在所有数据被处理之后
我并不打算用Groovy来取代awk的一般情况。相反,我正朝着我的典型用例努力,那就是。
- 使用一个脚本文件,而不是将代码放在命令行上
- 处理一个或多个输入文件
- 将我的默认字段定界符设置为
|,并根据该定界符拆分所读的行 - 使用OpenCSV来进行分割(我在awk中做不到的)。
框架类
这里是Groovy类中的 "awk引擎"。
1 @Grab('com.opencsv:opencsv:5.6')
2 import com.opencsv.CSVReader
3 public class AwkEngine {
4 // With admiration and respect for
5 // Alfred Aho
6 // Peter Weinberger
7 // Brian Kernighan
8 // Thank you for the enormous value
9 // brought my job by the awk
10 // programming language
11 Closure onBegin
12 Closure onEachLine
13 Closure onEnd
14 private String fieldSeparator
15 private boolean isFirstLineHeader
16 private ArrayList<String> fileNameList
17 public AwkEngine(args) {
18 this.fileNameList = args
19 this.fieldSeparator = "|"
20 this.isFirstLineHeader = false
21 }
22 public AwkEngine(args, fieldSeparator) {
23 this.fileNameList = args
24 this.fieldSeparator = fieldSeparator
25 this.isFirstLineHeader = false
26 }
27 public AwkEngine(args, fieldSeparator, isFirstLineHeader) {
28 this.fileNameList = args
29 this.fieldSeparator = fieldSeparator
30 this.isFirstLineHeader = isFirstLineHeader
31 }
32 public void go() {
33 this.onBegin()
34 int recordNumber = 0
35 fileNameList.each { fileName ->
36 int fileRecordNumber = 0
37 new File(fileName).withReader { reader ->
38 def csvReader = new CSVReader(reader,
39 this.fieldSeparator.charAt(0))
40 if (isFirstLineHeader) {
41 def csvFieldNames = csvReader.readNext() as
42 ArrayList<String>
43 csvReader.each { fieldsByNumber ->
44 def fieldsByName = csvFieldNames.
45 withIndex().
46 collectEntries { name, index ->
47 [name, fieldsByNumber[index]]
48 }
49 this.onEachLine(fieldsByName,
50 recordNumber, fileName,
51 fileRecordNumber)
52 recordNumber++
53 fileRecordNumber++
54 }
55 } else {
56 csvReader.each { fieldsByNumber ->
57 this.onEachLine(fieldsByNumber,
58 recordNumber, fileName,
59 fileRecordNumber)
60 recordNumber++
61 fileRecordNumber++
62 }
63 }
64 }
65 }
66 this.onEnd()
67 }
68 }
虽然这看起来是相当多的代码,但许多行都是分割后的长行的延续(例如,通常你会把第38行和第39行,第41行和第42行,等等)。让我们逐行看一下。
第1行使用@Grab 注解,从Maven中心获取5.6版OpenCSV库。不需要XML。
在第2行,我导入了OpenCSV的CSVReader 类。
在第3行,和Java一样,我声明了一个公共工具类,AwkEngine 。
第11-13行定义了脚本所使用的Groovy Closure实例,作为该类的钩子。如同任何Groovy类一样,这些都是 "默认的公共的",但Groovy将这些字段创建为私有的,并对其进行外部引用(使用Groovy提供的getters和setters)。我将在下面的示例脚本中进一步解释这个问题。
第14-16行声明了私有字段--字段分隔符,一个指示文件第一行是否为标题的标志,以及一个文件名的列表。
第17-31行定义了三个构造函数。第一个接收命令行参数。第二个接收字段分隔符。第三行接收指示第一行是否是头的标志。
第31-67行定义了引擎本身,作为go() 方法。
第33行调用onBegin() 闭包(相当于awk的BEGIN {} 语句)。
第34行将流的recordNumber (相当于awk的NR 变量)初始化为0(注意我在这里做的是0-origin而不是awk的1-origin)。
第35-65行使用每个{} ,在要处理的文件列表中循环。
第36行将文件的fileRecordNumber (相当于awk的FNR 变量)初始化为0(0-origin,不是1-origin)。
第37-64行为该文件获取一个Reader 实例,并对其进行处理。
第38-39行得到一个CSVReader 实例。
第40行检查第一行是否被当作头文件处理。
如果第一行被当作标题,那么第41-42行从第一条记录中获得字段标题的列表。
第43-54行处理其余的记录。
第44-48行将字段值复制到name:value 的映射中。
第49-51行调用onEachLine()闭包(相当于出现在awk程序中的BEGIN {} 和END {} ,虽然不能附加模式使执行有条件),传入name:value 的映射、流记录编号、文件名和文件记录编号。
第52-53行增加流记录号和文件记录号。
否则。
第56-62行处理这些记录。
第57-59行调用onEachLine() 闭包,传入字段值的数组、流记录号、文件名和文件记录号。
第60-61行增加流记录号和文件记录号。
第66行调用onEnd() 闭包(相当于awk的END {} )。
这就是框架的内容了。现在你可以编译它了。
$ groovyc AwkEngine.groovy
有几个评论。
如果传入的参数不是一个文件,代码就会失败,出现标准的Groovy堆栈跟踪,看起来像这样。
Caught: java.io.FileNotFoundException: not-a-file (No such file or directory)
java.io.FileNotFoundException: not-a-file (No such file or directory)
at AwkEngine$_go_closure1.doCall(AwkEngine.groovy:46)
OpenCSV倾向于返回String[] ,这不像Groovy中的List 那样方便(例如,没有为数组定义each {} )。第41-42行将标题字段值数组转换为列表,所以也许第57行的fieldsByNumber 也应该转换为列表。
在脚本中使用该框架
下面是一个非常简单的脚本,使用AwkEngine 来检查一个类似/etc/group 的文件,该文件是以冒号为分隔符的,并且没有文件头。
1 def ae = new AwkEngine(args, ‘:')
2 int lineCount = 0
3 ae.onBegin = {
4 println “in begin”
5 }
6 ae.onEachLine = { fields, recordNumber, fileName, fileRecordNumber ->
7 if (lineCount < 10)
8 println “fileName $fileName fields $fields”
9 lineCount++
10 }
11 ae.onEnd = {
12 println “in end”
13 println “$lineCount line(s) read”
14 }
15 ae.go()
第1行调用双参数构造器,传入参数列表和冒号作为分隔符。
第2行定义了一个脚本顶层变量,lineCount ,用来记录读取的行数(注意,Groovy闭包不要求闭包外部定义的变量是最终变量)。
第3-5行定义了onBegin() 闭包,它只是在标准输出上打印出字符串 "in begin"。
第6-10行定义了onEachLine() 闭包,它打印了文件名和前10行的字段,并且在任何情况下都会增加行数。
第11-14行定义了onEnd() 闭包,它打印出字符串 "in end "和所读行数的计数。
第15行使用AwkEngine 来运行该脚本。
运行这个脚本的方法如下。
$ groovy Test1Awk.groovy /etc/group
in begin
fileName /etc/group fields [root, x, 0, ]
fileName /etc/group fields [daemon, x, 1, ]
fileName /etc/group fields [bin, x, 2, ]
fileName /etc/group fields [sys, x, 3, ]
fileName /etc/group fields [adm, x, 4, syslog,clh]
fileName /etc/group fields [tty, x, 5, ]
fileName /etc/group fields [disk, x, 6, ]
fileName /etc/group fields [lp, x, 7, ]
fileName /etc/group fields [mail, x, 8, ]
fileName /etc/group fields [news, x, 9, ]
in end
78 line(s) read
$
当然,通过编译框架类而创建的.class 文件必须在classpath上,这样才能发挥作用。当然,你可以使用jar 来打包这些类文件。
我非常喜欢Groovy对行为委托的支持,这在其他语言中需要各种诡异的手段。许多年来,Java需要匿名类和相当多的额外代码。Lambdas已经在很大程度上解决了这个问题,但它们仍然不能引用其范围之外的非最终变量。
下面是另一个更有趣的脚本,它很容易让人想起我对awk的典型使用。
1 def ae = new AwkEngine(args, ‘;', true)
2 ae.onBegin = {
3 // nothing to do here
4 }
5 def regionCount = [:]
6 ae.onEachLine = { fields, recordNumber, fileName, fileRecordNumber ->
7 regionCount[fields.REGION] =
8 (regionCount.containsKey(fields.REGION) ?
9 regionCount[fields.REGION] : 0) +
10 (fields.PERSONAS as Integer)
11 }
12 ae.onEnd = {
13 regionCount.each { region, population ->
14 println “Region $region population $population”
15 }
16 }
17 ae.go()
第1行调用了三个参数的构造函数,认识到这是一个 "真正的CSV "文件,文件头在第一行。因为这是一个西班牙文件,其中逗号被用作小数点,标准的分隔符是分号。
第2-4行定义了onBegin() 闭包,在这种情况下它不做任何事情。
第5行定义了一个(空的)LinkedHashMap ,你将用字符串键和整数值填充它。这个数据文件来自智利最近的人口普查,在这个脚本中你要计算智利每个地区的人口数量。
第6-11行处理文件中的行数(包括标题在内有180,500行)--注意,在这种情况下,因为你将第1行定义为CSV列的标题,所以字段参数将是LinkedHashMap<String,String> 的实例。
第7-10行增加regionCount 地图,使用字段REGION中的值作为键,字段PERSONAS中的值作为值-注意,与awk不同,在Groovy中你不能在右边引用一个不存在的地图条目,并期望实现一个空白或零值。
第12-16行按地区打印出人口。
第17行在AwkEngine 实例上运行该脚本。
按如下方式运行这个脚本。
$ groovy Test2Awk.groovy ~/Downloads/Censo2017/ManzanaEntidad_CSV/Censo*csv
Region 1 population 330558
Region 2 population 607534
Region 3 population 286168
Region 4 population 757586
Region 5 population 1815902
Region 6 population 914555
Region 7 population 1044950
Region 8 population 1556805
Region 16 population 480609
Region 9 population 957224
Region 10 population 828708
Region 11 population 103158
Region 12 population 166533
Region 13 population 7112808
Region 14 population 384837
Region 15 population 226068
$
就这样了。对于那些喜欢awk但又想多学一点的人来说,我希望你能喜欢这种Groovy方法。