使用|使用 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 隐式参数的特性,这样设计可以让我们更加方便地去构造抽象语法树,优点包括:
  1. 数据的转换与处理全部在我们的 DSL 设计框架内,使问题都能通过 Protoable 和 Scalable 这两个特质去解决。
  2. 充分利用编译器隐式参数查找的特性,对于某个字段涉及不同类型的转换时,就让编译器在上下文中找一下这个字段的类型转换器。我们这里用到了 implicitly[T] 这个方法,这是 Scala 标准库中的方法,它可以帮我们从上下文中找到一个对应的 T 类型参数,比如这里我们需要找到一个 Scalable[String,StringValue]类型的隐式参数(在 traitScalable 中定义了)。
  3. 结合第2点,我们处理两个对象之间的转换时,不用递归去考虑子对象的问题,让我们去生成代码时,仅仅关注当前对象字段之间的关系就可以了,包括一些简单的Option 类型处理、集合类型处理,不用关心别的东西,其它的东西都通过隐式参数交给编译器去做,这会大大降低设计成本。
  4. 极易进行类型扩展。如果我们需要定义一个系统级的转换器,在 Protoable 和 Scalable 的伴生对象里添加一个就可以了;如果我们需要一个业务相关的转换器,在代码处能够够到的上下文中定义就可以了,Scala 的隐式参数查找规则都能帮你找到要扩展的类型转换器。
显然,这里我们似乎已经找到一个通用的规则去处理这里字段之间的转换了,现在我们可以让 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 设计时可以更简洁,宏实现部分要全部重写。
Scala3 还有一些问题
  • 不能编译 protoc 生成的 java
    文件https://github.com/lampepfl/d...,不过我提了一个 PR 正在修复这个问题
    https://github.com/lampepfl/d...
  • 编译时反射的 API 要更少一点,导致一些类型涉及到类型推导的功能不好做 这些都是我们未来支持 scala3 需要克服的问题。
【使用|使用 Scala 宏解决对象转换】使用|使用 Scala 宏解决对象转换
文章图片

    推荐阅读