从多个文件高效地将数据提取到单个 CSV 文件

从多个文件高效地将数据提取到单个 CSV 文件

我有大量具有相同结构的 XML 文件:

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

<double>其中每个 XML 文件中此类条目的数量是固定且已知的(在我的特定情况下,为 168)。

我需要构建一个csv文件,其中存储所有这些 XML 文件的内容,如下所示:

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

ETC。

我怎样才能有效地做到这一点?


我想出的最好的办法是:

#!/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

当我将上述脚本放在包含约 10K XML 文件的文件夹中时,我得到:

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

并不可怕,但我希望能够处理 100 倍或 1000 倍以上的文件。我怎样才能使这个处理更有效率?

另外,使用上面的解决方案,我是否会遇到全局扩展达到极限的情况,例如在处理数百万个文件时? (典型的"too many args"问题)。

更新

对于任何对此问题有兴趣的人,请阅读@mikeserve 的回答。它是迄今为止速度最快、扩展能力最好的一个。

答案1

这应该可以解决问题:

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

解释:

  • awk:使用该程序awk,我用GNU awk 4.0.1测试了它
  • -F '[<>]':使用<and>作为字段分隔符
  • NR!=1 && FNR==1{printf "\n"}: 如果不是整体的第一行 ( NR!=1) 而是文件的第一行 ( FNR==1) 则打印换行符
  • FNR==1{sub(".*/", "", FILENAME); sub(".xml$", "", FILENAME); printf FILENAME}:如果是文件的第一行,则删除文件名 ( ) 中最后一个/( ) 之前的所有内容,删除尾随( ) 并打印结果 ( )sub(".*/", "", FILENAME)FILENAME.xmlsub(".xml$", "", FILENAME)printf FILENAME
  • /double/{printf " %s", $3}如果一行包含“double”( /double/),则打印一个空格,后跟第三个字段 ( printf " %s", $3)。使用<>作为分隔符,这将是数字(第一个字段是第一个字段之前的任何内容<,第二个字段是double)。如果需要,您可以在此处设置数字格式。例如,通过使用%8.3f代替%s任何数字,将打印 3 位小数且总长度(包括点和小数位)至少为 8 位。
  • END{printf "\n"}:在最后一行之后打印一个额外的换行符(这可以是可选的)
  • $path_to_xml/*.xml: 文件列表
  • > final_table.csvfinal_table.csv:通过重定向输出将结果放入

如果出现“argument list to long”错误,您可以使用findwith 参数-exec来生成文件列表,而不是直接传递它:

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

解释:

  • find $path_to_xml: 告诉find列出文件$path_to_xml
  • -maxdepth 1: 不进入子文件夹$path_to_xml
  • -type f:仅列出常规文件(这也排除了$path_to_xml自身)
  • -name '*.xml': only list files that match the pattern*.xml`,需要引用它,否则 shell 将尝试扩展模式
  • -exec COMMAND {} +:使用COMMAND匹配文件作为参数来代替{}.+表示可以一次传递多个文件,从而减少分叉。如果使用\;(;需要加引号,否则由 shell 解释),而不是+为每个文件单独运行该命令。

您还可以xargs结合使用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

解释

  • -print0:输出由空字符分隔的文件列表
  • |(管道):将 的标准输出重定向find到 的标准输入xargs
  • xargs:从标准输入构建并运行命令,即为传递的每个参数(此处为文件名)运行命令。
  • -0:直接xargs假设参数由空字符分隔

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在更改文件时被调用(如果您的 awk 支持)。

答案2

关于全局扩展可能超出限制 - 是和否。 shell 已经在运行,所以它不会停止。但是,如果您要将整个全局数组作为参数传递给单个命令,那么是的,这是绝对可能的。处理这个问题的便携且强大的方法涉及find......

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

...这只会cat在当前目录中名称匹配的常规文件pattern,但也只会调用cat必要的次数以避免超出ARG_MAX

但事实上,既然你有 GNU,sed我们就可以几乎sed只需在一个脚本中完成整个事情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 - -

我想到了另一个办法。这会是非常.速度很快,但这绝对取决于每个文件恰好有 168 个匹配项,并且文件名中只能有一个点。

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

根据要求,以下是该命令如何工作的一些详细说明:

  1. ( ... )

    • 首先,整个小脚本在它自己的子 shell 中运行,因为我们将在执行过程中更改一些全局环境属性,这样当工作完成时我们会更改所有属性将恢复到其原始值 - 无论它们是什么。
  2. export LC_ALL=C; set '' - -
    • 通过将当前区域设置设置为,C我们可以节省过滤器的大量工作。在 UTF-8 语言环境中,任何字符都可能由一个或多个字节表示,并且需要从数千个可能的字符组中选择找到的任何字符。在 C 语言环境中,每个字符都是一个字节,并且只有 128 个。总体而言,它使字符匹配变得更快。
    • set语句更改 shell 的位置参数。执行set '' - -设置$1\0、和$2以及。$3-
  3. while ... set "$@$@"; done; shift ...
    • 基本上这个语句的重点是获得一个由 168 个破折号组成的数组。我们稍后将使用paste空格替换 167 个换行符的连续集合,同时保留第 168 个换行符。最简单的方法是给它 168 个对-stdin 的参数引用,并告诉它把所有这些粘贴在一起。
  4. find ... -exec grep -F '<double>' /dev/null' ...
    • find位之前已经讨论过,但是grep我们只打印那些可以与-F固定字符串匹配的行<double>。通过创建grep第一个参数/dev/null- 这是一个可以绝不匹配我们的字符串 - 我们确保grep每次调用始终搜索 2 个或更多文件参数。当使用 2 个或更多命名搜索文件调用时,grep将始终打印文件名,就像file_000.xml:在每个输出行的开头一样。
  5. tr \<: '>>'
    • 在这里,我们grep将 的输出中出现的每个:<字符转换为>
    • 此时,示例匹配线将如下所示./file_000.xml> >double>0.0000>/double>
  6. cut -d\> -f1,4
    • cut将从其输出中剥离在按字符划分的第一个或第四个字段中找不到的所有输入>
    • 此时,示例匹配线将如下所示./file_000.xml>0.0000
  7. paste -d\ "$@"
    • 已经讨论过,但这里我们paste以 168 为批次输入行。
    • 此时 168 条匹配的行同时出现,如下所示:./file_000.xml>0.000 .../file_000.xml>0.167
  8. sed 'h;s|./[^>]*>||g;x;s|\.xml.*||;s|..||;G;s|\n| |'
    • 现在,更快、更小的公用事业公司已经完成了大部分工作。在多核系统上,他们甚至可能同时完成这件事。还有那些公用事业 -尤其 cut并且它们所做的事情比我们使用更高级别的实用程序(例如,或者更糟糕的是,)paste进行的任何模拟尝试都要快得多。但我已经尽了我的想象,我可以做到这一点,我必须呼吁。sedawksed
    • 首先,我h旧了每个输入行的副本,然后我g全局删除模式空间中出现的每个模式./[^>]*>- 因此文件名的每个出现。此时sed的模式空间如下所示:0.000 0.0001...0.167
    • 然后我x更改h旧的和模式空间并删除所有内容\.xml.*- 因此从保存的行副本上的第一个文件名开始的所有内容。然后,我删除前两个字符 - 或者./也删除 - 此时模式空间看起来像file_000.
    • 所以剩下的就是将它们粘在一起。我G将旧空间的副本h附加到\newline 字符后面的模式空间,然后将ewlines///替换\n为空格。
    • 所以,最后,模式空间看起来像file_000 0.000...0.167。这就是sed每个文件写入输出的内容find传递给grep.

答案3

请代表未来的维护程序员和系统管理员 - 不要使用正则表达式来解析 XML。 XML 是一种结构化数据类型,它不太适合正则表达式解析 - 您可以通过假装它是纯文本来“伪造它”,但 XML 中存在大量语义相同的内容,但解析方式不同。例如,您可以嵌入换行符,并具有一元标签。

因此 - 使用解析器 - 我模拟了一些源数据,因为您的 XML 无效。给我一个更完整的样本,我会给你一个更完整的答案。

在基本层面上 - 我们double像这样提取节点:

#!/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> 

这打印:

1.2342
2.3456

所以我们扩展一下:

#!/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);

我认为这应该可以解决问题(没有样本数据,我不能肯定地说)。但请注意 - 通过使用 XML 解析器,我们不会遇到一些可以完全有效地完成的 XML 重新格式化(根据 XML 规范)。通过使用 CSV 解析器,我们不会被任何带有嵌入式逗号或换行符的字段所困扰。

如果您正在寻找更具体的节点 - 您可以指定更详细的路径。实际上,上面的代码只是查找 的任何实例double。但你可以使用:

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

答案4

您为每个文件写入两次。这可能是最昂贵的部分。相反,您会想要尝试将整个内容保留在内存中,可能是在数组中。然后最后写一次。

查看ulimit您是否开始达到内存限制。如果您将此工作负载增加到 10-100 倍,则可能需要 10-100 GB 内存。您可以在每次迭代执行数千次的循环中对其进行批处理。我不确定这是否需要是一个可重复的过程,但如果您需要它更快/更强大,请变得更加复杂。否则,随后手工缝合批次。

您还为每个文件生成多个进程——您拥有的每个管道。您可以使用单个进程完成整个解析/修改(grep/sed/tr)。在 grep 之后,Zsh 可以通过扩展处理其他翻译(请参阅 参考资料man zshexpn)。或者,您可以sed在一次调用中使用多个表达式执行所有单行操作。如果您避免(扩展正则表达式)和非贪婪,sed可能会更快。-rgrep可以一次从许多文件中提取匹配的行,然后写入中间临时文件。不过,要了解你的瓶颈,不要去解决那些没有解决的问题。

相关内容