65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (25投票s)

2017年3月16日

CPOL

14分钟阅读

viewsIcon

36070

downloadIcon

483

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
(CP 上的文章)

可枚举 单个字符 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
  1. 不识别引号内的换行符。
  2. 我的实现。
  3. 额外的开销,因为输出需要适应字符串集合。

下图显示了与 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 FieldEOF 没有转换——这将抛出异常,因为带引号的字段未关闭。同样,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 语句和 enums 来实现状态机。通过这一切,我设法再提高了 5% 的性能。

通过重写状态机,我摆脱了虚拟方法。这使得 JIT 编译器能够比以前内联更多的代码。我认为这是性能提升的主要原因。

关注点

如果你认为 CSV 文件已经过时,那你说得对。有更好的信息交换方式。它已经死了吗?远未如此。

我们能进一步提高性能吗?

这取决于。我们的实现——本文开头提到的那个——不仅解析文件,还转换数字和日期,然后将其包装在 IDataReader 实现中,最后通过 SqlBulkCopy 将所有内容插入数据库。如果你想象一下整个管道,它看起来是这样的: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
© . All rights reserved.