Permalink
Browse files

sjson-new and custom codecs using LList

  • Loading branch information...
1 parent a9b562b commit 856e48123b29a7f496eb4c867d227039e33f13be @eed3si9n committed May 25, 2016
@@ -0,0 +1,182 @@
+ [1]: http://eed3si9n.com/ja/sjson-new
+ [2]: http://2016.flatmap.no/
+ [3]: http://event.scaladays.org/scaladays-nyc-2016
+ [4]: https://vimeo.com/165837504
+
+2ヶ月ぐらい前に [sjson-new][1] について書いた。週末にまたちょっといじってみたので、ここに報告する。
+前回は Scala エコシステムにおける JSON ライブラリの家系をたどって、複数バックエンドに対応し、かつ型クラスベースの JSON コーデックライブラリという概念を導入した。課題は、カスタムコーデックを簡単に定義できるようにする必要があるということだった。
+
+### 私家版 shapeless
+
+4月に書いたのと先週までの間に [flatMap(Oslo) 2016][2] と [Scala Days New York 2016][3] という 2つのカンファレンスがあった。残念ながら、僕は flatMap の方には行けなかったけども、Daniel Spiewak さんの "Roll Your Own Shapeless" (「私家版 Shapeless のすゝめ」) というトークを New York で聞けた。[flatMap 版][4]の方が完全版でそれは vimeo にも出てるので、是非チェックしてみてほしい。
+
+sbt の内部では、sbinary を用いたキャッシングに HList が用いられてたりする:
+
+<scala>
+implicit def mavenCacheToHL = (m: MavenCache) => m.name :+: m.rootFile.getAbsolutePath :+: HNil
+implicit def mavenRToHL = (m: MavenRepository) => m.name :+: m.root :+: HNil
+...
+</scala>
+
+そういう影響もあって、HList とか Shapeless の `LabelledGeneric` みたいなのがあれば JSON object を表す中間値としていいのではないかと思っていたので、Daniel のトークには最後に背中を押してもらった気がする。
+
+本稿では、HList の目的を特化した LList というものを紹介する。
+
+### LList
+
+sjson-new には **LList** というデータ型があって、これは labelled heterogeneous list、ラベル付された多型リストだ。
+標準ライブラリについてくる `List[A]` は、`A` という同じ型しか格納することができない。標準の `List[A]` と違って、LList はセルごとに異なる型の値を格納でき、またラベルも格納することができる。このため、LList はそれぞれ独自の型を持つ。REPL で見てみよう:
+
+<scala>
+scala> import sjsonnew._, LList.:+:
+import sjsonnew._
+import LList.$colon$plus$colon
+
+scala> import BasicJsonProtocol._
+import BasicJsonProtocol._
+
+scala> val x = ("name", "A") :+: ("value", 1) :+: LNil
+x: sjsonnew.LList.:+:[String,sjsonnew.LList.:+:[Int,sjsonnew.LNil]] = (name, A) :+: (value, 1) :+: LNil
+
+scala> val y: String :+: Int :+: LNil = x
+y: sjsonnew.LList.:+:[String,sjsonnew.LList.:+:[Int,sjsonnew.LNil]] = (name, A) :+: (value, 1) :+: LNil
+</scala>
+
+`x` の長い型の名前の中に `String``Int` が書かれているのが分かるだろうか。`y` の例が示すように、`String :+: Int :+: LNil` は同じ型の略記法だ。
+
+`BasicJsonProtocol` は全ての LList の値を JSON オブジェクトに変換することができる。
+
+### isomorphism を使ったカスタムコーデック
+
+LList は JSON object に変換可能なので、あとはカスタムの型から LList に行ったり来たりできるようになればいいだけだ。この概念は isomorphism (同型射) と呼ばれる。
+
+<scala>
+scala> import sjsonnew._, LList.:+:
+import sjsonnew._
+import LList.$colon$plus$colon
+
+scala> import BasicJsonProtocol._
+import BasicJsonProtocol._
+
+scala> case class Person(name: String, value: Int)
+defined class Person
+
+scala> implicit val personIso = LList.iso(
+ { p: Person => ("name", p.name) :+: ("value", p.value) :+: LNil },
+ { in: String :+: Int :+: LNil => Person(in.head, in.tail.head) })
+personIso: sjsonnew.IsoLList.Aux[Person,sjsonnew.LList.:+:[String,sjsonnew.LList.:+:[Int,sjsonnew.LNil]]] = sjsonnew.IsoLList$$anon$1@4140e9d0
+</scala>
+
+上のような implicit 値を `Person`*ある* LList と同型である「証明」として使って、sjson-new はここから `JsonFormat` を導出することができる。
+
+<scala>
+scala> import sjsonnew.support.spray.Converter
+import sjsonnew.support.spray.Converter
+
+scala> Converter.toJson[Person](Person("A", 1))
+res0: scala.util.Try[spray.json.JsValue] = Success({"name":"A","value":1})
+</scala>
+
+見てのとおり、`Person("A", 1)``{"name":"A","value":1}` にエンコードすることができた。
+
+### 型の直和としての ADT
+
+sealed trait を使った代数的データ型があるとする。`JsonFormat` を合成するために、`unionFormat2`, `unionFormat3`, ... という関数を用意した。
+
+<scala>
+scala> import sjsonnew._, LList.:+:
+import sjsonnew._
+import LList.$colon$plus$colon
+
+scala> import BasicJsonProtocol._
+import BasicJsonProtocol._
+
+scala> :paste
+// Entering paste mode (ctrl-D to finish)
+
+sealed trait Contact
+case class Person(name: String, value: Int) extends Contact
+case class Organization(name: String, value: Int) extends Contact
+
+implicit val personIso = LList.iso(
+ { p: Person => ("name", p.name) :+: ("value", p.value) :+: LNil },
+ { in: String :+: Int :+: LNil => Person(in.head, in.tail.head) })
+implicit val organizationIso = LList.iso(
+ { o: Organization => ("name", o.name) :+: ("value", o.value) :+: LNil },
+ { in: String :+: Int :+: LNil => Organization(in.head, in.tail.head) })
+implicit val ContactFormat = unionFormat2[Contact, Person, Organization]
+
+// Exiting paste mode, now interpreting.
+
+scala> import sjsonnew.support.spray.Converter
+import sjsonnew.support.spray.Converter
+
+scala> Converter.toJson[Contact](Organization("Company", 2))
+res0: scala.util.Try[spray.json.JsValue] = Success({"value":{"name":"Company","value":2},"type":"Organization"})
+</scala>
+
+
+`unionFormatN[U, A1, A2, ...]` 関数は、型 `U` が sealed な親 trait であることを前提としている。JSON object 中では、これは簡単な型名 (クラス名の部分だけ) を `type` というフィールドに書くことでエンコードしている。実行時クラス名を取得するのに Java リフレクションを使った。
+
+### 低レベル API: Builder と Unbuilder
+
+例えば JString を使ったエンコードを行いたいなど、もっと低レベルな JSON 書き出しを支援するために、sjson-new は Builder と Unbuilder というものを提供する。これは命令型スタイルの API で、より AST に近い。例えば、`IntJsonFormat` はこのように定義されている:
+
+<scala>
+implicit object IntJsonFormat extends JsonFormat[Int] {
+ def write[J](x: Int, builder: Builder[J]): Unit =
+ builder.writeInt(x)
+ def read[J](js: J, unbuilder: Unbuilder[J]): Int =
+ unbuilder.readInt(js)
+}
+</scala>
+
+`Builder` はプリミティブ値を書き出すための `writeX` メソッド群を提供する。一方 `Unbuilder` は、`readX` メソッド群を提供する。
+
+`BasicJsonProtocol` は既に `List[A]` などの標準コレクションのエンコーディングを提供するけども、独自の型を JSON array にエンコードしたいかもしれない。JSON array を書くには、`beginArray()` を呼び、`writeX` メソッド群を使って、最後に `endArray()` を呼ぶ。Builder は内部で状態を保持しているので、array を開始してないのに終了できないようになっている。
+
+JSON object を書き出すには、上記のように LList への isomorphism を使うか、`beginObject()` を呼び、`addField("...")``writeX` メソッド群をペアで呼んで、最後に `endObject()` を呼ぶ。先ほど見た `Person` case class のカスタムコーデックを Builder/Unbuilder を直接使って定義するとこうなる:
+
+<scala>
+implicit object PersonFormat extends JsonFormat[Person] {
+ def write[J](x: Person, builder: Builder[J]): Unit = {
+ builder.beginObject()
+ builder.addField("name")
+ builder.writeString(x.name)
+ builder.addField("value")
+ builder.writeInt(x.value)
+ builder.endObject()
+ }
+ def read[J](js: J, unbuilder: Unbuilder[J]): Person = {
+ unbuilder.beginObject(js)
+ val name = unbuilder.lookupField("name") match {
+ case Some(x) => unbuilder.readString(x)
+ case _ => deserializationError(s"Missing field: name")
+ }
+ val value = unbuilder.lookupField("value") match {
+ case Some(x) => unbuilder.readInt(x)
+ case _ => 0
+ }
+ unbuilder.endObject()
+ Person(name, value)
+ }
+}
+</scala>
+
+さっきのは 3行だったけど、これは 25行になった。LList を作らない分速くはなるかもしれない。
+
+### sjson-new 0.2.0
+
+本稿で紹介した機能は 0.2.0 に入っている。Json4s-AST と使う場合は:
+
+<scala>
+libraryDependencies += "com.eed3si9n" %% "sjson-new-json4s" % "0.2.0"
+</scala>
+
+Spray と使う場合は:
+
+<scala>
+libraryDependencies += "com.eed3si9n" %% "sjson-new-spray" % "0.2.0"
+</scala>
+
+今の所マクロは一切使用してなくて、リフレクションもパターンマッチングとクラス名の取得に限られている。
@@ -0,0 +1,180 @@
+ [1]: http://eed3si9n.com/sjson-new
+ [2]: http://2016.flatmap.no/
+ [3]: http://event.scaladays.org/scaladays-nyc-2016
+ [4]: https://vimeo.com/165837504
+
+Two months ago, I wrote about [sjson-new][1]. I was working on that again over the weekend, so here's the update.
+In the earlier post, I've introduced the family tree of JSON libraries in Scala ecosystem, the notion of backend independent, typeclass based JSON codec library. I concluded that we some easy way of defining a custom codec it to be usable.
+
+### roll your own shapeless
+
+In between the April post and the last weekend, there were [flatMap(Oslo) 2016][2] and [Scala Days New York 2016][3]. Unfortunately I wasn't able to attend flatMap, but I was able to catch Daniel Spiewak's "Roll Your Own Shapeless" talk in New York. The full [flatMap version][4] is available on vimeo, so I recommend you check it out.
+
+sbt internally uses HList for caching using sbinary:
+
+<scala>
+implicit def mavenCacheToHL = (m: MavenCache) => m.name :+: m.rootFile.getAbsolutePath :+: HNil
+implicit def mavenRToHL = (m: MavenRepository) => m.name :+: m.root :+: HNil
+...
+</scala>
+
+and I've been thinking something like an HList or Shapeless's `LabelledGeneric` would be a good intermediate datatype to represent JSON object, so Daniel's talk became the last push on my back.
+In this post, I will introduce a special purpose HList called LList.
+
+### LList
+
+sjson-new comes with a datatype called **LList**, which stands for labelled heterogeneous list.
+`List[A]` that comes with the Standard Library can only store values of one type, namely `A`. Unlike the standard `List[A]`, LList can store values of different types per cell, and it can also store a label per cell. Because of this reason, each LList has its own type. Here's how it looks in the REPL:
+
+<scala>
+scala> import sjsonnew._, LList.:+:
+import sjsonnew._
+import LList.$colon$plus$colon
+
+scala> import BasicJsonProtocol._
+import BasicJsonProtocol._
+
+scala> val x = ("name", "A") :+: ("value", 1) :+: LNil
+x: sjsonnew.LList.:+:[String,sjsonnew.LList.:+:[Int,sjsonnew.LNil]] = (name, A) :+: (value, 1) :+: LNil
+
+scala> val y: String :+: Int :+: LNil = x
+y: sjsonnew.LList.:+:[String,sjsonnew.LList.:+:[Int,sjsonnew.LNil]] = (name, A) :+: (value, 1) :+: LNil
+</scala>
+
+Can you find `String` and `Int` mentioned in that long type name of `x`? `String :+: Int :+: LNil` is a short form of writing that as demonstrated by `y`.
+
+`BasicJsonProtocol` is able to convert all LList values into a JSON object.
+
+### custom codecs as isomorphism
+
+Because LList is able to turn itself into a JSON object, all we need now is a way to going back and forth between your custom type and an LList. This notion is called isomorphism.
+
+<scala>
+scala> import sjsonnew._, LList.:+:
+import sjsonnew._
+import LList.$colon$plus$colon
+
+scala> import BasicJsonProtocol._
+import BasicJsonProtocol._
+
+scala> case class Person(name: String, value: Int)
+defined class Person
+
+scala> implicit val personIso = LList.iso(
+ { p: Person => ("name", p.name) :+: ("value", p.value) :+: LNil },
+ { in: String :+: Int :+: LNil => Person(in.head, in.tail.head) })
+personIso: sjsonnew.IsoLList.Aux[Person,sjsonnew.LList.:+:[String,sjsonnew.LList.:+:[Int,sjsonnew.LNil]]] = sjsonnew.IsoLList$$anon$1@4140e9d0
+</scala>
+
+We can use the implicit value as a proof that `Person` is isomorphic to an LList, and sjson-new can then use that to derive a `JsonFormat`.
+
+<scala>
+scala> import sjsonnew.support.spray.Converter
+import sjsonnew.support.spray.Converter
+
+scala> Converter.toJson[Person](Person("A", 1))
+res0: scala.util.Try[spray.json.JsValue] = Success({"name":"A","value":1})
+</scala>
+
+As you can see, `Person("A", 1)` was encoded as `{"name":"A","value":1}`.
+
+### encoding ADT as union of types
+
+Suppose now that we have an algebraic datatype represented by a sealed trait. There's a function to compose the `JsonFormat` called `unionFormat2`, `unionFormat3`, ...
+
+<scala>
+scala> import sjsonnew._, LList.:+:
+import sjsonnew._
+import LList.$colon$plus$colon
+
+scala> import BasicJsonProtocol._
+import BasicJsonProtocol._
+
+scala> :paste
+// Entering paste mode (ctrl-D to finish)
+
+sealed trait Contact
+case class Person(name: String, value: Int) extends Contact
+case class Organization(name: String, value: Int) extends Contact
+
+implicit val personIso = LList.iso(
+ { p: Person => ("name", p.name) :+: ("value", p.value) :+: LNil },
+ { in: String :+: Int :+: LNil => Person(in.head, in.tail.head) })
+implicit val organizationIso = LList.iso(
+ { o: Organization => ("name", o.name) :+: ("value", o.value) :+: LNil },
+ { in: String :+: Int :+: LNil => Organization(in.head, in.tail.head) })
+implicit val ContactFormat = unionFormat2[Contact, Person, Organization]
+
+// Exiting paste mode, now interpreting.
+
+scala> import sjsonnew.support.spray.Converter
+import sjsonnew.support.spray.Converter
+
+scala> Converter.toJson[Contact](Organization("Company", 2))
+res0: scala.util.Try[spray.json.JsValue] = Success({"value":{"name":"Company","value":2},"type":"Organization"})
+</scala>
+
+The `unionFormatN[U, A1, A2, ...]` functions assume that type `U` is the sealed parent trait of the passed in types. In the JSON object this is encoded by putting the simple type name (just the class name portion) into `type` field. I am using Java reflection to retrieve the runtime class name.
+
+### lower-level API: Builder and Unbuilder
+
+If you want to drop down to a more lower level JSON writing, for example, to encode something as JString, sjon-new offers Builder and Unbuilder. This is a procedural style API, and it's closer to the AST. For instance, `IntJsonFormat` is defined as follows:
+
+<scala>
+implicit object IntJsonFormat extends JsonFormat[Int] {
+ def write[J](x: Int, builder: Builder[J]): Unit =
+ builder.writeInt(x)
+ def read[J](js: J, unbuilder: Unbuilder[J]): Int =
+ unbuilder.readInt(js)
+}
+</scala>
+
+`Builder` provides other `writeX` methods to write primitive values. `Unbuilder` on the other hand provides `readX` methods.
+
+`BasicJsonProtocol` already provides encoding for standard collections like `List[A]`, but you might want to encode your own type using JSON array. To write a JSON array, use `beginArray()`, `writeX` methods, and `endArray()`. The builder internally tracks the states, so it won't let you end an array if you haven't started one.
+
+To write a JSON object, you can use the LList isomorphism as described above, or use `beginObject()`, pairs of `addField("...")` and `writeX` methods, and `endObject()`. Here's an example codec of the same case class `Person` using Builder/Unbuilder:
+
+<scala>
+implicit object PersonFormat extends JsonFormat[Person] {
+ def write[J](x: Person, builder: Builder[J]): Unit = {
+ builder.beginObject()
+ builder.addField("name")
+ builder.writeString(x.name)
+ builder.addField("value")
+ builder.writeInt(x.value)
+ builder.endObject()
+ }
+ def read[J](js: J, unbuilder: Unbuilder[J]): Person = {
+ unbuilder.beginObject(js)
+ val name = unbuilder.lookupField("name") match {
+ case Some(x) => unbuilder.readString(x)
+ case _ => deserializationError(s"Missing field: name")
+ }
+ val value = unbuilder.lookupField("value") match {
+ case Some(x) => unbuilder.readInt(x)
+ case _ => 0
+ }
+ unbuilder.endObject()
+ Person(name, value)
+ }
+}
+</scala>
+
+The other one was three lines of iso, but this is 25 lines of code. Since it doesn't create LList, it might run faster.
+
+### sjson-new 0.2.0
+
+The features described in this post is available in 0.2.0. Here's how to use with Json4s-AST:
+
+<scala>
+libraryDependencies += "com.eed3si9n" %% "sjson-new-json4s" % "0.2.0"
+</scala>
+
+Here's how to use with Spray:
+
+<scala>
+libraryDependencies += "com.eed3si9n" %% "sjson-new-spray" % "0.2.0"
+</scala>
+
+Thus far, no macros are used, and the use of reflection is limited to pattern matching and retrieving class names.

0 comments on commit 856e481

Please sign in to comment.