Your browser doesn't support the features required by impress.js, so you are presented with a simplified version of this presentation.

For the best experience please use the latest Chrome, Safari or Firefox browser.

# 一个基于属性匹配的函数式对象映射系统的设计 ## 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)