Десериализовать JSON, различая отсутствующие и нулевые значения

У меня есть требование проанализировать объект JSON, используя play-json, и различать отсутствующее значение, строковое значение и нулевое значение.

Так, например, я мог бы захотеть десериализоваться в следующий класс case:

case class MyCaseClass(
  a: Option[Option[String]]
)

Где значения «а» означают:

  • Нет - отсутствовала буква "a" - нормальное поведение play-json
  • Some(Some(String)) - "a" имело строковое значение
  • Some(None) - "a" имело нулевое значение

Итак, примеры ожидаемого поведения:

{}

should deserialize to myCaseClass(None)

{
  "a": null
} 

should deserialize as myCaseClass(Some(None))

{
  "a": "a"
}

should deserialize as myCaseClass(Some(Some("a"))

Я пробовал писать собственные средства форматирования, но методы formatNullable и formatNullableWithDefault не различают отсутствующее и нулевое значение, поэтому код, который я написал ниже, не может генерировать результат Some(None)

object myCaseClass {
  implicit val aFormat: Format[Option[String]] = new Format[Option[String]] {
    override def reads(json: JsValue): JsResult[Option[String]] = {
      json match {
        case JsNull => JsSuccess(None) // this is never reached
        case JsString(value) => JsSuccess(Some(value))
        case _ => throw new RuntimeException("unexpected type")
      }
    }
    override def writes(codename: Option[String]): JsValue = {
      codename match {
        case None => JsNull
        case Some(value) =>  JsString(value)
      }
    }
  }

  implicit val format = (
      (__ \ "a").formatNullableWithDefault[Option[String]](None)
  )(MyCaseClass.apply, unlift(MyCaseClass.unapply))
}

Я пропустил трюк здесь? Как мне это сделать? Я очень хочу закодировать конечное значение каким-либо другим способом, кроме Option[Option[Sting]], например, каким-то классом case, который инкапсулирует это:

case class MyContainer(newValue: Option[String], wasProvided: Boolean)

person iandotkelly    schedule 29.01.2018    source источник
comment
И да, я знаю, что могу написать полностью настраиваемый метод чтения для всего объекта. Я пытался избежать этого.   -  person iandotkelly    schedule 30.01.2018
comment
Тип Option[Option[_]] вряд ли может иметь какой-либо смысл в любом случае. Кстати, я не вижу пользы от такого null/отсутствующего различия.   -  person cchantep    schedule 30.01.2018
comment
@cchantep .. наверняка есть запах кода, но это мои требования. Существует несколько правдоподобный вариант использования. В любом случае, это разбор json, с которым я борюсь. Для целей вопроса, если мы считаем undefined и null отдельными значениями в json... как мне разобрать это в игре, не просто просматривая json самостоятельно.   -  person iandotkelly    schedule 30.01.2018
comment
Вы предполагаете, что undefined или null различны, тогда как они оба одинаковы, используя разный формат: представляют тот факт, что для указанного поля нет значения.   -  person cchantep    schedule 30.01.2018
comment
@cchantep ... но они принципиально разные и имеют разные свойства в JavaScript, языке, на котором был создан JSON. Да, это может быть запахом кода, чтобы рассматривать их по-разному, но странно, что так сложно работать с фундаментальным типом в JSON в этой структуре. Один представляет, что у окружающего объекта нет этого свойства, один — свойство определено, но не имеет значения.   -  person iandotkelly    schedule 30.01.2018
comment
У меня есть требование - хорошая ли идея спорна. Можно или нет это делать, вот в чем вопрос.   -  person iandotkelly    schedule 30.01.2018
comment
JSON не имеет undefined   -  person Aluan Haddad    schedule 13.05.2019
comment
@AluanHaddad ... Ну да. Undefined означает, что свойство не существует/не определено в объекте JSON.   -  person iandotkelly    schedule 13.05.2019
comment
@iandotkelly именно так. Я просто был педантичен в этом вопросе.   -  person Aluan Haddad    schedule 14.05.2019
comment
я абсолютно вижу, что null vs опущено как два совершенно разных намерения. К сожалению, в спецификации JSON по этому поводу не меньше двусмысленности, но интуитивно мне имеет смысл делать то, что предлагает здесь @iandotkelly (особенно при рассмотрении PATCH). Я начал обсуждение этого вопроса с основной командой play-json здесь: discuss.lightbend.com/t/   -  person kflorence    schedule 03.09.2020


Ответы (3)


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

Следующий фрагмент кода добавляет к JsPath три метода расширения для чтения/записи/форматирования полей типа Option[Option[A]]. В каждом случае отсутствующее поле сопоставляется с None, null с Some(None), а ненулевое значение с Some(Some(a)), как запросил исходный постер:

import play.api.libs.json._

object tristate {
  implicit class TriStateNullableJsPathOps(path: JsPath) {
    def readTriStateNullable[A: Reads]: Reads[Option[Option[A]]] =
      Reads[Option[Option[A]]] { value =>
        value.validate[JsObject].flatMap { obj =>
          path.asSingleJsResult(obj) match {
            case JsError(_)           => JsSuccess(Option.empty[Option[A]])
            case JsSuccess(JsNull, _) => JsSuccess(Option(Option.empty[A]))
            case JsSuccess(json, _)   => json.validate[A]
                                             .repath(path)
                                             .map(a => Option(Option(a)))
          }
        }
      }

    def writeTriStateNullable[A: Writes]: OWrites[Option[Option[A]]] =
      path.writeNullable(Writes.optionWithNull[A])

    def formatTriStateNullable[A: Format]: OFormat[Option[Option[A]]] =
      OFormat(readTriStateNullable[A], writeTriStateNullable[A])
  }
}

Как и предыдущие предложения в этой теме, этот метод требует, чтобы вы полностью записали формат JSON, используя аппликативную DSL. К сожалению, он несовместим с макросом Json.format, но приближает вас к тому, что вы хотите. Вот вариант использования:

import play.api.libs.json._
import play.api.libs.functional.syntax._
import tristate._

case class Coord(col: Option[Option[String]], row: Option[Option[Int]])

implicit val format: OFormat[Coord] = (
  (__ \ "col").formatTriStateNullable[String] ~
  (__ \ "row").formatTriStateNullable[Int]
)(Coord.apply, unlift(Coord.unapply))

Некоторые примеры написания:

format.writes(Coord(None, None))
// => {}

format.writes(Coord(Some(None), Some(None)))
// => { "col": null, "row": null }

format.writes(Coord(Some(Some("A")), Some(Some(1))))
// => { "col": "A", "row": 1 }

И несколько примеров чтения:

Json.obj().as[Coord]
// => Coord(None, None)

Json.obj(
  "col" -> JsNull, 
  "row" -> JsNull
).as[Coord]
// => Coord(Some(None), Some(None))

Json.obj(
  "col" -> "A", 
  "row" -> 1
).as[Coord]
// => Coord(Some(Some("A")), Some(Some(1)))

В качестве бонусного упражнения для читателя вы, вероятно, могли бы объединить это с небольшим бесформенным кодеком для автоматического получения кодеков и заменить макрос Json.format другим однострочником (хотя и таким, который требует больше времени для компиляции).

person Dave Gurnell    schedule 13.05.2019
comment
Интересно, можно ли это настроить с помощью OptionHandlers в JsonConfiguration: playframework.com/documentation/2.7.0/api/scala/play/api/libs/ - person kflorence; 03.09.2020

К сожалению, я не знаю, как добиться того, чего вы хотите автоматически. Пока мне кажется, что стандартным макросом так не сделаешь. Однако, как ни удивительно, вы можете получить аналогичный результат, если вы согласны с заменой случаев null и «отсутствует» (что, я согласен, немного сбивает с толку).

Предположим, что класс Xxx определен как (важно значение по умолчанию — это будет результат для случая null)

case class Xxx(a: Option[Option[String]] = Some(None))

и вы предоставляете следующие неявные Reads:

implicit val optionStringReads:Reads[Option[String]] = new Reads[Option[String]] {
  override def reads(json: JsValue) = json match {
    case JsNull => JsSuccess(None) // this is never reached
    case JsString(value) => JsSuccess(Some(value))
    case _ => throw new RuntimeException("unexpected type")
  }
}

implicit val xxxReads = Json.using[Json.WithDefaultValues].reads[Xxx]

Затем для тестовых данных:

val jsonNone = "{}"
val jsonNull = """{"a":null}"""
val jsonVal = """{"a":"abc"}"""
val jsonValues = List(jsonNone, jsonNull, jsonVal)

jsonValues.foreach(jsonString => {
  val jsonAst = Json.parse(jsonString)
  val obj = Json.fromJson[Xxx](jsonAst)
  println(s"'$jsonString' => $obj")
})

вывод

'{}' => JsSuccess(Xxx(Некоторые(Нет)),)
'{"a":null}' => JsSuccess(Xxx(Нет),)
'{"a": "abc"}' => JsSuccess(Xxx(Некоторые(Некоторые(abc))),)

So

  • отсутствующий атрибут отображается на Some(None)
  • null отображается на None
  • Значение отображается на Some(Some(value))

Это неуклюже и немного неожиданно для разработчика, но, по крайней мере, это отличает все 3 варианта. Причина, по которой варианты null и «отсутствует» меняются местами, заключается в том, что единственный способ, которым я нашел, чтобы различать эти случаи, - это объявить значение в целевом классе как Option и со значением по умолчанию одновременно, и в этом случае значение по умолчанию значение — это то, на что сопоставляется случай «отсутствует»; и, к сожалению, вы не можете контролировать значение, на которое отображается null - это всегда None.

person SergGr    schedule 30.01.2018
comment
Да... Я думал, что могу поменять местами значения null и undefined и добиться чего-то подобного. Спасибо, что подтвердили это. - person iandotkelly; 30.01.2018

Следуя предложению @kflorence по поводу OptionHandler, я смог добиться желаемого поведения.

implicit def optionFormat[T](implicit tf: Format[T]): Format[Option[T]] = Format(
    tf.reads(_).map(r => Some(r)),
    Writes(v => v.map(tf.writes).getOrElse(JsNull))
  )

object InvertedDefaultHandler extends OptionHandlers {
  def readHandler[T](jsPath: JsPath)(implicit r: Reads[T]): Reads[Option[T]] = jsPath.readNullable

  override def readHandlerWithDefault[T](jsPath: JsPath, defaultValue: => Option[T])(implicit r: Reads[T]): Reads[Option[T]] = Reads[Option[T]] { json =>
    jsPath.asSingleJson(json) match {
      case JsDefined(JsNull) => JsSuccess(defaultValue)
      case JsDefined(value)  => r.reads(value).repath(jsPath).map(Some(_))
      case JsUndefined()     => JsSuccess(None)
    }
  }

  def writeHandler[T](jsPath: JsPath)(implicit writes: Writes[T]): OWrites[Option[T]] = jsPath.writeNullable
}

val configuration = JsonConfiguration[Json.WithDefaultValues](optionHandlers = InvertedDefaultHandler)

case class RequestObject(payload: Option[Option[String]] = Some(None))

implicit val requestObjectFormat: OFormat[RequestObject] = Json.configured(configuration).format[RequestObject]
Json.parse(""" {} """).as[RequestObject] // RequestObject(None)
Json.parse(""" {"payload": null } """).as[RequestObject] // RequestObject(Some(None))
Json.parse(""" {"payload": "hello" } """).as[RequestObject] // RequestObject(Some(Some(hello)))

Итак, важные части:

  • readHandlerWithDefault в основном переворачивает то, как JsDefined(JsNull) и JsUndefined обрабатывают отсутствующие и явные нули по сравнению с исходной реализацией в OptionHandlers.Default
  • JsonConfiguration принимает как Json.WithDefaultValues, так и optionHandlers
  • Как устанавливается значение по умолчанию. Обратите внимание на значение по умолчанию для RequestObject.payload.
person sue    schedule 01.04.2021