花点时间,写了个CSV解析器
前段时间学习了下编译原理,凑巧的是,同事有解析 CSV 格式文件的需求,然后我就花了点时间,写了个 CSV 解析器,这里分享出来。
本次主要内容有:
- CSV 格式文件定义
- 描述 CSV 格式
- 接口定义
- 解析实现
- 单元测试
1. CSV 格式文件定义
根据 RFC4184,将 CSV 格式定义如下:
1. 每条记录用换行符分割,换行符定义为 CRLF(\r\n)。例如:
aaa,bbb,ccc CRLF zzz,yyy,xxx CRLF
2. 文件最后一行可以有换行,也可以没有,如:
aaa,bbb,ccc CRLF zzz,yyy,xxx
3. 文件可以选择性的在第一行指定一行和其他记录相同格式的标题行。这一行标题需要包含和其他记录相同的列,并且按照其他记录相同的顺序指定列的名称。有没有标题行应该可以通过参数指定。如:
field_name,field_name,field_name CRLF aaa,bbb,ccc CRLF zzz,yyy,xxx CRLF
4. 对于标题行和其他的所有记录行,可能会有一个或多个用逗号(',')分隔的字段。在文件中,所有的记录都应该有相同的字段数量。空白字符应该也是字段的一部分,不应该被忽略。最后一个字段不可以添加逗号。如:
aaa,bbb,ccc
5. 字段可以被双括号(")括起来,也可以不使用双括号括起来。如果一个字段没有被双括号括起来,那么双括号就不能出现在字段值中。如:
"aaa","bbb","ccc" CRLF zzz,yyy,xxx
6. 包含换行(CRLF),双引号('"')和逗号(',')的字段,必须使用双引号括起来,如:
"aaa","b CRLF bb","ccc" CRLF zzz,yyy,xxx
7. 如果双引号在字段值中出现,则需要使用另一个前导双引号对它进行转义,如:
"aaa","b""bb","ccc"
2. 描述 CSV 格式
在RFC4180中,已经对 CSV 格式使用了 ABNF 进行描述,这里不再重复。我们在这里使用有穷状态机进行描述如下:
在这里,是读取一个字段的状态转换图,说明如下:
-
开始读取的时候,我们读取到任何不在 '"', ',', LRLF 和 EOF 中的字符,均将其存储,并继续读取下一个字符;
-
如果读取到了一个双引号,说明下面一个记录应该是一个字符串,所以我们转换到 readString 状态一直读取到一个双引号位置,说明下一个字符可能是一个转换字符,所以转换到 readEscape 状态,如果在 readEscape 状态读取到了双引号,说明我们读取到了一个需要被转义的双引号,则继续转回 readString 状态,如果读取到的字符不是双引号,则说明读取结束。
-
在 readRecord 字段,如果读取到了一个逗号,换行或者文件结束符,说明当前记录读取完成。
3. 接口定义
目前为止,我们只是需要实现一个字段一个字段读取文件的读取器即可,所以我们接口定义如下:
1 public CsvToken NextToken()
即,我们只需要定义一个读取下一个 Token 的方法即可。其中 CsvToken 的定义如下:
1 ///2 /// 从 CSV 文件读取到的一个分词。 3 /// 4 public class CsvToken 5 { 6 /// 7 /// 使用给定的分词类型和分词值,创建并初始化一个 8 /// 对象实例。 9 /// 10 /// 11 /// 当前分词值的类型,参考 。 12 /// 13 /// 当前分词的值。 14 public CsvToken(CsvTokenType tokenType, string value) 15 { 16 this.TokenType = tokenType; 17 this.Value = value; 18 } 19 20 /// 21 /// 设置或者获取当前分词值的类型,参考 22 /// 。 23 /// 24 public CsvTokenType TokenType { get; set; } 25 26 /// 27 /// 设置或获取当前分词的值。 28 /// 29 public string Value { get; set; } 30 }
对于 Token 类型,我们定义如下:
1 ///2 /// 从 CSV 文件中读取到的分词类型。 3 /// 4 public enum CsvTokenType : byte 5 { 6 /// 7 /// 默认值,通常标志着还未读取。 8 /// 9 Unknow, 10 /// 11 /// 读取到一个字段。 12 /// 13 Record, 14 /// 15 /// 标志着读取到了一条记录的最后一个字段。 16 /// 17 EndRecord, 18 /// 19 /// 标志着读取到了文件的结尾。 20 /// 21 Eof, 22 }
4. 解析实现
其实有了以上的分析,则实现已经不难了,只需要循环读取输入字符流就可以了,我们全类代码如下:
1 ///2 /// 将字符流转换为标记流,只支持向前读取。 3 /// 4 public partial class CsvTokenizer 5 { 6 private readonly TextReader reader; 7 8 /// 9 /// 使用指定的字符流读取器,创建并初始化一个 10 /// 对象实例。 11 /// 12 /// 字符流读取器。 13 public CsvTokenizer(TextReader reader) 14 { 15 this.reader = reader 16 ?? throw new ArgumentNullException(nameof(reader)); 17 } 18 19 /// 20 /// 从构造传入的字符输入流,读取下一个记录。 21 /// 22 /// 下一个记录信息。 23 public CsvToken NextToken() 24 { 25 int ch; 26 StringBuilder buff = new StringBuilder(); 27 CsvTokenType tokenType = CsvTokenType.Unknow; 28 while ((ch = reader.Read()) != -1) 29 { 30 switch (ch) 31 { 32 case ',': 33 tokenType = CsvTokenType.Record; 34 goto ret; 35 case '"': 36 if (buff.Length <= 0) 37 { 38 this.ReadString(buff); 39 continue; 40 } 41 break; 42 case '\r': 43 if (reader.Peek() == '\n') 44 { 45 // skip '\n' 46 reader.Read(); 47 tokenType = CsvTokenType.EndRecord; 48 goto ret; 49 } 50 break; 51 default: 52 break; 53 } 54 buff.Append((char)ch); 55 } 56 57 if (ch == -1) 58 { 59 tokenType = CsvTokenType.Eof; 60 } 61 ret: 62 return new CsvToken(tokenType, buff.ToString()); 63 } 64 65 private void ReadString(StringBuilder buff) 66 { 67 int ch; 68 while ((ch = reader.Read()) != -1) 69 { 70 switch (ch) 71 { 72 case '"': 73 if (reader.Peek() == '"') 74 { 75 // skip next double-qoutes 76 reader.Read(); 77 break; 78 } 79 return; 80 default: 81 break; 82 } 83 buff.Append((char)ch); 84 } 85 } 86 }
其中,类 CsvTokenizer 只有一个构造方法,传入一个 StringReader 对象,进行字符读取,其中 System.IO.StreamReader, System.IO.StringReader 均扩展了 System.IO.TextReader 抽象类,所以之类我们的构造方法选择使用 System.IO.TextReader 抽象类作为传入参数,以实现最大的灵活性。
另外需要说明的是,CsvTokenizer 类的主要任务是将传入的字符流转换为标记流(Token),所以这里没有进行错误校验,比如列的数量是否统一等。
5. 单元测试
到此为止,我们的 CSV 解析器,已经写完了。到最后,我们为其添加一些单元测试,以查看其工作结果,测试用例如下:
-
CsvTokenizer 构造方法参数为 null 时,应抛出 System.ArgumentNullException 异常;
-
CsvTokenizer 的 Dispose 方法调用之后,应该也将构造使用的 TextReader 对象也释放掉;
-
没有双引号,只有普通字符和逗号的 CSV 字符串解析;
-
字段没有使用双引号括起来,但是字段中间有双引号的字段,应该直接将双引号添加到字段值中。
-
使用双引号括起来的字段中如果出现了双引号,则应该将 "" 替换为 " 作为字段值的一部分;
-
当用双引号括起来的字段中包含回车换行时,应该将回车换行原样作为字段值的一部分;
-
当用双引号括起来的字段中出现逗号时,应将逗号作为字段值的一部分;
-
当字段中出现Unicode字符时,应能正常识别,这里使用 Emoji 进行测试;
测试代码如下:
1 [TestClass()] 2 public class CsvTokenizerTests 3 { 4 [TestMethod()] 5 public void CsvTokenizerTest_ConstructorArgumentNull() 6 { 7 ArgumentNullException ane = 8 Assert.ThrowsException(() => 9 { 10 new CsvTokenizer(null); 11 }); 12 } 13 14 [TestMethod] 15 public void CsvTokenizerTest_Disposed() 16 { 17 StringReader reader = new StringReader(""); 18 CsvTokenizer tokenizer = new CsvTokenizer(reader); 19 20 tokenizer.Dispose(); 21 22 Assert.ThrowsException (() => 23 { 24 reader.Read(); 25 }); 26 27 Assert.ThrowsException (() => 28 { 29 tokenizer.NextToken(); 30 }); 31 } 32 33 [TestMethod] 34 public void CsvTokenizerTest_OneLineNormal() 35 { 36 using CsvTokenizer tokenizer = new CsvTokenizer( 37 new StringReader(@"aaa,bbb,ccc")); 38 List<string> records = new List<string>(); 39 40 CsvToken token; 41 while ((token = tokenizer.NextToken()).TokenType 42 != CsvTokenType.Eof) 43 { 44 records.Add(token.Value); 45 } 46 records.Add(token.Value); 47 48 Assert.AreEqual(3, records.Count); 49 Assert.AreEqual("aaa", records[0]); 50 Assert.AreEqual("bbb", records[1]); 51 Assert.AreEqual("ccc", records[2]); 52 } 53 54 [TestMethod] 55 public void CsvTokenizerTest_DoubleQouteOnRecord() 56 { 57 using CsvTokenizer tokenizer = new CsvTokenizer( 58 new StringReader("aaa\"")); 59 60 CsvToken token = tokenizer.NextToken(); 61 62 Assert.AreEqual(CsvTokenType.Eof, token.TokenType); 63 Assert.AreEqual("aaa\"", token.Value); 64 } 65 66 [TestMethod] 67 public void CsvTokenizerTest_DoubleQouteInDoubleQoutedValues() 68 { 69 using CsvTokenizer tokenizer = new CsvTokenizer( 70 new StringReader("aaa\",\"b\"\"\",ccc")); 71 List<string> records = new List<string>(); 72 73 CsvToken token; 74 while ((token = tokenizer.NextToken()).TokenType 75 != CsvTokenType.Eof) 76 { 77 records.Add(token.Value); 78 } 79 records.Add(token.Value); 80 81 Assert.AreEqual(3, records.Count); 82 Assert.AreEqual("aaa\"", records[0]); 83 Assert.AreEqual("b\"", records[1]); 84 Assert.AreEqual("ccc", records[2]); 85 } 86 87 88 [TestMethod] 89 public void CsvTokenizerTest_LRLFInDoubleQoutedValues() 90 { 91 using CsvTokenizer tokenizer = new CsvTokenizer( 92 new StringReader("aaa\",\"b\r\n\"\"\",ccc")); 93 List<string> records = new List<string>(); 94 95 CsvToken token; 96 while ((token = tokenizer.NextToken()).TokenType 97 != CsvTokenType.Eof) 98 { 99 records.Add(token.Value); 100 } 101 records.Add(token.Value); 102 103 Assert.AreEqual(3, records.Count); 104 Assert.AreEqual("aaa\"", records[0]); 105 Assert.AreEqual("b\r\n\"", records[1]); 106 Assert.AreEqual("ccc", records[2]); 107 } 108 109 [TestMethod] 110 public void CsvTokenizerTest_CommaInDoubleQoutedValues() 111 { 112 using CsvTokenizer tokenizer = new CsvTokenizer( 113 new StringReader("aaa\",\"b,\r\n\"\"\",ccc")); 114 List<string> records = new List<string>(); 115 116 CsvToken token; 117 while ((token = tokenizer.NextToken()).TokenType 118 != CsvTokenType.Eof) 119 { 120 records.Add(token.Value); 121 } 122 records.Add(token.Value); 123 124 Assert.AreEqual(3, records.Count); 125 Assert.AreEqual("aaa\"", records[0]); 126 Assert.AreEqual("b,\r\n\"", records[1]); 127 Assert.AreEqual("ccc", records[2]); 128 } 129 130 [TestMethod] 131 public void CsvTokenizerTest_Emoji() 132 { 133 using CsvTokenizer tokenizer = new CsvTokenizer( 134 new StringReader("??aaa,b??bb,ccc??")); 135 List<string> records = new List<string>(); 136 137 CsvToken token; 138 while ((token = tokenizer.NextToken()).TokenType 139 != CsvTokenType.Eof) 140 { 141 records.Add(token.Value); 142 } 143 records.Add(token.Value); 144 145 Assert.AreEqual(3, records.Count); 146 Assert.AreEqual("??aaa", records[0]); 147 Assert.AreEqual("b??bb", records[1]); 148 Assert.AreEqual("ccc??", records[2]); 149 } 150 }
运行单元测试,结果如下:
转载请注明出处:https://mp.weixin.qq.com/s/SWifce8ndOupuc0TltItcQ