如何在Scala Play中解析具有变量键的JSON?

7

首先,新年快乐!

我在Play框架中遇到了一些解析JSON格式的问题,我所需要处理的格式如下:

JSON Response:

 ...
"image":{  
    "large":{  
      "path":"http://url.jpg",
      "width":300,
      "height":200
    },
    "medium":{  
      "path":"http://url.jpg",
      "width":200,
      "height":133
    },
    ...
 }
...

我遇到了一个与大小有关的问题。它们显然是变量,我不确定如何为此编写格式化程序?JSON数据来自外部服务。

到目前为止,我的做法是:

final case class Foo(
  ..
  ..
  image: Option[Image])


final case class Image(size: List[Size])

final case class Size(path: String, width: Int, height: Int)

对于我刚才所做的格式化,我只是对所有类执行了Json.reads[x]。然而,我相当确定大小变量正在破坏格式,因为它无法从传入的JSON创建图像对象。


您好,我不认为您可以使用“reads”宏来完成此操作。您是否想保留尺寸描述符(如大号、中号等)? - Michael Zajac
我知道有11个尺寸描述符及其值。是否有一种方法可以将它们放入某个Seq(size1, size2, ..., sizeN) -> imageDetails或类似的东西中? - goralph
把描述符加到 Size 类里怎么样?Size(name: String, path: String, width: Int, height: Int),然后读入列表中?Image(..., sizes: List[Size]) - Michael Zajac
抱歉,是的,我可以这样建模。我只是不能更改我收到的JSON。谢谢。 - goralph
请看我的答案,其中包括play.api.libs.json.Format值的读取和写入示例。 - isomarcte
显示剩余2条评论
3个回答

8

更新于2016年7月28日

下面描述的解决方案由于使用了return关键字,破坏了引用透明性,因此并不是我今天推荐的内容。然而,出于历史原因,我不会将其删除。

介绍

这里的问题是,您需要找到一个地方来保存Image对象中每个Size对象的键。有两种方法可以做到这一点,一种是将其保存在Size对象本身中。这是有道理的,因为名称与Size对象密切相关,并且在那里存储很方便。因此,让我们首先探索这种解决方案。

关于对称性的简短说明

在我们深入任何解决方案之前,让我先介绍一下对称性的概念。这意味着当您阅读任何Json值时,您可以使用Scala模型表示返回到完全相同的 Json值。

处理编组数据时对称性并不是严格要求的,事实上有时可能不可能实现,或者强制实现会太昂贵而没有真正的收益。但通常情况下,它相当容易实现,并且使得使用序列化实现更加舒适。在许多情况下,它也是必需的。

name保存在Size

import play.api.libs.json.Format
import play.api.libs.json.JsPath
import play.api.libs.json.Reads
import play.api.libs.json.JsValue
import play.api.libs.json.JsResult
import play.api.libs.json.JsSuccess
import play.api.libs.json.JsError
import play.api.libs.json.JsObject
import play.api.libs.json.Json

final case class Foo(images: Option[Image])

object Foo {
  implicit val fooFormat: Format[Foo] = Json.format[Foo]
}

final case class Image(sizes: Seq[Size])

object Image {

  implicit val imagesFormat: Format[Image] =
    new Format[Image] {

      /** @inheritdoc */
      override def reads(json: JsValue): JsResult[Image] = json match {
        case j: JsObject => {
          JsSuccess(Image(j.fields.map{
            case (name, size: JsObject) =>
              if(size.keys.size == 3){
                val valueMap = size.value
                valueMap.get("path").flatMap(_.asOpt[String]).flatMap(
                  p=> valueMap.get("height").flatMap(_.asOpt[Int]).flatMap(
                    h => valueMap.get("width").flatMap(_.asOpt[Int]).flatMap(
                      w => Some(Size(name, p, h, w))
                    ))) match {
                  case Some(value) => value
                  case None => return JsError("Invalid input")
                }
              } else {
                  return JsError("Invalid keys on object")
              }
            case _ =>
              return JsError("Invalid JSON Type")
          }))
        }
        case _ => JsError("Invalid Image")
      }

      /** @inheritdoc */
      override def writes(o: Image): JsValue = {
        JsObject(o.sizes.map((s: Size) =>
          (s.name ->
            Json.obj(
              ("path" -> s.path),
              ("height" -> s.height),
              ("width" -> s.width)))))
      }
    }

}

final case class Size(name: String, path: String, height: Int, width: Int)

在这个解决方案中,Size 没有任何直接的 Json 序列化或反序列化,而是作为 Image 对象的一个产品。这是因为,为了使 Image 对象具备对称序列化,您需要保留不仅是 Size 对象的参数,即路径、高度和宽度,还需要将 Sizename 指定为 Image 对象上的键。如果您不保存这些信息,则无法自由地来回转换。

因此,如下所示,这个方案可以正常工作:

scala> import play.api.libs.json.Json
import play.api.libs.json.Json

scala> Json.parse("""
     | {  
     |     "large":{  
     |       "path":"http://url.jpg",
     |       "width":300,
     |       "height":200
     |     },
     |     "medium":{  
     |       "path":"http://url.jpg",
     |       "width":200,
     |       "height":133
     |     }
     | }""")
res0: play.api.libs.json.JsValue = {"large":{"path":"http://url.jpg","width":300,"height":200},"medium":{"path":"http://url.jpg","width":200,"height":133}}

scala> res0.validate[Image]
res1: play.api.libs.json.JsResult[Image] = JsSuccess(Image(ListBuffer(Size(large,http://url.jpg,200,300), Size(medium,http://url.jpg,133,200))),)

scala> 

非常重要的是,它既安全对称

scala> Json.toJson(res0.validate[Image].get)
res4: play.api.libs.json.JsValue = {"large":{"path":"http://url.jpg","height":200,"width":300},"medium":{"path":"http://url.jpg","height":133,"width":200}}

scala> 

关于安全的快速说明

在生产代码中,您永远不要使用.as[T]方法处理JsValue。这是因为如果数据与您的预期不符,它会崩溃而没有任何有意义的错误处理。如果必须使用,则应该使用.asOpt[T],但通常更好的选择是.validate[T],因为它会在失败时产生某种形式的错误,您可以记录并向用户报告。

可能更好的解决方案

现在,可能更好的解决方案是将Image类声明更改为以下内容:

final case class Image(s: Seq[(String, Size)])

然后将 Size 保留为原来的样子,
final case class Size(path: String, height: Int, width: Int)

为了保险起见,使其对称,你只需要执行以下操作。

如果我们这样做,则实现会更好,同时仍然保持安全和对称。

import play.api.libs.json.Format
import play.api.libs.json.JsPath
import play.api.libs.json.Reads
import play.api.libs.json.JsValue
import play.api.libs.json.JsResult
import play.api.libs.json.JsSuccess
import play.api.libs.json.JsError
import play.api.libs.json.JsObject
import play.api.libs.json.Json

final case class Foo(images: Option[Image])

object Foo {
  implicit val fooFormat: Format[Foo] = Json.format[Foo]
}

final case class Image(sizes: Seq[(String, Size)])

object Image {

  implicit val imagesFormat: Format[Image] =
    new Format[Image] {

      /** @inheritdoc */
      override def reads(json: JsValue): JsResult[Image] = json match {
        case j: JsObject =>
          JsSuccess(Image(j.fields.map{
            case (name, size) =>
              size.validate[Size] match {
                case JsSuccess(validSize, _) => (name, validSize)
                case e: JsError => return e
              }
          }))
        case _ =>
          JsError("Invalid JSON type")
      }

      /** @inheritdoc */
      override def writes(o: Image): JsValue = Json.toJson(o.sizes.toMap)
    }
}

final case class Size(path: String, height: Int, width: Int)

object Size {
  implicit val sizeFormat: Format[Size] = Json.format[Size]
}

仍然像以前一样工作

scala> Json.parse("""
     | {
     | "large":{  
     |       "path":"http://url.jpg",
     |       "width":300,
     |       "height":200
     |     },
     |     "medium":{  
     |       "path":"http://url.jpg",
     |       "width":200,
     |       "height":133}}""")
res1: play.api.libs.json.JsValue = {"large":{"path":"http://url.jpg","width":300,"height":200},"medium":{"path":"http://url.jpg","width":200,"height":133}}

scala> res1.validate[Image]
res2: play.api.libs.json.JsResult[Image] = JsSuccess(Image(ListBuffer((large,Size(http://url.jpg,200,300)), (medium,Size(http://url.jpg,133,200)))),)

scala> Json.toJson(res1.validate[Image].get)
res3: play.api.libs.json.JsValue = {"large":{"path":"http://url.jpg","height":200,"width":300},"medium":{"path":"http://url.jpg","height":133,"width":200}}

但是有了“Size”的好处,现在它反映了真正的Json,即您可以序列化和反序列化只有“Size”值。这使得它更易于使用和思考。
第一个示例中对“读取”(reads)的简短评论
尽管我认为第一种解决方案略逊于第二种解决方案,但我们在第一次实现“读取”(reads)时使用了一些有趣的习语,这些习语在更一般的情况下非常有用,但常常不为人所理解。因此,我想花时间详细介绍它们,供那些感兴趣的人参考。如果您已经了解正在使用的习语,或者您只是不在乎,可以随意跳过本讨论。
flatMap链接
当我们试图从“valueMap”中获取所需的值时,在任何步骤中都可能出现问题。我们希望合理地处理这些情况,而不会抛出灾难性的异常。
为了实现这一点,我们使用 Option 值和常见的 flatMap 函数来链接我们的计算。对于每个所需值,我们实际上需要进行两个步骤:从 valueMap 中获取该值,并使用 asOpt[T] 函数将其强制转换为适当的类型。现在好处是,valueMap.get(s: String)jsValue.asOpt[T] 都返回 Option 值。这意味着我们可以使用 flatMap 来构建最终结果。 flatMap 具有很好的属性,即如果 flatMap 链中的任何步骤失败(即返回 None),则不运行所有其他步骤,并将最终结果作为 None 返回。
这种习惯用法是通用的 单子 编程的一部分,它在函数式语言中很常见,特别是在 Haskell 和 Scala 中。在 Scala 中,它通常不被称为 单子,因为当概念在 Haskell 中引入时,经常解释不清楚,导致许多人不喜欢它,尽管它实际上非常有用。由于这个原因,人们经常害怕在 Scala 中使用 "M 词"。

函数短路

在Scala的reads中,另一个惯用语是通过使用return关键字来短路函数调用。

正如您可能知道的那样,在Scala中通常不鼓励使用return关键字,因为任何函数的最终值都会自动成为该函数的返回值。然而,有一种非常有用的情况可以使用return关键字,即当您调用代表对某些内容进行重复调用的函数时,例如map函数。如果您在其中一个输入上遇到了终止条件,您可以使用return关键字停止执行剩余元素上的map调用。这在某种程度上类似于在像Java这样的语言中使用for循环中的break

在我们的情况下,我们希望确保Json中的元素具有正确的键和类型,并且如果我们的任何假设不正确,我们希望返回正确的错误信息。现在,我们可以只对Json中的字段进行map操作,然后在map操作完成后检查结果,但是考虑一下,如果有人向我们发送了包含数千个不符合我们要求结构的键的非常大的 Json,即使我们在第一次应用后就知道了错误,我们仍必须将函数应用于所有值。使用return,我们可以在发现错误时立即结束map应用程序,而无需花费时间将map应用程序应用于已知结果的其余元素。无论如何,我希望这段迂腐的解释对您有所帮助!

嘿,非常感谢你的回答。很清晰明了。还要感谢你关于“validate”和不使用“as”的提示,我之前没有想到过。今天我要关闭电脑了,但明天会再留言的。非常感激。 - goralph

5
假设您想反序列化到以下案例类:
case class Size(name: String, path: String, width: Int, height: Int)
case class Image(sizes: List[Size])
case class Foo(..., image: Option[Image])

有许多方法可以通过自定义Reads实现来使其工作。 我将使用reads宏来处理Size

implicit val sizeReads = Json.reads[Size]

然后,由于尺寸不是image对象内的实际数组,我将把它们合并成一个数组,以利用我已经拥有的Reads[Size]。我可以将作为Image验证的给定JsValue转换为JsObject。然后,我可以从JsObject中获取fields,这将是一个Seq[(String, JsValue)]。在这种情况下,String是尺寸描述符,而JsValue是包含该尺寸所有值的对象。我将它们合并成一个单一的对象,并将Seq制作成JsArray
从那里,我所需要做的就是将JsArray验证为List[Size],并将其映射到Image
implicit val imageReads = new Reads[Image] {
    def reads(js: JsValue): JsResult[Image] = {
        val fields: Seq[JsValue] = js.as[JsObject].fields.map { case (name, values) =>
            Json.obj("name" -> name) ++ values.as[JsObject]
        }

        JsArray(fields).validate[List[Size]].map(Image(_))
    }
}

那么Foo也可以使用reads宏。

implicit val fooReads = Json.reads[Foo]

例子:

case class Foo(something: String, image: Option[Image])

val json = Json.parse("""{
    "something":"test",
    "image":{  
        "large":{  
            "path":"http://url.jpg",
            "width":300,
            "height":200
        },
        "medium":{  
            "path":"http://url.jpg",
            "width":200,
            "height":133
        }
    }
}""")

scala> json.validate[Foo]
res19: play.api.libs.json.JsResult[Foo] = JsSuccess(Foo(test,Some(Image(List(Size(large,http://url.jpg,300,200), Size(medium,http://url.jpg,200,133))))),)

如果你利用Json.obj来模拟你想要的JSON输出的结构,实现一个Writes[Image]会更容易一些。由于输出的JSON实际上并没有使用数组,因此我们还需要将尺寸列表合并回单个对象,这可以使用foldLeft来完成。

implicit val writes = new Writes[Image] {
    def writes(img: Image): JsValue = {
        img.sizes.foldLeft(new JsObject(Nil)) { case (obj, size) =>
            obj ++ Json.obj(
                size.name -> Json.obj(
                    "path" -> size.path,
                    "width" -> size.width,
                    "height" -> size.height
                )
            )
        }
    }
}

嘿,这是一个很棒的答案,让我感到非常有道理。我刚编译了一下,发现我还需要一个写入器,因为在 class Foo 中我有 (__ \ 'image).format[Option[Image],所以我也需要能够将对象写入 JSON。我编辑了我的问题,包括我的尝试,但它没有起作用,你对此有什么建议吗?再次感谢您的出色回答。 - goralph

0

也许在基本类型中更常见。我们只需要定义两个类:

final case class Size(path: String, width: Int, height: Int)
final case class Image(image: Map[String, Size])

implicit val sizeFormat: Format[Size] = Json.format[Size]
implicit val imageFormat: Format[Image] = Json.format[Image]

然后,运行一个示例:

val json: JsValue = Json.parse("""
{
  "image":{  
    "large":{  
      "path":"http://url.jpg",
      "width":300,
      "height":200
    },
    "medium":{  
      "path":"http://url.jpg",
      "width":200,
      "height":133
    }
  }
}
""")

json.validate[Image]

你可以获得

scala> json.validate[Image]
res13: play.api.libs.json.JsResult[Image] = JsSuccess(Image(Map(large -> Size(http://url.jpg,300,200), medium -> Size(http://url.jpg,200,133))),)

scala> json.validate[Image].get.image
res14: Map[String,Size] = Map(large -> Size(http://url.jpg,300,200), medium -> Size(http://url.jpg,200,133))

scala> json.validate[Image].get.image("large")
res15: Size = Size(http://url.jpg,300,200)

scala> json.validate[Image].get.image("large").path
res16: String = http://url.jpg

你也可以写成:

scala> json.validate[Image].get
res18: Image = Image(Map(large -> Size(http://url.jpg,300,200), medium -> Size(http://url.jpg,200,133)))

scala> Json.toJson(json.validate[Image].get)
res19: play.api.libs.json.JsValue = {"image":{"large":{"path":"http://url.jpg","width":300,"height":200},"medium":{"path":"http://url.jpg","width":200,"height":133}}}

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