Contraband、case class の代替案
しばらく考えている疑問がいくつかある:
- データや API はどう書かれるべきだろうか?
- そのデータは Java や Scala ではどう表現されるべきか?
- そのデータは JSON などのワイヤーフォーマットにどう変換することができるか?
- そのデータをどうやってバイナリ互換性を崩さずに進化させることができるか?
case class の限界
Scala でデータ型を表現する慣用的な方法は sealed trait と case class だが、バイナリ互換性を保ったままフィールドを追加することができない。簡単な Greeting
という case class を例に取って、それがどのようなクラスとコンパニオンオブジェクトに展開されるか考察してみよう:
package com.example
class Greeting(name: String) {
override def equals(o: Any): Boolean = ???
override def hashCode: Int = ???
override def toString: String = ???
def copy(name: String = name): Greeting = ???
}
object Greeting {
def apply(name: String): Greeting = ???
def unapply(v: Greeting): Option[String] = ???
}
次に、x
という新しいフィールドを追加する:
package com.example
class Greeting(name: String, x: Int) {
override def equals(o: Any): Boolean = ???
override def hashCode: Int = ???
override def toString: String = ???
def copy(name: String = name, x: Int = x): Greeting = ???
}
object Greeting {
def apply(name: String): Greeting = ???
def unapply(v: Greeting): Option[(String, Int)] = ???
}
見て分かる通り、copy
メソッドと unapply
メソッドはバイナリ互換性を崩す。
対策として、sbt のコードでは UpdateOptions というような擬似 case class を手書きで書いたりしている。
Contraband
GraphQL は Facebook社が開発した JSON API のためのクエリ言語だ。 その GraphQL のスキーマ言語の派生言語を作って Contraband という名前を付けた。sbt プラグインから Java と Scala 向けに擬似 case class を生成できるようになっている。これは以前は sbt-datatype と呼ばれていたもので、去年 Martin Duhem 君と僕が開発していた。
Contraband では Greeting
の例は以下のように書ける:
package com.example
@target(Scala)
type Greeting {
name: String
}
これは以下のようなコードを生成する:
// DO NOT EDIT MANUALLY
package com.example
final class Greeting private (
val name: Option[String]) extends Serializable {
override def equals(o: Any): Boolean = o match {
case x: Greeting => (this.name == x.name)
case _ => false
}
override def hashCode: Int = {
37 * (17 + name.##)
}
override def toString: String = {
"Greeting(" + name + ")"
}
protected[this] def copy(name: Option[String] = name): Greeting = {
new Greeting(name)
}
def withName(name: Option[String]): Greeting = {
copy(name = name)
}
def withName(name: String): Greeting = {
copy(name = Option(name))
}
}
object Greeting {
def apply(name: Option[String]): Greeting = new Greeting(name)
def apply(name: String): Greeting = new Greeting(Option(name))
}
copy
の代わりに withName("foo")
を使う。GraphQL/Contraband での String
は、Scala の Option[String]
に対応することにも注意してほしい。これは Protocol Buffer v3 の単一フィールドが「ゼロ個か1個」の意味を持つのと似ている。
データの進化
データをどう進化できるかみていく。新しいフィールド x
を追加するとこうなる。
package com.example
@target(Scala)
type Greeting {
name: String @since("0.0.0")
x: Int @since("0.1.0")
}
Contraband では @since
を使ってフィールドにバージョン名を併記できる。
これは以下を生成する:
// DO NOT EDIT MANUALLY
package com.example
final class Greeting private (
val name: Option[String],
val x: Option[Int]) extends Serializable {
private def this(name: Option[String]) = this(name, None)
....
def withX(x: Option[Int]): Greeting = {
copy(x = x)
}
def withX(x: Int): Greeting = {
copy(x = Option(x))
}
}
object Greeting {
def apply(name: Option[String]): Greeting = new Greeting(name, None)
def apply(name: String): Greeting = new Greeting(Option(name), None)
def apply(name: Option[String], x: Option[Int]): Greeting = new Greeting(name, x)
def apply(name: String, x: Int): Greeting = new Greeting(Option(name), Option(x))
}
簡単のため equals
、hashCode
、toString
、withName
などは上から省いた。
バージョン 0.0.0 と 0.1.0 それぞれに対応した apply
のオーバーロードが生成されることがポイントだ。
JSON コーデックの生成
JsonCodecPlugin をサブプロジェクトに追加することで Contraband 型に対する sjson-new の JSON コーデックが生成される。
lazy val root = (project in file("."))
.enablePlugins(ContrabandPlugin, JsonCodecPlugin)
.settings(
scalaVersion := "2.12.1",
libraryDependencies += "com.eed3si9n" %% "sjson-new-scalajson" % "0.7.1"
)
sjson-new はコーデック・ツールキットで、一つのコーデック定義から Spray JSON の AST、SLIP-28 Scala JSON、MessagePack と複数のバックエンドをサポートすることができる。
スキーマにもういくつか項目を指定してやる:
package com.example
@target(Scala)
@codecPackage("com.example.codec")
@codecTypeField("type")
@fullCodec("CustomJsonProtocol")
type Greeting {
name: String @since("0.0.0")
x: Int @since("0.1.0")
}
ここからバックエンド独立な JSON コーデックとして使うことができる GreetingFormat
trait が生成される。Greeting
から JSON に変換して、また戻ってくるラウンドトリップを REPL でデモするとこうなる。
scala> import sjsonnew.support.scalajson.unsafe.{ Converter, CompactPrinter, Parser }
import sjsonnew.support.scalajson.unsafe.{Converter, CompactPrinter, Parser}
scala> import com.example.codec.CustomJsonProtocol._
import com.example.codec.CustomJsonProtocol._
scala> import com.example.Greeting
import com.example.Greeting
scala> val g = Greeting("Bob")
g: com.example.Greeting = Greeting(Some(Bob), None)
scala> val j = Converter.toJsonUnsafe(g)
j: scala.json.ast.unsafe.JValue = JObject([Lscala.json.ast.unsafe.JField;@25667024)
scala> val s = CompactPrinter(j)
s: String = {"name":"Bob"}
scala> val x = Parser.parseUnsafe(s)
x: scala.json.ast.unsafe.JValue = JObject([Lscala.json.ast.unsafe.JField;@372115ef)
scala> val h = Converter.fromJsonUnsafe[Greeting](x)
h: com.example.Greeting = Greeting(Some(Bob), None)
scala> assert(g == h)
今の所対象言語は Java と Scala のみだけど、Contraband は GraphQL の派生言語なので、興味がある人は既存のツールを再利用するなどして他の言語への対応も試してみることができるかもしれない。
Contraband に関する詳細は Contraband ドキュメンテーションを参照してほしい。