2020/08/30 更新:请使用 Scala 库 kantan.csv,以最准确和正确地实现 RFC 4180,它定义了 .csv MIME 类型。 p>
虽然我喜欢创建以下解决方案的学习过程,但请不要使用它,因为我发现它存在许多问题,尤其是在规模上。为了避免下面我的解决方案带来的明显技术债务,选择维护良好的 RFC 驱动的 Scala 原生解决方案应该是您照顾当前和未来客户的方式。
我已经创建了特定的 CSV 相关函数,可以从中组合出更通用的解决方案。
事实证明,由于逗号 (,) 和双引号 (") 周围的异常,尝试解析 CSV 文件非常棘手。CSV 的规则是列值是否包含逗号或引号,整个值必须放在双引号中。如果值中出现任何双引号,则必须通过在现有双引号前插入额外的双引号来转义每个双引号。这就是为什么经常引用的StringOps.split(",") 方法根本不起作用,除非可以保证他们永远不会使用逗号/双引号转义规则遇到文件。这是一个非常不合理的保证。
此外,请考虑在有效的逗号分隔符和单个双引号的开头之间可能存在字符。或者在最后的双引号和下一个逗号或行尾之间可以有字符。解决此问题的规则是丢弃那些超出双引号范围的值。这也是一个简单的StringOps.split(",") 不仅答案不充分而且实际上不正确的另一个原因。
关于我使用StringOps.split(",") 发现的意外行为的最后一点说明。你知道这段代码sn-p中result有什么值吗?:
val result = ",,".split(",")
如果您猜到“result 引用了一个包含三个元素的 Array[String],其中每个元素都是一个空的 String”,那么您就错了。 result 引用了一个空的 Array[String]。对我来说,一个空的Array[String] 不是我期望或需要的答案。所以,为了所有神圣的爱,请把最后一颗钉子放在StringOps.split(",")棺材里!
所以,让我们从已读入的文件开始,该文件显示为List[String]。 object Parser下面是一个通用的解决方案,有两个功能; fromLine 和 fromLines。后一个函数fromLines 是为方便起见而提供的,它仅映射到前一个函数fromLine。
object Parser {
def fromLine(line: String): List[String] = {
def recursive(
lineRemaining: String
, isWithinDoubleQuotes: Boolean
, valueAccumulator: String
, accumulator: List[String]
): List[String] = {
if (lineRemaining.isEmpty)
valueAccumulator :: accumulator
else
if (lineRemaining.head == '"')
if (isWithinDoubleQuotes)
if (lineRemaining.tail.nonEmpty && lineRemaining.tail.head == '"')
//escaped double quote
recursive(lineRemaining.drop(2), true, valueAccumulator + '"', accumulator)
else
//end of double quote pair (ignore whatever's between here and the next comma)
recursive(lineRemaining.dropWhile(_ != ','), false, valueAccumulator, accumulator)
else
//start of a double quote pair (ignore whatever's in valueAccumulator)
recursive(lineRemaining.drop(1), true, "", accumulator)
else
if (isWithinDoubleQuotes)
//scan to next double quote
recursive(
lineRemaining.dropWhile(_ != '"')
, true
, valueAccumulator + lineRemaining.takeWhile(_ != '"')
, accumulator
)
else
if (lineRemaining.head == ',')
//advance to next field value
recursive(
lineRemaining.drop(1)
, false
, ""
, valueAccumulator :: accumulator
)
else
//scan to next double quote or comma
recursive(
lineRemaining.dropWhile(char => (char != '"') && (char != ','))
, false
, valueAccumulator + lineRemaining.takeWhile(char => (char != '"') && (char != ','))
, accumulator
)
}
if (line.nonEmpty)
recursive(line, false, "", Nil).reverse
else
Nil
}
def fromLines(lines: List[String]): List[List[String]] =
lines.map(fromLine)
}
要验证上述代码适用于所有各种奇怪的输入场景,需要创建一些测试用例。因此,使用 Eclipse ScalaIDE 工作表,我创建了一组简单的测试用例,我可以在其中直观地验证结果。这是工作表的内容。
val testRowsHardcoded: List[String] = {
val superTrickyTestCase = {
val dqx1 = '"'
val dqx2 = dqx1.toString + dqx1.toString
s"${dqx1}${dqx2}a${dqx2} , ${dqx2}1${dqx1} , ${dqx1}${dqx2}b${dqx2} , ${dqx2}2${dqx1} , ${dqx1}${dqx2}c${dqx2} , ${dqx2}3${dqx1}"
}
val nonTrickyTestCases =
"""
,,
a,b,c
a,,b,,c
a, b, c
a ,b ,c
a , b , c
"a,1","b,2","c,2"
"a"",""1","b"",""2","c"",""2"
"a"" , ""1" , "b"" , ""2" , "c"",""2"
""".split("\n").tail.toList
(superTrickyTestCase :: nonTrickyTestCases.reverse).reverse
}
val parsedLines =
Parser.fromLines(testRowsHardcoded)
parsedLines.map(_.mkString("|")).mkString("\n")
我直观地验证了测试是否正确完成,并给我留下了分解后的准确原始字符串。所以,我现在有了输入解析方面所需的东西,这样我就可以开始数据提炼了。
数据精炼完成后,我需要能够组合输出,以便我可以将精炼的数据发回,重新应用所有 CSV 编码规则。
所以,让我们从List[List[String]] 作为优化源开始。 object Composer下面是一个通用的解决方案,有两个功能; toLine 和 toLines。后一个函数 toLines 是为方便起见而提供的,并且仅映射到前一个函数 toLine。
object Composer {
def toLine(line: List[String]): String = {
def encode(value: String): String = {
if ((value.indexOf(',') < 0) && (value.indexOf('"') < 0))
//no commas or double quotes, so nothing to encode
value
else
//found a comma or a double quote,
// so double all the double quotes
// and then surround the whole result with double quotes
"\"" + value.replace("\"", "\"\"") + "\""
}
if (line.nonEmpty)
line.map(encode(_)).mkString(",")
else
""
}
def toLines(lines: List[List[String]]): List[String] =
lines.map(toLine)
}
为了验证上述代码适用于所有各种奇怪的输入场景,我重用了我用于 Parser 的测试用例。同样,使用 Eclipse ScalaIDE 工作表,我在现有代码下方添加了更多代码,我可以在其中直观地验证结果。这是我添加的代码:
val composedLines =
Composer.toLines(parsedLines)
composedLines.mkString("\n")
val parsedLines2 =
Parser.fromLines(composedLines)
parsedLines == parsedLines2
保存 Scala WorkSheet 后,它会执行其内容。最后一行应该显示值“true”。它是所有测试用例通过解析器、composer 和解析器往返的结果。
顺便说一句,事实证明有大量的variation around the definition of a "CSV file"。所以,这是上面的代码强制执行的the source for the rules。
PS。感谢@dhg 指出,有一个CSV Scala library 处理解析CSV,以防万一您想要比我上面的Scala 代码sn-ps 更健壮并且有更多选项的东西。