类型体系是scala中最为复杂的特性,它既是scala强大的原因,也是scala号称宇宙最复杂语言的直接原因。而且,Scala正在自我革命,新的Scala3.0规划在很大程度上就是要在类型系统上大胆革命(参见:http://dotty.epfl.ch项目)。
我在最早构建 scala-sql 这个数据库访问库的时候,主要是想把groovy的一些实现方式和早期的esql方式迁移到scala中(参考:ORM是否必要? - 王在祥的回答 - 知乎
https://www.zhihu.com/question/23244681/answer/78839922),但第一个版本在类型化上实现得并不理想,主要存在一下的问题:
-
sql"interpolation"
中的插值是动态类型的,而在执行时,则是通过反射,来决定是使用setInt
还是setString
等基础JDBC操作的。
这种设计导致的问题是:我们可以将任意的值传给sql,而在执行过程中,则因为类型无法识别,而产生case class SQLWithArgs(sql: String, args: Seq[Any]) { ... }
RuntimeException
。从而无法享受到编译期的静态类型检查,这在感觉上也不符合scala的风格。 - 难以扩展自定义类型的支持。在scala-sql中,我们有很多的场景需要支持自定义的数据类型,譬如:
- 支持 scala.BigDecimal 类型,而不仅仅是 java.math.BigDecimal类型。
- 支持 Option[Int], Option[String]等类型。
- 支持 joda.Date 等类型。
在 scala-sql 1.0中,虽然也支持了一定的扩展类型,但存在很严重的问题,其一是:这个扩展能力只能内建在内部,无法让用户扩展。其二是采用反射的方式来实现,代码中大量的case match操作,很不优美。(参考:https://github.com/wangzaixiang/scala-sql/blob/v1.0.6/src/main/scala/wangzx/scala_commons/sql/RichConnection.scala)
- 很蹩脚的 ORM 实现。虽然我有些反感 Hibernate、JPM这样的重量级ORM实现,但还是需要一个轻量级的ORM,不处理关系,而只完成简单层面的字段映射。在scala-sql 1.0中,是通过反射来进行mapping的,这一样会出现上述的两个问题。
在scala-sql 1.0应用了一段时间之后,我对这个库越来越不满意,开始在思考如何重构,建立一个更加简单统一的类型模型,并且能够支持用户扩展类型体系。在这个重构的过程中,最终完成了目前的 scala-sql 2.0版本。
在做这个重构之前,我一直在思考,数据库支持的类型,诸如int
、string
、date
等有什么共性,是否可以用一个统一的类型 T
来 描述呢?哪些是这个类型的基本操作呢?
- 作为 parameter传入给 Statement。
- 从 ResultSet 中获取值。
基于此,我们需要这样一个类型:
trait T {
def passIn(stmt: PreparedStatement, index: Int)
def passOut(rs: ResultSet, index: Int): T
def passOut(rs: ResultSet, name: String): T
}
问题是,我们不可能让 Int
、String
等类型继承这个接口,Scala的扩展方法也并不能很好的满足这个场景。而且,即便是我们为Int、String扩展了上述的方法,也并不会好用。因为passout的时候,我们更希望将passOut的值赋给我们的目标变量,而不是调用目标变量的方法来改变它的值。
这个时候,scala的 Context Bound 类型就是非常有意义了,我们定义了:
trait JdbcValueAccessor[T] {
def passIn(stmt: PreparedStatement, index: Int, value: T)
def passOut(rs: ResultSet, index: Int): T
def passOut(rs: ResultSet, name: String): T
}
JdbcValueAccessor
并不是一个值类型,而是一个处理某种值类型T的能力接口,可以这么读:JdbcValueAccessor[String]
是一个处理String值类型的JdbcValueAccessor
,它可以将String
传递给Statement
,也可以从ResultSet
中提取String
。在这理,JdbcValueAccessor[String] 就是 String 的一个能力绑定,为String对象赋予了作为JdbcValue的能力。任何时候,我们需要将String作为一个JdbcValue处理的时候,我们也需要你提供这个能力对象,完成对应的操作。
在这里,我们并不需要对String、Int进行任何的改造,我们只是将数据库访问这种能力提取出来,作为一个JdbcValueAccessor,这种能力并不一定只是一个扩展方法,而可能是一个扩展方法集合。
case class SQLWithArgs(sql: String, args: Seq[JdbcValue[_]]) { ... }
case class JdbcValue[T: JdbcValueAccessor](value: T) {
def accessor: JdbcValueAccessor[T] = implicitly[JdbcValueAccessor[T]]
def passIn(stmt: PreparedStatement, index: Int) = accessor.passIn(stmt, index, value)
}
现在的sql插值参数,都是强类型的了,任何不符合JdbcValue的对象都不能作为插值来传递,而如果有了JdbcValue,自然,我们知道如何将这个值传递给Statement了。
那么问题来了,Int
、String
并不是一个JdbcValue类型啊?怎么传递给sql""插值呢?每次都做一次转换?如JdbcValue("Hello", StringJdbcValueAccessor)"
这样做的话,就非常的不友好了。这时,Scala的隐式转换就非常实用了。
object JdbcValue {
implicit def wrap[T: JdbcValueAccessor](t: T): JdbcValue[T] = JdbcValue(t)
implicit def wrap[T: JdbcValueAccessor](t: Option[T]): JdbcValue[Option[T]] = JdbcValue(t)(new JdbcValueAccessor_Option[T])
}
当我们需要将 Int
转换为 JdbcValue[Int]
的时候,有一下几个隐式转换方法是可以派上用场的:
- 全局的
implicit def convertIntToJdbcValue(i: Int): JdbcValue[Int]
- 在 object Int 中的
implicit def convertIntToJdbcValue(i: Int): JdbcValue[Int]
这个不现实了。 - 在 object JdbcValue中的
implicit def convertIntToJdbcValue(i: Int): JdbcValue[Int]
如果采用这种方法,我们需要为每一种类型,都在object JdbcValue中定义一个implicit方法,这样做仍然存在问题:无法支持用户扩展的类型。 - 在 object JdbcValue中,
implicit def wrap[T: JdbcValueAccessor](t: T): JdbcValue[T] = JdbcValue(t)
通过这个方法,我们可以把任意的 T 都转换为 JdbcValue[T],前提是存在相应的 JdbcValueAccessor 上下文绑定。 而这,其实是另外一个隐式值了。
implicit def wrap[T: JdbcValueAccessor](t: T): JdbcValue[T] = JdbcValue(t)
这个写法,和下面的写法是完全一致的,是一个语法上的甜品:
implicit def wrap(t: T)(implicit value: JdbcValueAccessor[T]): JdbcValue[T] = JdbcValue(t, implicitly[JdbcValueAccessor[T]])
通过上面的定义,现在我们可以支持将任意的T传递给 sql 插值了。前提是我们为之定义了一个 JdbcValueAccessor[T] 的上下文绑定。在scala-sql中,我们在 wangzx.scala_commons.sql
这个package对象中定义了几乎所有的内置类型的绑定:
- Boolean
- Byte
- Short
- Int
- Long
- Float
- Double
- BigDecimal & scala.BigDecimal
- Date、Timestamp
- String
- Array[Byte]
- Option[T: JdbcValueAccessor] 所有的JdbcValue类型,都可以作为Option[T]传递。
而要新增一种类型,你只需要参考内置类型,定义一个扩展的 JdbcValueAccessor[T]即可,不需要对scala-s ql 库做任何的修改。
作为一个扩展的示例,你可以参考 框架中的一个扩展:mysql.MySqlBitSet:
case class MySqlBitSet(val mask: Long) {
def isSet(n: Int) = {
assert(n >= 0 && n < 64)
((mask >> n) & 0x1L) == 1
}
override def toString: String = s"b'${mask.toBinaryString}'"
}
object MySqlBitSet {
implicit object jdbcValueAccessor extends JdbcValueAccessor[MySqlBitSet] {
override def passIn(stmt: PreparedStatement, index: Int, value: MySqlBitSet): Unit =
stmt.setBytes(index, toByteArray(value.mask))
override def passOut(rs: ResultSet, index: Int): MySqlBitSet = { ... }
override def passOut(rs: ResultSet, name: String): MySqlBitSet = { ... }
}
}
从ResultSet中提取值
上面的例子,都是介绍如何将 T 作为插值 传给PreparedStatement, 而如果需要从 ResultSet中读取 T 时,我们就会实用到 JdbcValueAccessor[T].passOut了。
def rows[T : ResultSetMapper](sql: SQLWithArgs): List[T] = ...
在这里,我们要从sql执行的结果中提取 T 时,需要一个将 ResultSet 转还为 T 的能力对象,我们称之为:ResultSetMapper[T],这个对象是这样定义的:
trait ResultSetMapper[T] {
def from(rs: ResultSet): T
}
实际上,有了这个能力对象,rows的实现是非常简单的,这里就不赘述了。相反,如何为 T 准备一个 ResultSetMapper[T] 就要复杂的多。
先看一个简单的实现:
implicit object ResultSetMapper_Int extends ResultSetMapper[Int] {
override def from(rs: ResultSet): Int = rs.getInt(1)
}
这个实现是从 ResultSet 中映射一个 Int 值,这适合与 "select count(*) from table"这样的只有一个返回字段的场景。
而对于多字段的结果集呢,以下是一个示例:
case class User(name: String, age: Int, classRoom: Int = 1)
implicit object ResultSetMapper_User extends ResultSetMapper[User] {
override def from(rs: ResultSet): User = {
val name = implictly[JdbcValueAccessor[String]].passOut(rs, "name")
val age = implicitly[JdbcValueAccessor[Int]].passOut(rs, "age")
val classRoom = implicitly[JdbcValueAccessor[Int]].passOut(rs, "classroom")
User(name, age, classRoom)
}
}
可以看出来,这个mapper实际上也是基于 JdbcValueAccessor的,这样,就可以支持User中实用任何的字段,只要这个字段有 JdbcValueAccessor[T] 的上下文绑定。
当然,如果,对每一个Bean,都需要编写这样的一个 Mapper 的话,这只能算是矛盾转移,其代码的工作量会非常之大,没有什么实用之处。 不过,这样的代码纯属体力劳动,完全可以使用 Scala 的另外一个利器:Macro,让编译器自动生成。这也正是 scala-sql 2.0 中所提供的。对所有的Case Class,只要满足:
- 每个字段的类型 T 都满足 JdbcValueAccessor[T] 上下文限定
scala-sql 就可以自动的通过 macro 来生成其 ResultSetMapper。
在这个意义上,JdbcValueAccessor[T] 这个上下文限定统一了 passIn,passOut,scala-sql 2.0 也算是完美的、统一的支持了数据类型的扩展能力。