Magnolia Tech

いつもコードのことばかり考えている人のために。

Spay-JsonがJSON ASTからScalaオブジェクトを生成する仕組み

調べたことを雑に残すシリーズです。

github.com

Spray-Jsonの基本的な使い方は以下の通りです。

case class Color(name: String, red: Int, green: Int, blue: Int)

object MyJsonProtocol extends DefaultJsonProtocol {
  implicit val colorFormat = jsonFormat4(Color)
}

import MyJsonProtocol._
import spray.json._

val json = Color("CadetBlue", 95, 158, 160).toJson
val color = json.convertTo[Color]
  • case classであれば個別に自分で変換のためのコードを書く必要は無い
  • ただし、case classのフィールドの個数を、jsonFormatNというメソッドで指定する

フィールドの個数だけは指定させているのはなぜでしょう?

jsonFormatNメソッド

jsonFormatNメソッドは以下のソースコードで定義されています。

https://github.com/spray/spray-json/blob/release/1.3.x/src/main/boilerplate/spray/json/ProductFormatsInstances.scala.template

ただし、これはテンプレートであり、実際のコードはsbt-boilerplateというpluginで生成されるようになっています。Scalaでおなじみの引数が1〜22個までの同じ挙動のメソッドを自動で生成するための仕組みになっています。

github.com

一度コンパイルすると、以下の場所にソースが生成されます。

spray-json/target/scala-2.12/src_managed/main/spray/json/ProductFormatsInstances.scala

例えば引数を二つ取るjsonFormat2は以下のようなコードに展開されます。

// Case classes with 2 parameters

  def jsonFormat2[P1 :JF, P2 :JF, T <: Product :ClassTag](construct: (P1, P2) => T): RootJsonFormat[T] = {
    val Array(p1, p2) = extractFieldNames(classTag[T])
    jsonFormat(construct, p1, p2)
  }
  def jsonFormat[P1 :JF, P2 :JF, T <: Product](construct: (P1, P2) => T, fieldName1: String, fieldName2: String): RootJsonFormat[T] = new RootJsonFormat[T]{
    def write(p: T) = {
      val fields = new collection.mutable.ListBuffer[(String, JsValue)]
      fields.sizeHint(2 * 3)
      fields ++= productElement2Field[P1](fieldName1, p, 0)
      fields ++= productElement2Field[P2](fieldName2, p, 1)
      JsObject(fields.toSeq: _*)
    }
    def read(value: JsValue) = {
      val p1V = fromField[P1](value, fieldName1)
      val p2V = fromField[P2](value, fieldName2)
      construct(p1V, p2V)
    }
  }

先ほどの implicit val colorFormat = jsonFormat4(Color)は、オブジェクトを渡していますが、以下の仕組みにより、実際にはColor.applyが渡されているのと同等です。

xuwei-k.hatenablog.com

つまり、そのcase classのファクトリメソッドが渡されているわけです。

writeJSON astを生成するメソッドなので、ここで必要になってくるのはreadの方です。

JSON astからScalaオブジェクトを生成する

先ほどのreadメソッドの中を一つ一つ見ていきましょう。

二つのフィールドをJSON astが格納されているvalueからfromFieldメソッドを使って取り出しているようですね。続いてfromFieldメソッドの中身を見ていきましょう。

fromFieldメソッドは、src/main/scala/spray/json/ProductFormats.scalaで以下のように定義されています。

protected def fromField[T](value: JsValue, fieldName: String)
                                     (implicit reader: JsonReader[T]) = value match {
    case x: JsObject if
      (reader.isInstanceOf[OptionFormat[_]] &
        !x.fields.contains(fieldName)) =>
      None.asInstanceOf[T]
    case x: JsObject =>
      try reader.read(x.fields(fieldName))
      catch {
        case e: NoSuchElementException =>
          deserializationError("Object is missing required member '" + fieldName + "'", e, fieldName :: Nil)
        case DeserializationException(msg, cause, fieldNames) =>
          deserializationError(msg, cause, fieldName :: fieldNames)
      }
    case _ => deserializationError("Object expected in field '" + fieldName + "'", fieldNames = fieldName :: Nil)
  }

ちょっと複雑で分かりづらいですが…

  • 一つ目は、そのフィールドがオプションでかつ値を持たなければNoneを返しています
  • 二つ目は、JsObjectから指定されたフィールド名で値を取り出しています。
  • そのどちらにも当てはまらない場合は、例外発生です。

元々、case classの引数を取得しているはずなので、JsObjectとして値が取れないと例外なのは妥当ですね。

readは取り出す値の型ごとに用意されていて、例えばStringとして値を取得したい場合は以下のメソッドが呼ばれます。

 implicit object StringJsonFormat extends JsonFormat[String] {
    def write(x: String) = {
      require(x ne null)
      JsString(x)
    }
    def read(value: JsValue) = value match {
      case JsString(x) => x
      case x => deserializationError("Expected String as JsString, but got " + x)
    }
  }

JsStringであれば、値が取り出され、それ以外は例外になることが分かります。

基本的な型については、src/main/scala/spray/json/BasicFormats.scalaで定義されています。

こうやって値を一つ一つ生成していって、最終的にはScalaのcase classからのオブジェクトがapplyメソッド(相当)で生成されます。

ランタイムリフレクションを使って、メソッドの引数と型を一つ一つ特定する方法もありますが、こちらの方がシンプルですね。

ということで、Spray-JSONが引数の個数を指定させるのは、case classのオブジェクトが型情報を引き回してくれることを利用して型を特定する方法を使うため、ということが分かりました。