Pesquisa de site

Infunda seus scripts awk com Groovy


Awk e Groovy se complementam para criar scripts robustos e úteis.

Recentemente escrevi uma série sobre como usar scripts Groovy para limpar tags em meus arquivos de música. Desenvolvi uma estrutura que reconhecia a estrutura do meu diretório de músicas e a usava para iterar os arquivos de conteúdo. No artigo final dessa série, separei esse framework em uma classe utilitária que meus scripts poderiam usar para processar os arquivos de conteúdo.

Essa estrutura separada me lembrou muito de como o awk funciona. Para aqueles que não estão familiarizados com o awk, você pode se beneficiar do e-book da Opensource.com, Um guia prático para aprender awk.

Eu tenho usado o awk extensivamente desde 1984, quando nossa pequena empresa comprou seu primeiro computador “real”, que rodava System V Unix. Para mim, o awk foi uma revelação: ele tinha memória associativa – pense em arrays indexados por strings em vez de números. Ele tinha expressões regulares integradas, parecia projetado para lidar com dados, especialmente em colunas, e era compacto e fácil de aprender. Por fim, ele foi projetado para funcionar em pipelines Unix, lendo seus dados de entradas ou arquivos padrão e gravando na saída, sem necessidade de cerimônia para fazer isso – os dados apenas apareciam no fluxo de entrada.

Dizer que o awk tem sido uma parte essencial do meu kit de ferramentas de computação do dia a dia é um eufemismo. E ainda assim há algumas coisas sobre como uso o awk que me deixam insatisfeito.

Provavelmente, o principal problema é que o awk é bom para lidar com dados apresentados em campos delimitados, mas curiosamente não é bom para lidar com arquivos com valores separados por vírgula, que podem ter delimitadores de campo incorporados em um campo, desde que o campo esteja entre aspas. Além disso, as expressões regulares evoluíram desde que o awk foi inventado, e a necessidade de lembrar dois conjuntos de regras de sintaxe de expressões regulares não conduz a um código livre de bugs. Um conjunto dessas regras já é ruim o suficiente.

Como o awk é uma linguagem pequena, faltam algumas coisas que às vezes considero úteis, como uma variedade mais rica de tipos base, estruturas, instruções switch e assim por diante.

Em contraste, Groovy tem todas estas coisas boas: acesso à biblioteca OpenCSV, que facilita lidar com arquivos CSV, expressões regulares Java e ótimos operadores de correspondência, uma rica variedade de tipos base, classes, instruções switch e muito mais.

O que falta ao Groovy é a visão simples e orientada ao pipeline dos dados como um fluxo de entrada e dos dados processados como um fluxo de saída.

Mas minha estrutura de processamento de diretório de música me fez pensar, talvez eu possa criar uma versão Groovy do "mecanismo" do awk. Esse é o meu objetivo para este artigo.

Instale Java e Groovy

Groovy é baseado em Java e requer instalação Java. Uma versão recente e decente do Java e do Groovy pode estar nos repositórios da sua distribuição Linux. O Groovy também pode ser instalado seguindo as instruções na página inicial do Groovy. Uma boa alternativa para usuários de Linux é o SDKMan, que pode ser usado para obter múltiplas versões de Java, Groovy e muitas outras ferramentas relacionadas. Para este artigo, estou usando as versões do SDK de:

  • Java: versão 11.0.12-open do OpenJDK 11;
  • Groovy: versão 3.0.8.

Criando awk com Groovy

A ideia básica aqui é encapsular as complexidades de abrir um arquivo ou arquivos para processamento, dividir a linha em campos e fornecer acesso ao fluxo de dados em três partes:

  • Antes de qualquer dado ser processado
  • Em cada linha de dados
  • Depois que todos os dados forem processados

Não estou optando pelo caso geral de substituir o awk pelo Groovy. Em vez disso, estou trabalhando no meu caso de uso típico, que é:

  • Use um arquivo de script em vez de ter o código na linha de comando
  • Processar um ou mais arquivos de entrada
  • Defina meu delimitador de campo padrão como | e divida as linhas lidas nesse delimitador
  • Use OpenCSV para fazer a divisão (o que não consigo fazer no awk)

A classe de estrutura

Aqui está o "mecanismo awk" em uma classe 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 }

Embora pareça um código razoável, muitas das linhas são continuações de linhas mais longas divididas (por exemplo, normalmente você combinaria as linhas 38 e 39, as linhas 41 e 42 e assim por diante). Vejamos isso linha por linha.

A linha 1 usa a anotação @Grab para buscar a biblioteca OpenCSV versão 5.6 do Maven Central. Não é necessário XML.

Na linha 2, importo a classe CSVReader do OpenCSV.

Na linha 3, assim como em Java, declaro uma classe de utilidade pública, AwkEngine.

As linhas 11-13 definem as instâncias do Groovy Closure usadas pelo script como ganchos para esta classe. Eles são "públicos por padrão", como é o caso de qualquer classe Groovy - mas o Groovy cria os campos como referências privadas e externas a eles (usando getters e setters fornecidos pelo Groovy). Explicarei isso mais detalhadamente nos scripts de exemplo abaixo.

As linhas 14 a 16 declaram os campos privados – o separador de campos, um sinalizador para indicar se a primeira linha de um arquivo é um cabeçalho e uma lista para o nome do arquivo.

As linhas 17-31 definem três construtores. O primeiro recebe os argumentos da linha de comando. O segundo recebe o caractere separador de campos. A terceira recebe a flag indicando se a primeira linha é um cabeçalho ou não.

As linhas 31-67 definem o mecanismo em si, como o método go().

A linha 33 chama o encerramento onBegin() (equivalente à instrução awk BEGIN {}).

A linha 34 inicializa o recordNumber do stream (equivalente à variável awk NR) como 0 (observe que estou fazendo a origem 0 aqui em vez da origem 1 do awk).

As linhas 35 a 65 usam cada {} para percorrer a lista de arquivos a serem processados.

A linha 36 inicializa o fileRecordNumber do arquivo (equivalente à variável awk FNR) como 0 (origem 0, não origem 1).

As linhas 37-64 obtêm uma instância de Reader para o arquivo e o processam.

As linhas 38-39 obtêm uma instância de CSVReader.

A linha 40 verifica se a primeira linha está sendo tratada como um cabeçalho.

Se a primeira linha estiver sendo tratada como um cabeçalho, as linhas 41 a 42 obterão a lista de nomes de cabeçalho de campo do primeiro registro.

As linhas 43-54 processam o restante dos registros.

As linhas 44-48 copiam os valores dos campos no mapa de name:value.

As linhas 49-51 chamam o fechamento onEachLine() (equivalente ao que aparece em um programa awk entre BEGIN {} e END {}, embora nenhum padrão pode ser anexado para tornar a execução condicional), passando no mapa de nome:valor, o número do registro do fluxo, o nome do arquivo e o número do registro do arquivo.

As linhas 52-53 incrementam o número do registro do fluxo e o número do registro do arquivo.

De outra forma:

As linhas 56-62 processam os registros.

As linhas 57-59 chamam o encerramento onEachLine(), passando o array de valores de campo, o número do registro do fluxo, o nome do arquivo e o número do registro do arquivo.

As linhas 60-61 incrementam o número do registro do fluxo e o número do registro do arquivo.

A linha 66 chama o fechamento onEnd() (equivalente ao awk END {}).

É isso para a estrutura. Agora você pode compilá-lo:

$ groovyc AwkEngine.groovy

Alguns comentários:

Se for passado um argumento que não seja um arquivo, o código falhará com um rastreamento de pilha Groovy padrão, que se parece com isto:

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 tende a retornar valores String[], que não são tão convenientes quanto valores List no Groovy (por exemplo, não há each {} definido para uma matriz). As linhas 41-42 convertem a matriz de valores do campo de cabeçalho em uma lista, então talvez fieldsByNumber na linha 57 também deva ser convertido em uma lista.

Usando a estrutura em scripts

Aqui está um script muito simples usando AwkEngine para examinar um arquivo como /etc/group, que é delimitado por dois pontos e não tem cabeçalho:

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()

A linha 1 chama o construtor de dois argumentos, passando a lista de argumentos e os dois pontos como delimitador.

A linha 2 define uma variável de nível superior do script, lineCount, usada para registrar a contagem de linhas lidas (observe que os encerramentos Groovy não exigem que variáveis definidas externamente ao encerramento sejam finais).

As linhas 3 a 5 definem o fechamento onBegin(), que apenas imprime a string "in begin" na saída padrão.

As linhas 6 a 10 definem o fechamento onEachLine(), que imprime o nome do arquivo e os campos para as primeiras 10 linhas e, em qualquer caso, aumenta a contagem de linhas.

As linhas 11-14 definem o fechamento onEnd(), que imprime a string "in end" e a contagem do número de linhas lidas.

A linha 15 executa o script usando o AwkEngine.

Execute este script da seguinte maneira:

$ 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
$

É claro que os arquivos .class criados pela compilação da classe do framework devem estar no classpath para que isso funcione. Naturalmente, você poderia usar jar para empacotar esses arquivos de classe.

Gosto muito do apoio do Groovy à delegação de comportamento, que exige diversas travessuras em outros idiomas. Por muitos anos, Java exigiu classes anônimas e bastante código extra. Lambdas percorreram um longo caminho para corrigir isso, mas ainda não conseguem se referir a variáveis não finais fora de seu escopo.

Aqui está outro script mais interessante que lembra muito meu uso típico do 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()

A linha 1 chama o construtor de três argumentos, reconhecendo que este é um arquivo "CSV verdadeiro" com o cabeçalho na primeira linha. Por se tratar de um arquivo em espanhol, onde a vírgula é usada como "ponto" decimal, o delimitador padrão é o ponto e vírgula.

As linhas 2 a 4 definem o fechamento onBegin() que neste caso não faz nada.

A linha 5 define um LinkedHashMap (vazio), que você preencherá com chaves String e valores inteiros. O arquivo de dados é do censo mais recente do Chile e você está calculando o número de pessoas em cada região do Chile neste script.

As linhas 6 a 11 processam as linhas do arquivo (há 180.500 incluindo o cabeçalho) - observe que, neste caso, como você está definindo a linha 1 como os cabeçalhos das colunas CSV, o parâmetro de campos será uma instância de LinkedHashMap.

As linhas 7 a 10 incrementam o mapa regionCount, usando o valor no campo REGION como a chave e o valor no campo PERSONAS como o valor - observe que, diferentemente do awk, no Groovy você não pode se referir para uma entrada de mapa inexistente no lado direito e espera que um valor em branco ou zero se materialize.

As linhas 12 a 16 imprimem a população por região.

A linha 17 executa o script na instância AwkEngine.

Execute este script da seguinte maneira:

$ 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
$

É isso. Para aqueles que amam o awk e ainda gostariam de um pouco mais, espero que gostem dessa abordagem Groovy.