背景
转为对象:方便地读取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个双引号包裹。
其他
整体来说实现还是比较初级,但是目前能解决需求,未来能做的事情:
- 支持配置CSV的转换器更多配置
- 支持列和CSV列的顺序不同
- 支持更复杂的列分隔规则,主要是边界和极端case
具体实现代码:https://github.com/bitlap/scala-macro-tools/tree/master/csv-core/src/main/scala/org/bitlap/csv/core
如果对你有帮助欢迎点个star。
如果使用有问题可以创建issue。