Ricerca nel sito web

Infondi i tuoi script awk con Groovy


Awk e Groovy si completano a vicenda per creare script robusti e utili.

Recentemente ho scritto una serie sull'utilizzo degli script Groovy per ripulire i tag nei miei file musicali. Ho sviluppato un framework che riconosceva la struttura della mia directory musicale e lo utilizzava per scorrere i file di contenuto. Nell'articolo finale di quella serie, ho separato questo framework in una classe di utilità che i miei script potevano utilizzare per elaborare i file di contenuto.

Questo framework separato mi ha ricordato molto il modo in cui funziona awk. Per quelli di voi che non hanno familiarità con awk, potrebbe trarre vantaggio dall'eBook di Opensource.com, Una guida pratica per imparare awk< /intervallo>.

Ho usato awk estensivamente dal 1984, quando la nostra piccola azienda acquistò il suo primo computer "vero", che eseguiva System V Unix. Per me, awk è stata una rivelazione: aveva una memoria associativa: pensa agli array indicizzati da stringhe invece che da numeri. Aveva espressioni regolari integrate, sembrava progettato per gestire i dati, soprattutto in colonne, ed era compatto e facile da imparare. Infine, è stato progettato per funzionare nelle pipeline Unix, leggendo i suoi dati da input o file standard e scrivendo sull'output, senza alcuna cerimonia richiesta per farlo: i dati apparivano semplicemente nel flusso di input.

Dire che awk è stato una parte essenziale del mio toolkit informatico quotidiano è un eufemismo. Eppure ci sono alcune cose su come utilizzo awk che mi lasciano insoddisfatto.

Probabilmente il problema principale è che awk è bravo a gestire i dati presentati in campi delimitati ma curiosamente non è bravo a gestire file con valori separati da virgole, che possono avere delimitatori di campo incorporati all'interno di un campo, a condizione che il campo sia citato. Inoltre, le espressioni regolari sono cambiate da quando è stato inventato awk, e la necessità di ricordare due serie di regole di sintassi delle espressioni regolari non favorisce un codice privo di bug. Un insieme di tali regole è già abbastanza grave.

Poiché awk è un linguaggio piccolo, mancano alcune cose che a volte trovo utili, come un assortimento più ricco di tipi di base, strutture, istruzioni switch e così via.

Al contrario, Groovy ha tutte queste cose positive: accesso alla libreria OpenCSV, che facilita la gestione dei file CSV, espressioni regolari Java e ottimi operatori di corrispondenza, un ricco assortimento di tipi di base, classi, istruzioni switch e altro ancora.

Ciò che manca a Groovy è la semplice visione orientata alla pipeline dei dati come flusso in entrata e dei dati elaborati come flusso in uscita.

Ma il mio framework di elaborazione delle directory musicali mi ha fatto pensare, forse posso creare una versione Groovy del "motore" di awk. Questo è il mio obiettivo per questo articolo.

Installa Java e Groovy

Groovy è basato su Java e richiede l'installazione di Java. Sia una versione recente che decente di Java e Groovy potrebbero trovarsi nei repository della tua distribuzione Linux. Groovy può anche essere installato seguendo le istruzioni sulla homepage di Groovy. Una buona alternativa per gli utenti Linux è SDKMan, che può essere utilizzato per ottenere più versioni di Java, Groovy e molti altri strumenti correlati. Per questo articolo, sto utilizzando le versioni dell'SDK di:

  • Java: versione 11.0.12-open di OpenJDK 11;
  • Groovy: versione 3.0.8.

Creare awk con Groovy

L'idea di base qui è quella di incapsulare le complessità legate all'apertura di uno o più file per l'elaborazione, dividere la riga in campi e fornire l'accesso al flusso di dati in tre parti:

  • Prima che qualsiasi dato venga elaborato
  • Su ogni riga di dati
  • Dopo che tutti i dati sono stati elaborati

Non intendo il caso generale di sostituire awk con Groovy. Invece, sto lavorando verso il mio caso d'uso tipico, che è:

  • Utilizza un file di script anziché avere il codice sulla riga di comando
  • Elabora uno o più file di input
  • Imposta il mio delimitatore di campo predefinito su | e dividi le righe lette su quel delimitatore
  • Usa OpenCSV per eseguire la suddivisione (cosa non posso fare in awk)

La classe quadro

Ecco il "motore awk" in una 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 }

Anche se sembra un bel pezzo di codice, molte righe sono continuazioni di righe più lunghe divise (ad esempio, normalmente si combinerebbero le righe 38 e 39, le righe 41 e 42 e così via). Diamo un'occhiata a questo riga per riga.

La riga 1 utilizza l'annotazione @Grab per recuperare la libreria OpenCSV versione 5.6 da Maven Central. Nessun XML richiesto.

Nella riga 2, importo la classe CSVReader di OpenCSV.

Nella riga 3, proprio come con Java, dichiaro una classe di pubblica utilità, AwkEngine.

Le righe 11-13 definiscono le istanze Groovy Closure utilizzate dallo script come hook in questa classe. Questi sono "pubblici per impostazione predefinita" come nel caso di qualsiasi classe Groovy, ma Groovy crea i campi come riferimenti privati ed esterni a questi (usando getter e setter forniti da Groovy). Lo spiegherò ulteriormente negli script di esempio riportati di seguito.

Le righe 14-16 dichiarano i campi privati: il separatore di campo, un flag per indicare se la prima riga di un file è un'intestazione e un elenco per il nome del file.

Le righe 17-31 definiscono tre costruttori. Il primo riceve gli argomenti della riga di comando. Il secondo riceve il carattere separatore di campo. La terza riceve il flag che indica se la prima riga è un'intestazione oppure no.

Le righe 31-67 definiscono il motore stesso, come il metodo go().

La riga 33 chiama la chiusura onBegin() (equivalente all'istruzione awk BEGIN {}).

La riga 34 inizializza il recordNumber per lo stream (equivalente alla variabile awk NR) su 0 (nota che qui sto usando 0-origin invece che awk 1-origin).

Le righe 35-65 utilizzano ogni {} per scorrere l'elenco dei file da elaborare.

La riga 36 inizializza il fileRecordNumber per il file (equivalente alla variabile awk FNR) su 0 (0-origin, non 1-origin).

Le righe dalla 37 alla 64 ottengono un'istanza Reader per il file e la elaborano.

Le righe 38-39 ottengono un'istanza CSVReader.

La riga 40 controlla se la prima riga viene trattata come un'intestazione.

Se la prima riga viene trattata come un'intestazione, le righe 41-42 ottengono l'elenco dei nomi delle intestazioni di campo dal primo record.

Le righe 43-54 elaborano il resto dei record.

Le righe 44-48 copiano i valori dei campi nella mappa di name:value.

Le righe 49-51 chiamano la chiusura onEachLine() (equivalente a ciò che appare in un programma awk tra BEGIN {} e END {}, però non è possibile allegare alcun modello per rendere condizionale l'esecuzione), passando la mappa di nome:valore, il numero del record dello stream, il nome del file e il numero del record del file.

Le righe 52-53 incrementano il numero di record del flusso e il numero di record del file.

Altrimenti:

Le righe 56-62 elaborano i record.

Le righe 57-59 chiamano la chiusura onEachLine(), passando l'array di valori di campo, il numero del record del flusso, il nome del file e il numero del record del file.

Le righe 60-61 incrementano il numero di record del flusso e il numero di record del file.

La riga 66 chiama la chiusura onEnd() (equivalente a awk END {}).

Questo è tutto per il quadro. Ora puoi compilarlo:

$ groovyc AwkEngine.groovy

Un paio di commenti:

Se viene passato un argomento che non è un file, il codice fallisce con una traccia dello stack Groovy standard, che assomiglia a questa:

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 restituire valori String[], che non sono convenienti come i valori List in Groovy (ad esempio non c'è each {} definito per un array). Le righe 41-42 convertono l'array di valori del campo header in un elenco, quindi forse anche fieldsByNumber nella riga 57 dovrebbe essere convertito in un elenco.

Utilizzo del framework negli script

Ecco uno script molto semplice che utilizza AwkEngine per esaminare un file come /etc/group, che è delimitato da due punti e non ha intestazione:

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

La riga 1 chiama il costruttore a due argomenti, passando l'elenco degli argomenti e i due punti come delimitatore.

La riga 2 definisce una variabile di livello superiore dello script, lineCount, utilizzata per registrare il conteggio delle righe lette (nota che le chiusure Groovy non richiedono variabili definite esterne alla chiusura per essere finali).

Le righe 3-5 definiscono la chiusura onBegin(), che stampa semplicemente la stringa "in Begin" sullo standard output.

Le righe 6-10 definiscono la chiusura onEachLine(), che stampa il nome del file ed i campi per le prime 10 righe e comunque incrementa il conteggio delle righe.

Le righe 11-14 definiscono la chiusura onEnd(), che stampa la stringa "in end" e il conteggio del numero di righe lette.

La riga 15 esegue lo script utilizzando AwkEngine.

Esegui questo script come segue:

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

Ovviamente i file .class creati compilando la classe framework devono trovarsi nel classpath affinché funzioni. Naturalmente, potresti usare jar per impacchettare quei file di classe.

Mi piace molto il supporto di Groovy alla delega del comportamento, che richiede vari imbrogli in altre lingue. Per molti anni Java ha richiesto classi anonime e un bel po' di codice extra. I Lambda hanno fatto molto per risolvere questo problema, ma non possono ancora fare riferimento a variabili non finali al di fuori del loro ambito.

Ecco un altro script più interessante che ricorda molto il mio uso tipico di 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()

La riga 1 chiama il costruttore a tre argomenti, riconoscendo che si tratta di un file "vero CSV" con l'intestazione sulla prima riga. Poiché si tratta di un file spagnolo, in cui la virgola viene utilizzata come "punto" decimale, il delimitatore standard è il punto e virgola.

Le righe 2-4 definiscono la chiusura onBegin() che in questo caso non fa nulla.

La riga 5 definisce un LinkedHashMap (vuoto), che riempirai con chiavi stringa e valori interi. Il file di dati proviene dal censimento più recente del Cile e stai calcolando il numero di persone in ciascuna regione del Cile in questo script.

Le righe 6-11 elaborano le righe nel file (ce ne sono 180.500 inclusa l'intestazione): tieni presente che in questo caso, poiché stai definendo la riga 1 come intestazioni di colonna CSV, il parametro campi sarà un'istanza di LinkedHashMap.

Le righe 7-10 incrementano la mappa regionCount, utilizzando il valore nel campo REGION come chiave e il valore nel campo PERSONAS come valore; nota che, a differenza di awk, in Groovy non puoi fare riferimento a una voce della mappa inesistente sul lato destro e aspettarsi che si materializzi un valore vuoto o zero.

Le righe 12-16 stampano la popolazione per regione.

La riga 17 esegue lo script sull'istanza AwkEngine.

Esegui questo script come segue:

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

Questo è tutto. Per quelli di voi che amano awk e tuttavia vorrebbero qualcosa in più, spero che questo approccio Groovy vi piaccia.

Articoli correlati: