[akka-http] spray-jsonでフィールド22個の制約を超える
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)
しかし、jsonFormatN
は jsonFormat0
~ jsonFormat22
しか用意されていません。
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
の値を JsObject
、number
の値を JsNumber
という型で表現します。また、これらの型を総称して JsValue
という抽象的な型で表現できます。
それぞれ、次のような戦略で実装します。
-
def write
(Scala → JSON)- フィールドを分割して切り出した case class をそれぞれ
JsObject
に変換 - 変換された
JsObject
からフィールドだけ取り出し新たなJsObject
へフラットに詰めなおす
- フィールドを分割して切り出した case class をそれぞれ
-
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))
Discussion