什么是简单的(仅限Scala)方式读入然后写出一个通过List [List [String]]的小.csv文件?

cha*_*ium 6 csv parsing scala

我刚刚收到了一堆CSV(逗号分隔值)格式的杂乱数据文件.我需要对数据集进行一些正常的清理,验证和过滤工作.我将在Scala(2.11.7)中进行清理.

在我寻找两个方向的解决方案,输入解析和输出组合时,我发现大多数不明智的切线,包括来自" Scala Cookbook "的切线,在输入解析方面.而且大多数人专注于非常错误的解决方案"使用String.split(",")"以获得CSV线作为List[String].我在作曲输出方面几乎找不到任何东西.

什么样的漂亮的简单的Scala代码片段存在这很容易做到上述CSV往返? 我想避免导入整个库只是为了获取这两个函数(目前我的业务需求使用Java库不是一个可接受的选项).

cha*_*ium 8

我创建了特定的CSV相关函数,从中可以组成更通用的解决方案.

事实证明,由于逗号(,)和双引号(")的异常,尝试解析CSV文件非常棘手.对于CSV的规则,如果列值包含逗号或引号,则整个值必须放在双引号中.如果值中出现任何双引号,则必须通过在现有双引号前面插入一个额外的双引号来转义每个双引号.这是经常引用StringOps.split(",")方法的原因之一.除非可以保证他们永远不会遇到使用逗号/双引号转义规则的文件,否则根本不起作用.这是一个非常不合理的保证.

另外,请考虑有效逗号分隔符和单个双引号的开头之间可能存在字符.或者最终双引号与下一个逗号或行尾之间可以有字符.解决这个问题的规则是丢弃那些超出双引号边界值的规则.这是另一个原因,简单StringOps.split(",")不仅是答案不充分,而且实际上是不正确的.


关于我发现使用的意外行为的最后一点说明StringOps.split(",").您知道此代码段中的结果值是多少?:

val result = ",,".split(",")
Run Code Online (Sandbox Code Playgroud)

如果你猜到" result引用Array[String]包含三个元素,每个元素都是空的String",那么你就错了.result引用一个空的Array[String].对我来说,空洞Array[String]不是我期待或需要的答案.因此,对于所有神圣的爱,请请把最后的钉子放在StringOps.split(",")棺材里!


那么,让我们从已经读入的文件开始,该文件被呈现为List[String].下面object Parser是一个具有两个功能的通用解决方案; fromLinefromLines.后一个函数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)
}
Run Code Online (Sandbox Code Playgroud)

为了验证上述代码适用于所有各种奇怪的输入场景,需要创建一些测试用例.因此,使用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")
Run Code Online (Sandbox Code Playgroud)

我在视觉上验证了测试正确完成并给我留下了分解的准确原始字符串.所以,我现在拥有输入解析端所需的东西,所以我可以开始我的数据精炼.

在完成数据精炼之后,我需要能够编写输出,以便我可以重新应用所有CSV编码规则来发送我的精炼数据.

所以,让我们从a开始List[List[String]]作为改进的来源.下面object Composer是一个具有两个功能的通用解决方案; toLinetoLines.后一个函数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)
}
Run Code Online (Sandbox Code Playgroud)

为了验证上面的代码适用于所有各种奇怪的输入场景,我重用了我用于Parser的测试用例.再次,使用Eclipse ScalaIDE工作表,我在现有代码下面添加了一些代码,在那里我可以直观地验证结果.这是我添加的代码:

val composedLines =
  Composer.toLines(parsedLines)
composedLines.mkString("\n")
val parsedLines2 =
  Parser.fromLines(composedLines)
parsedLines == parsedLines2
Run Code Online (Sandbox Code Playgroud)

保存Scala工作表时,它会执行其内容.最后一行应显示"true"值.这是通过解析器,通过作曲家和通过解析器返回所有测试用例的结果.

顺便说一句,事实证明"CSV文件"的定义存在很多变化.所以,这是上面代码强制执行的规则来源.

PS.感谢@dhg指出它,有一个CSV Scala库可以处理解析CSV,以防万一你需要的东西可能更强大,并且有比我上面的Scala代码片段更多的选项.

  • 为避免使用预先存在的库,这似乎是一种荒谬的努力.更不用说现有的图书馆已经考虑了所有的角落情况并且经过了彻底的测试. (3认同)