circeのEncoder/Decoderを拡張してみる

More than 1 year has passed since last update.


概要

circeのEncoder/Decoderの拡張で試行錯誤した結果をメモしておきます。

内容に間違いや他にもっと良い書き方があるなどありましたら、ご指摘いただけると助かります。

なお、環境は以下で実施しています。


  • Scala 2.12.1

  • circe 0.6.1


circeについて

まずはcirceについて。

circeはScalaのJSONライブラリです。

特徴としては以下の通りです。



  • Typelevel.scala のプロジェクト


  • Argonaut をforkして開発されている(ただし、Scalaz の代わりに cats に依存している)


  • jawn による高速なJSONパース


  • shapeless によるcase classのジェネリックな自動コーデック


  • Monocle のLensによるJSONのトラバース

  • Scala.js のサポート

ちなみにcirceの読み方は最初「キルケ」だと思っていたのですが、以下の動画で確認すると「セルスィ」みたいに発音してますね。

公式ガイド にも "pronounced SUR-see" みたいに書いてあるので、きっとそうなんだろう。)


準備

build.sbtにcirceの依存を追加します。

lazy val circeVersion = "0.6.1"

libraryDependencies ++= Seq(
"io.circe" %% "circe-core" % circeVersion,
"io.circe" %% "circe-generic" % circeVersion,
"io.circe" %% "circe-parser" % circeVersion
)


拡張がいらないケース

circeって普通に使う分には、例えば この辺り に定義されているような型だったら特別な定義を書かなくてもEncode/Decodeできるから楽ちんですよね。

他のライブラリみたくimplicitなformat定義を書かなくても良いのでこれはかなり好印象です。

case class Person(id: Long, name: String, age: Int)

object Example extends App {

import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
import io.circe.syntax._

val person = Person(123l, "Taro", 15)

// Encode (case class -> JSON)
val encodeData: String = person.asJson.noSpaces
println(encodeData)
// {"id":123,"name":"Taro","age":15}

// Decode (JSON -> case class)
val decodedData: Either[Error, Person] = decode[Person](encodeData)
println(decodedData)
// Right(Person(Id(123),Name(Taro),Age(15)))

}

でも、それ以外の型とかデータ構造を扱う場合は一工夫する必要があります。


拡張の必要があるケース

拡張の必要があるケースとは、例えば次のようなケースです。


  • 値クラスでネストしたくない場合

  • 代数的データ型

  • Date and Time API (JSR-310)


値クラスでネストしたくない場合

最初に値クラスのケースを見てみましょう。

以下は値クラス IdNameAge をcase classの Person に含めた例です。

// 値クラス

case class Id(value: Long) extends AnyVal
case class Name(value: String) extends AnyVal
case class Age(value: Int) extends AnyVal

case class Person(id: Id, name: Name, age: Age)

object ValueClassExample extends App {
import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
import io.circe.syntax._

val person = Person(
Id(1234567890l),
Name("bob"),
Age(17)
)

// Encode (case class -> JSON)
val encodeData: String = person.asJson.spaces2
println(encodeData)

// Decode (JSON -> case class)
val decodedData: Either[Error, Person] = decode[Person](encodeData)
println(decodedData)

}

これをそのままEncodeすると、以下のようにネストしてしまいます。

{

"id" : {
"value" : 1234567890
},
"name" : {
"value" : "bob"
},
"age" : {
"value" : 17
}
}

このネストを回避するには、implicitの探索可能な箇所に以下のようなEncoder/Decoderの定義を追加します。(以降の例でもそうですが、今回はimplicitの探索が手っ取り早いコンパニオンオブジェクトに定義しました。しかし、実際にはドメインとかのレイヤーの役割を考慮し適切な箇所に定義する必要があります。)

case class Id(value: Long) extends AnyVal

object Id {
import io.circe._
implicit val encode: Encoder[Id] = Encoder[Long].contramap(_.value)
implicit val decode: Decoder[Id] = Decoder[Long].map(Id(_))
}

case class Name(value: String) extends AnyVal
object Name {
import io.circe._
implicit val encode: Encoder[Name] = Encoder[String].contramap(_.value)
implicit val decode: Decoder[Name] = Decoder[String].map(Name(_))
}

case class Age(value: Int) extends AnyVal
object Age {
import io.circe._
implicit val encode: Encoder[Age] = Encoder[Int].contramap(_.value)
implicit val decode: Decoder[Age] = Decoder[Int].map(Age(_))
}

こうすることで、Encode/Decode時のネストを回避することができます。

{

"id" : 1234567890,
"name" : "bob",
"age" : 17
}

なお、代替案として、shapelessを使ってこんな定義を追加すれば、先ほどのEncodeに関しては一括でネストなしに変換してくれるようになります。

implicit def encodeAnyVal[W <: AnyVal, U](implicit unwrapped: Unwrapped.Aux[W, U], encoderUnwrapped: Encoder[U]): Encoder[W] =

Encoder.instance[W](v => encoderUnwrapped(unwrapped.unwrap(v)))

shapelessは個人的にはまだほとんど触ったことがないので、今後試していきたいと思います。

また、こういう話も出てるので、今後のバージョンで対応してくれるかもしれませんね。


代数的データ型

次は代数的データ型(ADT)です。

以下のColorTypeは、色を表す代数的データ型です。

sealed trait ColorType

object ColorType extends (String => ColorType) {

case object Yellow extends ColorType
case object Blue extends ColorType
case object Red extends ColorType

def apply(color: String): ColorType = color.toLowerCase match {
case "yellow" => Yellow
case "blue" => Blue
case "red" => Red
case other => throw new IllegalArgumentException(s"invalid color: $other")
}
}

これをcase classのColorsのフィールドに定義してEncode/Decodeしてみます。

case class Colors(id: Long, colorType: ColorType)

object ADTExample extends App {
import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
import io.circe.syntax._

val colors = Colors(
1234567890l,
ColorType.Yellow
)

val encodeData: String = colors.asJson.spaces2
println(encodeData)

val decodedData: Either[Error, Colors] = decode[Colors](encodeData)
println(decodedData)

}

すると、Encodeの結果はこうなってしまいます。何か変ですね。

{

"id" : 1234567890,
"colorType" : {
"Yellow" : {

}
}
}

これ、"colorType" : "Yellow" という感じでEncodeしたいですよね。

そんな時は、以下の定義を追加します。

object ColorType extends (String => ColorType) {

// 省略

import io.circe._
implicit val encode: Encoder[ColorType] = Encoder.instance(x => Json.fromString(x.toString))
implicit val decode: Decoder[ColorType] = Decoder[String].map(ColorType(_))

こうすると、期待通りにEncodeしてくれるようになります。

{

"id" : 1234567890,
"colorType" : "Yellow"
}

ちなみにこのケースの代替案としては、circe-generic-extrasを使うという手もあります。

build.sbtlibraryDependenciesに以下を追加します。

"io.circe" %% "circe-generic-extras" % circeVersion

そして、Encoder/Decoderはこう書くと先ほどと同じように変換してくれます。

object ColorType {

// 省略

import io.circe._
import io.circe.generic.extras.semiauto._
implicit val encode: Encoder[ColorType] = deriveEnumerationEncoder
implicit val decode: Decoder[ColorType] = deriveEnumerationDecoder

なお、circe-generic-extrasは、それ以外にも次のような機能が提供されているようです。


  • JSONのフィールドのキャメルケースとスネークケースの相互変換

  • デフォルト値の指定

など


Date and Time API (JSR-310)

Java8の日付型であるDate and Time APIの場合は、最初自前で色々頑張っていたんですが、別途提供されているcirce-java8を使えば良いことが分かりました。

build.sbtlibraryDependenciesに以下を追加します。

"io.circe" %% "circe-java8" % circeVersion

使い方はこんな感じです。

import java.time.{LocalDate, LocalDateTime}

case class DateTimeModel(
date: LocalDate,
dateTime: LocalDateTime
)

object DateTimeExample extends App {

import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
import io.circe.syntax._
import io.circe.java8.time._

val original = DateTimeModel(
LocalDate.now(),
LocalDateTime.now()
)

// Encode
val encodeData: String = original.asJson.spaces2
println(encodeData)
// {
// "date" : "2017-01-09",
// "dateTime" : "2017-01-09T05:15:37.597"
// }

// Decode
val decodedData: Either[Error, DateTimeModel] = decode[DateTimeModel](encodeData)
println(decodedData)
// Right(DateTimeModel(2017-01-09,2017-01-09T05:15:37.597))

}


おわりに

circeは標準で提供されている以外のことをやろうとするとまだまだ情報が少ないのかなと感じました。

それでもやっぱり使っていて楽しいライブラリですね。

あと、個人的にはshapelessを知っていれば他にもっとやりようがあるのかなと感じているので、今年の前半はshapelessを学んでみようと思ってます。