Extração de dados eficiente de vários arquivos para um único arquivo CSV

3

Eu tenho uma grande coleção de arquivos XML com a mesma estrutura exata:

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

em que o número de tais entradas <double> em cada arquivo XML é fixo e conhecido (no meu caso particular, 168).

Eu preciso criar um único arquivo csv com o conteúdo de todos esses arquivos XML armazenados da seguinte forma:

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

etc.

Como posso fazer isso com eficiência?

O melhor que eu tenho é o seguinte:

#!/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]*).*?//' | \
    tr '\n' ' ' | sed -e '$a\'  |  >> table_numbers.csv

    echo ${x:t} >> file_IDs.csv
done

paste file_IDs table_numbers.csv > final_table.csv

Quando eu tempo o script acima em uma pasta com arquivos XML ~ 10K eu recebo:

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

não é terrível, mas espero trabalhar com 100x ou 1000x mais arquivos. Como posso tornar esse processamento mais eficiente?

Além disso, com a minha solução acima, eu poderia acabar em uma situação em que a expansão glob atinge um limite, por exemplo, ao trabalhar com milhões de arquivos? (o típico problema "too many args" ).

Atualizar

Para qualquer pessoa interessada em uma ótima solução para esse problema, leia a resposta do @mikeserve. É o mais rápido e o que melhor se expande de longe.

    
por Amelio Vazquez-Reina 23.07.2015 / 02:01

5 respostas

4

Em relação à expansão glob possivelmente excedendo um limite - sim e não. A concha já está funcionando e não vai parar. Mas se você passasse toda a matriz globbed como argumentos para um único comando, então sim, essa é uma possibilidade definida. A maneira portátil e robusta de lidar com isso envolve find ...

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

... que só irá cat dos arquivos regulares no diretório atual com um nome que corresponda a pattern , mas também invocará apenas cat quantas vezes forem necessárias para evitar excedendo ARG_MAX .

Na verdade, como você tem um GNU sed , nós podemos quase fazer a coisa toda com apenas sed em um script find .

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

Eu pensei em outro jeito. Isso será muito rápido, mas isso depende absolutamente de haver exatamente 168 correspondências por arquivo, e só pode haver um . dot nos nomes de arquivo.

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

Conforme solicitado, aqui está um pequeno resumo de como esse comando funciona:

  1. ( ... )

    • Em primeiro lugar, todo o pequeno script é executado dentro de sua própria sub-camada porque existem algumas propriedades ambientais globais que estaremos alterando no decorrer de sua execução, e assim, quando o trabalho é executado, todas as as propriedades que alteramos serão restauradas para seus valores originais - quaisquer que fossem elas.
  2. %código%
    • Ao definir a localidade atual como export LC_ALL=C; set '' - - , podemos economizar muito esforço em nossos filtros. Em um código de idioma UTF-8, qualquer caractere pode ser representado por um ou vários bytes por peça, e qualquer caractere encontrado precisará ser selecionado de um grupo de muitos milhares de possíveis. Na localidade C, cada caractere é um único byte e há apenas 128 deles. Isso faz com que o char corresponda a um assunto muito mais rápido no geral.
    • A instrução C altera os parâmetros posicionais do shell. Fazendo set conjuntos set '' - - a $1 e $2 e $3 a - .
  3. %código%
    • Basicamente, o objetivo dessa declaração é obter uma matriz de 168 traços. Usaremos while ... set "$@$@"; done; shift ... depois para substituir conjuntos sequenciais de 167 novas linhas por espaços, preservando o 168º. A maneira mais simples de fazer isso é dar 168 referências de argumento para paste stdin e dizer para colar todas juntas.
  4. %código%
    • O - bit foi discutido anteriormente, mas com find ... -exec grep -F '<double>' /dev/null' ... imprimimos apenas as linhas que podem ser comparadas com a find ixed string grep . Ao tornar o primeiro argumento -F <double> - que é um arquivo que pode nunca corresponder a nossa string - garantimos que grep esteja sempre pesquisando 2 ou mais argumentos de arquivo para cada chamada. Quando invocado com 2 ou mais arquivos de pesquisa nomeados, /dev/null sempre imprimirá o nome do arquivo como grep na cabeça de cada linha de saída.
  5. %código%
    • Aqui, traduzimos cada ocorrência na saída de grep dos caracteres file_000.xml: ou tr \<: '>>' para grep .
    • Nesse ponto, uma amostra da linha correspondente será parecida com : .
  6. %código%
    • < removerá de sua saída toda a sua entrada que não pode ser encontrada no primeiro ou no quarto campo dividido por > chars.
    • Nesse ponto, uma amostra da linha correspondente será parecida com ./file_000.xml> >double>0.0000>/double> .
  7. %código%
    • Já discutimos, mas aqui estamos cut -d\> -f1,4 linhas de entrada em lotes de 168.
    • Nesse momento, 168 linhas correspondentes ocorrem juntas, como: cut
  8. %código%
    • Agora, os utilitários mais rápidos e menores já fizeram a maior parte do trabalho. Em um sistema multicore, eles provavelmente até fizeram isso concorrentemente. E esses utilitários - especialmente > e ./file_000.xml>0.0000 são muito mais rápidos no que eles fazem do que qualquer tentativa de emulação que poderíamos fazer com utilitários de nível superior como paste -d\ "$@" ou, pior ainda,% código%. Mas eu levei isso até onde eu posso imaginar até agora, e eu tenho que chamar paste .
    • Primeiro, eu ./file_000.xml>0.000 .../file_000.xml>0.167 old uma cópia de cada linha de entrada, então eu sed 'h;s|./[^>]*>||g;x;s|\.xml.*||;s|..||;G;s|\n| |' lobally remove todas as ocorrências do padrão cut no espaço de padrão - portanto, toda ocorrência do nome do arquivo. Neste ponto, o espaço padrão de paste é semelhante a: sed
    • Em seguida, eu e awk change sed espaços antigos e padrão e removo tudo de h on - então tudo a partir do primeiro nome de arquivo na cópia salva da linha. Eu então retiro os dois primeiros caracteres - ou g também - e neste ponto o espaço padrão parece com ./[^>]*> .
    • Então tudo o que resta é colocá-los juntos. Eu sed et uma cópia de 0.000 0.0001...0.167 old espaço acrescentado ao espaço padrão seguindo um caractere x ewline, então eu h ubstitute o \.xml.* ewline para um espaço.
    • Por fim, o espaço padrão é semelhante a ./ . E é isso que file_000 grava na saída para cada arquivo G passa para h .
por 23.07.2015 / 06:19
5

Isso deve funcionar:

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

Explicação:

  • awk : use o programa awk , eu testei com o GNU awk 4.0.1
  • -F '[<>]' : use < e > como separadores de campo
  • NR!=1 && FNR==1{printf "\n"} : se não for a primeira linha geral ( NR!=1 ), mas a primeira linha de um arquivo ( FNR==1 ) imprime uma nova linha
  • FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} : se for a primeira linha de um arquivo, remova tudo até o último / ( sub(".*/", "", FILENAME) ) no nome do arquivo ( FILENAME ), retire um .xml (% co_de) à direita %) e imprime o resultado ( sub(".xml$", "", FILENAME) )
  • printf FILENAME se uma linha contiver "duplo" ( /double/{printf " %s", $3} ), imprima um espaço seguido pelo terceiro campo ( /double/ ). Usando printf " %s", $3 e < como separadores, esse seria o número (com o primeiro campo sendo qualquer coisa antes do primeiro > e o segundo campo sendo < ). Se você quiser, você pode formatar os números aqui. Por exemplo, usando double em vez de %8.3f , qualquer número será impresso com 3 casas decimais e um comprimento total (incluindo casas e casas decimais) de pelo menos 8.
  • END {printf "\ n"}: depois da última linha, imprima uma nova linha adicional (isso pode ser opcional)
  • %s : a lista de arquivos
  • $path_to_xml/*.xml : coloque o resultado em > final_table.csv redirecionando a saída

No caso de erros "list list to long", você pode usar final_table.csv com o parâmetro find para gerar uma lista de arquivos em vez de transmiti-la diretamente:

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

Explicação:

  • -exec : informe find $path_to_xml para listar arquivos em find
  • $path_to_xml : não desça em subpastas de -maxdepth 1
  • $path_to_xml : lista apenas arquivos regulares (isso também exclui -type f )
  • $path_to_xml *. xml ', isso precisa ser citado senão o shell tentará expandir o padrão
  • -name '*.xml': only list files that match the pattern : execute o comando -exec COMMAND {} + com os arquivos correspondentes como parâmetros no lugar de COMMAND . {} indica que vários arquivos podem ser passados de uma vez, o que reduz o bifurcação. Se você usar + ( \; precisa ser citado senão é interpretado pelo shell) em vez de ; , o comando é executado para cada arquivo separadamente.

Você também pode usar + em conjunto com xargs :

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

Explicação

  • find : lista de saída de arquivos separados por caracteres nulos
  • -print0 (canal): redireciona a saída padrão de | para a entrada padrão de find
  • xargs : constrói e executa comandos a partir da entrada padrão, ou seja, executa um comando para cada argumento (nomes de arquivos aqui) passados.
  • xargs : direct -0 para assumir que os argumentos são separados por caracteres nulos
awk -F '[<>]' '      
      BEGINFILE {sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME} 
      /double/{printf " %s", $3}
      ENDFILE {printf "\n"}
    ' $path_to_xml/*.xml > final_table.csv

onde xargs , BEGINFILE são chamados ao alterar o arquivo (se o seu awk o suportar).

    
por 23.07.2015 / 13:22
2

Por favor, em nome de futuros programadores de manutenção e sysadmins - NÃO use um regex para analisar XML. XML é um tipo de dados estruturado, e NÃO é adequado para a análise de expressões regulares - você pode 'fingir' fingindo ser texto simples, mas há um monte de coisas semanticamente idênticas em XML que não analisam o mesmo. Você pode incorporar feeds de linha e ter tags unários, por exemplo.

Assim - use um analisador - fiz um mock up de alguns dados de origem, porque o seu XML não é válido. Dê-me uma amostra mais completa e eu lhe darei uma resposta mais completa.

Em um nível básico - extraímos os nós double assim:

#!/usr/bin/env perl

use strict;
use warnings;
use XML::Twig;

my $twig = XML::Twig -> new;
$twig -> parse ( \*DATA ); 

foreach my $double ( $twig -> get_xpath('//double') ) {
   print $double -> trimmed_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> 

Isto imprime:

1.2342
2.3456

Então, expandimos isso:

#!/usr/bin/env perl

use strict;
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 { $_->trimmed_text } $twig->get_xpath('//double');
    #print it as comma separated. 
    $csv->print( $output, [ $filename, @doubles ] );

}
close($output);

Acho que isso deve funcionar (sem dados de amostra, não posso dizer com certeza). Mas note - ao usar um analisador de XML, não nos deparamos com parte da reformatação de XML que pode ser feita perfeitamente (de acordo com a especificação XML). Ao usar um analisador de CSV, não seremos pegos por nenhum campo com vírgulas ou feeds de linha incorporados.

Se você estiver procurando por nós mais específicos, poderá especificar um caminho mais detalhado. Como está, o acima apenas procura por qualquer instância de double . Mas você pode usar:

get_xpath("/root/subnode/another_node/double")
    
por 07.08.2015 / 14:07
0

Você pode tentar este single liner para cada arquivo. O delimitador múltiplo do awk realiza uma divisão eficiente e concha todas as linhas na memória, em vez de no disco.

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

Eu não consigo criar um perfil disso no meu final - já que não tenho os mesmos dados, mas meu palpite é que deveria ser mais rápido.

Além disso, esse é o problema mais fácil de dividir e governar - se você tiver acesso a várias máquinas ou farms, poderá dividir a tarefa inteira em várias máquinas e, finalmente, conciliar todas as saídas em um arquivo. Desta forma, os limites de linha de comando e a memória também podem ser gerenciados.

    
por 23.07.2015 / 06:11
-1

Você está escrevendo duas vezes para cada arquivo. Esta é provavelmente a parte mais cara. Em vez disso, você tentará manter a coisa toda na memória, provavelmente em uma matriz. Então escreva uma vez no final.

Procure em ulimit se você começar a atingir os limites de memória. Se você está aumentando essa carga de trabalho para 10 a 100 vezes, está vendo talvez de 10 a 100 GB de memória. Você poderia agrupar isso em um loop que faz muitos milhares por iteração. Não tenho certeza se isso precisa ser um processo repetitivo, mas fique mais sofisticado se você precisar que ele seja mais rápido / robusto. Caso contrário, costure manualmente os lotes depois.

Você também gera vários processos por arquivo - todos os canais que você tem. Você poderia fazer todo o parsing / munging (grep / sed / tr) com um único processo. Após o grep, o Zsh pode manipular as outras traduções através de expansões (veja man zshexpn ). Ou você poderia fazer toda a única linha sed em uma chamada com várias expressões. sed pode ser mais rápido se você evitar o -r (regex estendido) e a não-avidez. Seu grep poderia simplesmente extrair linhas correspondentes de muitos arquivos de uma só vez e gravar em arquivos temporários intermediários. Conhece seus gargalos e não conserta o que não é.

    
por 23.07.2015 / 05:54