【问题标题】:What's a simple (Scala only) way to read in and then write out a small .csv file passing through a List[List[String]]?读入然后写出一个通过 List[List[String]] 的小 .csv 文件的简单(仅限 Scala)方法是什么?
【发布时间】:2025-12-09 18:40:02
【问题描述】:

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

在搜索输入解析和输出组合这两个方向的解决方案时,我发现很多ill informed tangents,包括一个来自“Scala Cookbook”的输入解析端。大多数人专注于非常错误的解决方案“使用String.split(",")”将CSV 行返回为List[String]。我在作曲输出方面几乎一无所获。

存在什么样的简单的 Scala 代码 sn-ps 可以轻松完成上述 CSV 往返? 我想避免仅仅为了获取这两个函数而导入整个库(目前,对于我的业务需求来说,使用 Java 库不是一个可接受的选择)。

【问题讨论】:

  • 这个问题不是重复的,因为这个问题明确指出需要一个不使用库(尤其是 Java 库)的解决方案。顺便说一句,对于 *,dhg 引用的问题被关闭为“不具建设性”,这几乎不会使这个问题重复。
  • @dhg,我同意 chaotic3quilibrium——不是重复的。这是我见过的关于 CSV 文件和 Scala 的最有用的问题。
  • 好的,好的。但对我来说,“重新发明*”似乎仍然是一个糟糕的案例,而且远不如使用现有的、经过验证的解决方案来解决棘手的问题更安全。就我个人而言,当涉及到这样的事情时,我认为 scala 库和 java 库 + 瘦 scala 包装器之间没有区别。毕竟,首先使用 scala 的好处之一是我们可以利用大量预先存在的 java 库。
  • @dhg 我想我最好明白你的意思。我同意,到目前为止,最好的情况是选择使用现有的(隐含的健壮)CSV 实现而不是代码 sn-p(隐含的脆弱)。而且我将来可能会选择该选项(再次,tysvm 用于提供我添加的链接可能会回答)。但是,对于我们这些目前在日常繁重工作中使用 StringOps.split(",") 的人(包括忍受架构师阻止将其他库添加到他们的项目中),使用代码 sn- ps 我提供的至少是一个更好的选择。
  • 使用github.com/scala/scala-parser-combinators。它仍然是标准库的一部分,但已被分离。

标签: scala parsing csv


【解决方案1】:

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下面是一个通用的解决方案,有两个功能; 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)
}

要验证上述代码适用于所有各种奇怪的输入场景,需要创建一些测试用例。因此,使用 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下面是一个通用的解决方案,有两个功能; 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)
}

为了验证上述代码适用于所有各种奇怪的输入场景,我重用了我用于 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 更健壮并且有更多选项的东西。

【讨论】:

  • 您能否添加有关 csv 转义规则的来源?我不确定您描述的内容是否通用,因此很高兴知道什么软件会生成您的代码解析的 csvs。我假设 MS Excel 和兼容软件?无论如何,感谢您在此处记录此内容!
  • @SillyFreak Tysvm 提出请求。我只是附加了一个段落来涵盖 CSV 作为一个松散的定义和我使用的规则的基础。
  • 似乎只是为了避免使用预先存在的库而付出的巨大努力。更不用说现有的库已经考虑了所有的极端情况并经过了彻底的测试。
  • 谢谢你,@chaotic3quilibrium。我同意这很有用,而且很有指导意义。问和回答你自己的问题是正常的 * 做法吗?您是否像回答别人的问题一样获得积分?
  • @Phasmid 我不知道,老实说不在乎。我不得不花费数小时寻找(强大的)解决方案,或修复其他人损坏的库(假设它甚至可能),甚至在编写我自己的解决方案时(只是在我需要它时忘记它),成本要高得多在未来的项目中),而不是 SO(*)背后的任何基于非财务点的任意奖励系统。 SO 鼓励发布问题并提供您自己的答案。我以前做过这个,最后选择了一个与我不同的答案,因为它更好。
最近更新 更多