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