scala で Json を取り扱うときの筆頭ライブラリ spray-json
の使い方メモ。
spray/spray-json
tl;dr
- Json キーとクラスフィールドをそのままに変換する場合には
jsonFormatN
- Json キーとクラスフィールドを名前を変えて変換する場合には
josnFormat
- Json キーとクラスフィールドの変換組み合わせを独自で定義したい場合には
RootJsonFormat[T]
-
RootJsonFormat[T]
を継承した object を定義して、read
,write
メソッドをオーバーライドする
-
全体像
公式 spray-json
リポジトリにとても分かりやすい図がある。
画像引用: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)
jsonFormat2
の 2
というは、Person クラスのフィールドの数に対応している。
Format は toJson
や convertTo
メソッドの暗黙のパラメーターとして渡されるので、同一のスコープに定義する必要がある。
※ IntelliJIDEA を使っている場合、toJson
やconvertTo
にカーソルをあわせて 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 class
と metadata
に相当する 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.code
が List(99,100)
となっているが、実際に person.code
をコード内使用すると Seq[Int]
型である。
ケーススタディ2
ほとんどないパターンであると思われるが、私が某SNSGraphApiを叩いたときに遭遇したパターン。
解決方法として記載する。
- Json キー値に配列で jsonObject をもつ
- 配列内の jsonObject は
values
を持つが、その型は同一ではない
- 配列内の jsonObject は
val targetJson =
"""
|{
| "name": "Ed",
| "age": 24,
| "metadata": [
| {
| "title": "rank",
| "values": 10
| },
| {
| "title": "job",
| "values": "saber"
| }
| ]
|}
|""".stripMargin
metadata
には jsonObject の配列があり、title
と values
を持っているが
values
は Int
と String
どちらかである。
このような場合、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