类型体系在scala-sql项目中的应用

类型体系是scala中最为复杂的特性,它既是scala强大的原因,也是scala号称宇宙最复杂语言的直接原因。而且,Scala正在自我革命,新的Scala3.0规划在很大程度上就是要在类型系统上大胆革命(参见:http://dotty.epfl.ch项目)。

我在最早构建 scala-sql 这个数据库访问库的时候,主要是想把groovy的一些实现方式和早期的esql方式迁移到scala中(参考:ORM是否必要? - 王在祥的回答 - 知乎
https://www.zhihu.com/question/23244681/answer/78839922),但第一个版本在类型化上实现得并不理想,主要存在一下的问题:

  1. sql"interpolation" 中的插值是动态类型的,而在执行时,则是通过反射,来决定是使用setInt还是setString等基础JDBC操作的。
     case class SQLWithArgs(sql: String, args: Seq[Any]) { ... }
    
    这种设计导致的问题是:我们可以将任意的值传给sql,而在执行过程中,则因为类型无法识别,而产生RuntimeException。从而无法享受到编译期的静态类型检查,这在感觉上也不符合scala的风格。
  2. 难以扩展自定义类型的支持。在scala-sql中,我们有很多的场景需要支持自定义的数据类型,譬如:
  3. 很蹩脚的 ORM 实现。虽然我有些反感 Hibernate、JPM这样的重量级ORM实现,但还是需要一个轻量级的ORM,不处理关系,而只完成简单层面的字段映射。在scala-sql 1.0中,是通过反射来进行mapping的,这一样会出现上述的两个问题。

在scala-sql 1.0应用了一段时间之后,我对这个库越来越不满意,开始在思考如何重构,建立一个更加简单统一的类型模型,并且能够支持用户扩展类型体系。在这个重构的过程中,最终完成了目前的 scala-sql 2.0版本。

在做这个重构之前,我一直在思考,数据库支持的类型,诸如intstringdate等有什么共性,是否可以用一个统一的类型 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
}

问题是,我们不可能让 IntString等类型继承这个接口,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了。

那么问题来了,IntString并不是一个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 也算是完美的、统一的支持了数据类型的扩展能力。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,802评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,109评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,683评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,458评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,452评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,505评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,901评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,550评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,763评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,556评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,629评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,330评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,898评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,897评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,140评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,807评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,339评论 2 342

推荐阅读更多精彩内容