本次主要内容有:
CSV 格式文件定义
描述 CSV 格式
接口定义
解析实现
单元测试
CSV 格式文件定义
每一条记录用换行符分割,换行符定义为 CRLF(\r\n)。例如:
aaa,bbb,ccc CRLFzzz,yyy,xxx CRLF
2. 文件的最后一行可以有,也可以没有换行,如:
aaa,bbb,ccc CRLFzzz,yyy,xxx
3. 文件可以选择性的在第一行指定一行和其他记录相同格式的标题行。这一行标题需要包含和其他记录相同的列,并且按照其他记录相同的顺序指定列的名称。有没有标题行应该可以通过参数指定。如:
field_name,field_name,field_name CRLFaaa,bbb,ccc CRLFzzz,yyy,xxx CRLF
4. 对于标题行和其他的所有记录行,可能会有一个或多个用逗号(',')分隔的字段。在文件中,所有的记录都应该有相同的字段数量。空白字符应该也是字段的一部分,不应该被忽略。最后一个字段不可以添加逗号。如:
aaa,bbb,ccc
5. 字段可以被双括号(")括起来,也可以不使用双括号括起来。如果一个字段没有被双括号括起来,那么双括号就不能出现在字段值中。如:
"aaa","bbb","ccc" CRLFzzz,yyy,xxx
6. 包含换行(CRLF),双引号('"')和逗号(',')的字段,必须使用双引号括起来,如:
"aaa","b CRLFbb","ccc" CRLFzzz,yyy,xxx
7. 如果双引号在字段值中出现,则需要使用另一个前导双引号对它进行转义,如:
"aaa","b""bb","ccc"
2. 描述 CSV 格式
在RFC4180中,已经对 CSV 格式使用了 ABNF 进行描述,这里不再重复。我们在这里使用有穷状态机进行描述如下:在这里,是读取一个字段的状态转换图,说明如下:
开始读取的时候,我们读取到任何不在 '"', ',', LRLF 和 EOF 中的字符,均将其存储,并继续读取下一个字符;
如果读取到了一个双引号,说明下面一个记录应该是一个字符串,所以我们转换到 readString 状态一直读取到一个双引号位置,说明下一个字符可能是一个转换字符,所以转换到 readEscape 状态,如果在 readEscape 状态读取到了双引号,说明我们读取到了一个需要被转义的双引号,则继续转回 readString 状态,如果读取到的字符不是双引号,则说明读取结束。
在 readRecord 字段,如果读取到了一个逗号,换行或者文件结束符,说明当前记录读取完成。
3. 接口定义
目前为止,我们只是需要实现一个字段一个字段读取文件的读取器即可,所以我们接口定义如下:public CsvToken NextToken()
即,我们只需要定义一个读取下一个 Token 的方法即可。其中 CsvToken 的定义如下:
/// /// 从 CSV 文件读取到的一个分词。/// public class CsvToken{ /// /// 使用给定的分词类型和分词值,创建并初始化一个 /// 对象实例。 /// /// /// 当前分词值的类型,参考 。 /// /// 当前分词的值。 public CsvToken(CsvTokenType tokenType, string value) { this.TokenType = tokenType; this.Value = value; } /// /// 设置或者获取当前分词值的类型,参考 /// 。 /// public CsvTokenType TokenType { get; set; } /// /// 设置或获取当前分词的值。 /// public string Value { get; set; }}
对于 Token 类型,我们定义如下:
/// /// 从 CSV 文件中读取到的分词类型。/// public enum CsvTokenType : byte{ /// /// 默认值,通常标志着还未读取。 /// Unknow, /// /// 读取到一个字段。 /// Record, /// /// 标志着读取到了一条记录的最后一个字段。 /// EndRecord, /// /// 标志着读取到了文件的结尾。 /// Eof,}
4. 解析实现
其实有了以上的分析,则实现已经不难了,只需要循环读取输入字符流就可以了,我们全类代码如下:/// /// 将字符流转换为标记流,只支持向前读取。/// public partial class CsvTokenizer{ private readonly TextReader reader; /// /// 使用指定的字符流读取器,创建并初始化一个 /// 对象实例。 /// /// 字符流读取器。 public CsvTokenizer(TextReader reader) { this.reader = reader ?? throw new ArgumentNullException(nameof(reader)); } /// /// 从构造传入的字符输入流,读取下一个记录。 /// /// 下一个记录信息。 public CsvToken NextToken() { int ch; StringBuilder buff = new StringBuilder(); CsvTokenType tokenType = CsvTokenType.Unknow; while ((ch = reader.Read()) != -1) { switch (ch) { case ',': tokenType = CsvTokenType.Record; goto ret; case '"': if (buff.Length <= 0) { this.ReadString(buff); continue; } break; case '\r': if (reader.Peek() == '\n') { // skip '\n' reader.Read(); tokenType = CsvTokenType.EndRecord; goto ret; } break; default: break; } buff.Append((char)ch); } if (ch == -1) { tokenType = CsvTokenType.Eof; } ret: return new CsvToken(tokenType, buff.ToString()); } private void ReadString(StringBuilder buff) { int ch; while ((ch = reader.Read()) != -1) { switch (ch) { case '"': if (reader.Peek() == '"') { // skip next double-qoutes reader.Read(); break; } return; default: break; } buff.Append((char)ch); } }}
其中,类 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 进行测试;
测试代码如下:
[TestClass()]public class CsvTokenizerTests{ [TestMethod()] public void CsvTokenizerTest_ConstructorArgumentNull() { ArgumentNullException ane = Assert.ThrowsException(() => { new CsvTokenizer(null); }); } [TestMethod] public void CsvTokenizerTest_Disposed() { StringReader reader = new StringReader(""); CsvTokenizer tokenizer = new CsvTokenizer(reader); tokenizer.Dispose(); Assert.ThrowsException<ObjectDisposedException>(() => { reader.Read(); }); Assert.ThrowsException<ObjectDisposedException>(() => { tokenizer.NextToken(); }); } [TestMethod] public void CsvTokenizerTest_OneLineNormal() { using CsvTokenizer tokenizer = new CsvTokenizer( new StringReader(@"aaa,bbb,ccc")); List<string> records = new List<string>(); CsvToken token; while ((token = tokenizer.NextToken()).TokenType != CsvTokenType.Eof) { records.Add(token.Value); } records.Add(token.Value); Assert.AreEqual(3, records.Count); Assert.AreEqual("aaa", records[0]); Assert.AreEqual("bbb", records[1]); Assert.AreEqual("ccc", records[2]); } [TestMethod] public void CsvTokenizerTest_DoubleQouteOnRecord() { using CsvTokenizer tokenizer = new CsvTokenizer( new StringReader("aaa\"")); CsvToken token = tokenizer.NextToken(); Assert.AreEqual(CsvTokenType.Eof, token.TokenType); Assert.AreEqual("aaa\"", token.Value); } [TestMethod] public void CsvTokenizerTest_DoubleQouteInDoubleQoutedValues() { using CsvTokenizer tokenizer = new CsvTokenizer( new StringReader("aaa\",\"b\"\"\",ccc")); List<string> records = new List<string>(); CsvToken token; while ((token = tokenizer.NextToken()).TokenType != CsvTokenType.Eof) { records.Add(token.Value); } records.Add(token.Value); Assert.AreEqual(3, records.Count); Assert.AreEqual("aaa\"", records[0]); Assert.AreEqual("b\"", records[1]); Assert.AreEqual("ccc", records[2]); } [TestMethod] public void CsvTokenizerTest_LRLFInDoubleQoutedValues() { using CsvTokenizer tokenizer = new CsvTokenizer( new StringReader("aaa\",\"b\r\n\"\"\",ccc")); List<string> records = new List<string>(); CsvToken token; while ((token = tokenizer.NextToken()).TokenType != CsvTokenType.Eof) { records.Add(token.Value); } records.Add(token.Value); Assert.AreEqual(3, records.Count); Assert.AreEqual("aaa\"", records[0]); Assert.AreEqual("b\r\n\"", records[1]); Assert.AreEqual("ccc", records[2]); } [TestMethod] public void CsvTokenizerTest_CommaInDoubleQoutedValues() { using CsvTokenizer tokenizer = new CsvTokenizer( new StringReader("aaa\",\"b,\r\n\"\"\",ccc")); List<string> records = new List<string>(); CsvToken token; while ((token = tokenizer.NextToken()).TokenType != CsvTokenType.Eof) { records.Add(token.Value); } records.Add(token.Value); Assert.AreEqual(3, records.Count); Assert.AreEqual("aaa\"", records[0]); Assert.AreEqual("b,\r\n\"", records[1]); Assert.AreEqual("ccc", records[2]); } [TestMethod] public void CsvTokenizerTest_Emoji() { using CsvTokenizer tokenizer = new CsvTokenizer( new StringReader("🍀aaa,b🍀bb,ccc🍀")); List<string> records = new List<string>(); CsvToken token; while ((token = tokenizer.NextToken()).TokenType != CsvTokenType.Eof) { records.Add(token.Value); } records.Add(token.Value); Assert.AreEqual(3, records.Count); Assert.AreEqual("🍀aaa", records[0]); Assert.AreEqual("b🍀bb", records[1]); Assert.AreEqual("ccc🍀", records[2]); }}
运行单元测试,结果如下: