背景
在 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/playframework/play-json 的 Reader、Writer 设计设计理念和 https://github.com/scalalandio/chimney 用 Scala 宏对类型转换的解决形式,咱们最终应用了 Scala 宏联合隐式参数 DSL 设计的思路实现了 https://github.com/changvvb/scala-protobuf-java 一套解决方案。
计划成果
咱们先在这里定义一个 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 的隐式参数查找规定都能帮你找到要扩大的类型转换器。
显然,这里咱们仿佛曾经找到一个通用的规定去解决这里字段之间的转换了,当初咱们能够让 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/dotty/issues/6138,不过我提了一个 PR 正在修复这个问题
https://github.com/lampepfl/dotty/pull/12884 - 编译时反射的 API 要更少一点,导致一些类型波及到类型推导的性能不好做 这些都是咱们将来反对 scala3 须要克服的问题。