Декодирование структурированных массивов JSON с помощью circe в Scala

Предположим, мне нужно декодировать массивы JSON, которые выглядят следующим образом: в начале есть пара полей, произвольное количество однородных элементов, а затем другое поле:

[ "Foo", "McBar", true, false, false, false, true, 137 ]

Я не знаю, почему кто-то решил закодировать свои данные таким образом, но люди делают странные вещи, и полагаю, что в этом случае мне просто придется иметь дело с этим.

Я хочу декодировать этот JSON в класс case, как это:

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 поднимаются довольно часто (например, сегодня утром). Конкретные детали того, как это сделать, могут измениться в следующей версии Circe (хотя API будет похожим, см. Этот экспериментальный проект для некоторых деталей), поэтому я не хочу тратить много времени на добавление Пример, подобный этому, приведен в документации, но он подходит достаточно, и я думаю, что он заслуживает вопросов и ответов о переполнении стека.

1 ответ

Решение

Работа с курсорами

Существует лучший способ! Вы можете написать это гораздо более кратко, сохраняя при этом полезные сообщения об ошибках, работая напрямую с курсорами:

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 является HCursor что мы надеемся, указывает на массив. c.downArray перемещает курсор на первый элемент в массиве. Если входные данные вообще не являются массивом или являются пустым массивом, эта операция завершится неудачей, и мы получим полезное сообщение об ошибке. Если это удастся, первая строка for-comprehension попытается декодировать этот первый элемент в строку и оставит наш курсор, указывающий на этот первый элемент.

Вторая строка в 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)

Это также будет работать, и у него будет дополнительное преимущество, заключающееся в том, что если декодирование не удастся более чем для одного из участников, вы можете получать сообщения об ошибках для всех сбоев одновременно. Например, если у нас есть что-то вроде этого, мы должны ожидать три ошибки (для нестрокового имени, нецелого возраста и не-логического значения вещи):

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

Какой из этих двух подходов вы предпочитаете, зависит от вкуса, и не важно, заботитесь ли вы о накоплении ошибок - лично я считаю первый более читабельным.

Другие вопросы по тегам