1. 値クラスについて
Scalaには値クラス (value class)と呼ばれる機構があります。
値クラスは要は単なるラッパークラスですが、ラッパーとして振る舞うのはコンパイルまでで、実行時のデータ構造には内部の値を直接使ってくれる(ラッパーオブジェクトへのメモリ割り当てが回避される)クールなやつです。
値クラスは下記のように、ただ1つのpublicなval
パラメータを持つクラスを、AnyVal
のサブクラスとして定義します。
class IntWrapper(val x: Int) extends AnyVal
値クラスのおかげで、メモリを気にせずラッパーの恩恵(型安全性、リッチなコンテクストなど)を享受できます。1
2. circeについて
circe(サースィー、またはキルケーと読むらしい)は、Scala用のオープンソースのJSONライブラリです。
既存型のみならず、ユーザー定義型でもそのままいい感じにJSONと相互変換してくれるスマートなやつです。
circeの使い方自体は、公式ドキュメントや以下のサイトなどを参考にしてください。
ScalaのJSONライブラリcirceの使い方
circeを使ってJSONとScalaクラスを相互変換する
3. circeで値クラス含みのクラスをJSONと相互変換する
本題です。
例として、以下のようなクラスを考えます。
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
に依存性を追加します。
ここはさらっと流します。
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文字列)。
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
クラスのインスタンスを生成できません。
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
を定義したトレイトを用意します。
Encoder
とDecoder
はどちらも型クラスなので、BookId
クラスをEncoder
およびDecoder
のインスタンスとして宣言します。
(「型クラス」の意味がわからなくても、なんとなく無名クラスを作っているんだなくらいの理解でOKです)
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"とか)になります。
(※このvalue
はBookId
クラスのvalue
フィールドとは全く無関係の別物です。)
bookIdDecoder
はJSONの値をまずはLong
として解釈し、それからそのLong
値をBookId
クラスに変換しています。
戻り値の型のDecoder.Result[A]
は、単なるEither[DecodingFailure, A]
の型エイリアスです。
final type Result[A] = Either[DecodingFailure, A]
JSONのデコードは失敗する可能性があるため、結果をEither
でラップしています。
細かい注意点として、上記のEncoder[BookId]/Decoder[BookId]
を使用する際は、逆に今までのようにネスト含みのJSONを生成することも、{ "value" : 100 }
のようなBookId
の構造通りのJSONのデコードもできなくなります。
それで困るような場面もまずないと思いますが、必要に応じて使い分けてください。
利用する際は、上記のトレイトを`Book`クラス(`BookId`クラスではない)をエンコード/デコードしたいクラスにミックスインします。
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
以外のフィールドも考慮しなければいけない場合も、利用側はその詳細を知らずに済みます。
// 考慮するフィールドが増えても
trait BookJsonSupport
extends BookIdJsonSupport
with TitleJsonSupport
with AuthorJsonSupport
with DescriptionJsonSupport
// 利用側は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変換により以下のように無名クラスをラムダ式に置き換えることが可能です。
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
の便利なメソッドを使っての簡略化も可能です(多分これがデファクトスタンダード)。
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までお願いします。
-
メモリが割り当てられる場合もありますが、本題ではないため割愛します。 ↩