【问题标题】:Defaults for missing properties in play 2 JSON formatsplay 2 JSON 格式中缺少的属性的默认值
【发布时间】:2025-12-17 04:10:02
【问题描述】:

我在玩 scala 中有一个等效于以下模型的模型:

case class Foo(id:Int,value:String)
object Foo{
  import play.api.libs.json.Json
  implicit val fooFormats = Json.format[Foo]
}

对于下面的 Foo 实例

Foo(1, "foo")

我会得到以下 JSON 文档:

{"id":1, "value": "foo"}

此 JSON 被持久化并从数据存储中读取。现在我的要求发生了变化,我需要向 Foo 添加一个属性。该属性有一个默认值:

case class Foo(id:String,value:String, status:String="pending")

写入 JSON 不是问题:

{"id":1, "value": "foo", "status":"pending"}

然而,从它读取会产生一个 JsError,因为缺少“/status”路径。

如何提供噪音最小的默认值?

(ps:我有一个答案,我将在下面发布,但我对此并不满意,并会投票并接受任何更好的选择)

【问题讨论】:

  • 嗨,让。我一直在研究这个问题,发现this。除了围着它说话,似乎没有任何活动。你找到比变压器更优雅的解决方案了吗?
  • 我现在还在使用变压器。在我有足够的时间研究如何编写自己的宏之前,我认为这就是我能够使用的全部:(

标签: json scala playframework-2.2


【解决方案1】:

玩 2.6+

根据@CanardMoussant's answer,从 Play 2.6 开始,play-json 宏得到了改进,并提出了多项新功能,包括在反序列化时使用默认值作为占位符:

implicit def jsonFormat = Json.using[Json.WithDefaultValues].format[Foo]

对于低于 2.6 的游戏,最好的选择仍然是使用以下选项之一:

play-json-extra

我发现了一个更好的解决方案,可以解决我使用 play-json 的大部分缺点,包括问题中的那个:

play-json-extra 在内部使用 [play-json-extensions] 来解决此问题中的特定问题。

它包含一个宏,该宏将自动包含序列化器/反序列化器中缺少的默认值,从而使重构更不容易出错!

import play.json.extra.Jsonx
implicit def jsonFormat = Jsonx.formatCaseClass[Foo]

您可能想查看更多内容:play-json-extra

Json 转换器

我目前的解决方案是创建一个 JSON 转换器并将其与宏生成的读取结合起来。变压器是通过以下方法生成的:

object JsonExtensions{
  def withDefault[A](key:String, default:A)(implicit writes:Writes[A]) = __.json.update((__ \ key).json.copyFrom((__ \ key).json.pick orElse Reads.pure(Json.toJson(default))))
}

然后格式定义变成:

implicit val fooformats: Format[Foo] = new Format[Foo]{
  import JsonExtensions._
  val base = Json.format[Foo]
  def reads(json: JsValue): JsResult[Foo] = base.compose(withDefault("status","bidon")).reads(json)
  def writes(o: Foo): JsValue = base.writes(o)
}

Json.parse("""{"id":"1", "value":"foo"}""").validate[Foo]

确实会生成一个应用了默认值的 Foo 实例。

我认为这有两个主要缺陷:

  • 默认键名在字符串中,不会被重构提取
  • 默认值是重复的,如果在一处更改,则需要在另一处手动更改

【讨论】:

  • 我现在已经接受了我自己的答案,对于试图回答初始要求的解决方案的问题,我试图解释为什么它不适合。如果提出更好的答案,我将相应地移动接受的答案。
  • 就我而言,该字段始终相同 (createdAt)。在这种情况下,第一个缺陷会稍微缓解:gist.github.com/felipehummel/5569dd69038142ce3bb5
  • play-json-extra 的链接现在已断开
  • 确实,我已经更新了 github 上 doc 文件夹根目录的链接 github.com/aparo/play-json-extra/tree/master/doc
【解决方案2】:

这可能无法满足“尽可能少的噪音”要求,但为什么不将新参数作为Option[String] 引入呢?

case class Foo(id:String,value:String, status:Option[String] = Some("pending"))

从旧客户端读取Foo 时,您将获得None,然后我将在您的消费者代码中处理(使用getOrElse)。

或者,如果您不喜欢这个,请介绍BackwardsCompatibleFoo

case class BackwardsCompatibleFoo(id:String,value:String, status:Option[String] = "pending")
case class Foo(id:String,value:String, status: String = "pending")

然后将其转换为Foo 以进一步处理,避免在代码中一直处理这种数据体操。

【讨论】:

  • 选项的问题是我必须映射或获取并且通常使用单子操作来访问一个 not 可选的值,只是默认值。就打字而言,使用一个选项会引入一个错误信号,只是为了匹配库的当前实现。
  • 是的,但是您提到您的要求已经改变,因此从“客户端”(在本例中为数据存储)的角度来看,该值是可选的(它是模式的新版本, 可以这么说)。在这种情况下,我建议要么迁移存储中的数据(在缺少的地方设置默认值),要么采用一种机制来处理数据存储中数据的优雅向后兼容性
  • 完全正确:我正在尝试建立一种机制,通过提供可接受的默认值来优雅地处理与存储中的数据(或尚未更新的客户端)的向后兼容性。这样,当我读取然后将我的数据写回存储时,它会自动更新,并且我的系统不会因老客户而爆炸。当没有合理的默认值时,我显然会使用 Option,但在很多情况下我可以提供默认值(就像我在案例类中所做的那样)
【解决方案3】:

我刚刚遇到了这样一种情况,我希望 所有 JSON 字段是可选的(即在用户端是可选的),但在内部我希望所有字段都是非可选的,并且在如果用户没有指定某个字段。这应该与您的用例类似。

我目前正在考虑使用完全可选参数简单地包装 Foo 的构造的方法:

case class Foo(id: Int, value: String, status: String)

object FooBuilder {
  def apply(id: Option[Int], value: Option[String], status: Option[String]) = Foo(
    id     getOrElse 0, 
    value  getOrElse "nothing", 
    status getOrElse "pending"
  )
  val fooReader: Reads[Foo] = (
    (__ \ "id").readNullable[Int] and
    (__ \ "value").readNullable[String] and
    (__ \ "status").readNullable[String]
  )(FooBuilder.apply _)
}

implicit val fooReader = FooBuilder.fooReader
val foo = Json.parse("""{"id": 1, "value": "foo"}""")
              .validate[Foo]
              .get // returns Foo(1, "foo", "pending")

不幸的是,它需要写明确的Reads[Foo]Writes[Foo],这可能是你想要避免的?另一个缺点是只有在缺少键或值为null 时才会使用默认值。但是,如果键包含错误类型的值,则整个验证再次返回ValidationError

嵌套这样的可选 JSON 结构不是问题,例如:

case class Bar(id1: Int, id2: Int)

object BarBuilder {
  def apply(id1: Option[Int], id2: Option[Int]) = Bar(
    id1     getOrElse 0, 
    id2     getOrElse 0 
  )
  val reader: Reads[Bar] = (
    (__ \ "id1").readNullable[Int] and
    (__ \ "id2").readNullable[Int]
  )(BarBuilder.apply _)
  val writer: Writes[Bar] = (
    (__ \ "id1").write[Int] and
    (__ \ "id2").write[Int]
  )(unlift(Bar.unapply))
}

case class Foo(id: Int, value: String, status: String, bar: Bar)

object FooBuilder {
  implicit val barReader = BarBuilder.reader
  implicit val barWriter = BarBuilder.writer
  def apply(id: Option[Int], value: Option[String], status: Option[String], bar: Option[Bar]) = Foo(
    id     getOrElse 0, 
    value  getOrElse "nothing", 
    status getOrElse "pending",
    bar    getOrElse BarBuilder.apply(None, None)
  )
  val reader: Reads[Foo] = (
    (__ \ "id").readNullable[Int] and
    (__ \ "value").readNullable[String] and
    (__ \ "status").readNullable[String] and
    (__ \ "bar").readNullable[Bar]
  )(FooBuilder.apply _)
  val writer: Writes[Foo] = (
    (__ \ "id").write[Int] and
    (__ \ "value").write[String] and
    (__ \ "status").write[String] and
    (__ \ "bar").write[Bar]
  )(unlift(Foo.unapply))
}

【讨论】:

    【解决方案4】:

    另一种解决方案是使用 formatNullable[T] 与来自 InvariantFunctorinmap 结合使用。

    import play.api.libs.functional.syntax._
    import play.api.libs.json._
    
    implicit val fooFormats = 
      ((__ \ "id").format[Int] ~
       (__ \ "value").format[String] ~
       (__ \ "status").formatNullable[String].inmap[String](_.getOrElse("pending"), Some(_))
      )(Foo.apply, unlift(Foo.unapply))
    

    【讨论】:

    • 我采用了这种方法;它可以工作,而且比当前接受的答案更干净,但不幸的是,它有一个缺点,即必须在两个地方(案例类和读取定义)指定类型。
    • inmap 方法很有趣,我将不得不看看是否有一种实用的方法来使用它,它不涉及手动编写整个映射。就这个问题中的 Foo 来说,这很容易,但想象一下 foo 有 15 个属性......
    • 同意@StevenBakhtiari - 我一直在使用这种方法,它非常有用,不仅可以用于默认值,还可以对从 JsPath 返回的值进行其他操作。
    【解决方案5】:

    我发现的最简洁的方法是使用“或纯”,例如,

    ...      
    ((JsPath \ "notes").read[String] or Reads.pure("")) and
    ((JsPath \ "title").read[String] or Reads.pure("")) and
    ...
    

    当默认值是常量时,这可以以正常的隐式方式使用。当它是动态的时,你需要编写一个方法来创建读取,然后在作用域内引入它,a la

    implicit val packageReader = makeJsonReads(jobId, url)
    

    【讨论】:

    • 代码示例意味着您手动编写映射,我更喜欢使用适当的默认值来增强基于宏的映射的方法,特别是对于“更大”的案例类。除此之外,我自己的解决方案也使用了 json 转换器中的 Reads.pure 来设置默认值。
    • @Jean - 是的,我正在使用“脏”json,其中宏读取非常无用 - 错误的字段名称,需要额外的处理等。如果你在哪里,我可以看到控制 JSON 格式和质量,使用它会更有意义。
    • 这个用例与我在最初的问题中描述的相差甚远 :)
    • 这对我帮助很大。
    【解决方案6】:

    您可以将状态定义为选项

    case class Foo(id:String, value:String, status: Option[String])
    

    像这样使用 JsPath:

    (JsPath \ "gender").readNullable[String]
    

    【讨论】:

    • 除非我不想有选项,我想有一个值,如果没有提供就提供一个默认值。 Option[String] 和 String 的语义肯定不一样。
    【解决方案7】:

    我认为现在官方的答案应该是使用 Play Json 2.6 中的 WithDefaultValues:

    implicit def jsonFormat = Json.using[Json.WithDefaultValues].format[Foo]
    

    编辑:

    请务必注意,该行为与 play-json-extra 库不同。例如,如果您有一个 DateTime 参数,其默认值为 DateTime.Now,那么您现在将获得进程的启动时间 - 可能不是您想要的 - 而使用 play-json-extra 您有创建时间来自 JSON。

    【讨论】:

    • 我也确认这在 Play 2.6 中有效,无需添加额外的依赖项。我尝试添加 play-json-extra 但我遇到了一些与 sbt 中的依赖项不兼容有关的问题。