play-json: JsNull 不等于 JsString(null),如何解决这个问题?

play-json: JsNull is not equal to JsString(null), how to get around this issue?

我正在处理一些较大的 JSON 输出。我们有一堆测试来检查输出。我们通过在磁盘上创建 JSON 的副本,在 InputStream 上执行 Json.parse() 并将其与我们在内存中构建的 JsObject 进行比较来创建测试。

在我开始将我们的一些 Writes 转换为使用函数式语法(即,不要覆盖特征中的 writes 方法,而是使用构建器)之前,这一直很有效。

突然,测试开始失败:抱怨 null 字段不相等。

显然,当使用函数语法时,Option[String] 将转换为 JsString(null) 而不是 JsNull。这在字符串化版本中并不明显。

考虑以下片段,使用

libraryDependencies += "com.typesafe.play" %% "play-json" % "2.7.4"
import play.api.libs.json._
import play.api.libs.functional.syntax._

object FooBar {

  case class Foo(option: Option[String])

  def main(args: Array[String]): Unit = {

    val classic: OWrites[Foo] = new OWrites[Foo] {
      override def writes(f: Foo): JsObject = Json.obj("foo" -> f.option)
    }

    val dsl: OWrites[Foo] = (__ \ "foo").write[String].contramap(foo => foo.option.orNull)

    val json_classic = Json.toJsObject(Foo(None))(classic)
    val json_dsl = Json.toJsObject(Foo(None))(dsl)


    val json_parse = Json.parse("""{"foo":null}""")

    val string_classic = Json.prettyPrint(json_classic)
    val string_dsl = Json.prettyPrint(json_dsl)

    println(
      s"""Result is:
         |json_dsl       == json_classic : ${json_dsl == json_classic} // (expect true)
         |json_dsl       == json_parse   : ${json_dsl == json_parse} // (expect true)
         |json_classic   == json_parse   : ${json_classic == json_parse} // (expect true)
         |string_classic == string_dsl   : ${string_classic == string_dsl} // (expect true)
         |""".stripMargin)

    println(s"classic:\n$string_classic")
    println(s"dsl:\n$string_dsl")

  }
}

实际输出为

Result is:
json_dsl       == json_classic : false // (expect true)
json_dsl       == json_parse   : false // (expect true)
json_classic   == json_parse   : true // (expect true)
string_classic == string_dsl   : true // (expect true)

classic:
{
  "foo" : null
}
dsl:
{
  "foo" : null
}

在调试时,您会看到 classic 使用 Tuple ("foo", JsNull) 创建包装器对象,而 dsl 使用 Tuple ("foo", JsString(null)).

创建包装器

似乎 dsl 的预期方式是在这种情况下使用 writeNullable,但它以这种方式工作感觉很奇怪。

我希望 JsString(null) == JsNull 为真,或者 dsl 会捕获 null 值并阻止创建 JsString

我是不是做错了什么?

我只想重写为 .writeNullable[String],这将从 JSON 中删除该字段,但我们有一个需要该字段存在的架构:

...
"properties": {
  ...
  "foo": {
    "oneOf": [
      {"type": "string"},
      {"type": "null"}
    ]
  },
  ...
}
...
"required": [ "foo" ],

这是 API 的一部分,因此更改它需要时间。

澄清一下:字符串表示在所有情况下都是正确的。我只对 JsValue 的内存表示感兴趣,因此我可以在测试期间使用它的相等性。

我无法使用 Play-JSON 2.7.4:

在 REPL 中重现您的测试
import play.api.libs.json._

case class Foo(option: Option[String])

val classic: OWrites[Foo] = new OWrites[Foo] {
  override def writes(f: Foo): JsObject = Json.obj("foo" -> f.option)
}

val dsl: OWrites[Foo] = (__ \ "foo").write[String].contramap(foo => foo.option.orNull)


val json_classic = Json.toJsObject(Foo(None))(classic)
val json_dsl = Json.toJsObject(Foo(None))(dsl)

val json_classic = Json.toJsObject(Foo(None))(classic)
// json_classic: play.api.libs.json.JsObject = {"foo":null}

val json_dsl = Json.toJsObject(Foo(None))(dsl)
// json_dsl: play.api.libs.json.JsObject = {"foo":null}

json_classicjson_dsl 之间的不等式是另外一回事,但是 JSON 表示在这两种情况下是一致的,即使 foo.option.orNull 是 unsafe/weird 对我来说。

否则,如果您想将 "null" 视为 null,您可以在需要此特定行为的地方覆盖默认值 Reads[String]

scala> val legacyStrReads: Reads[Option[String]] =
     |   Reads.optionWithNull(Reads.StringReads).map {
     |     case Some("null") => None
     |     case other => other
     |   }
legacyStrReads: play.api.libs.json.Reads[Option[String]] = play.api.libs.json.Reads$$anon@138decb1

scala> Json.toJson("null").validate(legacyStrReads)
res9: play.api.libs.json.JsResult[Option[String]] = JsSuccess(None,)

我想你想要的是这个:

val dsl: OWrites[Foo] = (__ \ "foo").writeOptionWithNull[String]
  .contramap(_.option)

或者,如果使用没有 writeOptionWithNull:

的旧 play-json 版本
val dsl: OWrites[Foo] = (__ \ "foo").write[JsValue]
  .contramap(_.option match {
    case None => JsNull
    case Some(string) => JsString(string)
  })

注意,play.api.libs.json.JsNullnull是两个完全不同且不相关的概念,切勿混淆或混淆。在 Scala-only APIs 中,整个 Scala 生态系统的约定是永远、永远不要使用 null,以至于大多数库只是假装它甚至不存在。因此,您不会找到许多执行空检查的 Scala 库,它们只是假设所有内容都是非空的,而当您开始使用空的那一刻,您就处于未经测试和不受支持的边缘情况的狂野西部。您唯一一次使用或处理空值是在使用 Java API 时,因为它们使用空值,并且那里的约定是包装任何可以在 [= 中产生 null 的内容17=] 尽可能早,并尽可能晚地使用 orNull 解包 Option,这样至少在不直接与 Java 代码接口的 Scala 代码内部,一切使用 Option,而不是 null。但是 play-json 专为 Scala 使用而设计,因此与 Scala-only 生态系统的其余部分一样,它只是假设 null 不存在。

当然,在 json 中,null 确实存在,并且可能有正当理由使用它(尤其是在与需要它的其他系统集成时)。所以 play-json 确实对它进行建模,但它以一种非常强类型的方式对其进行建模——没有什么会自动从 Scala 中的 null 变为 JSON 中的 null ,它总是很明确,这符合 Scala 是强类型的。此外,在 JSON 中使用 null 并不常见,因此大多数默认方法(即 writeNullable 方法)将 Option 映射到非-存在值,所以要写JSONnull的时候要明确一点,如上图