これはplayframework2014アドベントカレンダーの、13日目です。
少し長いので先に内容を要約しておくと
- play-jsonで、直和型のReads, Writes, Formatを短く簡潔に定義しようとすると微妙に面倒なことに気づいた
- どのあたりが面倒なのか?という理由の説明
- 解決方法はいくつかありそうだが、とりあえず以前作ったplay-json-extraに便利メソッド追加したのでそれの説明
という話の流れです。
versionによってあまり変わらないはずですが、ひとまず環境は、play2.3.7、scala2.11.4としておきます。
以下のような直和型を含んだ代数的データ型があったとします。これのAAAとしてのOFormatは、どのように定義すればいいでしょうか?
play組み込みのマクロや、shapeless使って頑張る方法など色々ありえますが、まず今回はマクロ使わずにplay標準のものでやっていく方法を考えてみましょう。
sealed abstract class AAA final case class BBB(a: Int, b: String) extends AAA final case class CCC(c: Long, d: List[Int], e: Option[String]) extends AAA
これもいくつか書き方がありそうですが、サブクラスであるBBB, CCCのOFormatをそれぞれ定義して、最後にそれらを合成してAAAのOFormatを定義すればよさそうですね?まずそれぞれのサブクラスのOFormatを以下のように定義すると、そこは普通にうまくいきます
import play.api.libs.functional.syntax._ import play.api.libs.json._ sealed abstract class AAA final case class BBB(a: Int, b: String) extends AAA object BBB { val format: OFormat[BBB] = ( (__ \ "a").format[Int] and (__ \ "b").format[String] )(apply, unlift(unapply)) } final case class CCC(c: Long, d: List[Int], e: Option[String]) extends AAA object CCC { val format: OFormat[CCC] = ( (__ \ "c").format[Long] and (__ \ "d").format[List[Int]] and (__ \ "e").format[Option[String]] )(apply, unlift(unapply)) }
さて、ここまでできたのでOFormat[BBB]
とOFormat[CCC]
を使ってOFormat[AAA]
を定義していきます。
OFormatは、結局OWritesとReadsが組み合わさったものに過ぎないので、まずはOWritesだけ定義してみます。
val writes: OWrites[AAA] = OWrites[AAA]{ case x: BBB => BBB.format.writes(x) case x: CCC => CCC.format.writes(x) }
定形コードで残念ですね。しかし、マクロかリフレクションかコード生成などを使わない限り、これ以上あまり短くならないと思います。OWritesについては諦めます。
問題はReadsです。
play標準では、実はReadsの合成の仕組みが存在します。このあたり
- https://github.com/playframework/playframework/blob/2.3.7/framework/src/play-json/src/main/scala/play/api/libs/json/Reads.scala#L87-L94
- https://github.com/playframework/playframework/blob/2.3.7/framework/src/play-functional/src/main/scala/play/api/libs/functional/syntax/package.scala#L17
- https://github.com/playframework/playframework/blob/2.3.7/framework/src/play-functional/src/main/scala/play/api/libs/functional/Alternative.scala#L20-L21
上記のコードだけみても、どうやって使うのかわからないとおもいますが、簡単にまとめると
- ReadsはAlternativeのインスタンスです*1
- Alternativeには
def |[A, B >: A](alt1: M[A], alt2: M[B]): M[B]
というメソッドがあります - つまりAlternative[Reads]の場合は
def |[A, B >: A](alt1: Reads[A], alt2: Reads[B]): Reads[B]
になり、2つのReadsを合成して1つの新しいReadsを返します - その新しいReadsの動作とは
- AlternativeOpsというものへのimplicit conversionがあり、pimp my libraryパターンで r1 or r2 もしくは r1 | r2 と呼び出せるようになってる
という感じです。便利そうですね?さっそく使ってみましょう
val reads: Reads[AAA] = BBB.format or CCC.format
value or is not a member of play.api.libs.json.OFormat[play.jsonext.BBB] [error] val reads: Reads[AAA] = BBB.format or CCC.format [error] ^
エラーになりました、つらいですね。なぜかpimp my libraryができなくて、そもそもメソッドないといわれます。以下のように、明示的にReadsのAlternativeのインスタンス持ってきて呼び出すか、一旦OFormat[BBB]をReads[BBB]にするだけで、とりあえずAlternativeのメソッドの呼び出しまではやろうとしてくれます
val reads: Reads[AAA] = (BBB.format: Reads[BBB]) or CCC.format
val reads: Reads[AAA] = implicitly[Alternative[Reads]].|(BBB.format, CCC.format)
しかし、どちらも同じエラーです。
type mismatch; [error] found : play.api.libs.json.OFormat[CCC] [error] required: play.api.libs.json.Reads[AAA] [error] Note: CCC <: AAA, but trait Reads is invariant in type A. [error] You may wish to define A as +A instead. (SLS 4.5) [error] val reads: Reads[AAA] = (BBB.format: Reads[BBB]) or CCC.format [error] ^
type mismatch; [error] found : play.api.libs.json.OFormat[CCC] [error] required: play.api.libs.json.Reads[AAA] [error] Note: CCC <: AAA, but trait Reads is invariant in type A. [error] You may wish to define A as +A instead. (SLS 4.5) [error] val reads: Reads[AAA] = implicitly[Alternative[Reads]].|(BBB.format, CCC.format) [error] ^
Scalaつらいですね、共変反変つらいですね、Haskellやりましょう。
さて、とはいってもScalaで頑張らないといけないので、諦めずにもう少し考えてみましょう。
required: Reads[AAA]
と言われているし、Alternativeのあのメソッドの定義的にも、Reads[BBB]やReads[CCC]が、そもそも全部Reads[AAA]だったら動きそうですよね?じゃあ型アノテーション付けるだけでReads[BBB]がReads[AAA]になるか?というとなりません。怒られます
type mismatch; [error] found : play.api.libs.json.OFormat[BBB] [error] required: play.api.libs.json.Reads[AAA] [error] Note: BBB <: AAA, but trait Reads is invariant in type A. [error] You may wish to define A as +A instead. (SLS 4.5) [error] (BBB.format: Reads[AAA]) [error] ^
「Readsが非変だから、Readsを共変にすればいいかもよ?」と、Scalaコンパイラさんは、親切に助言してくれます。
確かにReadsが共変だったら動く気もします。一つの結論としては、それで完結です。
しかし、Readsを共変にすると別の問題発生するかも?とか、そもそもpull reqするの面倒ですし、pull reqしてもすぐ使えるわけでもないですし、なによりvarianceと共に生きていくのはつらいです。varianceやsubtypingがない世界にいきたいです。
まず、別の雑な解決策としては、面倒なので直接キャストしてしまえば解決です。やりましたね!*2
val reads: Reads[AAA] = implicitly[Alternative[Reads]].|(BBB.format, CCC.format.asInstanceOf[Reads[AAA]])
直和型のパターンが2つくらいだったら上記でいいですが、もっと多かったら、AAAのobject内に(privateな)キャスト用メソッドつくっておくとかすれば、asInstanceOfを書く箇所が1つになって、すこしはマシになるとかでしょうか。
private[this] def castReads[X <: AAA](a: Reads[X]): Reads[AAA] = a.asInstanceOf[Reads[AAA]] // BBB, CCC 以外にも、もっとサブクラスが存在した場合 val read: Reads[AAA] = castReads(BBB.format) | castReads(CCC.format) | castReads(DDD.format) | castReads(EEE.format)
あとは、このアプローチでは、同じレベルの安全性で、これ以上の抽象化したりコード短くするのは厳しいと思います。castReadsメソッドも、単純にはこれ以上抽象化できない?ので、それぞれの直和型の親のコンパニオンに定義しないといけなそうです。
マクロかリフレクションか、HList的なメタメタしたテクニックか、コード生成しない限り、Scalaでこれ以上の根本的な抽象化は無理だと思います、Scalaはその程度の言語です、諦めましょう。
さて、少し視点を変えて考えるとして、そもそも、Reads[BBB] を Reads[AAA] になぜ出来ないのでしょうか?
実は、Reads[BBB] を単体で以下のように明示的に型パラメータを指定して定義すれば、Reads[AAA]型にできますね?
val reads: Reads[AAA] = ( (__ \ "a").read[Int] and (__ \ "b").read[String] ).apply[AAA](apply _)
やりました!キャスト使わずにReads[BBB]がReads[AAA]になってますね!
じゃあサブクラスで、ReadsとWritesを別々に定義すればいいんでしょうか?それとも、OFormatの定義でも、いい感じに同時にReads[AAA]を定義するいい方法があるでしょうか?
まず、ReadsとWritesを愚直に別々に定義したら、fieldのそれぞれのkeyのStringやvalueの型を2回かかないといけなくて、全然DRYじゃないですね?
val reads: Reads[AAA] = ( (__ \ "a").read[Int] and (__ \ "b").read[String] ).apply[AAA](apply _) val writes: Writes[BBB] = ( (__ \ "a").write[Int] and (__ \ "b").write[String] )(unlift(unapply))
これならもう、さっきの方法でキャストしたほうがマシに思えます。
キャスト使わずに型安全な操作にこだわると、上記でやってるようなReadsとWritesの別々の定義を、便利にDRYに書けるユーティリティメソッド作っておくという方法くらいしかなさそうです。
1から22までのボイラープレートは必要そうですが、一応それだけやれば、汎用的に使えそうです。
というわけで、マクロとか考えないとだいぶ地味なたいしたことない結論に落ち着くわけですが、以前つくったplay-json-extraというライブラリに、そんな感じの
「こういった直和型のために、ReadsとOWritesで違う型を返すための便利メソッド(内部実装でもキャスト使ってない!)」
を追加して、新しいversion 0.2.2をリリースしました。
https://github.com/xuwei-k/play-json-extra/tree/v0.2.2
実際の使い方は、以下のテストコードみてください。readsAndWritesというメソッドの型パラメータにReadsの型だけ明示的に指定します。
すると、タプルで、(Reads[AAA], Write[BBB]) が返ってきます。
https://github.com/xuwei-k/play-json-extra/blob/v0.2.2/src/test/scala/CoproductTest.scala#L36
object BBB { val (reads, writes) = CaseClassFormats.readsAndWrites[AAA]("a", "c").build(apply, unapply) }
注意点として、そもそも元々のplay-json-extraの仕様として、readNullable, writeNullableなどの「キー自体が存在しないパターン」や、その他複雑なパターンには対応できないので注意してください。
readNullableに対応したものを作りたい場合は、以下のあたりの
CanBuild2やCanBuild3を引数にとるか、pimp my libraryパターンでメソッド生やすものを作ることになる感じですかね?
それほどコード量変わらないので、もうキャストのメソッドを毎回定義でもいいような気もします。
ちなみに、このあたりのsealedとサブクラスで表現された直和型なデータの型クラスのインスタンスの生成は、
数日前に原理を解説したように
代数的データ型とshapelessのマクロによる型クラスのインスタンスの自動導出
shapeless使うとほぼ全自動でやってくれるので、(keyの値を明示的に指定できなくなる、という欠点はあるが)、shapelessやマクロ使うのに抵抗がなくて、とにかく手軽に全自動生成したい場合は、shapeless使えばいいと思います。