花点时间,写了个CSV解析器


前段时间学习了下编译原理,凑巧的是,同事有解析 CSV 格式文件的需求,然后我就花了点时间,写了个 CSV 解析器,这里分享出来。

本次主要内容有:

  1. CSV 格式文件定义
  2. 描述 CSV 格式
  3. 接口定义
  4. 解析实现
  5. 单元测试

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 进行描述,这里不再重复。我们在这里使用有穷状态机进行描述如下:

在这里,是读取一个字段的状态转换图,说明如下:

  1. 开始读取的时候,我们读取到任何不在 '"', ',', LRLF 和 EOF 中的字符,均将其存储,并继续读取下一个字符;

  2. 如果读取到了一个双引号,说明下面一个记录应该是一个字符串,所以我们转换到 readString 状态一直读取到一个双引号位置,说明下一个字符可能是一个转换字符,所以转换到 readEscape 状态,如果在 readEscape 状态读取到了双引号,说明我们读取到了一个需要被转义的双引号,则继续转回 readString 状态,如果读取到的字符不是双引号,则说明读取结束。

  3. 在 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 解析器,已经写完了。到最后,我们为其添加一些单元测试,以查看其工作结果,测试用例如下:

  1.  CsvTokenizer 构造方法参数为 null 时,应抛出 System.ArgumentNullException 异常;

  2.  CsvTokenizer 的 Dispose 方法调用之后,应该也将构造使用的 TextReader 对象也释放掉;

  3. 没有双引号,只有普通字符和逗号的 CSV 字符串解析;

  4. 字段没有使用双引号括起来,但是字段中间有双引号的字段,应该直接将双引号添加到字段值中。

  5. 使用双引号括起来的字段中如果出现了双引号,则应该将 "" 替换为 " 作为字段值的一部分;

  6. 当用双引号括起来的字段中包含回车换行时,应该将回车换行原样作为字段值的一部分;

  7. 当用双引号括起来的字段中出现逗号时,应将逗号作为字段值的一部分;

  8. 当字段中出现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