Поиск по сайту:

Наполните свои awk-скрипты Groovy


Awk и Groovy дополняют друг друга, создавая надежные и полезные сценарии.

Недавно я написал серию статей об использовании сценариев Groovy для очистки тегов в моих музыкальных файлах. Я разработал структуру, которая распознавала структуру моего музыкального каталога и использовала ее для перебора файлов содержимого. В последней статье этой серии я выделил эту платформу в служебный класс, который мои сценарии могли использовать для обработки файлов содержимого.

Этот отдельный фреймворк во многом напомнил мне, как работает awk. Тем из вас, кто не знаком с awk, может быть полезна электронная книга Opensource.com, Практическое руководство по изучению awk< /пролет>.

Я широко использовал awk с 1984 года, когда наша маленькая компания купила свой первый «настоящий» компьютер, на котором работала System V Unix. Для меня awk стал открытием: у него была ассоциативная память — представьте, что массивы индексируются строками, а не числами. Он имел встроенные регулярные выражения, казалось, был создан для работы с данными, особенно в столбцах, и был компактным и простым в освоении. Наконец, он был разработан для работы в конвейерах Unix, считывая данные из стандартного ввода или файлов и записывая их на вывод, без каких-либо церемоний — данные просто появлялись во входном потоке.

Сказать, что awk был неотъемлемой частью моего повседневного компьютерного инструментария, значит не сказать ничего. И все же есть некоторые моменты в том, как я использую awk, которые меня не удовлетворяют.

Вероятно, основная проблема заключается в том, что awk хорошо справляется с данными, представленными в полях с разделителями, но, как ни странно, не очень хорош с файлами со значениями, разделенными запятыми, которые могут иметь разделители полей, встроенные в поле, при условии, что поле заключено в кавычки. Кроме того, регулярные выражения изменились с момента изобретения awk, и необходимость помнить два набора правил синтаксиса регулярных выражений не способствует созданию кода без ошибок. Один набор таких правил уже достаточно плох.

Поскольку awk — небольшой язык, в нем отсутствуют некоторые вещи, которые я иногда нахожу полезными, например, более богатый набор базовых типов, структур, операторов переключения и т. д.

Напротив, в Groovy есть все эти хорошие возможности: доступ к библиотеке OpenCSV, которая облегчает работу с файлами CSV, регулярные выражения Java и отличные операторы сопоставления, богатый набор базовых типов, классов, операторов переключения и многое другое.

Чего не хватает в Groovy, так это простого конвейерно-ориентированного представления данных как входящего потока и обработанных данных как исходящего потока.

Но моя среда обработки музыкальных каталогов заставила меня задуматься, возможно, я смогу создать Groovy-версию «движка» awk. Это моя цель в этой статье.

Установите Java и Groovy

Groovy основан на Java и требует установки Java. В репозиториях вашего дистрибутива Linux могут находиться как последние, так и достойные версии Java и Groovy. Groovy также можно установить, следуя инструкциям на домашней странице Groovy. Хорошей альтернативой для пользователей Linux является SDKMan, который можно использовать для получения нескольких версий Java, Groovy и многих других связанных инструментов. Для этой статьи я использую версии SDK:

  • Java: открытая версия OpenJDK 11 11.0.12;
  • Groovy: версия 3.0.8.

Создание awk с помощью Groovy

Основная идея здесь состоит в том, чтобы инкапсулировать сложности открытия файла или файлов для обработки, разделения строки на поля и предоставления доступа к потоку данных в трех частях:

  • Прежде чем какие-либо данные будут обработаны
  • В каждой строке данных
  • После обработки всех данных

Я не собираюсь рассматривать общий случай замены awk на Groovy. Вместо этого я работаю над своим типичным вариантом использования, а именно:

  • Используйте файл сценария, а не код в командной строке.
  • Обработать один или несколько входных файлов
  • Установите разделитель полей по умолчанию на | и разделите строки, прочитанные на этом разделителе.
  • Используйте OpenCSV для разделения (чего я не могу сделать в awk)

Класс фреймворка

Вот «движок awk» в классе Groovy:

 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 для получения библиотеки OpenCSV версии 5.6 из Maven Central. XML не требуется.

В строке 2 я импортирую класс CSVReader OpenCSV.

В строке 3, как и в случае с Java, я объявляю общедоступный служебный класс AwkEngine.

Строки 11–13 определяют экземпляры Groovy Closure, используемые сценарием в качестве перехватчиков этого класса. Они являются «общедоступными по умолчанию», как и любой класс Groovy, но Groovy создает поля как частные и внешние ссылки на них (используя методы получения и установки, предоставляемые Groovy). Я объясню это далее в примерах сценариев ниже.

Строки 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-начало, а не 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[], что не так удобно, как значения List в Groovy (например, не существует каждого {), определенного для массива). Строки 41–42 преобразуют массив значений полей заголовка в список, поэтому, возможно, fieldsByNumber в строке 57 также следует преобразовать в список.

Использование фреймворка в скриптах

Вот очень простой сценарий, использующий 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(), которое печатает строку «в конце» и подсчитывает количество прочитанных строк.

Строка 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, созданные при компиляции класса платформы, должны находиться в пути к классам. Естественно, вы можете использовать jar для упаковки этих файлов классов.

Мне очень нравится поддержка Groovy делегирования поведения, которое требует различных махинаций на других языках. В течение многих лет Java требовала анонимных классов и большого количества дополнительного кода. Лямбды проделали долгий путь, чтобы исправить это, но они по-прежнему не могут ссылаться на нефинальные переменные вне своей области действия.

Вот еще один, более интересный сценарий, очень напоминающий мое типичное использование 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.

Строки 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.