Scala CSV转case class对象,支持自定义解析

背景

转为对象:方便地读取CSV文件到Scala类型,便于使用已知数据。
构造数据:随机Scala对象,写入CSV文件,便于创建或Mock新数据。

要求&目标

不引入任何第三方库,能够定制CSV某列的格式,可以解析复杂的CSV结构,而不仅限于普通的简单格式,最终能配置解析的一些规则。能使用类型安全的方式编程。

下面这个使用shapeless实现,比较简洁,能处理一般的转换,主要问题是我们需要处理CSV中的JSON,而并不需要shapeless的其他功能,所以,嗯。。为了一个方法引入了一个库,不可取。不过这是shapeless官方给的例子,对简单需要确实够用了。

/* Copyright (c) 2022 bitlap.org */
package org.bitlap.testkit.csv

import shapeless._

import scala.collection.immutable.{ :: => Cons }
import scala.util.{ Failure, Success, Try }

/**
 * Csv encoder and decoder implement by shapeless.
 *
 * @author 梦境迷离
 * @since 2022/04/27
 * @version 1.0
 */
trait CsvConverter[T] {
  def from(s: String): Try[T]
  def to(t: T): String
}

object CsvConverter {
  def apply[T](implicit st: => CsvConverter[T]): CsvConverter[T] = st

  def fail(s: String): Failure[Nothing] = Failure(CsvException(s))

  // Primitives
  implicit def stringCSVConverter: CsvConverter[String] = new CsvConverter[String] {
    def from(s: String): Try[String] = Success(s)
    def to(s: String): String = s
  }

  implicit def intCsvConverter: CsvConverter[Int] = new CsvConverter[Int] {
    def from(s: String): Try[Int] = Try(s.toInt)
    def to(i: Int): String = i.toString
  }

  implicit def longCsvConverter: CsvConverter[Long] = new CsvConverter[Long] {
    def from(s: String): Try[Long] = Try(s.toLong)
    def to(i: Long): String = i.toString
  }

  implicit def doubleCsvConverter: CsvConverter[Double] = new CsvConverter[Double] {
    def from(s: String): Try[Double] = Try(s.toDouble)
    def to(i: Double): String = i.toString
  }

  def listCsvLinesConverter[A](l: List[String])(implicit ec: CsvConverter[A]): Try[List[A]] = l match {
    case Nil => Success(Nil)
    case Cons(s, ss) =>
      for {
        x <- ec.from(s)
        xs <- listCsvLinesConverter(ss)(ec)
      } yield Cons(x, xs)
  }

  implicit def listCsvConverter[A](implicit ec: CsvConverter[A]): CsvConverter[List[A]] = new CsvConverter[List[A]] {
    def from(s: String): Try[List[A]] = listCsvLinesConverter(s.split("\n").toList)(ec)
    def to(l: List[A]): String = l.map(ec.to).mkString("\n")
  }

  // HList
  implicit def deriveHNil: CsvConverter[HNil] =
    new CsvConverter[HNil] {
      def from(s: String): Try[HNil] = s match {
        case "" => Success(HNil)
        case s  => fail("Cannot convert '" ++ s ++ "' to HNil")
      }
      def to(n: HNil) = ""
    }

  implicit def deriveHCons[V, T <: HList](implicit
    scv: => CsvConverter[V],
    sct: => CsvConverter[T]
  ): CsvConverter[V :: T] =
    new CsvConverter[V :: T] {
      def from(s: String): Try[V :: T] = s.span(_ != ',') match {
        case (before, after) =>
          for {
            front <- scv.from(before)
            back <- sct.from(if (after.isEmpty) after else after.tail)
          } yield front :: back

        case _ => fail("Cannot convert '" ++ s ++ "' to HList")
      }

      def to(ft: V :: T): String =
        scv.to(ft.head) ++ "," ++ sct.to(ft.tail)
    }

  implicit def deriveHConsOption[V, T <: HList](implicit
    scv: => CsvConverter[V],
    sct: => CsvConverter[T]
  ): CsvConverter[Option[V] :: T] =
    new CsvConverter[Option[V] :: T] {
      def from(s: String): Try[Option[V] :: T] = s.span(_ != ',') match {
        case (before, after) =>
          (for {
            front <- scv.from(before)
            back <- sct.from(if (after.isEmpty) after else after.tail)
          } yield Some(front) :: back).orElse {
            sct.from(if (s.isEmpty) s else s.tail).map(None :: _)
          }

        case _ => fail("Cannot convert '" ++ s ++ "' to HList")
      }

      def to(ft: Option[V] :: T): String =
        ft.head.map(scv.to(_) ++ ",").getOrElse("") ++ sct.to(ft.tail)
    }

  // Anything with a Generic
  implicit def deriveClass[A, R](implicit gen: Generic.Aux[A, R], conv: CsvConverter[R]): CsvConverter[A] =
    new CsvConverter[A] {
      def from(s: String): Try[A] = conv.from(s).map(gen.from)
      def to(a: A): String = conv.to(gen.to(a))
    }
}

对应官方需要的这个实现,我也自己实现了一个叫做Converter,使用与官方例子一模一样如下所示:

    val line =
      """1,cdf,d,12,2,false,0.1,0.2
        |2,cdf,d,12,2,false,0.1,0.1""".stripMargin
    val dimension = Converter[List[Dimension]].toScala(line)
    assert(
      dimension.toString == "Some(List(Dimension(1,Some(cdf),d,12,2,false,0.1,0.2), Dimension(2,Some(cdf),d,12,2,false,0.1,0.1)))"
    )
    val csv = Converter[List[Dimension]].toCsvString(dimension.orNull)
    println(csv)
    assert(csv == line)

最终方案

给定下面这种复杂的CSV数据,其中第三列是个JSON:

val csvData =
  """100,1,"{""city"":""北京"",""os"":""Mac""}",vv,1
    |100,1,"{""city"":""北京"",""os"":""Mac""}",pv,2
    |100,1,"{""city"":""北京"",""os"":""Windows""}",vv,1
    |100,1,"{""city"":""北京"",""os"":""Windows""}",pv,3
    |100,2,"{""city"":""北京"",""os"":""Mac""}",vv,1
    |100,2,"{""city"":""北京"",""os"":""Mac""}",pv,5
    |100,3,"{""city"":""北京"",""os"":""Mac""}",vv,1
    |100,3,"{""city"":""北京"",""os"":""Mac""}",pv,2
    |200,1,"{""city"":""北京"",""os"":""Mac""}",vv,1
    |200,1,"{""city"":""北京"",""os"":""Mac""}",pv,2
    |200,1,"{""city"":""北京"",""os"":""Windows""}",vv,1
    |200,1,"{""city"":""北京"",""os"":""Windows""}",pv,3
    |200,2,"{""city"":""北京"",""os"":""Mac""}",vv,1
    |200,2,"{""city"":""北京"",""os"":""Mac""}",pv,5
    |200,3,"{""city"":""北京"",""os"":""Mac""}",vv,1
    |200,3,"{""city"":""北京"",""os"":""Mac""}",pv,2""".stripMargin

需要将其转换到下面的case class中,目前CSV列需要与case class的字段顺序一一对应:

case class Metric(time: Long, entity: Int, dimensions: List[Dimension], metricName: String, metricValue: Int)
case class Dimension(key: String, value: String)

使用ScalableBuilder解析CSV

val metrics: Array[Option[Metric]] = csvData
  .split("\n") // 1.按行分列
  .map(csv => // 2. 对每行csv进行解析
    ScalableBuilder[Metric] // 解析的目标结果类型,Metric是case class
      // setField用于设置dimensions字段应该怎样从CSV行的该列中被解析出来
      // dims值为字符串:{"city":"北京","os":"Mac"}
      // StringUtils.extraJsonValues 是默认提供的解析方法,当然也可以使用JSON,但是为了不依赖任何第三方库,我选择由用户指定如何解析,也更加灵活
      .setField[List[Dimension]](_.dimensions, dims => StringUtils.extraJsonValues[Dimension](dims)((k, v) => Dimension(k, v)))
      .build(csv) // 这里没有传,采用默认列分隔符 ','
      .toScala)  // 执行转换操作

Scalable DSL 设计

这个特质用于将CSV某行某列的一个数据转换为一个Scala对象。

Scalable转换器的粒度是某行某列,也就是说一个最小单元是CSV文件中的一行数据中的一列。

这个转换设计之所以仅针对一个具体数据而不是对CSV整行数据,是想着这样粒度更细更灵活,容易拓展。同时能复用很多“基本”类型转换器,毕竟这些类型每个对象都需要。

trait Scalable[T] {

  // 仅用于处理某行某列数据,实际上就是列数据的转换,这个命名可能有点问题,,
  @InternalApi
  def _toScala(column: String): Option[T] = None

  // 用于外部触发整个转换,实际上就是执行一系列的`_toScala`方法来处理每行的完整数据
  def toScala: Option[T] = None
}

使用宏自动生成代码

不使用宏的代码

不用宏的话,我们需要为每个对象编写一个方法,才能把CSV解析到Scala对象上。比如下面的toScala方法。

在这里,由于Scalable已经是最小粒度,并不方便直接使用,而且也没有办法拓展自定义,最终我们会通过一个中间对象,或者称为粒度更粗的一个转换器builder来实现。整个转换器builder实际就是去构造很多个Scalable,当然并不会真的去构造,而是通过implicit的方式寻找已知的转换器。

没有builder的情况下,我们需要手动编写这些列怎么被解析并创建Metric对象的,比如,已知toScala方法就是最终用于调用一系列转换器的_toScala方法,它的最终详细代码如下所示:

override def toScala: Option[Metric] = Option(Metric(
    _root_.org.bitlap.csv.core.Scalable[Long]._toScala(_columns(0)).getOrElse(0L),
    _root_.org.bitlap.csv.core.Scalable[Int]._toScala(_columns(1)).getOrElse(0), 
    _ScalableBuilderFunction$dimensions.apply(_columns(2)).asInstanceOf[Seq[org.bitlap.csv.core.test.Dimension]], // 比较特别,是个自定义函数,后面说
    _root_.org.bitlap.csv.core.Scalable[String]._toScala(_columns(3)).getOrElse(""), 
    _root_.org.bitlap.csv.core.Scalable[Int]._toScala(_columns(4)).getOrElse(0)
    )
)

可以清楚地的看到,在toScala方法中,我们构建了一个Metric对象,这里需要对每列都使用自己的Scalable转换器,而Scalable[Int]这些都是已知的“基本”类型,我们已经定义好他们的转换实现了:

  implicit val stringScalable: Scalable[String] = new Scalable[String] {
    override def _toScala(column: String): Option[String] = if (column.isEmpty) None else Some(column)
  }

  implicit val intScalable: Scalable[Int] = new Scalable[Int] {
    override def _toScala(column: String): Option[Int] = Option(column.toInt)
  }

所以toScala中的诸如_root_.org.bitlap.csv.core.Scalable[Int]._toScala(_columns(1)).getOrElse(0)这些方法都能直接被调用,因为在作用域内有隐式转换,嗯,,我们不需要为Int这些“基本”类型重复定义Scalable转换器,对于所有CSV文件,这些转换器都是有效的,所以已经被内置于Scalable的伴生对象中。

编写toScala是有点困难且是无聊的,如果需要对一列数据做特殊处理,那么还会出现很多if else代码,并且没有很好的判断实现是否正确。如果这件事交给机器,那么就相对简单了。嗯,,,对于一套固定实现或者说模式,完全没必要对每个对象都这么写一套,我们的目的当然是既少写代码又能通用,依赖最小还能拓展出自己想要的解析规则。

使用宏的代码

val metrics: Array[Option[Metric]] = csvData
  .split("\n") // 1.按行分列
  .map(csv => // 2. 对每行csv进行解析
    ScalableBuilder[Metric] // 解析的目标结果类型,Metric是case class
      // setField用于设置dimensions字段应该怎样从CSV行的该列中被解析出来
      // dims值为字符串:{"city":"北京","os":"Mac"}
      // StringUtils.extractJsonValues 是默认提供的解析方法,当然也可以使用JSON,但是为了不依赖任何第三方库,我选择由用户指定如何解析,也更加灵活
      .setField[List[Dimension]](_.dimensions, dims => StringUtils.extractJsonValues[Dimension](dims)((k, v) => Dimension(k, v)))
      // setField方法可以调用多次,对不同字段都进行处理。(还没来得及测试这种情况
      .build(csv) // 这里没有传,采用默认列分隔符 ','
      .toScala)  // 执行转换操作

是不是很简洁了?extractJsonValues主要是描述如何去解析这列数据,这里我使用的是字符串硬分隔。你也可以使用任意方法,比如直接引入第三方JSON库。(本文不依赖任何库,所以不讨论使用JSON的方式)

读取文件中的怎么办?提供一个ScalableHelper工具类直接读取classpath下的CSV:

    val metrics = ScalableHelper.readCsvFromClassPath[Metric]("simple_data.csv") { line =>
      ScalableBuilder[Metric]
        .setField[List[Dimension]](
          _.dimensions,
          dims => StringUtils.extractJsonValues[Dimension3](dims)((k, v) => Dimension(k, v))
        )
        .build(line)
        .toScala
    }

ScalableBuilder DSL 设计

/**
 * Builder to create a custom Csv Decoder.
 *
 * @author 梦境迷离
 * @version 1.0,2022/4/30
 */
class ScalableBuilder[T] {

  /**
   * 设置2个函数,第一个SF表示用T的类型为SF的字段来接受value值。第二个value函数表示将CSV某行某列(最小单元)数据,如何转换为一个SF类型。
   * 就像Java的builder模式,set方法一直返回builder对象本书。
   */
  def setField[SF](scalaField: T => SF, value: String => SF): ScalableBuilder[T] =
    macro DeriveScalableBuilder.setFieldImpl[T, SF] // 使用宏实现,因为每个对象都需要不同的builder

  /**
   * 使用之前setFiled的传入函数构建一个用于类型T的Scalable转换器,即:Scalable[T]
   *
   * @param line            这个转换器是针对行数据的,即一行转换为一个T对象。T对象的字段又需要去调用其他Scalable转换器,只有作用域中存在即可,直到Int,Long这些原始类型。。
   * @param columnSeparator CSV列数据的分隔符,一般使用逗号
   * @return 通过一些自定义操作,最终构建出了Scalable[T]的转换器,现在就能使用了。使用宏实现,因为每个对象都需要不同的Scalable[T]
   */
  def build(line: String, columnSeparator: Char): Scalable[T] = macro DeriveScalableBuilder.buildImpl[T]

}

object ScalableBuilder {
  // 这个方法主要是方便构建一个ScalableBuilder对象,初始化一个用于存储一些编译期间数据,也使用了宏实现。重点是setField和build
  def apply[T <: Product]: ScalableBuilder[T] = macro DeriveScalableBuilder.applyImpl[T]
}

这其实是Scala类型转换中一个常见的设计思路,很多Scala库都使用这种设计。

这个方案假设CSV列和case class列是一一对应的顺序,,主要为了方便处理,同时,这个方案在分隔列时,只考虑一般情况,比如,JSON结构的key和value都是被2个双引号包裹。

其他

整体来说实现还是比较初级,但是目前能解决需求,未来能做的事情:

  1. 支持配置CSV的转换器更多配置
  2. 支持列和CSV列的顺序不同
  3. 支持更复杂的列分隔规则,主要是边界和极端case

具体实现代码:https://github.com/bitlap/scala-macro-tools/tree/master/csv-core/src/main/scala/org/bitlap/csv/core

如果对你有帮助欢迎点个star。
如果使用有问题可以创建issue。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值