在上一篇中,咱們示範了使用macro來重寫 Log 的 debug/info 方法,並大體的介紹了 macro 的基本語法、基本使用方法、以及macro背後的一些概念, 如AST等。那麼,本篇中,咱們將結合做者在 scala-sql 項目中的一些實際應用,向你展現 macro 能夠用來作什麼?怎麼用?html
scala-sql 是一個輕量級的 JDBC 庫,提供了在scala中訪問關係型數據庫的一個簡單的API,其定位是對面 scala開發者,提供一個能夠替換 spring-jdbc, iBatis 的數據訪問庫。相比 spring-jdbc, iBatis 等庫,scala-sql有一些本身獨特的特色:java
面向scala語言。所以,若是選擇 java 或者其餘編程語言,scala-sql基本上沒有意義。而spring-jdbc, iBatis等顯然不受這個限制。android
概念簡單。 scala-sql 爲 java.sql.Connection, javax.sql.DataSource 等對象擴展了:executeUpdate
、rows
、foreach
等方法, 但其語義徹底與 jdbc 中的概念是一致的。 熟悉jdbc的程序員,而且熟悉scala語法的程序員,scala-sql基本上沒有新的概念須要學習。程序員
強類型。 scala-sql目前是2.0版本,使用了sql"sql-statement" 的插值語法來替代了Jdbc中複雜的 setParameter 操做,而且是強類型和 可擴展的。正則表達式
強類型:若是你試圖傳入 URL、FILE 等對象時,scala編譯器會檢測錯誤,提示無效的參數類型。spring
可擴展:不一樣於JDBC,只能使用固定的一些類型,scala-sql經過Bound Context:JdbcValueAccessor
來擴展,也就是說,若是你定義了 對應的擴展,URL、FILE也是能夠做爲傳給JDBC的參數的。(固然,須要在JdbcValueAccessor
中正確的進行處理)。sql
函數式支持。scala-sql支持從ResultSet到Object的映射,在1.0版本中,是映射到JavaBean,而在2.0中,則是映射到Case Class。選擇 Case Class的緣由也是爲了更好的支持函數式編程。(支持Case Class與函數式編程有什麼關係?函數式編程的精髓就是無反作用的值變換, immutable纔是函數式編程的真愛)數據庫
編譯期間的SQL語法檢查。 這個特性可讓開發變得更加便捷一些,若是有SQL語法錯誤,或者存在錯誤拼寫的字段名、表名等狀況,在編譯時期就能 發現錯誤,能夠更快的暴露錯誤,縮短測試周期。編程
上面的幾個特性中,從ResultSet到Case Class的映射、以及編譯時期的SQL語法檢查,都是依賴scala macro來完成的。json
在scala-sql 1.0版本中,可使用以下的代碼:
class User {
var name: String = _
var age: Int = _
var address: String = _ }
val name = "Q%"
val users: List[User] = dataSource.rows[User](sql"select * from users where name like $name")
在scala-sql 1.0版本中,是經過反射的方式來完成這個映射的。除了是使用mutable的數據結構這個缺點(對FP不友好),經過反射來完成映射,有 如下的缺點: - 性能損失。(經過使用cache來存儲反射信息,能夠下降性能損失到一個不須要關注的水平) - 類型檢查。若是user中存在不適合映射的字段類型,那麼只有在運行期間才能瞭解到。
scala-sql 2.0版本決定支持Case Class(並廢棄掉對JavaBean的支持),Case Class數immutable的,也沒法經過反射的方式在運行時動態的進行 對象的構建,要解決這個問題,咱們只能爲每個Case Class在編譯時期靜態的生成一個從ResultSet到Case Class的映射。
case class User(name:String, age:Int, address:String) trait ResultSetMapper[T] {
def from(rs: ResultSet): T }
對某個Case Class好比User
來講,咱們須要有一個ResultSetMappper[User]
的實現,若是手動的寫代碼,這個代碼會形如:
/**
* the base class used in automate generated ResultSetMapper.
*/ abstract class CaseClassResultSetMapper[T] extends ResultSetMapper[T] { case class Field[T: JdbcValueAccessor](name: String, default: Option[T] = None) {
def apply(rs: ResultSetEx): T = {
if ( rs hasColumn name ){ rs.get[T](name) }
else { default match {
case Some(m) => m
case None => throw new RuntimeException(s"The ResultSet have no field $name but it is required") } } } } }
object UserMapper extends CaseClassResultSetMapper[User] {
override def from(rs: ResultSet): User = {
val NAME = Field[String]("name")
val AGE = Field[Int]("age")
val CLASSROOM = Field[Int]("classRoom")
User(NAME(rs), AGE(rs), CLASSROOM(rs)) } }
可是,若是對每個Case Class都須要定義對應的Mapper的話,這個事情就變得不那麼美好了:程序員都更喜歡作一些有挑戰性的任務,而對這寫近乎 Copy-Paste的任務或者不擅長,或者不樂意。固然,大量的Copy-Paste代碼實際上也會構成工程的災難,當某個點須要修改的時候,這簡直就是災難。 因此,在編程實踐領域,有"重複的代碼是最大的質量問題"這一說法,也是很是有道理的。
Macro實際上就是一種有效消除這類Copy-Paste代碼的利器,若是有一個macro,可以根據定義的Case Class,自動的生成咱們須要的上面的代碼,同時, 還能夠在生成代碼的同時,作一些類型檢查,在編譯時候就能發現錯誤,例如,若是某個字段是沒法從ResultSet映射的,則能夠在編譯時期報錯。
實際上,這也正式macro的應用場景。
object ResultSetMapper {
implicit def material[T]: ResultSetMapper[T] = macro Macros.generateCaseClassResultSetMapper[T] }
object Macros { def generateCaseClassResultSetMapper[T: c.WeakTypeTag](c: scala.reflect.macros.whitebox.Context): c.Tree = {
import c.universe._ val t: c.WeakTypeTag[T] = implicitly[c.WeakTypeTag[T]] // 獲取到類型參數 T 對應的TypeTag assert( t.tpe.typeSymbol.asClass.isCaseClass, s"only support CaseClass, but ${t.tpe.typeSymbol.fullName} is not" ) val companion = t.tpe.typeSymbol.asClass.companion // 獲取到 T 對應的伴生對象的類型,在這裏,主要是獲取Case Class某個字段的缺省值 // 經過獲取 Case Class的主構造方法,來獲取Case Class的字段定義 val constructor: c.universe.MethodSymbol = t.tpe.typeSymbol.asClass.primaryConstructor.asMethod var index = 0 val args: List[(c.Tree, c.Tree)] = constructor.paramLists(0).map { (p: c.universe.Symbol) => val term: c.universe.TermSymbol = p.asTerm index += 1 // search "apply$default$X" val name = term.name.toString
val newTerm = TermName(name.toString) val tree = if(term.isParamWithDefault) { // 這個字段有缺省值 val defMethod: c.universe.Symbol = companion.asModule.typeSignature.member(TermName("$lessinit$greater$default$" + index)) q"""val $newTerm = Field[${term.typeSignature}]($name, Some($companion.$defMethod) )""" }
else q"""val $newTerm = Field[${term.typeSignature}]($name) """ (q"""${newTerm}(rs)""", tree) } q"""
import wangzx.scala_commons.sql._
import java.sql.ResultSet
new CaseClassResultSetMapper[$t] {
..${args.map(_._2)} // 定義各個字段的提取器
override def from(arg: ResultSet): $t = {
val rs = new ResultSetEx(arg)
new $t( ..${args.map(_._1) } )
}
}
""" } }
因爲使用了 Quasiquote(http://docs.scala-lang.org/overviews/quasiquotes/intro.html),能夠更爲方便的處理AST(包括構造AST,和從AST中提取信息), 這段代碼仍是比較簡潔的。一些相關的API,讀者能夠經過:Scala Macros、 Scala Reflection、 Scala Quasiquotes等文檔作進一步的瞭解。
new $t( ..${args.map(_._1) } ) 中「..」用法以下:
scala> val ab = List(q"a", q"b")
scala> val fab = q"f(..$ab)"
fab: universe.Tree = f(a, b)
詳情請參考Scala 準引用 - Quasiquote介紹中 Splicing部分介紹
那麼,scala-sql又是如何實現對類型的檢查的呢?實際上,上面的這段代碼自身並無對Case Class的字段的類型進行檢查,而是經過生成的代碼: val NAME = Field[String]("name")
來完成類型檢查的,若是字段的類型,譬如爲 URL, 則val NAME = Field[URL]("name")
將沒法經過編譯 檢查。由於在Field[T: JdbcValueAccessor]
的構造中,全部的數據類型必須同時具有bound context: JdbcValueAccessor
,即存在一個 implicit val anyName: JdbcValueAccessor[T]
的隱式值,由這個值來負責處理從 T 到 SQL(包括parameter, ResultSet)的訪問操做。
固然,咱們也能夠在macro中,顯示的對T進行檢查,若是不符合,則顯示一個更爲友好的編譯錯誤:例如,字段 name 的類型 URL,不是合法的數據庫字段類型
, 這也是能夠作到的,咱們將會在後續的案列中,對其進行示範。
在咱們的這個案例中,咱們使用Macro來生成Case Class的ResultSetMapper,若是咱們要爲Case Class來提供JSON、XML映射,也可使用一樣的方式來 實現,相比經過反射方式(如GSON、fastjson)等,macro能夠生成更高效的代碼,同時,給到咱們更好的類型安全支持。
implicit class SQLStringContext(sc: StringContext) {
def sql(args: JdbcValue[_]*) = SQLWithArgs(sc.parts.mkString("?"), args)
def SQL(args: JdbcValue[_]*): SQLWithArgs = macro Macros.parseSQL } object Macros {
def parseSQL(c: reflect.macros.blackbox.Context)(args: c.Tree*): c.Tree = { import c.universe._ // 提取 SQL"..."中的sql字符串常量,這是咱們須要校驗的sql語句 val q"""$a(scala.StringContext.apply(..$literals))""" = c.prefix.tree val it: c.Tree = c.enclosingClass // 從當前代碼所在類的 @db(name="dbname") 中提取當前代碼的db名稱,主要是若是在項目中 // 可能訪問多個數據庫時,肯定當前sql的校驗是經過哪一個數據庫進行。 val db: String = it.symbol.annotations.flatMap { x => x.tree match {
case q"""new $t($name)""" if t.symbol.asClass.fullName == classOf[db].getName => val Literal(Constant(str: String)) = name
Some(str)
case _ => None } } match {
case Seq(name) => name
case _ => "default" } val x: List[Tree] = literals // 提取字符串常量 val stmt = x.map { case Literal(Constant(value: String)) => value }.mkString("?") try { // 鏈接到數據庫進行語法檢查。 SqlChecker.checkSqlGrammar(db, stmt, args.length) }
catch {
case ex: Throwable => // 有錯誤時,報告編譯錯誤。 c.error(c.enclosingPosition, s"SQL grammar erorr ${ex.getMessage}") } q"""wangzx.scala_commons.sql.SQLWithArgs($stmt, Seq(..$args))""" } }
這個例子相比上一個示例,增長了以下的特性:
提取當前代碼中的更多信息,例如,獲取當前定義類的 @db 標註信息。
提取當前代碼中的 字符串常量,對這些字符串進行進一步的檢查。在本例中,咱們是經過在編譯時期,鏈接到一個本地的數據庫,在這個數據庫上, 模擬的執行當前的SQL語句,既能夠檢查語法,還能夠檢查其中表名、字段名等標識符的拼寫是否正確。
相似的,咱們能夠編寫macro,對不少的字符串進行進一步的檢查。 例如:
對正則表達式進行語法檢查。
對日期格式進行語法檢查 而這些在傳統的代碼中,都是在運行期進行的,提早到編譯器進行,不只可讓代碼變得更安全,並且也更符合敏捷的思路,Let it fail fast.
參考:
轉自: