Recherche de site Web

Infusez vos scripts awk avec Groovy


Awk et Groovy se complètent pour créer des scripts robustes et utiles.

Récemment, j'ai écrit une série sur l'utilisation de scripts Groovy pour nettoyer les balises de mes fichiers musicaux. J'ai développé un framework qui reconnaissait la structure de mon répertoire musical et l'utilisais pour parcourir les fichiers de contenu. Dans le dernier article de cette série, j'ai séparé ce framework en une classe utilitaire que mes scripts pourraient utiliser pour traiter les fichiers de contenu.

Ce framework séparé m'a beaucoup rappelé le fonctionnement d'awk. Pour ceux d'entre vous qui ne connaissent pas awk, vous pourriez bénéficier du livre électronique d'Opensource.com, Un guide pratique pour apprendre awk< /envergure>.

J'ai beaucoup utilisé awk depuis 1984, lorsque notre petite entreprise a acheté son premier "vrai" ordinateur, qui exécutait System V Unix. Pour moi, awk a été une révélation : il possédait une mémoire associative : pensez aux tableaux indexés par des chaînes plutôt que par des nombres. Il contenait des expressions régulières intégrées, semblait conçu pour traiter des données, en particulier dans les colonnes, et était compact et facile à apprendre. Enfin, il a été conçu pour fonctionner dans les pipelines Unix, lisant ses données à partir d'une entrée ou de fichiers standard et écrivant dans la sortie, sans aucune cérémonie requise pour le faire : les données apparaissaient simplement dans le flux d'entrée.

Dire que awk est un élément essentiel de ma boîte à outils informatique quotidienne est un euphémisme. Et pourtant, il y a certaines choses sur la façon dont j'utilise awk qui me laissent insatisfait.

Le principal problème est probablement que awk est efficace pour gérer les données présentées dans des champs délimités, mais curieusement, il n'est pas bon pour gérer les fichiers de valeurs séparées par des virgules, qui peuvent avoir des délimiteurs de champ intégrés dans un champ, à condition que le champ soit cité. De plus, les expressions régulières ont évolué depuis l'invention d'awk, et le fait de devoir se souvenir de deux ensembles de règles de syntaxe d'expression régulière n'est pas propice à un code sans bug. Un seul ensemble de telles règles est déjà assez mauvais.

Parce que awk est un petit langage, il lui manque certaines choses que je trouve parfois utiles, comme un assortiment plus riche de types de base, de structures, d'instructions switch, etc.

En revanche, Groovy possède toutes ces bonnes choses : l'accès à la bibliothèque OpenCSV, qui facilite le traitement des fichiers CSV, les expressions régulières Java et d'excellents opérateurs de correspondance, un riche assortiment de types de base, de classes, d'instructions switch, et bien plus encore.

Ce qui manque à Groovy, c'est la vue simple, orientée pipeline, des données en tant que flux entrant et des données traitées en tant que flux sortant.

Mais mon framework de traitement de répertoire musical m'a fait réfléchir, je pourrais peut-être créer une version Groovy du "moteur" d'awk. C'est mon objectif pour cet article.

Installer Java et Groovy

Groovy est basé sur Java et nécessite une installation Java. Une version récente et décente de Java et Groovy pourrait se trouver dans les référentiels de votre distribution Linux. Groovy peut également être installé en suivant les instructions sur la page d'accueil de Groovy. Une alternative intéressante pour les utilisateurs de Linux est SDKMan, qui peut être utilisé pour obtenir plusieurs versions de Java, Groovy et de nombreux autres outils associés. Pour cet article, j'utilise les versions du SDK :

  • Java : version 11.0.12-ouverte d'OpenJDK 11 ;
  • Groovy : version 3.0.8.

Créer awk avec Groovy

L'idée de base ici est d'encapsuler les complexités liées à l'ouverture d'un ou plusieurs fichiers pour le traitement, à la division de la ligne en champs et à la fourniture d'un accès au flux de données en trois parties :

  • Avant le traitement des données
  • Sur chaque ligne de données
  • Une fois toutes les données traitées

Je ne vais pas dans le cas général du remplacement de awk par Groovy. Au lieu de cela, je travaille sur mon cas d'utilisation typique, à savoir :

  • Utilisez un fichier de script plutôt que d'avoir le code sur la ligne de commande
  • Traiter un ou plusieurs fichiers d'entrée
  • Définir mon délimiteur de champ par défaut sur | et diviser les lignes lues sur ce délimiteur
  • Utilisez OpenCSV pour effectuer le fractionnement (ce que je ne peux pas faire dans awk)

La classe framework

Voici le "moteur awk" dans une 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 }

Bien que cela ressemble à un bout de code, de nombreuses lignes sont des continuations de lignes plus longues (par exemple, vous combineriez normalement les lignes 38 et 39, les lignes 41 et 42, et ainsi de suite). Regardons cela ligne par ligne.

La ligne 1 utilise l'annotation @Grab pour récupérer la bibliothèque OpenCSV version 5.6 depuis Maven Central. Aucun XML requis.

À la ligne 2, j'importe la classe CSVReader d'OpenCSV.

À la ligne 3, tout comme avec Java, je déclare une classe d'utilité publique, AwkEngine.

Les lignes 11 à 13 définissent les instances Groovy Closure utilisées par le script comme hooks dans cette classe. Ceux-ci sont "publics par défaut" comme c'est le cas pour n'importe quelle classe Groovy, mais Groovy crée les champs en tant que références privées et externes à ceux-ci (en utilisant les getters et setters fournis par Groovy). Je vais expliquer cela plus en détail dans les exemples de scripts ci-dessous.

Les lignes 14 à 16 déclarent les champs privés : le séparateur de champ, un indicateur pour indiquer si la première ligne d'un fichier est un en-tête et une liste pour le nom du fichier.

Les lignes 17 à 31 définissent trois constructeurs. Le premier reçoit les arguments de la ligne de commande. Le second reçoit le caractère séparateur de champ. Le troisième reçoit le drapeau indiquant si la première ligne est un en-tête ou non.

Les lignes 31 à 67 définissent le moteur lui-même, comme la méthode go().

La ligne 33 appelle la fermeture onBegin() (équivalente à l'instruction awk BEGIN {}).

La ligne 34 initialise le recordNumber du flux (équivalent à la variable awk NR) à 0 (notez que je fais ici 0-origin plutôt que awk 1-origin).

Les lignes 35 à 65 utilisent chaque {} pour parcourir la liste des fichiers à traiter.

La ligne 36 initialise le fileRecordNumber du fichier (équivalent à la variable awk FNR) à 0 (origine 0, pas origine 1).

Les lignes 37 à 64 obtiennent une instance Reader pour le fichier et la traitent.

Les lignes 38 à 39 obtiennent une instance CSVReader.

La ligne 40 vérifie si la première ligne est traitée comme un en-tête.

Si la première ligne est traitée comme un en-tête, les lignes 41 et 42 obtiennent la liste des noms d'en-tête de champ du premier enregistrement.

Les lignes 43 à 54 traitent le reste des enregistrements.

Les lignes 44 à 48 copient les valeurs des champs dans la carte de nom:valeur.

Les lignes 49 à 51 appellent la fermeture onEachLine() (équivalente à ce qui apparaît dans un programme awk entre BEGIN {} et END {}, cependant aucun modèle ne peut être attaché pour rendre l'exécution conditionnelle), en passant la carte de nom:valeur, le numéro d'enregistrement du flux, le nom du fichier et le numéro d'enregistrement du fichier.

Les lignes 52 et 53 incrémentent le numéro d'enregistrement de flux et le numéro d'enregistrement de fichier.

Sinon:

Les lignes 56 à 62 traitent les enregistrements.

Les lignes 57 à 59 appellent la fermeture onEachLine(), en transmettant le tableau de valeurs de champ, le numéro d'enregistrement du flux, le nom du fichier et le numéro d'enregistrement du fichier.

Les lignes 60 et 61 incrémentent le numéro d'enregistrement de flux et le numéro d'enregistrement de fichier.

La ligne 66 appelle la fermeture onEnd() (équivalente à awk END {}).

Voilà pour le cadre. Vous pouvez maintenant le compiler :

$ groovyc AwkEngine.groovy

Quelques commentaires :

Si un argument transmis n'est pas un fichier, le code échoue avec une trace de pile Groovy standard, qui ressemble à ceci :

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 a tendance à renvoyer des valeurs String[], qui ne sont pas aussi pratiques que les valeurs List dans Groovy (par exemple, il n'y a pas de each {} défini pour un tableau). Les lignes 41 et 42 convertissent le tableau de valeurs du champ d'en-tête en liste, donc peut-être que fieldsByNumber à la ligne 57 devrait également être converti en liste.

Utiliser le framework dans les scripts

Voici un script très simple utilisant AwkEngine pour examiner un fichier comme /etc/group, qui est délimité par deux points et n'a pas d'en-tête :

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 ligne 1 appelle le constructeur à deux arguments, en passant la liste d'arguments et les deux points comme délimiteur.

La ligne 2 définit une variable de script de niveau supérieur, lineCount, utilisée pour enregistrer le nombre de lignes lues (notez que les fermetures Groovy ne nécessitent pas de variables définies externes à la fermeture pour être définitives).

Les lignes 3 à 5 définissent la fermeture onBegin(), qui imprime simplement la chaîne "in start" sur la sortie standard.

Les lignes 6 à 10 définissent la fermeture onEachLine(), qui imprime le nom du fichier et les champs des 10 premières lignes et incrémente dans tous les cas le nombre de lignes.

Les lignes 11 à 14 définissent la fermeture onEnd(), qui imprime la chaîne "in end" et le nombre de lignes lues.

La ligne 15 exécute le script à l'aide de AwkEngine.

Exécutez ce script comme suit :

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

Bien sûr, les fichiers .class créés en compilant la classe framework doivent être sur le chemin de classe pour que cela fonctionne. Naturellement, vous pouvez utiliser jar pour regrouper ces fichiers de classe.

J'aime beaucoup le support de Groovy pour la délégation de comportement, qui nécessite diverses manigances dans d'autres langues. Pendant de nombreuses années, Java a nécessité des classes anonymes et pas mal de code supplémentaire. Les Lambdas ont parcouru un long chemin pour résoudre ce problème, mais ils ne peuvent toujours pas faire référence à des variables non finales en dehors de leur portée.

Voici un autre script plus intéressant qui rappelle beaucoup mon utilisation typique de 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 ligne 1 appelle le constructeur à trois arguments, reconnaissant qu'il s'agit d'un "vrai fichier CSV" dont l'en-tête se trouve sur la première ligne. Comme il s'agit d'un fichier espagnol, où la virgule est utilisée comme "point" décimal, le délimiteur standard est le point-virgule.

Les lignes 2 à 4 définissent la fermeture onBegin() qui dans ce cas ne fait rien.

La ligne 5 définit un LinkedHashMap (vide), que vous remplirez avec des clés de chaîne et des valeurs entières. Le fichier de données provient du recensement le plus récent du Chili et vous calculez le nombre de personnes dans chaque région du Chili dans ce script.

Les lignes 6 à 11 traitent les lignes du fichier (il y en a 180 500, en-tête compris) – notez que dans ce cas, parce que vous définissez la ligne 1 comme en-tête de colonne CSV, le paramètre field sera une instance de LinkedHashMap.

Les lignes 7 à 10 incrémentent la carte regionCount, en utilisant la valeur du champ REGION comme clé et la valeur du champ PERSONAS comme valeur. Notez que, contrairement à awk, dans Groovy, vous ne pouvez pas faire référence à une entrée de carte inexistante sur le côté droit et attendez-vous à ce qu’une valeur vide ou nulle se matérialise.

Les lignes 12 à 16 affichent la population par région.

La ligne 17 exécute le script sur l'instance AwkEngine.

Exécutez ce script comme suit :

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

C'est ça. Pour ceux d'entre vous qui aiment awk et qui aimeraient pourtant un peu plus, j'espère que vous apprécierez cette approche Groovy.

Articles connexes: