【Scala】play-jsonのReads/Writes頻出パターン5選

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

play-jsonはPlay Frameworkに含まれているJSONを取り扱うためのライブラリです。Play Frameworkを利用していないプロジェクトでもplay-jsonをライブラリ依存性に追加すれば利用することができます。
このライブラリは、非常に強力なJSONコンビネータを備えています。Play Frameworkを使っている方にとってはおなじみのReadsWritesFormatですね。
今回は、これらのコンビネータにまつわる頻出パターンを5つほどご紹介します。

目次

前提知識

本家のドキュメントがとても詳細なため、それを一通り読めば基本的な使い方がわかります。ですが一応、以下におさらいを載せておきます。

これより後に示すコードでは、以下を含む自明の前提を省略しています。
import play.api.libs.json._
import play.api.libs.functional.syntax._
また、以下のいずれのコードも__ \ "path_name"の形式でDSLを記述していますが、この"path_name"についてはシンボルリテラル(アトム)を利用可能です。本来は極力シンボルリテラルを用いるべきですが、シンタックスハイライトの問題があるため本稿では全て文字列としています。

play-jsonのReadsは以下のように定義し、利用します。

case class Person(fullName: String, age: Int)

implicit val personReads = (
  (__ \ "full_name").read[String] ~
    (__ \ "age").read[Int]
  )(Person)

val samplePersonJson = Json.obj(
  "full_name" -> "Nico Yazawa",
  "age" -> 17
)

val person = Json.fromJson[Person](samplePersonJson).get
print(person.age) // -> 17

play-jsonのWritesは以下のように定義し、利用します。

case class Person(fullName: String, age: Int)

implicit val personWrites = (
  (__ \ "full_name").write[String] ~
    (__ \ "age").write[Int]
  )(unlift(Person.unapply))
    
val samplePerson = Person("Nozomi Tojo", 17)

val json = Json.toJson(samplePerson)
print(json.toString) // -> {"full_name":"Nozomi Tojo","age":17}

play-jsonのFormatは上記の2つを一括で定義することができます。

implicit val personFormat = (
  (__ \ "full_name").format[String] ~
    (__ \ "age").format[Int]
  )(Person, unlift(Person.unapply))

val samplePersonJson = Json.obj(
  "full_name" -> "Nico Yazawa",
  "age" -> 17
)

val samplePerson = Person("Nozomi Tojo", 17)

val person = Json.fromJson[Person](samplePersonJson).get
println(person.name) // -> Nico Yazawa
val json = Json.toJson(samplePerson)
println(json.toString) // -> {"full_name":"Nozomi Tojo","age":17}

一定の条件下では、これらのReads/Writes/Formatを略記して1行で定義できますが、今回は紹介しません。興味のある方は先ほどリンクを紹介したドキュメントを参照してください。

それでは、5つのパターンを紹介します!

1. inmapパターン

Formatにまつわるパターンです。ある型の値を別の型に置き換えます。

case class Person(fullName: String, age: String)

val samplePersonJson = Json.obj(
  "full_name" -> "Nico Yazawa",
  "age" -> 17
)

implicit val personFormat = (
  (__ \ "full_name").format[String] ~
    (__ \ "age").format[Int].inmap[String](_.toString, _.toInt)
  )(Person, unlift(Person.unapply))

先ほどはage: Intだったデータ型がage: Stringに変わりました。しかしJSONでは相変わらず数値型のままです。 その結果、読み取り指定する型(Int)と期待する型(String)が違うものになってしいました。
このような場合にはinmapを使います。

見るとわかる通り、第一引数には読み取り指定した型 → 期待する型第二引数には期待する型 → 読み取り指定した型の関数をそれぞれ渡してやります。

このパターンをFormatではなくReads/Writesで個別に利用したい場合は、それぞれmapcontramapを利用します。

implicit val personReads = (
  (__ \ "full_name").read[String] ~
    (__ \ "age").read[Int].map[String](_.toString)
  )(Person)

implicit val personWrites = (
  (__ \ "full_name").write[String] ~
    (__ \ "age").write[Int].contramap[String](_.toInt)
  )(unlift(Person.unapply))

もしあなたが上例ではじめてcontramapを目にしたのであれば、何かこう、いたたまれない感情を覚えたかもしれません。contramapはとても抽象度の高い概念の産物です。どうか「contramapが気になって先に進めないぞ!うおおお!」状態にならないで下さい。
「考えるな、感じろ!」contramapが出てきた行のコードを逆から読んでみて下さい。それでなんとなく飲み込めたら十分です。あるいは先ほど例に上げたinmapと比べてみてください。そこから第一引数をなくしただけです。

2. tupledパターン

Readsにまつわるパターンです。外部サービスのAPI戻り値JSONなどから、オブジェクト構築に必要な複数の情報を取り出して、一つのオブジェクトに落としこむ場合などに利用できます。

case class Person(fullName: String, age: Int)

val samplePersonJson = Json.obj(
  "full_name" -> "Kotori Otonashi",
  "open_age" -> 24,
  "saba" -> 5
)

implicit val personReads = (
  (__ \ "full_name").read[String] ~
    ((__ \ "open_age").read[Int] ~ (__ \ "saba").read[Int])
      .tupled.map { case (openAge, saba) => openAge + saba }
  )(Person)

val person = Json.fromJson[Person](samplePersonJson).get
println(person.age) // -> 29

Personオブジェクトの生成にはage: Int(実年齢)が必要ですが、JSONは年齢パラメータが"open_age"(公称年齢)と"saba"(鯖)に分かれているため、この2つのパラメータの和をとる必要があります。
このようなケースは、上例のようにtupledで必要な情報をタプルにまとめてから目的の型にmapすることで対応できます。

(2016/01/18 20:40 追記) Disqusコメントにて、上例の

.tupled.map { case (openAge, saba) => openAge + saba }

.apply { (openAge, saba) => openAge + saba }

の一文で置き換え可能というご指摘を頂きました!applyであれば、タプルのパターンマッチを行わずに書くことができます。ご指摘ありがとうございました!

3. デフォルト値パターン

Readsにまつわるパターンです。JSONから値を取得できなかった場合のデフォルト値を指定します。
このパターンは、KVS(Redisなど)をメインDBに利用してJSONを保存しているアプリでデータ構造の変更が発生した場合などに活躍します。

case class Person(fullName: String, age: Int, gender: String)

val samplePersonJson = Json.obj(
  "full_name" -> "Rin Hoshizora",
  "age" -> 15
)

implicit val personReads = (
  (__ \ "full_name").read[String] ~
    (__ \ "age").read[Int] ~
    ((__ \ "gender").read[String] | Reads.pure("Female"))
  )(Person)

val person = Json.fromJson[Person](samplePersonJson).get
println(person.gender) // -> Female

JSONには今までどおりfullNameとageのみが含まれています。Personの定義を見てみると、gender: String(性別)が新たに追加されています。
11行目の| Reads.pure("Female")では、JSONからの"gender"パラメータの取得に失敗した場合に"Female"をデフォルト値として利用する設定をしています(性別不明の場合は女性、というのはかなり乱暴ですが……)。

Reads.pureを見てティンとくる方も多いかと思います。Readsはアプリカティブな関手です。が、play-jsonは深追いするとConvariantFunctorやInvariantFunctorが出てくるので「pureがあるんだな」くらいの認識にとどめておくことを強くおすすめします。

4. 列挙型パターン

Formatにまつわるパターンです。一般的なシールドクラスによる列挙型の変換を"簡素に"行います。
まず、今回利用する列挙型を定義してみます。

sealed abstract class SampleEnum(val text: String)

case object SampleA extends SampleEnum("A")
case object SampleB extends SampleEnum("B")

object SampleEnum extends (String => SampleEnum) {
  override def apply(s: String): SampleEnum = s match {
    case SampleA.text => SampleA
    case SampleB.text => SampleB
  }
  
  def unapply(se: SampleEnum): Option[String] = Some(se.text)
}

4行目までの列挙型の定義は一般的なものです。自身を一意に示す値としてtextを保持しています。
6行目以降では、この列挙型SampleEnumのコンパニオンオブジェクトを定義し、applyとunapplyを実装しています。このapplyとunapplyを用いると自身のtext ↔ 自身の相互変換ができるようになっています。

Formatの定義は以下のようになります。

implicit object SampleEnumFormat extends Format[SampleEnum] {
  override def reads(j: JsValue): JsResult[SampleEnum] =
    j.validate[String].map(SampleEnum)
  
  override def writes(se: SampleEnum): JsValue =
    JsString(unlift(SampleEnum.unapply)(se))
}

case class SomeClass(someValue: String, sampleEnum: SampleEnum)

implicit val someJsonFormat = (
  (__ \ "some_value").format[String] ~
    (__ \ "sample_enum").format[SampleEnum]
  )(SomeClass, unlift(SomeClass.unapply))
  
val someJson = Json.toJson(SomeClass("aaa", SampleB))
println(someJson) // -> {"some_value":"aaa","sample_enum":"B"}

9行目〜13行目で今回の列挙型sampleEnum: SampleEnumを値に持つSomeClassとそのFormatを定義しました。これらなんら特別なものではありません。その前段で定義したSampleEnumのFormatがこのパターンのキモになります。
先ほどSampleEnumのコンパニオンオブジェクトに定義したapplyとunapplyを利用して、JsStringと自身とを相互変換させています。このようにすることで例にある"sample_enum": "B"のようなJSONパラメータとsampleEnumフィールドが相互変化が可能になります。

ところで、今回「Format[SampleEnum]を継承したオブジェクトを作る」というややこしい定義になっているのは、JsPath.formatを使う場合空のノードリストを持ったJsPathに対応する値はJsObjectである必要があるためです(そしてここで生成したJsObjectを次々マージしていく戦略です)。本当は次のような書き方ができると幸せです。

implicit val sampleEnumFormat = 
  __.format[String].inmap(SampleEnum, unlift(SampleEnum.unapply)) //Json.toJsonできない

ひょっとしたら、こんな感じでシンプルな書き方が用意されているのかもしれませんが、発見できませんでした。ご存知のかた、情報お待ちしております!

(2016/01/18 20:40 追記) Disqusコメントにて、以下の書き方を教えていただきました!

implicit val sampleEnumFormat = 
  implicitly[Format[String]].inmap(SampleEnum, unlift(SampleEnum.unapply))

言うまでもなくobject SampleEnumForomat extends Format[SampleEnum]よりシンプルで、幸せな書き方ですね。情報のご提供ありがとうございました!

最初に「このパターンは"簡素に"行うもの」と書きました。このパターンは設計上の問題を無視して、簡素さを追求しています。
具体的には、SampleEnum ↔ Stringの変換ロジックがSampleEnumクラス/オブジェクトに含まれてしまっている点が設計上の問題になります。SampleEnumがJSONレンダリング専用の列挙型でも無い限りは、レンダリングのロジックはSampleEnumが存在する層(大半の場合domain層)とは別の場所に配置されなければなりません。これを更に具体的に言うと、SampleEnum(text: String)のように値を持たせることはせず、sealed trait SampleEnumとしておいて、値のマッピングは全てFormat[SampleEnum]が定義される層で行うのが最もクリーンな状態です。このパターンを使う際には、設計のクリーンさとコード簡素さのトレードオフをよく考慮する必要があります。
設計のクリーンさを追求する場合、次に紹介する「直和型パターン」を列挙型にも用いることができます。

5. 直和型パターン

ラストはFormatにまつわるパターンです。一般的な直和型の変換を行います。
今回例に上げるのは、IPhone | Android(ベンダ名)の2要素からなるSmartPhone型です。

sealed trait SmartPhone

case object IPhone extends SmartPhone
final case class Android(vendor: String) extends SmartPhone

この場合、Formatの定義は次のようになります。

val androidFormat =
  (__ \ "vendor").format[String].inmap(Android, unlift(Android.unapply))
  
implicit object SmartPhoneFormat extends Format[SmartPhone] {
  val TypeKey = "type"
  val IPhoneValue = "iphone"
  val AndroidValue = "android"
  
  override def reads(j: JsValue): JsResult[SmartPhone] = (j \ TypeKey).asOpt[String] match {
    case Some(IPhoneValue) => JsSuccess(IPhone)
    case Some(AndroidValue) => androidFormat.reads(j)
    case _ => JsError("Unexpected")
  }
  
  override def writes(sp: SmartPhone): JsValue = sp match {
    case IPhone => Json.obj(TypeKey -> IPhoneValue)
    case a: Android => 
      androidFormat.writes(a) ++ Json.obj(TypeKey -> AndroidValue)
  }
}

println(Json.toJson(Android("Sony"))) // -> {"vendor":"Sony","type":"android"}
println(Json.toJson(IPhone)) // -> {"type":"iphone"}
println(Json.fromJson[SmartPhone](Json.toJson(Android("ASUS"))).get) // -> Android(ASUS)
println(Json.fromJson[SmartPhone](Json.toJson(IPhone)).get) // -> IPhone

あるJSONがSmartPhone型を示すとして、それは「SmartPhone型のどの要素にあたるのか」を最低限示す必要があります。そのため"type": "iphone""type": "android"といったパラメータををJSONに含めます。このパラメータのことを便宜的に要素タイプパラメータと呼びます。

まずはreadsから確認しましょう。(j \ TypeKey)は要素タイプパラメータのパスを指しています。

  • 要素タイプパラメータの値が"iphone" → JsSuccess(IPhone)を返す
  • 要素タイプパラメータの値が"android" → androidFormat: OFormat[Android]に処理を委譲する
  • それ以外 → JsErrorを返す

次にwritesを確認してみます。writesにはSmartPhone型の値(IPhone, Androidいずれか)が渡されます。

  • IPhoneの場合 → IPhoneを示す要素タイプパラメータのみが含まれるJsObjectを返す
  • Androidの場合 → 「androidFormatに処理を委譲した結果」と「Androidを示す要素タイプパラメータ」マージしたJsObjectを返す

このmatch式はSmartPhone型に対してすでに網羅的なため、case _ =>のマッチは必要ありません。

このパターンのポイントとしては、次の点が挙げられます。

  • 構造を持った要素(e.g. Android)のreads/writes処理は、事前に定義した専用のFormatに委譲する。
  • 構造を持たない要素 (e.g. IPhone) のreads処理は、対応するオブジェクトをJsSuccessにくるんで返す。
  • 要素タイプパラメータをJSONに付加し、直和型の要素のうちどれを指しているJSONかを明確にする。

要素タイプパラメータのフィールドを今回はシンプルに"type"としていますが、この場合androidFormatでは重複して"type"を使うことができません。委譲先のFormatと衝突しないフィールドを適宜設定して下さい。

小休止: ReadsとWritesはどこに置く?

比較的小規模なプロジェクトでは、コンパニオンオブジェクトにReads/Writesを置く手が使えます。

package domains.person

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

object Person extends ((String, Int) => Person) {
  implicit val PersonReads = ...
  implicit val PersonWrites = ...
}

このようにしておくと、import domains.person.Personさえしてあれば、他に特別なことをせずともJson.toJson(Person("John", 30))といった処理を書くことができます。これは、あるクラスのコンパニオンオブジェクトは、Scalaコンパイラがimplicitなパラメータを探索する場所に含まれているからです。

さて、コンパニオンオブジェクトは、Scalaコンパイラの強力なバックアップがあるためとても魅力的な置き場所です。しかしながら、このままではオニオンアーキテクチャやクリーンアーキテクチャといった中〜大規模向けのアプリケーションアーキテクチャで破綻する可能性が高いです
理由は、ドメイン層のオブジェクトにJSONレンダリングの知識が含まれてしまっているからです。JSONレンダリングの知識はプレゼンテーション層やインフラ層など、実際にJSONを扱う層の知識です。
よって、中〜大規模のプロジェクト、あるいは小規模であってもドメイン駆動設計(DDD)を実践しているプロジェクトではこの書き方をすることはできません。

例えばオニオンアーキテクチャを実践しているなら、インフラ層のJSONコンバータはインフラ層で定義しなければなりません。

package infras.person

import domains.person.Person

trait PersonConverter {
  implicit val personReads = PersonConverter.PersonReads
  implicit val personWrites = PersonConverter.PersonWrites
}

object PersonConverter {
  private val PersonReads = ...
  private val PersonWrites = ...
}

一例ですが、このように定義しておいて実際にJson.toJsonJson.fromJsonを使うクラスにPersonConverterをミックスインする手段があります。
これはReads/Writesに限らず一般的に言えることですが、implicitな値への参照をハードコードすると単体テスト時に差し替えが効かなくなるのでアンチパターンです。そのため、上例のようにReads/Writesの実態はprivateにしておくとよいと思います。
Reads/Writes以外でよく目にするimplicitな値としてはExecutionContextがあります。例えば、PlayのApplicationに依存して生成されるExecutionContextをimport文でハードコードしてしまうと、そのクラスの振る舞いはPlayのApplicationなしにテストすることができなくなってしまいます。

まとめ

今回は play-json を利用していて頻出するReads/Writes/Formatのパターンを紹介させていただきました。
play-json のJSONコンビネータはとても便利なのでどんどん使っていきましょう!ではまた!

参考