自建 CSV 解析器(或不自建)






4.98/5 (25投票s)
CSV 解析器的特性比较及一个自定义实现的介绍
引言
我将讨论 CSV 解析器的各种特性,比较 NuGet 上的几个解析器,并提供我的自定义实现。
背景
这基于我过去为一家金融客户所做的工作。我们当时正在构建一个计算引擎,而导入 CSV 文件是其中的一项主要任务。我评估了许多 CSV 解析器,但最终我们决定自己开发一个实现,因为我们有独特的需求。通常情况下,这些特殊需求在事后看来并没有那么重要,当然,直到实现完成后我们才意识到这一点。最终,我们将自定义解决方案的核心替换为 TextFieldParser,它能更好地处理带引号的字段。现在,我将分享我从 CSV 解析中学到的东西。
什么是 CSV 及其解析方法
CSV 格式由 RFC4180 定义如下:
file = [header CRLF] record *(CRLF record) [CRLF]
header = name *(COMMA name)
record = field *(COMMA field)
name = field
field = (escaped / non-escaped)
escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE
non-escaped = *TEXTDATA
COMMA = %x2C
CR = %x0D
DQUOTE = %x22
LF = %x0A
CRLF = CR LF
TEXTDATA = %x20-21 / %x23-2B / %x2D-7E
让我们来讨论其中的几个有趣事实。
逗号作为唯一的字段分隔符
这通常是不正确的,因为人们发送给你的文件可能使用几乎任何字符作为分隔符。诚然,逗号、管道符和分号是最常见的。
我们最初有一个要求,分隔符可以是一个以上的字符。但这从未被使用过。
CR+LF 作为行尾
同样,这个要求也经常被忽略。你可以确定,没人会在乎他们发送给你的文件使用 Windows、Unix 还是 Mac 的行尾,或者混合使用。你只需要处理它。理想情况下,我们应该能够解析 CR 和 LF 的任何合理组合,就像 StreamReader.ReadLine 方法那样。
在我们的实现中,我们非常具体,允许用户显式指定行尾。这从未受欢迎,原因如下——我们没有解决问题,反而把它抛给了用户。最终,我们将其默认设置为 Windows+Unix+Mac,然后不再更改。
带引号的字段
带引号的字段必须以双引号开头和结尾。字段内的引号通过加倍来转义。带引号的字段可以包含逗号(字段分隔符)和换行符(行分隔符)。这非常重要,因为文本字段中可能包含各种字符。
请注意,文本数据中包含引号是无效的:abc "efg" hij。理论上,这应该会因未定义状态而失败,但在现实生活中,你想尽可能多地导入数据,所以这种情况只会像普通字符一样被忽略和解析。
我不得不说,实现自定义引号字符并不是什么大问题。尽管如此,我从未见过除双引号以外的其他字符。偶尔,人们会尝试使用反斜杠来转义引号,但这完全是错误的。
跳过行
没有注释或空行!
在大多数实现中,允许使用单行注释。如果你以井号 (#) 或你选择的自定义字符开头一行,该行将被跳过。
同样,空行也不是上述语法的组成部分。但许多实现仍然允许你自动跳过空行。
注释很有用——它们偶尔会被使用。另一方面,我在生产环境中没见过带空行的文件。
偶尔,可以选择跳过前 N 行。如果有人在文件开头发送了垃圾信息,这可能很有用。另一方面,如果有垃圾信息,使用注释来跳过它要好得多。
数据转换
一些实现允许你自动修剪字段周围的空白字符。虽然这可能很有用,但我认为这不是解析器的职责,而是更高层的职责。如果提供了此功能,则应将其设为可选。
一些解析器尝试做的不仅仅是提供行和字段——它们试图将字符串转换为用户指定的类型。一些实现者认为,直接将数据加载到 POCO 实体是解析 CSV 的唯一正确方法。我当然认为这些功能有其目标受众,但在我的情况下,我要么禁用了此功能(如果可能),要么将解析器排除在我的评估之外,因为它不符合我的需求。
读取和写入数据
有些实现会将整个文件解析到一个数组、列表或 DataTable 中,并将所有内容一次性返回给你。对于小文件来说这没问题,但当你需要导入数 GB 的数据时,就会成为障碍。我只关注那些可以逐行读取文件的实现。
一些库提供了写入 CSV 数据的功能。我没有关注这部分,因为它不符合我的用例。
功能比较
每个解析器的一些有趣特性。空字段表示我未找到相关信息。
解析器 | 访问 | 分隔符 | 行尾 | 引用 | 引号内的 EOL | 注释 | 修剪空白字符 |
Microsoft.VisualBasic. FileIO.TextFieldParser | 逐行读取 | 多个字符串 | " | 是 | 多个字符串 | optional | |
Nuget: Cinchoo ETL 1.0.2.4 (CP 上的文章) | 枚举器 | 单个字符串 | 单个字符串 | 单个字符 | 是 | 多个字符串 | 是 |
Nuget: Csv 1.0.11 | 可枚举 | 单个字符 | StreamReader.ReadLine | " | 否 | 否 | optional |
Nuget: CsvHelper 3.0.0-beta7 | 逐行读取 | 单个字符串 | Windows, Unix | 单个字符 | 是 | 单个字符 | optional |
Nuget: CsvToolkit 0.13.0 | 可枚举 | 单个字符 | Windows, Unix | 单个字符 | 是 | 否 | optional |
Nuget: DevLib.Csv 2.16.23.19010 | 可枚举 | 单个字符 | StreamReader.ReadLine | 单个字符 | 否 | 否 | 否 |
Nuget: LibCsv4Net 1.8.9.1102 | 可枚举 | 单个字符 | 单个字符串 | 单个字符 | 是 | 否 | 否 |
Nuget: LumenWorksCsvReader 3.9.1 | 可枚举 | 单个字符 | Windows, Unix | 单个字符 | 是 | 单个字符 | optional |
Nuget: Net.Code.Csv 1.0.3 | 数据读取器 | 单个字符 | 单个字符 | 是 | 单个字符 | optional | |
Nuget: Nortal.Utilities.Csv 0.9.2 | 逐行读取 | 单个字符 | 单个字符串 | 单个字符 | 是 | 否 | 否 |
Nuget: Uncomplicated.Csv 1.5.2 | 逐行读取 | 单个字符 | Windows | 单个字符 | 是 | 否 | 否 |
我的实现 | 逐行读取 | 单个字符 | CR,LF,CR+LF,LF+CR 规范化为 CR+LF | " | 是 | # | 否 |
CodeProject: C# - 轻巧快速的 CSV 解析器 | 可枚举 | 单个字符 | Windows, Unix | 单个字符 | 是 | 否 | 是 |
CodeProject: C# CSV 文件和字符串读取器类 | 逐行读取 | 单个字符 | 单个字符 | 是 | 否 | 否 |
数据访问说明
- 逐行读取:有一个方法可以读取单行,形式为字符串数组(或等效物),并在到达文件末尾时返回
null
。 - 可枚举:解析器返回一个可枚举的行(字符串数组或等效物)。
- 数据读取器:解析器实现了 IDataReader 接口。
Using the Code
要重现下表所示的结果,请使用 SimpleCsvReader.Demo
项目,这是一个简单的控制台应用程序。有三种模式。
首先,你需要生成一个随机测试文件。你可以指定行数,即第二个参数的大小。
SimpleCsvReader.Demo.exe /gen 10000
生成文件后,你可以使用各种解析器对其进行解析并记录时间。第二个参数指定了你要重复测量多少次,将计算结果的平均值。
SimpleCsvReader.Demo.exe /run 3
最后,还有第三个选项可以验证解析的数据是否与生成的数据匹配。这是通过对写入和读取文件的 SHA256 哈希值来完成的。
SimpleCsvReader.Demo.exe /verify
性能比较
下表显示了解析给定行数文件的平均时间(秒)。样本文件不包含任何注释,但包含带换行的带引号字段。请参阅表格下方的注释。
解析器 | 10k | 100k | 1M | 10M | 100M |
Microsoft.VisualBasic.FileIO.TextFieldParser | 0.21 | 2.04 | 20.17 | 201.21 | 2061.95 |
Nuget: Cinchoo ETL 1.0.2.4 (3) | 0.71 | 6.80 | 67.72 | 678.22 | 6890.05 |
Nuget: Csv 1.0.11 (1)(3) | 0.66 | 6.51 | 64.45 | 662.48 | 6642.64 |
Nuget: CsvHelper 3.0.0-beta7 | 0.06 | 0.56 | 5.60 | 55.69 | 584.74 |
Nuget: CsvToolkit 0.13.0 (3) | 0.12 | 1.18 | 11.68 | 118.92 | 1221.08 |
Nuget: DevLib.Csv 2.16.23.19010 (1) | 0.04 | 0.35 | 3.51 | 34.95 | 374.55 |
Nuget: LibCsv4Net 1.8.9.1102 | 0.25 | 2.46 | 24.50 | 244.65 | 2507.25 |
Nuget: LumenWorksCsvReader 3.9.1 | 0.02 | 0.21 | 2.11 | 20.96 | 229.66 |
Nuget: Net.Code.Csv 1.0.3 (3) | 0.05 | 0.52 | 5.16 | 51.30 | 541.96 |
Nuget: Nortal.Utilities.Csv 0.9.2 | 0.06 | 0.59 | 5.89 | 59.02 | 613.04 |
Nuget: Uncomplicated.Csv 1.5.2 | 0.04 | 0.34 | 3.43 | 33.95 | 365.23 |
SimpleCsvParser (2) | 0.08 | 0.77 | 7.63 | 77.53 | 790.33 |
SimpleCsvParserMerged (2) | 0.07 | 0.65 | 6.48 | 64.65 | 675.81 |
CodeProject: C# - 轻巧快速的 CSV 解析器 | 0.06 | 0.56 | 5.53 | 56.26 | 585.87 |
CodeProject: C# CSV 文件和字符串读取器类 (3) | 0.05 | 0.51 | 5.12 | 52.02 | 545.95 |
- 不识别引号内的换行符。
- 我的实现。
- 额外的开销,因为输出需要适应字符串集合。
下图显示了与 VB TextFieldParser
相比的相对解析器速度,它被用作参考。比参考解析器慢的解析器未显示。
LumenWorks 是最好的。我的实现速度达到了 40% 以下。CsvHelper
beta7 的性能比最后一个稳定版本提高了约 30%。
自定义实现
首先,我必须说明,我在这里展示的不是我为前雇主编写的实现,因为那是专有作品。我在这里展示的是我为研究目的自己实现的。
该部分描述的代码位于附件项目的 SimpleCsvReader.Lib 中。
设计
CSV 解析器可以实现为 有限状态机。解析一行可以用下面的图表示。每个转换都标有触发转换的相应输入,然后是执行的操作。这被称为 Mealy 机。
Start Line 是起始状态,End Line 是最终状态。为了处理多个行尾,它们被预先处理,以便状态机不必直接处理它们,因此由单个令牌 EOL 表示。EOF 是文件结束。ELSE 表示任何其他字符。当输入中没有更多数据时,返回 null
。如果行为空,则返回空字符串数组。
Append char 表示将当前字符添加到当前字段。End field 获取当前字段,将其添加到当前行并清除当前字段,以便继续解析。Keep char 表示输入未前进到下一个字符,因此下一个状态会处理相同的字符。
请注意,从 Quoted Field 到 EOF 没有转换——这将抛出异常,因为带引号的字段未关闭。同样,Double Quote 后面不能有任何文本——这会再次引发异常。另一方面,我们允许在 Regular Field 中包含引号。
TextReaderWrapper 类
底层文件使用 TextReader.Read 方法 逐个字符读取。在文件结束时,它返回一个整数并返回 -1
。为了方便处理行尾,我们将其包装起来,并在行尾返回 -2
。
这个类还有另一个重要职责,那就是跟踪输入文件中的当前位置。不仅是当前字符的绝对位置,还有逻辑行号和列号。这在向用户报告错误时很有用。
LineBuilder 类
在这里,行是逐字段、逐字符构建的。当前字段由 StringBuilder 表示,当前行由字符串列表表示。字符被添加到当前字段,行尾会自动转换为 Environment.NewLine——原始值丢失。当字段结束时,字符串被附加到当前行,并且当前字段的 StringBuilder
被清除。当行结束时,当前行被返回给调用者。
Context 和 State 类
我选择使用 状态设计模式 来实现解析器。这样可以将转换逻辑很好地封装起来。它直接翻译了上面的图。Context 类作为主要中心,促进转换并执行相关操作,而决定下一个状态和执行什么操作的实际逻辑则在 respective state 类中。
CsvParser 类
这就是一切的汇集之处。CsvParser
类是解析器的主要接口——ReadLine
方法就位于此处。如果我们发现需要比分隔符更多的参数,就应该在这里处理和验证它们。我们还在这里捕获解析异常,并注入行号和列号以帮助解决问题。
CsvParserMerged 类
我的解析器运行良好,单元测试也通过了,但我对性能感到有些失望。我以为我的解析器会非常快!虽然我比参考实现快了一倍多,但还有一些其他解析器更快。当然,我不能容忍这一点。
如果你查看代码,你会发现我并没有做太多改变。主要是,我将所有内容合并到一个类中,使其成为 private
。我还使用简单的 switch
语句和 enum
s 来实现状态机。通过这一切,我设法再提高了 5% 的性能。
通过重写状态机,我摆脱了虚拟方法。这使得 JIT 编译器能够比以前内联更多的代码。我认为这是性能提升的主要原因。
关注点
如果你认为 CSV 文件已经过时,那你说得对。有更好的信息交换方式。它已经死了吗?远未如此。
我们能进一步提高性能吗?
FileStream
> StreamReader
> 数据转换 > 数据读取器 > SqlBulkCopy
。我花了一些时间用性能分析器查看性能,正如你可能猜到的,大部分时间花在读取文件本身上——这并不奇怪。第二个瓶颈是数据转换。那里肯定有改进的空间。
此外,还有大量的缓冲和缓冲区之间的复制。FileStream
将字节读入缓冲区。StreamReader
使用编码从字节中获取字符,并将它们放入自己的缓冲区。然后你从这些字符创建字符串。然后转换接收字符串,但内部实现使用字符数组。SqlBulkCopy
总是调用 GetValue 方法,该方法返回一个对象,你的数字和日期会被装箱。
通过一些技巧,你可以节省一些内存分配,但我怀疑这是否值得。
值得自己编写 CSV 解析器吗?
不值得。只需从 NuGet 上选择一个然后开始使用即可。如果需要,你总可以替换成更好的。
我们为什么不使用现有的库?原因之一是那些荒谬的需求。另一个原因是转换层,它将字符串转换为数字和日期。考虑到你需要解析不同格式和文化的数据,你会惊讶于使其正确是多么困难。这就是为什么我们保留了转换层,只替换了解析器。
我们为什么不使用像 SSIS 这样的现有解决方案?这是一个很好的问题,也许有一天我会写另一篇文章来讨论它。
历史
- 2017年3月16日 - 初始发布
- 2017年3月17日 - 移除了
CsvParser
0.5.2,因为它预先解析数据 - 2017年3月20日 - 向比较中添加了新的解析器
- 来自 CodeProject 文章的两个解析器
- Cinchoo ETL 1.0.2.4
CsvHelper
更新到 3.0.0-beta7