正则表达式(C#)对于RFC 4180的CSV
需要通过规范RFC 4180的通用CSV解析器。 有csv文件,包含规范的所有问题:
Excel按照规范中的说明打开文件:
任何人都在使用正则表达式进行解析吗?
CSV文件
“一个
b
C “中,” x
ÿ
Z”,357
试验;试验,XXX; XXX,152
“TEST2,TEST2”, “XXX2,XXX2”,123
“TEST3” “TEST3”, “XXX3” “XXX3”,987
,QWE,13
ASD,123,
,,
,123,
,, 123
123 ,,
123123
预期成绩
我会说,忘掉正则表达式。 TextFieldParser类可以轻松解析CSV。 要做到这一点,你需要做到
using Microsoft.VisualBasic.FileIO;
然后你可以使用它:
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 ... } } }
注意:虽然下面的解决方案可能适用于其他正则表达式引擎,但按原样使用它将要求您的正则表达式引擎使用与单个捕获组相同的名称处理多个命名捕获组。 (.NET默认执行此操作)
关于模式
当CSV文件/流(匹配RFC标准4180 )的一行或多行/记录传递到下面的正则表达式时,它将为每个非空行/记录返回匹配。 每个匹配将包含一个名为Value
的捕获组,其中包含该行/记录中捕获的值(如果行/记录OpenValue
有一个打开的引号,则可能包含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 "(?(?:[^"]|"")*)"| // Quoted value -or- (? (?!")[^,\r\n]+)| // Unquoted value -or- "(?(?:[^"]|"")*)(?=\r|\n|$)| // Open ended quoted value -or- (?) // 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) (?:(?<=,)(? ))? // 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
这是没有所有注释或空格的原始模式。
(?<=\r|\n|^)(?!\r|\n|$)(?:(?:"(?(?:[^"]|"")*)"|(? (?!")[^,\r\n]+)|"(?(?:[^"]|"")*)(?=\r|\n|$)|(?))(?:,|(?=\r|\n|$)))+?(?:(?<=,)(? ))?(?:\r\n|\r|\n|$)
以下是Debuggex.com的可视化信息 (为了清晰起见,命名为捕获组):
用法示例:
一次读取整个CSV文件/流的简单示例( 在C#Pad上测试):
(为了获得更好的性能并减少对系统资源的影响,您应该使用第二个示例)
using System.Text.RegularExpressions; Regex CSVParser = new Regex( @"(?<=\r|\n|^)(?!\r|\n|$)" + @"(?:" + @"(?:" + @"""(?(?:[^""]|"""")*)""|" + @"(? (?!"")[^,\r\n]+)|" + @"""(?(?:[^""]|"""")*)(?=\r|\n|$)|" + @"(?)" + @")" + @"(?:,|(?=\r|\n|$))" + @")+?" + @"(?:(?<=,)(? ))?" + @"(?:\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); }
读取大型CSV文件/流而不将整个文件/流读入字符串( 在C#Pad上测试)的更好示例。
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|$)(?:(?:""(?(?:[^""]|"""")*)""|(? (?!"")[^,\r\n]+)|""(?(?:[^""]|"""")*)(?=\r|\n|$)|(?))(?:,|(?=\r|\n|$)))+?(?:(?<=,)(? ))?(?:\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); } } }
两个示例都返回相同的结果:
R1:V1 =
R1:V2 = record1 value2
R1:V3 = val3
R1:V4 =值4
R1:V5 =测试“嵌入式双引号”
R1:V6 =测试引用“,”字符
R1:V7 =值7
R1:V8 =
R1:V9 =值9
R1:V10 =测试空“”嵌入式引号
R1:V11 =测试报价值包括CR / LF模式
(我们希望永远不会发生 - 但确实如此)
R1:V12 = CR / LF后
错误 - 开放式报价值: 测试开放式报价值,值2,值3,
R3:V1 =测试
(注意粗体“ERROR ...”行,certificate开放式引用值 - testing an open ended quoted value
- 导致正则表达式匹配该值,并且所有后续值都被正确引用的"test"
值,作为OpenValue
组中捕获的错误)
在此之前我发现的其他正则表达式解决方案的主要特点:
-
支持嵌入/转义引号的引用值。
-
支持跨越多行的引用值
value1,"value 2 line 1 value 2 line 2",value3
-
保留/捕获空值(除了GB标准4180中未明确涵盖的空行,并且假定此正则表达式出错。这可以通过删除第二组模式来更改 -
(?!\r|\n|$)
- 来自正则表达式) -
行/记录可以以CR + LF结尾或仅以CR或LF结尾
-
解析CSV的多行/记录,同时为记录中的值返回每个记录和组的匹配(由于.NET能够将多个值捕获到单个命名的捕获组中)。
-
保留正则表达式本身的大部分解析逻辑。 您不需要将CSV传递给此正则表达式,然后检查代码中的条件x,y或z以获取实际值(在下面的限制中突出显示exception)。
限制(变通办法需要正则表达式外部的应用程序逻辑) :
-
通过量化正则表达式中的值模式,无法可靠地限制记录匹配。 也就是说,使用像
(
而不是){10}(\r\n|\r|\n|$) (
可能会将您的行/记录匹配限制为仅包含十个值的那些。 但是,它也会强制模式尝试仅匹配十个值,即使这意味着将一个值拆分为两个值或在一个空值的空间中捕获九个空值来执行此操作。)+?(\r\n|\r|\n|$) -
转义/加倍引号字符不是“未转义/未加倍”。
-
具有开放式引用值的记录/行(缺少结束引用)仅支持用于调试目的。 需要外部逻辑来确定如何通过在
OpenValue
捕获组上执行额外的解析来更好地处理这种情况。由于RFC标准中没有定义如何处理这种情况的规则,因此无论如何都需要由应用程序定义此行为。 但是,我认为当发生这种情况时正则表达式模式的行为非常好(捕获开放引号和下一个有效记录之间的所有内容作为开放值的一部分)。
注意:模式可以更改为更早失败(或根本不失败),也不会捕获后续值(例如,从正则表达式中删除
OpenValue
捕获)。 但是,通常这会导致其他错误突然出现。
为什么?:
我想在被问到之前解决一个常见的问题 - “你为什么要努力创建这个复杂的正则表达式模式,而不是使用更快,更好或者更好的解决方案X?”
我意识到有数百个正则表达式的答案,但我找不到一个符合我的高期望的答案。 大多数这些期望都包含在问题中引用的RFC标准4180中,但主要/另外是捕获跨越多行的引用值以及使用正则表达式解析多行/记录(或整个CSV内容)的能力,如果需要的话而不是一次一行地传递给正则表达式。
我也意识到大多数人都放弃了 TextFieldParser或其他库(例如FileHelpers ) 的正则表达式方法来处理CSV解析。 而且,这很棒 - 很高兴它对你有用。 我选择不使用它们,因为:
-
(主要原因)我认为在正则表达式中做这个是一个挑战,我喜欢一个很好的挑战。
-
TextFieldParser实际上达不到要求,因为它不处理文件中可能有或没有引号的字段。 某些CSV文件仅在需要时引用值以节省空间。 (在其他方面可能会达不到,但是那个让我无法尝试)
-
我不喜欢依赖于第三方库 ,原因有几个,但主要是因为我无法控制它们的兼容性(即它是否适用于OS /框架X?),安全漏洞,或及时的错误修正和/或维护。