Декодирование структурированных массивов 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 будет аналогичным; см. этот экспериментальный проект для некоторых деталей), поэтому я действительно не хочу тратить много времени на добавление такого примера в документацию, но его достаточно, чтобы я думаю, что он заслуживает переполнения стека Вопросы и ответы.


person Travis Brown    schedule 10.09.2017    source источник


Ответы (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-compution попытается декодировать этот первый элемент в строку и оставит наш курсор, указывающий на этот первый элемент.

Во второй строке for-computing написано: «Хорошо, мы закончили с первым элементом, так что давайте забудем об этом и перейдем ко второму». Часть 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))

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

person Travis Brown    schedule 10.09.2017