この記事は
Play! framework Advent Calendar 2011 jp #play_jaの7日目です。
勢いで参加してみたものの、ネタが見つからず困っていたところ、@mumoshu さんのつぶやきが目にとまりました。
-
mumoshu この2日間のPlay 2.0は、Scala向けのJSON APIが変更されたり、Actionの合成方法がドキュメントにそった形になった。JSONの方は、使うライブラリがsjsonからJerksonへ変更。Jerksonをベースに、sjsonのようなAPIを実装している。 04 Dec 2011 from Twitter for Mac
-- this quote was brought to you by quoteurl Jerksonというライブラリは全く知りませんでした。
調度良いのでこのJerksonについて調べたことを書こうと思います。
(Play20のplay.api.jsonは、現在絶賛開発中といった感じなので、これからしばらく不安定な様子です。
おそらくここに書くこともすぐに古くなると思いますのでご了承ください。)
Jerksonって?
Jerksonは、Java JSON-processorであるところの
Jacksonの、Scala wrapperです。
これまで候補であったsjsonについては@eed3si9n さんの翻訳記事、
sjson: Scala の型クラスによる JSON シリアライゼーション に詳しく書かれています。sjsonは、implicit parameterによる型クラスエミュレートを使った便利なライブラリです。sjsonでは、型クラスを使うことでシリアライズ、デシリアライズプロトコルのカスタマイズ手段を提供しています。
sjsonからJackson/Jerksonに移行した理由はわかりませんが、ScalaとJavaを両方サポートするPlay!ではJavaベースのJacksonとそのScala wrapperというのが都合が良かったのかもしれません。
play.api.jsonでは、このJerksonに、sjsonライクなシリアライズ/デシリアライズプロトコルをかぶせた形になっています。
apiとしては、まずJsValue型とStringの相互変換をする2つの関数が用意されています。
def parseJson(input: String): JsValue = JerksonJson.parse[JsValue](input)
def stringify(json: JsValue): String = JerksonJson.generate(json)
parseJsonは、StringからJsValueへ、stringfyはその逆で、JsValueをStringにします。
これらの中身はJerksonにそのままお任せで、特にカスタマイズも想定されていますん。
JsValue という型が中間表現になっていて、任意のScalaオブジェクトをJsValueにしてからStringにしたり、
JSON文字列をパースしてまずはJsValueにしてから、任意のScalaオブジェクトに変換することになります。
ちなみにJsValueという型は次のような構成になっています。
sealed trait JsValue {...}
case object JsNull extends JsValue {...}
case class JsUndefined(error: String) extends JsValue {...}
case class JsBoolean(override val value: Boolean) extends JsValue
case class JsNumber(override val value: BigDecimal) extends JsValue
case class JsString(override val value: String) extends JsValue
case class JsArray(override val value: List[JsValue]) extends JsValue {...}
case class JsObject(override val value: Map[String, JsValue]) extends JsValue {...}
ベタにJSONのデータ構造そのままですね。
それぞれcase classなので、このままでも使うことができますが、ちょっと不便です。
そこで、任意の型との相互変換を行う関数として、次の2つが用意されています。
def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue = tjs.writes(o)
def fromJson[T](json: JsValue)(implicit fjs: Reads[T]): T = fjs.reads(o)
Writes[T]がT型からJsValueへの変換を、
Reads[T]がJsValueからT型への変換を提供するオブジェクトの型になっています。
それぞれimplicitとなっているので、
import play.api.json.Reads._
import play.api.json.Writes._
などとしておけば、
assert (toJson(Map("a"->2)).toString =="""{"a":2.0}""")
assert (fromJson[Int](parseJson("1")) == 1)
assert (fromJson[Map[String,SortedSet[Int]]](parseJson("""{"a":[1,5,3]}""")) == Map("a" -> SortedSet(1,3,5)))
assert (fromJson[Map[String,List[Int]]](parseJson("""{"a":[1,5,3]}""")) == Map("a" -> List(1,5,3)))
といった形で、よくある感じの型にはデフォルトで変換プロトコルが提供されていて、
簡単に使うことができます。
ちなみにWrites[T] とReads[T]はこんな感じです。
trait Writes[T] {
def writes(o: T): JsValue
}
trait Reads[T] {
def reads(json: JsValue): T
}
(Writes Readsの他に次のようなFormatという型も用意されています。おそらく今後これを実装したobjectが提供されるんだと思います。
trait Format[T] extends Writes[T] with Reads[T]
)
カスタム変換プロトコル
次に自分の好きな変換を提供する方法について考えてみます。
まずは、Reads[T]の実装をみてみます。
シンプルな例としてReads[Boolean]は次のようになっています。
implicit object BooleanReads extends Reads[Boolean] {
def reads(json: JsValue) = json match {
case JsBoolean(b) => b
case _ => throw new RuntimeException("Boolean expected")
}
}
def reads(json: JsValue):Boolean
な型のメソッドを持つReads[Boolean]なオブジェクトをimplicitで提供するだけですね。
変換したい型が、List[T]などのパラメータ化された型の場合はもう少し複雑になります。
List[Int]、List[Boolean]などについて個別にReads[List[Int]]などを用意していけば、
Reads[Boolean]と同じ形に書けますが、これだとList[List[List[Int]]]などのネストを考えると
事前にすべて用意する訳にはいかなそうです。
そこで、Tがなにかはわからないけれど、T(例えばIntだったりする何か)について変換する方法を
教えてくれれば、List[T]の変換方法を教えましょう、という形の関数を書くことになります。
Tの変換方法とはつまりReads[T]型のオブジェクトで、
List[T]の変換方法とはReads[List[T]]型のオブジェクトです。
なので型でいうと、Reads[T]型のオブジェクトを受け取って、Reads[List[T]]型のオブジェクトを返す関数を
書くことになります。実際のソースの該当部分は次のようになります。
implicit def listReads[T](implicit fmt: Reads[T]): Reads[List[T]] = new Reads[List[T]] {
def reads(json: JsValue) = json match {
case JsArray(ts) => ts.map(t => fromJson(t)(fmt))
case _ => throw new RuntimeException("List expected")
}
}
ここで、implicit fmt: Reads[T]と、Reads[T]型の引数についてもimplicitが指定してあるので、
例えば先ほどのimplicit object BooleanReads extends Reads[Boolean]
などが提供されていれば、listReadsにBooleanReadsが暗黙に自動的に供給されて、
Reads[List[Boolean]] も自動的に供給されることになります。
この仕組みを使って、ここでは試しにReads[Either[A, B]]な変換方法の提供を考えてみたいと思います。
JSONは基本動的型付けなので、[1, 2, null]のように、
ひとつの配列の中に様々な型の値が一緒に入っていることがあります。
連想配列であれば、違うキーに対しては、むしろ別の型の値が入っていることの方が普通でしょう。
例えば、{"name":"milk", "price":150}のように。
こういったものでもEither型への変換ができればすこしだけ便利かもしれません。
Either[A,B]は型パラメータを二つとるので、変換方法もReads[A]とReads[B]の2種類が必要です。
fromJsonを使って型の変換に失敗したときは例外が投げられるので、それをキャッチして
分岐してもいいのですが、成功したときはSome[A]、変換に失敗したときなNoneを返してくれる
便利なasOptメソッドがあるのでここではそれを使ってみました。
implicit def eitherReads[A,B](implicit fmtA: Reads[A], fmtB: Reads[B]):Reads[Either[A, B]] =
new Reads[Either[A, B]] {
def reads(json: JsValue) =
json.asOpt[A](fmtA) match {
case Some(a) => Left(a)
case None => Right(fromJson[B](json)(fmtB))
}
}
assert (fromJson[Either[String,Int]](json.parseJson("""1""")) == Right(1))
assert (fromJson[Either[String,Int]](json.parseJson(""""a"""")) == Left("a"))
assert (fromJson[List[Either[Either[Boolean,String],Int]]](json.parseJson("""["a",true, 5]"""))
== List(Left(Right("a")), Left(Left(true)), Right(5.0)))
これでちょっとは便利になりましたが、Either型をたくさん使うと
どこかでパターンマッチをたくさん書くことになって、JsValueに対して
パターンマッチを書いていくのとあまり変わらないことになってしまいます。
なにより型の混ざった配列ならともかく、
{"name":"milk", "price":150}のような連想配列は
Map[String, Either[String,Int]]
に変換するのではなく、
case class Hoge(name:String, price:Int)
のような何らかのクラスに変換したいのが普通でしょう。
もちろん特定のcaseクラスへの変換を記述することはそれほど大変なことではありませんが、
case クラスがたくさんある場合は、ちょっと面倒そうです。
そんな時のために、sjsonでは、任意のcase クラスへの変換プロトコルを簡単に提供するための
便利な関数が用意されています。Scalaでは可変長の型引数はサポートされていないので、
case クラスのメンバの数に応じて、asProduct1、asProduct2、asProduct3...
といったメソッドが用意されています。
sjsonのソースでは、テンプレートを使ってこれらの定義が自動生成していますが、
ここではplay.api.json用に、asProduct3だけをポーティングしてみたいと思います。
まず、asProduct3のシグネチャは次のようになります。
def asProduct3[S, T1, T2, T3]
(f1: String, f2: String, f3: String)
(apply : (T1, T2, T3) => S)
(unapply : S => Product3[T1, T2, T3])
(implicit bin1: Format[T1], bin2: Format[T2], bin3: Format[T3])
: Format[S]
まず、型パラメータの[S, T1, T2, T3] です。
ひとつめのSは、変換対象のcase クラスです。
T1,T2,T3はそれぞれ、caseクラスのメンバ変数の型になります。
最初の引数、
(f1: String, f2: String, f3: String)
は、メンバ変数に体操するJSONでのキーになります。
メンバ変数の名前と同じものになることも多いでしょう。
次の二つの引数 applyとunapplyは、
(apply : (T1, T2, T3) => S)
(unapply : S => Product3[T1, T2, T3])
caseクラスのコンパニオンオブジェクトのapply unapplyメソッド
を受け取ります。
最後に、各メンバ変数の型ごとにReads[T1]やWrites[T1]を供給するために
Format[T1]をimplicitに受け取ります。
たとえば、case class Hoge(name:String, price:Int, flag:Boolean)
を、{"name":"...", "price":1234, "flag":true}に変換するのであれば、
asProduct3[Hoge, String, Int, Boolean]
("name", "price", "flag")
(Hoge.apply)
(Hoge.unapply)
といった引数を渡すだけで、
Format[Hoge]の実装を得ることができます。
asProduct3の実装は次のような感じになります。
def asProduct3[S, T1, T2, T3](f1: String, f2: String, f3: String)
(apply : (T1, T2, T3) => S)
(unapply : S => Product3[T1, T2, T3])
(implicit bin1: Format[T1], bin2: Format[T2], bin3: Format[T3])
= new Format[S]{
def writes(s: S) = {
val product = unapply(s)
JsObject(
Map(
(f1, toJson(product._1)),
(f2, toJson(product._2)),
(f3, toJson(product._3))
))
}
def reads(js: JsValue) = js match {
case JsObject(m) =>
apply(
fromJson[T1](m(f1)),
fromJson[T2](m(f2)),
fromJson[T3](m(f3))
)
case _ => throw new RuntimeException("object expected")
}
}
これを使えば、例えば次のようなcase クラスに対して、
case class Shop(store: String, item: String, price: Int)
次のように変換プロトコルを簡単に用意することができます。
object ShopProtocol {
import DefaultFormat._
implicit val ShopFormat: Format[Shop] =
asProduct3("store", "item", "price")(Shop)(Shop.unapply(_).get)
}
詳しくは、sjsonのgithubの該当部分をご覧ください。
https://github.com/debasishg/sjson/blob/master/src/main/scala/sjson/json/Generic.scala
以上です。
これを書いている間に日が変わってしまいました!ゴメンナサイ!!
次回12月8日は@hagikuratakeshiさんです。
よろしくお願いします!!