5
6

More than 3 years have passed since last update.

spray-jsonのススメ

Posted at

scala で Json を取り扱うときの筆頭ライブラリ spray-json の使い方メモ。
spray/spray-json

tl;dr

  • Json キーとクラスフィールドをそのままに変換する場合には jsonFormatN
  • Json キーとクラスフィールドを名前を変えて変換する場合には josnFormat
  • Json キーとクラスフィールドの変換組み合わせを独自で定義したい場合には RootJsonFormat[T]
    • RootJsonFormat[T]を継承した object を定義して、read, writeメソッドをオーバーライドする

全体像

公式 spray-json リポジトリにとても分かりやすい図がある。

Conversions.png
画像引用:spray/spray-jsonのREADMEより

この図で表していることは、

  • String.parseJsonを呼ぶと、JsValue が得られる
  • JsValue.convertTo[T]を呼ぶと、ScalaType T型(独自定義のClass等)が得られる
  • ScalaType.toJsonを呼ぶと、JsValue が得られる
  • JsValue.compactPrintを呼ぶと、String が得られる

である。
各型に変換用のメソッドがあるので、該当するメソッドを呼べばいいだけである。

ただし、上記の図のクラス Person ように
JsValue <-> 独自定義クラス を相互変換する場合、
どのようにして変換されるのか、というのを決定するのに Format が必要である。
それを下記で解説する。

使い方

Personクラス <-> JsValue
の変換をコードで見てみる。

全体像の項で触れたように、独自クラスと JsValue 間の変換には Format が必要で、
jsonFormatN を使って Format を定義している。

  case class Person(name: String, age: Int)
  implicit val personJsFmt = jsonFormat2(Person.apply)

  val person = Person("Ed", 24)

  // case class -> JsValue
  val personJson = person.toJson // -> {"age":24,"name":"Ed"}

  // JsValue -> case class
  val convertToPerson = personJson.convertTo[Person] // -> Person(Ed,24)

jsonFormat22 というは、Person クラスのフィールドの数に対応している。
Format は toJsonconvertTo メソッドの暗黙のパラメーターとして渡されるので、同一のスコープに定義する必要がある。

※ IntelliJIDEA を使っている場合、toJsonconvertToにカーソルをあわせて cmd + shift + P を入力すると、
暗黙的に渡している引数の定義(ここでは personJsFmt )にジャンプすることができる。

Json キー名とクラスフィールドの変数名を変える

Jsonキーはスネークケースだけど、クラスに変換したらキャメルケースで扱いたい
というのは往々にしてある。その場合、以下のような Format を定義する。

  case class Person(name: String, age: Int)
  implicit val personJsFmt = jsonFormat(Person.apply, "name_string", "age_int")

今度は jsonFormat メソッドを使い、第2引数以降に Person クラスのフィールドに対応する Json キー名を書いていく。

  val person = Person("Ed", 24)

  val personJson = person.toJson // -> {"age_int":24,"name_string":"Ed"}

  val convertToPerson = personJson.convertTo[Person] // -> Person(Ed,24)

jsonFormatを使う場合、第1引数で渡したクラスのフィールドの数の分第2引数以降Stringを指定する必要があり、省略はできない。

Json キーにはないフィールドを変換先クラスに追加する

基本的に Json キーとクラスフィールドは1対1でそのまま変換したほうがわかりやすい。
だが、時には Json の複数キーの値を加工してクラスの1フィールドに当てる、みたいなこともしたい。
例を示す。

  val targetJson =
    """
      |{
      |  photo_views: 10,
      |  video_plays: 20,
      |  others: 30
      |}
      |""".stripMargin

上記のような Json を想定し、以下の Clicks というクラスに変換したい。

  case class Clicks(photoViews: Int, videoPlays: Int, others: Int, totalCount: Int)

totalCaount という Json にはないフィールドを持っている。
このようにクラスの生成を独自にカスタマイズしたい場合は RootJsonFormat[T] を使って Format を定義する。

  trait JsonProtocol extends DefaultJsonProtocol {
    implicit object clicksJsFmt extends RootJsonFormat[Clicks] {
      override def write(obj: Clicks): JsValue = {}
      override def read(json: JsValue): Clicks = {}
    }
  }

RootJsonFormat[T]をextendsしたobjectを作り、write, readメソッドを実装する。

write -> ClicksからJsValueに変換するFormat
read -> JsValueからClicksに変換するFormat

readでは、JsValueから特定のキーの値を型付きで取り出す、という処理が必要になる。
そこで下記のような共通部品を定義しておく。

  object JsonUtil {
    def int(v: JsValue, key: String): Option[Int] = v match {
      case JsObject(m) => m.get(key).flatMap(asInt)
      case _ => None
    }
    def asInt(v: JsValue): Option[Int] = v match {
      case JsNumber(n) if Int.MinValue <= n && n <= Int.MaxValue => Some(n.toInt)
      case _ => None
    }

    def str(v: JsValue, key: String): Option[String] = v match {
      case JsObject(m) => m.get(key).flatMap(asStr)
    }

    def asStr(v: JsValue): Option[String] = v match {
      case JsString(s) => Some(s)
      case _ => None
    }
  }

共通部品を使って RootJsonFormat[Clicks] を実装する。

  trait ClicksJsonProtocol extends DefaultJsonProtocol {
    implicit object clicksJsFmt extends RootJsonFormat[Clicks] {
      override def write(obj: Clicks): JsValue = Map(
        "photo_views" -> obj.photoViews.toJson,
        "video_plays" -> obj.videoPlays.toJson,
        "others" -> obj.others.toJson
      ).toJson

      override def read(json: JsValue): Clicks = {
        val photoViews = JsonUtil.int(json, "photo_views").getOrElse(0)
        val videoPlays = JsonUtil.int(json, "video_plays").getOrElse(0)
        val others = JsonUtil.int(json, "others").getOrElse(0)
        Clicks(
          photoViews = photoViews,
          videoPlays = videoPlays,
          others = others,
          totalCount = photoViews + videoPlays + others
        )
      }
    }
  }

上記のFormatを定義して、ClicksJsonProtocolをextendsして同一スコープに置くと目的の変換が達成できる。

  val clicks = Clicks(10, 20, 30, 60)

  val clicksJsValue = clicks.toJson // -> {"photo_views":10,"video_plays":20,"others":30}

  val convertToClicks = clicksJsValue.convertTo[Clicks]  // -> Clicks(10,20,30,60)

また、String(Json doc)Json.parseJsonからでも変換できることを確認してみる。

  val clicksJsValue = targetJson.parseJson // -> {"others":30,"photo_views":10,"video_plays":20}

  val clicks = clicksJsValue.convertTo[Clicks] // -> Clicks(10,20,30,60)

  val clicksToJson = clicks.toJson  // -> {"photo_views":10,"video_plays":20,"others":30}

複雑なJsonに対応する

ケーススタディ1

  • Json キー値に JsonObject や配列がある
  val targetJson =
    """
      |{
      |  "name": "Ed",
      |  "age": 24,
      |  "code": [99, 100],
      |  "metadata": {
      |    "email": "Ed@example.com",
      |    "tel": "08012345678"
      |  }
      |}
      |""".stripMargin

上記のようなJsonを想定する。

この場合、targetJsonに相当する case classmetadata に相当する case class を作り、
入れ子にすることで変換ができる。

  case class Person(
    name: String,
    age: Int,
    code: Seq[Int],
    metadata: MetaData
  )

  case class MetaData(
    email: String,
    tel: String
  )

そしてそれぞれの case class のFormatを定義する。

  implicit val metadataJsFmt = jsonFormat2(MetaData.apply)
  implicit val personJsFmt = jsonFormat4(Person.apply)

このとき、最下層 object である MetaData に対応するFormatから先に定義しなくてはならない。
personJsFmt には Person のパラメーターである MetaData のFormatも必要になるからである。

  val personJsValue = targetJson.parseJson
  // -> {"age":24,"code":[99,100],"metadata":{"email":"Ed@example.com","tel":"08012345678"},"name":"Ed"}

  val person = personJsValue.convertTo[Person]
  // -> Person(Ed,24,List(99, 100),MetaData(Ed@example.com,08012345678))

  val personToJson = person.toJson
  // -> {"age":24,"code":[99,100],"metadata":{"email":"Ed@example.com","tel":"08012345678"},"name":"Ed"}

各変換ができていることを確認できる。

person.codeList(99,100) となっているが、実際に person.code をコード内使用すると Seq[Int] 型である。

ケーススタディ2

ほとんどないパターンであると思われるが、私が某SNSGraphApiを叩いたときに遭遇したパターン。
解決方法として記載する。

  • Json キー値に配列で jsonObject をもつ
    • 配列内の jsonObject はvaluesを持つが、その型は同一ではない 
  val targetJson =
    """
      |{
      |  "name": "Ed",
      |  "age": 24,
      |  "metadata": [
      |    {
      |      "title": "rank",
      |      "values": 10
      |    },
      |    {
      |      "title": "job",
      |      "values": "saber"
      |    }
      |  ]
      |}
      |""".stripMargin

metadata には jsonObject の配列があり、titlevalues を持っているが
valuesIntString どちらかである。

このような場合、values は抽象で定義してしまおう。

  case class Person(
    name: String,
    age: Int,
    metadata: Seq[MetaData]
  )

  case class MetaData(
    title: String,
    values: MetaDataValues
  )

  sealed trait MetaDataValues
  case class MetaDataString(values: String) extends MetaDataValues
  case class MetaDataInt(values: Int) extends MetaDataValues

そして、各 case class に Format を定義する。

  trait PersonJsFmt extends DefaultJsonProtocol {
    implicit val metadataStringJsFmt = jsonFormat1(MetaDataString.apply)
    implicit val metadataIntJsFmt = jsonFormat1(MetaDataInt.apply)

    implicit object metadataJsFmt extends RootJsonFormat[MetaData] {
      override def write(obj: MetaData): JsValue = Map(
        "title" -> obj.title.toJson,
        "values" -> (obj.values match {
          case MetaDataString(v) => v.toJson
          case MetaDataInt(v) => v.toJson
        })
      ).toJson

      override def read(json: JsValue): MetaData = {
        val title = JsonUtil.str(json, "title").getOrElse("")
        MetaData(
          title = title,
          values = title match {
            case "rank" => MetaDataInt(JsonUtil.int(json, "values").getOrElse(0))
            case "job" => MetaDataString(JsonUtil.str(json, "values").getOrElse(""))
          }
        )
      }
    }

    implicit val personJsFmt = jsonFormat3(Person.apply)
  }

ケーススタディ1と同様、変換クラスの最下層から順に定義していく。

MetaData のFormatは少々複雑だが、やっていることは
values = String なのか values = Int なのかで処理を振り分けている。
このような振り分けをするために RootJsonFormat[T] を使っている。

JsValue からMedaData に変換する readメソッドの場合、
Values が String なのか Int なのかをは title で判断している。

  val personJsValue = targetJson.parseJson
  // -> {"age":24,"metadata":[{"title":"rank","values":10},{"title":"job","values":"saber"}],"name":"Ed"}

  val person = personJsValue.convertTo[Person]
  // -> Person(Ed,24,List(MetaData(rank,MetaDataInt(10)), MetaData(job,MetaDataString(saber))))

  val personToJson = person.toJson
  // -> {"age":24,"metadata":[{"title":"rank","values":10},{"title":"job","values":"saber"}],"name":"Ed"}

無事、相互変換ができている。

参考

https://github.com/spray/spray-json
https://qiita.com/petitviolet/items/79f2bd3b4f1d54d38db1

5
6
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
5
6