使用 awk 有效解析 CSV 的最可靠方法是啥?

Posted

技术标签:

【中文标题】使用 awk 有效解析 CSV 的最可靠方法是啥?【英文标题】:What's the most robust way to efficiently parse CSV using awk?使用 awk 有效解析 CSV 的最可靠方法是什么? 【发布时间】:2018-01-07 07:12:16 【问题描述】:

这个问题的目的是提供一个规范的答案。

假设 CSV 可能由 Excel 或其他工具生成,在字段中嵌入换行符和/或双引号和/或逗号,以及空字段,例如:

$ cat file.csv
"rec1, fld1",,"rec1"",""fld3.1
"",
fld3.2","rec1
fld4"
"rec2, fld1.1

fld1.2","rec2 fld2.1""fld2.2""fld2.3","",rec2 fld4
"""""","""rec3,fld2""",

有效地使用 awk 来识别单独的记录和字段的最稳健的方法是什么:

Record 1:
    $1=<rec1, fld1>
    $2=<>
    $3=<rec1","fld3.1
",
fld3.2>
    $4=<rec1
fld4>
----
Record 2:
    $1=<rec2, fld1.1

fld1.2>
    $2=<rec2 fld2.1"fld2.2"fld2.3>
    $3=<>
    $4=<rec2 fld4>
----
Record 3:
    $1=<"">
    $2=<"rec3,fld2">
    $3=<>
----

因此它可以在 awk 脚本的其余部分内部用作这些记录和字段。

有效的 CSV 是符合 RFC 4180 或可由 MS-Excel 生成的。

解决方案必须容忍记录的结尾只是 LF (\n),这是 UNIX 文件的典型特征,而不是 CRLF (\r\n),因为该标准需要 Excel 或其他 Windows 工具生成。它还将容忍未引用的字段与引用的字段混合。它特别不需要容忍使用前面的反斜杠(即\"而不是"")转义"s,因为其他一些CSV格式允许 - 如果你有这个,那么在前面添加一个gsub(/\\"/,"\"\"")可以处理它并且试图在一个脚本中自动处理这两种转义机制会使脚本变得不必要地脆弱和复杂。

【问题讨论】:

另见***.com/questions/1560393/… 【参考方案1】:

如果您的 CSV 不能包含换行符,那么您只需要(使用 GNU awk for FPAT):

$ echo 'foo,"field,""with"",commas",bar' |
    awk -v FPAT='[^,]*|("([^"]|"")*")' 'for (i=1; i<=NF;i++) print i, "<" $i ">"'
1 <foo>
2 <"field,""with"",commas">
3 <bar>

如果您真正想要做的只是将您的 CSV 转换为单独的行,例如,在带引号的字段中用空格替换换行符,用分号替换逗号,那么您所需要的就是这个,再次使用 GNU awk 进行多字符 RS和RT:

$ awk -v RS='"([^"]|"")*"' -v ORS= 'gsub(/\n/," ",RT); gsub(/,/,";",RT); print $0 RT' file.csv
"rec1; fld1",,"rec1"";""fld3.1 ""; fld3.2","rec1 fld4"
"rec2; fld1.1  fld1.2","rec2 fld2.1""fld2.2""fld2.3","",rec2 fld4
"""""","""rec3;fld2""",

不过,识别适用于任何现代 awk* 的字段的通用、稳健、便携的解决方案是:

$ cat decsv.awk
function buildRec(      fpat,fldNr,fldStr,done) 
    CurrRec = CurrRec $0
    if ( gsub(/"/,"&",CurrRec) % 2 ) 
        # The string built so far in CurrRec has an odd number
        # of "s and so is not yet a complete record.
        CurrRec = CurrRec RS
        done = 0
    
    else 
        # If CurrRec ended with a null field we would exit the
        # loop below before handling it so ensure that cannot happen.
        # We use a regexp comparison using a bracket expression here
        # and in fpat so it will work even if FS is a regexp metachar
        # or a multi-char string like "\\\\" for \-separated fields.
        CurrRec = CurrRec ( CurrRec ~ ("[" FS "]$") ? "\"\"" : "" )
        $0 = ""
        fpat = "([^" FS "]*)|(\"([^\"]|\"\")+\")"
        while ( (CurrRec != "") && match(CurrRec,fpat) ) 
            fldStr = substr(CurrRec,RSTART,RLENGTH)
            # Convert <"foo"> to <foo> and <"foo""bar"> to <foo"bar>
            if ( gsub(/^"|"$/,"",fldStr) ) 
                gsub(/""/, "\"", fldStr)
            
            $(++fldNr) = fldStr
            CurrRec = substr(CurrRec,RSTART+RLENGTH+1)
        
        CurrRec = ""
        done = 1
    
    return done


# If your input has \-separated fields, use FS="\\\\"; OFS="\\"
BEGIN  FS=OFS="," 
!buildRec()  next 

    printf "Record %d:\n", ++recNr
    for (i=1;i<=NF;i++) 
        # To replace newlines with blanks add gsub(/\n/," ",$i) here
        printf "    $%d=<%s>\n", i, $i
    
    print "----"

.

$ awk -f decsv.awk file.csv
Record 1:
    $1=<rec1, fld1>
    $2=<>
    $3=<rec1","fld3.1
",
fld3.2>
    $4=<rec1
fld4>
----
Record 2:
    $1=<rec2, fld1.1

fld1.2>
    $2=<rec2 fld2.1"fld2.2"fld2.3>
    $3=<>
    $4=<rec2 fld4>
----
Record 3:
    $1=<"">
    $2=<"rec3,fld2">
    $3=<>
----

以上假设 UNIX 行结尾为 \n。使用 Windows \r\n 换行符更简单,因为每个字段中的“换行符”实际上只是换行符(即\ns),因此您可以设置RS="\r\n"(使用 GNU awk 进行多字符 RS)和那么字段中的\ns 将不会被视为行尾。

只要遇到RS,它就通过简单地计算当前记录中存在多少"来工作 - 如果它是一个奇数,那么RS(大概是\n,但没有to be) 是中场,因此我们会继续构建当前记录,但如果是这样,那么它就是当前记录的结尾,因此我们可以继续脚本的其余部分处理现在完整的记录。

*我在上面说“现代 awk”是因为 tawk 和 mawk1 显然仍然非常旧(即大约 2000 年)版本在其gsub() 实现中存在错误,因此gsub(/^"|"$/,"",fldStr) 不会删除开始/结束@ 987654340@s 来自fldStr。如果您正在使用其中之一,那么获得一个新的 awk,最好是 gawk, 因为它们也可能存在其他问题,但如果这不是一个选项,那么我希望您可以通过以下方式解决该特定错误改变这个:

        if ( gsub(/^"|"$/,"",fldStr) ) 

到这里:

        if ( sub(/^"/,"",fldStr) && sub(/"$/,"",fldStr) ) 

感谢以下人员使用此答案的原始版本确定并建议解决所述问题:

    @mosvy 用于字段中的转义双引号。 @datatraveller1 用于字段中的多对连续转义引号和记录末尾的空字段。

相关:另请参阅How do I use awk under cygwin to print fields from an excel spreadsheet?,了解如何从 Excel 电子表格生成 CSV。

【讨论】:

【参考方案2】:

对@EdMorton 的FPAT 解决方案的改进,它应该能够处理双引号(") 通过加倍("" - CSV 所允许的standard) 转义。

gawk -v FPAT='[^,]*|("[^"]*")+' ...

还是这样

    无法处理带引号的字段中的换行符,这在标准 CSV 文件中是完全合法的。

    假设 GNU awk (gawk),标准的 awk 不行。

例子:

$ echo 'a,,"","y""ck","""x,y,z"," ",12' |
gawk -v OFS='|' -v FPAT='[^,]*|("[^"]*")+' '$1=$11'
a||""|"y""ck"|"""x,y,z"|" "|12

$ echo 'a,,"","y""ck","""x,y,z"," ",12' |
gawk -v FPAT='[^,]*|("[^"]*")+' '
  for(i=1; i<=NF;i++)
    if($i~/"/) $i = substr($i, 2, length($i)-2); gsub(/""/,"\"", $i) 
    print "<"$i">"
  
'
<a>
<>
<>
<y"ck>
<"x,y,z>
< >
<12>

【讨论】:

这是一个很好的 FPAT,我无法想象它会允许任何无效的情况 - 你应该建议 gawk 人更新 their FPAT documentation 以使用它而不是 FPAT = "([^,]*)|(\"[^\"]+\"),如底部记录的那个部分,我用过。 gawk 手册已更新,a new section 解决了这个问题,现在提供了一个不同的FPAT,它还可以处理包含逗号和转义引号的字段,所以我更新了我的答案以使用它,FPAT='([^,]*)|("([^"]|"")*")',为了与文档保持一致。谢谢。【参考方案3】:

这正是 csvquote 的用途 - 它使 awk 和其他命令行数据处理工具变得简单。

有些东西很难用 awk 表达。不是运行单个 awk 命令并尝试让 awk 处理带有嵌入逗号和换行符的引用字段,而是通过 csvquote 为 awk 准备数据,以便 awk 始终可以将它找到的逗号和换行符解释为字段分隔符和记录分隔符.这使得管道的 awk 部分更简单。 awk 处理完数据后,它会返回 csvquote -u 以恢复引用字段中嵌入的逗号和换行符。

csvquote file.csv | awk -f my_awk_script | csvquote -u

【讨论】:

请edit 你的答案包含csvquote file.csv 的输出,这样我们就可以从我的问题中看到它对CSV 的确切作用。还要添加csvquote file.csv | csvquote -u 的输出,这样我们就可以看到它是否在原样地重现了输入。

以上是关于使用 awk 有效解析 CSV 的最可靠方法是啥?的主要内容,如果未能解决你的问题,请参考以下文章

在 C++ 中从文件末尾读取的最有效方法是啥? (解析文件中的最后 128 位)

(预)处理存储在 json 中的大型数据集的最有效方法是啥?

检查浮点变量是不是为整数的最可靠方法是啥?

在 JavaScript 中隐藏/欺骗引荐来源网址的最可靠方法是啥?

在 C++ 中禁止复制构造函数的最可靠方法是啥?

在 Laravel 5.2 中捕获保存和删除的最可靠方法是啥?