LoginSignup
3
1

More than 3 years have passed since last update.

circeで値クラス含みのクラスのエンコード/デコード

Last updated at Posted at 2020-01-13

1. 値クラスについて

Scalaには値クラス (value class)と呼ばれる機構があります。
値クラスは要は単なるラッパークラスですが、ラッパーとして振る舞うのはコンパイルまでで、実行時のデータ構造には内部の値を直接使ってくれる(ラッパーオブジェクトへのメモリ割り当てが回避される)クールなやつです。
値クラスは下記のように、ただ1つのpublicなvalパラメータを持つクラスを、AnyValのサブクラスとして定義します。

IntWrapper.scala
class IntWrapper(val x: Int) extends AnyVal

値クラスのおかげで、メモリを気にせずラッパーの恩恵(型安全性、リッチなコンテクストなど)を享受できます。1

2. circeについて

circe(サースィー、またはキルケーと読むらしい)は、Scala用のオープンソースのJSONライブラリです。
既存型のみならず、ユーザー定義型でもそのままいい感じにJSONと相互変換してくれるスマートなやつです。
circeの使い方自体は、公式ドキュメントや以下のサイトなどを参考にしてください。

ScalaのJSONライブラリcirceの使い方
circeを使ってJSONとScalaクラスを相互変換する

3. circeで値クラス含みのクラスをJSONと相互変換する

本題です。
例として、以下のようなクラスを考えます。

Book.scala
case class Book(id: BookId, title: String)
case class BookId(value: Long) extends AnyVal

BookクラスはBookIdクラスの値と文字列をフィールドに持ちます。
BookIdクラスは値クラスで、ただのLongのラッパーです。

このBookクラスを、circeでJSONと相互変換してみます。

3-1. 依存性の追加

まずはcirceを使えるよう、build.sbtに依存性を追加します。
ここはさらっと流します。

build.sbt
val circeVersion = "0.12.3"

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

3-2. 通常変換

通常時の挙動を確かめるために、何も手を加えずにエンコードしてみます(Book → JSON文字列)。

Main.scala
object Main extends App {
  import io.circe.generic.auto._
  import io.circe.syntax._

  val book = Book(BookId(1), "Scalaスケーラブルプログラミング")

  // asJsonでBookをcirce.Jsonに変換し、spaces2でcirce.JsonをStringに変換している
  println(book.asJson.spaces2)
}
出力結果
{
  "id" : {
    "value" : 1
  },
  "title" : "Scalaスケーラブルプログラミング"
}

circeの力によりJSONへの変換自体は問題なくできたものの、BookIdクラスの部分がネストしています。
おそらく上記のvalueの部分が必要なケースはほとんどなく、通常はネストを取り除いた下記のようなJSONにしたいのではないでしょうか?

顧客が本当に欲しかったもの
{
  "id" : 1,
  "title" : "Scalaスケーラブルプログラミング"
}

また、このネストはデコード時にも問題になります(JSON文字列 → Book)。
現状では下記のように、BookIdクラスの構造に合わせてネストしたJSONを渡さなければ、Bookクラスのインスタンスを生成できません。

Main.scala
object Main2 extends App {
  import io.circe.generic.auto._
  import io.circe.parser.decode

  // ネストなし(通常想定されるJSON)
  val json = """ { 
      | "id": 1,
      | "title": "Scalaスケーラブルプログラミング"
      | } """.stripMargin

  // ネストあり(BookIdクラスの構造を加味したJSON)
  val nestedJson = """ { 
     | "id": { 
     |   "value": 1 
     | },
     | "title": "Scalaスケーラブルプログラミング"
     | } """.stripMargin

  // ネストなしだとデコードに失敗する
  println("normal: " + decode[Book](json))
  // ネストありだと成功する
  println("nested: " + decode[Book](nestedJson))
}
出力結果
normal: Left(DecodingFailure(Attempt to decode value on failed cursor, List(DownField(value), DownField(id))))
nested: Right(Book(BookId(1),Scalaスケーラブルプログラミング))



これらのBookIdクラスのネストについて、エンコード時(Book → JSON文字列)はネストを取り除き、デコード時(JSON文字列 → Book)はネストのない文字列からBookインスタンスを生成できるようにするのが、今回の主題です。

3-3. ネストの排除

まず、BookId用のEncoder/Decoderを定義したトレイトを用意します。
EncoderDecoderはどちらも型クラスなので、BookIdクラスをEncoderおよびDecoderのインスタンスとして宣言します。
(「型クラス」の意味がわからなくても、なんとなく無名クラスを作っているんだなくらいの理解でOKです)

BookIdJsonSupport.scala
import io.circe.{Decoder, Encoder, HCursor, Json}
import io.circe.syntax._

trait BookIdJsonSupport {
  // BookId → JSON
  implicit val bookIdEncoder: Encoder[BookId] = new Encoder[BookId] {
    override def apply(bookId: BookId): Json = bookId.value.asJson
  }

  // JSON → BookId
  implicit val bookIdDecoder: Decoder[BookId] = new Decoder[BookId] {
    override def apply(c: HCursor): Decoder.Result[BookId] =
      c.value.as[Long].map(BookId.apply)
  }
}

重要な点として、それぞれimplicitキーワードをつけて宣言する必要があります。

EncoderトレイトとDecoderトレイトは(型は違いますが)それぞれapplyというただ一つの抽象メソッドを持つため、それらを実装した無名クラスを定義しています。

bookIdEncoder[BookId]BookIdをJSONに変換するとき、自身(BookId)ではなく自身の持つ値(Long)をJSONに変換します。
これによりJSONからvalueという項目がなくなり、ネストが排除されます。

bookIdDecoderは少しわかりづらいかもしれません。
HCursorクラスの詳細は割愛しますが、このc.valueがJSONの値(1とか"hoge"とか)になります。
(※このvalueBookIdクラスのvalueフィールドとは全く無関係の別物です。)

bookIdDecoderはJSONの値をまずはLongとして解釈し、それからそのLong値をBookIdクラスに変換しています。

戻り値の型のDecoder.Result[A]は、単なるEither[DecodingFailure, A]の型エイリアスです。

Decoder.scala
final type Result[A] = Either[DecodingFailure, A]

JSONのデコードは失敗する可能性があるため、結果をEitherでラップしています。

細かい注意点として、上記のEncoder[BookId]/Decoder[BookId]を使用する際は、逆に今までのようにネスト含みのJSONを生成することも、{ "value" : 100 }のようなBookIdの構造通りのJSONのデコードもできなくなります。
それで困るような場面もまずないと思いますが、必要に応じて使い分けてください。



利用する際は、上記のトレイトをBookクラス(BookIdクラスではない)をエンコード/デコードしたいクラスにミックスインします。

Main.scala
import io.circe.generic.auto._
import io.circe.parser.decode
import io.circe.syntax._

object Main extends App with BookIdJsonSupport {
  val book = Book(BookId(1), "Scalaスケーラブルプログラミング")
  val json = """ { "id":2, "title": "実践Scala入門" } """

  // 変換時に暗黙のEncoder[BookId]/Decoder[BookId]を必要とする場合、
  // BookIdJsonSupportのものを使うようになる
  println(book.asJson.spaces2)
  println(decode[Book](json))
}
出力結果
{
  "id" : 1,
  "title" : "Scalaスケーラブルプログラミング"
}
Right(Book(BookId(2),実践Scala入門))

無事、BookIdのネスト部分がなくなり、またネストなしのJSONからBook
クラスのインスタンスを生成できるようになりました。
ここではトレイトをミックスインしていますが、Encoder[BookId]/Decoder[BookId]が暗黙のパラメータの探索範囲内に存在しさえすれば、他の方法でも構いません。

また実用上は上記のようにBookIdJsonSupportを直接ミックスインするよりも、BookJsonSupportのようなBookクラス用のトレイトを作り、そちらをミックスインした方が便利なケースが多いです。
こうすることで、下記のようにBookクラスのエンコード/デコード時にBookId以外のフィールドも考慮しなければいけない場合も、利用側はその詳細を知らずに済みます。

BookJsonSupport.scala
// 考慮するフィールドが増えても
trait BookJsonSupport
    extends BookIdJsonSupport
    with TitleJsonSupport
    with AuthorJsonSupport
    with DescriptionJsonSupport
HogeImpl.scala
// 利用側はBookJsonSupportをミックスインするだけでいい(詳細は知らなくていい)
class HogeImpl extends Hoge with BookJsonSupport {
  // ・・・
}

3-4. 表記の簡略化

ここからは余談です。
上記のEncoder[BookId]/Decoder[BookId]は説明用にかなりゴテゴテに書いたので、実際はもう少し簡略化できます。

簡略化1. SAM変換

circeのEncoder[A]Decoder[A]はどちらも抽象メソッドをただ一つだけ持つ型なので、SAM type(Single-Abstract-Method type)にあたります。
そのため、SAM変換により以下のように無名クラスをラムダ式に置き換えることが可能です。

BookIdJsonSupport.scala
import io.circe.Decoder.Result
import io.circe.{Decoder, Encoder, HCursor, Json}
import io.circe.syntax._

trait BookIdJsonSupport {

  // BookId -> Jsonのラムダ式に置き換え
  // この_.valueはBookId#value
  implicit val bookIdEncoder: Encoder[BookId] = _.value.asJson

  // HCursor -> Decorder.Result[BookId]のラムダ式に置き換え
  // この_.valueはHCursor#value
  implicit val bookIdDecoder: Decoder[BookId] = _.value.as[Long].map(BookId.apply)

//  implicit val bookIdEncoder: Encoder[BookId] = new Encoder[BookId] {
//    override def apply(bookId: BookId): Json = bookId.value.asJson
//  }

//  implicit val bookIdDecoder: Decoder[BookId] = new Decoder[BookId] {
//    override def apply(c: HCursor): Decoder.Result[BookId] =
//      c.value.as[Long].map(BookId.apply)
//  }
}

簡略化2. Encoder/Decoderのメソッドを使う

SAM変換はScalaの機能ですが、単純にEncoder/Decoderの便利なメソッドを使っての簡略化も可能です(多分これがデファクトスタンダード)。

BookIdJsonSupport.scala
import io.circe.{Decoder, Encoder}

trait BookIdJsonSupport {
  implicit val bookIdEncoder: Encoder[BookId] = Encoder[Long].contramap(_.value)

  implicit val bookIdDecoder: Decoder[BookId] = Decoder[Long].map(BookId.apply)
}

実装の詳細は割愛しますが、やってることは最初の明示的に無名クラスを作ったときとほとんど同じです。
circeが裏でいい感じに無名クラスを作ってくれています。

特に理由がない限り、この書き方でいいんじゃないかなと思います。

4. 最後に

circeを使う際、値クラスをどう扱えばいいのかで試行錯誤したのでまとめてみました。
参考になれば幸いです。

最後までお読み頂きありがとうございました。
質問や不備についてはコメント欄かTwitterまでお願いします。


  1. メモリが割り当てられる場合もありますが、本題ではないため割愛します。 

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1