使用 Scala 解析器组合器解析 CSV 文件

Posted

技术标签:

【中文标题】使用 Scala 解析器组合器解析 CSV 文件【英文标题】:Use Scala parser combinator to parse CSV files 【发布时间】:2011-07-01 02:03:40 【问题描述】:

我正在尝试使用 Scala 解析器组合器编写 CSV 解析器。语法基于RFC4180。我想出了以下代码。它几乎可以工作,但我无法让它正确分离不同的记录。我错过了什么?

object CSV extends RegexParsers 
  def COMMA   = ","
  def DQUOTE  = "\""
  def DQUOTE2 = "\"\"" ^^  case _ => "\"" 
  def CR      = "\r"
  def LF      = "\n"
  def CRLF    = "\r\n"
  def TXT     = "[^\",\r\n]".r
  
  def file: Parser[List[List[String]]] = ((record~((CRLF~>record)*))<~(CRLF?)) ^^  
    case r~rs => r::rs
  
  def record: Parser[List[String]] = (field~((COMMA~>field)*)) ^^ 
    case f~fs => f::fs
  
  def field: Parser[String] = escaped|nonescaped
  def escaped: Parser[String] = (DQUOTE~>((TXT|COMMA|CR|LF|DQUOTE2)*)<~DQUOTE) ^^  case ls => ls.mkString("")
  def nonescaped: Parser[String] = (TXT*) ^^  case ls => ls.mkString("") 

  def parse(s: String) = parseAll(file, s) match 
    case Success(res, _) => res
    case _ => List[List[String]]()
  



println(CSV.parse(""" "foo", "bar", 123""" + "\r\n" + 
  "hello, world, 456" + "\r\n" +
  """ spam, 789, egg"""))

// Output: List(List(foo, bar, 123hello, world, 456spam, 789, egg)) 
// Expected: List(List(foo, bar, 123), List(hello, world, 456), List(spam, 789, egg))

更新:问题已解决

默认的 RegexParsers 使用正则表达式 [\s]+ 忽略空格,包括空格、制表符、回车和换行符。上面的解析器无法分离记录的问题就是因为这个。我们需要禁用 skipWhitespace 模式。将空格定义替换为 [ \t] 并不能解决问题,因为它将忽略字段中的所有空格(因此 CSV 中的“foo bar”变为“foobar”),这是不希望的。因此解析器的更新源是

import scala.util.parsing.combinator._

// A CSV parser based on RFC4180
// https://www.rfc-editor.org/rfc/rfc4180

object CSV extends RegexParsers 
  override val skipWhitespace = false   // meaningful spaces in CSV

  def COMMA   = ","
  def DQUOTE  = "\""
  def DQUOTE2 = "\"\"" ^^  case _ => "\""   // combine 2 dquotes into 1
  def CRLF    = "\r\n" | "\n"
  def TXT     = "[^\",\r\n]".r
  def SPACES  = "[ \t]+".r

  def file: Parser[List[List[String]]] = repsep(record, CRLF) <~ (CRLF?)

  def record: Parser[List[String]] = repsep(field, COMMA)

  def field: Parser[String] = escaped|nonescaped


  def escaped: Parser[String] = 
    ((SPACES?)~>DQUOTE~>((TXT|COMMA|CRLF|DQUOTE2)*)<~DQUOTE<~(SPACES?)) ^^  
      case ls => ls.mkString("")
    
  

  def nonescaped: Parser[String] = (TXT*) ^^  case ls => ls.mkString("") 



  def parse(s: String) = parseAll(file, s) match 
    case Success(res, _) => res
    case e => throw new Exception(e.toString)
  

【问题讨论】:

为什么常量是用def而不是val定义的?有什么好处吗? Check this out。 tl;dr def 使用更少的内存,val 更快。 对于编译时常量几乎没有什么区别——“val”将在构造函数中使用该常量初始化一个字段,然后创建一个返回其值的方法,而“def”将简单地返回常量 - 对于编译时常量,这实际上是免费的。 @rancidfishbreath 它是一个对象,所以只有 1 个实例,所以如果它节省任何内存,这将是微不足道的 【参考方案1】:

您错过的是空白。我进行了一些额外的改进。

import scala.util.parsing.combinator._

object CSV extends RegexParsers 
  override protected val whiteSpace = """[ \t]""".r

  def COMMA   = ","
  def DQUOTE  = "\""
  def DQUOTE2 = "\"\"" ^^  case _ => "\"" 
  def CR      = "\r"
  def LF      = "\n"
  def CRLF    = "\r\n"
  def TXT     = "[^\",\r\n]".r

  def file: Parser[List[List[String]]] = repsep(record, CRLF) <~ opt(CRLF)
  def record: Parser[List[String]] = rep1sep(field, COMMA)
  def field: Parser[String] = (escaped|nonescaped)
  def escaped: Parser[String] = (DQUOTE~>((TXT|COMMA|CR|LF|DQUOTE2)*)<~DQUOTE) ^^  case ls => ls.mkString("")
  def nonescaped: Parser[String] = (TXT*) ^^  case ls => ls.mkString("") 

  def parse(s: String) = parseAll(file, s) match 
    case Success(res, _) => res
    case _ => List[List[String]]()
  

【讨论】:

这与protected val whiteSpace = """\s+""".rRegexParsers 的默认设置)有何不同? ——啊,明白了。换行符也是空格,因此您的覆盖将其从考虑中删除。 非常感谢您指出空白问题!您的解决方案正确解析不同的记录。但是,它也会忽略字段中的空格。采纳您的更改后,请查看我更新的问题以查看我的解决方案。 如果你想支持非windows换行,请将文件中的CRLF更改为CRLF|LF(在Linux中只是\n)【参考方案2】:

从 2.11 开始,Scala 标准库中的 Scala Parser Combinators 库没有充分的理由不使用性能更高的 Parboiled2 库。 这是 Parboiled2 的 DSL 中 CSV 解析器的一个版本:

/*  based on comments in https://github.com/sirthias/parboiled2/issues/61 */
import org.parboiled2._
case class Parboiled2CsvParser(input: ParserInput, delimeter: String) extends Parser 
  def DQUOTE = '"'
  def DELIMITER_TOKEN = rule(capture(delimeter))
  def DQUOTE2 = rule("\"\"" ~ push("\""))
  def CRLF = rule(capture("\r\n" | "\n"))
  def NON_CAPTURING_CRLF = rule("\r\n" | "\n")

  val delims = s"$delimeter\r\n" + DQUOTE
  def TXT = rule(capture(!anyOf(delims) ~ ANY))
  val WHITESPACE = CharPredicate(" \t")
  def SPACES: Rule0 = rule(oneOrMore(WHITESPACE))

  def escaped = rule(optional(SPACES) ~
    DQUOTE ~ (zeroOrMore(DELIMITER_TOKEN | TXT | CRLF | DQUOTE2) ~ DQUOTE ~
    optional(SPACES)) ~> (_.mkString("")))
  def nonEscaped = rule(zeroOrMore(TXT | capture(DQUOTE)) ~> (_.mkString("")))

  def field = rule(escaped | nonEscaped)
  def row: Rule1[Seq[String]] = rule(oneOrMore(field).separatedBy(delimeter))
  def file = rule(zeroOrMore(row).separatedBy(NON_CAPTURING_CRLF))

  def parsed() : Try[Seq[Seq[String]]] = file.run()

【讨论】:

既然你努力写了一篇关于它的好博客,我们不妨把链接贴在这里:-) maciejb.me/2014/07/11/… 不应该 CRLF = rule(capture("\n\r" | "\n"))CRLF = rule(capture("\r\n" | "\n")) 吗?又是NON_CAPTURING_CRLF? @Toby 当然应该!感谢您指出这一点,我已经更正了答案。 好东西。它不应该支持开箱即用的(双)引用值吗?在我看来应该是这样,但它并没有像我期望的那样解析它。即,“a,b”,“c” @Toby 当然应该!我也修好了那个。 :-)【参考方案3】:

RegexParsers 解析器的默认空格是\s+,其中包括新行。所以CRLFCRLF 永远没有机会被处理,因为它会被解析器自动跳过。

【讨论】:

以上是关于使用 Scala 解析器组合器解析 CSV 文件的主要内容,如果未能解决你的问题,请参考以下文章

Google Adwords CSV 文件解析器

如何在 C# 中使用 CSV 帮助器解析 TSV 文件?

Scala Sax 解析器无法处理 <!DOCTYPE XML>

为 CSV 文件制作解析器以获取大量数据

如何使用 scala 在 playframework 2.0 中重用异常解析器

C++ 中的 CSV 解析器不读取第一个元素