🐫

[akka-http] spray-jsonでフィールド22個の制約を超える

2021/07/11に公開

spray-json で JSON をシリアライズ/デシリアライズするには JsonFormat を定義する必要がありますが、Scala の case class の型から JsonFormat を生成する機能があるためそれほど手間はかかりません。

final case class Color(name: String, red: Int, green: Int, blue: Int)
// フィールド数 N のとき、jsonFormatN() を使って JsonFormat を定義できる
val colorJsonFormat: JsonFormat[Color] = jsonFormat4(Color)

しかし、jsonFormatNjsonFormat0jsonFormat22 しか用意されていません。

23 個以上のフィールドがある case class から JsonFormat を作るにはどうすれば良いのでしょうか。

// 40 個のフィールドを持つ case class
final case class LargeDocument(field1: String, field2: String, ..., field40: String)

解決策の 1 つに、case class のフィールドを複数の case class に分割した上で、JsonFormat を実装するという方法があります。

実装方法

まずは、たくさんのフィールドを持つ case class(LargeDocument)のフィールドを他の case class に、フィールドが 22 個以内になるように切り出します。元の case class は、切り出した case class を内包するようにしておきます。

final case class LargeDocument(part1: LargeDocumentPart1, part2: LargeDocumentPart2)
// フィールドを分割して他の case class に切り出す
final case class LargeDocumentPart1(field1: String, ..., field22: String)
final case class LargeDocumentPart2(field23: String, ..., field40: String)

次に、LargeDocument 用の JsonFormat を実装します。JsonFormat を自分で実装するときは Scala のオブジェクトを JSON(JsValue)に変換する write メソッドと、JSON から Scala のオブジェクトに変換する read メソッドを実装します。


spray-json では JSON の object の値を JsObjectnumber の値を JsNumber という型で表現します。また、これらの型を総称して JsValue という抽象的な型で表現できます。


それぞれ、次のような戦略で実装します。

  • def write (Scala → JSON)
    • フィールドを分割して切り出した case class をそれぞれ JsObject に変換
    • 変換された JsObject からフィールドだけ取り出し新たな JsObject へフラットに詰めなおす
  • def read(JSON → Scala)
    • JSON を部分ごとに case class へ変換し、それらをまとめる case class に詰める

具体的な実装は次のようになります。

object LargeDocumentProtocol extends DefaultJsonProtocol {

  // フィールドを切り出した case class の JsonFormat を定義
  // toJson/convertTo[T] を呼ぶのに必要
  implicit val part1Format: JsonFormat[LargeDocumentPart1] =
    jsonFormat22(LargeDocumentPart1)
  implicit val part2Format: JsonFormat[LargeDocumentPart2] =
    jsonFormat18(LargeDocumentPart2)

  implicit object LargeDocumentJsonFormat extends RootJsonFormat[LargeDocument] {
    // Scala → JSON
    override def write(obj: LargeDocument): JsValue = {
      // JsValue (JsObject) からフィールドだけ取り出す
      val part1Fields: Map[String, JsValue] = obj.part1.toJson.asJsObject.fields
      val part2Fields: Map[String, JsValue] = obj.part2.toJson.asJsObject.fields
      // フィールドをマージして新たな JsObject に詰め直す
      JsObject(part1Fields ++ part2Fields)
    }
    // JSON → Scala
    override def read(json: JsValue): LargeDocument = {
      // JSON から変換した LargeDocumentPart1 と LargeDocumentPart2 を LargeDocument に詰める
      // ※ JSON 内に変換先の case class にないフィールドがあっても spray-json では単に無視されるため、この実装ができる
      LargeDocument(json.convertTo[LargeDocumentPart1], json.convertTo[LargeDocumentPart2])
    }
  }
}

case class を分割すると、分割した case class 同士でフィールド名を重複して定義してしまうリスクがあります。write 内で重複をチェックしてユニットテストなどで間違いを検知できるようにしておくのが良さそうです。

override def write(obj: LargeDocument): JsValue = {
    val objFields1 = obj.doc1.toJson.asJsObject.fields
    val objFields2 = obj.doc2.toJson.asJsObject.fields
    // 重複するキーをチェック
    val duplicateKeys = objFields1.keySet.intersect(objFields2.keySet)
    if (duplicateKeys.nonEmpty) {
    serializationError(s"duplicate key found: ${duplicateKeys}")
    }
    JsObject(objFields1 ++ objFields2)
}

さいごに

spary-json の機能だけを使ってフィールド 22 個の制限を超えてみました。
この制約は関数の引数の数やタプルの要素数の上限が 22 であるところから来ていると予想しています。Scala 3 ではこの制限が撤廃されるので、spray-json のこの制限も無くなると良いですね。

今回動作確認に使ったコードを掲載しておきます。IDEA の Scratch File などで実行できます。

import spray.json._

case class LargeDocumentPart1(field1: String, field2: String, field3: String, field4: String, field5: String,
                              field6: String, field7: String, field8: String, field9: String, field10: String,
                              field11: String, field12: String, field13: String, field14: String, field15: String,
                              field16: String, field17: String, field18: String, field19: String, field20: String,
                              field21: String, field22: String)

case class LargeDocumentPart2(field23: String, field24: String, field25: String,
                              field26: String, field27: String, field28: String, field29: String, field30: String,
                              field31: String, field32: String, field33: String, field34: String, field35: String,
                              field36: String, field37: String, field38: String, field39: String, field40: String)

case class LargeDocument(doc1: LargeDocumentPart1, doc2: LargeDocumentPart2)

object LargeDocumentProtocol extends DefaultJsonProtocol {

  implicit val part1Format: JsonFormat[LargeDocumentPart1] = jsonFormat22(LargeDocumentPart1)
  implicit val part2Format: JsonFormat[LargeDocumentPart2]  = jsonFormat18(LargeDocumentPart2)

  implicit object LargeDocumentFormat extends RootJsonFormat[LargeDocument] {
    override def write(obj: LargeDocument): JsValue = {
      val objFields1 = obj.doc1.toJson.asJsObject.fields
      val objFields2 = obj.doc2.toJson.asJsObject.fields
      val duplicateKeys = objFields1.keySet.intersect(objFields2.keySet)
      if (duplicateKeys.nonEmpty) {
        serializationError(s"duplicate key found: ${duplicateKeys}")
      }
      JsObject(objFields1 ++ objFields2)
    }

    def read(json: JsValue): LargeDocument = {
      LargeDocument(json.convertTo[LargeDocumentPart1], json.convertTo[LargeDocumentPart2])
    }
  }
}

import LargeDocumentProtocol._

val obj = LargeDocument(
  LargeDocumentPart1(
    "field1", "field2", "field3", "field4", "field5",
    "field6", "field7", "field8", "field9", "field10",
    "field11", "field12", "field13", "field14", "field15",
    "field16", "field17", "field18", "field19", "field20",
    "field21", "field22"
  ),
  LargeDocumentPart2(
    "field23", "field24", "field25",
    "field26", "field28", "field27", "field29", "field30",
    "field31", "field32", "field33", "field34", "field35",
    "field36", "field37", "field38", "field39", "field40"
  )
)
println(obj.toJson.toString())
// ⇒ {"field1":"field1","field10":"field10","field11":"field11","field12":"field12","field13":"field13","field14":"field14","field15":"field15","field16":"field16","field17":"field17","field18":"field18","field19":"field19","field2":"field2","field20":"field20","field21":"field21","field22":"field22","field23":"field23","field24":"field24","field25":"field25","field26":"field26","field27":"field28","field28":"field27","field29":"field29","field3":"field3","field30":"field30","field31":"field31","field32":"field32","field33":"field33","field34":"field34","field35":"field35","field36":"field36","field37":"field37","field38":"field38","field39":"field39","field4":"field4","field40":"field40","field5":"field5","field6":"field6","field7":"field7","field8":"field8","field9":"field9"}

val json =
  """
  {
    "field1":"field1", "field2":"field2", "field3":"field3", "field4":"field4", "field5":"field5",
    "field6":"field6", "field7":"field7", "field8":"field8", "field9":"field9", "field10":"field10",
    "field11":"field11", "field12":"field12", "field13":"field13", "field14":"field14", "field15":"field15",
    "field16":"field16", "field17":"field17", "field18":"field18", "field19":"field19", "field20":"field20",
    "field21":"field21", "field22":"field22", "field23":"field23", "field24":"field24", "field25":"field25",
    "field26":"field26", "field27":"field27", "field28":"field28", "field29":"field29", "field30":"field30",
    "field31":"field31", "field32":"field32", "field33":"field33", "field34":"field34", "field35":"field35",
    "field36":"field36", "field37":"field37", "field38":"field38", "field39":"field39", "field40":"field40"
  }
  """
println(json.parseJson.convertTo[LargeDocument])
// ⇒ LargeDocument(LargeDocumentPart1(field1,field2,field3,field4,field5,field6,field7,field8,field9,field10,field11,field12,field13,field14,field15,field16,field17,field18,field19,field20,field21,field22),LargeDocumentPart2(field23,field24,field25,field26,field27,field28,field29,field30,field31,field32,field33,field34,field35,field36,field37,field38,field39,field40))
GitHubで編集を提案

Discussion