使用Scala中的circe解析结构化JSON数组

15
假设我需要解码类似以下的JSON数组,其中有一些字段在开头,任意数量的同类元素,然后是其他字段:
[ "Foo", "McBar", true, false, false, false, true, 137 ]

我不知道为什么有人会选择像这样编码他们的数据,但是人们做出了奇怪的事情,而在这种情况下,我只能处理它。

我想将此JSON解码为如下的案例类:

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值之间来回切换,丢弃错误消息中的历史记录,并且通常会让人感到难看?


一些背景:关于编写类似 circe 中自定义 JSON 数组解码器的问题经常出现(例如,今天早上)。如何实现这个的具体细节可能会在 circe 的即将发布的版本中发生改变(尽管 API 将是相似的;请参见 这个实验性项目 以获取一些详细信息),因此我不想花费太多时间将这样的示例添加到文档中,但它出现的频率足够高,我认为它确实值得在 Stack Overflow 上有一个问答。

1个回答

25

使用游标

有更好的方法!您可以通过直接使用游标来编写更简洁的代码,并保持有用的错误消息:

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)))

此外,这种方法更加简短、声明性,且不需要那些令人难以理解的嵌套。

工作原理

关键思路是我们将“读取”操作(在光标上使用.as[X])与导航/修改操作(downArray 和三个 delete 方法调用)交替执行。

一开始,c 是一个希望指向数组的 HCursorc.downArray 将游标移动到数组中的第一个元素。如果输入根本不是数组,或者是空数组,则此操作将失败,并且我们将获得有用的错误消息。如果成功,for 循环的第一行将尝试将该数组的第一个元素解码为字符串,并使我们的光标指向该第一个元素。

for 循环的第二行表示“好了,我们完成了第一个元素,所以让我们把它忘掉并移动到第二个元素”。方法名称中的 delete 部分并不意味着它实际上正在改变任何东西——在 circe 中,任何用户可以观察到的东西都不会被改变——它只是意味着该元素对于结果光标上的任何将来操作都不可用。

第三行尝试将原始 JSON 数组中的第二个元素(现在是我们新光标中的第一个元素)解码为字符串。完成后,第四行“删除”该元素并移动到数组末尾,然后第五行尝试将最终元素解码为 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)

这个方法也可以工作,并且它有一个额外的好处,如果无法解码其中一个成员,则可以同时获取所有失败的错误消息。例如,如果我们有类似以下代码,则应该期望三个错误(非字符串的名字,非整数的年龄和非布尔值的stuff):

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))

你应该偏好哪种方法是个人口味而且是否关心错误累积的问题——我个人觉得第一种方法稍微更易读。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接