# 一个基于属性匹配的函数式对象映射系统的设计
## by 水山清风
## 背景知识
- slick: 数据库操作框架(函数式关系映射)
- circe: 最流行的 scala json 库之一(Generic derivation)
- play: 最流行的 scala web 框架,本次主要讨论 json 和 BodyParser 部分
- DTO(Data Transfer Object): 数据传输对象
## 课题一
- 改进 circe
##
case class User(id: Option[Int], first: String, last: String)
val userJson = io.circe.parser.parse("{ \"id\": 123, \"first\": \"firstName\", \"last\": \"lastName\" }")
userJson.as[User]
##
- 问题1
- 不能处理特例,遇到
##
{ "id": "123", "first": "firstName", "last": "lastName" }
##
- 只能手写 Decoder,不能智能识别
## 期望代码
case class User(id: Option[Int], first: String, last: String)
object Table {
val id = Decoder.decodeJson.emapTry { json =>
if (json.isString)
json.as[String].toTry.map(_.toInt)
else
json.as[Int].toTry
}
}
val userJson = io.circe.parser.parse("{ \"id\": \"123\", \"first\": \"firstName\", \"last\": \"lastName\" }")
decodeWithTable[User](Table)(userJson) //Right(User(Option(123), firstName, lastName))
- 只声明要特殊处理的列
- 问题2
- 编译速度缓慢
- 原因: 无法并发获取 HList 的隐式转换
- circe 解决方案: 重写 shapeless 部分功能
- 缺点: 难度大,没有可以适配其他库的通用 api
- 问题3
- 无法调试或调试困难,发生编译错误时难以定位出现问题的属性
## 课题二
- 改进 slick
##
case class User(id: Option[Int], first: String, last: String)
class Users(tag: Tag) extends Table[User](tag, "users") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def first = column[String]("first")
def last = column[String]("last")
def * = (id.?, first, last) <> (User.tupled, User.unapply)
}
val users = TableQuery[Users]
- 问题1
- 无法自动映射属性
- 问题2
- 超过 22 字段的大表将会把问题 1 放大数倍,造成维护困难
- 问题3
- 与 circe 相同,编译速度缓慢,超过 100 字段的表编译一次需要 20s+
- 问题4
- 与 circe 相反,slick 处处特例,无法省略没有特殊需求的列声明
- 问题5
- 与 circe 的问题相同,无法调试或调试困难.发生编译错误时难以定位出现问题的属性
## 期望代码一
case class User(id: Option[Int], first: String, last: String)
class Users(tag: Tag) extends Table[User](tag, "users") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def first = column[String]("first")
def last = column[String]("last")
def * = autoMapper[User](this) //自动映射
}
val users = TableQuery[Users]
## 期望代码二
case class User(id: Option[Int], first: String, last: String)
class Users(tag: Tag) extends Table[User](tag, "users") with AutoMapperHelper {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def * = autoMapper[User](this) //自动创建遵循默认行为的列
}
val users = TableQuery[Users]
# 核心问题:
# 我们需要一个对象到对象的自动映射系统
- 我们需要一个对象到对象的自动映射系统
- 目标:
1. 1.匹配规则必须直观
1. 做法:基于属性名称匹配
1. 2.足够通用,可以面对几乎所有特有的或普遍的类型系统
1. 做法:不使用 Monad,自建抽象
1. 较普遍的类型系统: circe, play-json
1. 特有的类型系统: slick, shapeless 相关, tuple 相关
1. 反例:无法处理的类型系统: quill,原因:深度 Macro 相关
1. 3.动态映射和静态映射可以同时存在
1. 既能映射静态的普通类型对象属性,也能映射动态的集合属性(List, Map, JsonObject)
- 目标:
1. 4.类型系统可推导性强
1. 做法:类型驱动
1. 5.较快的编译速度
1. 解决方法:见下文
1. 6.可调试,可定位的编译期错误
1. 解决方法:见下文
- 难点:
1. 1.不能存在性能问题
1. 解决方法1:不使用任何运行时反射(Macro, Implicit)
1. 解决方法2:不限定任何特定中间数据格式
1. 2.任何时候不可以回退到非自动映射
1. 3.Macro 模块代码要易于维护和理解
1. 4.万一 Macro 模块失效,需要提供一定的回退措施
1. 5.不能有 22 限制
# 接下来一小时,让我们开始艰难的系统设计
### 项目源码地址: [scalax/asuna](https://github.com/scalax/asuna)
# 最简单的对象映射情况
## java 模式的 DTO(Data Transfer Object)
case class Foo(name: String, id: Int, age: Int)
case class Bar(id: Int, name: String, age: Int)
val convert: Foo => Bar = ???
### 属性匹配规则:按顺序匹配 vs 按名称匹配
- 按顺序匹配:
- 现有支持: shapeless
- 优点: 1.支持完善
- 缺点: 1.难以赋予 HList 的 index 实际意义,必须保持严格的先后顺序对应关系
- 2.数据结构修改后难以维护
- 3.HList 提升编译速度的难度较大
- 4.不能处理数据源不一定是 case class 的情况
### 属性匹配规则:按顺序匹配 vs 按名称匹配
- 按名称匹配:
- 现有支持: 无或只有简单的 case class copy 支持([kailuowang/henkan](https://github.com/kailuowang/henkan))
- 优点: 1.实现比较自由,可以实现更多自定义映射规则
- 2.匹配规则比较直观,容易在对象间互相查找对应的属性
- 3.不需要关心顺序,在定义时和维护时更友好
- 4.调优空间大
- 缺点: 1.宏的实现十分复杂
- 2.目前的宏将会被 scala 抛弃(有新的替代)
# 结论: 按属性名称匹配
## DTO 实现结果
case class Foo(name: String, id: Int, age: Int)
case class Bar(id: Int, name: String, age: Int)
val convert: Foo => Bar = { fooModel: Foo =>
dto.effect(dto.singleModel[Bar](fooModel).compile).value
}
val foo = Foo("name", 1234, 6789)
println(foo) //Foo(name,1234,6789)
println(convert(foo)) //Bar(1234,name,6789)
- 特点: 1.按名称匹配
- 2.类型安全
- 3.没有运行时反射,保证性能
### [示例代码](https://github.com/djx314/djx314.github.io/tree/master/lesson01/src/main/scala)
### 运行命令: sbt test01
## 然而,事情总不会那么美好
- scala 风格的 DTO
##
case class Foo(name: Future[String], id: Either[Exception, Int], age: Future[Either[Exception, Int]])
case class Bar(id: Int, name: String, age: Int)
val convert: Foo => Bar = ???
##
- 或者
##
object Foo {
val name = Future.successful("name")
val id = Right(1234)
val age = Future.successful(Right(6789))
}
case class Bar(id: Int, name: String, age: Int)
val newModel: Bar = ???
## 新的实现
- [示例代码](https://github.com/djx314/djx314.github.io/tree/master/lesson02/src/main/scala)
- 运行命令: sbt test02
##
val ec = scala.concurrent.ExecutionContext.Implicits.global
val newModel: Future[Either[Exception, Bar]] =
fe.effect(fe.singleModel[Bar](Foo).compile).data(ec)
println(Await.result(newModel, Duration.Inf)) //Right(Bar(1234,name,6789))
##
- 小技巧: 无须直接引入 ExecutionContext,作为参数在最后传入即可,减少 implicit not found 时的不确定因素
- 开始简要介绍概念 DecoderShape
case class SplitData[T, R](current: T, left: R)
trait DecoderShape[-E, RepCol, DataCol] extends CommonShape[E, RepCol] {
override type Target
type Data
override def packed: DecoderShape.Aux[Target, Data, Target, RepCol, DataCol]
override def wrapRep(base: => E): Target
override def buildRep(base: Target, oldRep: RepCol): RepCol
def takeData(rep: Target, oldData: DataCol): SplitData[Data, DataCol]
}
##
- RepCol 列的临时容器,例如 List[Decoder[_]]
- DataCol 数据的临时容器,例如 List[Any], (Any, Any)
- buildRep 叠加列
- takeData 取出根据协议由 RepCol 生成 DataCol 后的数据
- 重点:由 Any 类型到特定类型
trait EncoderShape[-E, RepCol, DataCol] extends CommonShape[E, RepCol] {
override type Target
type Data
override def packed: EncoderShape.Aux[Target, Data, Target, RepCol, DataCol]
override def wrapRep(base: => E): Target
override def buildRep(base: Target, oldRep: RepCol): RepCol
def buildData(data: Data, rep: Target, oldData: DataCol): DataCol
}
- RepCol 列的临时容器,例如 List[Decoder[_]]
- DataCol 数据的临时容器,例如 List[Any], (Any, Any)
- buildRep 叠加列
- buildData 由特定类型数据转化为 DataCol,再根据协议与 RepCol 发生作用
- 重点:由特定类型到 Any 类型
trait FormatterShape[-E, RepCol, EncoderDataCol, DecoderDataCol]
extends EncoderShape[E, RepCol, EncoderDataCol] with DecoderShape[E, RepCol, DecoderDataCol]
with CommonShape[E, RepCol] {
override type Target
override type Data
override def packed: FormatterShape.Aux[Target, Data, Target, RepCol, EncoderDataCol, DecoderDataCol]
override def wrapRep(base: => E): Target
override def buildRep(base: Target, oldRep: RepCol): RepCol
override def takeData(rep: Target, oldData: DecoderDataCol): SplitData[Data, DecoderDataCol]
override def buildData(data: Data, rep: Target, oldData: EncoderDataCol): EncoderDataCol
}
- EncoderShape, DecoderShape 兼而有之
- DataCol 不统一, RepCol 统一
## ShapeValue 介绍
trait DecoderShapeValue[U, RepCol, DataCol] extends CommonShapeValue[U, RepCol] {
override type RepType
override val rep: RepType
override val shape: DecoderShape.Aux[RepType, U, RepType, RepCol, DataCol]
}
- 带 rep 的 DecoderShapeValue, 自动提供 DecoderShape.
- 同理 EncoderShapeValue, FormatterShapeValue
## 一般映射流程
context.effect(context.singleModel[CaseClass](table).compile)
## singleModel 实现的 3 个版本
- 1.只使用 Macro
- 难度大
- 代码难以维护
- 扩展性差
- 难以推导
- 无法处理 `shapeless.Lazy` 的情况
- 2.使用 HList(非 shapeless)
- 易于推理
- 没有 22 限制
- 编译缓慢
- 大对象难以调试
- 3.使用 tuple(非 scala 原生 tuple)
##
implicit def hlistDecoderImplicit2[A, B <: HList, H, I <: HList, M, N <: HList, RepCol, DataCol](
implicit head: Lazy[DecoderShape.Aux[A, H, M, RepCol, DataCol]]
, tail: Lazy[DecoderShape.Aux[B, I, N, RepCol, DataCol]]
): DecoderShape.Aux[A :: B, H :: I, M :: N, RepCol, DataCol]
##
- HList 只能逐列推理
##
implicit def caseClassHelper2DecoderGen[Rep1, Data1, Target1, Rep2, Data2, Target2, RepCol, DataCol](
implicit
shape1: DecoderShape.Aux[Rep1, Data1, Target1, RepCol, DataCol]
, shape2: DecoderShape.Aux[Rep2, Data2, Target2, RepCol, DataCol]
): DecoderShape.Aux[CaseClassRepMapper2[Rep1, Data1, Rep2, Data2], CaseClassDataMapper2[Data1, Data2], CaseClassRepMapper2[Target1, Data1, Target2, Data2], RepCol, DataCol]
- tuple 更容易同时获取多个 implicit
- 1.避免与 scala.Tuple 产生冲突,采用自建 tuple
- 2.自建 tuple 最大元素数量为 6
- 3.在 Macro 方面采用嵌套 tuple 突破 22 限制
- 4.采用 code gen 生成所有的辅助类和 implicit
### 在 100 列的 slick table 中,编译时间由原生 api 的 24s 降低到 6s
# 到目前为止,一切都很美好
### 我们不仅仅需要把属性对应起来
- 我们需要省略无用的声明
- 我们需要 LabelGeneric
- 我们需要在任何时候都不回退到原始 api
- 任何时候不能带来数量级的属性声明增长
- 高阶类型带来的问题尚未完全解决
### 通过 slick 的例子阐述大部分问题
## slick + asuna 概述
- 项目地址: [scalax/shino](https://github.com/scalax/shino)
- 普通自动映射 [reader](https://github.com/scalax/shino/blob/master/README_reader.md) [writer](https://github.com/scalax/shino/blob/master/README_writer.md) [formatter](https://github.com/scalax/shino/blob/master/README_formatter.md)
- 动态 sortby 映射
- 动态取列
## 处理属性重写
- 1.定义时重写
##
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
def id = column[Long]("id", O.AutoInc)
//...
@OverrideProperty("id")
def id_? = id.?
override def * = shino.effect(shino.singleModel[Friend](this).compile).shape
}
- `id_?` 可以是其他无关的属性名称
- 2.对原对象低侵入的重写
##
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
self =>
def id = column[Long]("id", O.AutoInc)
def name = column[String]("name")
def nick = column[String]("nick")
def age = column[Int]("age")
override def * = shino.effect(shino.singleModel[Friend](new FriendTableExt { override val ft = self }: FriendTableExt).compile).shape
}
trait FriendTableExt {
@RootTable val ft: FriendTable
def id = ft.id.?
}
- RootTable 会把所注解的属性(ft)的所有子属性提高到上一级(FriendTableExt),但优先级较低
## 处理数据类型转换
case class Friend(id: Long, name: String, nick: String, age: Int)
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
def id = column[Long]("id", O.AutoInc)
def name = shino.shaped(column[String]("name")).fmap(s => "user name:" + s)(t => t)
def nick = column[String]("nick")
def age = column[Int]("age")
override def * = shino.effect(shino.singleModel[Friend](this).compile).shape
}
val friendTq = TableQuery[FriendTable]
- EncoderShapeValue.emap, DecoderShapeValue.dmap, FormatterShapeValue.fmap
## 高级功能
- RootModel 注解
- RepColumnContent(对应 shapeless LabelGeneric)
- RepGroup(类似 Mixin)
- PlaceHolder
- LazyModel
- UnusedModel
## RootModel
case class IdAndName(id: Int, name: String)
def validateIdAndName = Future.successful(Right(IdAndName(1234, "name")))
object Foo {
@RootModel[IdAndName]
val idAndName = validateIdAndName
val age = Future.successful(Right(6789))
}
case class Bar(id: Int, name: String, age: Int)
val newModel = fe.effect(fe.singleModel[Bar](Foo).compile).data(ec)
println(Await.result(newModel, Duration.Inf))
- [代码链接](https://github.com/djx314/djx314.github.io/tree/master/lesson03/src/main/scala), 运行命令: `sbt test03`
## RootModel
- 应用场景: 多列混合验证
- play-json 需要根据 id 和 name 验证账号是否重复返回一个带 Future 的复合 case class
- 作用: 防止重复求值,支持多列混合运算
## RepColumnContent & PlaceHolder
- 所有的 case class Mapper 都内置 RepColumnContent 以支持 LabelGeneric
- 所有需要被映射的列如果在 table 中没有对应都会以 PlaceHolder[DataType] 填充
## slick auto mapper(目标二)
- [shino 对应文档(最后一个 Case)](https://github.com/scalax/shino/blob/master/README_formatter.md)
## circe encoder(目标一)
- 实现两个 implicit
##
implicit def feImplicit1[T](implicit encoder: Encoder[T]):
EncoderShape.Aux[
SingleRepContent[Placeholder[T], T]
, T
, EncoderWrapperImpl[T]
, List[EncoderWrapper]
, List[(String, Json)]
]
implicit def feImplicit2[T]: EncoderShape.Aux[
SingleRepContent[Encoder[T], T]
, T
, EncoderWrapperImpl[T]
, List[EncoderWrapper]
, List[(String, Json)]
]
- RepColumnContent 有两个子类, SingRepContent 和 MutiplyContent, MutiplyContent 代表该列使用了
RootModel, 存储了一个属性名称的 List, 但一般不会使用在实际操作中, RootModel 的列一般需要另外提供逻辑特殊处理. 而 SingleRepContent 则必然是只映射了一个属性的 table 列.
- 在列名不敏感时声明 RepColumnContent 即可. 因为不需要 RepColumnContent 时显式声明 RepColumnContent 可以减少类型判定的歧义,但该 implicit 不能用于对列进行 context.shaped 操作
- 没有特例的情况
- [下面两个例子代码链接](https://github.com/djx314/djx314.github.io/tree/master/lesson04/src/main/scala), 运行命令 `sbt test04`
##
case class Bar(id: Int, name: String, age: Int)
object Foo {}
val bar1 = Bar(478848, "name", 8237)
val json1 = circe.effect(circe.singleModel[Bar](Foo).compile).toJson(bar1)
val json2 = Json.fromJsonObject(json1)
println(json2.noSpaces) //{"age":8237,"name":"name","id":478848}
val json3 = bar1.asJson
println(json2 == json3) //true
- 某一列需要特殊 Encoder
##
case class Bar(id: Int, name: String, age: Int)
case class CompareBar(id: String, name: String, age: Int)
object Foo2 {
val id = Encoder.encodeInt.contramap { str: String => str.toInt }
}
val bar1 = Bar(478848, "name", 8237)
val bar2 = CompareBar("478848", "name", 8237)
val json3 = bar1.asJson
val json4 = circe.effect(circe.singleModel[CompareBar](Foo2).compile).toJson(bar2)
val json5 = Json.fromJsonObject(json4)
println(json5.noSpaces) //{"age":8237,"name":"name","id":478848}
println(json5 == json3) //true
- 亦可容易实现特殊的列名转换需求
## RepGroup(简要介绍)
- slick 的 table 需要 encode/decode 到不同的 context(input, output, sort by, filter)
- 某些列在某个 context 有特殊需求
- 可以用 context.shaped lift 有特殊需求的列,使用 mixin 进行混入操作
- 由于查找规则十分特殊,也要和 RepColumnContent 兼容,内部实现十分诡异 :P
- 示意代码
##
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
def id = column[Long]("id", O.AutoInc)
def name = shino.shaped(column[String]("name")).fmap(s => "user name:" + s)(t => t).mixin(sortby.shaped(SortByContent("nameKey", column[String]("name"))))
def nick = column[String]("nick")
def age = column[Int]("age")
override def * = shino.effect(shino.singleModel[Friend](this).compile).shape
val sortBy = sortby.effect(sortby.singleModel[Friend](this).compile).shape
}
## LazyModel & UnusedModel
![快完了快完了](./impress.css/pic-01.jpg)
- 快完了快完了,最重要的概念放最后了
## LazyModel 情景:
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
def id = column[Long]("id", O.AutoInc)
def name = column[String]("name")
def nick = column[String]("nick")
def age = column[Int]("age")
...
}
case class FriendWrap(
id: Long
, name: String
, nick: String
, age: Int
, photos: List[Photo])
def findPhotos(friendId: Long): List[Photo] = ???
- 如何映射?
- 设计一:
##
case class FriendPhotos(photos: List[Photo])
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
val mapper: ReaderShapeValue[FriendPhotos => FriendWrap, RepCol, DataCol] = ???
}
val friendTq = TableQuery[FriendTable]
val result = friendTq.map(s => (s.id, s.mapper))
##
- id 需要被 select 2 次
- 结论: Nope
trait LazyModel[Input, Output, Sub] {
def apply(input: Input): Output
def sub: Sub
}
case class FriendPhotos(photos: List[Photo])
case class FriendId(id: Long)
trait LazyModel[FriendPhotos, FriendWrap, FriendId] {
def apply(input: FriendPhotos): FriendWrap
def sub: FriendId
}
class FriendTable(tag: slick.lifted.Tag) extends Table[Friend](tag, "firend") with SlickResultIO {
val mapper: ReaderShapeValue[LazyModel[FriendPhotos, FriendWrap, FriendId], RepCol, DataCol] = ???
}
val friendTq = TableQuery[FriendTable]
val result = friendTq.map(s => s.mapper)
- id 列在完整结果被求值时已被求出,可以提前暴露
- 避免不必要的列 select
- 无须因为数据一致性限制而建立过多 model(尤其是大 model)
- 重申: 避免出现数量级的属性声明增长
- 有了这个特性才是上述 DTO 的完整版本
## UnusedModel
- LazyModel: 提前一点,延后一点,只用于 DecoderShape
- UnusedModel: 增加一点,减少一点,只用于 EncoderShape
- UnusedModel[Input, Main, Unused]
- Main: 主体 case class(slick 用于 update 的 model)
- Input: Main 所不具备的列
- Unused: Main 需要排除的列
## 调试
- 两个阶段需要调试:
- 1. Macro 生成映射代码阶段
- 2. 获取 implicit 阶段
## 映射代码生成阶段
- 更换调试方法
- 不改变第一阶段逻辑,生成黑盒 Macro 代码
- 临时文件打印 scalafmt 后的代码,以 html 显示
- 打印代码可直接覆盖原 Macro 声明部分(Macro 失效可临时补救)
- Macro 功能: 生成后的代码编译后报错优先于 Macro 内部报错
- 大量抛 Exception 函数: 避免升级时带来的大量编译错误
- [图2](./impress.css/pic-02.png)
- [图3](./impress.css/pic-03.png)
- [图4](./impress.css/pic-04.png)
## implicit not found 定位
- 更换调试方法
- 按提示调试
- 定位
- 根据第一步打印的 Mapping info 定位问题列
- [图5](./impress.css/pic-05.png)
- [图6](./impress.css/pic-06.png)
- [图7](./impress.css/pic-07.png)
- [图8](./impress.css/pic-08.png)
- [图9](./impress.css/pic-09.png)
- [图10](./impress.css/pic-10.png)
- [图11](./impress.css/pic-11.png)
## 基本抽象结构
- CommonShape: 只有列遍历
- EncoderShape: 列与 model 映射, 继承 CommonShape, 特定类型 => Any, UnusedModel
- DecoderShape: 列与 model 映射, 继承 CommonShape, Any => 特定类型, LazyModel
- FormatterShape: 列与 model 映射, 继承 EncoderShape, DecoderShape, Any => 特定类型, 特定类型 => Any
## 代码美感
- 展开讲述
## 现有成果及未来展望
- 1.slick input, output
- 2.slick sort by
- 3.slick input, output with circe(使用 encoder 生成 decoder)
- 4.slick with sangria
- 5.akka-http, play 参数传入
- (1)多源头参数传入: json, form, url parameters
- (2)默认传入源头(PlaceHolder)
- (3)验证(支持 future, 前端提示友好, 多源头数据无需先 decode 再验证, 多列组合验证, 改善 akka-http 多层回调结构)
## 问题
- 1.循环类型声明要么 object lazy implicit 一把梭, 要么用比较原始的 api 自己写深层嵌套, 未有自动化较高的方法(更新: 目前可以使用类似 play-json 的声明方式或者 asuna 特定的声明方式两种方法解决)
- 2.不支持抽象代数类型(打算直接使用 shapeless 的 API)
- 3.default value 未有适配方案(更新: 已支持)
- 4.Macro 映射规则未 stable
## 问题
- 5.为了类型推导而产生的大量装箱操作极大地影响了性能, 目前的优化结果是, 跟某些 json 库的 encode 功能进行比较, 性能是 circe 的 60% 和 play-json 的 70%+.
- 但由于抽象程度较高, 如果 encoder 为 strict, 则可通过简单的改动把 non strict 情况下的 60% - 70% 性能提高到 strict 下的 130% - 150%,
而且处理 default value 的方式十分友好, 另外自带可以处理某些列需要特殊 encoder 等情况.
![谢谢](./impress.css/pic-12.jpg)