Play 2 JSON格式中缺失属性的默认值

37

我在play 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"}

但是从中读取会因为缺少“/status”路径而导致JsError。

我怎样才能提供一个最少噪音的默认值?

(附注:我有一个答案,我将在下面发布,但我并不真正满意它,我将点赞并接受更好的选项)


嗨,Jean。我一直在研究这个问题,并找到了这个。除了围绕它谈论之外,似乎没有任何活动。你有比转换器更优雅的解决方案吗? - Yar
我现在还在使用transformers。在我有足够的时间研究如何编写自己的宏之前,我想那就是我能使用的全部了:( - Jean
7个回答

42

Play 2.6+

根据@CanardMoussant的回答,从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转换器并将其与宏生成的Reads组合在一起。该转换器由以下方法生成:

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实例。

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

  • 默认值的键名是一个字符串,不会被重构捕捉到
  • 默认值的数值是重复的,如果在一个地方更改,则需要手动更改另一个地方

我现在已经接受了自己的答案,并且对于那些有解决方案的问题,我会尝试回答最初的要求,并解释为什么它不适用。如果有更好的答案被提出,我会相应地移动已接受的答案。 - Jean
在我的情况下,字段始终相同(createdAt)。在这种情况下,第一个缺陷略有减轻:https://gist.github.com/felipehummel/5569dd69038142ce3bb5 - Felipe Hummel
play-json-extra的链接现在已经失效了 - Thilo
实际上,我已经更新了链接到Github上doc文件夹的根目录 https://github.com/aparo/play-json-extra/tree/master/doc - Jean

21

我发现最干净的方法是使用“or pure”,例如,

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

当默认值是一个常量时,可以以正常的隐式方式使用它。当它是动态的时,你需要编写一个方法来创建Reads,然后将其引入范围内,类似于

implicit val packageReader = makeJsonReads(jobId, url)

代码示例意味着您需要手动编写映射,我更喜欢一种方法来增强基于宏的映射与适当的默认值,特别是对于“较大”的案例类。除此之外,我的解决方案还使用Reads.pure在JSON转换器中设置默认值。 - Jean
@Jean - 是的,我正在处理“脏”JSON数据,其中宏读取功能几乎没有用处 - 字段名称不好,需要进行额外的处理等。如果您能控制JSON格式和质量,那么使用它会更有意义。 - Ed Staub
这个用例跟我最初的问题描述相差甚远 :) - Jean
这对我帮助很大。 - milos.ai

17

另一种解决方案是使用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))

我采用了这种方法,它能够正常工作,而且比目前被接受的答案更为简洁。但不幸的是,它与其中一个缺点相同,即需要在两个地方指定类型(即 case class 和 reads 定义)。 - Steven Bakhtiari
1
inmap方法很有趣,我得看看是否有实用的方法来使用它,而不需要手动编写整个映射。在这个问题中,对于Foo来说很容易,但是想象一下Foo有15个属性... - Jean
同意 @StevenBakhtiari 的说法 - 我一直在使用这种方法,它非常有用,不仅可以为默认值提供便利,还可以对从 JsPath 返回的值执行其他操作。 - DemetriKots

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中依赖项不兼容的问题。 - Robert Gabriel

2

我刚刚遇到了这样一个情况,我希望所有的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))
}

1

这可能不能满足“最小噪音”的要求,但为什么不将新参数引入为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以便进一步处理,避免在代码中一直处理此类数据体操。

2
使用“Option”存在的问题是我必须进行映射或获取,并通常使用单子操作来访问一个值,这个值是_不_可选的,只是默认值。在那里使用“Option”会在类型上引入错误信号,只是为了匹配库的当前实现。 - Jean
真的,但是您提到您的需求已经改变了,所以从“客户端”的角度来看(在这种情况下,数据存储),该值是可选的(可以说是模式的新版本)。在这种情况下,我建议要么迁移存储中的数据(在缺失默认值的任何地方设置默认值),要么使用一种机制来处理数据存储中的优雅向后兼容性。 - Manuel Bernhardt
1
我试图建立一种机制,以优雅地处理存储中的向后兼容性(或者对于尚未更新的客户端),通过提供可接受的默认值来实现。这样当我读取并将数据写回存储时,它会自动更新,我的系统不会因旧的客户端而爆炸。当没有合理的默认值时,我显然使用Option,但在许多情况下,我可以提供默认值(如在case类中所做的那样)。 - Jean

1
你可以将状态定义为选项。
case class Foo(id:String, value:String, status: Option[String])

使用 JsPath 的方法如下:
(JsPath \ "gender").readNullable[String]

1
除非我不想要一个选项,我想要一个值,如果没有提供默认值。Option [String]和String的语义绝对不相同。 - Jean

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接