在 Scala 中使用 circe 解码结构化 JSON 数组
Decoding structured JSON arrays with circe in Scala
假设我需要解码如下所示的 JSON 数组,其中开头有几个字段,一些任意数量的同类元素,然后是其他一些字段:
[ "Foo", "McBar", true, false, false, false, true, 137 ]
我不知道为什么有人会选择这样编码他们的数据,但人们会做一些奇怪的事情,假设在这种情况下我只需要处理它。
我想将这个 JSON 解码成这样的案例 class:
case class Foo(firstName: String, lastName: String, age: Int, stuff: List[Boolean])
我们可以这样写:
import cats.syntax.either._
import io.circe.{ Decoder, DecodingFailure, Json }
implicit val fooDecoder: Decoder[Foo] = Decoder.instance { c =>
c.focus.flatMap(_.asArray) match {
case Some(fnJ +: lnJ +: rest) =>
rest.reverse match {
case ageJ +: stuffJ =>
for {
fn <- fnJ.as[String]
ln <- lnJ.as[String]
age <- ageJ.as[Int]
stuff <- Json.fromValues(stuffJ.reverse).as[List[Boolean]]
} yield Foo(fn, ln, age, stuff)
case _ => Left(DecodingFailure("Foo", c.history))
}
case None => Left(DecodingFailure("Foo", c.history))
}
}
…有效:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false, 137 ]""")
res3: io.circe.Decoder.Result[Foo] = Right(Foo(Foo,McBar,137,List(true, false)))
但是,呃,那太可怕了。错误消息也完全没用:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false ]""")
res4: io.circe.Decoder.Result[Foo] = Left(DecodingFailure(Int, List()))
当然有一种方法可以做到这一点,它不需要在光标和 Json
值之间来回切换,在我们的错误消息中丢弃历史记录,而且通常只是一个碍眼的地方?
一些背景:关于编写像这样的自定义 JSON 数组解码器的问题在 circe 中经常出现(例如 this morning). The specific details of how to do this are likely to change in an upcoming version of circe (although the API will be similar; see this experimental project 的一些细节),所以我真的不想花很多钱有时间在文档中添加这样的示例,但我认为它确实值得 Stack Overflow Q&A。
使用游标
有更好的方法!您可以通过直接使用游标一直写得更简洁,同时还可以保留有用的错误消息:
case class Foo(firstName: String, lastName: String, age: Int, stuff: List[Boolean])
import cats.syntax.either._
import io.circe.Decoder
implicit val fooDecoder: Decoder[Foo] = Decoder.instance { c =>
val fnC = c.downArray
for {
fn <- fnC.as[String]
lnC = fnC.deleteGoRight
ln <- lnC.as[String]
ageC = lnC.deleteGoLast
age <- ageC.as[Int]
stuffC = ageC.delete
stuff <- stuffC.as[List[Boolean]]
} yield Foo(fn, ln, age, stuff)
}
这也有效:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false, 137 ]""")
res0: io.circe.Decoder.Result[Foo] = Right(Foo(Foo,McBar,137,List(true, false)))
但它也为我们提供了错误发生位置的指示:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false ]""")
res1: io.circe.Decoder.Result[Foo] = Left(DecodingFailure(Int, List(DeleteGoLast, DeleteGoRight, DownArray)))
此外,它更短、更明确,并且不需要难以理解的嵌套。
工作原理
关键思想是我们将 "reading" 操作(游标上的 .as[X]
调用)与导航/修改操作(downArray
和三个 delete
方法交错电话)。
当我们开始时,c
是一个HCursor
,我们希望它指向一个数组。 c.downArray
将光标移动到数组中的第一个元素。如果输入根本不是一个数组,或者是一个空数组,这个操作就会失败,我们会得到一个有用的错误消息。如果成功,for
-comprehension 的第一行将尝试将第一个元素解码为字符串,并将光标指向第一个元素。
for
-comprehension 中的第二行表示 "okay, we're done with the first element, so let's forget about it and move to the second"。方法名称的 delete
部分并不意味着它实际上正在改变任何东西——圈内没有任何东西以用户可以观察到的任何方式改变任何东西——它只是意味着该元素将不可用于任何未来的操作结果光标。
第三行尝试将原始 JSON 数组中的第二个元素(现在是新游标中的第一个元素)解码为字符串。完成后,第四行 "deletes" 该元素并移动到数组的末尾,然后第五行尝试将该最终元素解码为 Int
.
下一行可能是最有趣的:
stuffC = ageC.delete
这表示,好的,我们位于 JSON 数组的修改视图中的最后一个元素(之前我们删除了前两个元素)。现在我们删除最后一个元素并将光标 向上移动 使其指向整个(修改后的)数组,然后我们可以将其解码为布尔值列表,这样就完成了。
错误累积更多
实际上还有一种更简洁的写法:
import cats.syntax.all._
import io.circe.Decoder
implicit val fooDecoder: Decoder[Foo] = (
Decoder[String].prepare(_.downArray),
Decoder[String].prepare(_.downArray.deleteGoRight),
Decoder[Int].prepare(_.downArray.deleteGoLast),
Decoder[List[Boolean]].prepare(_.downArray.deleteGoRight.deleteGoLast.delete)
).map4(Foo)
这也行得通,而且还有一个额外的好处,即如果多个成员的解码失败,您可以同时收到所有失败的错误消息。例如,如果我们有这样的事情,我们应该期待三个错误(对于非字符串名字、非整数年龄和非布尔值):
val bad = """[["Foo"], "McBar", true, "true", false, 13.7 ]"""
val badResult = io.circe.jawn.decodeAccumulating[Foo](bad)
这就是我们所看到的(连同每次故障的具体位置信息):
scala> badResult.leftMap(_.map(println))
DecodingFailure(String, List(DownArray))
DecodingFailure(Int, List(DeleteGoLast, DownArray))
DecodingFailure([A]List[A], List(MoveRight, DownArray, DeleteGoParent, DeleteGoLast, DeleteGoRight, DownArray))
您应该更喜欢这两种方法中的哪一种取决于您的品味以及您是否关心错误累积——我个人认为第一种更具可读性。
假设我需要解码如下所示的 JSON 数组,其中开头有几个字段,一些任意数量的同类元素,然后是其他一些字段:
[ "Foo", "McBar", true, false, false, false, true, 137 ]
我不知道为什么有人会选择这样编码他们的数据,但人们会做一些奇怪的事情,假设在这种情况下我只需要处理它。
我想将这个 JSON 解码成这样的案例 class:
case class Foo(firstName: String, lastName: String, age: Int, stuff: List[Boolean])
我们可以这样写:
import cats.syntax.either._
import io.circe.{ Decoder, DecodingFailure, Json }
implicit val fooDecoder: Decoder[Foo] = Decoder.instance { c =>
c.focus.flatMap(_.asArray) match {
case Some(fnJ +: lnJ +: rest) =>
rest.reverse match {
case ageJ +: stuffJ =>
for {
fn <- fnJ.as[String]
ln <- lnJ.as[String]
age <- ageJ.as[Int]
stuff <- Json.fromValues(stuffJ.reverse).as[List[Boolean]]
} yield Foo(fn, ln, age, stuff)
case _ => Left(DecodingFailure("Foo", c.history))
}
case None => Left(DecodingFailure("Foo", c.history))
}
}
…有效:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false, 137 ]""")
res3: io.circe.Decoder.Result[Foo] = Right(Foo(Foo,McBar,137,List(true, false)))
但是,呃,那太可怕了。错误消息也完全没用:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false ]""")
res4: io.circe.Decoder.Result[Foo] = Left(DecodingFailure(Int, List()))
当然有一种方法可以做到这一点,它不需要在光标和 Json
值之间来回切换,在我们的错误消息中丢弃历史记录,而且通常只是一个碍眼的地方?
一些背景:关于编写像这样的自定义 JSON 数组解码器的问题在 circe 中经常出现(例如 this morning). The specific details of how to do this are likely to change in an upcoming version of circe (although the API will be similar; see this experimental project 的一些细节),所以我真的不想花很多钱有时间在文档中添加这样的示例,但我认为它确实值得 Stack Overflow Q&A。
使用游标
有更好的方法!您可以通过直接使用游标一直写得更简洁,同时还可以保留有用的错误消息:
case class Foo(firstName: String, lastName: String, age: Int, stuff: List[Boolean])
import cats.syntax.either._
import io.circe.Decoder
implicit val fooDecoder: Decoder[Foo] = Decoder.instance { c =>
val fnC = c.downArray
for {
fn <- fnC.as[String]
lnC = fnC.deleteGoRight
ln <- lnC.as[String]
ageC = lnC.deleteGoLast
age <- ageC.as[Int]
stuffC = ageC.delete
stuff <- stuffC.as[List[Boolean]]
} yield Foo(fn, ln, age, stuff)
}
这也有效:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false, 137 ]""")
res0: io.circe.Decoder.Result[Foo] = Right(Foo(Foo,McBar,137,List(true, false)))
但它也为我们提供了错误发生位置的指示:
scala> fooDecoder.decodeJson(json"""[ "Foo", "McBar", true, false ]""")
res1: io.circe.Decoder.Result[Foo] = Left(DecodingFailure(Int, List(DeleteGoLast, DeleteGoRight, DownArray)))
此外,它更短、更明确,并且不需要难以理解的嵌套。
工作原理
关键思想是我们将 "reading" 操作(游标上的 .as[X]
调用)与导航/修改操作(downArray
和三个 delete
方法交错电话)。
当我们开始时,c
是一个HCursor
,我们希望它指向一个数组。 c.downArray
将光标移动到数组中的第一个元素。如果输入根本不是一个数组,或者是一个空数组,这个操作就会失败,我们会得到一个有用的错误消息。如果成功,for
-comprehension 的第一行将尝试将第一个元素解码为字符串,并将光标指向第一个元素。
for
-comprehension 中的第二行表示 "okay, we're done with the first element, so let's forget about it and move to the second"。方法名称的 delete
部分并不意味着它实际上正在改变任何东西——圈内没有任何东西以用户可以观察到的任何方式改变任何东西——它只是意味着该元素将不可用于任何未来的操作结果光标。
第三行尝试将原始 JSON 数组中的第二个元素(现在是新游标中的第一个元素)解码为字符串。完成后,第四行 "deletes" 该元素并移动到数组的末尾,然后第五行尝试将该最终元素解码为 Int
.
下一行可能是最有趣的:
stuffC = ageC.delete
这表示,好的,我们位于 JSON 数组的修改视图中的最后一个元素(之前我们删除了前两个元素)。现在我们删除最后一个元素并将光标 向上移动 使其指向整个(修改后的)数组,然后我们可以将其解码为布尔值列表,这样就完成了。
错误累积更多
实际上还有一种更简洁的写法:
import cats.syntax.all._
import io.circe.Decoder
implicit val fooDecoder: Decoder[Foo] = (
Decoder[String].prepare(_.downArray),
Decoder[String].prepare(_.downArray.deleteGoRight),
Decoder[Int].prepare(_.downArray.deleteGoLast),
Decoder[List[Boolean]].prepare(_.downArray.deleteGoRight.deleteGoLast.delete)
).map4(Foo)
这也行得通,而且还有一个额外的好处,即如果多个成员的解码失败,您可以同时收到所有失败的错误消息。例如,如果我们有这样的事情,我们应该期待三个错误(对于非字符串名字、非整数年龄和非布尔值):
val bad = """[["Foo"], "McBar", true, "true", false, 13.7 ]"""
val badResult = io.circe.jawn.decodeAccumulating[Foo](bad)
这就是我们所看到的(连同每次故障的具体位置信息):
scala> badResult.leftMap(_.map(println))
DecodingFailure(String, List(DownArray))
DecodingFailure(Int, List(DeleteGoLast, DownArray))
DecodingFailure([A]List[A], List(MoveRight, DownArray, DeleteGoParent, DeleteGoLast, DeleteGoRight, DownArray))
您应该更喜欢这两种方法中的哪一种取决于您的品味以及您是否关心错误累积——我个人认为第一种更具可读性。