19. Play 框架和 Web 服务

本章的各小节展示了如何使用Scala开发Web服务,包括处理服务器端的HTTP请求,在JSON和Scala对象之间进行转换,以及编写客户端的HTTP请求等比较常用的场景。

时至2021年,Scala生态中有一些很棒的库可以进行服务器端开发,在Awesome Scala列表( https://oreil.ly/rC08K )中可以找到这些库。本章重点介绍Play框架(Play),因为它很流行,支持度高,而且相对容易上手,特别是如果开发人员以前使用过Ruby on Rails这一类的框架的话就更简单了。

注意,在本书编写之时,Play还没有更新到支持Scala 3。因此,在本章中看到的Play示例使用的是Scala 2语法。也就是说,Play的API已经相当稳定,可以追溯到2013年和Scala Cookbook第一版的发布,因此,当Play支持Scala 3时,这些例子有望很好地转化为支持Scala 3。

本章的前几个小节侧重于使用 Play 的服务器端开发。这些小节包括:

  • 小节19.1,创建第一个Play项目

  • 小节19.2,创建一个新的端点,即一个服务器端REST服务的URL

  • 小节19.3,从Play GET请求返回一个JSON

  • 小节19.4,将Scala对象转换为JSON

  • 小节19.5,将JSON转换为Scala对象

然后,最后两个小节展示了可用于服务器端或客户端开发的技术:

  • 小节19.6,在Play框架外使用Play JSON库

  • 小节19.7,使用sttp HTTP客户端

19.1 创建一个Play框架项目

问题

这一章中,大部分小节都使用Play 框架(Play)做范例,如果你之前没用过Play,可能需要知道如何创建一个新的Play项目。

解决方案

创建一个新的Play项目的最简单方法是使用sbt种子模板:

    $ sbt new playframework/play-scala-seed.g8

当该命令运行时,只需要给它一个项目名称和组织名称,然后 cd 进入新的目录,如下所示:

    $ sbt new playframework/play-scala-seed.g8 
    [info] Set current project to play ...
    This template generates a Play Scala project

    name [play-scala-seed]: hello-world 
    organization [com.example]: com.alvinalexander

    Template applied in hello-world

    $ cd hello-world

然后在该项目目录下,运行 sbt run 命令:

    $ sbt run 
    // a LOT of output here ...
    [info] loading settings for project hello-world-build from plugins.sbt ... 
    [info] loading settings for project root from build.sbt ...
    [info] set current project to hello-world ...

    --- (Running the application, auto-reloading is enabled) --
    [info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000 
    (Server started, use Enter to stop and go back to the console...)

第一次运行该命令时,会有很多输出,如果以最后三行结束,这表明Play服务器在9000端口运行。

现在,当在浏览器中打开http://localhost:9000的URL时,会看到一个 "Welcome to Play"的网页,看起来像图19-1中的页面那样。

f19-1

图19-1,"欢迎来使用Play!"欢迎页

如果需要在9000以外的端口运行Play,请在你的操作系统命令行中使用这个命令:

    $ sbt "run 8080"

或者在sbt shell中使用如下命令:

    sbt> run 8080

讨论

一个Play应用程序由以下组件组成:

  • sbt build.sbt 文件,包含应用程序的依赖和其他配置信息。

  • 放在 app/controllers 文件夹中的controller。

  • 在app/models文件夹中的模型。这个文件夹通常不会自动创建。

  • 包含HTML、JavaScript、CSS和Scala代码片段的模板被放在 app/views 文件夹中。

  • 一个 conf/routes 文件,里面有将URI和HTTP方法映射到controller方法。

其他重要的文件包括:

  • conf/application.conf 文件中的应用程序配置信息。这里包括关于如何访问数据库的信息。

  • conf/evolutions 文件夹中的数据库演化脚本。

  • 模板文件的设计资源被放在 public/imagespublic/javascriptspublic/stylesheets 文件夹中。

因为本章是关于构建Web服务的,而不是Web 1.0应用程序,所以主要关注的是这些目录和文件:

  • conf/routes 路由文件

  • app/controllers 自定义controller的

conf/routes 文件

要了解项目中的文件,首先看conf/routes文件。当前的Play 2.8模板创建了一个具有以下内容的文件:

    # Routes 
    # This file defines all application routes (Higher priority routes first) 
    # https://www.playframework.com/documentation/latest/ScalaRouting

    # An example controller showing a sample home page 
    GET / controllers.HomeController.index

    # Map static resources from the /public folder to the /assets URL path 
    GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)

想了解欢迎页面是如何显示的,则关注该文件中的这一行:

    GET / controllers.HomeController.index

这一行可以理解为:“当以HTTP GET方法请求/ URI上时,调用 controller 包中的 HomeController 类中定义的 index 方法”。如果使用过Ruby on Rails等其他框架,就见过这种东西。它将特定的HTTP方法(如GET或POST)和URI绑定到一个类中的方法。

关于路由的两件重要的事情是:

  • conf/routes 文件被编译了,所以可以在浏览器中直接看到路由错误。

  • 正如将在controller类代码中看到的,它使用了依赖注入。根据Play文档,“Play的默认路由生成器创建了一个路由器类,在 @Inject-annotated 构造函数中接受controller实例。这意味着该类适合使用依赖注入,也可以使用构造函数手动实例化。”

####controller

接下来,打开 app/controllers/HomeController.scala ,查看其 index 方法:

    package controllers
    import javax.inject._ 
    import play.api._ 
    import play.api.mvc._

    /**
     * This controller creates an `Action` to handle HTTP requests to the 
     * application's home page.
     */
    @Singleton 
    class HomeController @Inject()(val controllerComponents: ControllerComponents)          
    extends BaseController {

      /** 
       * Create an Action to render an HTML page.
       * 
       * The configuration in the `routes` file means that this method 
       * will be called when the application receives a `GET` request with 
       * a path of `/`.
       */
      def index() = Action { implicit request: Request[AnyContent] =>
        Ok(views.html.index()) 
      }
    }

这是一个普通的Scala源代码文件,有一个名为 index 的方法。这个方法通过调用一个名为 Ok 的方法实现一个Play Action ,并传入需要显示的内容。代码 views.html.index 是Play对 app/views/index.scala.html 模板文件的引用。Play架构的一个超棒的功能是,Play模板被编译为Scala函数,所以在这段代码中看到的是一个普通的函数调用:

    Ok(views.html.index())

这段代码就是调用 views.html 包中一个名为 index 的函数。

view 模版

在明白了模板会编译成一个普通的Scala函数之后,打开 app/views/ index.scala.html 模板文件,可以看到以下内容:

    @()

    @main("Welcome to Play") { 
      <h1>Welcome to Play!</h1> 
    }

注意这段代码的第一行

    @()

如果把模板看成一个函数,这就是函数的参数列表。在这个例子中,参数列表是空的,但如果这个模板有一个名字为 message 的字符串参数,这一行就会是这样的:

    @(message: String)

文件中的 @ 符号是Play模板文件中的一个特殊字符。它表示它后面的内容是一个Scala表达式。例如,在所示的这行代码中,@ 字符在函数参数列表之前。在第三行代码中,@ 字符在调用一个名为 main 的函数之前。注意在这行代码中,字符串 "Welcome to Play"被传递到 main 方法中。

可能细心的读者已经猜到了,虽然 main 看起来像一个函数,但它也是一个模板文件。当代码调用 main 时,它实际上调用了 app/views/main.scala.html 模板。下面是 main.scala.html 的默认源代码:

    @(title: String)(content: Html)

    <!DOCTYPE html>
    <html lang="en">
        <head>
            @* Here's where we render the page title `String`. *@ 
            <title>@title</title>
            <link rel="stylesheet" media="screen"
                href="@routes.Assets.versioned("stylesheets/main.css")">
            <link rel="shortcut icon" type="image/png"
                href="@routes.Assets.versioned("images/favicon.png")">
        </head> 
        <body>
            @* And here's where we render the `Html` object containing 
            * the page content. *@ 
            @content

        <script src="@routes.Assets.versioned("javascripts/main.js")"
            type="text/javascript"></script>
        </body>
    </html>

这个文件是项目的默认包装模板文件。如果其他每一个模板文件都以 index.scala.html 文件的方式调用 main ,那么可以放心,这些模板都会被这个相同的HTML、CSS和JavaScript包裹起来。因此,所有的页面都会有相同的视觉体验。

在本章的课程中,其实是不需要知道这些细节,但在这里展示这两个初始文件,为了让读者更清楚了解web应用在Play中的样子。

单页应用 —— TODO 鸟图

这里说的 “web应用”,指的是用HTML编写的应用——或者在这里的情景中,指的是支持HTML和Scala片段混合的模板系统——在HTML中添加JavaScript和CSS。对于用JavaScript编写的单页应用程序(SPA),想在服务器端使用Play,可以不用对Scala的模板系统有更多了解。

sbt/Play 控制台

当在开发一个应用程序时,在项目的根目录下启动sbt:

    $ sbt

在这里可以运行所有常规的sbt命令,也可以启动应用程序。如解决方案中所示,该命令在默认的9000端口上启动应用程序:

    [play01] $ run

    (lots of output here ...)
    --- (Running the application, auto-reloading is enabled) --
    [info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000 
    (Server started, use Enter to stop and go back to the console ...)

sbt输出的最后一行显示,可以按下Enter键停止服务器并返回到sbt提示。

正如在下面的一些小节中所示,也可以在sbt里面发出一个 console 命令:

    sbt> console 
    [info] Starting scala interpreter...
    Welcome to Scala 2.13 Type in expressions for evaluation. Or try :help.

    scala> _

这将启动一个Scala REPL,加载所有项目的依赖。在这里,可以测试Play JSON代码和其他Play代码,包括使用自定义类、模型等。

另见

  • 更多关于“开始使用”的信息,请参看Play Framework网站( https://www.playframework.com )。

  • 我的Play框架秘方可以在这( https://alvinalexander.com/scala/scala-cookbook-play-framework-recipes-pdf-ebook/ ) 免费下载,尽管它是为Play的早期版本写的

  • 更多关于如何使用sbt的例子,请参阅第17章。

  • 关于如何使用Scala.js创建单页应用程序的细节,请参阅21.3小节,“用Scala.js构建单页应用程序”。

19.2 创建新的Play框架端点

问题

当把Play作为一个单页面应用程序( https://oreil.ly/satko )的RESTful服务器时,你想要创建一个新的端点(在谈论REST服务时,这个术语也被称为URL、URI或路由)。

解决方案

在Play应用程序中创建一个新的端点涉及到创建一个新的路由,一个controller方法,通常是创建新的模型或将其添加到一个现有的模型。具体步骤如下:

  1. conf/routes 文件中创建一个新路由。

  2. 新的路由指向一个controller方法,所以要创建这个controller方法;如果controller类还不存在,也要创建这个controller类。

  3. controller方法通常需要一个模型类,所以需要视情况来创建该模型类。

  4. 测试新服务。

为了演示这个过程,这里将在前面的配方中创建的骨架项目上演示。在这个解决方案中,将创建新的代码,响应 /hello URI的GET请求。当收到该请求时,controller方法会返回一个响应为 text/plain 的字符串。

1. 创建一个新路由

首先,这里需要在 /hello 处理一个新的URI,所以在 conf/routes 文件中添加一个新的路由。因为这是一个HTTP GET请求,所以通过在文件末尾添加以下行来映射URI:

    # our app 
    GET /hello controllers.HelloController.sayHello

这里可以理解为:"当在 /hello URI上接收到一个GET请求时,调用controllers包中 HelloController 类的 sayHello 方法。"

2. 创建一个新的controller方法

接下来,在 app/controllers 目录下的 HelloController.scala 文件中创建 HelloController 类。在该类中添加以下代码:

    package controllers

    import javax.inject._
    import play.api._ 
    import play.api.mvc._ 
    import models._

    @Singleton 
    class HelloController @Inject()(val controllerComponents: ControllerComponents)
    extends BaseController {

        def sayHello() = Action { 
            Ok("Hello, world") 
        }
    }

当这个类中的 sayHello 方法被调用时,它将返回一个 Action 的实例,以 Ok 方法的实现它。这段代码在讨论中将有所解释。

3. 创建模型

在大多数实际情况中,会创建一个模型类和数据库相关的代码,但对于像这样的小例子来说,并不需要这样。

4. 测试服务

有了这些部件——因为这是一个简单的GET请求,不用向服务器传递cookies或头信息——可以直接在浏览器中测试。在的浏览器中输入 http://localhost:9000/hello URI,在那里应该看到打印出 Hello, world

也可以用 curl 这样的命令行工具来测试:

    $ curl --request GET http://localhost:9000/hello
    Hello, world

如果想看Play返回的HTTP 响应头,使用 curl --head 命令:

    $ curl --head http://localhost:9000/hello 
    HTTP/1.1 200 OK 
    Date: (the date and time are shown here) 
    Content-Type: text/plain; charset=UTF-8 Content-Length: 12 
    (other headers have been omitted)

注意,当一个 字符串 被传入 Ok 时,Http 响应的 Content-Type 被自动设置为 text/plain

讨论

当查看 sayHello 方法时,可以看到它返回一个Play Action

    def sayHello() = Action { 
        Ok("Hello, world") 
    }

Action 是一个处理请求并创建一个结果并发送到客户端的函数。更具体地说,Action 是一个接收请求并返回结果的函数。它的签名是这样的:

    play.api.mvc.Request => play.api.mvc.Result

在这个例子中,并不处理 Request ,而 Result 是返回给客户端的值。它代表创建的HTTP响应。

解释下Ok方法

作为 sayHello 方法的最后一行使用的 Ok 方法构建了一个“200 OK”的响应。在Action里面使用Ok,相当于写了这段更长的代码:

    def sayHello = Action {
      Result( 
        header = ResponseHeader(200, Map.empty), 
        body = HttpEntity.Strict( 
            ByteString("Hello, world"), 
            Some("text/plain") 
        ) 
      )
    }

除了 Ok ,其他常见的响应方法包括 ForbiddenNotFoundBadRequestMethodNotAllowedRedirect 。也可以像这样手动设置状态代码:

    Status(5150)("Van Halen status")

当访问端点时,该方法的输出结果看起来会像这样:

    $ curl http://localhost:9000/hello 
    Van Halen status

    $ curl --head http://localhost:9000/hello 
    HTTP/1.1 5150 
    (other output not shown)

所有可用的辅助方法都可以在 play.api.mvc.Results 的 Scaladoc 中找到。

这段代码响应了一个简单的GET请求

注意,所展示的代码响应的是一个简单的GET请求——它不期望从客户端获得任何参数。例如,如果用查询参数调用URL,Play会忽略它们。

    $ curl --request GET "http://localhost:9000/hello?x=1&y=2" 
    Hello, world

conf/routes 文件中配置服务可以处理哪些参数,由于这里没有声明 /hello URI需要任何参数,所以传递给它的参数被忽略了。

此外,如果试图使用其他方法来调用这个URI,如PUT、POST、DELETE或其他非GET方法,Play将抛出一个错误:

    $ curl --request POST http://localhost:9000/hello 
    (long error message here ...)

处理查询参数

要用GET请求处理查询参数,请遵循以下步骤:

1. 创建一个新路由。conf/routes 文件的末尾为新服务添加一行,定义它接受一个名为 name 的参数:

    GET /hi/:name controllers.HelloController.sayHi(name: String)

这一行可以理解为:“当一个GET请求到达 /hi URI上并带有 name 参数时,将这个 name 传递给 HelloControllersayHi 方法。”

2. 创建一个新的controller方法。 接下来,更新 HelloController 类。在原来的 sayHello方法之后添加这个新的 sayHi 方法:

    def sayHi(theName: String) = Action { 
        Ok(s"Hi, $theName") 
    }

注意,这个方法需要一个字符串参数,而且它不需要和 路由 文件中使用的名称相同。

3. 创建一个模型 这个例子中不需要创建模型,跳过。

4. 测试服务 通过在浏览器中输入这个URL或使用 curl 来测试这个新服务:

    $ curl --request GET http://localhost:9000/hi/Darja 
    Hi, Darja

用 Play 处理路由和查询参数的方法还有几种。详情请参见 Play Scala HTTP Routing 页面( https://oreil.ly/h5n6d )。

另见

  • 关于创建Action和controller的更多细节,请参见 Play Framework on actions, controllers, and result ( https://oreil.ly/FKnQR )的页面。

  • 关于定义路由的更多细节,请参见Play的Scala HTTP路由( https://www.playframework.com/documentation/2.8.x/ScalaRouting )页面。

19.3 让Play使用JSON作为GET请求的返回

问题

你想知道如何编写一个Play Framework controller方法,并使用JSON作为响应GET请求的返回。

解决方案

一般的解决方案是这样的:

  1. conf/routes 文件中添加一个条目来定义新的端点。

  2. 创建一个符合该文件描述的controller类和方法。

  3. 创建对应的模型,以及将该模型序列化为JSON的代码。

  4. 用浏览器,或像curl这样的工具来测试新服务。

前三个步骤可以按任何顺序进行。一般来说,要么从定义端点到模型,如上面所示,或者从定义模型到定义端点。

1.在routes文件中添加一个条目

按照上面所示的步骤,这个解决方案首先创建端点,然后创建完成该服务所需的一切。第一步是在 conf/routes 文件中添加下面代码,以创建该端点:

    GET   /movies   controllers.MovieController.getMovies

这可以理解为 “当在 /movies URI有一个GET请求时,调用 controllers 包中 MovieController 类中的 getMovies 方法“。

2 创建controller和方法

接下来,在 MovieController 类中创建 getMovies 方法。为了使这个方法最初尽可能的简单,代码将把一个字符串列表——电影的名字列表——转换成JSON,然后返回JSON。电影列表和JSON转换过程就在这个controller代码中:

    package controllers
    import javax.inject._ 
    import play.api.mvc._ 
    import play.api.libs.json._ 
    import models.Movie

    @Singleton 
    class MovieController @Inject()(val controllerComponents: ControllerComponents) 
    extends BaseController {

        /** 
         * Let Play convert the `List[String]` to JSON for you. 
         */ 
        def getMovies = Action {
            // these three steps are shown explicitly so you
            // can see the types:
            val goodMovies: Seq[String] = Movie.goodMovies()
            val json: JsValue = Json.toJson(goodMovies)
            Ok(json)
        }
    }

关于上面的代码的说明:

  • 这是一个新的controller,就像前面的小节中创建的一样。

  • Json.toJson 方法知道如何将一个 Seq[String] 转换为JSON。

  • Ok 方法构建了一个 “200 OK的响应”。

最后两点将在讨论中详细说明。

3.创建模型

现在要做的就是创建一个模型来匹配上面那段代码:

    package models

    object Movie { 
        def goodMovies(): Seq[String] = List( 
            "The Princess Bride", 
            "The Matrix", 
            "Firefly" 
        )
    }

实际上——在后面的小节中——模型将更加复杂。但是为了使这个例子尽可能的简单, goodMovies 被定义为一个返回 List[String] 的方法。(在实际工程中,goodMovies 会从数据库或其他数据存储中获取这些数据。)

4. 用浏览器或curl访问端点

对于这样一个没有向服务器传递任何自定义头信息的GET请求,可以直接用浏览器测试。只要在浏览器中访问 http://localhost:9000/movies ,应该可以看到这样的JSON输出:

    ["The Princess Bride","The Matrix","Firefly"]

也可以使用像 curl 这样的命令行工具:

    $ curl --request GET http://localhost:9000/movies
    ["The Princess Bride","The Matrix","Firefly"]

如果看到了上面那样的输出,那么就可以确认使用Play 创建了一个使用JSON作为输出的响应GET请求的Action。

讨论

这里来看看controller代码是如何工作的。

Json.toJson

Json.toJson 方法知道如何将一个字符串列表——电影字符串列表——转换成JSON:

    val json: JsValue = Json.toJson(goodMovies)

Scala 2中toJson方法是这样定义的:

    Json.toJson[T](T)(implicit writes: Writes[T])

如小节19.4所示, Writes 值是一个转换器,它知道如何将Scala各种类型转换为JSON。Play为 IntDoubleString 等基本类型预制了Writes转换器。当在导入 play.api.libs.json._ 包时,这些转换器会被导入到当前作用域中。

它对 集合 也有隐式的 Writes 实现,所以如果一个类型 AWrites[A] 转换器, Json.toJson 方法可以转换包含该类型 A 的集合。在这个例子中,Play提供了一个 Writes[String] 转换器,所以它也可以转换 Seq[String]

Ok方法

作为 getMovies 方法的最后一行, Ok 方法构建了一个 "200 OK"的响应。这相当于写了这样的代码:

    def index = Action {
      Result( 
        header = ResponseHeader(200, Map.empty), 
        body = HttpEntity.Strict(
            //JSON here ...
            Some("application/json")
        )
      )
    }

如上所示,当 Ok 返回一个 JsValue 对象时,它将HTTP 的 content-type 设置为“application/json”。可以在 curl 命令的输出中确认这一事实:

    $ curl -I http://localhost:9000/movies 
    HTTP/1.1 200 OK 
    Content-Type: application/json
    Content-Length: 45 
    (other output not shown)

Play JSON类型

像其他JSON框架一样,Play有对应于JSON类型的类型实现:

  • JsString

  • JsNumber

  • JsObject

  • JsNull

  • JsBoolean

  • JsArray

  • JsUndefined

这些类型中的每一个都是 JsValue 的一个子类型。注意,JsArray是一个序列,可以包含其他 JsValue 类型的异构或同构的集合。

这些类型在 play.api.libs.json 包中,可以通过导入 play.api.libs.json._ 然后在代码中使用它们。

如何在Scala REPL中使用Play/JSON

可以在Play/sbt REPL中测试这些类型是如何工作的。想要测试这些类型,首先得在Play项目的根目录下启动sbt:

    $ sbt

然后在sbt shell里面,使用 console 命令:

    sbt> console

这将启动一个Scala REPL。除了是一个普通的 REPL 之外,还可以访问 classpath 上项目里的所有类。所以可以像这样导入JSON类型:

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

然后可以运行各种小实验。这些例子展示了可以执行的一些表达式,以及它们的结果类型:

    JsString("hi")              // JsString = "hi"
    JsNumber(100)               // JsNumber = 100
    JsNumber(1.23)              // JsNumber = 1.23
    JsNumber(BigDecimal(1.23))  // JsNumber = 1.23
    JsBoolean(true)             // JsBoolean = true

    val x = Json.toJson(4)      // JsValue = 4
    val x = Json.toJson(false)  // JsValue = false

    // Sequences 
    Json.toJson(Seq("A", "B", "C"))    // JsValue = ["A","B","C"]
    JsArray(Array(Json.toJson(1)))     // JsArray = [1]
    JsArray(Seq(Json.toJson("Hi")))    // JsArray = ["Hi"]
    JsArray(Array(1))                  // does not compile

    // Map
    val map = Map("1" -> "a", "2" -> "b")
    Json.toJson(map)  // JsValue = {"1":"a","2":"b"}

    // Some
    val number = Json.toJson(Some(100))   // JsValue = 100
    val number = Json.toJson(Some("Hi"))  // JsValue = "Hi"

    // None 
    val x: Option[Int] = None 
    val number = Json.toJson(x)           // JsValue = null

另见

  • play.api.libs.json Scala文档。( https://oreil.ly/8xjPm

19.4 将Scala对象序列化成JSON字符串

问题

你需要知道如何使用Play将Scala对象转换成一个JSON字符串。

解决方案

首选的方法是为类创建一个隐式的 Writes 转换器,然后使用 Json.toJson 方法将类转换(序列化)为一个JSON字符串值。

举个例子,假定有这么一个 Movie 类:

    case class Movie(title: String, year: Int, rating: Double)

像下面这样创建一个 Movie 的隐式 Writes

    import play.api.libs.json._

    implicit val movieWrites = new Writes[Movie] { 
        def writes(m: Movie) = Json.obj( 
            "title" -> m.title, 
            "year" -> m.year, 
            "rating" -> m.rating 
        )
    }

现在可以创建这样一个实例:

    val pb = Movie("The Princess Bride", 1987, 8.5)

然后使用 Json.toJson 将其转换成JSON:

    scala> val json = Json.toJson(pb) 
    val json: play.api.libs.json.JsValue =
        {"title":"The Princess Bride","year":1987,"rating":8.5}

对象序列

这种方法的一个好处是,它也适用于对象的集合。假设创建一个这样的集合:

    val goodMovies = List( 
        Movie("The Princess Bride", 1987, 8.5), 
        Movie("The Matrix", 1999, 8.8), 
        Movie("Firefly", 2002, 9.2) 
    )

然后将其序列化为JSON:

    scala> val json = Json.toJson(goodMovies)
    val json: play.api.libs.json.JsValue = [
        {"title":"The Princess Bride","year":1987,"rating":8.5},
        {"title":"The Matrix","year":1999,"rating":8.8},
        {"title":"Firefly","year":2002,"rating":9.2} 
    ]

就像在后面讨论中那样,这种方法也适用于嵌套对象。

讨论

这种方法是可行的是因为 Json.toJson 被设计成使用一个隐式的 Writes 值,这个值在当前的作用域内是有效的:

    Json.toJson[T](T)(implicit writes: Writes[T])

Writesplay.api.libs.json 包中的一个特质,当为类定义一个像这样隐式的 Writes 实例:

    implicit val movieWrites = new Writes[Movie] ...

然后将它导入到当前的作用域中,Json.toJson 方法就可以进行转换了。如解决方案中所示,它对一个类的单个实例和该类型的集合都有效。

嵌套对象

同样 Writes 技术也支持嵌套对象。例如,给定这两个样例类:

    case class Address( 
        street: String, 
        city: String, 
        state: String, 
        postalCode: String 
    )

    case class Person(name: String, address: Address)

可以创建隐式 Writes 来对其进行转换:

    object WritesConverters { 
        import play.api.libs.json._

        implicit val addressWrites = new Writes[Address] { 
            def writes(a: Address) = Json.obj( 
                "street" -> a.street, 
                "city" -> a.city, 
                "state" -> a.state, 
                "postalCode" -> a.postalCode, 
            )
        }

        implicit val personWrites = new Writes[Person] { 
            def writes(p: Person) = Json.obj( 
                "name" -> p.name, 
                "address" -> p.address 
            )
        }
    }

然后,在将它们导入作用域后,可以创建一个包含 AddressPerson 实例,并把 Person 转换为JSON。这个过程在下面这段代码中展示:

    object JsonWrites2AddressPerson extends App {

        import WritesConverters._
        val stubbs = Person(
            "Stubbs", 
            Address(
                "123 Main Street", 
                "Talkeetna", 
                "Alaska", 
                "99676" 
            )
        )

        val jsValue = Json.toJson(stubbs) 
        println(jsValue)
    }

当该代码运行时,你会看到 jsValue 有这样的类型和数据:

    jsValue: JsValue = {
        "name":"Stubbs", 
        "address":{ 
            "street":"123 Main Street", 
            "city":"Talkeetna", 
            "state":"Alaska",
            "postalCode":"99676" 
        } 
    }

其他可以使用的方式

通过Play,可以使用其他方法将Scala对象序列化为JSON,但 Writes 的方法很直接,所以使用其他方法似乎并没有很大的优势。

这里简略看下其他转换方式,这段代码显示了将名为 mMovie 实例转换为 JsValue 的另一种方法:

    import play.api.libs.json._

    val json: JsValue = JsObject( 
        Seq(
            "title" -> JsString(m.title),
            "year" -> JsNumber(m.year), 
            "rating" -> JsNumber(m.rating) 
        ) 
    )

有关其他转换方法的详情,请参见Play关于JSON的文档( https://oreil.ly/bZUdm

19.5 将JSON反序列化成Scala对象

问题

当Play接收到一个与Scala类对应的JSON时,你需知道如何将其转换成一个Scala对象,当然也有可能是一个Scala对象集合。

解决方案

将单个JSON转换成Scala对象的将在下面展示。将JSON转换成一个对象集合则会在讨论中展示。

要将JSON字符串转换为单一的Scala对象,可以参照以下步骤:

  1. 创建一个与JSON对应的类。

  2. 创建一个Play Reads 用于转换。

  3. 在Playcontroller方法中接收JSON。

  4. 将JSON字符串转换为Scala对象,并在此过程中验证JSON正确性。

这基本上是将前一个小节对应的方法反了过来,增加了验证的步骤。前面的小节使用隐式的 Writes ,而这里则使用隐式的 Reads

1. 创建一个与JSON对应的Scala类

例如,假设有这样一个JSON字符串用来代表一个 Movie

    val jsonString = """{"title":"The Princess Bride","year":1987,"rating":8.5}"""

创建一个样例类来与这个JSON对应:

    case class Movie(title: String, year: Int, rating: Double)

2. 创建一个 Reads 用于转换

然后为 Movie 创建一个隐式的 Reads 转换器:

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

    // conversion without validation
    implicit val movieReads: Reads[Movie] = (
        (JsPath \ "title").read[String] and
        (JsPath \ "year").read[Int] and
        (JsPath \ "rating").read[Double] 
    )(Movie.apply _)

不考虑验证的情况,这里已经可以使用 Json.fromJson 将JSON字符串转换成Scala的 Movie 对象了:

    val json: JsValue = Json.parse(jsonString)

    val movie = Json.fromJson(json)
    // JsResult[Movie] = JsSuccess(Movie(The Princess Bride,1987,8.5),)

然而,在实际工程中总是需要验证进入应用程序的外部数据,所以需要为每个值添加验证:

    // conversion with validation.
    // minLength, min, and max are validation methods that come with
    // the Reads object.
    import play.api.libs.json._
    import play.api.libs.functional.syntax._
    import play.api.libs.json.Reads._

    implicit val movieReads: Reads[Movie] = ( 
        (JsPath \ "title").read[String](minLength[String](2)) and 
        (JsPath \ "year").read[Int](min(1920).keepAnd(max(2020))) and 
        (JsPath \ "rating").read[Double](min(0d).keepAnd(max(10d))) 
    )(Movie.apply _)

minminLengthmax 这样的函数是 Reads 对象自带的辅助验证方法,这些例子展示了如何将它们应用于三个传入的字段。

可读性 -- TODO 松鼠图

如果代码在添加验证器时变得太难读,可以考虑一次只处理一个 JsPath 字段。

3. 在Playcontroller方法中接收JSON

这个步骤在本小节中没有使用,但如果想在controller的方法使用请参见讨论。

4. 将JSON转换为Scala对象

现在可以尝试解析和验证JSON,构建一个Scala对象:

    val json: JsValue = Json.parse(jsonString) 
    val jsResult = json.validate[Movie]

当验证成功时,jsResult 的类型和值是:

    jsResult: JsResult[Movie] = JsSuccess(Movie(The Princess Bride,1987,8.5),)

处理 jsResult 值的一种方法是使用 匹配 表达式——它是 JsSuccessJsError

    jsResult match { 
        case JsSuccess(movie,_) => println(movie) 
        case e: JsError => println(s"error: $e") 
    }

这是一种最常见的处理方法,JsResult 类型也有其他方法可以使用,包括 asOptfoldforeachgetOrElse

讨论

这个解决方案与上一个小节类似,但这个小节讲的不是从Scala对象到JSON字符串,而是从JSON字符串到Scala对象。上一个小节使用了隐式 Writes 转换器和 Json.toJson 方法,这个小节使用了隐式 Reads 转换器和Json.fromJsonjson.validate 方法。

JSON和controller方法

controller方法在19.1小节和19.2小节已经展示过这里就不做赘述。

当在controller方法中处理JSON时,需要对请求对象进行操作,像下面这样:

    // one option 
    def yourMethod = Action { request: Request[AnyContent] =>
        val json: Option[JsValue] = request.body.asJson
        // more the json here ... 
    }

    // another option ("body parser") 
    def yourMethod = Action(parse.json) { request: Request[JsValue] =>
        // work with 'request' as JSON here
        val name = request.body \ "username").as[String] 
    }

关于使用 Action 、JSON和 body parsers 的更多细节,请参见Play Framework文档。

使用 JsPath

解决方案中展示的模式被称为 组合子模式

    (JsPath \ "title").read[String] and 
    (JsPath \ "year").read[Int] and 
    (JsPath \ "rating").read[Double]

在这段代码中,JsPath 是一个代表 JsValue 路径的类,即它在 JsValue 结构中的位置。它用来像XPath处理XML那样来处理JSON的,并且它提供的搜索模式来遍历JsValue结构。

对于嵌套对象,可以这样来搜索路径:

    val city = JsPath \ "address" \ "city"

对于对象的序列,像这样访问序列元素:

    val friend0 = (JsPath \ "friends")(0)

更多 JsPath 的细节,请参见Play文档中关于 Reads/Writes/Format 组合子中的的“Reads”部分。

处理验证失败的数据

当收到的数据没有通过验证过程,validate 会返回一个 JsError 。例如,这个JSON字符串使用了不正确的键名name 而不是 title

    // intentional mistake here (using 'name' instead of 'title'):
    val jsonString = """{"name":"The Princess Bride","year":1987,"rating":8.5}"""

所以当运行 validate 方法时,它会返回一个 JsError

    scala> json.validate[Movie]
    val res0: play.api.libs.json.JsResult[Movie] =
      JsError(List((/title,List(JsonValidationError(List(error.path.missing),
      ArraySeq())))))

现在,当使用 匹配 表达式时,就会触发 JsError 的 case语句:

    jsResult match { 
        case JsSuccess(movie,_) => println(movie) 
        case e: JsError => println(s"error: $e") 
    }
    // output:
    JsError(List(
    (/title,List(JsonValidationError(List(error.path.missing),ArraySeq())))))

验证器

如验证例子所示,Play有几个内置的辅助验证的函数。它们包含在 Reads 对象中,可以这样导入:

    import play.api.libs.json.Reads._

目前内置的验证器有:

  • email 验证一个字符串是否有电子邮件的格式

  • minLength 验证一个字符串或者集合的最小长度

  • min 验证一个值的是否大于最小值

  • max 验证一个值的是否小于最大值

It also has the keepAnd, andKeep, and or methods, which work as operators. See the

Play JSON documentation on Reads/Writes/Format combinators (https://oreil.ly/

jcr6g) for details on these combinator methods.

处理JSON序列

当方法收到一个模型的JSON序列时,上面提到的方法也是有效的。唯一要做的就是将这行代码中的 Movie

    val jsResult = json.validate[Movie]

修改为 Seq[Movie]

    val jsResult = json.validate[Seq[Movie]]

可以通过在一个Play项目的根目录下启动sbt控制台来进行测试:

    $ sbt
    play> console
    scala> _

然后把这段代码粘贴到REPL中:

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

    case class Movie(title: String, year: Int, rating: Double)

    val jsonString = """[ 
        {"title":"The Princess Bride","year":1987,"rating":8.5}, 
        {"title":"The Matrix","year":1999,"rating":8.8
        {"title":"Firefly","year":2002,"rating":9.2} 
    ]"""

    implicit val movieReads: Reads[Movie] = ( 
        (JsPath \ "title").read[String](minLength[String](2)) and 
        (JsPath \ "year").read[Int](min(1920).keepAnd(max(2020))) and 
        (JsPath \ "rating").read[Double](min(0d).keepAnd(max(10d))) 
    )(Movie.apply _)

    val json: JsValue = Json.parse(jsonString) 
    val jsResult = json.validate[Seq[Movie]]

可以看到 jsResult 是一个 包含了 电影列表的 JsSuccess

    jsResult: play.api.libs.json.JsResult[Seq[Movie]] = 
      JsSuccess(List(Movie(The Princess Bride,1987,8.5) ...

    scala> jsResult.get.foreach(println) 
    Movie(The Princess Bride,1987,8.5) 
    Movie(The Matrix,1999,8.8) 
    Movie(Firefly,2002,9.2)

另见

Play Reads 对象( https://oreil.ly/iDOUD )的Scaladoc提供了关于可用的辅助函数的更多细节。

19.6 在Play项目外使用Play JSON库

问题

你希望在非Play项目中使用Play JSON库。

解决方案

Play JSON ( https://oreil.ly/mIkGC )作为一个独立的库存在,所以可以在Play环境之外的Scala项目中使用它。要单独使用它只需在build.sbt 文件中添加其依赖关系:

    "com.typesafe.play" %% "play-json" % "2.9.1"

一个使用Play JSON和sttp的完整的 build.sbt 文件看起来是像这样的:

    name := "PlayJsonWithoutPlay" 
    version := "0.1" 
    scalaVersion := "2.13.5"
    libraryDependencies ++= Seq( 
        "com.typesafe.play" %% "play-json" % "2.9.1", 
        "com.softwaremill.sttp.client3" %% "core" % "3.2.3" 
    )

一旦添加了依赖关系,就可以像在Play框架使用Play JSON一样,在项目中使用Play JSON了。

从Scala对象到JSON

这个例子展示了如何用 Writes 转换器将Scala对象转换为JSON:

    import play.api.libs.json._

    case class Movie(title: String, year: Int, rating: Double)

    object JsonWithoutPlay_WritesExample extends App {

        implicit val movieWrites = new Writes[Movie] {
            def writes(m: Movie) = Json.obj(
                "title" -> m.title,
                "year" -> m.year,
                "rating" -> m.rating
            )
        }
        val pb = Movie("The Princess Bride", 1987, 8.5)
        println(Json.toJson(pb))

    }

从JSON到Scala对象

这个例子展示了如何使用sttp访问一个REST API,然后使用Play JSON将其收到的JSON转换为Scala对象。因为所有这些代码都在其他小节中展示过了,所以注释中描述了它的工作原理:

    import play.api.libs.json._ 
    import play.api.libs.functional.syntax._ 
    import play.api.libs.json.Reads._ 
    import sttp.client3._

    // a case class to model the data received from the REST url
    case class ToDo( 
        userId: Int, 
        id: Int, 
        title: String, 
        completed: Boolean 
    )

    object JsonWithoutPlay_ReadsExample extends App {

        // the Reads implementation that matches the data. 
        // the first three fields are also validated.
        implicit val todoReads: Reads[ToDo] = (
            (JsPath \ "userId").read[Int](min(0)) and
            (JsPath \ "id").read[Int](min(0)) and
            (JsPath \ "title").read[String](minLength[String](2)) and
            (JsPath \ "completed").read[Boolean] 
        )(ToDo.apply _)

        // make the GET request with sttp
        val response = basicRequest
            .get(uri"https://jsonplaceholder.typicode.com/todos/1"
            .send(HttpURLConnectionBackend())

        // get the JSON from the response, then convert the JSON string 
        // to a Scala `ToDo` instance
        response.body match {
            case Left(e) => println(s"Response error: $e") 
            case Right(jsonString) =>
                val json: JsValue = Json.parse(jsonString)
                val jsResult = json.validate[ToDo]
                jsResult match { 
                    case JsSuccess(todo,_) => println(todo) 
                    case e: JsError => println(s"JsError: $e") 
                }
        }
    }

JSONPlaceholder( https://jsonplaceholder.typicode.com/ )是一个流行的用于测试REST调用服务,目前每月有约9亿个测试请求在这个服务上测试。当用 curl 访问代码中所示的URL时,可以看到这样一个JSON结果:

    $ curl https://jsonplaceholder.typicode.com/todos/1 
    { 
    "userId": 1, 
    "id": 1, 
    "title": "delectus aut autem", 
    "completed": false 
    }

这个JSON会被转换为在 jsResulttodo 变量中的值:

    jsResult: JsSuccess(ToDo(1,1,delectus aut autem,false),)
    ToDo(1,1,delectus aut autem,false)

讨论

本例中使用了sttp HTTP客户端库( https://oreil.ly/a8sHJ );它讲在19.7小节中详细解释。本例中需要了解的重要事项是:

  • 它向所示的URL发出一个HTTP GET请求。

  • 它返回一个HTTP响应,其类型为Either[String,String]。

  • JSON 字符串 是在 response.body 匹配 表达式的 Right case 语句中被提取出来的

其他Scala JSON库

还有其他几个Scala库用于处理JSON。Circe ( https://github.com/circe )是最受欢迎的JSON库之一。正如其文档所述,Circe源于Java的 Argonaut库,并依赖于Cats 这个函数式编程库( https://oreil.ly/fg5pO )。

uJson ( https://oreil.ly/2rnUF ),包括uPickle( https://oreil.ly/BTjmI )项目,是另一个流行的JSON库。它的价值在于其API试图与Python、Ruby和JavaScript等语言的JSON库相似。另外,大多数Scala JSON库将JSON视为不可改变的,而uJson则允许改变JSON。

19.7 使用sttp HTTP客户端

问题

你想知道如何使用sttp客户端库在Scala 中发送HTTP请求。

解决方案

sttp可以处理所有的HTTP客户端请求,包括GET、POST和PUT请求;请求头、cookies、重定向和设置超时;以及使用同步和异步后端库来建立HTTP连接。

解决方案中将展示sttp的一些功能。

基本GET请求

这个例子展示了如何使用默认的后端引擎,用sttp进行同步的GET请求。它没有使用任何查询参数、请求头或cookies。

首先,创建一个sbt项目,在 build.sbt 文件中加入sttp client3依赖项:

    lazy val root = project
        .in(file(".")) 
        .settings( 
            name := "Sttp", 
            version := "0.1.0",
            scalaVersion := "3.0.0",
            libraryDependencies ++= Seq( 
                "com.softwaremill.sttp.client3" %% "core" % "3.2.3" 
            )
        )

在本小节后都会使用这个 build.sbt 文件。

在配置好sbt后,使用GET请求访问这样一个URL:

    import sttp.client3.*

    @main def basicGet =
        val backend = HttpURLConnectionBackend()
        val response = basicRequest
                          .get(uri"http://httpbin.org/get")
                          .send(backend)
        println(response)

response 会是这样:

    Response(Right({ 
      "args": {},
      "headers": {
        "Accept": "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2",
        "Accept-Encoding": "gzip, deflate", 
        "Host": "httpbin.org", 
        "User-Agent": "Java/11.0.9",
        "X-Amzn-Trace-Id": "Root=1-6068ea23-1354da2305a7c4b12b4dd4dc"
      }, 
      "origin": "148.69.71.19", "url": "http://httpbin.org/get"
    }
    ),200,OK, 
    Vector( much more output here ...

虽然 response 是嵌套类型 Response(Right(...)) ,但它很容易操作。可以使用各种方法来检查状态代码、响应头,以及查看请求是否被重定向:

    response.code                       // sttp.model.StatusCode = 200
    response.is200                      // true
    response.isClientError              // false
    response.statusText                 // "OK"
    response.headers                    // Seq[sttp.model.Header]
    response.header("Content-Length")   // Some("344")
    response.isRedirect                 // false

当访问 response 体时,类型是一个 Either ,当访问URL成功时,它是一个 Right

    response.body  // Either[String, String] = Right( ...

使用 Either 可以同时处理成功和不成功的结果。例如,可以使用一个 匹配 表达式来处理结果:

    response.body match
        case Left(error) => println(s"LEFT: $error")
        case Right(result) => println(s"RIGHT: $result")

因为 response.bodyEither[String, String] 类型,也可以使用 foreach、getOrElse、isRight、map 等方法来进行处理。

后端

sttp是由一个前端,也就是上面的API,和一个后端组成。例如,在前面的代码中看到,后端是这样引用的:

    // create a backend 
    val backend = HttpURLConnectionBackend()

    // send the Request to the backend
    val response = basicRequest
                      .get(uri"http://httpbin.org/get") 
                      .send(backend)

该代码的这一部分创建了一个 Request

    val request = basicRequest.get(uri"http://httpbin.org/get")

请求中的重要部分是 method(本例中是GET)、uri字符串,以及请求中是否有一个请求体。因为输出很长,所以这里不会进行展示,请读者自行运行该代码。

send 方法被调用时,这个请求被发送到配置的后端。这里的后端是一个 SttpBackend 的实例,sttp文档( https://oreil.ly/v4MGO )有说明大部分请求工作是在这里发生的:

  • 请求会被转译成后端相关的形式。

  • 打开连接并发送和接受数据。

  • 后端相关的响应会被转译成sttp的 Response .

后端可以是同步的,也可以是异步的,它们管理连接池、处理响应的线程池,并有配置选项。根据sttp文档( https://oreil.ly/a8sHJ ),sttp目前至少支持五个后端,包括,"基于akka-http、async-http-client、http4s、OkHttp的后端,以及与Java自带的HTTP客户端。" 这些后端可以与Akka、Monix、fs2、cats-effect、scalaz和ZIO集成。

更多关于如何配置与使用不同后端的方法参见sttp 后端文档( https://oreil.ly/9XusR

讨论

本讨论展示了更多的sttp能力。

带请求头的GET请求

当向GitHub REST API v3发出请求时,Github鼓励请求方在请求中发送这个请求头( https://oreil.ly/E7QDc ):

    Accept: application/vnd.github.v3+json

要想用sttp做到这一点,只需在查询中添加一个 header 方法。其完整的Scala 3 代码看起来像这样:

    import sttp.client3.* 

    @main def basicGetHeader =
        val backend = HttpURLConnectionBackend() 
        // note: all data is sent and received as json 
        val response = basicRequest 
                          .header("Accept", "application/vnd.github.v3+json")
                          .get(uri"https://api.github.com")
                          .send(backend)
        println(response.body)

该查询从GitHub返回一个长的JSON响应,其开头是这样的:

    {"current_user_url":"https://api.github.com/user" ...

带查询参数的GET请求

sttp 文档展示了如何创建一个带查询参数的 GET 请求,假设仍然使用当前的GitHub API:

    import sttp.client3.*

    @main def getWithQueryParameters =
        val query = "http language:scala" 
        val request = basicRequest.get( 
            uri"https://api.github.com/search/repositories?q=$query"
        )

        val backend = HttpURLConnectionBackend() 
        val response = request.send(backend) 
        println(s"URL: ${request.toCurl}") // prints the URL that’s create
        println(response.body)

第一个println语句的结果,其创建的URL,会看到这样的输出:

    URL: curl -L --max-redirs 32 -X GET
    'https://api.github.com/search/repositories?q=http+language:scala'

该输出展示了sttp是如何将查询参数翻译成一个GET URL的,也可以用显示的curl命令来测试。注意,query 参数是自动进行URL编码的。

POST例子

sttp可以创建任何HTTP请求方法,包括POST方法。下面是一个向 httpbin.org 测试服务发送POST查询的例子:

    import sttp.client3.*

    @main def post =
        val backend = HttpURLConnectionBackend() 
        val response = basicRequest
            .body("Hello, world!")
            .post(uri"https://httpbin.org/post?hello=world")
            .send(backend)
        println(response)

该查询返回一个超过五百个字符的JSON响应,包括一组响应头信息,以及这样的数据:

    "data": "Hello, world!"

Cookies

也可以用sttp设置和访问cookies。下面这个例子展示了在调用 httpbin.org 网址时如何设置两个cookie:

    import sttp.client3.*

    @main def cookies =
        val backend = HttpURLConnectionBackend() 
        val response = basicRequest 
            .get(uri"https://httpbin.org/cookies") 
            .cookie("first_name", "sam")
            .cookie("last_name", "weiss") 
            .send(backend) 
        println(response)

response 的值看上去像下面这样:

    Response(Right({ 
      "cookies": { 
        "first_name": "sam",
        "last_name": "weiss" 
      }
    }
    ),200,OK, 
    more output omitted ...

设置超时时间

在实际生产代码中,作者总是会设置HTTP查询超时时间。这段代码显示了如何在连接和读取尝试中设置超时时间:

    import sttp.client3.* 
    import scala.concurrent.duration.*

    @main def timeout =
        // specify backend options, like a connection timeout 
        val backend = HttpURLConnectionBackend( 
            options = SttpBackendOptions.connectionTimeout(5.seconds) 
        )

        // can also set a read timeout 
        val request = basicRequest
            .get(uri"https://httpbin.org/get") 
            .readTimeout(5.seconds)

        val response = request.send(backend) 
        println(response)

注意,这段代码会抛出一个 SttpClientException.ConnectExceptionSttpClientException.ReadException ,所以一定要在代码中处理这些异常。

最后更新于

这有帮助吗?