最近,我写了一系列关于使用 Groovy 脚本清理我的音乐文件中的标签的文章。我开发了一个 框架,它可以识别我的音乐目录的结构,并使用它来迭代内容文件。在该系列的最后一篇文章中,我将此框架分离为一个实用程序类,我的脚本可以使用该类来处理内容文件。
这个单独的框架让我想起了 awk 的工作方式。 对于那些不熟悉 awk 的人,你可能会从 Opensource.com 的电子书 学习 awk 的实用指南 中受益。
自 1984 年以来,我一直在广泛使用 awk,当时我们的小公司购买了第一台运行 System V Unix 的“真正”计算机。 对我来说,awk 是一种启示:它具有关联内存——可以想象一下,数组通过字符串而不是数字进行索引。 它内置了正则表达式,似乎是为处理数据而设计的,尤其是在列中,而且它结构紧凑且易于学习。 最后,它被设计用于 Unix 管道中,从标准输入或文件读取数据并写入输出,无需任何仪式即可做到这一点——数据只需出现在输入流中。
毫不夸张地说,awk 一直是我日常计算工具包中必不可少的一部分。 然而,关于我使用 awk 的方式,有一些事情让我不满意。
可能主要问题是 awk 擅长处理以分隔字段呈现的数据,但奇怪的是不擅长处理逗号分隔值文件,这些文件可能在字段中嵌入了字段分隔符,前提是该字段被引用。 此外,自从 awk 发明以来,正则表达式已经发展,需要记住两组正则表达式语法规则不利于编写无错误代码。一组这样的规则已经够糟糕了。
因为 awk 是一种小型语言,它缺少一些我有时觉得有用的东西,例如更丰富的基本类型、结构、switch 语句等等。
相比之下,Groovy 拥有所有这些优点:可以访问 OpenCSV 库,这有助于处理 CSV 文件、Java 正则表达式和出色的匹配运算符、丰富的基本类型、类、switch 语句等等。
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 Central 获取 OpenCSV 库版本 5.6。 无需 XML。
在第 2 行中,我导入 OpenCSV 的 CSVReader
类。
在第 3 行中,就像在 Java 中一样,我声明了一个公共实用程序类 AwkEngine
。
第 11-13 行定义了脚本使用的 Groovy Closure 实例,作为进入此类的钩子。 这些默认情况下是“公共的”,就像任何 Groovy 类一样——但 Groovy 将字段创建为私有的,并且外部引用这些字段(使用 Groovy 提供的 getter 和 setter)。 我将在下面的示例脚本中进一步解释这一点。
第 14-16 行声明私有字段——字段分隔符、一个指示文件第一行是否为标题的标志以及文件名列表。
第 17-31 行定义了三个构造函数。 第一个接收命令行参数。 第二个接收字段分隔符字符。 第三个接收指示第一行是否为标题的标志。
第 31-67 行将引擎本身定义为 go()
方法。
第 33 行调用 onBegin()
闭包(相当于 awk 的 BEGIN {}
语句)。
第 34 行将流的 recordNumber
(相当于 awk 的 NR
变量)初始化为 0(注意我在这里做的是 0 起始,而不是 awk 的 1 起始)。
第 35-65 行使用 each {}
循环遍历要处理的文件列表。
第 36 行将文件的 fileRecordNumber
(相当于 awk 的 FNR
变量)初始化为 0(0 起始,而不是 1 起始)。
第 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 闭包不需要在闭包外部定义的变量是 final)。
第 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 在很大程度上解决了这个问题,但它们仍然不能引用其范围之外的非 final 变量。
这是另一个更有趣的脚本,非常让人想起我典型对 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
,您将使用 String 类型的键和 Integer 类型的值填充它。数据文件来自智利最近的人口普查,您正在此脚本中计算智利每个区域的人口数量。
第 6-11 行处理文件中的行(包括标题共有 180,500 行)—请注意,在这种情况下,由于您将第 1 行定义为 CSV 列标题,因此 `fields` 参数将是 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 方法。
1 条评论