252要素のcase classまでのplayframeworkのJsonのReadsやWritesやFormatを生成できるライブラリ作った

https://github.com/xuwei-k/play-twenty-three


特に断りが無い限り、以下の解説は、これ書いてる時点の(最初のリリースである)0.1.0に関するものです。

sonatype経由でmaven centralにおいてあるので、githubのREADME通りに設定すればもう使えるはずです。

以下のような謎の努力をしました


そもそも、以前紹介したように、shapeless使えばそれほど苦労なくできるわけですが

shapelessのHListとScalazを組み合わせて23要素以上のcase classのPlay2のJsonのReadsを作る

そのときにも書いたけれど、case classのそれぞれの型から推論してほしくて、JsonのkeyとなるStringだけ指定したい場合のほうが多いと思うので、それをできるようにしました。つまり以前作ったplay-json-extraというやつ

playframework2のjsonに関するライブラリつくった

と目的は同じです。23以上のcase classに対応させただけです。play-json-extraとリポジトリ分けてあるのは、今回作ったものはshapelessに依存するのと、play2.3.xかつScala2.11.xのみの対応になったからです。*1


個人的には、コンパイル時間を減らすための謎の努力が面白かったですが、まぁその詳細な解説はみなさんそれほど興味ないでしょうし、今回は詳しくは書きません。基本的にはとにかく抽象化諦めたり、implicitを明示的に渡すようにしただけです。
ただ、コンパイル時間減ったと言っても、手元のcore i7のそれなりのPCでも10分程度かかりますし、travis ci上だと20分以上かかります。*2

travisはたしか、10分以上なにも出力しないと死んでるとみなされ強制的に殺されるので、殺されないように「意味なく一定時間ごとに適当な文字列を出力する」ような工夫をしたくらいです。

使い方は、testのディレクトリに最低限のサンプルはおいてあるので、それをみてもらうのがいいかと思います

https://github.com/xuwei-k/play-twenty-three/blob/v0.1.0/src/test/scala/Test.scala


コード自体が1ファイルで超巨大で読みづらいので、たとえば3要素の場合にどうなっているかコードを抜き出しておくと、こんな感じです

  def apply(k1: String, k2: String, k3: String): Builder3 =
    new Builder3(k1, k2, k3)

  def apply3(k1: String, k2: String, k3: String): Builder3 =
    new Builder3(k1, k2, k3)

  final class Builder3 private[PlayJson](k1: String, k2: String, k3: String) {
    
    def reads[A1, A2, A3, Z](f: Generic[Z]{ type Repr = A1 :: A2 :: A3 :: HNil })(implicit A1: Reads[A1], A2: Reads[A2], A3: Reads[A3]): Reads[Z] =
      Reads[Z]( j =>
        sequence3[A1, A2, A3](Reads.at(JsPath(KeyPathNode(k1) :: Nil))(A1).reads(j) :: Reads.at(JsPath(KeyPathNode(k2) :: Nil))(A2).reads(j) :: Reads.at(JsPath(KeyPathNode(k3) :: Nil))(A3).reads(j) :: HNil).map(f.from)
      )
    
    def writes[A1, A2, A3, Z](f: Generic[Z]{ type Repr = A1 :: A2 :: A3 :: HNil })(implicit A1: Writes[A1], A2: Writes[A2], A3: Writes[A3]): OWrites[Z] =
      OWrites[Z]{ z =>
        val _0 = f.to(z)
        val _1 = _0.tail; val _2 = _1.tail
        (JsPath.createObj((JsPath \ k1) -> A1.writes(_0.head))).deepMerge(JsPath.createObj((JsPath \ k2) -> A2.writes(_1.head))).deepMerge(JsPath.createObj((JsPath \ k3) -> A3.writes(_2.head)))
      }
    
    def format[A1, A2, A3, Z](f: Generic[Z]{ type Repr = A1 :: A2 :: A3 :: HNil })(implicit A1: Format[A1], A2: Format[A2], A3: Format[A3]): OFormat[Z] =
      OFormat(
        reads(f)(A1, A2, A3),
        writes(f)(A1, A2, A3)
      )
  }

これが、1から252まで延々繰り返されてるものを、自動生成しました。sequenceの処理*3は、typelevel/shapeless-contribのものをほぼ使いつつ、実行時の効率化とコンパイル時間短縮のために、わざと抽象度を下げてplayのJsResultに特殊化したものを組み込みました。なので、shapeless-cotribには依存していないし、scalazにも依存してません。依存はplay-jsonとshapelessだけです。独自にHListつくればshapelessに依存しなくてもすみましたが、みんなcase classからHListの相互変換手動で書かずにやるとしたら、shapelessのGenericを使うくらいしか無いだろうから、shapelessには依存しました。


使う場合は最初に、keyのStringを渡して、その後shapelessのGenericというオブジェクトを渡すようになってます(shapeless.Genericを渡す際に推論され、implicitにReadsやWritesを引数に取る)
shapelessのGenericは、case classとHListの相互変換するためのものを、マクロで完全に自動生成してくれるスゴイものです。*4
keyを渡すと、一旦別のオブジェクト(上記ではBuilder3となってるもの)が返ってきます。その理由は

ということです。

さらにHList使って頑張れば、252要素ではなくもっといけるとも思いますが、さすがに252あれば十分だろうと思ったので頑張ってません。
そもそも、こんな巨大なコード生成せずに、こういうことはマクロでやるべきといえばそのとおりなんですが、実行時の効率化やコンパイル時間短縮、マクロ使い慣れてない人へのわかりやすさ*5、などで少しはメリットがあるかもしれません(?)
あとは、とにかく試してみたかったのと、現状の自分にはそれほどマクロ書く技術がない、というだけで深い理由はありません。


気が向いたら、似たようなものをplayのjson以外でも作ろうかなぁ・・・

*1:Scala2.10には対応してません! shapelessのGenericが、case classでないclassからは、Genericのオブジェクト作れないようですし、そうするとScala2.10では手動でclassとHListの相互変換書くことになり、そんなことをする人は多くないだろうし、まぁ切り捨てていいか、と言った理由

*2:このライブラリ自体をビルドする際の話で、ライブラリのユーザー側は、そこまではかかりません

*3: A[B[C]]をB[A[C]]に変える処理。今回の場合、HListとplayのJsResultを逆にする処理

*4:もちろん実行時のリフレクションは一切使わないし型安全

*5:内部的にはこれもshapeless経由でマクロ使うわけだが、より隠蔽されるので