使用|使用 Scala 宏解决对象转换
背景
在 GrowingIO 服务端的开发中,我们使用 gRPC 进行微服务之间的数据通信,每个提供服务的项目,都要定义一套自己的 Protobuf 消息格式,然后利用 protoc 去生成对应语言的适配代码。在我们维护的项目中,主要使用 Scala 语言来实现各个服务,每个服务都会定义一套自己的领域模型(一般是一些 case class),而 protoc 默认生成的 JVM 平台的代码是 Java 语言的,对应的 Protobuf 消息格式与 Scala 项目中定义的领域模型会存在一些对应关系,并且他们的属性往往会高度一致。当我们需要在这两种类型做数据转换时,会出现许多 protobuf-java 和 scala case class之间的转换代码。
一般情况下,protobuf-java 和 Scala 之间都能找到对应类型,比如 java.util.List 和 Seq/List/Array,Timestamp 和 ZonedDateTime,同时一些 Scala 中 Option 类型也可以用 protobuf 是一封装类型去表示,比如 Option[String] 可以用 StringValue 去表示。因为每个类型都有自己对应的特性,类型嵌套又会增加极大地增加复杂度,我们一直在找一个通用的转换方案,使用起来类型安全,并尽可能地省掉臃肿无味的代码。
参考了
https://github.com/playframew... 的 Reader、Writer 设计设计理念和 https://github.com/scalalandi...用 Scala 宏对类型转换的处理方式,我们最终使用了 Scala 宏结合隐式参数 DSL 设计的思路实现了 https://github.com/changvvb/s... 一套解决方案。
方案效果
我们先在这里定义一个 case class User 和 Protobuf UserPB 来对比使用该方案前后的效果。
case class User (
id:Long,
name:String,
phoneNumber: Option[String],
hobbies: Seq[String]
)
message UserPB (
int64 id = 1,
string name = 2,
google.protobuf.StringValue phone_number = 3,
repeated string hobbies = 4
)
如果我们自己手写 scala case class 到 protobuf-java 的转换,效果将是这样的:
val user = User(1,"Jack",Some("1234567890"),Seq("ping pong", "reading"))
?
val builder = UserPB.newBuilder// 新建一个 builder
builder.setId(user.id).setName(user.Name)// 设置 id、name 字段
if(user.phoneNumber.isDefined) {// 这里也可以直接简写成 user.phoneNumber.map(StringValue.of).foreach(builder.setPhoneNumber)
builder.setPhoneNumber(StringValue.of(user.phoneNumber.get))
}
builder.setAllHobbies(user.hobbies.asJava)// 设置 hobbies 字段
val userPB = builder.build// 构建 UserPB 对象
如果将 protobuf-java 对象转换成 scala case class 对象也需要写差不多的代码,只不过是将一些 set 方法变成 get 方法的结果传入到 User 的构造方法中。
使用我们的解决方案后,代码将是这样的:
val user = User(1,"Jack",Some("1234567890"),Seq("ping pong", "reading"))
val userPB = Protoable[User, UserPB].toProto(user)//一行代码就可以完成
可以看到,代码简洁了一个维度,用起来也会帮我们做类型安全检查,真正实现了简单、安全、易用的目标。
下面我们将具体介绍一下这个工具的设计方法与思路,以及其中的哲学思想。
DSL 设计 DSL 中最根本的两个特质是 Protoable[-S, +P] 和 Scalable[+S, -P],S 代表一个 Scala 类型,P 代表一个 protobuf-java 类型。Protoable 表示这是一个可以将 Scala 类型转换成 protobuf-java 类型的东西,Scalable 类型则相反。此处用到了协变和逆变。
trait Protoable[-S, +P] {
def toProto(entity: S): P
}
?
trait Scalable[+S, -P] {
def toScala(proto: P): S
}
紧接着,我们需要在他们的伴生对象里去写一些构造方法,和一些默认的转换器。这样,一些基本的东西就有了。
object Scalable {
?
def apply[S, P](convert: P ? S): Scalable[S, P] = x => convert(x)// 类似 java lambda 的用法
?
implicit val javaIntegerScalable = Scalable[Int, java.lang.Integer](_.toInt)// 装箱类型的转换
implicit val stringValueScalable = Scalable[String, StringValue](_.getValue)// Protobuf 封装类型的转换
implicit val zonedDateTimeProtoable = Scalable[ZonedDateTime, Timestamp] { proto ?// 时间类型的转换
Instant.ofEpochSecond(proto.getSeconds, proto.getNanos).atZone(ZoneId.systemDefault())
}
}
?
object Protoable {
?
def apply[S, P](convert: S ? P): Protoable[S, P] = x => convert(x)
?
implicit val javaDoubleProtoable = Protoable[Double, java.lang.Double](_.toDouble)
implicit val stringValueProtoable = Protoable[String, StringValue](StringValue.of)
implicit val zonedDateTimeProtoable = Protoable[ZonedDateTime, Timestamp] { entity ?
Timestamp.newBuilder().setSeconds(entity.toEpochSecond).setNanos(entity.getNano).build()
}
}
使用宏自动生成代码 还是在文章前面定义的两个 User 和 UserPB 类型,思考一下我们应该怎么去用上面的 DSL 去写他们之间的转换呢?
直接看结果,可以这样写:
new Protoable[User, UserPB] {
override def toProto(entity: User): UserPB = {
val builder = UserPB.newBuilder()
builder.setId(entity.id)
builder.setName(entity.name)
if(entity.phoneNumber.isDefined) {
builder.setPhoneNumber(implicity[Protoable[String,StringValue]].toProto(entity.phoneNumber))
}
builder.addAllHobbies(implicitly[Protoable[Seq[String], java.util.List[String]]].toProto(entity.hobbies))
builder.build
}
}
?
new Scalable[User, UserPB] {
override def toScala(proto: UserPB): User = {
new User(
id = proto.getId,
name = proto.getName,
phoneNumber = if(proto.hasPhoneNumber) {
Some(implicitly[Scalable[String,StringValue]].toScala(proto.getPhoneNumber))
} else {
None
},
hobbies = implicitly[Scalable[Seq[String, java.util.List[String]]].toScala(proto.getBobbiesList)
)
}
}
这就是我们需要 Scala 宏去生成的代码,这些代码充分使用了我们上面定义的 Protoable 和 Scalable 两个特质,以及 Scala 隐式参数的特性,这样设计可以让我们更加方便地去构造抽象语法树,优点包括:
- 数据的转换与处理全部在我们的 DSL 设计框架内,使问题都能通过 Protoable 和 Scalable 这两个特质去解决。
- 充分利用编译器隐式参数查找的特性,对于某个字段涉及不同类型的转换时,就让编译器在上下文中找一下这个字段的类型转换器。我们这里用到了 implicitly[T] 这个方法,这是 Scala 标准库中的方法,它可以帮我们从上下文中找到一个对应的 T 类型参数,比如这里我们需要找到一个 Scalable[String,StringValue]类型的隐式参数(在 traitScalable 中定义了)。
- 结合第2点,我们处理两个对象之间的转换时,不用递归去考虑子对象的问题,让我们去生成代码时,仅仅关注当前对象字段之间的关系就可以了,包括一些简单的Option 类型处理、集合类型处理,不用关心别的东西,其它的东西都通过隐式参数交给编译器去做,这会大大降低设计成本。
- 极易进行类型扩展。如果我们需要定义一个系统级的转换器,在 Protoable 和 Scalable 的伴生对象里添加一个就可以了;如果我们需要一个业务相关的转换器,在代码处能够够到的上下文中定义就可以了,Scala 的隐式参数查找规则都能帮你找到要扩展的类型转换器。
object Scalable {
def apply[S <: Product, P]: Protoable[S, P] = macro ProtoScalableMacro.protosImpl[S, P]
}
?
object Protoable {
def apply[S <: Product, P]: Scalable[S, P] = macro ProtoScalableMacro.scalasImpl[S, P]
}
可以看到,每个方法只需要两个类型参数就可以了。具体宏是怎么实现的,这里不展开讲了,总之就是根据上面的思路结合 Scala 宏在编译时反射各种类型去生成我们需要的代码的语法树(AST)。
我们使用它去做一次双向的转换,通过很简单的代码就可以完成。
val user = User(1,"Jack", Some("1234567890"), Seq("ping pong","coding"))
val userPb = Protoable[User,UserPB].toProto(user)// 将 Scala case class 对象转换成 protobuf-java 对象
val user2 = Scalable[User,UserPB].toScala(user)// 将 protobuf-java 对象转换成 Scala case class 对象
?
assert(user == user2)
可以看到,做一次转换,不管你字段有多少,只需要一行代码,这让我们代码数据减少一个数据级,而且是类型安全的,类型不对,参数不够都会在编译时报错。
对于嵌套类型,我们只需要先定义一个内部的转换,然后在编译器能找到的上下文中使用就可以了。我们假定这里 Outer 类型中有一个 Inner 类型的字段,就可以这样写。
implicit val innerScalable = Scalable[Inner,InnerPB]
Protoable[Outer,OuterPB].toScala(outerObj)
进一步优化,使用 Builder 方式去定制内部的转换逻辑 如果我们有一个这样的业务场景,比如我们需要在 UserPB 转 User 时,让 id 始终不小于 0,上面东西基本上不好实现这个简单的需求,即使实现了,可能也比较难看。我们引入 Builder 构造器来帮我们去做这件事情。Builder 构造器会帮我们注入定制的一些规则到宏生成的代码中。
val scalable = ScalableBuilder[User,UserPB]
.setField(_.id, userPB => if(userPB.getId < 0) 0 else userPB.getId)
.setField(_.name, /*这里可以放一些别的逻辑,或不写这行,用之前默认的处理方式 */)
.build
scalable.toScala(...)
setField 有两个参数,第一个是字段选择器,第二个是一个 lambda 表达式,lambda 表达式用于表示输入一个对象,输出这个字段想要什么东西,最后调用 build 方法,可以生成一个的 Scalable 对象。
除了ScalableBuilder,同时我们还有 ProtoableBuilder 来做反方向转换的工作。这两个 Builder 都可以来做一些字段级别的逻辑控制,或生成一些缺失的字段,在很多时候非常有用。
Scala3 支持 我们知道,Scala3 在前两个月刚刚正式发布,关于这个工具 Scala3 带来的特性有:
- 更简洁隐式参数定义
- 基于 inline、Quotes 全新的宏设计 这使这们在 dsl 设计时可以更简洁,宏实现部分要全部重写。
- 不能编译 protoc 生成的 java
文件https://github.com/lampepfl/d...,不过我提了一个 PR 正在修复这个问题
https://github.com/lampepfl/d... - 编译时反射的 API 要更少一点,导致一些类型涉及到类型推导的功能不好做 这些都是我们未来支持 scala3 需要克服的问题。
文章图片
推荐阅读
- 由浅入深理解AOP
- 【译】20个更有效地使用谷歌搜索的技巧
- mybatisplus如何在xml的连表查询中使用queryWrapper
- MybatisPlus|MybatisPlus LambdaQueryWrapper使用int默认值的坑及解决
- MybatisPlus使用queryWrapper如何实现复杂查询
- iOS中的Block
- Linux下面如何查看tomcat已经使用多少线程
- 使用composer自动加载类文件
- android|android studio中ndk的使用
- 使用协程爬取网页,计算网页数据大小