為你的 awk 腳本注入 Groovy
最近我寫了一個使用 Groovy 腳本來清理我的音樂文件中的標籤的系列。我開發了一個 框架,可以識別我的音樂目錄的結構,並使用它來遍歷音樂文件。在該系列的最後一篇文章中,我從框架中分離出一個實用類,我的腳本可以用它來處理文件。
這個獨立的框架讓我想起了很多 awk 的工作方式。對於那些不熟悉 awk 的人來說,你學習下這本電子書:
我從 1984 年開始大量使用 awk,當時我們的小公司買了第一台「真正的」計算機,它運行的是 System V Unix。對我來說,awk 是非常完美的:它有 關聯內存 ——將數組視為由字元串而不是數字來索引的。它內置了正則表達式,似乎專為處理數據而生,尤其是在處理數據列時,而且結構緊湊,易於學習。最後,它非常適合在 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
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive