自动检测 CSV 分隔符
解释如何检测 CSV 文件中用作分隔符的字符
引言
CSV 文件因其简单的文本格式和极少的规则而非常适合存储表格数据。这使得它们具有很高的互操作性,因为 CSV 读取器和写入器的实现相对容易。互操作性可能是人们选择以 CSV 格式保存数据的首要原因。
尽管 CSV 文件写入和读取的规则(将在下一章解释)相对已知且被广泛接受,但有一条规则是例外——确定用作分隔符的字符。顾名思义,CSV 文件(Comma Separated Values,逗号分隔值)应该使用逗号 [,] 作为分隔符,但许多 CSV 文件使用分号 [;] 或水平制表符 [\t] 作为分隔符。
因此,为了构建一个通用的 CSV 读取器,它可以读取任何 CSV 文件而不管分隔符是什么,读取器必须首先找出使用了哪个字符作为分隔符。本文提供了一种解决此问题的方法。
CSV 格式
写入 CSV 文件的规则非常简单
- 如果值包含分隔符字符、换行符或以引号开头,则用引号将该值括起来。
- 如果值被引号括起来,则值中包含的任何引号字符都应后跟另一个引号字符。
这两个简单的规则使我们能够轻松地编写 CSV 写入器,只需几分钟即可完成。实现 CSV 读取器则要复杂得多,因为 CSV 流必须逐个字符地顺序解析,并且需要提供额外的状态存储——这实际上使 CSV 读取器成为一个状态机。市面上有很多 CSV 读取器实现不正确,因为它们没有遵循上述规则。
实现
现在我们已经定义了 CSV 文件的规则,就可以实现一个能够找出分隔符字符的 CSV 读取器了。
以下是检测 CSV 流中分隔符的方法的完整 C# 源代码:
public static char Detect(TextReader reader, int rowCount, IList<char> separators)
{
IList<int> separatorsCount = new int[separators.Count];
int character;
int row = 0;
bool quoted = false;
bool firstChar = true;
while (row < rowCount)
{
character = reader.Read();
switch (character)
{
case '"':
if (quoted)
{
if (reader.Peek() != '"') // Value is quoted and
// current character is " and next character is not ".
quoted = false;
else
reader.Read(); // Value is quoted and current and
// next characters are "" - read (skip) peeked qoute.
}
else
{
if (firstChar) // Set value as quoted only if this quote is the
// first char in the value.
quoted = true;
}
break;
case '\n':
if (!quoted)
{
++row;
firstChar = true;
continue;
}
break;
case -1:
row = rowCount;
break;
default:
if (!quoted)
{
int index = separators.IndexOf((char)character);
if (index != -1)
{
++separatorsCount[index];
firstChar = true;
continue;
}
}
break;
}
if (firstChar)
firstChar = false;
}
int maxCount = separatorsCount.Max();
return maxCount == 0 ? '\0' : separators[separatorsCount.IndexOf(maxCount)];
}
CSV 流由 reader
参数表示,用于从 CSV 流中读取字符,rowCount
参数告诉方法在确定分隔符之前应该读取多少行,而 separators
参数是一个字符列表,告诉方法哪些字符是可能的分隔符。
方法通过以下参数维护内部状态:
separatorsCount
– 用于计算 CSV 流中可能的每个分隔符作为分隔符的出现次数。character
– 从 CSV 流中读取的最后一个字符。row
– CSV 流中当前正在处理的行的索引。quoted
– 如果接下来读取的字符被引号括起来,则为true
,否则为false
。firstChar
– 如果要读取的下一个字符是 CSV 流中下一个条目的第一个字符,则为true
。此参数是必需的,因为只有当起始引号是 CSV 条目的第一个字符时,我们才认为该值被引号括起来。
当读取完 rowCount
行或 CSV 流读取到末尾时,方法将返回在 CSV 流中出现次数最多的那个可能分隔符。如果任何可能的分隔符从未在 CSV 流中作为分隔符出现,则返回 ‘\0’
。
方法在读取包含在引号值中的引号、分隔符和换行符时会进行特殊处理。在这种情况下,如果读取到引号,方法将窥视 CSV 流以查看下一个字符是否也是引号,否则将认为此引号是结束引号。包含在引号值中的换行符和分隔符将被忽略。
例如,在下面的 Employees.csv 文件中:
Name,Surname,Salary
John,Doe,"$2,130"
Fred;Nurk;"$1,500"
Hans;Meier;"$1,650"
Ivan;Horvat;"$3,200"
方法检测到 CSV 分隔符是 [;],尽管 [;] 的总出现次数是 6,而 [,] 的总出现次数是 8。这是因为 [,] 的最后 4 次出现被包含在引号中,因此不被视为可能的分隔符。所以 [,] 作为分隔符的总出现次数为 4,而 [;] 作为分隔符的总出现次数为 6,这使得 [;] 成为最有可能的 CSV 分隔符。
本文附带了一个 WPF 解决方案,演示了 CSV 分隔符自动检测功能。解决方案可以在此处下载。应用程序位于 bin/Release 文件夹中。
替代方案
从本文提供的代码推导出整个 CSV 读取器应该不难,但是表格数据可能以多种不同的格式出现,为每种格式实现读取器和写入器可能并不容易,并且可能会严重影响您的工作效率。
因此,您可以使用支持各种文件格式的第三方组件。这可能需要一些费用,但像 XLS、XLSX、CSV、ODS、HTML 等格式很可能在同一个 API 中得到支持,这样您的应用程序就可以使用相同的代码来处理不同的文件格式。