前段时间学习了下编译原理,凑巧的是,同事有解析 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 /// <summary>
2 /// 从 CSV 文件读取到的一个分词。
3 /// </summary>
4 public class CsvToken
5 {
6 /// <summary>
7 /// 使用给定的分词类型和分词值,创建并初始化一个
8 /// <see cref="CsvToken"/>对象实例。
9 /// </summary>
10 /// <param name="tokenType">
11 /// 当前分词值的类型,参考 <see cref="CsvTokenType"/>。
12 /// </param>
13 /// <param name="value">当前分词的值。</param>
14 public CsvToken(CsvTokenType tokenType, string value)
15 {
16 this.TokenType = tokenType;
17 this.Value = value;
18 }
19
20 /// <summary>
21 /// 设置或者获取当前分词值的类型,参考
22 /// <see cref="CsvTokenType"/>。
23 /// </summary>
24 public CsvTokenType TokenType { get; set; }
25
26 /// <summary>
27 /// 设置或获取当前分词的值。
28 /// </summary>
29 public string Value { get; set; }
30 }

对于 Token 类型,我们定义如下:

 1 /// <summary>
2 /// 从 CSV 文件中读取到的分词类型。
3 /// </summary>
4 public enum CsvTokenType : byte
5 {
6 /// <summary>
7 /// 默认值,通常标志着还未读取。
8 /// </summary>
9 Unknow,
10 /// <summary>
11 /// 读取到一个字段。
12 /// </summary>
13 Record,
14 /// <summary>
15 /// 标志着读取到了一条记录的最后一个字段。
16 /// </summary>
17 EndRecord,
18 /// <summary>
19 /// 标志着读取到了文件的结尾。
20 /// </summary>
21 Eof,
22 }

4. 解析实现

其实有了以上的分析,则实现已经不难了,只需要循环读取输入字符流就可以了,我们全类代码如下:

 1 /// <summary>
2 /// 将字符流转换为标记流,只支持向前读取。
3 /// </summary>
4 public partial class CsvTokenizer
5 {
6 private readonly TextReader reader;
7
8 /// <summary>
9 /// 使用指定的字符流读取器,创建并初始化一个
10 /// <see cref="CsvTokenizer"/>对象实例。
11 /// </summary>
12 /// <param name="reader">字符流读取器。</param>
13 public CsvTokenizer(TextReader reader)
14 {
15 this.reader = reader
16 ?? throw new ArgumentNullException(nameof(reader));
17 }
18
19 /// <summary>
20 /// 从构造传入的字符输入流,读取下一个记录。
21 /// </summary>
22 /// <returns>下一个记录信息。</returns>
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<ArgumentNullException>(() =>
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<ObjectDisposedException>(() =>
23 {
24 reader.Read();
25 });
26
27 Assert.ThrowsException<ObjectDisposedException>(() =>
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,bbb,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("bbb", records[1]);
148 Assert.AreEqual("ccc", records[2]);
149 }
150 }

运行单元测试,结果如下:

转载请注明出处:https://mp.weixin.qq.com/s/SWifce8ndOupuc0TltItcQ

花点时间,写了个CSV解析器的更多相关文章

  1. 开源一个CSV解析器(附设计过程 )

    在ExcelReport支持csv的开发过程中,需要一个NETStandard的csv解析器.在nuget上找了几个试用,但都不太适合. 于是,便有了:AxinLib.IO.CSV. 先看看怎么用: ...

  2. c# 怎样能写个sql的解析器

    c# 怎样能写个sql的解析器 本示例主要是讲明sql解析的原理,真实的源代码下查看 sql解析器源代码 详细示例DEMO 请查看demo代码 前言 阅读本文需要有一定正则表达式基础 正则表达式基础教 ...

  3. C++写一个简单的解析器(分析C语言)

    该方案实现了一个分析C语言的词法分析+解析. 注意: 1.简单语法,部分秕.它可以在本文法的基础上进行扩展,此过程使用自上而下LL(1)语法. 2.自己主动能达到求First 集和 Follow 集. ...

  4. .NET Core中的CSV解析库

    感谢 本篇首先特别感谢从此启程兄的<.NetCore外国一些高质量博客分享>, 发现很多国外的.NET Core技术博客资源, 我会不定期从中选择一些有意思的文章翻译总结一下. .NET ...

  5. 非标准的xml解析器的C++实现:一、思考基本数据结构的设计

    前言: 我在C++项目中使用xml作为本地简易数据管理,到目前为止有5年时间了,从最初的全文搜索标签首尾,直到目前项目中实际运用的类库细致到已经基本符合w3c标准,我一共写过3次解析器,我自己并没有多 ...

  6. Python 之父撰文回忆:为什么要创造 pgen 解析器?

    花下猫语: 近日,Python 之父在 Medium 上开通了博客,并发布了一篇关于 PEG 解析器的文章(参见我翻的 全文译文).据我所知,他有自己的博客,为什么还会跑去 Medium 上写文呢?好 ...

  7. boost之词法解析器spirit

    摘要:解析器就是编译原理中的语言的词法分析器,可以按照文法规则提取字符或者单词.功能:接受扫描器的输入,并根据语法规则对输入流进行匹配,匹配成功后执行语义动作,进行输入数据的处理. C++ 程序员需要 ...

  8. Spring boot中自定义Json参数解析器

    转载请注明出处... 一.介绍 用过springMVC/spring boot的都清楚,在controller层接受参数,常用的都是两种接受方式,如下 /** * 请求路径 http://127.0. ...

  9. SpringBoot自定义参数解析器

    一.背景 平常经常用 @RequestParam注解来获取参数,然后想到我能不能写个自己注解获取请求的ip地址呢?就像这样 @IP String ip 二.分析 于是开始分析 @RequestPara ...

  10. thinkphp5项目--企业单车网站(九)(加强复习啊)(花了那么多时间写的博客,不复习太浪费了)

    thinkphp5项目--企业单车网站(九)(加强复习啊)(花了那么多时间写的博客,不复习太浪费了) 项目地址 fry404006308/BicycleEnterpriseWebsite: Bicyc ...

随机推荐

  1. 在Ubuntu上使用Let's Encrypt配置Nginx SSL证书并自动更新

    在Ubuntu上使用Let's Encrypt配置Nginx SSL证书并自动更新 绪言 这篇文章其实内容不多,难度不大,只是自己记录一下. Arisu拷打了我几次我在阿里云上花钱购买SSL证书一事. ...

  2. CSP-S 2020模拟训练题1-信友队T2 挑战NPC

    题意简述 有一个\(k\)维空间,每维的跨度为\(L\),即每一维的坐标只能是\(0,1, \cdots ,L-1\).每一步你可以移动到任意一个曼哈顿距离到自己小于等于\(d\)的任意一个合法坐标. ...

  3. 华为MAAS、阿里云PAI、亚马逊AWS SageMaker、微软Azure ML各大模型深度分析对比

    一.技术架构深度对比 1. 硬件基础设施 平台 自研芯片 分布式训练方案 边缘协同能力 华为MAAS 昇腾Ascend 910 + Atlas 900集群 MindSpore + HCCL(华为集合通 ...

  4. docker pull镜像加速

    配置说明 $ vim /etc/docker/daemon.json { "registry-mirrors": [ "https://ustc-edu-cn.mirro ...

  5. 如何在FastAPI中打造一个既安全又灵活的权限管理系统?

    title: 如何在FastAPI中打造一个既安全又灵活的权限管理系统? date: 2025/06/16 08:17:05 updated: 2025/06/16 08:17:05 author: ...

  6. centos7搭建postgresql-14

    环境:centos7  + pg 14 1:在postgresql官网下载页面,根据提示下载 https://www.postgresql.org/download/linux/redhat/ 2 连 ...

  7. Chiplet封装技术的应用现状

    这是IC男奋斗史的第39篇原创 本文1651字,预计阅读4分钟. 接上文:Chiplet解决芯片技术发展瓶颈 Chiplet封装的产品介绍 以下介绍几款国内外使用Chiplet封装技术的代表产品,包括 ...

  8. sql交并差运算

    -- 取并集 select count(distinct user_id) from ( select user_id from hive_table where {some condition} u ...

  9. 前端开发系列131-进阶篇之Promise源码实现

    本文介绍参考[PromiseA+]规范来实现一个符合规范的Promise库. 上面是ES6+实现的Promise核心方法,其整体结构也可以通过下面的打印查看 /* 01-打印Promise类的内容(静 ...

  10. js定时器高级用法

    //////////////////////////////////////////////批量执行方法start var delegateArray = []; var DeArr = { AddF ...