Scala
JSON
json4s
MicroAdDay 20

json4sのCustomSerializerで複雑なJSONをデシリアライズする

マイクロアドアドベントカレンダー20日目の記事です。

マイクロアドで内定者アルバイトエンジニアをしています。
何らかのjson文字列を受け取りcase classにマッピングする際に、値の文字列をそのまま文字列としてマッピングして欲しくなかったり、何らかの処理を加えてからマッピングしたいということもあると思います。そんな時にjson4sのCustomSerializerを使うと、簡単に実装することが可能です。

前提

Scalaのバージョンと、json4sのバージョンは以下のようになります。

scalaVersion := "2.11.7"
...
libraryDependencies ++= Seq("org.json4s" %% "json4s-native" % "3.2.11")

CustomSerializerの実装

例として、以下のようなJSONを想定します。scala側では"belongings"の値はSetで持っておきたいという気持ちがあります。belongingsの値としてそもそもカンマ区切りで送ってくるなみたいなところはあります。

{
  "name": "taro",
  "email": "taro@email.com",
  "belongings": "ご飯,味噌汁,パン"
}

このようなJSONが送られてくると、belongingsは通常のjson4sのパースでは単なるStringとなるので、Setとして扱いたい時などは一度case classにStringとしてマッピングした後に別のclassに変換したりしなければなりません。

そこで以下のように、CustomSerializer[A: Manifest]を継承したUserSerializerを作成します。

User.scala
case class User(name: String, email: String, belongings: Set[String])
UserSerializer.scala
class UserSerializer extends CustomSerializer[User](implicit formats => ({
  case jsonObj: JObject =>
    val name        = (jsonObj \ "name").extract[String]
    val email       = (jsonObj \ "email").extract[String]
    val belongings  = (jsonObj \ "belongings").extract[String].split(",")

    User(name, email, belongings)
}, {
  case user: User =>
    ...
}))

こうして作成したクラスのコンストラクタにser: Formats => (PartialFunction[JValue, A], PartialFunction[Any, JValue])となる関数を渡します。シリアライズ、デシリアライズのためのフォーマットを引数として受け取り、シリアライズ、デシリアライズの為の部分関数をタプルで返すような関数です。

今回はデシリアライズのみを目的にしているのででシリアライズの為のPartialFunction[JValue, A]のみ実装してます。最初にcase jsonObj: JObjectとすることで、パースされた全ての入力にマッチします。
公式ドキュメントには載ってますが実はこの部分を

case JObject(JField("start", JInt(s)) :: JField("end", JInt(e)) :: Nil) =>
  new Interval(s.longValue, e.longValue)

みたいにすると、入力のJSON構造にマッチしてデシリアライズを行うことが可能です。しかしマッチしない場合には例外が吐かれるので、不安定な構造のJSONを取り扱う時には注意が必要です。

また、コンストラクタ引数であるformatsimplicitで受け取ることで、特に意識せずに部分関数内でextract[A]extractOpt[A]を使って値の抽出が可能になります。

\演算子

\演算子を用いると、JObjectからjsonObj \ "key"のようにして"key"以下のJValueを部分抽出することが可能です。

scala> jsonObj \ "belongings"
res: org.json4s.JValue = JString(ご飯,味噌汁,パン)

extract, extractOpt

extractはJValueが存在しない時(JNothingの時)には例外を吐きますが、extractOptはJNothingの時であっても例外を吐きません。

scala> (jsonObj \ "belongings").extract[String]
res: String = ご飯,味噌汁,パン

scala> (jsonObj \ "nothing").extract[String]
org.json4s.package$MappingException: Did not find value which can be converted into java.lang.String
  at org.json4s.reflect.package$.fail(package.scala:95)
  at org.json4s.Extraction$$anonfun$org$json4s$Extraction$$convert$2.apply(Extraction.scala:704)
  at org.json4s.Extraction$$anonfun$org$json4s$Extraction$$convert$2.apply(Extraction.scala:704)
  at scala.Option.getOrElse(Option.scala:121)
  at org.json4s.Extraction$.org$json4s$Extraction$$convert(Extraction.scala:704)
  at org.json4s.Extraction$$anonfun$extract$6.apply(Extraction.scala:394)
  at org.json4s.Extraction$$anonfun$extract$6.apply(Extraction.scala:392)
  at org.json4s.Extraction$.customOrElse(Extraction.scala:606)
  at org.json4s.Extraction$.extract(Extraction.scala:392)
  at org.json4s.Extraction$.extract(Extraction.scala:39)
  at org.json4s.ExtractableJsonAstNode.extract(ExtractableJsonAstNode.scala:21)
  ... 43 elided

scala> (jsonObj \ "belongings").extractOpt[String]
res: Option[String] = Some(ご飯,味噌汁,パン)

scala> (jsonObj \ "nothing").extractOpt[String]
res: Option[String] = None

JSONの構造が不定の時に活用するといいでしょう。

使い方

作成したCustomSerializerを使う時には、デシリアライズ後のクラスがcase classならばobjectにパース用の関数を持たせ、そこでJsonMethods.parse()を実行すると良さそうです。
この時、implicit lazy val formats: Formats = DefaultFormats + new UserSerializer()のように、作成したCustomSerializerをformatsに加えるのを忘れないようにしましょう。

User.scala
object User {

  implicit lazy val formats: Formats = DefaultFormats + new UserSerializer()

  def fromJson(value: String): User =
    JsonMethods.parse(value).extract[User]

}

まとめ

処理の流れとしては、

  • \演算子とextract[A]extractOpt[A]で目的の値を抽出。
  • 抽出した値をcase classに対応する形に加工して、ファクトリによって作成したインスタンスを返却。

という風になると思います。
これで入力のJSONがクソフォーマットでもScalaで楽に扱えそうですね。

(元々CustomSerializerは、サポートしていない型をシリアライズする為の機能っぽいので、マッピング先のクラスはcase classでなきゃいけない訳じゃありません。case classは標準で自動抽出に対応しているので、自動抽出では取り扱えないようなJSONをマッピングする時に使いましょう)

参考文献