为你的 awk 脚本注入 Groovy

2022-11-09
8分钟阅读时长

awk 和 Groovy 相辅相成,可以创建强大、有用的脚本。

最近我写了一个使用 Groovy 脚本来清理我的音乐文件中的标签的系列。我开发了一个 框架,可以识别我的音乐目录的结构,并使用它来遍历音乐文件。在该系列的最后一篇文章中,我从框架中分离出一个实用类,我的脚本可以用它来处理文件。

这个独立的框架让我想起了很多 awk 的工作方式。对于那些不熟悉 awk 的人来说,你学习下这本电子书:

《awk 实用指南》

我从 1984 年开始大量使用 awk,当时我们的小公司买了第一台“真正的”计算机,它运行的是 System V Unix。对我来说,awk 是非常完美的:它有 关联内存 associative memory ——将数组视为由字符串而不是数字来索引的。它内置了正则表达式,似乎专为处理数据而生,尤其是在处理数据列时,而且结构紧凑,易于学习。最后,它非常适合在 Unix 工作流使用,从标准输入或文件中读取数据并写入到输出,数据不需要经过其他的转换就出现在了输入流中。

说 awk 是我日常计算工具箱中的一个重要部分一点也不为过。然而,在我使用 awk 的过程中,有几件事让我感到不满意。

可能主要的问题是 awk 善于处理以分隔字段呈现的数据,但很奇怪它不善于处理 CSV 文件,因为 CSV 文件的字段被引号包围时可以嵌入逗号分隔符。另外,自 awk 发明以来,正则表达式已经有了很大的发展,我们需要记住两套正则表达式的语法规则,而这并不利于编写无 bug 的代码。一套这样的规则已经很糟糕了

由于 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 的开源版本
  • Groovy:3.0.8

使用 Groovy 创建 awk

这里的基本想法是将打开一个或多个文件进行处理、将每行分割成字段、以及提供对数据流的访问等复杂情况封装在三个部分:

  • 在处理数据之前
  • 在处理每行数据时
  • 在处理完所有数据之后

我并不打算用 Groovy 来取代 awk。相反,我只是在努力实现我的典型用例,那就是:

  • 使用一个脚本文件而不是在命令行写代码
  • 处理一个或多个输入文件
  • 设置默认的分隔符为 |,并基于这个分隔符分割所有行
  • 使用 OpenCSV 完成分割工作(awk 做不到)

框架类

下面是用 Groovy 类实现的 “awk 引擎”:

@Grab('com.opencsv:opencsv:5.6')
import com.opencsv.CSVReader
public class AwkEngine {
    // With admiration and respect for
    //     Alfred Aho
    //     Peter Weinberger
    //     Brian Kernighan
    // Thank you for the enormous value
    // brought my job by the awk
    // programming language
    Closure onBegin
    Closure onEachLine
    Closure onEnd
    private String fieldSeparator
    private boolean isFirstLineHeader
    private ArrayList<String> fileNameList
    public AwkEngine(args) {
        this.fileNameList = args
        this.fieldSeparator = "|"
        this.isFirstLineHeader = false
    }
    public AwkEngine(args, fieldSeparator) {
        this.fileNameList = args
        this.fieldSeparator = fieldSeparator
        this.isFirstLineHeader = false
    }
    public AwkEngine(args, fieldSeparator, isFirstLineHeader) {
        this.fileNameList = args
        this.fieldSeparator = fieldSeparator
        this.isFirstLineHeader = isFirstLineHeader
    }
    public void go() {
        this.onBegin()
        int recordNumber = 0
        fileNameList.each { fileName ->
            int fileRecordNumber = 0
            new File(fileName).withReader { reader ->
                def csvReader = new CSVReader(reader,
                    this.fieldSeparator.charAt(0))
                if (isFirstLineHeader) {
                    def csvFieldNames = csvReader.readNext() as
                        ArrayList<String>
                    csvReader.each { fieldsByNumber ->
                        def fieldsByName = csvFieldNames.
                            withIndex().
                            collectEntries { name, index ->
                                [name, fieldsByNumber[index]]
                            }
                        this.onEachLine(fieldsByName,
                                recordNumber, fileName,
                                fileRecordNumber)
                        recordNumber++
                        fileRecordNumber++
                    }
                } else {
                    csvReader.each { fieldsByNumber ->
                        this.onEachLine(fieldsByNumber,
                            recordNumber, fileName,
                            fileRecordNumber)
                        recordNumber++
                        fileRecordNumber++
                    }
                }
            }
        }
        this.onEnd()
    }
}

虽然这看起来是相当多的代码,但许多行是因为太长换行了(例如,通常你会合并第 38 行和第 39 行,第 41 行和第 42 行,等等)。让我们逐行看一下。

第 1 行使用 @Grab 注解从 Maven Central 获取 OpenCSV 库的 5.6 本周。不需要 XML。

第 2 行我引入了 OpenCSV 的 CSVReader

第 3 行,像 Java 一样,我声明了一个 public 实用类 AwkEngine

第 11-13 行定义了脚本所使用的 Groovy 闭包实例,作为该类的钩子。像任何 Groovy 类一样,它们“默认是 public”,但 Groovy 将这些字段创建为 private,并对其进行外部引用(使用 Groovy 提供的 getter 和 setter 方法)。我将在下面的示例脚本中进一步解释这个问题。

第 14-16 行声明了 private 字段 —— 字段分隔符,一个指示文件第一行是否为标题的标志,以及一个文件名的列表。

第 17-31 行定义了三个构造函数。第一个接收命令行参数。第二个接收字段的分隔符。第三个接收指示第一行是否为标题的标志。

第 31-67 行定义了引擎本身,即 go() 方法。

第 33 行调用了 onBegin() 闭包(等同于 awk 的 BEGIN {} 语句)。

第 34 行初始化流的 recordNumber(等同于 awk 的 NR 变量)为 0(注意我这里是从 00 而不是 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 行将标题字段值数组转换为 list,因此第 57 行的 fieldsByNumber 可能也应该转换为 list。

在脚本中使用这个框架

下面是一个使用 AwkEngine 来处理 /etc/group 之类由冒号分隔并没有标题的文件的简单脚本:

def ae = new AwkEngine(args, ':')
int lineCount = 0
ae.onBegin = {
    println “in begin”
}
ae.onEachLine = { fields, recordNumber, fileName, fileRecordNumber ->
    if (lineCount < 10)
        println “fileName $fileName fields $fields”
    lineCount++
}
ae.onEnd = {
    println “in end”
    println “$lineCount line(s) read”
}
ae.go()

第 1 行 调用的有两个参数的构造函数,传入了参数列表,并定义冒号为分隔符。

第 2 行定义一个脚本级的变量 lineCount,用来记录处理过的行数(注意,Groovy 闭包不要求定义在外部的变量为 final)。

第 3-5 行定义 onBegin() 闭包,在标准输出中打印出 “in begin” 字符串。

第 6-10 行定义 onEachLine() 闭包,打印文件名和前 10 行字段,无论是否为前 10 行,处理过的总行数 lineCount 都会自增。

第 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 把这些 class 文件打包起来。

我非常喜欢 Groovy 对行为委托的支持,这在其他语言中需要各种诡异的手段。许多年来,Java 需要匿名类和相当多的额外代码。Lambda 已经在很大程度上解决了这个问题,但它们仍然不能引用其范围之外的非 final 变量。

下面是另一个更有趣的脚本,它很容易让人想起我对 awk 的典型使用方式:

def ae = new AwkEngine(args, ';', true)
ae.onBegin = {
    // nothing to do here
}
def regionCount = [:]
    ae.onEachLine = { fields, recordNumber, fileName, fileRecordNumber ->
        regionCount[fields.REGION] =
            (regionCount.containsKey(fields.REGION) ?
                regionCount[fields.REGION] : 0) +
            (fields.PERSONAS as Integer)
}
ae.onEnd = {
    regionCount.each { region, population ->
        println “Region $region population $population”
    }
}
ae.go()

第 1 行调用了三个函数的构造方法,true 表示这是“真正的 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 的方法。


via: https://opensource.com/article/22/9/awk-groovy

作者:Chris Hermansen 选题:lkxed 译者:lxbwolf 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出