Extraction efficace des données de plusieurs files vers un seul file CSV

J'ai une grande collection de files XML avec la même structure exacte:

$ cat file_<ID>.xml ... ... ... <double>1.2342</double> <double>2.3456</double> ... ... ... ... 

où le nombre de ces inputs <double> dans chaque file XML est fixe et connu (dans mon cas particulier, 168).

J'ai besoin de build un seul file csv avec le contenu de tous ces files XML stockés comme suit:

 file_0001 1.2342 2.3456 ... file_0002 1.2342 2.3456 ... 

etc.

Comment puis-je le faire efficacement?


Le meilleur que j'ai trouvé est le suivant:

 #!/usr/bin/env zsh for x in $path_to_xmls/*.xml; do # 1) Get the doubles ignoring everything else # 2) Remove line breaks within the same file # 3) Add a new line at the end to construct the CSV file # 4) Join the columns together cat $x | grep -F '<double>' | \ sed -r 's/.*>([0-9]+\.*[0-9]*).*?/\1/' | \ tr '\n' ' ' | sed -e '$a\' | >> table_numbers.csv echo ${x:t} >> file_IDs.csv done paste file_IDs table_numbers.csv > final_table.csv 

Quand je time le script ci-dessus dans un dossier avec des files XML ~ 10K j'obtiens:

 ./from_xml_to_csv.sh 100.45s user 94.84s system 239% cpu 1:21.48 total 

pas terrible, mais j'espère travailler avec 100x ou 1000x plus de files. Comment puis-je rendre ce traitement plus efficace?

En outre, avec ma solution ci-dessus, pourrais-je me refind dans une situation où l'expansion glob atteint une limite, par exemple lorsque vous travaillez avec des millions de files? (le problème typique de "too many args" ).

Mettre à jour

Pour toute personne intéressée par une excellente solution à ce problème, veuillez lire la réponse de @ mikeserve. C'est le plus rapide et celui qui élève le mieux de loin.

En ce qui concerne l'expansion globale dépassant éventuellement une limite – oui et non. Le shell est déjà en cours d'exécution, et donc il ne s'arrêtera pas. Mais si vous deviez passer tout le tableau globbed comme arguments à une seule command, alors oui, c'est une possibilité définie. Le moyen portable et robuste pour gérer cela implique de find

 find . \! -name . -prune -name pattern -type f -exec cat {} + | ... 

… qui chattera seulement les files réguliers dans le directory en cours avec un nom qui correspond à pattern , mais ARG_MAX aussi cat autant de fois que nécessaire pour éviter de dépasser ARG_MAX .

En fait, cependant, puisque vous avez un GNU sed nous pouvons presque tout faire avec juste sed dans un script de find .

 cd /path/to/xmls find . \! -name . -prune -name \*.xml -type f -exec \ sed -sne'1F;$x;/\n*\( \)*<\/*double>/!d' \ -e '$s//\1/gp;H' {} + | paste -d\\0 - - 

J'ai pensé d'une autre façon. Ce sera très rapide, mais cela dépendra du fait qu'il y aura exactement 168 correspondances par file, et qu'il ne peut y en avoir qu'une seule . point dans les noms de files.

 ( export LC_ALL=C; set '' - - while [ "$#" -lt 168 ]; do set "$@$@"; done shift "$((${#}-168))" find . \! -name . -prune -name \*.xml -type f \ -exec grep -F '<double>' /dev/null {} + | tr \<: '>>' | cut -d\> -f1,4 | paste -d\ "$@" | sed 'h;s|./[^>]*>||g;x;s|\.x.*||;s|..||;G;s|\n| |' ) 

Comme demandé, voici un petit détail sur le fonctionnement de cette command:

  1. ( ... )

    • En premier lieu, tout le petit script est exécuté dans son propre sous-shell car il y a quelques propriétés environnementales globales que nous allons modifier au cours de son exécution, et de cette façon, lorsque le travail est fait, toutes les propriétés que nous modifions seront restaurés à leurs valeurs d'origine – quels qu'ils soient.
  2. export LC_ALL=C; set '' - -
    • En définissant les parameters régionaux actuels sur C nous pouvons économiser beaucoup d'efforts sur nos filters. Dans une locale UTF-8, n'importe quel char peut être représenté par un ou plusieurs octets par morceau, et tout char trouvé devra être sélectionné parmi un groupe de plusieurs milliers de possibles. Dans la locale C, chaque char est un seul octet, et il n'y en a que 128. Cela rend char correspondant à une affaire beaucoup plus rapide dans l'set.
    • L'instruction set modifie les parameters de position du shell. Doing set '' - - fixe $1 à \0 et $2 et $3 à - .
  3. while ... set "$@$@"; done; shift ...
    • Fondamentalement, le point entier de cette déclaration est d'get un tableau de 168 tirets. Nous utiliserons la paste plus tard pour replace les jeux séquentiels de 167 returns avec des espaces, tout en préservant le 168e. La façon la plus simple de le faire est de lui donner 168 references d'arguments à - stdin et de lui dire de coller tous ces éléments set.
  4. find ... -exec grep -F '<double>' /dev/null' ...
    • Le bit de find a été discuté précédemment, mais avec grep nous n'imprimons que les lignes qui peuvent être comparées à la string -fixée <double> . En faisant le premier argument de /dev/null grep – qui est un file qui ne peut jamais correspondre à notre string – nous nous assurons que grep search toujours 2 arguments de file ou plus pour chaque invocation. Lorsqu'il est appelé avec 2 ou plusieurs files de search nommés, grep imprimera toujours le nom de file comme file_000.xml: en tête de chaque ligne de sortie.
  5. tr \<: '>>'
    • Ici, nous traduisons toutes les occurrences dans la sortie grep de : ou < caractères à > .
    • À ce stade, un exemple de ligne appariée ressemblera à ./file_000.xml> >double>0.0000>/double> .
  6. cut -d\> -f1,4
    • cut de sa sortie toute sa consortingbution qui ne peut être trouvée dans les 1er ou 4ème champs divisés par > chars.
    • À ce stade, un exemple de ligne appariée ressemblera à ./file_000.xml>0.0000 .
  7. paste -d\ "$@"
    • Déjà discuté, mais ici, nous avons paste des lignes d'input par lots de 168.
    • À ce stade, 168 lignes appariées se produisent set comme: ./file_000.xml>0.000 .../file_000.xml>0.167
  8. sed 'h;s|./[^>]*>||g;x;s|\.xml.*||;s|..||;G;s|\n| |'
    • Maintenant, les services plus petits et plus rapides ont déjà fait la majorité du travail. Sur un système multicœur, ils l'ont probablement fait simultanément. Et ces utilitaires – en particulier le cutpaste sont beaucoup plus rapides à ce qu'ils font que toute tentative d'émulation que nous pourrions faire avec des utilitaires de niveau supérieur comme sed ou, pire encore, awk . Mais je l'ai pris aussi loin que je pourrais imaginer que je pourrais faire si loin, et je dois appeler sed .
    • Tout d'abord, j'utilise une copy de chaque ligne d'input, puis je supprime globalement toutes les occurrences du motif ./[^>]*> dans l'espace des motifs – ainsi chaque occurrence du nom de file. À ce stade, l'espace des motifs de sed ressemble à: 0.000 0.0001...0.167
    • Ensuite, je change les anciens et les espaces de model et supprime tout de \.xml.* – ainsi tout du premier nom de file sur la copy sauvegardée de la ligne. Je dépouille ensuite les deux premiers caractères – ou ./ aussi – et à ce stade, l'espace de patron ressemble à file_000 .
    • Donc, tout ce qui rest est de les coller set. I G et une copy de l'ancien espace ajouté à l'espace des motifs à la suite d'un \n ewline char, alors je s/// installe le \n ewline pour un espace.
    • Et finalement, l'espace des motifs ressemble à file_000 0.000...0.167 . Et c'est ce que sed écrit à la sortie pour chaque file find passe à grep .

Cela devrait faire l'affaire:

 awk -F '[<>]' ' NR!=1 && FNR==1{printf "\n"} FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} /double/{printf " %s", $3} END{printf "\n"} ' $path_to_xml/*.xml > final_table.csv 

Explication:

  • awk : utilise le programme awk , je l'ai testé avec GNU awk 4.0.1
  • -F '[<>]' : utilise < et > comme séparateur de champs
  • NR!=1 && FNR==1{printf "\n"} : si ce n'est pas la première ligne globale ( NR!=1 ) mais la première ligne d'un file ( FNR==1 )
  • FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} : s'il s'agit de la première ligne d'un file, dépouiller le file / ( sub(".*/", "", FILENAME) ) dans le nom du file ( FILENAME ) .xml ( sub(".xml$", "", FILENAME) ) et imprimez le résultat ( printf FILENAME )
  • /double/{printf " %s", $3} si une ligne contient "double" ( /double/ ), imprimez un espace suivi du troisième champ ( printf " %s", $3 ). Utiliser < et > comme séparateurs serait le nombre (le premier champ étant quelque chose avant le premier < et le deuxième champ étant le double ). Si vous le souhaitez, vous pouvez formater les numéros ici. Par exemple, en utilisant %8.3f au lieu de %s un nombre quelconque sera imprimé avec 3 décimales et une longueur totale (y compris les points et les décimales) d'au less 8.
  • END {printf "\ n"}: après la dernière ligne, imprimez une nouvelle ligne supplémentaire (cela peut être facultatif)
  • $path_to_xml/*.xml : la list des files
  • > final_table.csv : mettez le résultat dans final_table.csv en redirigeant la sortie

Dans le cas d'une erreur "list list to long", vous pouvez utiliser find avec le paramètre -exec pour générer une list de files au lieu de la passer directement:

 find $path_to_xml -maxdepth 1 -type f -name '*.xml' -exec awk -F '[<>]' ' NR!=1 && FNR==1{printf "\n"} FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} /double/{printf " %s", $3} END{printf "\n"} ' {} + > final_table.csv 

Explication:

  • find $path_to_xml : tell find pour listr les files dans $path_to_xml
  • -maxdepth 1 : ne descendez pas dans les sous-dossiers de $path_to_xml
  • -type f : list seulement les files réguliers (cela exclut également $path_to_xml lui-même)
  • -name '*.xml': only list files that match the pattern * .xml`, cela doit être cité sinon le shell essaiera d'étendre le motif
  • -exec COMMAND {} + : exécutez la command COMMAND avec les files correspondants en tant que parameters à la place de {} . + indique que plusieurs files peuvent être transmis en même time, ce qui réduit le fork. Si vous utilisez \; (doit être cité sinon il est interprété par le shell) au lieu de + la command est exécutée séparément pour chaque file.

Vous pouvez également utiliser xargs en conjonction avec find:

 find $path_to_xml -maxdepth 1 -type f -name '*.xml' -print0 | xargs -0 awk -F '[<>]' ' NR!=1 && FNR==1{printf "\n"} FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} /double/{printf " %s", $3} END{printf "\n"} ' > final_table.csv 

Explication

  • -print0 : list de sortie des files séparés par des caractères nuls
  • | (pipe): redirige la sortie standard de find vers l'input standard de xargs
  • xargs : construit et exécute des commands depuis une input standard, c'est-à-dire exécuter une command pour chaque argument (ici les noms de file) passé.
  • -0 : les xargs directs supposent que les arguments sont séparés par des caractères Nuls

 awk -F '[<>]' ' BEGINFILE {sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} /double/{printf " %s", $3} ENDFILE {printf "\n"} ' $path_to_xml/*.xml > final_table.csv 

BEGINFILE , ENDFILE sont appelés lors du changement de file. (si votre awk le supporte).

S'il vous plaît, au nom des futurs programmeurs de maintenance et administrateurs système – NE PAS utiliser une regex pour parsingr XML. XML est un type de données structuré, et il n'est PAS bien adapté à l'parsing des expressions rationnelles. Vous pouvez le simuler en prétendant qu'il s'agit d'un text simple, mais il y a un tas de choses sémantiquement identiques en XML qui n'parsingnt pas la même chose. Vous pouvez intégrer des saut de ligne, et avoir des labels unaires par exemple.

Ainsi – utilisez un parsingur – j'ai moqué certaines données source, parce que votre XML n'est pas valide. Donnez-moi un échantillon plus complet, et je vous donnerai une réponse plus complète.

Au niveau de base – nous extrayons les nœuds double comme ceci:

 #!/usr/bin/env perl use ssortingct; use warnings; use XML::Twig; my $twig = XML::Twig -> new; $twig -> parse ( \*DATA ); foreach my $double ( $twig -> get_xpath('//double') ) { print $double -> sortingmmed_text,"\n"; } __DATA__ <root> <subnode> <another_node> <double>1.2342</double> <double>2.3456</double> <some_other_tag>fish</some_other_tag> </another_node> </subnode> </root> 

Cela imprime:

 1.2342 2.3456 

Nous élargissons donc ceci:

 #!/usr/bin/env perl use ssortingct; use warnings; use XML::Twig; use Text::CSV; my $twig = XML::Twig->new; my $csv = Text::CSV->new; #open our results file open( my $output, ">", "results.csv" ) or die $!; #iterate each XML File. foreach my $filename ( glob("/path/to/xml/*.xml") ) { #parse it $twig->parsefile($filename); #extract all the text of all the 'double' elements. my @doubles = map { $_->sortingmmed_text } $twig->get_xpath('//double'); #print it as comma separated. $csv->print( $output, [ $filename, @doubles ] ); } close($output); 

Je pense que cela devrait faire l'affaire (sans données d'échantillon, je ne peux pas dire avec certitude). Mais notez – en utilisant un parsingur XML, nous ne nous trompons pas avec le reformatting XML qui peut être fait parfaitement valablement (comme selon la spécification XML). En utilisant un parsingur CSV, nous ne serons pas pris par des champs avec des virgules ou des sauts de ligne incorporés.

Si vous cherchez des nœuds plus spécifiques – vous pouvez spécifier un path plus détaillé. Comme il est, le ci-dessus cherche juste une instance de double . Mais vous pouvez utiliser:

 get_xpath("/root/subnode/another_node/double") 

Vous pouvez essayer ce liner unique pour chaque file. Les séparateurs multiples awk effectuent un fractionnement efficace et regroupent toutes les lignes dans la memory plutôt que sur le disque.

 for f in `ls *.xml` ; do echo $f,`grep double $f | awk -F '[<>]' '{print $3}' | tr '\n' ','`; done 

Je ne peux pas le profiler à ma fin – puisque je n'ai pas les mêmes données, mais j'ai l'intuition que cela devrait être plus rapide.

En dehors de cela, c'est le problème le plus facile à split et à régner – si vous avez access à plusieurs machines ou fermes, vous pouvez simplement split la tâche entière en plusieurs machines et finalement concaténer toutes les sorties dans un seul file. De cette façon, les limites de command line et la memory peuvent également être gérées.

Vous écrivez deux fois pour chaque file. C'est probablement la partie la plus chère. Vous voudrez plutôt essayer de garder le tout en memory, probablement dans un tableau. Puis écris une fois à la fin.

Regardez dans ulimit si vous commencez à atteindre les limites de memory. Si vous augmentez cette charge de travail à 10-100x, vous envisagez peut-être de 10 à 100 Go de memory. Vous pouvez mettre en lot cela dans une boucle qui fait-plusieurs-milliers par itération. Je ne sais pas si cela doit être un process reproductible, mais devenir plus sophistiqué si vous en avez besoin pour être plus rapide / plus robuste. Sinon, cousez à la main les lots par la suite.

Vous générez également plusieurs process par file – chaque canal que vous avez. Vous pouvez faire l'parsing complète / munging (grep / sed / tr) avec un seul process. Après le grep, Zsh peut gérer les autres traductions via des extensions (voir man zshexpn ). Ou, vous pourriez faire toute la ligne sed unique dans une invocation avec plusieurs expressions. sed peut être plus rapide si vous évitez le -r (regex étendu) et non-gourmandise. Votre grep pourrait simplement extraire les lignes correspondantes de plusieurs files à la fois, et écrire dans des files temporaires intermédiaires. Connaissez vos goulots d'étranglement, cependant, et ne réparez pas ce qui ne l'est pas.