正则表达式(C#)对于RFC 4180的CSV

che*_*rex 5 c# regex csv

需要通过规范RFC 4180的通用CSV解析器.有csv文件,包含规范的所有问题:

Excel打开文件,因为它在规范中编写:

任何人都在使用正则表达式进行解析吗?

CSV文件

"a
b
c","x
y
z",357
test; test,xxx; xxx,152
"test2,test2","xxx2,xxx2",123
"test3""test3","xxx3""xxx3",987
,qwe,13
asd,123
,,,,
123
,,, 123
123 ,,
123,123

预期成绩

表格由EXCEL提供

小智 6

注意: 虽然下面的解决方案可能适用于其他正则表达式引擎,但按原样使用它需要您的正则表达式引擎使用与单个捕获组相同的名称来处理多个命名捕获组。(.NET 默认执行此操作)


###关于模式 当 CSV 文件/流(匹配RFC 标准 4180 )的一行或多行/记录传递给下面的正则表达式时,它将返回每个非空行/记录的匹配项。每个匹配将包含一个名为的捕获组Value,该捕获组包含该行/记录中捕获的值OpenValue如果行/记录末尾有一个开放引号,则可能是一个捕获组)

这是注释模式(在 Regexstorm.net 上测试):

(?<=\r|\n|^)(?!\r|\n|$)                       // Records start at the beginning of line (line must not be empty)
(?:                                           // Group for each value and a following comma or end of line (EOL) - required for quantifier (+?)
  (?:                                         // Group for matching one of the value formats before a comma or EOL
    "(?<Value>(?:[^"]|"")*)"|                 // Quoted value -or-
    (?<Value>(?!")[^,\r\n]+)|                 // Unquoted value -or-
    "(?<OpenValue>(?:[^"]|"")*)(?=\r|\n|$)|   // Open ended quoted value -or-
    (?<Value>)                                // Empty value before comma (before EOL is excluded by "+?" quantifier later)
  )
  (?:,|(?=\r|\n|$))                           // The value format matched must be followed by a comma or EOL
)+?                                           // Quantifier to match one or more values (non-greedy/as few as possible to prevent infinite empty values)
(?:(?<=,)(?<Value>))?                         // If the group of values above ended in a comma then add an empty value to the group of matched values
(?:\r\n|\r|\n|$)                              // Records end at EOL
Run Code Online (Sandbox Code Playgroud)
这是没有所有注释或空格的原始模式。
(?<=\r|\n|^)(?!\r|\n|$)(?:(?:"(?<Value>(?:[^"]|"")*)"|(?<Value>(?!")[^,\r\n]+)|"(?<OpenValue>(?:[^"]|"")*)(?=\r|\n|$)|(?<Value>))(?:,|(?=\r|\n|$)))+?(?:(?<=,)(?<Value>))?(?:\r\n|\r|\n|$)
Run Code Online (Sandbox Code Playgroud)
[这是来自 Debuggex.com 的可视化][3](为清晰起见命名的捕获组): ![Debuggex.com 可视化][4]

###用法示例:

一次读取整个 CSV 文件/流的简单示例(在 C# Pad 上测试):(
为了获得更好的性能并减少对系统资源的影响,您应该使用第二个示例)

using System.Text.RegularExpressions;

Regex CSVParser = new Regex(
    @"(?<=\r|\n|^)(?!\r|\n|$)" +
    @"(?:" +
        @"(?:" +
            @"""(?<Value>(?:[^""]|"""")*)""|" +
            @"(?<Value>(?!"")[^,\r\n]+)|" +
            @"""(?<OpenValue>(?:[^""]|"""")*)(?=\r|\n|$)|" +
            @"(?<Value>)" +
        @")" +
        @"(?:,|(?=\r|\n|$))" +
    @")+?" +
    @"(?:(?<=,)(?<Value>))?" +
    @"(?:\r\n|\r|\n|$)",
    RegexOptions.Compiled);

String CSVSample =
    ",record1 value2,val3,\"value 4\",\"testing \"\"embedded double quotes\"\"\"," +
    "\"testing quoted \"\",\"\" character\", value 7,,value 9," +
    "\"testing empty \"\"\"\" embedded quotes\"," +
    "\"testing a quoted value" + Environment.NewLine +
    Environment.NewLine +
    "that includes CR/LF patterns" + Environment.NewLine +
    Environment.NewLine +
    "(which we wish would never happen - but it does)\", after CR/LF" + Environment.NewLine +
    Environment.NewLine +
    "\"testing an open ended quoted value" + Environment.NewLine +
    Environment.NewLine +
    ",value 2 ,value 3," + Environment.NewLine +
    "\"test\"";

MatchCollection CSVRecords = CSVParser.Matches(CSVSample);

for (Int32 recordIndex = 0; recordIndex < CSVRecords.Count; recordIndex++)
{
    Match Record = CSVRecords[recordIndex];

    for (Int32 valueIndex = 0; valueIndex < Record.Groups["Value"].Captures.Count; valueIndex++)
    {
        Capture c = Record.Groups["Value"].Captures[valueIndex];
        Console.Write("R" + (recordIndex + 1) + ":V" + (valueIndex + 1) + " = ");

        if (c.Length == 0 || c.Index == Record.Index || Record.Value[c.Index - Record.Index - 1] != '\"')
        {
            // No need to unescape/undouble quotes if the value is empty, the value starts
            // at the beginning of the record, or the character before the value is not a
            // quote (not a quoted value)
            Console.WriteLine(c.Value);
        }
        else
        {
            // The character preceding this value is a quote
            // so we need to unescape/undouble any embedded quotes
            Console.WriteLine(c.Value.Replace("\"\"", "\""));
        }
    }
    
    foreach (Capture OpenValue in Record.Groups["OpenValue"].Captures)
        Console.WriteLine("ERROR - Open ended quoted value: " + OpenValue.Value);
}
Run Code Online (Sandbox Code Playgroud)
读取大型 CSV 文件/流而不将整个文件/流读入字符串的更好示例(在 C# Pad 上测试它[6])。
using System.IO;
using System.Text.RegularExpressions;

// Same regex from before shortened to one line for brevity
Regex CSVParser = new Regex(
    @"(?<=\r|\n|^)(?!\r|\n|$)(?:(?:""(?<Value>(?:[^""]|"""")*)""|(?<Value>(?!"")[^,\r\n]+)|""(?<OpenValue>(?:[^""]|"""")*)(?=\r|\n|$)|(?<Value>))(?:,|(?=\r|\n|$)))+?(?:(?<=,)(?<Value>))?(?:\r\n|\r|\n|$)",
    RegexOptions.Compiled);

String CSVSample = ",record1 value2,val3,\"value 4\",\"testing \"\"embedded double quotes\"\"\",\"testing quoted \"\",\"\" character\", value 7,,value 9,\"testing empty \"\"\"\" embedded quotes\",\"testing a quoted value," + 
    Environment.NewLine + Environment.NewLine + "that includes CR/LF patterns" + Environment.NewLine + Environment.NewLine + "(which we wish would never happen - but it does)\", after CR/LF," + Environment.NewLine + Environment
    .NewLine + "\"testing an open ended quoted value" + Environment.NewLine + Environment.NewLine + ",value 2 ,value 3," + Environment.NewLine + "\"test\"";

using (StringReader CSVReader = new StringReader(CSVSample))
{
    String CSVLine = CSVReader.ReadLine();
    StringBuilder RecordText = new StringBuilder();
    Int32 RecordNum = 0;

    while (CSVLine != null)
    {
        RecordText.AppendLine(CSVLine);
        MatchCollection RecordsRead = CSVParser.Matches(RecordText.ToString());
        Match Record = null;
        
        for (Int32 recordIndex = 0; recordIndex < RecordsRead.Count; recordIndex++)
        {
            Record = RecordsRead[recordIndex];
        
            if (Record.Groups["OpenValue"].Success && recordIndex == RecordsRead.Count - 1)
            {
                // We're still trying to find the end of a muti-line value in this record
                // and it's the last of the records from this segment of the CSV.
                // If we're not still working with the initial record we started with then
                // prep the record text for the next read and break out to the read loop.
                if (recordIndex != 0)
                    RecordText.AppendLine(Record.Value);
                
                break;
            }
            
            // Valid record found or new record started before the end could be found
            RecordText.Clear();            
            RecordNum++;
            
            for (Int32 valueIndex = 0; valueIndex < Record.Groups["Value"].Captures.Count; valueIndex++)
            {
                Capture c = Record.Groups["Value"].Captures[valueIndex];
                Console.Write("R" + RecordNum + ":V" + (valueIndex + 1) + " = ");
                if (c.Length == 0 || c.Index == Record.Index || Record.Value[c.Index - Record.Index - 1] != '\"')
                    Console.WriteLine(c.Value);
                else
                    Console.WriteLine(c.Value.Replace("\"\"", "\""));
            }
            
            foreach (Capture OpenValue in Record.Groups["OpenValue"].Captures)
                Console.WriteLine("R" + RecordNum + ":ERROR - Open ended quoted value: " + OpenValue.Value);
        }
        
        CSVLine = CSVReader.ReadLine();
        
        if (CSVLine == null && Record != null)
        {
            RecordNum++;
            
            //End of file - still working on an open value?
            foreach (Capture OpenValue in Record.Groups["OpenValue"].Captures)
                Console.WriteLine("R" + RecordNum + ":ERROR - Open ended quoted value: " + OpenValue.Value);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)
两个示例都返回相同的结果:

R1:V1 =
R1:V2 = record1 value2
R1:V3 = val3
R1:V4 = value 4
R1:V5 = 测试“嵌入双引号”
R1:V6 = 测试引用的“,”字符
R1:V7 = value 7
R1:V8 =
R1:V9 = 值 9
R1:V10 = 测试空的 "" 嵌入引号R1:V11 = 测试 包含 CR/LF 模式的
引用值 (我们希望永远不会发生 - 但它确实发生) R1:V12 = CR/ 之后LF错误 - 开放式引用值:测试开放式引用值 ,值 2,值 3, R3:V1 = 测试









(请注意粗体“ERROR...”行表明开放式引用值 - testing an open ended quoted value- 已导致正则表达式匹配该值以及所有后续值,直到正确引用的"test"值为止,作为组中捕获的错误OpenValue


### 与我之前发现的其他正则表达式解决方案相比的关键功能:

  • 支持带有嵌入/转义引号的引用值。

  • 支持跨多行的带引号的值
    value1,"value 2 line 1 value 2 line 2",value3

  • 保留/捕获空值( RFC 标准 4180中未明确涵盖且被此正则表达式假定为错误的空行除外。这可以通过从正则表达式中删除第二组模式 - - 来更改)(?!\r|\n|$)

  • 行/记录可能以 CR+LF 或仅 CR 或 LF 结尾

  • 一次解析 CSV 的多行/记录,返回每条记录的匹配项以及记录中值的组(感谢 .NET 能够将多个值捕获到单个命名捕获组中)。

  • 将大部分解析逻辑保留在正则表达式本身中。您不需要将 CSV 传递给此正则表达式,然后检查代码中的条件 x、y 或 z 来获取实际值(下面的限制中突出显示的例外情况)。


###限制(解决方法需要正则表达式外部的应用程序逻辑)

  • 通过量化正则表达式中的值模式无法可靠地限制记录匹配。也就是说,使用类似(<value pattern>){10}(\r\n|\r|\n|$)替代的东西(<value pattern>)+?(\r\n|\r|\n|$)可能会将您的行/记录匹配限制为仅包含十个值的行/记录。但是,它也会强制模式尝试仅匹配十个值,即使这意味着将一个值拆分为两个值或在一个空值的空间中捕获九个空值来执行此操作。

  • 转义/双引号字符不是“未转义/未加倍”。

  • 具有开放式引号值(缺少结束引号)的记录/行仅支持用于调试目的。需要外部逻辑来确定如何通过对OpenValue捕获组执行额外的解析来更好地处理这种情况。

由于 RFC 标准中没有定义如何处理这种情况的规则,因此无论如何,这种行为都需要由应用程序定义。但是,我认为发生这种情况时正则表达式模式的行为非常好(捕获开放报价和下一个有效记录之间的所有内容作为开放值的一部分)。

注意:可以将该模式更改为更早失败(或根本不失败)并且不捕获后续值(例如通过OpenValue从正则表达式中删除捕获)。但是,通常这会导致其他错误的出现。


###Why?:我想在被问到之前解决一个常见问题 - “为什么你要花精力创建这个复杂的正则表达式模式,而不是使用更快、更好或其他什么的解决方案 X?”

我意识到有数百个正则表达式对此问题的答案,但我找不到一个能够满足我的高期望。问题中引用的RFC 标准 4180涵盖了大部分期望,但主要/附加的是捕获跨越多行的引用值,以及在需要时使用正则表达式解析多行/记录(或整个 CSV 内容)的能力,而不是而不是一次向正则表达式传递一行。

我还意识到大多数人正在放弃TextFieldParser或其他库(例如FileHelpers )的正则表达式方法来处理 CSV 解析。而且,那太好了 - 很高兴它对你有用。我选择不使用它们是因为:

  • (主要原因)我认为用正则表达式来做这件事是一个挑战,而且我喜欢一个很好的挑战。

  • TextFieldParser实际上达不到要求,因为它不处理文件中可能有引号或没有引号的字段某些 CSV 文件仅在需要时引用值以节省空间。(它可能在其他方面有所不足,但这一点让我什至无法尝试)

  • 我不喜欢依赖第三方库有几个原因,但主要是因为我无法控制它们的兼容性(即它是否与操作系统/框架 X 一起工作?)、安全漏洞或及时的错误修复和/或维护。


Wap*_*pac 3

我想说,忘记正则表达式吧。CSV 可以通过 TextFieldParser 类轻松解析。为此,您需要

using Microsoft.VisualBasic.FileIO;
Run Code Online (Sandbox Code Playgroud)

然后你就可以使用它:

  using (TextFieldParser parser = new TextFieldParser(Stream))
  {
    parser.TextFieldType = FieldType.Delimited;
    parser.SetDelimiters(",");

    while (!parser.EndOfData)
    {
      string[] fields = parser.ReadFields();
      foreach (string field in fields)
      {
         // Do your stuff here ...
      }
    }
  }
Run Code Online (Sandbox Code Playgroud)