Scala-REST-Web-服务指南-全-
Scala REST Web 服务指南(全)
原文:
zh.annas-archive.org/md5/59fff067c194016c9980dfc9d60030fa
译者:飞龙
前言
Scala 是一种出色的语言,它提供了函数式编程和面向对象编程之间的一种很好的组合。使用 Scala,您能够以高度生产力的方式编写美观、简洁且易于维护的代码。Scala 还提供了创建 REST 服务非常有用的语言结构。在 Scala 生态系统中,有大量的 REST 框架,每个框架都允许您以略微不同的方式创建此类服务。本书为您概述了五个最成熟和灵活的 REST 框架,并通过广泛的示例向您展示您如何使用这些框架的各种功能。
本书涵盖的内容
第一章, 开始使用 RESTful Web 服务,向您展示如何获取代码、设置您的 IDE 以及启动并运行一个最小的服务。它还提供了关于 REST 和我们将在整本书中使用的模型的一些背景信息。
第二章, 使用 Finagle 和 Finch 创建函数式风格的 REST 服务,解释了您如何使用 Finch 库与 Finagle 一起创建遵循函数式编程方法的 REST 服务。
第三章, 使用未过滤的 REST 服务模式匹配,展示了您如何使用 Unfiltered,一个小的 REST 库,来创建 REST 服务。使用 Unfiltered,您可以完全控制您的 HTTP 请求和响应的处理方式。
第四章, 使用 Scalatra 创建简单的 REST 服务,使用 Scalatra 框架的一部分来创建一个简单的 REST 服务。Scalatra 是一个出色的直观 Scala 框架,它使得创建 REST 服务变得非常简单。
第五章, 使用 Akka HTTP DSL 定义 REST 服务,专注于 Akka HTTP 的 DSL 部分,并解释了您如何使用基于知名的 Spray DSL 的 DSL,来创建易于组合的 REST 服务。
第六章, 使用 Play 2 框架创建 REST 服务,解释了您如何使用知名的 Play 2 Web 框架的一部分来创建 REST 服务。
第七章, JSON, HATEOAS 和文档,深入探讨了 REST 的两个重要方面——JSON 和 HATEOAS。本章通过示例向您展示如何处理 JSON 并将 HATEOAS 纳入讨论的框架中。
您需要这本书的内容
要使用本书中的示例,您除了 Scala 之外不需要任何特殊软件。第一章解释了如何安装最新的 Scala 版本,从那里,您可以获取本书中解释的所有框架的库。
本书面向的对象
如果你是一位有 Scala 经验的 Scala 开发者,并且想要了解 Scala 世界中可用的框架概述,那么这本书非常适合你。你需要具备关于 REST 和 Scala 的一般知识。这本书非常适合寻找与 Scala 一起使用的良好 REST 框架的资深 Scala(或其他语言)开发者。
习惯用法
在本书中,您会发现许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入应如下所示:“在这种情况下,我们拒绝请求,因为HttpMethod
(动词)与我们能够处理的任何内容都不匹配。”
代码块应如下设置:
class ScalatraBootstrapStep1 extends LifeCycle {
override def init(context: ServletContext) {
context mount (new ScalatraStep1, "/*")
}
}
任何命令行输入或输出都应如下所示:
$ sbt runCH04-runCH04Step1
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter4.steps.ScalatraRunnerStep1
10:51:40.313 [run-main-0] INFO o.e.jetty.server.ServerConnector - Started ServerConnector@538c2499{HTTP/1.1}{0.0.0.0:8080}
10:51:40.315 [run-main-0] INFO org.eclipse.jetty.server.Server - Started @23816ms
新术语和重要词汇将以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“在 Postman 中,你可以使用步骤 03 – 获取所有任务请求来完成此操作。”
注意
警告或重要注意事项将以如下框中显示。
小贴士
小技巧和窍门将以如下方式显示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说非常重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>
,并在邮件主题中提及本书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com
下载示例代码文件,这是您购买的所有 Packt Publishing 书籍的账户。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support
,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
海盗行为
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com>
与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问答
如果您对本书的任何方面有问题,您可以联系我们的 <questions@packtpub.com>
,我们将尽力解决问题。
第一章. 使用 RESTful Web 服务入门
欢迎阅读这本关于如何使用 Scala 创建 REST 服务的书籍。在这本书中,我将介绍几个不同的基于 Scala 的框架,并展示如何使用它们创建 RESTful 服务。每个框架都有其创建 REST 服务的特定方式;有些更注重函数式编程,而有些则提供了丰富的领域特定语言(DSL)。阅读完这本书并完成示例后,您将能够选择最适合您和您特定问题的方法。
在本书中,我们将讨论以下 Scala 框架:
-
Akka HTTP / DSL:Akka HTTP 是在 Akka Streams 之上构建的一个新的 REST 框架。它提供了一个基于 Spray 的基于 DSL 的方法。Spray 是 Scala 世界中最好的 REST 框架之一,其最新版本将在 Akka HTTP 之上运行。我们将探索这个 DSL 的功能,并展示如何使用它来创建 REST 服务。
-
Unfiltered:Unfiltered 是一个小巧的 REST 框架,它提供了一种非常结构化的创建 REST 服务的方法。这个框架提供了对 HTTP 请求所有部分的直接访问,并且不对您如何处理 REST 服务做出假设。这使您能够完全控制请求的处理方式和响应的生成。
-
Play 2:Play 2 是最受欢迎的 Scala 框架之一,它提供了创建完整 Web 应用程序的功能。Play 2 还提供了创建标准 REST 服务的强大支持。我们将专注于 Play 2 的 REST 特定功能。
-
Finagle和Finch:Finagle 和 Finch 都来自 Twitter 团队。使用 Finagle 和 Finch,您可以使用干净、函数式编程的方法创建 REST 服务。
-
Scalatra:我们将讨论的最后一个是 Scalatra 框架。Scalatra 是一个轻量级框架,基于更为人所知的 Sinatra 框架,使用它来创建 REST 服务非常简单。
除了这些框架之外,本书的最后一章还将提供一些关于如何处理高级主题(如 HATEOAS、链接和 JSON 处理)的指导。
在本章中,我们不会探索任何框架,而是将使用本章来介绍一些概念并设置一些工具:
-
我们首先需要确保您能够运行本书提供的所有示例,因此我们将向您展示如何获取代码并设置 SBT 和 IDE
-
我们还将简要介绍 RESTful 服务是什么。
-
最后,我们将查看我们将使用不同 Scala 框架实现的服务的 API。
对于 REST 有很多不同的定义,所以在我们深入技术细节之前,让我们先看看本书中我们将使用的 REST 定义。
REST 框架简介
在这本书中,当我们谈论 REST 时,我们谈论的是 Roy Fielding 的论文中描述的 REST(www.ics.uci.edu/~fielding/pubs/dissertation/top.htm
)。基本上,REST 是一种软件架构风格,遵循这些指南时,可以用来创建性能优良、可靠和可维护的服务。为了更好地理解 REST 是什么,一个好的起点是考虑一个服务必须遵循的约束,以成为 RESTful 的。在他的论文中,Roy Fielding 定义了以下一组约束:
-
客户端-服务器:这个约束意味着客户端和服务器通过一个标准化的接口相互分离。这种方法的优点是客户端不需要担心持久性、数据库、消息传递、可扩展性和其他服务器端概念;相反,它们可以专注于面向用户的功能。另一个优点是客户端和服务器可以独立开发,因为它们之间唯一的依赖关系是标准化的合同。请注意,如果您需要客户端和服务器之间非常严格的合同,基于 WSDL/SOAP 的服务可能比采用 RESTful 方法更好。
-
无状态:除了有独立的客户端和服务器之外,这两个组件之间的通信必须是状态无关的。这意味着客户端发送的每个请求都应该包含服务器所需的所有信息。请注意,对于认证,服务器可以暂时在持久存储中存储一些会话/用户信息,但所有真实的应用程序状态都应该存储在客户端。这种方法的一个大优点是,通过仅添加更多实例,可以非常容易地水平扩展服务器。
-
可缓存:在 RESTful 架构中,客户端被允许缓存响应。由服务器端决定哪些响应可以被缓存以及缓存多长时间。这个约束的目的是通过避免发送那些响应将保持不变的请求,来最小化客户端和服务器之间的交互。这当然提高了客户端的性能并减少了带宽。
-
分层系统:这个约束描述了在 RESTful 架构中,可以创建一个分层系统,其中每一层都有其特定的功能。例如,在客户端和服务器之间,可能会有防火墙、负载均衡器、反向代理等等。然而,客户端却不会注意到这些不同的层。
-
统一接口:在所有约束中,这可能是最有趣的一个。这个约束定义了统一接口(客户端和服务器之间的合同)应该看起来像什么。这个约束本身由以下四个部分组成:
-
资源的标识:在请求中,每个资源都应该有唯一的标识。通常,这是通过一种 URI 形式来完成的。请注意,资源的技术表示并不重要。通过 URI 标识的资源可以用 JSON、CSV、XML 和 PDF 表示,同时仍然是同一资源。
-
通过这些表示形式操作资源:当客户端拥有资源的表示形式(例如,JSON 消息)时,客户端可以通过更新表示形式并将其发送到服务器来修改这个资源。
-
自描述消息:客户端和服务器之间发送的每条消息都应该自描述。客户端不需要知道其他任何信息就能解析和处理消息。它应该能够从消息中确切地了解它可以对资源做什么。
-
超媒体作为应用状态引擎:这个限制条件,也称为HATEOAS,意味着 API 的用户事先不需要知道它可以用特定的资源做什么。通过在资源中使用链接和定义媒体类型,客户端可以探索和学习它可以在资源上执行的操作。
-
按需代码:按需代码是唯一可选的限制条件。当与其他限制条件进行比较时,它与其他限制条件也略有不同。这个限制条件背后的想法是,服务器可以通过传输可执行代码暂时扩展客户端的功能。在实践中,这个限制条件并不常见;大多数 RESTful 服务处理的是发送静态响应,而不是可执行代码。
-
重要的是要注意,这些限制条件并没有说任何关于实现技术的事情。
小贴士
经常在讨论 REST 时,人们会立即关注 HTTP 和 JSON。RESTful 架构并不强迫你采用这些技术。另一方面,大多数情况下,RESTful 架构是在 HTTP 之上实现的,目前使用 JSON 作为消息格式。在这本书中,我们也将关注使用 HTTP 和 JSON 来实现 RESTful 服务。
这里提到的限制条件概述了服务应该如何行动才能被认为是 RESTful 的。然而,在创建服务时,通常很难遵守所有这些限制条件,在某些情况下,并非所有限制条件都可能非常有用,或者可能非常难以实现。许多人注意到了这一点,几年前,Richardson 的成熟度模型(martinfowler.com/articles/richardsonMaturityModel.html
)提出了一种更实际的 REST 观点。
在 Richardson 的成熟度模型中,您不必遵循所有约束才能被认为是 RESTful;相反,定义了多个成熟度级别,以表明您的服务有多 RESTful。级别越高,您的服务就越成熟,这将导致服务更易于维护、更易于扩展和更易于使用。此模型定义了以下级别:
级别是这样描述的:
-
第 0 级描述了您仅向单个 HTTP 端点发送 XML 或 JSON 对象的情况。基本上,您没有做 REST,而是在 HTTP 上做 RPC。
-
第 1 级通过使用分而治之来处理复杂性,将大型服务端点分解成多个资源
-
第 2 级引入了一套标准的动词,以便我们可以以相同的方式处理类似的情况,消除不必要的差异
-
第 3 级引入了可发现性,提供了一种使协议更易于文档化的方法
在本书中,我们将主要关注在第 2 级支持 REST。因此,我们将使用定义良好的资源,并使用适当的 HTTP 动词来指示我们对资源要做什么。
在本书的第七章中,我们将讨论 HATEOAS,这可以帮助我们达到成熟度第 3 级。
现在我们已经解决了理论问题,让我们获取代码,设置您最喜欢的 IDE,并定义我们将要实现的 REST 服务的 API。
获取源代码
获取本书代码的方法有几个。我们在本书网站上提供了一个下载链接(www.packtpub.com/books/content/support/23321
),您可以从那里下载 GitHub(github.com/josdirksen/rest-with-scala/archive/master.zip
)上的最新源代码的 ZIP 文件,或者更好的是,直接使用 Git 克隆源代码库。
下载 ZIP 文件
如果您已下载 ZIP 文件,只需将其解压到您选择的目录:
Joss-MacBook-Pro:Downloads jos$ unzip rest-with-scala-master.zip
使用 Git 克隆仓库
如果您已经安装了 Git,克隆仓库也非常简单。如果您还没有安装 Git,请按照git-scm.com/book/en/v2/Getting-Started-Installing-Git
中的说明操作。
一旦安装了 Git,只需运行以下命令:
Joss-MacBook-Pro:git jos$ git clone https://github.com/josdirksen/rest-with-scala
Cloning into 'rest-with-scala'...
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (4/4), done.
Checking connectivity... done.
到目前为止,您已将源代码放在您选择的目录中。接下来,我们需要确保我们可以下载所有框架的依赖项并运行示例。为此,我们将使用 SBT(更多信息可以在www.scala-sbt.org/
找到),这是 Scala 项目的最常用构建工具。
设置 Scala 和 SBT 以运行示例
要运行本书中提供的示例,我们需要安装 Scala 和 SBT。根据你的操作系统,需要采取不同的步骤。
安装 Java
在我们安装 SBT 和 Scala 之前,我们首先需要安装 Java。Scala 至少需要 Java 运行时版本 1.6 或更高。如果你还没有在你的系统上安装 Java,请按照 www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html
上的说明操作。
安装 Scala 和 SBT
一旦安装了 Java,安装 Scala 和 SBT 就同样简单。要安装 Scala,只需访问 www.scala-lang.org/download/
并下载适合你系统的二进制文件。要安装 SBT,可以遵循 www.scala-sbt.org/download.html
上的说明。
要检查是否已安装所有内容,请在终端中运行以下命令:
Joss-MacBook-Pro:~ jos$ java -version
java version "1.8.0_40"
Java(TM) SE Runtime Environment (build 1.8.0_40-b27)
Java HotSpot(TM) 64-Bit Server VM (build 25.40-b25, mixed mode)
Joss-MacBook-Pro:~ jos$ scala -version
Scala code runner version 2.11.6 -- Copyright 2002-2013, LAMP/EPFL
Joss-MacBook-Pro:~ jos$ sbt -v
[process_args] java_version = '1.8.0_40'
# Executing command line:
java
-Xms1024m
-Xmx1024m
-XX:ReservedCodeCacheSize=128m
-XX:MaxMetaspaceSize=256m
-jar
/usr/local/Cellar/sbt/0.13.8/libexec/sbt-launch.jar
[info] Set current project to jos (in build file:/Users/jos/)
要退出 SBT,按 Ctrl + C。
运行示例
现在你已经安装了 Java、Scala 和 SBT,我们可以运行示例。当然,你也可以从你的 IDE 中运行示例(参见下一节关于如何设置 IntelliJ IDEA 和 Eclipse),但通常直接使用 SBT 也很简单。要运行示例,请按照以下步骤操作:
-
打开一个终端并转到你提取源 ZIP 文件或克隆存储库的目录。
-
为了测试配置,我们创建了一个简单的
HelloWorld
示例。从控制台执行sbt runCH01-HelloWorld
:Joss-MacBook-Pro:rest-with-scala jos$ sbt runCH01-HelloWorld [info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project [info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/) [info] Compiling 2 Scala sources to /Users/jos/dev/git/rest-with-scala/chapter-01/target/scala-2.11/classes... [info] Running org.restwithscala.chapter1.HelloWorld SBT successfully ran HelloWorld, configuration seems ok! Press <enter> to exit. [success] Total time: 18 s, completed Jun 13, 2015 2:34:41 PM Joss-MacBook-Pro:rest-with-scala jos$
-
当加载各种依赖项时,你可能会看到很多输出,但过一段时间后,你应该会看到消息,“SBT 成功运行 HelloWorld,配置看起来正常!”
-
本书中的所有示例都等待用户输入以终止。所以,一旦你玩完示例,只需按 Enter 键即可终止正在运行的程序。
在每一章中,我们将看到需要执行的 sbt
命令。如果你想了解可以运行的所有示例,也可以运行 sbt
alias
命令,它将生成以下输出:
Joss-MacBook-Pro:rest-with-scala jos$ sbt alias
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
runCH01-HelloWorld = ; chapter01/runCH01HelloWorld
runCH01-EchoServer = ; chapter01/runCH01EchoServer
除了直接从命令行运行示例外,还可以从 IDE 中运行它们。在下一节中,我们将了解如何将示例导入 IntelliJ IDEA 和 Eclipse。
设置 IDE
安装了 SBT 和 Scala 后,你就有了一切运行示例所需的东西。然而,有时直接从 IDE 中玩转和实验示例会更简单。Scala 工作中最受欢迎的两个 IDE 是 IntelliJ IDEA 和 Eclipse。它们都有出色的 Scala 插件和优秀的 SBT 支持。
配置 IntelliJ IDEA
IntelliJ 提供了其 IDE 的社区版和商业版,两者都可以用来运行和玩转本书中的示例。以下步骤适用于社区版,但也可以以相同的方式应用于商业版本:
-
首件事是下载 IDE。您可以从
www.jetbrains.com/idea/download/
下载适用于您的操作系统的版本。下载后,运行安装程序并启动 IDE。当您第一次运行 IntelliJ 时,会询问您是否要安装特色插件。Scala 就是其中之一: -
从这里,点击Scala列下方的安装来在 IntelliJ 中安装 Scala 支持。安装完成后,点击开始使用 IntelliJ IDEA。IntelliJ 启动后,你会看到一个屏幕,你可以从这里导入现有项目:
-
从这里,选择导入项目,在打开的屏幕上,导航到我们提取下载源代码的目录,选择该目录,然后点击确定。在打开的屏幕上,选择从外部模型导入项目单选按钮,然后选择SBT。
-
现在点击下一步并填写打开的屏幕:
-
选中下载源代码和文档复选框。
-
对于项目 SDK,点击新建,选择JDK,并导航到您安装 JDK 1.8 的目录。
-
最后,点击完成。
-
IntelliJ 现在将导入所有项目并下载所有必需的依赖项。完成后,你会看到一个类似这样的屏幕,其中显示了所有项目,可以直接从 IDE 中运行示例:
使用具有出色 Scala 支持的 IntelliJ IDEA 的替代方案是 Eclipse。
设置 Eclipse
Scala 社区提供了一个包含您开发 Scala 所需一切内容的 Eclipse 打包版本。
-
要安装此版本的 Eclipse,首先从他们的下载网站
scala-ide.org/download/sdk.html
下载适用于您的操作系统的版本。 -
下载完成后,将存档解压到您选择的目录,启动 Eclipse,选择存储文件的地点。启动后,您将看到一个空白的编辑器:
-
在我们可以导入项目之前,我们必须首先创建所需的 Eclipse 项目配置。为此,打开一个终端并导航到您提取或克隆源代码的目录。从该目录运行
sbt eclipse
:Joss-MacBook-Pro:rest-with-scala jos$ sbt eclipse [info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project [info] Updating {file:/Users/jos/dev/git/rest-with-scala/project/}rest-with-scala-build... [info] Resolving org.fusesource.jansi#jansi;1.4 ... [info] Done updating. [info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/) [info] About to create Eclipse project files for your project(s). [info] Successfully created Eclipse project files for project(s): [info] chapter-01
-
现在我们可以导入项目了。从菜单中选择文件 | 导入,然后选择将现有项目导入工作空间。在下一屏幕上,选择包含源代码的目录作为根目录,Eclipse 应该会显示所有项目:
-
现在点击完成,项目将被导入。现在您可以直接从 Eclipse 中编辑和运行示例。
测试 REST API
在我们查看将要创建的服务 API 之前,我们先快速了解一下如何测试你的 REST API。当然,我们可以在 Scala 中创建一个 REST 客户端并使用它,但由于 REST 服务的最大优势之一是它们可以被人类阅读和理解,我们将使用一个简单的基于浏览器的(在这种情况下是 Chrome)REST 客户端,称为Postman。请注意,你当然也可以使用不同的 REST 客户端。我们选择 Postman 的原因是,使用 Postman,创建不同类型的请求非常容易;它支持 HATEOAS,并且还允许我们共享请求,因此你不必手动创建它们。
安装 Postman
Postman 作为一个 Chrome 插件运行,所以为了使用这个 REST 客户端,你需要使用 Chrome。一旦你启动了 Chrome,在浏览器中打开 URL,chrome.google.com/webstore/detail/postman-rest-client/fdmmgilgnpjigdojojpjoooidkmcomcm?hl=en
(或者直接在 Google 上搜索 Chrome Postman)。
小贴士
注意,Postman 有两个版本:一个简单的 Chrome 插件,在撰写本文时,版本为 0.8.4.14,以及一个 Chrome 应用,目前版本为 3.0.0.6。对于这本书,我们将使用更简单的 Chrome 插件,所以请确保你在浏览器中安装了 0.8.4.14。
安装完成后,通过访问chrome://apps
并选择应用程序或点击屏幕右上角新添加的按钮来打开此应用。当你打开这个插件时,你应该会看到以下窗口:
Postman 的一个有趣方面是你可以非常容易地共享 REST 查询。在本章的资源中,你可以找到一个名为common
的目录。
导入请求集合
在这个目录中,有一些文件包含每个单独章节的请求。例如,对于这个章节,这些请求包含在文件ch01_requests.json
中。每个文件都包含你可以用来测试本书中 REST 框架的多个请求。要导入所有这些请求,请按照以下步骤操作:
-
在屏幕的左侧,点击集合标签。
-
在此标签的右侧,会出现两个图标。点击右侧的图标,称为导入集合。
-
在弹出的屏幕上,点击选择文件,导航到
common
目录,并选择所有ch0#_requests.json
文件然后打开它们。
现在,你将有一系列集合,每个章节一个,你可以在这里找到不同章节的示例请求。
要运行一个请求,只需点击一个集合。这将显示该章节的所有请求。点击一个请求。现在点击发送按钮将请求发送到服务器。在接下来的章节中,我们将看到你可以使用哪个请求来测试特定 Scala REST 框架的功能。
测试 REST 服务
到目前为止,我们已经安装了 Scala 和 SBT,并可以使用 Postman 作为 REST 客户端。最后一步是查看一切是否正常工作。为此,我们将启动一个非常简单的 HTTP 服务,该服务会回显特定的请求参数。
小贴士
对于这个例子,我们使用了 HTTP4S。这是一个基本的 HTTP 服务器,允许您快速创建 HTTP 服务。如果您感兴趣,您可以在http4s.org/
找到更多关于这个库的信息。我们简单 echo 服务的源代码可以在chapter-01/src/main/scala
目录中找到。
要运行此示例,我们需要采取几个步骤:
-
首先,打开一个控制台窗口,转到您下载和提取源代码或克隆 Git 存储库的目录。
-
从那个目录中,运行
sbt runCH01-EchoServer
命令。这将启动 echo 服务:Joss-MacBook-Pro:rest-with-scala jos$ sbt runCH01-EchoServer [info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project [info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/) [info] Running org.restwithscala.chapter1.EchoService 11:31:24.624 [run-main-0] DEBUG o.http4s.blaze.channel.ServerChannel - Starting server loop on new thread Server available on port 8080
小贴士
下载示例代码
您可以从您在
www.packtpub.com
的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。 -
现在我们已经启动了服务器,打开您的 Chrome 浏览器,然后从那里打开 Postman(记住,可以使用添加的按钮或
chrome://apps
URL)。在集合列表中,点击标记为Echo 'hello'的请求。这将打开请求。您现在可以通过点击发送按钮来运行此请求。如果一切配置正确,结果将类似于以下内容: -
通过更改
msg request
参数的值,您可以测试服务器是否真的在回显用户的输入。 -
此集合中还有一些其他请求,展示了我们当前服务器的一些功能。您可以检查当请求参数被省略时以及当调用未知 URL 时会发生什么。
到目前为止,本章剩下的唯一事情是查看我们将在即将到来的章节中概述的不同框架创建的 API。
REST 服务和模型
为了展示本书中各种框架的特点以及它们以不同方式解决问题的方法,我们将定义一个简单的 API,我们将使用 REST 框架来实现它。对于本书,我们将实现一个简单的待办事项列表 API。
API 描述
我们想要创建一个 RESTful API,所以最重要的部分是从可以通過此 API 管理的资源描述开始。对于此 API,我们定义以下资源:
实体 | 描述 |
---|
| 任务 | 任务是需要完成的事情。任务的 JSON 看起来类似于以下内容:
{
"id": long,
"title": string,
"content": string,
"notes": [noteList],
"status": Status,
"assignedTo": Person,
}
|
| 项目 | 一个项目将允许我们将任务分组在一起,并且通过将人员分配到项目,我们可以确定谁可以处理特定的任务:
{
"id": string,
"title": string,
"tasks": [task],
"members": [person],
"updated": datetime
}
|
| 人员 | 人员是可以完成任务并在完成后关闭任务的人。人员只能完成分配给他的任务,或者当他属于任务所属的项目时:
{
"id": string,
"name": string
}
|
| 注意 | 可以给任务添加备注,以提供有关如何执行任务的额外信息:
{
"id": string,
"text": string,
}
|
在这里不深入细节,我们希望在 API 中支持以下功能:
-
CRUD 功能:我们希望支持一些基本的 CRUD 操作。应该能够执行以下操作:
-
创建、更新和删除新的任务、项目、人员和备注。
-
获取任务、项目、人员和备注的列表。
-
在任务列表中进行搜索。
-
给任务添加备注。还应该能够更新和删除现有的备注。
-
-
高级功能:除了标准的 CRUD-like 功能外,我们还想提供一些更高级的功能:
-
将任务分配给特定项目
-
将人员分配给任务
-
将人员分配给项目
-
将任务从一个项目移动到另一个项目
-
注意,我们不会为每个框架实现所有功能。我们将主要使用此 API 来解释我们如何使用各种 REST 框架。
摘要
第一章节到此结束。在本章中,我们介绍了 REST 是什么,以及我们将努力达到的 Robertsons 成熟度模型级别(第 2 级)。我们将在最后一章解释 HATEOAS。到目前为止,你应该已经安装了 Scala 和 SBT,并且应该能够使用 SBT 运行本书中的所有示例,并使用 Postman 中提供的请求进行测试。我们还看到了如何使用 IDE 来与代码互动。最后,我们介绍了将在接下来的章节中实现的高级 API。
在下一章中,我们将深入了解我们将要探索的第一个 Scala 框架。第一个是 Finch,它是一个建立在网络库 Finagle 之上的 REST 库。这两个都是最初由 Twitter 创建的。
第二章.使用 Finagle 和 Finch 的函数式 REST 服务
在本章中,我们将向您展示如何使用 Finagle 和 Finch 库创建 REST 服务。我们将使用以下示例集来完成这项工作:
-
您的第一个 Finagle 和 Finch 服务:在本节中,我们将创建一个最小的 REST 服务,它将简单地返回一个字符串。
-
HTTP 动词和 URL 匹配:任何 REST 服务的一个重要部分是如何处理各种 URL 路径和不同的 HTTP 动词。在本部分中,我们将向您展示 Finch 如何通过使用匹配器和提取器来支持这一点。
-
使用 RequestReaders 处理传入请求:当创建 REST 服务时,通常需要从传入的 HTTP 请求中获取信息。Finch 使用
RequestReader
实例从请求中访问信息,这部分我们将进行解释。 -
JSON 支持:REST 服务通常使用 JSON 来表示资源。Finch 支持多个不同的 JSON 库。在本部分中,我们将探讨其中一个 JSON 库以及如何在 Finch 服务中使用它。
-
请求验证和自定义响应:本章的最后部分处理验证传入请求和创建自定义响应。Finch 使用
RequestReader
实例和验证规则,提供了一种非常优雅的方式来检查传入请求是否有效,并且可以进一步处理。
在我们开始查看代码之前,让我们快速了解一下 Finagle 和 Finch 对库的作用。
Finagle 和 Finch 简介
Finagle 和 Finch 实际上是两个不同的框架。Finagle 是一个由 Twitter 创建的 RPC 框架,你可以用它轻松创建不同类型的服务。在其网站(github.com/twitter/finagle
)上,Finagle 背后的团队这样解释它:
"Finagle 是一个用于 JVM 的可扩展 RPC 系统,用于构建高并发服务器。Finagle 为几种协议实现了统一的客户端和服务器 API,并设计用于高性能和高并发。Finagle 的大部分代码与协议无关,简化了新协议的实现。"
因此,虽然 Finagle 提供了创建高度可扩展服务所需的基础设施,但它并不直接支持特定协议。这正是 Finch 发挥作用的地方。
Finch(github.com/finagle/finch
)在 Finagle 之上提供了一个 HTTP REST 层。在其网站上,您可以找到一个很好的引言,总结了 Finch 的目标:
"Finch 是在
twitter.github.io/finagle
之上的一个薄层纯函数基本块,用于构建可组合的 REST API。其使命是提供尽可能接近裸金属 Finagle API 的简单且健壮的 REST API 原语。"
在本章中,我们只将讨论 Finch。请注意,尽管如此,Finch 提供的大部分概念都是基于底层 Finagle 理念的。Finch 提供了一套非常棒的基于 REST 的函数集,使得与 Finagle 一起工作变得非常简单和直观。
构建您的第一个 Finagle 和 Finch REST 服务
让我们从构建一个最小的 Finch REST 服务开始。我们首先需要确保我们拥有正确的依赖项。正如我们在上一章中提到的,我们使用 SBT 来管理我们的依赖项。各种章节的所有依赖项都可以在您提取源代码的位置的 project
目录下的 Dependencies.scala
文件中找到。对于本章中我们将看到的 Finch 示例,我们使用以下依赖项:
lazy val finchVersion = "0.7.0"
val backendDeps = Seq(
"com.github.finagle" %% "finch-core" % finchVersion
)
本书使用单个 SBT 文件(位于根目录的 build.sbt
)来处理所有章节,并采用多模块方法。深入探讨多模块设置超出了本书的范围。如果您想了解更多关于我们如何使用 SBT 来管理和定义各种模块的信息,请查看 build.sbt
文件。
现在我们已经加载了库依赖项,我们可以开始编写我们的第一个 Finch 服务。接下来的代码片段(源代码可以在 chapter-02/src/main/scala/org/restwithscala/chapter2/gettingstarted/HelloFinch.scala
找到)展示了一个最小的 Finch 服务,它只响应一个 Hello, Finch!
消息:
package org.restwithscala.chapter2.gettingstarted
import io.finch.route._
import com.twitter.finagle.Httpx
object HelloFinch extends App {
Httpx.serve(":8080", (Get / "hello" /> "Hello, Finch!").toService)
println("Press <enter> to exit.")
Console.in.read.toChar
}
当此服务在 URL 路径 hello
上收到 GET
请求时,它将响应 Hello, Finch!
消息。Finch 通过从路由(下一节将详细介绍路由)创建一个服务(使用 toService
函数)并使用 Httpx.serve
函数托管创建的服务来实现这一点。要运行此示例,请在您提取源代码的目录中打开一个终端窗口。在那个目录中,运行 sbt runCH02-HelloFinch
命令:
$ sbt runCH02-HelloFinch
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter2.gettingstarted.HelloFinch
Jun 26, 2015 9:38:00 AM com.twitter.finagle.Init$$anonfun$1 apply$mcV$sp
INFO: Finagle version 6.25.0 (rev=78909170b7cc97044481274e297805d770465110) built at 20150423-135046
Press <enter> to exit.
到目前为止,我们有一个运行在端口 8080
上的 HTTP 服务器。当我们调用 http://localhost:8080/hello
时,此服务器将响应 Hello, Finch!
消息。为了测试此服务,我们已在 Postman 中提供了一个 HTTP 请求(请参阅上一章了解如何安装 Postman 和加载请求)。您可以使用 GET Hello Finch
请求来测试我们刚刚创建的 Finch 服务:
HTTP 动词和 URL 匹配
每个 REST 框架的一个重要部分是能够轻松匹配 HTTP 动词和 URL 的各种路径段。在本节中,我们将查看 Finch 提供给我们的工具。让我们先从启动服务开始。要运行此服务,您可以从源代码目录使用 sbt runCH02-runCH02Step1
命令:
$ sbt runCH02-runCH02Step1
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter2.steps.FinchStep1
Jun 26, 2015 10:19:11 AM com.twitter.finagle.Init$$anonfun$1 apply$mcV$sp
INFO: Finagle version 6.25.0 (rev=78909170b7cc97044481274e297805d770465110) built at 20150423-135046
Press <enter> to exit.
一旦服务器启动,您就可以再次使用 Postman 向此服务发送请求,使用来自 第二章 集合的请求。此服务对每个请求只返回一个简单的文本消息:
现在,让我们看看一些代码,看看如何使用 Finch (chapter-02/src/main/scala/org/restwithscala/chapter2/steps/FinchStep1.scala
) 来实现这一点:
package org.restwithscala.chapter2.steps
import com.twitter.finagle.Httpx
import io.finch.request._
import io.finch.route._
import io.finch.{Endpoint => _}
object FinchStep1 extends App {
// handle a single post using a RequestReader
val taskCreateAPI = Post / "tasks" /> (
for {
bodyContent <- body
} yield s"created task with: $bodyContent")
// Use matchers and extractors to determine which route to call
// For more examples, see the source file.
val taskAPI = Get / "tasks" />
"Get a list of all the tasks" | Get / "tasks" / long />
( id => s"Get a single task with id: $id" ) | Put / "tasks" / long />
( id => s"Update an existing task with id $id to " ) | Delete / "tasks" / long />
( id => s"Delete an existing task with $id" )
// a simple server that combines the two routes
val server = Httpx.serve(":8080",
(taskAPI :+: taskCreateAPI).toService )
println("Press <enter> to exit.")
Console.in.read.toChar
server.close()
}
在此代码片段中,我们创建了一些 Router
实例来处理我们从 Postman 发送的请求。让我们先看看 taskAPI
路由器的一个路由,Get / "tasks" / long /> (id => s"Get a single task with id: $id")
。以下表格解释了路由的各个部分:
部分 | 描述 |
---|---|
Get |
在编写路由器时,通常你做的第一件事是确定你想要匹配哪个 HTTP 动词。在这种情况下,这个路由将只匹配 GET 动词。除了 Get 匹配器之外,Finch 还提供了其他匹配器,如 Post 、Patch 、Delete 、Head 、Options 、Put 、Connect 和 Trace 。 |
"tasks" |
路由的下一部分是一个匹配器,用于匹配 URL 路径段。在这种情况下,我们匹配 URL,http://localhost:8080/tasks 。Finch 将使用隐式转换将此字符串对象转换为 finch Matcher 对象。Finch 还提供了两个通配符匹配器:* 和 ** 。* 匹配器允许单个路径段有任意值,而 ** 匹配器允许多个路径段有任意值。 |
long |
路由的下一部分被称为 提取器。使用提取器,你可以将 URL 的一部分转换为值,然后可以使用它来创建响应(例如,使用提取的 ID 从数据库中检索对象)。long 提取器,正如其名称所暗示的,将匹配的路径段转换为长整型值。Finch 还提供了 int、string 和 boolean 提取器。 |
long => B |
路由的最后一部分用于创建响应消息。Finch 提供了不同的方式来创建响应,我们将在本章的其他部分展示。在这种情况下,我们需要向 Finch 提供一个函数,该函数将转换我们提取的长整型值,并返回一个 Finch 可以转换为响应的值(你将在后面了解更多)。在这个例子中,我们只返回一个字符串。 |
如果你仔细查看源代码,你可能已经注意到 Finch 使用自定义运算符来组合路由的各个部分。让我们更仔细地看看这些运算符。在 Finch 中,我们得到以下运算符(在 Finch 术语中也称为 组合器):
-
/
或andThen
:使用这个组合器,你可以顺序地组合各种匹配器和提取器。每当第一部分匹配时,就会调用下一个,例如,Get / "path" / long
。 -
|
或orElse
:这个组合器允许你将两个路由器(或其部分)组合在一起,只要它们是同一类型。因此,我们可以使用(Get | Post)
来创建一个匹配器,该匹配器匹配GET
和POST
HTTP 动词。在代码示例中,我们也使用了它来组合所有返回简单字符串给taskAPI
路由器的路由。 -
/>
或map
:使用这个组合器,我们将请求和从路径中提取的任何值传递给一个函数以进行进一步处理。调用函数的结果作为 HTTP 响应返回。正如您将在本章的其余部分看到的那样,有不同方式处理 HTTP 请求并创建响应。 -
:+:
: 最终组合器允许您将不同类型的两个路由器组合在一起。在示例中,我们有两个路由器:taskAPI
,它返回一个简单的字符串,以及taskCreateAPI
,它使用RequestReader
对象(通过body
函数)来创建响应。由于结果是通过两种不同的方法创建的,所以我们不能使用|
来组合它们,而是使用:+:
组合器。
到目前为止,我们每次收到请求时都只是返回简单的字符串。在下一节中,我们将探讨如何使用 RequestReader
实例将传入的 HTTP 请求转换为案例类,并使用这些案例类来创建 HTTP 响应。
使用 RequestReaders 处理传入请求
到目前为止,我们对传入的请求还没有做任何事情。在前面的示例中,我们只是返回了一个字符串,而没有使用请求中的任何信息。Finch 提供了一个非常棒的模型,使用 Reader monad,您可以使用它轻松地将传入请求的信息组合起来以实例化新对象。
注意
Reader monad 是一个标准的函数式设计模式,它允许您定义所有访问相同值的函数。关于 Reader monad 的工作原理的精彩解释可以在 eed3si9n.com/learning-scalaz/Monad+transformers.html
找到。
让我们看看一些使用 RequestReader
处理传入请求的代码(完整的源代码可以在 FinchStep2.scala
文件中找到):
object FinchStep2 extends App {
val matchTask: Matcher = "tasks"
val matchTaskId = matchTask / long
// handle a single post using a RequestReader
val taskCreateAPI =
Get / matchTask /> GetAllTasks() :+:
Post / matchTask /> CreateNewTask() :+:
Delete / matchTaskId /> DeleteTask :+:
Get / matchTaskId /> GetTask :+:
Put / matchTaskId /> UpdateTask
val taskAPI = ...
val server = Httpx.serve(":8080",
(taskAPI :+: taskCreateAPI).toService )
println("Press <enter> to exit.")
Console.in.read.toChar
server.close()
sealed trait BaseTask {
def getRequestToTaskReader(id: Long): RequestReader[Task] = {
( RequestReader.value(id) ::
param("title") ::
body ::
RequestReader.value(None:Option[Person]) ::
RequestReader.value(List.empty[Note]) ::
RequestReader.value(Status(""))
).as[Task]
}
}
case class CreateNewTask() extends Service[Request, String]
with BaseTask {
def apply(req: Request): Future[String] = {
val p = for {
task <- getRequestToTaskReader(-1)(req)
stored <- TaskService.insert(task)
} yield stored
p.map(_.toString)
}
}
case class DeleteTask(id: Long)
extends Service[Request, HttpResponse] {
def apply(req: Request): Future[HttpResponse] =
TaskService.delete(id).map {
case Some(task) => Ok()
case None => NotFound()
}
}
case class GetAllTasks() extends Service[Request, HttpResponse] {
def apply(req: Request): Future[HttpResponse] = {
for {
tasks <- TaskService.all
} yield Ok(tasks.mkString(":"))
}
}
case class GetTask(taskId: Long)
extends Service[Request, HttpResponse] {
def apply(req: Request): Future[HttpResponse] = {
TaskService.select(taskId).map {
case Some(task) => Ok(task.toString)
case None => NotFound()
}
}
}
case class UpdateTask(taskId: Long)
extends Service[Request, HttpResponse] with BaseTask {
def apply(req: Request): Future[HttpResponse] =
for {
task <- getRequestToTaskReader(taskId)(req)
stored <- TaskService.update(task)
} yield stored match {
case Some(task) => Ok(task.toString)
case None => NotFound()
}
}
}
在此代码中,我们看到一些新事物。我们不是直接返回一个字符串值,而是使用一个从 Service
扩展的案例类来处理 HTTP 请求并创建响应。您也可以直接从 SBT 运行此服务。运行 sbt runCH02-runCH02Step2
命令将启动服务:
$ sbt runCH02-runCH02Step2
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Compiling 1 Scala source to /Users/jos/dev/git/rest-with-scala/chapter-02/target/scala-2.11/classes...
[info] Running org.restwithscala.chapter2.steps.FinchStep2
Jun 27, 2015 10:26:49 AM com.twitter.finagle.Init$$anonfun$1 apply$mcV$sp
INFO: Finagle version 6.25.0 (rev=78909170b7cc97044481274e297805d770465110) built at 20150423-135046
Press <enter> to exit.
您可以使用 Postman 再次测试此服务。让我们先测试一下我们是否可以创建一个新的任务。为此,打开 Postman 并执行请求 步骤 02 – 创建任务:
我们收到的响应开始看起来像真实数据。我们在 body
中输入的文本以及 title
请求参数都被使用了。
让我们详细查看我们用来创建新任务的路由器,以了解它是如何工作的:
val matchTask: Matcher = "tasks"
val taskCreateAPI = Post / matchTask /> CreateNewTask()
...
sealed trait BaseTask {
def getRequestToTaskReader(id: Long): RequestReader[Task] = {
( RequestReader.value(id) ::
param("title") ::
body ::
RequestReader.value(None:Option[Person]) ::
RequestReader.value(List.empty[Note]) ::
RequestReader.value(Status(""))
).as[Task]
}
}
case class CreateNewTask() extends Service[Request, String]
with BaseTask {
override def apply(req: Request): Future[String] = {
val p = for {
task <- getRequestToTaskReader(-1)(req)
stored <- TaskService.insert(task)
} yield stored
p.map(_.toString)
}
}
在这个代码片段的顶部,你可以看到我们是如何定义处理我们刚刚通过 Postman 发起的创建任务请求的路由器的。每当有 POST
请求发送到 tasks
URL 时,这个路由器会匹配并将请求映射到 />
组合符右侧的函数。然而,这一次,我们并没有映射到一个返回字符串的函数,而是映射到一个扩展自 Service
的案例类。在我们的类中,我们必须实现从抽象 Service
类中继承的 def apply(request: Req): Future[Rep]
函数。在这个特定的例子中,我们为这个服务指定了类型参数为 Request
和 String
,因此 apply
函数应该将传入的 Request
实例转换为一个 Future[String]
对象。
提示
作为第一个类型参数,你通常指定 Request
(除非你在处理请求之前应用了过滤器,正如我们将在本章的最后部分解释的那样),第二个类型参数应该是 Finch 可以自动转换为 HTTP 响应的类型。为了自动转换,Finch 会查找作用域中的隐式 EncodeResponse[A]
类型类。默认情况下,Finch 会将字符串转换为 HTTP 响应。它还支持多个 JSON 库,其中案例类会自动转换为带有 JSON 体的 HTTP 响应。
在这个路由的服务的实现中,我们采取了一些步骤:
-
首先,我们调用在基类中定义的
getRequestToTaskReader
函数,并传入我们想要创建的任务的 ID。由于我们正在创建一个新的任务,我们只需指定-1
作为 ID,让后端生成一个真实的 ID。这个调用的结果是RequestReader[Task]
实例,它可以把一个请求转换为一个Task
类。 -
我们随后直接在返回的
RequestReader[Task]
实例上调用传入的apply
函数。这个调用返回一个Future[Task]
对象,我们随后在for
语句中进一步处理它。 -
当步骤 2 中的
future
解析时,我们就可以访问到一个任务。我们使用TaskService.insert
方法存储这个任务。这个调用同样返回一个Future
。 -
最后,我们返回存储的
Task
对象,作为一个Future[Task]
实例。 -
服务的最后一步是将
Future[Task]
对象转换为Future[String]
对象,我们只是通过一个简单的map
函数来完成这个操作。我们需要这样做的原因是 Finch 不知道如何自动将Task
对象转换为 HTTP 响应。
在我们进入下一节之前,让我们更仔细地看看我们用来将 Request
对象转换为 Task
对象的 RequestReader[Task]
实例:
def getRequestToTaskReader(id: Long): RequestReader[Task] = {
( RequestReader.value(id) ::
param("title") ::
body ::
RequestReader.value(Option.empty[Person]) ::
RequestReader.value(List.empty[Note]) ::
RequestReader.value(Status(""))
).as[Task]
}
在这个函数中,我们使用::
组合子将各种RequestReader
(body
、param
和RequestReader.value
)组合起来(我们将在下一节中详细解释body
、param
和RequestReader.value
)。当我们向这个函数的结果传递一个Request
时,每个RequestReader
都会对请求执行。所有这些单独步骤的结果将使用as[A]
函数组合(你也可以使用asTuple
来收集结果)。Finch 标准支持转换为 int、long、float、double 和 boolean,还允许你转换为 case 类。在最后一种情况下,你必须确保来自单个RequestReader
的结果与你的 case 类的构造函数匹配。在这个例子中,Task
被定义为如下:
case class Task(id: Long, title: String, content: String,
assignedTo: Option[Person], notes: List[Note],
status: Status)
这与单个RequestReader
的结果相匹配。如果你想要转换到不支持的数据类型,你可以非常简单地编写自己的,只需确保它在作用域内:
implicit val moneyDecoder: DecodeRequest[Money] =
DecodeRequest(s => Try(new Money(s.toDouble)))
在到目前为止的示例代码中,我们只使用了几个RequestReader
:param
和body
。Finch 提供了一些其他读者,你可以使用它们来访问 HTTP 请求中的信息:
读取器 | 描述 |
---|---|
param(name) |
这将返回请求参数作为字符串,当参数找不到时抛出NotPresent 异常。 |
paramOption(name) |
这将返回请求参数作为Option[String] 对象。这个调用总是会成功。 |
paramsNonEmpty(name) |
这将返回一个多值参数作为Seq[String] 对象。如果参数找不到,将抛出NotPresent 异常。 |
params(name) |
这将返回一个多值参数(例如,?id=1,2,3&b=1&b=2 )作为Seq[String] 对象。如果参数找不到,将返回一个空列表。 |
header(name) |
这将返回一个指定名称的请求头作为字符串,当头找不到时抛出NotPresent 异常。 |
headerOption(name) |
这返回一个指定名称的请求头作为Option[String] 对象。这个调用总是会成功。 |
cookie(name) |
这从请求中获取一个Cookie 对象。如果指定的 cookie 不存在,将抛出NotPresent 异常。 |
cookieOption(name) |
这从请求中获取一个Cookie 对象。这个调用总是会成功。 |
body |
这将返回请求体名称作为字符串,当没有请求体时抛出NotPresent 异常。 |
bodyOption |
这将返回请求体作为Option[String] 对象。这个调用总是会成功。 |
binaryBody |
这将返回请求体名称作为Array[Byte] 对象,当没有请求体时抛出NotPresent 异常。 |
binaryBodyOption |
这将返回请求体作为Option[Array[Byte]] 对象。这个调用总是会成功。 |
fileUpload |
这个RequestReader 从请求中读取上传(multipart/form)参数,当上传找不到时抛出NotPresent 异常。 |
fileUploadOption |
这个 RequestReader 从请求中读取一个上传(一个多部分/表单)参数。这个调用总是会成功。 |
如您从表中看到的,已经有很多种 RequestReader
类型可用,在大多数情况下,这应该足以满足您的需求。如果 RequestReader
对象不提供所需的功能,还有一些辅助函数可供您使用,以创建您自己的自定义 RequestReader
:
函数 | 描述 |
---|---|
valueA:``RequestReader[A] |
此函数创建一个 RequestReader 实例,该实例始终成功并返回指定的值。 |
exceptionA:``RequestReader[A] |
此函数创建一个 RequestReader 实例,该实例始终失败并带有指定的异常。 |
constA:``RequestReader[A] |
这个 RequestReader 将仅返回指定的值。 |
applyA:``RequestReader[A] |
此函数返回一个 RequestReader 实例,该实例应用提供的函数。 |
RequestReader
中还有一部分我们尚未讨论。当 RequestReader
失败时会发生什么?Finch 有一个非常优雅的机制来处理这些验证错误。我们将在本章的最后部分回到这一点。
JSON 支持
到目前为止,我们只是使用纯字符串作为响应。在本节中,我们将扩展前面的示例并添加 JSON 支持,并展示您如何在处理请求时控制应使用哪个 HTTP 响应代码。由于 Finch 已经支持许多 JSON 库,因此使用 JSON 与 Finch 非常简单:
-
Argonaut (
argonaut.io/
). -
Jackson (
github.com/FasterXML/Jackson
) -
Json4s (
json4s.org/
)
Argonaut
在本节中,我们将探讨如何使用 Argonaut 库自动将我们的模型(我们的案例类)转换为 JSON。如果您想使用其他库之一,它们的工作方式几乎相同。
我们首先将查看我们的服务应该为这个场景处理请求和响应消息。首先,使用sbt runCH02-runCH02Step3
命令启动服务器:
$ sbt runCH02-runCH02Step3
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter2.steps.FinchStep3
Jun 27, 2015 1:58:20 PM com.twitter.finagle.Init$$anonfun$1 apply$mcV$sp
INFO: Finagle version 6.25.0 (rev=78909170b7cc97044481274e297805d770465110) built at 20150423-135046
Press <enter> to exit.
当服务器启动时,打开 Postman 并从第二章集合中选择请求步骤 03 – 创建任务。当您发送此请求时,服务器将将其解析为案例类,存储它,并将存储的任务再次作为 JSON 返回。
如果您发送消息几次,您会注意到响应的 ID 会增加。原因是我们为新建的任务生成一个新的 ID,所以请忽略来自 JSON 消息的 ID。
一旦创建了多个新任务,您也可以使用步骤 03 – 获取任务请求来获取所有存储的任务:
当你存储了一定数量的消息后,你也可以使用 API 来删除任务。点击步骤 02 – 删除任务,将 URL 更改为你想删除的 ID(例如,http://localhost:8080/tasks/3
):
如果你想删除的任务 ID 存在,它将返回200 Ok,如果 ID 不存在,你将看到404 Not Found。
为了使这个功能正常工作,我们首先需要获取所需的 Argonaut 依赖项。为此,我们需要更改我们的 SBT 构建中的依赖项,如下所示:
lazy val finchVersion = "0.7.0"
val backendDeps = Seq(
"com.github.finagle" %% "finch-core" % finchVersion,
"com.github.finagle" %% "finch-argonaut" % finchVersion
)
Jackson 和 Json4s
对于 Jackson 和 Json4s,你使用finch-jackson
和finch-json4s
模块。
为了自动将我们的 case classes 转换为 JSON 以及从 JSON 转换回来,我们需要告诉 Argonaut 如何将这些 case classes 转换为 JSON 以及反过来。在我们的例子中,我们已经在chapter2
包对象中完成了这个操作(位于package.scala
文件中):
implicit def personDecoding: DecodeJson[Person] = jdecode1L(Person.apply)("name")
implicit def personEncoding: EncodeJson[Person] = jencode1L((u: Person) => (u.name))("name")
implicit def statusDecoding: DecodeJson[Status] = jdecode1L(Status.apply)("status")
implicit def statusEncoding: EncodeJson[Status] = jencode1L((u: Status) => (u.status))("status")
implicit def noteDecoding: DecodeJson[Note] = jdecode2L(Note.apply)("id", "content")
implicit def noteEncoding: EncodeJson[Note] = jencode2L((u: Note) => (u.id, u.content))("id", "content")
implicit def taskDecoding: DecodeJson[Task] = jdecode6L(Task.apply)
("id", "title", "content", "assignedTo", "notes", "status")
implicit def taskEncoding: EncodeJson[Task] = jencode6L( (u: Task) => (u.id, u.title, u.content,
u.assignedTo, u.notes, u.status))
("id", "title", "content", "assignedTo", "notes", "status" )
对于我们想要支持的每个 case class,我们需要一组隐式值。为了从 JSON 转换,我们需要一个DecodeJson[A]
实例,为了转换为 JSON,需要一个EncodeJson[A]
实例。Argonaut 已经提供了一些辅助方法,你可以使用这些方法轻松地创建这些实例,我们在前面的例子中已经使用了这些方法。例如,使用jdecode2L
(其中的2
代表两个参数),我们将两个 JSON 值转换为 case class,而使用jencode2L
,我们将 case class 的两个参数转换为 JSON。要了解更多关于 Argonaut 的信息,你可以查看其网站argonaut.io/
;处理自动转换的部分(如这里所述)可以在argonaut.io/doc/codec/
找到。
现在我们已经定义了 JSON 和我们所使用的 case classes 之间的映射,我们可以看看这如何改变我们的实现。在下面的代码片段中,我们看到处理创建任务、删除任务和获取任务请求的代码:
val matchTask: Matcher = "tasks"
val matchTaskId = matchTask / long
val taskCreateAPI =
Get / matchTask /> GetAllTasks() :+:
Post / matchTask /> CreateNewTask() :+:
Delete / matchTaskId /> DeleteTask
...
case class CreateNewTask() extends Service[Request, HttpResponse] {
def apply(req: Request): Future[HttpResponse] = {
for {
task <- body.as[Task].apply(req)
stored <- TaskService.insert(task)
} yield Ok(stored)
}
}
case class DeleteTask(id: Long)
extends Service[Request, HttpResponse] {
def apply(req: Request): Future[HttpResponse] =
TaskService.delete(id).map {
case Some(task) => Ok()
case None => NotFound()
}
}
case class GetAllTasks() extends Service[Request, HttpResponse] {
def apply(req: Request): Future[HttpResponse] = {
for {
tasks <- TaskService.all
} yield Ok(tasks)
}
}
首先,我们将查看CreateNewTask
类。正如你所见,由于我们不再需要显式定义如何将传入的请求转换为一个Task
,代码已经变得简单多了。这次,在CreateNewTask
服务的apply
函数中,我们只需要使用正文、RequestReader
,并使用as[Task]
自动将提供的请求转换为Task
。这之所以可行,是因为我们隐式地定义了一个DecodeJson[Task]
实例。一旦从Request
创建出Task
,我们就将其传递给TaskService
以存储它。TaskService
返回一个Future[Task]
,其中包含存储的Task
(这将填充正确的 ID)。最后,我们返回带有存储的Task
作为参数的Ok
。Finch 将这个Ok
对象转换为带有代码 200 的HttpResponse
,并使用隐式的EncodeJson[Task]
实例将提供的Task
转换为 JSON。我们将在下一节更详细地看看如何构建和自定义 HTTP 响应。GetAllTasks()
类基本上以相同的方式工作。它从TaskService
实例检索一个Future[Seq[Task]]
对象,Finch 以及隐式定义的对象,并且知道如何将这个任务序列转换为正确的 JSON 消息。
在我们进入下一节之前,让我们快速看一下DeleteTask
类。正如你在代码中所见,这个 case 类有一个额外的参数。这个参数将包含由映射到这个Service
的router
中的long
提取器提取的长值。如果你在router
中有多个提取器,你的 case 类应该有相同数量的参数。
请求验证和自定义响应
到目前为止,我们还没有查看当我们的某个 RequestReader 无法读取所需信息时会发生什么。一个头可能缺失,一个参数可能格式不正确,或者一个 cookie 不存在。例如,如果你将步骤 03 – 创建任务请求中的 JSON 字段的某些名称重命名,并发出请求,它将静默失败:
然而,Finch 提供了一种优雅的方式来处理所有来自 RequestReaders 的异常。首先,我们将看看我们想要达到的结果。首先,启动另一个类似于这样的sbt
项目:
$ sbt runCH02-runCH02Step4
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Compiling 1 Scala source to /Users/jos/dev/git/rest-with-scala/chapter-02/target/scala-2.11/classes...
[info] Running org.restwithscala.chapter2.steps.FinchStep4
Jun 28, 2015 2:10:12 PM com.twitter.finagle.Init$$anonfun$1 apply$mcV$sp
INFO: Finagle version 6.25.0 (rev=78909170b7cc97044481274e297805d770465110) built at 20150423-135046
Press <enter> to exit.
打开 Postman 并使用步骤 03 – 创建任务来创建一些数据库中的任务。对于这个示例,我们添加了一个可以通过步骤 04 – 搜索任务请求访问的搜索功能。
为了展示验证是如何工作的,我们在请求参数中添加了一些规则。status
请求参数是必需的,当使用text
参数时,其值应该至少有五个字符。为了测试这是如何工作的,你可以移除status
参数,或者将text
参数的值更改为小于五个字符的内容。下面的截图显示了产生的错误消息:
下面的代码片段显示了用于搜索数据库和展示我们必须对我们的应用程序进行哪些更改以获得这些验证结果的 case class:
// uses the following route
// Get / matchTask / "search" /> SearchTasks()
case class SearchParams(status: String, text: Option[String])
case class SearchTasks() extends Service[Request, HttpResponse] {
def getSearchParams: RequestReader[SearchParams] = (
param("status") ::
paramOption("text").should(beLongerThan(5))
).as[SearchParams]
def apply(req: Request): Future[HttpResponse] = {
(for {
searchParams <- getSearchParams(req)
tasks <- TaskService.search
(searchParams.status, searchParams.text)
} yield Ok(tasks)).handle({case t: Throwable =>
BadRequest(errorToJson(t))})
}
def errorToJson(t: Throwable):Json = t match {
case NotPresent(ParamItem(param)) =>
Json("error" -> Json.jString("param not found"),
"param" -> Json.jString(param))
case NotValid(ParamItem(param), rule) =>
Json("error" -> Json.jString("param not valid"),
"param" -> Json.jString(param),
"rule" -> Json.jString(rule))
case RequestErrors(errors) =>
Json.array(errors.map(errorToJson(_)):_*)
case error:Throwable => Json("error" ->
Json.jString(error.toString))
}
}
当一个请求传递到这个Service
时,会调用apply
函数。在这个函数中,我们将请求传递给一个类似于下面的RequestReader[SearchParams]
对象:
def getSearchParams: RequestReader[SearchParams] = (
param("status") ::
paramOption("text").should(beLongerThan(5))
).as[SearchParams]
当这个RequestReader
被一个请求调用时,它将首先尝试获取status
参数。如果这个参数找不到,将会抛出一个NotPresent
异常。然而,这并不会停止请求的处理,RequestReader
会获取text
参数的值。如果text
参数可用,它应该至少有五个字符长(注意,我们还有一个shouldNot
函数,用于当你想要检查一个规则不适用时)。如果不是,将会抛出一个NotValid
异常。在前面的例子中,如果发生这种情况,请求的处理将会停止,并且服务不会返回任何响应。为了处理这些异常,我们需要在Future[HttpResponse]
实例(或RequestReader()
函数返回的Future
)上调用handle
函数。
注意
当你开始自己使用 Finch 时,你可能会注意到它不使用 Scala 的标准scala.concurrent.Future
类,而是使用com.twitter.util.Future
中定义的Future
。原因在于 Finch(以及 Finch 内部使用的 Finagle,它是一个 Twitter 项目),Twitter 的 Future 有很多额外的功能。例如,下一节中讨论的handle
函数是 Twitter Future
对象上的标准函数。然而,我们在这本书中使用的TaskService
使用标准的 Scala Future
对象。为了确保我们能够轻松地在 Scala Future
和 Twitter Future
对象之间进行交互,我们创建了一些隐式转换。如果你对它们的外观感兴趣,你可以在src/main/scala/org/restwithscala/chapter2/package.scala
文件中找到这些隐式转换。
handle
函数接受一个部分函数,在这个场景中,它应该返回一个HttpResponse
实例。正如你在代码中看到的,我们只是将验证相关的异常转换为 JSON 对象,并包装在BadRequest
类中。
在示例中,我们展示了我们使用了 beLongerThan
规则。Finch 提供了一些标准规则,您可以使用这些规则来检查特定 RequestReader
的结果是否有效:
规则 | 描述 |
---|---|
beLessThan(n: Int) |
这检查数值是否小于指定的整数。 |
beGreaterThan(n: Int |
这检查数值是否大于指定的整数。 |
beShorterThan(n: Int) |
这检查字符串的长度是否小于指定的整数。 |
beLongerThan(n: Int) |
这检查字符串的长度是否大于指定的整数。 |
and |
这将两个规则组合在一起。两个规则都必须是有效的。 |
or |
这将两个规则组合在一起。其中必须有一个规则是有效的。 |
创建自定义验证规则非常简单。例如,以下代码创建了一个新规则,用于检查字符串是否包含任何大写字母:
val shouldBeLowerCase = ValidationRuleString {!_.exists((c: Char) => c.isUpper) }
def getSearchParams: RequestReader[SearchParams] = (
param("status") ::
paramOption("text").should(beLongerThan(5) and shouldBeLowerCase)
).as[SearchParams]
当我们现在运行查询时,如果我们使用大写字母作为 text
参数,我们也会收到一条消息:
在我们进入下一章之前,我们将更详细地研究最后一部分,即如何创建 HTTP 响应。我们已经通过 Ok
、BadRequest
和 NotFound
情况类看到了一些这方面的内容。Finch 还提供了一些额外的函数来进一步自定义 HTTP 响应消息。您可以使用以下函数来创建响应:
函数 | 描述 |
---|---|
withHeaders(headers: (String, String)*) |
这会将提供的头添加到响应中。 |
withCookies(cookies: Cookie*) |
这会将提供的 cookie 添加到响应中。 |
withContentType(contentType: Option[String]) |
这将响应的内容类型设置为指定的 Option[String] 值。 |
withCharset(charset: Option[String]) |
这将响应的字符集设置为提供的 Option[String] 对象。 |
例如,如果我们想创建一个具有自定义字符集、自定义内容类型、一些自定义头和字符串体的 Ok
响应,我们会这样做:
Ok.withCharset(Some("UTF-8"))
.withContentType(Some("text/plain"))
.withHeaders(("header1" -> "header1Value"),
("header2" -> "header2Value"))("body")
摘要
在本章中,我们介绍了 Finch 框架。使用 Finch 框架,您可以使用函数式方法创建 REST 服务。请求处理是通过将请求映射到 Service
来完成的;使用 RequestReader
验证和解析请求;所有部分都是可组合的,可以从简单部分创建复杂的路由、请求读取器、规则和服务。
在下一章中,我们将深入研究一个采用不同方法的 Scala REST 框架。我们将探讨 Unfiltered,它使用基于模式匹配的方法来定义 REST 服务。
第三章。使用 Unfiltered 的 REST 服务模式匹配方法
在本章中,我们将介绍一个名为Unfiltered的轻量级 REST 框架。使用 Unfiltered,您可以使用标准的 Scala 模式匹配来完全控制如何处理 HTTP 请求和创建 HTTP 响应。在本章中,我们将探讨以下主题:
-
为基于 Unfiltered 的 REST 服务设置基本框架
-
使用匹配器和提取器处理传入的 HTTP 请求
-
以同步和异步方式处理请求
-
使用提取器和指令转换和验证传入的请求和参数
-
自定义响应代码和响应格式
什么是 Unfiltered
Unfiltered 是一个易于使用的轻量级 REST 框架,它提供了一组您可以使用来创建自己的 REST 服务的构造。为此,Unfiltered 使用 Scala 模式匹配,以及一组匹配器和提取器。Unfiltered 有趣的部分之一是它让您完全控制如何处理请求和定义响应。除非您告诉它,否则框架本身不会添加任何标题,也不会对内容类型或响应代码做出假设。
Unfiltered 已经存在了几年,并被许多公司使用。其中一些最知名的是以下两个:
-
Remember the Milk: Remember the Milk 是众所周知的待办事项应用之一。它使用 Unfiltered 来处理所有其公开 API。
-
Meetup: 使用 Meetup,有共同兴趣的人们可以聚集在一起分享知识和安排聚会。Meetup 使用 Unfiltered 来提供其实时 API。
关于 Unfiltered 的更多信息以及文档,您可以查看网站unfiltered.databinder.net/
。
您的第一个 Unfiltered 服务
就像我们在上一章中所做的那样,我们将从创建最基础的 Unfiltered REST 服务开始。本章中使用的示例和框架的依赖项可以在Dependencies.scala
文件中找到,该文件位于project
目录中。对于本章中解释的 Unfiltered 示例,我们使用以下 SBT 依赖项:
lazy val unfilteredVersion = "0.8.4"
val backendDeps = Seq(
"net.databinder" %% "unfiltered-filter" % unfilteredVersion,
"net.databinder" %% "unfiltered-jetty" % unfilteredVersion
)
要与 Unfiltered 一起工作,我们至少需要unfiltered-filter
模块,它包含我们创建路由和处理请求和响应所需的核心类。我们还需要定义我们想要使用哪种类型的服务器来运行 Unfiltered。在这种情况下,我们在嵌入的 Jetty(www.eclipse.org/jetty/
)实例上运行 Unfiltered。
在这些依赖项就绪后,我们可以创建一个最小的 Unfiltered 服务。您可以在本章的源文件中找到此服务的代码,该文件位于HelloUnfiltered.scala
,它位于本章的源文件中:
package org.restwithscala.chapter3.gettingstarted
import unfiltered.request._
import unfiltered.response._
object HelloUnfiltered extends App {
// explicitly set the thread name. If not, the server can't be
// stopped easily when started from an IDE
Thread.currentThread().setName("swj");
// Start a minimalistic server
val echo = unfiltered.filter.Planify {
case GET(Path("/hello")) => ResponseString("Hello Unfiltered")
}
unfiltered.jetty.Server.http(8080).plan(echo).run()
println("Press <enter> to exit.")
Console.in.read.toChar
}
我们在这里做的是创建一个简单的路由,它响应 /hello
路径上的 GET
请求。当 Unfiltered 收到这个请求时,它将使用 ResponseString
对象创建响应来返回 Hello Unfiltered
响应(我们将在稍后讨论这个问题)。这个路由绑定到一个运行在端口 8080
上的 Jetty 服务器。
小贴士
你可能已经注意到了这个例子开头奇怪的 Thread.currentThread().setName
调用。我们这样做的原因是为了避免 Unfiltered 以守护进程模式启动。Unfiltered 尝试检测我们是否是从 SBT 启动或正常运行;如果是通过 SBT 运行,它允许我们只需按下一个键(我们想要的操作)来停止服务器。如果不是,它将在后台运行,并需要一个关闭钩子来停止服务器。它是通过检查当前线程的名称来做到这一点的。如果名称是 main
,它将以守护进程模式运行 Unfiltered,如果名称是其他内容,它将正常运行,这允许我们轻松地停止服务器。所以通过将主线程的名称设置为其他内容,我们也可以在从 IDE 运行时获得这种良好的关闭行为。
此外,我们还需要为 Jetty 设置日志记录,Jetty 是 Unfiltered 所使用的引擎。Jetty 在其默认配置中具有一些繁多的日志记录。为了将 Jetty 的日志记录最小化,只记录有用的信息,我们需要添加一个 logback.xml
配置文件:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern> %d{HH:mm:ss.SSS}[%thread]%-5level%logger{36}-%msg%n
</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
现在,让我们启动服务器并使用 Postman 调用 Unfiltered 服务。要启动这个示例,从源代码的根目录运行 sbt runCH03-HelloUnfiltered
:
$ sbt runCH03-HelloUnfiltered
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter3.gettingstarted.HelloUnfiltered
10:04:32.108 [swj] INFO org.eclipse.jetty.server.Server - jetty-8.1.13.v20130916
10:04:32.178 [swj] INFO o.e.jetty.server.AbstractConnector - Started SocketConnector@0.0.0.0:8080
Embedded server listening at
http://0.0.0.0:8080
Press any key to stop.
打开 Postman,点击集合名称 第三章,然后打开第一个请求。一旦你点击 发送,你应该会看到服务器返回的响应,Hello Unfiltered:
在下一节中,我们将更详细地看看 Unfiltered 是如何使用模式匹配将传入的请求映射到函数的。
HTTP 动词和 URL 匹配
Unfiltered 使用标准的 Scala 模式匹配来确定如何处理特定的请求。以下代码显示了 Unfiltered 为许多简单的 REST 调用提供匹配器的方式:
package org.restwithscala.chapter3.steps
import unfiltered.request._
import unfiltered.response._
object Step1 extends App {
Thread.currentThread().setName("swj");
object api extends unfiltered.filter.Plan {
def intent = taskApi.onPass(fallback)
def taskApi = unfiltered.filter.Intent {
case GET(Path("/tasks"))
=> ResponseString(s"Get all tasks" )
case GET(Path(Seg("tasks" :: id :: Nil)))
=> ResponseString(s"Get a single task with id: $id" )
case DELETE(Path(Seg("tasks" :: id :: Nil)))
=> ResponseString
(s"Delete an existing task with id $id")
case req @ Path("/tasks") => req match {
case POST(_)
=> ResponseString(s"Create a new" +
s" task with body ${Body.string(req)}")
case PUT(_)
=> ResponseString(s"Update a new task with" +
s" body ${Body.string(req)}")
case _ => Pass
}
case _ => Pass
}
def fallback = unfiltered.filter.Intent {
case _ => NotImplemented ~>
ResponseString("Function is not implemented")
}
}
unfiltered.jetty.Server.http(8080).plan(api).run()
println("Press <enter> to exit.")
Console.in.read.toChar
}
在这个代码片段中有很多东西可以看,所以让我们从开始的地方开始。要在 Unfiltered 中创建一个 REST API,我们需要创建一个 Plan
对象。一个 Plan
描述了如何响应特定的请求。有两种不同的方式来创建这样的 Plan
。你可以直接传递一个部分函数到 unfiltered.filter.Planify
,就像我们在本章前面的入门示例中所做的那样,或者显式地扩展 unfiltered.filter.Plan
并设置 intent val
为你的路由配置。在本章的其余部分,我们将使用后一种方法,因为它允许我们以简单的方式组合 API 部分。让我们从 taskApi
实例的第一个匹配器集合开始:
case GET(Path("/tasks"))
=> ResponseString(s"Get all tasks" )
case GET(Path(Seg("tasks" :: id :: Nil)))
=> ResponseString(s"Get a single task with id: $id" )
case DELETE(Path(Seg("tasks" :: id :: Nil)))
=> ResponseString(s"Delete an existing task with id $id")
如您所见,我们使用了 Scala 的标准模式匹配。这些模式将与当前的 HttpRequest
实例进行匹配,并使用 Unfiltered 提供的多个匹配器:
匹配器 | 描述 |
---|---|
GET 和其他 HTTP 动词。 |
使用此匹配器,我们可以匹配请求的 HTTP 动词。Unfiltered 为此提供了以下标准匹配器:GET 、POST 、PUT 、DELETE 、HEAD 、CONNECT 、OPTIONS 、TRACE 、PATCH 、LINK 和 UNLINK 。如果您有需要匹配其他动词的边缘情况,您可以轻松创建自己的匹配器,例如:object WOW extends Method("WOW") |
Path |
我们接下来看到的匹配器是 Path 匹配器。使用此匹配器,您可以检查是否匹配完整的 URL 路径。因此,在前面的示例中,第一个模式仅在精确的 /tasks 路径被调用时匹配。 |
Seq |
如果您想提取路径段或更灵活地在多个路径段上进行匹配,您可以使用 Seq 匹配器。此匹配器将检查请求所发生的 URL,将其拆分为路径段,并检查单个路径段是否匹配或提取路径段以进行进一步处理。 |
因此,在我们的 API 中:
-
第一个案例匹配
/tasks
路径上的GET
请求。 -
第二个案例匹配
/tasks/:id
路径上的GET
请求。ID 将传递到处理此案例的函数中。 -
第三个案例与第二个案例相同,但这次是为
DELETE
请求。 |
除了本例中显示的匹配器之外,Unfiltered 还提供了以下功能:
匹配器 | 描述 |
---|---|
HTTP 和 HTTPS |
这两个匹配器允许您检查请求是否通过 HTTP 或 HTTPS 连接接收。 |
| Json
/JavaScript
/XML
等 | Unfiltered 还允许您检查请求的 Accepts
标头。例如,当 Accepts
标头为 application/json
或请求的 URL 有 .js
扩展名(没有 Accepts
标头)时,JSON 匹配器将匹配。Unfiltered 为此类型提供了以下标准匹配器:Json
、JavaScript
、AppJavaScript
、Jsonp
、Xml
、Html
和 Csv
。如果您想指定新的内容类型匹配器,可以这样做:
object Custom extends Accepting {
val contentType = "application/vnd+company.category"
val ext = "json"
}
|
HTTP_1_0 /HTTP_1_1 |
检查是否使用了 HTTP 版本 1.0 或 HTTP 版本 1.1 的协议。 |
---|---|
Mime |
此匹配器允许您检查请求是否符合特定的 MIME 类型。 |
任何标题 | Unfiltered 还提供了一组其他 HTTP 标头的匹配器和提取器。这里列出的太多,无法一一列举;要获取完整概述,请查看 Unfiltered 源代码中的 headers.scala 文件中的对象。 |
Params |
使用此匹配器提取器,您可以匹配特定的请求参数。我们将在下一节中展示此提取器的示例。 |
RemoteAddr |
特定匹配器,用于检查 XForwaredFor 标头是否包含受信任的地址。 |
让我们回顾一下示例,特别是对/tasks
URL 上的PUT
和POST
调用。对于这两个路由,我们使用了另一种方法:
case req @ Path("/tasks") => req match {
case POST(_)
=> ResponseString(s"Create a new" +
s" task with body ${Body.string(req)}")
case PUT(_)
=> ResponseString(s"Update a new task with" +
s" body ${Body.string(req)}")
case _ => Pass
}
在这里,我们首先匹配Path("/tasks")
路由,然后使用匹配的请求来确定对不同动词的处理。这是处理可以使用相同 URL 进行调用的多个动词的便捷方式。
在这种情况下,我们可以处理POST
和PUT
调用,并忽略任何其他调用,通过返回Pass
。当我们返回Pass
时,我们只是告诉 Unfiltered,这个意图无法处理请求。当 Unfiltered 无法匹配当前意图的请求时,它将尝试下一个。我们在示例中使用了这种方法:
object api extends unfiltered.filter.Plan {
def intent = taskApi.onPass(fallback)
def taskApi = unfiltered.filter.Intent { ... }
def fallback = unfiltered.filter.Intent {
case _ => NotImplemented ~>
ResponseString("Function is not implemented")
}
在我们的计划
中,我们定义了两个意图:一个处理我们的 API,即taskAPI
意图,另一个在taskAPI
意图不匹配时可以作为后备,命名为fallback
(我们将在本章后面详细解释这一点)。fallback
意图返回带有消息的 HTTP 代码NotImplemented
。通过调用taskApi.onPass(fallback)
,我们告诉 Unfiltered,当taskAPI
意图返回Pass
结果时,它应该尝试fallback
意图。fallback
意图还可以配置onPass
结果,这样,你可以轻松地链式和组合 API。
要测试此服务,请使用sbt runCH03-runCH03Step1
命令启动它:
$ sbt runCH03-runCH03Step1
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter3.steps.Step1
15:06:36.085 [swj] INFO org.eclipse.jetty.server.Server - jetty-8.1.13.v20130916
15:06:36.160 [swj] INFO o.e.jetty.server.AbstractConnector - Started SocketConnector@0.0.0.0:8080
Embedded server listening at
http://0.0.0.0:8080
Press any key to stop.
Postman 中的第三章集合为您提供了许多可以用来测试此服务器的请求。例如,创建新任务的调用看起来像这样:
当我们访问fallback
路由时,我们看到以下截图:
到目前为止,我们只关注了路由和请求匹配。在下一节中,我们将探讨如何访问请求参数并使用 Scala 的Future
对象创建异步响应。
提取请求参数和使用未来进行异步响应
现在我们已经了解了基础知识,让我们看看我们需要做什么来将传入的请求参数和正文转换为我们的领域模型(我们的案例类)。在本节中,我们将采取以下步骤:
-
将传入的请求转换为
Task
案例类。 -
将转换后的
Task
存储在我们的虚拟TaskService
中。 -
TaskService
对象返回Future[Task]
;我们将更改 Unfiltered 配置以开始异步处理请求。
让我们从第一部分开始,看看路由配置以及如何将传入的请求转换为Task
案例类。此示例的完整源代码可以在rest-with-scala/chapter-03/src/main/scala/org/restwithscala/chapter3/steps/
目录下的Step2.scala
文件中找到。
让我们先展示完整的代码,然后我们将更详细地查看各个部分。请注意,我们在这里只实现了完整任务 API 的一部分:
object Step2 extends App {
implicit def ec = ExecutionContext.Implicits.global
@io.netty.channel.ChannelHandler.Sharable
object api extends future.Plan with ServerErrorResponse {
implicit def executionContext = ec
def intent = {
case GET(Path("/tasks")) => Future
{ResponseString(s"Get all tasks" )}
case GET(Path(Seg("tasks" :: id :: Nil))) => Future
{ResponseString(s"Get a single task with id: $id" )}
case DELETE(Path(Seg("tasks" :: id :: Nil))) => Future
{ResponseString(s"Delete an existing task with id $id")}
case req @ Path("/tasks")) => req match {
case POST(_) =>
handleCreateTask(req).map(ResponseString(_))
case PUT(_) =>
handleCreateTask(req).map(ResponseString(_))
case _ => Future {Pass}
}
case _ => Future{Pass}
}
}
@io.netty.channel.ChannelHandler.Sharable
object fallback extends future.Plan with ServerErrorResponse {
implicit def executionContext = ec
def intent = {
case _ => Future {NotImplemented ~>
ResponseString("Function is not implemented")}
}
}
def handleCreateTask(req: HttpRequest[Any]): Future[String] = {
val task = requestToTask(TaskService.nextTaskId(), req)
val inserted = task map(TaskService.insert(_).map(_.toString))
inserted.getOrElse(Future{"Error inserting"})
}
def paramExtractor(param: String): Extract[String] = {
new ExtractString
}
def requestToTask(id: Long, req: HttpRequest[Any])
: Option[Task] = {
val title = paramExtractor("title")
req match {
case Params(title(param)) => Some(Task(
id, param, Body.string(req),
None, List.empty[Note], Status("")))
case _ => None
}
}
unfiltered.netty.Server.http(8080)
.handler(api)
.handler(fallback).run
dispatch.Http.shutdown()
}
我们将更详细地探讨的第一部分是如何将一个传入的请求转换为Task
案例类。
将请求转换为 Task 类
以下代码片段展示了我们如何使用 Unfiltered 将请求转换为Task
案例类:
def paramExtractor(param: String): Extract[String] = {
new ExtractString
}
def requestToTask(id: Long, req: HttpRequest[Any]): Option[Task] = {
val title = paramExtractor("title")
req match {
case Params(title(param)) => Some(Task(
id,
param,
Body.string(req),
None,
List.empty[Note],
Status("")))
case _ => None
}
}
我们定义了一个名为requestToTask
的函数,该函数将传入的HttpRequest
和一个 ID 转换为Task
。在这个函数中,我们采取的第一步是创建一个基于多个标准 Unfiltered 构建的自定义提取器:
def paramExtractor(param: String): Extract[String] = {
new ExtractString
}
在这个函数中,我们通过扩展 Unfiltered 提供的Extract
类来创建一个自定义提取器。这个类有一个带有两个参数的构造函数。第一个参数是我们想要提取的参数名称,第二个参数是一个具有Seq[String] => Option[T]
签名的函数。第二个参数可以用来提供一组断言,Unfiltered 使用这些断言来检查值是否可用,并且格式是否正确。在这种情况下,我们使用了 Unfiltered 提供的两个断言。Params.first
返回具有提供名称的第一个参数值或None
,而Params.nonempty
如果Params.first
的结果为空,则返回None
,如果不为空,则返回Some[String]
。正如你所见,我们可以使用~>
运算符来链接断言(这个运算符只是andThen
函数的语法糖)。
我们现在可以在我们的模式匹配逻辑中使用这个提取器:
req match {
case Params(title(param)) => ...
这意味着这个模式只有在提供的参数中有一个名为title
且不为空的情况下才会匹配。Unfiltered 默认提供以下构建块,您可以在创建新的匹配器和提取器时使用:
断言 | 描述 |
---|---|
even |
如果参数值可以转换为整数并且结果是偶数,它将返回参数值。 |
odd |
如果参数值可以转换为整数并且结果是奇数,它将返回参数值。 |
nonempty |
如果参数值不为空,则返回参数值。 |
first |
这返回参数的第一个值。例如,在?id=10&id=20 的情况下,它将返回10 。 |
int |
如果参数值可以转换为整数,它将返回整数值。 |
long |
如果参数值可以转换为长整型,它将返回长整数值。 |
float |
如果参数值可以转换为浮点数,它将返回浮点数值。 |
double |
如果参数值可以转换为双精度浮点数,它将返回双精度浮点数值。 |
trimmed |
这将使用String.trim 来修剪参数值。 |
~> |
这创建了一组断言,例如,first ~> nonempty ~> trimmed 。 |
在这一点上,我们检查是否有一个非空的标题参数,如果有,我们就将传入的请求转换为Task
。
提示
Unfiltered 还提供了一种您可以同时使用多个提取器的方法。使用&
匹配器,您可以组合两个提取器。模式只有在&
两边的匹配都成功时才会匹配。例如,我们可以检查参数是否包含非空标题和一个整数值,如下所示:
val title = new ExtractString
val amount = new ExtractString
然后用模式使用它,如下所示:
case Params(title(titleValue) & amount(amountValue))
我们将在本章后面的验证部分看到更多关于此的内容。
要最终将请求转换为Task
,我们只需直接调用构造函数,如下所示:
case Params(title(param)) => Some(Task(
id,
param,
Body.string(req),
None,
List.empty[Note],
Status("")))
现在我们可以将请求转换为Task
,让我们看看我们如何从我们的路由调用它并将其存储。
在 TaskService 中存储请求
要将请求存储在TaskService
中,我们必须调用TaskService.insert
函数。我们使用以下代码来完成:
case req @ Path("/tasks") => req match {
case POST(_) => handleCreateTask(req).map(ResponseString(_))
case PUT(_) => handleCreateTask(req).map(ResponseString(_))
case _ => Future {Pass}
}
...
def handleCreateTask(req: HttpRequest[Any]): Future[String] = {
val task = requestToTask(TaskService.nextTaskId(), req)
val insertedTask = task
map(TaskService.insert(_).map(_.toString))
insertedTask.getOrElse(Future{"Error inserting"})
}
当我们在/tasks
上接收到POST
或PUT
请求时,我们将请求传递给handleCreateTask
函数。在这个函数中,我们使用之前讨论的代码将请求转换为Task
,并使用TaskService.insert
函数将其存储。目前,如果我们成功转换并存储了Task
,我们将只返回创建的Task
的toString
。如果出现问题,我们将返回一个简单的错误消息,也是一个字符串。然后我们使用ResponseString
函数返回创建的Task
或错误消息。
handleCreateTask
函数返回一个Future[String]
对象,所以我们必须确保我们的 Unfiltered 配置可以处理未来。
配置 Unfiltered 以与未来一起工作
将配置从同步模型更改为异步模型非常简单。我们首先需要做的是将底层服务器从 Jetty 更改为 Netty。我们需要这样做是因为异步功能建立在 Netty 之上,因此不会与 Jetty 一起工作。要使用 Netty,我们需要将以下两个依赖项添加到我们的 SBT 配置中:
"net.databinder" %% "unfiltered-netty" % "0.8.4"
"net.databinder" %% "unfiltered-netty-server" % "0.8.4"
接下来,我们需要更改我们创建 API 的方式:
implicit def ec = ExecutionContext.Implicits.global
@io.netty.channel.ChannelHandler.Sharable
object api extends future.Plan with ServerErrorResponse {
def executionContext = ec
def intent = ..
}
我们不是从unfiltered.filter.Plan
扩展,而是从future.Plan
(带有ServerErrorResponse
,您可以用它以通用方式处理异常)。如果我们使用未来,我们还需要定义我们想要使用的执行上下文。这是 Unfiltered 用来异步运行未来的。在这种情况下,我们只是使用默认的全局执行上下文。请注意,我们需要在我们的 API 上添加io.netty.channel.ChannelHandler.Sharable
注解。如果我们不这样做,Netty 将阻止任何传入的请求,并且同一时间只有一个线程可以访问 API。由于我们将我们的服务移至 Netty,我们还需要以稍微不同的方式启动服务器:
unfiltered.netty.Server.http(8080)
.handler(api)
.handler(fallback).run
dispatch.Http.shutdown()
我们需要采取的最后一步是与未来一起工作,确保所有我们的模式匹配案例也返回一个Future
对象。我们只需通过将响应包装在Future
中即可完成此操作:
case GET(Path("/tasks")) => Future
{ResponseString(s"Get all tasks" )}
case GET(Path(Seg("tasks" :: id :: Nil))) => Future
{ResponseString(s"Get a single task with id: $id" )}
case DELETE(Path(Seg("tasks" :: id :: Nil))) => Future
{ResponseString(s"Delete an existing task with id $id")}
case req @ Path("/tasks")) => req match {
case POST(_) =>
handleCreateTask(req).map(ResponseString(_))
case PUT(_) => handleCreateTask(req).map(ResponseString(_))
case _ => Future {Pass}
}
case _ => Future{Pass}
大多数响应都明确地封装在 Future
对象中。对于 handleCreateTask
函数,我们已接收到一个 Future[String]
对象,因此只需使用 map
并通过一个 ResponseString
实例将其转换为正确的类型即可。
对于所有示例,我们还提供了一组您可以用来测试此 REST API 的样本请求。您可以在 Postman 的 第三章 收集中找到这些请求。最有趣的请求是 步骤 02 – 创建任务、步骤 02 – 创建任务 – 无效 和 步骤 02 – 触发回退。
为参数处理添加验证
到目前为止,我们还没有真正验证传入的请求。我们只是检查是否提供了参数,如果没有,就完全拒绝请求。这可行,但验证输入参数的方式相当繁琐。幸运的是,Unfiltered 通过使用称为指令的东西提供了一种替代的验证方式。使用指令,您告诉 Unfiltered 您期望什么,例如,一个可以转换为 int 的参数,Unfiltered 将获取该值或返回适当的响应消息。换句话说,使用指令,您定义了一组请求必须满足的标准。
介绍指令
在我们查看如何在我们的场景中使用指令之前,让我们看看您如何在代码中使用指令:
import unfiltered.directives._, Directives._
val intent = { Directive.Intent {
case Path("/") =>
for {
_ <- Accepts.Json
_ <- GET
amount <- data.as.Option[Int].named("amount") } yield JsonContent ~> ResponseString(
"""{ "response": "Ok" }""")
}
在这个意图中,我们使用 Directive.Intent
来表示我们想要创建一个使用指令处理请求的意图。在这个示例中,Accepts.Json
、GET
和 data.as.Option[Int].named("amount")
都是指令。当其中一个指令失败时,会自动返回适当的错误响应。使用指令,您几乎可以将您的匹配器和提取器逻辑移动到一组指令中。
默认情况下,Unfiltered 包含一系列指令,当它们不匹配时将自动返回响应:
指令 | 描述 |
---|---|
GET 、POST 以及其他 HTTP 动词 |
您可以匹配所有方法。如果方法不匹配,将返回 MethodNotAllowed 响应。 |
Accepts.Json |
所有 Accepts.Accepting 定义都受支持。如果其中之一失败,您将收到 NotAcceptable 响应。 |
QueryParams |
从请求中获取所有查询参数。 |
Params |
您可以检查单个参数是否可用。如果没有,将返回 BadRequest 响应。 |
data.as |
data.as 指令允许您获取一个参数并将其转换为特定值。它提供标准指令以将参数转换为:BigDecimal、BigInt、Double、Float、Int、Long 和 String。除此之外,它还允许您指定一个值是可选的还是必需的。 |
让我们更详细地看看 data.as
指令,因为当尝试验证输入时,这是最有趣的一个。要使用此指令,我们首先定义一个隐式函数,如下所示:
implicit val intValue =
data.as.String ~> data.as.Int.fail { (k,v) =>
BadRequest ~> ResponseString(
s"'$v' is not a valid int for $k"
)
}
并像这样从 for
推导中使用它:
...
for {
value <- data.as.Int.named("amount")
} yield {...}
如果请求参数中存在名为amount
的参数并且可以转换为整数,我们将检索该值。如果不存在,则不会发生任何操作,如果无法转换为整数,将返回指定的BadRequest
消息。我们也可以通过请求data.as.Option[Int]
来将其设置为Optional
。将值作为选项是很好的,但有时你想要确保特定的查询参数始终存在。为此,我们可以使用Required
。要使用Required
,我们首先必须添加另一个implicit
函数来定义当缺少必需字段时会发生什么:
implicit def required[T] = data.Requiring[T].fail(name =>
BadRequest ~> ResponseString(name + " is missing\n")
)
这意味着当缺少Required
字段时,我们会返回一个指定响应的BadRequest
消息。要使用Required
字段,我们只需将data.as.Int
更改为data.as.Required[Int]
:
...
for {
value <- data.as.Required[Int].named("amount")
} yield {...}
现在,Unfiltered 将首先检查字段是否存在,如果存在,将检查它是否可以转换为整数。当其中一个检查失败时,将返回正确的响应消息。
将搜索功能添加到我们的 API
现在,让我们继续我们的示例。对于这个场景,我们将在我们的 API 中添加一个search
函数。这个search
函数将允许你根据任务的状态和文本进行搜索,并返回匹配的任务列表。在我们深入到各个部分之前,让我们先看看完整的代码:
package org.restwithscala.chapter3.steps
import org.restwithscala.common.model._
import org.restwithscala.common.service.TaskService
import unfiltered.directives.{Directive => UDirective, ResponseJoiner, data}
import unfiltered.request._
import unfiltered.response._
import unfiltered.netty._
import scala.concurrent.{ExecutionContext}
import scala.concurrent.Future
import scalaz._
import scalaz.std.scalaFuture._
object Step3 extends App {
/**
* Object holds all the implicit conversions used by Unfiltered to
* process the incoming requests.
*/
object Conversions {
case class BadParam(msg: String) extends ResponseJoiner(msg)(
msgs =>
BadRequest ~> ResponseString(msgs.mkString("","\n","\n"))
)
implicit def requiredJoin[T] = data.Requiring[T].fail(name =>
BadParam(name + " is missing")
)
implicit val toStringInterpreter = data.as.String
val allowedStatus = Seq("new", "done", "progress")
val inStatus = data.ConditionalString).fail(
(k, v) => BadParam(s" value not allowed: $v, should be one of ${allowedStatus.mkString(",")} ")
)
}
Thread.currentThread().setName("swj");
implicit def ec = ExecutionContext.Implicits.global
// This plan contains the complete API. Works asynchronously
// directives by default don't work with futures. Using the d2
// directives, we can wrap the existing directives and use the
// async plan.
@io.netty.channel.ChannelHandler.Sharable
object api extends async.Plan with ServerErrorResponse {
// Import the required d2 directives so we can work
// with futures and directives together. We also bring
// the implicit directive conversions into scope.
val D = d2.Directives[Future]
import D._
import D.ops._
import Conversions._
// maps the requests so that we can use directives with the
// async intent. In this case we pass on the complete request
// to the partial function
val MappedAsyncIntent = d2.Async.Mapping[Any, HttpRequest[Any]] {
case req: HttpRequest[Any] => req
}
// d2 provides a function to convert standard Unfiltered
// directives to d2 directives. This implicit conversion
// makes using this easier by adding a toD2 function to
// the standard directives.
implicit class toD2T, L, R {
def toD2 = fromUnfilteredDirective(s)
}
// our plan requires an execution context,
def executionContext = ec
def intent = MappedAsyncIntent {
case Path("/search") => handleSearchSingleError
}
def handleSearchSingleError = for {
status <- inStatus.named("status").toD2
text1 <- data.as.Required[String].named("text").toD2
tasks <- TaskService
.search(status.get,Some(text1)).successValue
} yield {Ok ~> ResponseString(tasks.toString()) }
}
unfiltered.netty.Server.http(8080).handler(api).run
dispatch.Http.shutdown()
}
代码很多,其中一些可能看起来有点奇怪。在接下来的章节中,我们将看到为什么我们这样做。
指令和与未来的协作
在我们深入研究指令之前,你可能注意到一些关于 d2 的额外代码。对于这个示例,我们需要使用来自github.com/shiplog/directives2/
的Directives2
库,以便我们可以正确地结合使用未来和指令。标准的指令,如 Unfiltered 提供的,不支持异步计划,并且只允许你使用同步的 Jetty 方法。通过Directives2
指令,我们可以与未来一起工作,并使用 Unfiltered 提供的可用的异步计划之一。
然而,为了使这一切正常工作,我们需要一些粘合代码。以下是对之前步骤所做的更改,以便指令可以很好地与未来一起工作:
从future.Plan
迁移到async.Plan
:
@io.netty.channel.ChannelHandler.Sharable
object api extends async.Plan with ServerErrorResponse {
d2 指令支持async.Plan
类,但不支持future.Plan
。幸运的是,这并没有改变我们其余的代码。下一步是导入 d2 类和对象:
val D = d2.Directives[Future]
import D._
import D.ops._
import Conversions._
通过这些导入,我们获得了将未来作为指令进行操作的能力,并允许我们将标准的 Unfiltered 指令转换为 d2 指令。下一步是使用d2.Async.Mapping
对象将我们的新异步指令粘接到我们的async.Plan
:
val MappedAsyncIntent = d2.Async.Mapping[Any, HttpRequest[Any]] {
case req: HttpRequest[Any] => req
}
在这个设置中,我们将接收到的任何请求传递给一个部分函数,我们定义如下:
def intent = MappedAsyncIntent {
case Path("/search") => handleSearchSingleError
}
现在每当我们在/search
路径上收到请求时,我们将其传递给handleSearchSingleError
函数。我们做的最后一步是创建一个简单的辅助方法,将我们的标准指令转换为 d2 指令:
implicit class toD2T, L, R {
def toD2 = fromUnfilteredDirective(s)
}
当这个隐式
作用域有效时,我们只需在我们的常规指令上调用toD2
,这样它们就可以与来自 d2 库的指令正确地协同工作。
添加验证到请求参数
现在我们已经处理完 d2 相关的内容,让我们看看我们验证的定义。我们已经在Conversions
对象中隐式地定义了所有我们的指令:
object Conversions {
case class BadParam(msg: String) extends ResponseJoiner(msg)(
msgs =>
BadRequest ~> ResponseString(msgs.mkString("","\n","\n"))
)
implicit def required[T] = data.Requiring[T].fail(name =>
BadParam(name + " is missing")
)
implicit val toStringInterpreter = data.as.String
val allowedStatus = Seq("new", "done", "progress")
val inStatus = data.ConditionalString).fail(
(k, v) => BadParam(s" value not allowed: $v, should
be one of ${allowedStatus.mkString(",")} ")
)
}
在这里,我们定义了三个隐式值——required
检查一个值是否存在,toStringInterpreter
尝试将参数转换为字符串,而inStatus
检查一个字符串是否是特定值集合中的一个。如果其中任何一个失败,fail
函数将被调用并返回一个错误。然而,在这里,我们并没有直接将错误作为HttpResponse
返回,而是将其作为BadParam
类返回。这个BadParam
案例类作为错误收集器,允许以标准方式报告一个或多个错误。在下一节中,我们将回到这一点。现在,我们只报告我们看到的第一个错误。我们通过设置一个这样的for
理解来实现这一点:
def handleSearchSingleError = for {
status <- inStatus.named("status").toD2
text1 <- data.as.Required[String].named("text").toD2
tasks <- TaskService
.search(status.get,Some(text1)).successValue
} yield {Ok ~> ResponseString(tasks.toString()) }
这个for
理解工作得就像任何正常的for
理解一样。首先,我们检查状态查询参数是否有效。如果是,我们获取文本值,然后我们使用这两个值在TaskService
中搜索,最后将TaskService
的结果作为字符串返回。这里的一个评论是我们对Future
返回的successValue
函数的调用。这是一个 d2 特定的调用,它将Future
转换为指令。
让我们打开 Postman,首先使用无效的状态发送一个请求:
如您所见,错误消息显示了我们的期望。然而,如您可能已经注意到的,我们也没有输入文本值,但错误消息没有显示这一点。原因是我们的for
理解在第一个错误处停止。
幸运的是,Unfiltered 提供了一种合并错误的方法。我们只需将我们的for
循环更改为以下内容:
def handleSearchCombinedError = for {
(status, text) <- (
(data.as.Required[String].named("status")) &
(data.as.Required[String].named("text"))
).toD2
tasks <- TaskService.search(status,Some(text)).successValue
} yield {
Ok ~> ResponseString(tasks.toString())
通过使用&
运算符,我们可以组合指令。现在,组合指令的每个部分都将使用BadParam
案例类记录其错误,这将响应它收集的所有错误。您可以在以下屏幕截图中看到这是如何工作的:
摘要
在本章中,我们了解了 Unfiltered 的一些最重要的方面。你学习了如何处理请求,使用匹配器和提取器来路由请求,以及如何访问参数和路径段。你还了解到 Unfiltered 提供了不同的处理模型,即同步和异步,以及如何在 Jetty 或 Netty 之上运行你的服务。在最后一节中,我们探讨了如何在 Unfiltered 中使用指令以更强大的方式提取参数,以及在使用异步方式时需要采取的额外步骤。
总的来说,正如你所见,Unfiltered 是一个非常灵活的框架,易于扩展,并让你完全控制响应-请求周期。
第四章:使用 Scalatra 创建简单的 REST 服务
在前面的章节中,您学习了如何使用使用函数式、类似 Scala 语言的方法的框架来创建 REST 服务。Finch 使用了一种非常基于函数式编程的方法,而 Unfiltered 使用模式匹配。在本章中,我们将探索一个 Scala 框架,Scalatra,其主要目标是简单性。
在本章中,我们将通过以下示例解释 Scalatra 的功能:
-
第一个 Scalatra 服务:我们将创建一个简单的 Scalatra 服务,展示您如何快速启动。
-
动词和路径处理:Scalatra 提供了一系列构造,您可以使用它们来定义路由。一个路由可以匹配特定的 HTTP 动词和路径,并在匹配时返回特定的响应。
-
添加对未来的支持和简单验证:在其标准配置中,Scalatra 是同步工作的。在本部分,我们将向您展示如何添加对未来的支持,并添加一些基本的验证。
-
转换到和从 JSON,并支持高级验证:在本章的最后部分,我们将探讨 JSON 支持,并解释 Scalatra 如何支持更高级的验证传入请求的方式。
首先,让我们快速了解一下 Scalatra 是什么,以及它的目标是什么。
Scalatra 简介
Scalatra 是一个小巧的 Scala Web 框架,它试图保持简单。它提供了一套指南和辅助类,用于创建完整的 Web 应用程序。在本章中,我们将重点关注它为创建 REST 服务提供的支持。
Scalatra 在设计时考虑了多个原则(来自 Scalatra 主页):
从小处着手,逐步构建:从一个小的核心开始,并为常见任务提供大量易于集成的功能。
自由:允许用户自由选择对正在构建的应用程序最有意义的结构和库。
坚实但不过于僵化:使用坚实的基组件。例如,servlets 可能不是酷的,但它们非常稳定,并且有一个庞大的社区支持。同时,通过使用新技术和方法来推进 Web 应用程序开发的状态。
热爱 HTTP:拥抱 HTTP 及其无状态特性。当人们自欺欺人地认为一些不真实的事情时,他们会陷入麻烦 - 花哨的服务器端技巧来营造状态性的错觉并不适合我们。
正如您在本章中看到的,Scalatra 的主要目标是保持简单。
您的第一个 Scalatra 服务
要使我们的第一个 Scalatra 服务运行起来,我们需要采取一些额外的步骤。原因是 Scalatra 被设计为在 servlet 容器(例如 Tomcat 或 Jetty)中运行。虽然这对于测试和生产环境来说效果很好,但它不允许我们进行快速原型设计或轻松地从 SBT 或 IDE 中运行。幸运的是,您也可以以编程方式启动 Jetty servlet 容器,并从那里运行 Scalatra 服务。因此,在本节中,我们将:
-
首先展示运行 Scalatra 所需的依赖项
-
以这种方式设置 Jetty,使其能够运行我们的 Scalatra REST 服务
-
创建一个简单的 Scalatra 服务,以响应特定的
GET
请求
首先,让我们看看 Scalatra 的依赖项。你可以在 sources
目录下的 project
目录中的 Dependencies.scala
文件中找到这些依赖项。对于 Scalatra(以及 Jetty),我们定义以下内容:
lazy val scalatraVersion = "2.3.0"
val backendDeps = Seq(
"org.scalatra" %% "scalatra" % scalatraVersion,
"ch.qos.logback" % "logback-classic" % "1.1.3",
"org.eclipse.jetty" % "jetty-webapp" % "9.2.10.v20150310"
)
这个 Seq
中的第一个依赖项引入了所有必需的 Scalatra 库,第二个依赖项允许我们定义 Jetty 的日志记录方式和内容,最后一个依赖项是必需的,这样我们才能从我们的项目中以编程方式启动 Jetty。
定义了这些依赖项之后,我们可以创建一个嵌入式的 Jetty 服务器,我们可以使用它来提供我们的 REST 服务。这个启动器的代码可以在 chapter4/package.scala
文件中找到:
object JettyLauncher {
def launch(bootstrapClass: String): Server = {
// define the servlet context, point to our Scalatra servlet
val context = new WebAppContext()
context.setContextPath "/"
context.setResourceBase("src/main/webapp")
context.setInitParameter(ScalatraListener
.LifeCycleKey, bootstrapClass)
context.addEventListener(new ScalatraListener)
context.addServlet(classOf[DefaultServlet], "/")
// create a server and attach the context
val server = new Server(8080)
server.setHandler(context)
// add a lifecycle listener so to stop the server from console
server.addLifeCycleListener(new AbstractLifeCycleListener() {
override def lifeCycleStarted(event: LifeCycle): Unit = {
println("Press <enter> to exit.")
Console.in.read.toChar
server.stop()
}
})
// start and return the server
server.start
server.join
server
}
}
我们不会深入探讨这段代码,因为它实际上与 Scalatra 并无太大关联。这里要理解的主要是,我们定义了一个名为 launch
的函数,它接受一个启动类名称作为参数(稍后会有更多介绍),并且我们使用 addEventListener
函数添加了一个 ScalatraListener
实例。一旦 Jetty 服务器启动完成,ScalatraListener
将被调用,并使用提供的 bootstrapClass
启动 Scalatra 服务。
现在我们已经创建了一种启动我们的 Scalatra 服务的方法,让我们看看最基础的示例(源代码可以在 HelloScalatra.scala
文件中找到):
package org.restwithscala.chapter4.gettingstarted
import org.restwithscala.chapter4.JettyLauncher
import org.scalatra.{ScalatraServlet, LifeCycle}
import javax.servlet.ServletContext
// run this example by specifying the name of the bootstrap to use
object ScalatraRunner extends App {
JettyLauncher.launch(
"org.restwithscala.chapter4.gettingstarted.ScalatraBootstrap")
}
// used by jetty to mount the specified servlet
class ScalatraBootstrap extends LifeCycle {
override def init(context: ServletContext) {
context mount (new HelloScalatra, "/*")
}
}
// the real servlet code
class HelloScalatra extends ScalatraServlet {
notFound {
"Route not found"
}
get("/") {
"Hello from scalatra"
}
}
让我们从头到尾浏览这个文件。在顶部,我们定义了一个名为 ScalatraRunner
的对象。通过这个对象,我们通过调用之前看到的 JettyLauncher
上的 launch
方法来启动我们的 REST 服务。我们还向启动器传递了 ScalatraBootstrap
类的名称,这样我们之前看到的 ScalatraListener
就可以在 Jetty 启动完成后调用 ScalatraBootstrap 的 init
方法。在 ScalatraBootstrap
类中,我们实现了 init
方法,并使用它来实例化我们的 REST 服务(在这个例子中,它被称为 HelloScalatra
),并通过调用 mount 使其对外界可用。在本章的每个示例中,我们都会使用这种方法。在 HelloScalatra
类中,我们最终看到了 REST 服务的定义。在这种情况下,我们定义了一个路由,当它收到 /
路径上的 GET
请求时,返回 Hello from scalatra
。如果没有路由匹配,notFound
函数将被触发,返回一个 404
消息,表明“路由未找到”。
剩下的工作就是测试这两个场景。从源代码目录中,运行 sbt runCH04-HelloScalatra
。这应该会显示类似于以下内容的输出:
$ sbt runCH04-HelloScalatra
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter4.gettingstarted.ScalatraRunner
20:42:09.020 [run-main-0] INFO org.eclipse.jetty.util.log - Logging initialized @31722ms
20:42:09.536 [run-main-0] INFO org.eclipse.jetty.server.Server - jetty-9.2.10.v20150310
20:42:09.940 [run-main-0] INFO o.e.j.w.StandardDescriptorProcessor - NO JSP Support for /, did not find org.eclipse.jetty.jsp.JettyJspServlet
20:42:10.015 [run-main-0] INFO o.scalatra.servlet.ScalatraListener - The cycle class name from the config: org.restwithscala.chapter4.gettingstarted.ScalatraBootstrap
20:42:10.304 [run-main-0] INFO o.scalatra.servlet.ScalatraListener - Initializing life cycle class: ScalatraBootstrap
20:42:10.643 [run-main-0] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.w.WebAppContext@78dac2c7{/,file:/Users/jos/dev/git/rest-with-scala/src/main/webapp,AVAILABLE}
20:42:10.924 [run-main-0] INFO o.e.jetty.server.ServerConnector - Started ServerConnector@15f336ae{HTTP/1.1}{0.0.0.0:8080}
20:42:10.925 [run-main-0] INFO org.eclipse.jetty.server.Server - Started @33637ms
Press <enter> to exit.
到目前为止,我们可以按 Enter 键停止服务器,或者启动 Postman 来测试我们的服务。在 Postman 中,你会找到一个针对本章的请求集合;让我们只测试这个请求(hello scalatra
),它返回我们的 Hello from scalatra
消息,这样我们就可以知道一切是否按预期工作:
如前一个屏幕截图所示,Scalatra 的响应正如我们所期望的,因此我们的基本 Scalatra 设置工作正确。
动词和路径处理
现在我们已经运行了基本的 Scalatra REST 服务,让我们看看一个更复杂的例子,我们将用它来探索 Scalatra 的一些更多功能。在我们查看代码之前,让我们从 Postman 发起一个请求。首先,通过控制台调用sbt runCH04-runCH04Step1
来启动服务器,它将显示类似以下内容:
$ sbt runCH04-runCH04Step1
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter4.steps.ScalatraRunnerStep1
10:51:40.313 [run-main-0] INFO o.e.jetty.server.ServerConnector - Started ServerConnector@538c2499{HTTP/1.1}{0.0.0.0:8080}
10:51:40.315 [run-main-0] INFO org.eclipse.jetty.server.Server - Started @23816ms
Press <enter> to exit.
接下来,打开 Postman,从文件夹chapter-04
中选择request Step 01 – Update Task
并发送到服务器。这个请求将模拟创建一个新的任务,并通过回显它接收到的部分信息来响应:
如前一个屏幕截图所示,我们的服务器正在运行,并返回包含更新任务的消息。接下来,我们将逐步分析这个 Scalatra 服务的代码:
package org.restwithscala.chapter4.steps
import javax.servlet.ServletContext
import org.restwithscala.chapter4.JettyLauncher
import org.scalatra.{NotFound, BadRequest, ScalatraServlet, LifeCycle}
import scala.util.{Failure, Success, Try}
// run this example by specifying the name of the bootstrap to use
object ScalatraRunnerStep1 extends App {
JettyLauncher.launch(
"org.restwithscala.chapter4.steps.ScalatraBootstrapStep1")
}
class ScalatraBootstrapStep1 extends LifeCycle {
override def init(context: ServletContext) {
context mount (new ScalatraStep1, "/*")
}
}
class ScalatraStep1 extends ScalatraServlet {
notFound { "Route not implemented" }
post("/tasks") { s"create a new task with body ${request.body}"}
get("/tasks") { "Get all the tasks" }
get("/tasks/:id") {
Try { params("id").toInt } match {
case Success(id) => s"Get task with id: ${params("id")} "
case Failure(e) => BadRequest(reason = "Can't parse id")
}
}
delete("/tasks/:id") { s"Delete task with id: ${params("id")} "}
put("/tasks/:id") { s"Update an existing task " +
s"with id: ${params("id")} " +
s"and body ${request.body}"}
}
如前节所述,我们需要调用JettyLauncher
并定义一个ScalatraBootstrapStep1
类来运行我们的服务。在这个例子中,有趣的代码位于ScalatraStep1
类中,它定义了我们可以从 REST 客户端调用的多个路由。
提示
当你查看本章中的路由时,你会注意到更通用的路由位于ScalatraServlet
类的顶部。原因是 Scalatra 试图从底部开始匹配传入的请求,然后向上移动。因此,在定义路由时,请注意将最通用的路由放在顶部,最具体的路由放在底部。
在ScalatraStep1
类中,我们定义了多个路由。让我们看看其中的一些:
get("/tasks/:id") {
Try { params("id").toInt } match {
case Success(id) => s"Get task with id: ${params("id")} "
case Failure(e) => BadRequest(reason = "Can't parse id")
}
}
...
put("tasks/:id") { s"Update an existing task " +
s"with id: ${params("id")} " +
s"and body ${request.body}"}
在这里,我们可以看到两个路由。get("/tasks/:id")
匹配类似/tasks/12
的 URL 上的GET
HTTP 请求。当请求被发送时,我们使用params
函数获取路径段值,并尝试将其转换为整数。如果成功,我们只返回一个字符串;如果不成功,我们返回 HTTP 错误BadRequest
。put("tasks/:id")
路由匹配PUT
HTTP 请求,并始终返回一个字符串,该字符串包含使用params
函数提供的 ID,并显示通过request.body
值可以访问的发送的消息体。除了params
函数外,Scalatra 还提供了一个multiParams
函数。使用multiParams
函数,你不会得到一个单独的字符串,而是一个Seq[String]
实例。如果你想要访问具有多个值的请求参数,这特别有用。例如,如果我们匹配/task/search?status=new,old
,我们可以通过调用multiParams("status")
来获取包含 new 和 old 的Seq[String]
。
除了直接匹配路径元素外,Scalatra 还支持多种匹配 HTTP 请求的方式。下表显示了如何在 Scalatra 中匹配特定的 HTTP 动词和路由:
构建 | 描述 |
---|---|
get("/") , post("/") , put("/") , delete("/") 和其他 HTTP 动词。 |
Scalatra 允许您匹配特定的 HTTP 动词。Scalatra 支持以下 HTTP 动词,您可以直接在路由定义中使用:options 、get 、head 、post 、put 、delete 、trace 、connect 和 patch 。 |
get("/full/path") |
匹配特定路径上的请求的最基本方法是指定要匹配的完整路径。这只会匹配提供的路径完全匹配的情况。在这种情况下,"/full/path" 将匹配,而"/full/path/something" 则不会匹配。 |
get("/path/:param") |
如示例所示,您还可以通过在路径段前加冒号来从路径中提取变量。这将匹配类似于"/path/12" 和"path/hello" 的路径。 |
get("""^\/tasks\/(.*)""".r) |
您还可以使用正则表达式在 Scalatra 中匹配特定路径。要访问匹配的组,您可以使用params("splat") 调用或通过multiParams("splat") 函数调用。 |
get("/*/*") |
Scalatra 还支持使用通配符。您可以通过params("splat") 调用或通过multiParams("splat") 函数调用来访问匹配的参数。 |
get("/tasks", condition1, condition2) |
您可以通过提供条件来进一步微调匹配。条件是一个返回True 或False 的函数。如果所有条件都返回True ,则路由匹配并执行。例如,您可以使用类似post("/tasks", request.getHeader("headername") == "headerValue") 的代码。 |
在我们进入下一节之前,让我们快速了解一下如何访问请求的所有属性。到目前为止,我们已经看到了params
、multiParams
和request.body
来访问某些部分。Scalatra 还公开了请求的其他部分。以下表格显示了 Scalatra 公开的辅助函数和请求属性的完整概述(请注意,您可以将这些请求属性轻松用作路由中的条件):
函数 | 描述 |
---|---|
requestPath |
这返回与路由匹配的路径。 |
multiParams(key) |
这返回请求参数(或匹配路径段)的值作为 Seq[String] 。 |
Params(key) |
这返回请求参数(或匹配路径段)的值作为字符串。 |
request.serverProtocol |
这返回一个HttpVersion 对象,可以是Http11 或Http10 。 |
request.uri |
这是以 java.net.URI 形式的请求 URI。 |
request.urlScheme |
这返回一个 Scheme 对象,可以是 Http 或 Https 。 |
request.requestMethod |
这返回一个HttpMethod 对象,例如Get 、Post 或Put 。 |
request.pathInfo |
这返回请求的路径信息,如果没有路径信息则返回空字符串。 |
Request.scriptName |
这返回请求的 servlet 路径部分作为字符串。 |
Request.queryString |
这返回请求的查询字符串,如果没有查询字符串则返回空字符串。 |
Request.multiParameters |
这返回一个映射,包含此请求的所有参数作为MultiMap 。这包含查询字符串中的参数和任何已提交的表单数据。 |
request.headers |
这返回所有头部作为Map[String, String] 对象。 |
request.header(key) |
从请求中获取特定的头部并返回一个Option[String] 。 |
request.characterEncoding |
如果存在,这返回请求的字符编码和一个Option[String] 。 |
request.contentType |
如果存在,这获取请求的内容类型并返回一个Option[String] 。 |
request.contentLength |
这获取内容的长度并返回一个Option[Long] 。 |
request.serverName |
这返回完整路径的服务器名称部分和一个字符串。 |
request.serverPort |
这返回服务器端口号作为整数。 |
request.referrer |
这尝试从请求中获取引用并返回一个Option[String] 。 |
request.body |
这返回请求的正文作为字符串。 |
request.isAjax |
检查请求是否为 AJAX 请求。它通过检查X-Requested-With 头部的存在来完成此操作。 |
request.isWrite |
检查请求是否不是安全请求(参见 RFC 2616)。 |
request.multiCookie |
这返回一个映射,包含此请求的所有 cookie 作为MultiMap 。 |
request.cookies |
这返回所有 cookie 作为Map[String, String] 。 |
request.inputStream |
这获取请求的inputStream ,可以用来读取正文。注意,当你调用request.body 时,这个inputStream 已经被消耗了。 |
request.remoteAddress |
这尝试获取客户端的 IP 地址,并以字符串形式返回。 |
request.locale |
这返回请求中的Locale 值。 |
如你所见,Scalatra 封装了所有你期望的正常请求属性和属性,并通过一些辅助函数或作为可用请求值上的属性使它们易于访问。
现在我们已经探讨了 Scalatra 的基本功能,并看到了我们如何匹配 HTTP 动词和路径,接下来我们将查看下一节中的一些更高级的功能。
添加对 future 和简单验证的支持
在本节中,我们将为 Scalatra 添加对 future 的支持,并展示验证传入请求的一些初步步骤。为了异步工作,Scalatra 需要一些额外的依赖项。本示例所需的依赖项完整列表如下:
lazy val scalatraVersion = "2.3.0"
val backendDeps = Seq(
"org.scalatra" %% "scalatra" % scalatraVersion,
"ch.qos.logback"% "logback-classic" % "1.1.3",
"org.eclipse.jetty" % "jetty-webapp" % "9.2.10.v20150310",
"com.typesafe.akka" %% "akka-actor" % "2.3.4"
如从依赖关系中所见,Scalatra 使用 Akka (akka.io
)来异步处理请求。然而,你不需要了解太多关于 Akka 的知识就可以让一切正常运行。在下面的代码片段中,我们展示了连接所有移动部件所需的基本粘合剂:
package org.restwithscala.chapter4.steps
import javax.servlet.ServletContext
import akka.actor.ActorSystem
import org.restwithscala.chapter4.JettyLauncher
import org.restwithscala.common.model.{Status, Task}
import org.restwithscala.common.service.TaskService
import org.scalatra._
import org.slf4j.LoggerFactory
import scala.concurrent.{Future, ExecutionContext}
import scala.util.{Failure, Success, Try}
object ScalatraRunnerStep2 extends App {
JettyLauncher.launch(
"org.restwithscala.chapter4.steps.ScalatraBootstrapStep2")
}
class ScalatraBootstrapStep2 extends LifeCycle {
val system = ActorSystem()
override def init(context: ServletContext) {
context.mount(new ScalatraStep2(system), "/*")
}
override def destroy(context: ServletContext) {
system.shutdown()
}
}
class ScalatraStep2(system: ActorSystem) extends ScalatraServlet
with FutureSupport {
protected implicit def executor: ExecutionContext
= system.dispatcher
val Log = LoggerFactory.getLogger(this.getClass)
...
}
在这个代码片段中,我们使用之前看到的 JettyLauncher 来启动 Jetty 服务器,并指定当 Jetty 启动时要启动的 Scalatra 引导类。在这个示例的引导中,我们采取了一些额外的步骤:
class ScalatraBootstrapStep2 extends LifeCycle {
val system = ActorSystem()
override def init(context: ServletContext) {
context.mount(new ScalatraStep2(system), "/*")
}
override def destroy(context: ServletContext) {
system.shutdown()
}
}
当这个类被实例化时,我们创建一个新的 Akka,ActorSystem
,这是 Akka 所必需的。我们将这个系统传递给我们的 Scalatra 路由(ScalatraStep2
)的构造函数,这样我们就可以从那里使用它。在这个引导类中,我们还重写了 destroy
函数。当 Jetty 服务器关闭时,这将优雅地关闭 ActorSystem
并清理任何打开的资源。
在我们查看处理路由的代码之前,我们首先使用 Postman 进行一些调用,以便更好地理解我们的路由需要做什么。因此,使用 sbt runCH04-runCH04Step2
命令启动这部分的服务器:
$ sbt runCH04-runCH04Step2
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter4.steps.ScalatraRunnerStep2
17:46:00.339 [run-main-0] INFO org.eclipse.jetty.util.log - Logging initialized @19201ms
17:46:00.516 [run-main-0] INFO org.eclipse.jetty.server.Server - jetty-9.2.10.v20150310
17:46:01.572 [run-main-0] INFO o.e.jetty.server.ServerConnector - Started ServerConnector@5c3c276c{HTTP/1.1}{0.0.0.0:8080}
17:46:01.572 [run-main-0] INFO org.eclipse.jetty.server.Server - Started @20436ms
Press <enter> to exit.
现在通过使用本章的 Step 02 – Create Task 请求创建多个任务:
要获取所有已存储任务的概览,请使用 Step 02 – Get All Tasks 请求:
如您从两个截图中所见,这次我们存储了任务,您可以通过其他 REST 调用来检索它们。这两个路由的代码,以及基于 ID 删除任务的路由代码,将在下面展示:
class ScalatraStep2(system: ActorSystem) extends ScalatraServlet
with FutureSupport {
protected implicit def executor: ExecutionContext
= system.dispatcher
val Log = LoggerFactory.getLogger(this.getClass)
before("/*") {
Log.info(s"Processing request for: ${params("splat")}")
}
after(""""^\/tasks\/(.*)"""".r) {
Log.info(s"Processed request for tasks: ${params("captures")}")
}
notFound {
"Route not implemented"
}
post("/tasks") {
new AsyncResult() {
// we use a AsyncResult since we access the parameters
override val is = {
// convert provided request parameters to a task and store it
val createdTask = TaskService.insert(Task( -1,
params.getOrElse("title",
halt(status = 400,
reason="Title is required")), request.body, None, List.empty, Status(params.getOrElse("status", "new"))))
// the result is a Future[Task]; map this to a string
createdTask.map(_.toString)
}
}
}
get("/tasks") {
// directly return Future since we don't access request
TaskService.all.map(_.toString) }
delete("/tasks/:id") {
new AsyncResult() {
override val is = Try { params("id").toLong } match {
case Success(id) => TaskService.delete(id).map(_.toString)
case Failure(e) => Future{BadRequest(
reason = s"Can't parse id: ${e.getMessage}")}
}
}
}
}
让我们遍历这个代码片段,看看发生了什么。让我们从包含路由的定义和类中的第一个语句开始:
class ScalatraStep2(system: ActorSystem) extends ScalatraServlet
with FutureSupport {
protected implicit def executor: ExecutionContext
= system.dispatcher
这次,除了从 ScalatraServlet
继承之外,我们还混合了 FutureSupport
特质。当我们混合这个特质时,我们为 Scalatra 添加了对 futures 的支持。这个特质还要求我们定义一个 ExecutionContext
实例。在这个例子中,我们使用了 Akka 提供的默认 ExecutionContext
。当然,您也可以定义并配置一个自己的。
小贴士
ExecutionContext
被程序用来异步执行逻辑,并让开发者能够更精细地控制线程。例如,您可以通过将一个 Runnable
实例传递给 execute
方法来执行一段代码。Scalatra 和 Akka 隐藏了 ExecutionContext
的所有使用细节,但是开发者需要指定要使用哪个 ExecutionContext
以及如何配置它。
现在我们已经配置了最后一部分,我们可以查看异步运行的调用。我们将首先查看的路线是 get("/tasks")
路由:
get("/tasks") {
// directly return future since we don't access request
TaskService.all.map(_.toString) }
此路由非常简单。它调用 TaskService.all
函数,该函数返回一个 Future[List[Task]]
,我们通过 map
函数将其转换为 Future[String]
。内部,Scalatra 将在此请求之上运行 Akka,并等待,非阻塞地等待 Future
完成。一旦完成,Scalatra 将将字符串返回给客户端。好事是,你不需要做任何事情。只需返回一个 Future
对象,Scalatra 就知道如何处理一切,因为我们添加了 FutureSupport
。在这个示例中,我们只是在 Future
中返回一个字符串。Scalatra 还支持许多其他你可以使用的返回类型:
类型 | 描述 |
---|---|
ActionResult |
ActionResult 类型是一个类,你可以设置返回的状态、主体和头部。Scalatra 提供了大量标准 ActionResult ,你可以使用——OK 、Created 、Accepted 等等。要查看完整概述,请查看 ActionResult.scala 文件的源代码。 |
Array[Byte] |
如果未设置,响应的内容类型将被设置为 application/octet-stream,并且返回字节数组。 |
NodeSeq |
如果未设置,响应的内容类型将被设置为 text/HTML ,并且 NodeSeq 实例将被转换为字符串并返回。 |
Unit |
如果你没有指定任何内容,Scalatra 假设你已经自己设置了响应对象中的正确值。 |
Any |
如果未设置,响应的内容类型将被设置为 text/plain ,并且将在对象上调用 toString 方法,然后返回结果。 |
注意,你可以通过覆盖 renderResponse
函数来覆盖此功能或添加新功能。
现在让我们看看 delete("/tasks/:id")
路由:
delete("/tasks/:id") {
new AsyncResult() {
override val is = Try { params("id").toLong } match {
case Success(id) => TaskService.delete(id).map(_.toString)
case Failure(e) => Future{BadRequest(
reason = s"Can't parse id: ${e.getMessage}")}
}
}
}
TaskService.delete
服务返回一个 Future[Option[Task]]
,我们将其转换为 Future[String]
,就像我们在之前的代码片段中所做的那样。这里的主要区别是我们不直接返回一个 Future
,而是将代码块包裹在一个 AsyncResult
对象中。我们需要这样做的原因是我们从请求中访问值。我们使用 params("id")
从 URL 路径中获取值。如果你从请求中访问任何信息,你需要将其包裹在一个 AsyncResult
对象中,以避免时序问题和奇怪的异常。
提示
当处理 futures 或添加新的复杂路由时,通常在实际操作中添加一些日志来查看请求处理前后的情况是非常有用的。Scalatra 通过你可以在类中定义的 before("path")
和 after("path")
函数来支持这一点。在这个例子中,我们通过指定一个 before(/*)
函数来记录每个请求,并在对由正则表达式定义的特定路径发出的请求之后记录一些附加信息:after(""""^\/tasks\/(.*)"""".r).
现在让我们继续一些简单的验证。看看以下来自 POST
方法的代码:
val createdTask = TaskService.insert(Task( -1,
params.getOrElse("title",
halt(status = 400,
reason="Title is required")), request.body, None, List.empty, Status(params.getOrElse("status", "new"))))
// the result is a Future[Task]; map this to a string
createdTask.map(_.toString)
}
你在这里看到的是我们可以使用 getOrElse
在参数上检查它是否提供,如果没有提供,我们可以抛出一个错误或者添加一个默认值。
小贴士
注意,我们在这里使用了 Scalatra 的一个特殊构造——halt
。当这个函数被调用时,Scalatra 将立即停止处理请求并返回指定的 HTTP 响应。除了 halt
之外,Scalatra 还提供了一个 pass
函数,它可以用来在当前路由内部停止处理,并尝试查看是否有其他可能匹配的路由。
这只是一些基本的简单验证。在下一节中,我们将探讨添加验证的更高级方法。
高级验证和 JSON 支持
对于最后的示例,我们将向服务添加 JSON 支持和一些更高级的验证。要测试本节中的示例,请运行 sbt runCH04-runCH04Step3
。
添加 JSON 支持
让我们从添加 JSON 支持开始。首先,将以下依赖项添加到 SBT 构建文件中:
"org.json4s" %% "json4s-jackson" % "3.2.9",
"org.scalatra" %% "scalatra-json" % scalatraVersion,
添加 JSON 支持只需要几个简单的步骤。首先,将我们路由的类定义更改为以下内容:
class ScalatraStep3(system: ActorSystem) extends ScalatraServlet
with FutureSupport
with JacksonJsonSupport {
在添加了 JacksonJsonSupport
特性之后,我们接下来需要在配置文件中添加以下行以启用自动 JSON 解析:
protected implicit val jsonFormats: Formats = DefaultFormats
现在,我们只需要通知路由我们想要使用 JSON。为此,我们使用 before()
函数,并设置以下内容:
before("/*") {
contentType = formats("json")
}
到这一点,我们只需返回我们的案例类,Scalatra 将自动将它们转换为 JSON。例如,对于获取所有任务的服务,它看起来像这样:
get("/tasks") {
TaskService.all // we use json4s serialization to json.
}
在我们查看 Postman 并查看请求之前,我们需要采取最后一步,以便我们也可以存储传入的 JSON 消息。让我们看看 post("/tasks")
函数:
post("/tasks") {
new AsyncResult() {
override val is = {
// convert provided request parameters to a task and store it
TaskService.insert(parsedBody.extract[Task])
}
}
}
让我们看看实际效果。打开 Postman,使用 Step 03 – Add Task 请求添加一些任务:
如你所见,我们发送的正文是一个描述任务的 JSON 消息。检索消息的方式基本上相同。在 Postman 中,你可以使用 Step 03 – Get All Tasks 请求来做这件事:
在这里,你可以看到你刚刚添加的任务被返回。
高级验证
现在我们已经添加了 JSON 支持,让我们看看本章的最后一部分,并探讨如何向 Scalatra 路由添加更高级的验证。首先,我们需要在我们的 sbt
构建文件中添加一个额外的依赖项(scalatra-commands
)。到这一点,我们的依赖项应该看起来像这样:
lazy val scalatraVersion = "2.3.0"
val backendDeps = Seq(
"org.scalatra" %% "scalatra" % scalatraVersion,
"org.scalatra" %% "scalatra-json" % scalatraVersion,
"org.scalatra" %% "scalatra-commands" % scalatraVersion,
"org.json4s" %% "json4s-jackson" % "3.2.9",
"ch.qos.logback" % "logback-classic" % "1.1.3",
"org.eclipse.jetty" % "jetty-webapp" % "9.2.10.v20150310",
"com.typesafe.akka" %% "akka-actor" % "2.3.4"
)
通过使用命令,我们可以向输入参数添加更复杂的验证。为此,我们需要在我们的路由中更改一些设置。首先,我们需要在我们的路由中添加 CommandSupport
:
class ScalatraStep3(system: ActorSystem) extends ScalatraServlet
with FutureSupport
with JacksonJsonSupport with CommandSupport {
这允许我们使用命令来处理传入的请求。接下来,我们需要指定我们的服务应该处理哪些类型的命令。由于在这种情况下我们只使用一个命令,我们将CommandType
设置为SearchTaskCommand
(关于这个文件的内容稍后讨论):
override type CommandType = SearchTasksCommand
让我们更仔细地看看在这个例子中我们将使用的命令和验证:
object SearchCommands {
object SearchTasksCommand {
implicit def createSearchParams(cmd: SearchTasksCommand):
SearchParams =
SearchParams(cmd.status.value.get, cmd.text.value)
}
class ValidStatusValidations(b: FieldDescriptor[String]) {
def validStatus(message: String =
"%s be either 'new' or 'in progress'.") =
b.validateWith(_ =>
_ flatMap { new PredicateValidatorString.contains(_),
message).validate(_) }
)
}
/**
* Params only command parses incoming parameters
*/
class SearchTasksCommand extends ParamsOnlyCommand {
implicit def statusValidator(b: FieldDescriptor[String])
= new ValidStatusValidations(b)
val text: Field[String] = asTypeString
val status: Field[String] =
asTypeString.
notBlank.
minLength(3).
validStatus()
}
}
在这个对象的底部,我们定义了一个SearchTasksCommand
类。这个命令将处理传入的参数(因为我们扩展了ParamsOnlyCommand
),并检查传入的参数是否有效。在这种情况下,我们不验证文本参数,但期望status
参数通过notBlank
、minLength
和自定义的validStatus
验证器进行验证。在这个对象中,我们还定义了SearchTaskCommand
类和SearchParams
案例类之间的隐式转换,我们可以在我们的服务中使用它。这使得我们的路由中的代码更加简洁,正如我们稍后将会看到的。
我们还在这个对象中定义了一个自定义验证器——ValidStatusValidations
类。这个类接受一个FieldDescriptor
作为输入,并定义了一个validStatus
函数。在validStatus
函数中,我们使用FieldDescriptor
的validateWith
函数来验证参数的值。我们可以自己编写这个函数,或者像在这个例子中那样使用PredicateValidator
。一旦定义了验证,我们通过定义implicit def statusValidator
转换使其在我们的命令中可用。
Scalatra 自带了一组大量的验证器,您可以使用这里解释的方式使用它们:
名称 | 描述 |
---|---|
notEmpty |
这检查提供的字段是否包含值 |
greaterThan |
这尝试将值转换为数字,并检查它是否大于提供的值 |
lessThan |
这尝试将值转换为数字,并检查它是否小于提供的值 |
greaterThanOrEqualTo |
这尝试将值转换为数字,并检查它是否大于或等于提供的值 |
lessThanOrEqualTo |
这尝试将值转换为数字,并检查它是否小于或等于提供的值 |
notBlank |
这删除任何空格,并检查提供的字段是否仍然包含值 |
validEmail |
这检查值是否是有效的电子邮件值 |
validAbsoluteUrl |
这检查值是否是有效的绝对 URL |
validUrl |
这检查值是否是有效的 URL |
validForFormat |
这将值与提供的正则表达式进行比较 |
minLength |
这验证值的长度是否至少是特定数量的字符 |
maxLength |
这验证值的长度是否小于特定数量的字符 |
enumValue |
这检查值是否是提供的值之一 |
现在,我们终于可以在我们的路由中使用这个命令了:
get("/tasks/search") {
new AsyncResult() {
override val is = (command[SearchTasksCommand] >>
(TaskServiceWrapper.WrapSearch(_))).fold (
errors => halt(400, errors),
tasks => tasks
)
}
}
语法可能有点粗糙,但以下步骤在这里发生:
-
Command[SearchTasksCommand] >>
意味着我们执行SearchTasksCommand
类中指定的命令,并调用>>
右侧的函数 -
在这种情况下提供的函数是
TaskServiceWrapper.wrapSearch()
-
这个服务的结果是一个
ModelValidation[T]
,我们可以调用fold
-
如果我们的验证返回错误,我们将停止调用并返回错误
-
如果我们的验证返回任务,我们就直接返回那些任务
由于我们需要返回一个ModelValidation[T]
实例,我们在TaskService
周围创建了一个简单的包装服务:
object TaskServiceWrapper {
def WrapSearch(search: SearchParams): ModelValidation[Future[List[Task]]] = {
allCatch.withApply(errorFail) {
println(search)
TaskService.search(search).successNel
}
}
def errorFail(ex: Throwable) = ValidationError(ex.getMessage, UnknownError).failNel
}
在这一点上,当我们调用/tasks/search
URL 时,Scalatra 将创建命令,执行验证,如果成功,将调用WrapSearch
函数并返回一组任务。如果在验证过程中发生错误,将返回这些错误。
你可以用 Postman 轻松测试这个。首先,通过步骤 03 – 添加任务请求添加一些任务。现在当你调用步骤 03 – 搜索任务时,你将只得到一组有效结果:
另一方面,如果你调用步骤 03 – 搜索任务 – 无效,你会看到一个错误信息:
在本节中,我们看到了 Scalatra 命令为验证提供的可能性。你可以使用命令做更多的事情,比本节中展示的还要多;对于更多信息,请参阅 Scalatra 命令文档www.scalatra.org/2.3/guides/formats/commands.html
。
摘要
在本章中,你学习了如何使用 Scalatra 创建 REST 服务。我们看到了你需要一个自定义 Jetty 启动器来运行 Scalatra 独立,因为它被创建为在 servlet 容器内运行。一旦 Scalatra 运行起来,你只需将路由添加到一个类中,它们在运行 Scalatra 时将被自动拾取。记住,但是,路由是从底部开始匹配并向上进行的。Scalatra 还通过使用 Options 提供对请求所有属性的轻松访问和一些基本验证。对于更高级的验证,你可以使用scalatra-commands
模块,其中你可以自动解析传入的参数并验证一大组验证器。最后,将 JSON 支持添加到 Scalatra 非常简单。你只需要将正确的模块添加到你的构建中并导入转换类。
在下一章中,我们将向你展示如何使用基于 Spray 的 Akka HTTP DSL 创建 REST 服务。
第五章。使用 Akka HTTP DSL 定义 REST 服务
在本章中,我们将探讨 Scala 空间中最受欢迎的 REST 框架之一的后续者,称为 Spray。Spray 已经存在了几年,并提供了一个非常广泛的 领域特定语言(DSL),你可以使用它来定义你的 REST 服务。Spray 本身不再积极开发,并已合并到 Typesafe 提供的 Akka HTTP 创举中。然而,DSL 结构和创建 REST 服务的方式并没有发生太大的变化。因此,在本章中,我们将探讨 Akka HTTP DSL 提供的以下功能:
-
第一个基于 DSL 的服务
-
通过指令处理动词和路径
-
异常处理
-
验证和 JSON 支持
在下一节中,我们将首先深入探讨这个领域特定语言(DSL)包含的内容以及 Akka HTTP 项目的背景历史。
什么是 Akka HTTP?
Akka HTTP 是 Akka 库和框架集的一部分。Akka 本身是一个非常著名的演员框架,用于创建高度可扩展、分布式和健壮的应用程序。Akka HTTP 是建立在 Akka 框架之上的,1.0 版本于 2015 年夏季发布。你可以以两种不同的方式使用 Akka HTTP。你可以使用低级 API 并直接与反应式流一起工作来处理原始 HTTP 信息,或者你可以使用高级 API 并使用高级 DSL 来处理你的请求。在本章中,我们将使用后一种方法。
你可能会认为 Akka HTTP 不是一个非常成熟的框架,因为 1.0 版本最近才发布。但这并不是事实。Akka HTTP DSL 是基于广为人知的 Spray 框架,这个框架已经存在了几年。Spray 的发展已经停止,并继续在 Akka HTTP DSL 项目中发展。因此,对于那些有 Spray 经验的人来说,DSL 将看起来几乎一样,你将能够识别出所有来自 Spray 的标准结构。
创建一个基于 DSL 的简单服务
在本书中的每个框架中,我们都会创建一个简单的入门级服务。因此,对于 Akka HTTP,我们也做了同样的事情。在我们查看代码之前,让我们先启动服务并使用 Postman 发送一个请求。要启动服务,请在命令行中运行 sbt runCH05-HelloAkka-DSL
命令:
$ sbt runCH05-HelloAkka-DSL
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Running org.restwithscala.chapter5.gettingstarted.HelloDSL
Press <enter> to exit.
打开 Postman,从 第五章 集合中运行 Hello Akka-DSL
命令。服务器将响应一条简单的消息:
要创建这个示例,我们当然需要导入外部依赖项。对于这个示例,使用了以下 sbt
依赖项:
lazy val akkaHttpVersion = "1.0"
val backendDeps = Seq (
"com.typesafe.akka" %% "akka-stream-experimental" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-core-experimental" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaHttpVersion
)
注意,依赖项的名称中仍然带有实验性的标签。这意味着实现可能会改变,并且在此阶段,还没有来自 Typesafe 的官方支持。因此,未来可能会有一些变化,这些变化可能不是二进制兼容的。Typesafe 本身将其定义为:
"这个 Akka 模块被标记为实验性,这意味着它处于早期访问模式,这也意味着它不受商业支持。实验性模块不必遵守小版本之间保持二进制兼容性的规则。在不通知的情况下,可能会在次要版本中引入破坏 API 的变化,因为我们根据您的反馈进行精简和简化。实验性模块可能在主要版本中删除,而无需提前弃用。"
因此,在这个阶段,可能明智的做法是不要立即将所有现有的 Spray 代码转换为这个代码库,而是等待它们走出实验阶段。
在这些依赖项就绪后,我们可以创建我们的简单服务:
import akka.actor.ActorSystem
import akka.stream.ActorMaterializer
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
object HelloDSL extends App {
// used to run the actors
implicit val system = ActorSystem("my-system")
// materializes underlying flow definition into a set of actors
implicit val materializer = ActorMaterializer()
val route =
path("hello") {
get {
complete {
"hello Akka-HTTP DSL"
}
}
}
// start the server
val bindingFuture = Http().bindAndHandle(route, "localhost", 8080)
// wait for the user to stop the server
println("Press <enter> to exit.")
Console.in.read.toChar
// gracefully shut down the server
import system.dispatcher
bindingFuture
.flatMap(_.unbind())
.onComplete(_ => system.shutdown())
}
在这个服务中,我们首先定义两个隐式值。隐式的ActorSystem
是必需的,用于定义将要用于异步运行请求各种处理步骤的 actor 系统。Akka HTTP 会将我们创建的 DSL 转换为流定义(这是 Akka Streams 的一个构造),这个流可以看作是一个请求从开始到结束所采取步骤的蓝图。隐式的ActorMaterializer
会将这个流转换为一组 Akka actors,以便多个请求可以并发执行而不会相互干扰,这些 actors 运行在隐式定义的ActorSystem
上。
定义了隐式值之后,我们可以定义路由:
val route =
path("hello") {
get {
complete {
"hello Akka-HTTP DSL"
}
}
}
每个请求都会通过这个路由,当匹配时,其内层路由会被执行。所以在这种情况下,以下步骤被执行:
-
首先,检查提供的 URL 路径。在这种情况下,如果路径匹配
hello
,则执行路径函数的内层路由(这被称为指令)。 -
Akka HTTP 接下来检查的是动词是否匹配。在这个例子中,我们检查
GET
动词。 -
最终的内层路由通过调用 complete 来完成请求。当调用 complete 时,提供的块的结果作为响应返回。在这个例子中,我们只返回一个字符串。
在这个 hello world 示例的最后一段代码中,当按下键时,会关闭服务器。关闭服务器是通过以下代码完成的:
import system.dispatcher
bindingFuture
.flatMap(_.unbind())
.onComplete(_ => system.shutdown())
这种关闭服务器的方式可能看起来很复杂,但当你查看类型时,实际上它非常简单。我们在bindingFuture
实例(类型为Future[ServerBinding]
)上调用flatMap
,所以当Future
准备好(服务器成功启动)时,我们在ServerBinding
实例上调用 unbind。由于我们调用了flatMap
,这本身也返回一个Future
,它被扁平化。当这个最后的Future
解决时,我们关闭 Akka 系统以干净地关闭一切。
我们将在其他示例中使用相同的方式来启动和停止服务。
使用路径和指令进行工作
我们将要查看的第一个例子是我们 API 的第一个简单实现。我们目前不会返回真实对象或 JSON,而是一对字符串。这一步的代码如下:
val route =
// handle the /tasks part of the request
path("tasks") {
get {
complete { "Return all the tasks" }
} ~
post {
complete { s"Create a new task" }
} // any other request is also rejected.
} ~ { // we handle the "/tasks/id separately"
path("tasks" / IntNumber) {
task => {
entity(as[String]) { body => {
put { complete {
s"Update an existing task with id: $task and body: $body" } }
} ~
get { complete {
s"Get an existing task with id : $task and body: $body" } }
} ~ {
// We can manually add this rejection.
reject(MethodRejection(HttpMethods.GET),
MethodRejection(HttpMethods.PUT))
}
}
}
}
这段代码与我们在前面的例子中看到的代码看起来并没有太大的不同。我们通过使用path
、get
、post
和put
等指令来定义路由,并通过使用complete
函数来返回值。然而,我们确实使用了一些新的概念。在我们解释代码和 Akka HTTP 提供的概念之前,首先让我们发起一些请求。为此,启动本节示例:
$ sbt runCH05-runCH05Step1
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Compiling 3 Scala sources to /Users/jos/dev/git/rest-with-scala/chapter-05/target/scala-2.11/classes...
[info] Running org.restwithscala.chapter5.steps.AkkaHttpDSLStep1
Press <enter> to exit.
在您的浏览器中打开 Postman,首先执行步骤 01 - 更新具有 id 的任务请求:
正如您所看到的,我们返回一个简单的响应,显示了发送到服务器的数据。另一个有趣的例子是当我们发送一个无效请求时——步骤 01 - 无效请求:
在这里,您可以看到我们可以轻松地向用户提供有关如何使用我们服务的额外信息。
让我们更仔细地看看本节开头我们看到的代码的第二部分:
path("tasks" / IntNumber) {
task => {
entity(as[String]) { body => {
put { complete {
s"Update an existing task with id: $task and body: $body" } }
} ~
get { complete {
s"Get an existing task with id : $task and body: $body" } }
}
}
}
}
在这里,我们再次看到了熟悉的path
、get
和put
指令,我们还使用了额外的指令来从请求中获取更多信息。我们使用IntNumber
路径匹配器将路径的一部分转换为整数,并使用entity(as[String])
提取器将请求体作为字符串提取出来(我们将在本章末尾使用相同的方法处理 JSON 输入时看到更多关于这个指令的内容)。不过,让我们先更仔细地看看path
指令。
在这个例子中,我们已经使用了三个不同的路径匹配器。我们使用字符串值来匹配 URL 的一部分,使用/
匹配器来表示我们期望一个正斜杠,以及使用IntNumber
路径匹配器来匹配和提取一个数字。除了这些之外,您还可以使用以下表格中解释的匹配器:
路径匹配器 | 描述 |
---|---|
"/hello" |
这个匹配器匹配 URL 的一部分,并且消耗它。这里没有提取任何内容。 |
"[a-b]"r |
您也可以使用这个匹配器指定一个最多有一个捕获组的正则表达式。捕获组将被提取。 |
Map[String, T] |
使用这个匹配器,您可以根据匹配Map("path1" -> 1, "path2" -> 2, "path3" -> 3) 的路径提取一个值。 |
Slash (or /) |
这个匹配器匹配单个正斜杠。 |
Segment |
如果路径以路径段(而不是正斜杠)开头,则匹配。当前路径段作为字符串被提取。 |
PathEnd |
这个匹配器匹配路径的末尾,不提取任何内容。 |
Rest |
这个匹配器匹配路径的其余部分,并将其作为字符串返回。 |
RestPath |
这个匹配器匹配路径的其余部分,并将其作为路径返回。 |
IntNumber |
这个匹配器匹配可以转换为整数的数字。匹配到的整数将被提取。 |
LongNumber |
这个匹配器匹配可以转换为长整数的数字,并提取匹配到的长整数。 |
HexIntNumber |
这个操作符匹配可以转换为整数的十六进制数字,并提取匹配的整数。 |
HexLongNumber |
这个操作符匹配可以转换为长整数的十六进制数字,并提取匹配的长整数。 |
DoubleNumber |
这个操作符匹配可以转换为双精度数的数字,并提取匹配的双精度数。 |
JavaUUID |
这个操作符匹配并提取java.util.JavaUUID 对象的字符串表示形式。结果是.java.util.JavaUUID 实例。 |
Neutral |
这个操作符匹配一切,不消耗任何内容。 |
Segments |
这个操作符与Segment 匹配器相同,但这次匹配所有剩余的段,并返回这些段作为一个List[String] 对象。 |
separateOnSlashes |
这个操作符创建一个匹配器,将斜杠解释为路径段分隔符。 |
provide |
这个匹配器总是匹配并提取提供的元组值。 |
~ |
这个操作符允许你连接两个匹配器,例如 "hello" ~ "world" 等同于 "helloworld" 。 |
| |
这个操作符将两个匹配器组合在一起。当左侧的匹配器无法匹配时,才会评估右侧的匹配器。 |
后缀: ? |
? 后缀使匹配器可选,并且总是匹配。提取值的结果是Option[T] 对象。 |
前缀: ! |
这个前缀反转了匹配器。 |
后缀: .repeat |
使用repeat ,你可以创建一个匹配器,使其重复指定的次数。 |
transform, flatMap, map |
这些操作符允许你自定义匹配器并创建自己的自定义逻辑。 |
所以即使在path
指令中,你也能提取出很多信息并应用多个匹配器。除了path
指令之外,还有很多其他的指令。我们已经看到在这个例子中提取的实体是这样使用的:
entity(as[String]) { body => {
put { complete {
s"Update an existing task with id: $task and body: $body" } }
}
当你使用提取器时,提取的值会被作为参数传递给内部路由(在这个代码片段中是body
)。Akka HTTP 附带了许多你可以用来从请求中获取值的提取器。下表显示了最有用的几个:
指令 | 描述 |
---|---|
cookie("name") |
这个操作符提取指定名称的 cookie,并返回一个HttpCookiePair 实例。还有一个Option 变体——optionalCookie 。 |
entity(as[T]) |
这个操作符将请求实体反序列化为指定的类型(更多信息,请参阅关于 JSON 的部分)。 |
extractClientIp |
这个操作符从 X-Forwarded-或 Remote-Addressor X-Real-IP 头中提取客户端的 IP,作为一个RemoteAddress 。 |
extractCredentials |
这个操作符从授权头中获取Option[HttpCredentials] 。 |
extractExecutionContext |
这个操作符提供了对 Akka ExecutionContext 实例的访问。 |
extractMaterializer |
这个操作符提供了对 Akka Materializer 的访问。 |
extractHost |
这获取主机请求头部值的hostname 部分作为字符串。 |
extractMethod |
这提取请求方法作为HttpMethod 。 |
extractRequest |
这提供了对当前HttpRequest 的访问。 |
extractScheme |
这从请求中返回 URI 方案(如http 、https 等)作为字符串。 |
extractUnmatchedPath |
这提取当前点未匹配的路径部分作为Uri.Path 。 |
extractUri |
这通过Uri 访问请求的完整 URI。 |
formFields |
这从 HTML 表单中提取字段。有关更多信息,请参阅路径匹配器部分。 |
headerValueByName |
这提取具有给定名称的第一个 HTTP 请求头部的值,并将其作为字符串返回。您也可以通过使用OptionalHeaderValueByName 来获取Option[String] 。 |
headerValueByType [T] |
您也可以使用此指令提取头并将其自动转换为特定类型。对于这个,还有一个Option 变体——OptionalHeaderValueByType 。 |
parameterMap |
这从请求中获取所有参数作为Map[String, String] 。如果存在具有相同名称的多个参数,则仅返回最后一个。 |
parameterMultiMap |
这从请求中获取所有参数作为Map[String, List[String]] 。如果存在具有相同名称的多个参数,则所有参数都将返回。 |
parameterSeq |
这按顺序提取所有参数作为元组的Seq[(String, String)] 。 |
provide("value") |
这将提供的值注入到内部路由中。还有一个tprovide 函数,它可以注入一个元组。 |
大多数这些提取器相当直观。例如,当您想要提取特定的 HTTP 头时,您可以编写如下路由:
val exampleHeaderRoute = path("example") {
(headerValueByName("my-custom-header")) { header => {
complete(s"Extracted header value: $header")
}
}
}
现在,让我们回到我们的示例,再次看看我们路由的一个非常简单的部分:
get {
complete { "Return all the tasks" }
}
到目前为止,我们只看到了少量 Akka 指令。我们查看了上表中可能的提取器,以及匹配路径部分和特定 HTTP 动词的简单指令。除此之外,Akka HTTP 提供了大量指令,远远超过我们在这单章中可以解释的数量。在下面的表中,我们将列出我们认为最重要的、最灵活的指令,供您在路由中使用:
指令 | 描述 |
---|---|
conditional |
这提供了对按tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-26 指定的条件请求的支持。 |
decodeRequest / encodeRequest |
这压缩和解压缩使用 gzip 或 deflate 压缩发送的请求。 |
decodeRequestWith / encodeRequestWith |
这使用指定的编码器和解码器压缩和解压缩请求。 |
get / delete / post / put / head / options / patch |
当指定的 HTTP 动词匹配时,将执行此指令的内部路由。 |
host |
只有当在此指令中指定的主机与提供的匹配时,才会运行内部路由。您可以使用字符串或正则表达式。 |
method(httpMethod) |
检查请求是否与指定的 HttpMethod 匹配。 |
onComplete(future) |
当提供的 Future 完成时,将运行内部路由。 |
onSuccess |
当提供的 Future 成功完成时,将运行内部路由。 |
overrideMethodWithParameter |
这将传入请求的 HttpMethod 改变传入请求的方法(HTTP 动词)。 |
pass |
总是将请求传递到内部路由。 |
path |
如果提供的路径匹配,则将请求传递到内部路由(您将在下一个示例中了解更多关于它的信息)。 |
pathEnd |
只有当其完整路径匹配时,才会传递请求。 |
pathEndOrSingleSlash |
如果其完整路径匹配或仅剩单个斜杠,则仅传递请求。 |
pathPrefix |
这匹配路径的第一部分。自动添加前导斜杠。如果您只想测试而不消耗路径,请使用 pathPrefixTest 。如果您不想使用前导斜杠,可以使用 rawPrefix 和 rawPrefixTest 。 |
pathSingleSlash |
只有当路径包含单个斜杠时,才会运行内部路由。 |
pathSuffix |
这检查当前路径的末尾,如果匹配,则运行内部路由。如果您只想测试而不消耗路径,请使用 pathSuffixTest 指令。 |
requestEncodedWith |
检查请求编码是否与指定的 HttpEncoding 匹配。 |
requestEntityEmpty |
如果请求不包含正文,则匹配此条件。 |
requestEntityPresent |
如果请求包含正文,则匹配此条件。 |
scheme("http") |
这检查请求的方案。如果方案匹配,请求将被传递到内部路由。 |
validate |
这允许您测试任意条件。 |
在我们继续下一个示例之前,我们将快速查看本节开头看到的最后一个内部路由:
{
// We can manually add this rejection.
reject(MethodRejection(HttpMethods.GET),
MethodRejection(HttpMethods.PUT))
}
使用此内部路由,我们通知 Akka HTTP 内部路由拒绝请求。在这种情况下,我们拒绝请求是因为 HttpMethod
(动词)与我们能够处理的任何内容都不匹配。当您拒绝请求时,Akka HTTP 将检查是否有任何可能匹配的路由,如果没有,将拒绝转换为 HTTP 错误消息。此外,在本章中,我们还将更详细地了解 Akka HTTP 如何与拒绝和异常处理交互。
处理请求参数和自定义响应
在本节中,我们将更深入地探讨如何从请求中提取查询参数以及如何自定义发送给客户端的响应。在本节中,我们将更详细地了解以下请求的实现方式:
-
创建任务:我们将使用一组查询参数创建一个新任务
-
获取所有任务:我们将返回所有已创建的任务
-
获取任务:我们将根据提供的 ID 返回一个特定的任务
对于这些请求中的每一个,我们首先将展示从 Postman 的调用,然后展示如何使用 Akka HTTP 实现。首先,使用sbt runCH05-runCH05Step2
命令启动正确的服务器:
$ sbt runCH05-runCH05Step2
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
[info] Compiling 1 Scala source to /Users/jos/dev/git/rest-with-scala/chapter-05/target/scala-2.11/classes...
[info] Running org.restwithscala.chapter5.steps.AkkaHttpDSLStep2
Press <enter> to exit.
现在打开 Postman 并执行步骤 02 - 创建任务请求:
在响应中,您可以看到我们返回了添加的任务的字符串表示形式,并且任务的内容基于 URL 中的查询参数。为了实现这一点,我们实现了以下路由:
((post) &
(parameters("title", "person".?, "status" ? "new"))) {
(title, assignedTo, status) => {
(entity(as[String])) { body => {
complete {
val createdTask = TaskService.insert(
Task(-1,
title,
body,
assignedTo.map(Person(_)),
List.empty, Status(status)))
createdTask.map(_.toString)
}
}
}
}
}
在此路由中,我们首先通过使用&
符号组合两个指令,即post
指令和parameters
指令。这意味着在执行内部路由之前,这两个指令都应该匹配。您还可以使用|
符号,这意味着左边的指令或右边的指令应该匹配(put
|
post
)。参数与正文一起使用,通过TaskService
对象创建Task
实例。然后,将生成的Future[Task]
转换为Future[String]
并返回。由于我们尚未告诉 Akka HTTP 如何处理Task
类,因此我们需要手动将Task
转换为字符串(您将在稍后了解更多关于它的信息)。如果您查看参数指令,您不仅会认出原始请求中的查询参数,还会看到许多修饰符。以下项目符号解释了参数指令的工作原理:
-
"title"
:这提取参数的值作为字符串。这表示此查询参数是必需的。 -
"person".?
:通过使用.?
后缀,您使值可选。此结果为Option[String]
。 -
"status" ? "new"
:这检索参数,如果找不到,则使用默认值(在这种情况下为"new"
)。结果是字符串。 -
"status" ! "new"
:这要求查询参数的名称为"status"
的值为"new"
。 -
"number".as[Int]
:这尝试将参数转换为指定的类型。 -
"title.*"
:这从标题参数中提取多个实例到Iterable[String]
。 -
"number".as[Int].*
:这与前面的函数相同,但适用于类型。
因此,对于我们的示例,我们需要一个title
参数,一个可选的person
参数,以及一个可选的status
参数,其默认值为"new"
。使用这些结构,从请求的查询参数中提取正确的值变得非常容易。
现在,让我们更仔细地看看我们从这个示例中发送的响应。我们使用之前看到的 complete
指令,并仅返回一个 Future[String]
实例。由于它是一个字符串,Akka HTTP 知道如何将其序列化为 HTTP 响应。我们使用相同的方法来处理获取所有任务请求。从 Postman 开始,在你创建了一些任务之后,执行 步骤 02 - 获取所有 请求:
你会看到返回了你创建的任务列表。再次强调,这非常简单:
complete {
// our TaskService returns a Future[List[String]]. We map // this to a single Future[String] instance that can be returned // automatically by Akka HTTP
TaskService.all.map(_.foldLeft("")((z, b) => z + b.toString + "\n"))
}
我们调用 TaskService
对象,它返回一个 Future[List[Task]]
实例。由于我们尚未告诉 Akka HTTP 如何处理此类型,我们手动将其转换为正确的类型,并使用 complete
发送响应。到目前为止,我们只看到了 complete
的使用。在大多数情况下,Akka HTTP 提供的默认行为已经足够好,然而,你还可以以不同的方式自定义 Akka HTTP 发送的响应。
再次打开 Postman,使用 步骤 02 - 创建任务 选项创建多个任务,这次执行 步骤 02 – 获取任务无效 请求以获取单个请求。此请求将尝试获取 ID 为 100 的请求,该请求无效(除非你添加了 100 个任务)。结果看起来类似于以下内容:
如你所见,这次我们得到了一个 404 消息,带有自定义错误消息。如果你请求一个可用的任务(使用步骤 02 – 获取任务 请求),你会看到以下内容:
对于其余部分,这两个请求并没有太多特别之处。然而,如果你查看 Cookies 和 Headers 选项卡,你可能会注意到我们在那里得到了额外的结果。如果你打开这些选项卡,你会注意到一个带有 world
值的自定义 hello cookie,在 Headers 选项卡中,我们添加了一个名为 helloheader 的自定义头。
为了完成这个任务,我们使用了几个标准的 Akka HTTP 功能。以下代码片段显示了如何实现这一点:
val sampleHeader: HttpHeader =
(HttpHeader.parse("helloheader","hellovalue") match {
case ParsingResult.Ok(header, _) => Some(header)
case ParsingResult.Error(_) => None
}).get
...
implicit val StringMarshaller: ToEntityMarshaller[Task] =
Marshaller.opaque { s =>
HttpEntity(ContentType(`text/plain`), s.toString) }
...
get {
( setCookie(HttpCookie(name = "hello", value = "world")) &
respondWithHeader(sampleHeader)
) {
onComplete(TaskService.select(task)) {
case Success(Some(value)) => complete(value)
case Success(None) => complete(StatusCodes.NotFound,
"No tasks found")
case Failure(ex) => complete(StatusCodes.InternalServerError,
s"An error occurred: ${ex.getMessage}")
}
}
}
在这个代码片段中,我们使用了 Akka HTTP 提供的一些指令,可以用来自定义对请求的响应。使用 setCookie
和 respndWithHeader
指令,我们添加了自定义的 cookie 和头信息。在这个指令的内部路由中,我们使用 onComplete
指令来确定如何处理 TaskService.select
函数的响应。如果 Future
成功返回 Some[Task]
,我们以这个 Task
作为响应。如果没有找到 Task
,我们返回 404
(NotFound
状态码)。最后,如果 Future
无法成功完成,我们以 InternalServerError
作为响应。您可能会注意到,这次我们没有将 Task
转换为字符串,而是直接返回它。这是因为我们同时也定义了一个隐式的,ToEntityMarshaller[Task]
。这个 marshaller 允许 Akka HTTP 将 Task
case 类转换为 HTTPEntity
实例,Akka HTTP 知道如何将其序列化为 HttpResponse
。
除了这里显示的指令之外,Akka HTTP 还提供了一些其他指令和函数,您可以使用它们来自定义响应。以下表格显示了与自定义响应相关的指令:
指令 | 描述 |
---|---|
complete |
这使用提供的参数完成请求。 |
completeOrRecoverWith |
这返回提供的 Future 对象的结果。如果 Future 失败,则提取异常并运行内部路由。 |
completeWith |
这提取一个可以调用来完成请求的函数。 |
getFromBrowseableDirectories |
这将给定目录的内容作为文件系统浏览器提供服务。 |
getFromBrowseableDirectory |
这将给定目录的内容作为文件系统浏览器提供服务。 |
getFromDirectory |
这与一个 GET 请求匹配并返回特定目录中文件的內容。 |
getFromFile |
这与一个 GET 请求匹配并返回文件的內容。 |
getFromResource |
这与一个 GET 请求匹配并返回类路径资源的內容。 |
getFromResourceDirectory |
这与一个 GET 请求匹配并返回指定类路径目录中类路径资源的內容。 |
listDirectoryContents |
这与一个 GET 请求匹配并返回特定目录的內容。 |
| redirect
| 这发送一个重定向响应。还有更具体的指令:
-
redirectToNoTrailingSlashIfPresent
-
redirectToTrailingSlashIfMissing
|
| respondWithHeader
| 这在响应对象上设置一个特定的头。您也可以使用 responseWithHeaders
指令一次性添加多个头。这个指令会覆盖已设置的头。如果您不想覆盖现有的头,请使用以下指令:
-
respondWithDefaultHeaders
-
respondWithDefaultHeader
|
setCookie, deleteCookie |
这添加或删除一个 cookie。 |
---|---|
overrideStatusCode |
这设置了响应的 HTTP 状态码。Akka HTTP 提供了一个StatusCode 对象,你可以使用它来访问所有可用的响应码。 |
如果这些指令还不够,你也可以选择简单地返回一个HttpResponse
对象。Akka HTTP 提供了一个用于此目的的HttpResponse
案例类,你可以这样使用:
final case class HttpResponse(status: StatusCode = StatusCodes.OK,
headers: immutable.Seq[HttpHeader] = Nil,
entity: ResponseEntity = HttpEntity.Empty,
protocol: HttpProtocol = HttpProtocols.`HTTP/1.1`)
到目前为止,在本章中,我们看到了如何访问和提取请求信息以及设置和自定义响应的方式。在本章的最后几页,我们将探讨从 Akka HTTP 处理错误和异常情况的不同方法。
异常处理和拒绝
在本节中,我们将探讨 Akka HTTP 如何处理异常和拒绝。在我们查看代码之前,我们再次使用 Postman 来展示我们想要实现的目标。让我们从如何处理拒绝开始。拒绝是功能错误,它们要么由指令抛出,要么你可以自己抛出。在这个例子中,我们在创建任务请求中添加了一些验证。所以运行sbt runCH05-runCH05Step3
,打开 Postman,并执行请求,步骤 03 - 拒绝处理:
如您从响应中可以看到,我们想要创建的任务标题至少需要 10 个字符。展示如何添加此验证并配置拒绝处理器的代码如下:
handleRejections(customRejectionHandler) {
((post) & (parameters("title", "person".?, "status" ? "new"))) {
(title, assignedTo, status) => {
(entity(as[String])) { body => {
(validate(title.length > 10,
"Title must be longer than 10 characters") &
validate(List("new", "done", "in progress").contains(status),
"Status must be either 'new', 'done' or 'in progress'") &
validate(body.length > 0,
"Title must be longer than 10 characters")
) {
complete {
TaskService.insert(Task(-1, title, body,
assignedTo.map(Person(_)),
List.empty, Status(status)))
}
}
}
}
}
}
}
在此代码中,我们使用validate
指令来检查传入的参数。如果其中一个验证失败,请求将因ValidationRejection
结果而被拒绝。我们已经用handleRejections
指令包装了这个路由,所以这个路由的所有拒绝都将被这个处理器捕获。以下代码片段展示了这个处理器的外观:
val customRejectionHandler =
RejectionHandler.newBuilder()
.handle {
case ValidationRejection(cause, exception) =>
complete(HttpResponse(StatusCodes.BadRequest, entity =
s"Validation failed: $cause"))
}.result()
当你想创建一个拒绝处理器时,最简单的方法是使用RejectionHandler.newBuilder
对象。此对象为你提供了一些可以调用的函数来定义此处理器的行为。我们这里使用的handle
函数允许我们处理单个拒绝,我们使用它来在发生ValidationRejection
时响应 400 错误请求。任何其他拒绝都会沿着路由向上冒泡。除了handle
之外,你也可以使用handleAll
来同时处理同一拒绝的多个实例。最后,还有一个特定的handleNotFound
函数,你可以用它来定义当资源未找到时的行为。
异常处理的工作方式几乎相同。再次打开 Postman 并使用步骤 03 - 异常处理请求来触发异常:
在这种情况下,我们返回一个内部服务器错误。以下代码片段展示了如何使用 Akka HTTP 来完成这个操作:
val customExceptionHandler = ExceptionHandler {
case _: IllegalArgumentException =>
// you can easily access any request parameter here using extractors.
extractUri { uri =>
complete(HttpResponse(StatusCodes.InternalServerError, entity = s"The function on $uri isn't implemented yet"))
}
}
...
path("search") {
handleExceptions(customExceptionHandler) {
failWith(IllegalArgumentException("Search call not implemented"))
}
}
如您所见,使用异常处理程序与之前讨论的拒绝处理程序没有太大区别。这次,我们定义了一个ExceptionHandler
,并使用handleExceptions
指令将其连接到路由的特定部分。对于ExceptionHandler
,我们只提供了一个部分函数,其中我们指定要捕获的异常。
小贴士
在本节中,我们明确使用了handleExceptions
和handleRejections
函数来定义用于部分路由的处理程序。如果您想创建一个匹配完整请求的自定义处理程序,您也可以将处理程序定义为隐式。这样,它们将用于路由产生的所有拒绝和异常。值得注意的是,您不一定必须匹配所有拒绝和异常。如果您没有匹配特定的异常或拒绝,它将冒泡到路由中的更高一级,最终将到达 Akka HTTP 提供的默认处理程序。
在这个示例中,我们迄今为止已经看到了handleRejections
和handleExceptions
指令;还有一些其他与拒绝和异常相关的指令和函数可用。以下表格列出了所有这些:
指令 | 描述 |
---|---|
handleRejection |
这个指令使用提供的RejectionHandler 处理当前集合的拒绝。 |
handleException |
这个指令使用提供的ExceptionHandler 处理从内部路由抛出的异常。 |
cancelRejection |
这允许您取消来自内部路由的拒绝。您也可以使用cancelRejections 指令取消多个拒绝。 |
failWith |
这个指令引发指定的异常;这应该代替抛出异常使用。 |
recoverRejections |
这个指令和RecoverRejectionsWith 允许您处理拒绝并将它们转换为正常的结果。 |
reject |
这个指令使用指定的拒绝拒绝请求。 |
使用这些指令以及全局拒绝和异常处理程序,您应该能够干净地处理 REST 服务中的故障情况。
在关于 Akka HTTP 的讨论的最后主题中,我们将快速向您展示如何向您的服务添加 JSON 支持。
添加 JSON 支持
作为最后一步,我们将向我们的 Akka HTTP REST 服务添加 JSON 支持。添加后,我们将能够向我们的服务发送 JSON,Akka HTTP 将自动将其转换为我们所使用的案例类。我们需要做的第一件事是添加一个额外的 SBT 依赖项:
val backendDeps = Seq (
"com.typesafe.akka" %% "akka-stream-experimental" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-core-experimental" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-experimental" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaHttpVersion
)
一旦添加,我们需要通知 Akka HTTP 我们的哪些案例类应该被序列化和反序列化为 JSON。通常的做法是定义一个特定的特质,其中包含 Akka HTTP 需要的隐式。对于我们的示例,我们定义这个特质如下:
trait AkkaJSONProtocol extends DefaultJsonProtocol {
implicit val statusFormat = jsonFormat1(Status.apply)
implicit val noteFormat = jsonFormat2(Note.apply)
implicit val personFormat = jsonFormat1(Person.apply)
implicit val taskFormat = jsonFormat6(Task.apply)
}
这段代码相当直观。我们在这里定义了哪些案例类应该被序列化和反序列化为 JSON。接下来,我们需要确保我们导入了 Akka HTTP 的 JSON 库:
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
最后,确保我们在应用程序中扩展这个特质:
object AkkaHttpDSLStep4 extends App with AkkaJSONProtocol
在这个阶段,我们可以移除本章前面定义的自定义序列化器,并让所有的方法直接返回Task
实例,而不是将Task
实例转换为字符串。例如,要检索所有当前存储的任务,我们只需这样做:
path("tasks") {
get {
complete {
TaskService.all
}
}
}
要将传入的 JSON 转换为Task
对象,我们需要使用entity
指令。添加Task
对象的代码现在变为这样:
post {
(entity(as[Task])) { task => {
complete {
TaskService.insert(task)
}
}
}
}
执行这些步骤后,我们可以在 Postman 中查看它是否工作。在 Postman 中打开请求,04 - 创建任务,并运行它:
在这里,我们可以看到发送的 JSON 正在被服务器处理,并且成功添加。现在重复几次这个操作,然后使用04 - 获取所有任务请求来查看我们是否可以检索已添加的所有任务的列表:
摘要
在本章中,我们探讨了 Akka HTTP 提供的几乎所有功能。你学习了如何使用可用的指令来匹配特定的请求属性并从请求的头部、参数和体中提取信息。除了处理传入请求的指令外,Akka HTTP 还提供了创建和自定义发送回客户端的响应的指令。如果在处理请求时发生错误,Akka HTTP 提供了标准的异常和拒绝处理器。你还学习了如何通过添加自定义处理器来覆盖默认行为。最后,本章还展示了添加 JSON 支持以及自动将你的类序列化和反序列化到 JSON 是多么容易。
在下一章中,我们将探讨本书中将要讨论的最终 REST 框架,Play 2。
第六章. 使用 Play 2 框架创建 REST 服务
对于本书中的最后一个 REST 框架,我们将探讨 Play 2。Play 2 是一个现代 Web 框架,可以用来创建完整的应用程序。该框架提供的部分工具允许你快速轻松地创建 REST 服务。在本章中,我们将专注于这部分。
在本章中,我们将讨论以下主题:
-
使用路由文件进行路由匹配
-
处理传入的 HTTP 请求并自定义响应
-
添加 JSON 支持、自定义验证和错误处理
首先,让我们了解一下 Play 2 框架是什么,以及你可以在哪里找到有关它的更多信息。
Play 2 框架简介
Play 2 框架是 Scala 生态系统中最知名和最广泛使用的 Web 框架之一。它提供了一个非常友好的开发方式来创建 Web 应用程序和 REST 服务。Play 2 的一些最有趣的功能包括:
-
自动重新加载更改:在创建 Play 2 应用程序时,你不需要在代码更改后重新启动应用程序。只需重新发送请求,Play 2 将自动重新加载更改。
-
可扩展性:Play 2 基于 Akka。通过 Akka 框架,它提供了一个简单的方式来向上和向外扩展。
-
强大的工具支持:Play 2 基于 SBT,这是 Scala 的标准构建工具。通过一个 SBT 插件,你可以轻松启动、重新加载和分发你的 Play 2 应用程序。
尽管 Play 2 并非专门构建为 REST 框架,但我们可以使用其部分功能轻松创建 REST 应用程序,同时还能利用 Play 2 的特性。
使用 Play 2 的 Hello World
要在 Play 2 中创建一个简单的 Hello World REST 服务,我们首先需要正确设置 SBT 项目。Play 2 使用一个 SBT 插件来运行一个 HTTP 服务器,你可以使用它来访问你的 REST 服务。我们需要做的第一件事是将此插件添加到 plugins.sbt
文件中(该文件位于 project
目录中):
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.4.0")
接下来,我们定义依赖关系,以便我们可以创建一个基于 Play 2 的 REST 服务。对于 Play 2,我们使用以下依赖项:
lazy val playVersion = "2.4.0"
val backendDeps = Seq (
"com.typesafe.play" %% "play" % playVersion,
"com.typesafe.play" %% "play-docs" % playVersion
)
我们还需要在 SBT 配置(build.sbt
)中添加一个步骤,以便能够运行本章中的示例:
addCommandAlias("runCH06-HelloPlay", "; chapter06/run -Dhttp.port=8080 -Dplay.http.router=hello.Routes")
addCommandAlias("runCH06-runCH06Step1", "; chapter06/run -Dhttp.port=8080 -Dplay.http.router=step01.Routes")
addCommandAlias("runCH06-runCH06Step2", "; chapter06/run -Dhttp.port=8080 -Dplay.http.router=step02.Routes")
addCommandAlias("runCH06-runCH06Step3", "; chapter06/run -Dhttp.port=8080 -Dplay.http.router=step03.Routes")
import PlayKeys._
lazy val chapter06 = (project in file ("chapter-06"))
.enablePlugins(PlayScala)
.dependsOn(common)
.settings(commonSettings: _*)
.settings(
name := "chapter-06",
libraryDependencies := DependenciesChapter6.backendDeps
)
这里的重要部分是 addCommandAlias
和 enablePlugins
函数。通过 addCommandAlias
函数,我们定义 Play 2 应该监听哪个端口以及应该使用哪种路由配置(更多内容请参阅 使用路由文件 部分)。第一个 addCommandAlias
函数定义我们将使用 hello.Routes
文件并在端口 8080
上监听。为了能够在开发期间运行 Play 2,我们还需要将之前定义的插件添加到这个项目中。我们通过 enablePlugins(PlayScala)
调用来实现这一点。
现在,让我们更仔细地看看我们用来定义这个 Hello World 示例路由的hello.routes
文件。您可以在routes
目录中找到此文件。hello.routes
文件的内容看起来类似于以下内容:
GET /hello controllers.Hello.helloWorld
这意味着一个匹配/hello
路径的GET
请求将调用由controllers.Hello.helloWorld
定义的操作。这个操作定义在Hello.scala
文件中,Play 2 默认将其存储在app
目录下:
package controllers
import play.api.mvc._
object Hello extends Controller {
def helloWorld = Action {
Ok("Hello Play")
}
}
现在通过调用sbt runCH06-HelloPlay
启动项目:
Joss-MacBook-Pro:rest-with-scala jos$ sbt runCH06-HelloPlay
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
--- (Running the application, auto-reloading is enabled) ---
[info] p.a.l.c.ActorSystemProvider - Starting application default Akka system: application
[info] p.c.s.NettyServer$ - Listening for HTTP on /0:0:0:0:0:0:0:0:8080
(Server started, use Ctrl+D to stop and go back to the console...)
到目前为止,Play 2 已经开始,我们可以在端口8080
上访问我们的 REST 服务。请注意,这次我们没有创建自己的自定义启动器,而是使用了 Play 2 提供的。要停止此服务器,您可以按Ctrl + D。
小贴士
当我们像本章中这样做来启动 Play 2 时,我们是在开发模式下启动 Play 2。在开发模式下,Play 2 将自动重新加载服务器更改,并在出现问题时提供详细的错误消息。然而,您不应该在开发模式下运行生产 Play 2 应用程序。开发模式下的性能比生产模式差,并且通过详细的错误消息,大量内部状态被暴露给客户端,这在生产环境中是不希望看到的。
因此,当您完成开发,并开始性能测试或部署应用程序到生产环境时,您应该以生产模式启动 Play 2。有关如何操作的说明可以在 Play 2 网站上找到,网址为www.playframework.com/documentation/2.4.x/Production
。
为了测试是否一切正常工作,打开 Postman 并执行Chapter 6
文件夹中的Hello Play请求:
响应正如您可能预期的那样。对/hello
发出的GET
请求仅返回Hello Play
值。Play 2 的一个非常好的特点是您可以在不重新启动服务器的情况下更改代码。作为一个例子,将Hello
控制器中的响应消息更改为以下内容:
def helloWorld = Action {
Ok("Hello Play, now with reload!")
}
保存文件,不要停止或重新启动 Play 2 服务器;相反,打开 Postman 并再次执行相同的请求:
如您所见,服务器现在返回了更新的字符串值,而无需您重新启动服务器。
在下一节中,我们将更深入地探索路由文件。
与路由文件一起工作
在上一节中,我们看到了一个非常小的路由文件;它只包含一行,处理了到/hello
路径的GET
请求。对于这个例子,我们将使用以下路由文件(routes
文件夹中的step01.routes
):
POST /tasks controllers.Step1.createTask
GET /tasks controllers.Step1.getTasks
GET /tasks/:id controllers.Step1.getTask(id: Long)
DELETE /tasks/:id controllers.Step1.deleteTask(id: Long)
PUT /tasks/:id controllers.Step1.updateTask(id: Long)
GET /*path controllers.Step1.notImplemented(path: String)
POST /*path controllers.Step1.notImplemented(path: String)
PUT /*path controllers.Step1.notImplemented(path: String)
DELETE /*path controllers.Step1.notImplemented(path: String)
在这个路由文件中,我们定义了多个不同的 HTTP 动词、路径和要执行的操作。如果我们查看每一行,我们首先看到我们想要匹配的 HTTP 动词。你可以使用 Play 2 的这组 HTTP 动词:GET
、POST
、PUT
、PATCH
、DELETE
、HEAD
和OPTIONS
。接下来,我们看到我们想要匹配的路径。Play 2 在该位置提供了一些不同的构造可以使用:
路径构造 | 描述 |
---|---|
/tasks |
这与路径匹配,但不提取任何信息。 |
/tasks/:id |
这与路径匹配并提取一个段,该段以提供的名称(在这种情况下为id )传递给操作。 |
/*path |
Play 2 也支持通配符。使用* ,你可以匹配一个路径,该路径被提取,分配给提供的名称(在这种情况下为path ),并传递给操作。 |
/$id<[0-9]+> | 如果你以$ 开始路径,Play 2 将其解释为正则表达式。匹配项被提取,分配给名称(在这种情况下为id ),并传递给操作。 |
你可以看到,响应代码是404 Not Found,这是由于我们控制器中的NotFound("…")
函数的结果。
在下面的代码片段中,我们展示了提供从路由文件引用操作的控制器:
package controllers
import play.api.mvc._
object Step1 extends Controller {
def createTask = Action { request =>
val body: Option[String] = request.body.asText
Ok(s"Create a task with body:
${body.getOrElse("No body provided")}")
}
def getTasks = Action {
Ok("Getting all tasks")
}
def getTask(id: Long) = Action {
Ok(s"Getting task with id: $id")
}
def deleteTask(id: Long) = Action {
Ok(s"Delete task with id: $id")
}
def updateTask(id: Long) = Action { request =>
val body: Option[String] = request.body.asText
Ok(s"Update a task with body:
${body.getOrElse("No body provided")}")
}
def notImplemented(path: String) = Action {
NotFound(s"Specified route not found: $path")
}
}
在我们在这个Controller
中定义的操作中,我们没有做任何特别的事情。我们只是指定 HTTP 状态,它以Ok
、NotFound
或BadRequest
的形式存在,并将体设置为发送回客户端。Play 2 为所有定义的 HTTP 代码提供标准结果。查看play.api.mvc.Results
对象以获取所有可能性。
现在打开 Postman,我们将测试我们刚刚实现的一些路由。使用sbt runCH06-runCH06Step1
命令启动 Play 2 服务:
$ sbt runCH06-runCH06Step1
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
--- (Running the application, auto-reloading is enabled) ---
[info] p.a.l.c.ActorSystemProvider - Starting application default Akka system: application
[info] p.c.s.NettyServer$ - Listening for HTTP on /0:0:0:0:0:0:0:0:8080
(Server started, use Ctrl+D to stop and go back to the console...)
在 Postman 中,从第六章
文件夹中执行步骤 01 – 通过 ID 获取任务请求:
在响应中,你可以看到 ID 是从 URL 路径中提取出来的。如果你指定了一个无法转换为长整型的值,则路由不会匹配,操作也不会执行。在这个请求的响应代码中,你可以看到200 OK,这反映了我们在控制器中使用的Ok("…")
函数。接下来,让我们执行一个与通配符匹配器匹配的请求,即步骤 01 - 无效请求:
路由文件中每一行的最后一部分是要调用的操作名称(参见下一代码片段)。当我们提取一个值时,我们也可以指定我们期望的类型(我们将在下一节中看到更多关于这一点),例如,controllers.Step1.getTask(id: Long)
将提取的id
转换为Long
,如果我们没有指定任何内容,它将以String
的形式传递给操作。
在下一节中,我们将扩展这个示例并添加对Future
的支持。我们还将查看传入请求的更多细节以及你可以自定义响应的方式。
添加 Future 支持和输出编写器
在本节中,我们将在上一节中创建的非常简单的服务中添加更多功能。我们将添加使用请求参数创建任务的功能,并连接我们的通用 TaskService
。首先,我们查看路由文件(step02.routes
):
POST /tasks controllers.Step2.createTask(
title: String, person: Option[String],
status: String ?="New")
GET /tasks controllers.Step2.getTasks
GET /tasks/:id controllers.Step2.getTask(id: Long)
DELETE /tasks/:id controllers.Step2.deleteTask(id: Long)
PUT /tasks/:id controllers.Step2.updateTask(
id: Long, title: String,
person: Option[String],
status: String ?="New")
在这里没有太多变化,除了我们现在向 Controller
类中定义的操作传递更多的参数。如果你仔细看看第一行,你可以看到我们传递了多个参数。到目前为止,我们只看到了从 URL 路径中提取的参数。如果 Play 2 在 Action
方法的签名中遇到它不知道的参数,它假设这个参数是以请求参数的形式传入的。所以在这个第一个路由的情况下,我们期望有一个 title
参数,一个可选的 person
参数和一个 status
参数。title
参数是必需的,person
参数是可选的,对于 status
参数,我们使用 ?=
语法设置了一个默认值。在接下来的段落中,我们将看看这些路由是如何映射到 Step2
控制器中定义的操作的。
在我们查看操作之前,我们先快速看一下几个 Writeable
隐式值:
object Step2 extends Controller {
// simple implicit to convert our tasks to a simple string for now
implicit def wTask: Writeable[Task] =
Writeable(_.toString.getBytes, Some("application/text"))
implicit def wListTask: Writeable[List[Task]] =
Writeable(_.mkString("\n").getBytes, Some("application/text"))
Play 2 使用一个 Writeable
对象来确定如何将某些类型转换为 HTTP 响应。在上一个示例中,我们只是将每个任务转换为字符串并返回。我们需要在每个操作中这样做。通过定义一个 Writeable[Task]
对象(以及一个 Writeable[List[Task]]
用于 Tasks
的列表),我们只需返回一个 Ok(task)
,Play 2 将检查作用域内是否有该类型的隐式 Writeable
,并使用它将 Task
转换为它理解的输出(一个 Array[Byte]
实例)。在下面的代码片段中,我们可以看到我们只是返回一个 Task
,而不是在 Action
块中将其转换:
def createTask(title: String, person: Option[String],
status: String) = Action.async { request =>
val body: Option[String] = request.body.asText
val createdTask = TaskService.insert(Task(
-1,
title,
body.getOrElse("No body provided"),
person.map(Person(_)),
List[Note](),
MStatus(status)))
createdTask.map(Ok(_))
}
当我们使用所需的参数进行 POST
请求时,会调用 createTask
操作。在这个函数中,我们只是使用提供的信息和请求的主体来创建和存储一个新的 Task
实例。如果你查看 TaskService.insert
的签名,你可以看到这个函数返回一个 Future[Task]
实例。如果你使用 Action {...}
函数,你会得到一个编译错误信息,因为我们想返回一个 Result[Future[Task]]
实例,而 Play 2 不理解这个实例。这个问题很容易解决。我们只需要使用 Action.async {…}
而不是 Action {…}
。现在我们只需返回一个 Future[T]
,Play 2 就会知道该怎么做(只要作用域内有隐式 Writeable[T]
)。
在我们继续进行下一个操作之前,我们将更仔细地查看在这个示例中使用的请求对象。如果你在你的操作块中添加 request =>
,你可以访问传入的请求。在这个操作中,我们使用它来访问主体,但这个对象允许访问更多的请求信息。最重要的信息列在这里:
属性 | 描述 |
---|---|
id |
这是请求的 ID。当应用程序启动时,第一个请求的 ID 从 1 开始,并且随着每个请求的增加而递增。此对象类型为长整型。 |
| tags
| 此属性包含有关路由的信息,并返回一个 Map[String, String]
。此信息看起来类似于以下内容:
Map(ROUTE_COMMENTS -> this is a comment,
ROUTE_PATTERN -> /tasks,
ROUTE_CONTROLLER -> controllers.Step2,
ROUTE_ACTION_METHOD -> createTask,
ROUTE_VERB -> POST)
|
uri |
这包含请求的完整 URI。这是路径和查询字符串的组合。此类型为字符串。 |
---|---|
path |
这是 URI 的路径部分。这返回一个字符串 |
method |
这是用于发出请求的方法部分(动词)。这是一个字符串。 |
version |
这是此请求的 HTTP 版本。这也以字符串形式返回。 |
queryString |
这是以 Map[String, Seq[String]] 对象形式解析的查询字符串,其中包含查询参数。如果您想访问原始查询字符串,可以使用 rawQueryString 属性。 |
headers |
这访问请求中提供的所有头信息。此对象类型为 Headers 。 |
remoteAddress |
这返回客户端地址作为字符串。 |
secure |
如果客户端使用了 HTTPS,则此属性为 true;否则为 false。 |
host |
这是 URI 的主机部分,作为字符串。 |
domain |
URI 的域名部分,作为字符串。 |
cookies |
请求中发送的 cookie。此对象类型为 Cookies 。 |
除了这些之外,request
对象还提供了一些额外的属性来访问特定的头信息:acceptLanguages
、acceptedTypes
、mediaType
、contentType
和 charSet
。
现在我们已经看到了我们可以对传入的请求做什么,在接下来的代码片段中,我们将看看我们如何可以自定义发送回客户端的响应:
def getTasks = Action.async {
TaskService.all.map(
Ok(_)
.as("application/text")
.withCookies(new Cookie("play","cookie"))
.withHeaders(("header1" -> "header1value")))
}
在此片段中,我们调用 TaskService.all
函数,该函数返回一个 Future[List[Task]]
对象。由于我们已为 List[Task]
定义了 Writeable
,我们所需做的只是将 Future[List[Task]]
转换为 Future[Result]
,我们通过调用 map
并返回 Ok(_)
来实现这一点。当我们返回结果时,我们使用一些额外的函数来自定义结果。我们使用 as
来设置结果的 content-type
,并通过 withCookies
和 withHeaders
添加自定义的 cookie 和头信息。Play 2 为此提供了以下函数:
函数 | 描述 |
---|---|
withHeaders |
这会将提供的 (String, String) 元组作为头信息添加到响应中。 |
withCookies |
这会将提供的 Cookies 实例添加到响应中。 |
discardingCookies |
使用此函数也可以从响应中删除特定的 cookie。所有与提供的 DiscardingCookies 匹配的 cookie 都将被删除。 |
as |
这会将结果的 content-type 设置为提供的字符串值。 |
以下代码片段显示了我们在这一步实现的最后几个操作:
def getTask(id: Long) = Action.async {
val task = TaskService.select(id);
task.map({
case Some(task) => Ok(task)
case None => NotFound("")
})
}
def updateTask(id: Long, title: String,
person: Option[String],
status: String) = Action.async { request =>
val body: Option[String] = request.body.asText
val updatedTask = TaskService.update(id, Task(
id,
title,
body.getOrElse("No body provided"),
person.map(Person(_)),
List[Note](),
MStatus(status)))
updatedTask.map({
case Some(task) => Ok(task)
case None => NotFound("")
})
}
如您所见,这两个最后的动作与我们之前看到的非常相似。我们只是映射 Future
并在包含的选项上使用模式匹配来返回 Ok
(200)或 NotFound
(404)。
现在我们已经探讨了完整的示例,我们将检查它在 Postman 中的样子。首先通过运行 sbt runCH06-runCH06Step2
启动此步骤的示例:
$ sbt runCH06-runCH06Step2
[info] Loading project definition from /Users/jos/dev/git/rest-with-scala/project
[info] Set current project to rest-with-scala (in build file:/Users/jos/dev/git/rest-with-scala/)
--- (Running the application, auto-reloading is enabled) ---
[info] p.a.l.c.ActorSystemProvider - Starting application default Akka system: application
[info] p.c.s.NettyServer$ - Listening for HTTP on /0:0:0:0:0:0:0:0:8080
(Server started, use Ctrl+D to stop and go back to the console...)
一旦启动,首先执行 步骤 02 - 创建任务 请求几次:
如您所见,Task
是异步创建的,并且创建的 Task
被返回。现在当我们运行 步骤 02 – 获取所有任务 请求时,您应该看到所有请求的列表,通过换行符分隔:
在下一节中,我们将向您展示如何添加 JSON 序列化和错误处理。
添加 JSON 序列化、验证和错误处理
正如我们看到的其他框架一样,REST 框架的一个重要特性是对 JSON 的支持。在 Play 2 中,添加 JSON 非常简单。只需定义以下隐式参数即可:
implicit val fmtNote = Json.format[Note]
implicit val fmtPerson = Json.format[Person]
implicit val fmtMStatus = Json.format[MStatus]
implicit val fmtTask = Json.format[Task]
在作用域内使用这些隐式参数,我们可以通过以下两个函数将相关的 case 类转换为和从 JSON:
-
Json.toJson(obj)
:这会将提供的obj
实例转换为 JSON。如果我们已经为该 case 类定义了Format
对象,就像我们之前做的那样,这将起作用。 -
request.body.asJson.map(_.as[Task])
:从 JSON 转换为 case 类与直接转换一样简单。使用asJson
将传入的体转换为JsValue
,然后使用as
将其转换为支持的 case 类。
虽然这种方法对于简单场景效果很好,但它不允许你在创建 case 对象之前验证输入值。如果你想在解析传入的 JSON 时添加验证,你必须明确写出特定的 JSON 字段是如何映射到 case 类的属性的。
为了从和到 JSON 转换,我们需要为每个类定义一个 Reads[T]
和一个 Writes[T]
对象。Reads
对象定义了我们将如何从传入的 JSON 转换为 case 类,而 Writes
定义了相反的过程。如果 to
和 from
是对称的,你也可以使用单个 Format
隐式参数而不是分别定义 Reads
和 Writes
(注意,这不能用于只有一个参数的 case 类)。如果我们查看 Note
case 类的映射,我们可以使用 Format
方法:
implicit def notesFormat: Format[Note] = (
(JsPath \ "id").format[Long] and
(JsPath \ "content").format[String])
(Note.apply, unlift(Note.unapply))
在此代码片段中,我们将 case 类的第一个参数映射到 id
JSON 字段作为长整型,第二个参数映射到具有名称内容的字符串。对于 Status
case 类,我们定义了单独的 Writes
和 Reads
并添加了验证:
implicit def statusReads: Reads[MStatus] =
((JsPath \ "status").read(
minLengthString andKeep
filter(ValidationError("Status must be either New,
In Progress or Closed"))
((b: String) => List("New", "In Progress",
"Closed").contains(b))
))
.map(MStatus(_))
implicit def statusWrites: Writes[MStatus] = ((JsPath \
"status").write[String])
.contramap((_.status))
注意,在导入时,我们将我们的Status
案例类别别名为MStatus
,以避免与 Play 2 提供的Status
发生命名冲突。在statusReads
定义中,你可以看到我们将传入的状态 JSON 字段映射到我们的案例类。我们还为此属性添加了两个验证检查。它需要至少有3
个字符的长度(minLength
),并且我们使用自定义过滤器检查status
的值是否为New
、In Progress
或Closed
之一。对于写入 JSON,我们创建了一个简单的Writes[MStatus]
实例。
在Task
的Reads
中,我们还添加了一些简单的验证:
implicit def taskReads: Reads[Task] = (
(JsPath \ "id").read[Long] and
(JsPath \ "title").read(minLengthString
andKeep maxLengthString) and
(JsPath \ "content").read[String] and(JsPath \ "assignedTo").readNullable[Person] and
(JsPath \ "notes").read[List[Note]] and
(JsPath \ "status").read[MStatus])(Task.apply _)
我们希望标题至少有三个字符,最大长度为 10。注意,我们在这个Reads
定义中使用了readNullable
。使用readNullable
,我们得到一个Option[T]
对象,这意味着 JSON 字段也是可选的。以下表格显示了你可以进行的不同验证检查:
验证 | 描述 |
---|---|
max |
这检查一个数字 JSON 属性的值是否超过最大值。 |
min |
这检查数字 JSON 属性的最小值。 |
filterNot |
这检查是否满足自定义谓词。如果谓词返回 true,将创建一个验证错误。 |
filter |
这检查是否满足自定义谓词。如果谓词返回 false,将创建一个验证错误。 |
maxLength |
这检查字符串 JSON 属性的最大长度。 |
minLength |
这检查字符串 JSON 属性的最小长度。 |
pattern |
这检查 JSON 属性是否与提供的正则表达式匹配。 |
email |
这检查 JSON 属性是否是电子邮件地址。 |
你也可以将多个检查添加到单个 JSON 属性中。使用andKeep
和keepAnd
(这两个函数在语义上略有不同,但与验证一起使用时,它们以完全相同的方式工作),两个检查都必须成功,而使用or
,至少有一个检查必须成功。
现在我们已经定义了如何将数据转换为 JSON 以及从 JSON 转换回来,让我们看看如何在我们的操作中使用它:
def createTask = Action.async { request =>
val body: Option[JsResult[Task]] =
request.body.asJson.map(_.validate[Task])
// option defines whether we have a Json body or not.
body match {
case Some(task) =>
// jsResult defines whether we have failures.
task match {
case JsSuccess(task, _) => TaskService.insert(task).
map(b => Ok(Json.toJson(b)))
case JsError(errors) =>
Future{BadRequest(errors.mkString("\n"))}
}
case None => Future{BadRequest("Body can't be parsed to JSON")}
}
}
在这个代码片段中,我们首先使用asJson
函数将传入请求的主体转换为 JSON。这返回一个Option[JsValue]
对象,我们使用validate
函数将其映射到Option[JsResult[Task]]]
实例。如果我们的选项是None
,这意味着我们无法解析传入的 JSON,我们将返回一个BadRequest
结果。如果我们有验证错误,我们得到一个JsError
并响应一个显示错误的BadRequest
;如果验证顺利,我们得到一个JsSuccess
并将Task
添加到TaskService
,它响应Ok
。
现在打开 Postman 检查生成的 JSON 看起来像什么。首先添加几个Tasks
,使用步骤 03 – 创建任务请求:
在此请求中使用的 JSON 符合我们添加的验证,因此请求处理时没有错误。如果你执行步骤 03 – 创建无效任务请求,你会看到验证被触发并返回一个错误请求:
到目前为止,在本章中,我们只处理了功能错误。数据错误导致返回BadRequest
,而找不到Task
则返回Not Found
响应。Play 2 还提供了一种处理意外错误的方法。在下一节中,我们将向您展示如何向您的 Play 2 服务添加自定义错误处理器以处理未处理的异常。
首先,让我们看看当发生异常时 Play 2 的默认行为是什么。为此,我们将Delete
操作更改为以下内容:
def deleteTask(id: Long) = Action.async {
val task = TaskService.delete(id);
// assume this task does something unexpected and throws
// an exception.
throw new IllegalArgumentException("Unexpected argument");
task.map({
case Some(task) => Ok(task)
case None => NotFound("")
})
}
当我们调用此操作时,结果如下:
Play 2 响应一个500 内部服务器
错误以及大量解释错误的 HTML(注意:在生产模式下运行时,你会看到一个略有不同的错误,但仍然是 HTML 格式)。虽然这对于开发 Web 应用程序来说很好,但在创建 REST 服务时并不那么有用。为了自定义错误处理,我们必须向 Play 2 提供一个HttpErrorHandler
的实现。我们可以从头开始实现此接口,但一个更简单的方法是扩展 Play 2 提供的默认错误处理器(DefaultErrorHandler
)。我们将使用的处理器如下所示:
class ErrorHandler @Inject() (env: Environment,
config: Configuration,
sourceMapper: OptionalSourceMapper,
router: Provider[Router]
) extends DefaultHttpErrorHandler(env, config, sourceMapper, router) {
override def onDevServerError(request: RequestHeader,
exception: UsefulException) = {
Future.successful(
InternalServerError("A server error occurred: " +
exception.getMessage)
)
}
override def onProdServerError(request: RequestHeader,
exception: UsefulException) = {
Future.successful(
InternalServerError("A server error occurred: " +
exception.getMessage)
)
}
}
可以重写以下函数来定义自定义的错误处理:
函数 | 描述 |
---|---|
onClientError |
当 4xx 范围内的错误发生时调用此函数。根据错误的类型,默认处理器会委托给以下三个函数之一。 |
onBadRequest |
当发起一个错误请求(400)时调用此函数。 |
onForbidden |
当请求一个禁止访问的资源(403)时调用此函数。 |
onNotFound |
当找不到资源(404)时调用此函数。 |
onServerError |
当发生服务器错误时调用此函数。此函数将委托给以下两个函数之一。 |
onDevServerError |
在开发模式下,当发生服务器错误时调用此函数。 |
onProdServerError |
在生产模式下,当发生服务器错误时调用此函数。 |
现在我们将在发起导致内部服务器错误的请求时获得一个更简单的错误消息:
到目前为止,我们已经讨论了 Play 2 框架最重要的功能。
摘要
在本章中,我们向您介绍了 Play 2 的主要功能(至少是那些与 REST 部分相关的)。我们从一个简单的服务开始,介绍了路由文件、控制器和动作。之后,我们探讨了如何从请求中检索信息、解析路径段以及访问查询参数。Play 2 还使得对动作的响应进行自定义变得简单。它为所有可能的 HTTP 响应代码提供了标准的情况类,并提供了添加头和 cookie 到响应的附加功能。Play 2 通过 async
函数支持与未来的协同工作。这样我们就可以像处理普通对象一样处理未来。最后,我们探讨了 JSON 支持和验证。当将 JSON 转换为情况类时,您还可以添加验证,以检查提供的值在创建情况类之前是否有效。
通过本章关于 Play 2 的讨论,我们完成了本书中介绍的不同 REST 框架的讨论。在本书的最后一章中,我们将探讨一些高级 REST 框架功能,例如 HATEOAS、身份验证和客户端支持。
第七章:JSON、HATEOAS 和文档
在最后一章中,我们将更深入地探讨 REST 的一些重要部分。我们将从对可用的不同 JSON 库的更深入解释开始,然后我们将探索 HATEOAS 概念,并解释您如何将这一原则应用于本书中解释的框架。
让我们从 JSON 开始。
工作与 JSON
对于 Scala,有许多不同的 JSON 框架可供选择。在本章中,我们将探讨四个最重要和最常用的框架。以下表格简要介绍了我们将使用的框架:
框架 | 描述 |
---|---|
Argonaut | Argonaut 是一个功能丰富的 JSON 库,它提供了以函数式方法处理 JSON 的方法。它具有非常广泛的 JSON 遍历和搜索功能。您可以在argonaut.io 了解更多信息。 |
Json4s | Json4s 是一个提供标准方式解析和渲染 JSON 的库。它在上面的库(如 lift-json 和 Jackson)之上提供了一个标准化的接口。您可以在json4s.org/ 了解更多信息。 |
Play JSON | Play JSON 为 Play 2 框架提供了 JSON 支持。然而,这个库也可以独立使用,并提供了一种非常易于使用的方式来处理 JSON。您可以在www.playframework.com/documentation/2.4.x/ScalaJson 了解更多信息。 |
spray-json | spray-json 是一个轻量级的 JSON 框架,它提供了一些处理 JSON 的基本功能。它是 Spray 框架的一部分,但也可以独立使用。您可以在github.com/spray/spray-json 了解更多信息。 |
对于每个框架,我们将探讨如何完成以下步骤:
-
从字符串解析为 JSON 对象:这一步的输入是一个字符串。我们将向您展示如何使用 JSON 框架将这个字符串转换为 JSON 对象。
-
将 JSON 对象输出为字符串:当您拥有一个 JSON 对象时,一个常见的需求是将它打印为字符串。所有框架都提供了对这个功能的支持。
-
手动创建 JSON 对象:有时,您可能需要手动创建一个 JSON 对象(例如,在序列化复杂对象的一部分时)。在这个步骤中,我们将解释如何做到这一点。
-
查询 JSON 对象:在将字符串转换为 JSON 对象后,一个常见的需求是从 JSON 字符串中获取特定值。在这个步骤中,我们将向您展示支持这一功能的多种方式。
-
将数据转换为和从 case 类:在前面的章节中,我们已经看到了如何将 case 类转换为 JSON 以及从 JSON 转换为 case 类。所有框架都提供了对这个功能的具体支持,我们将在这一步中探讨它是如何工作的。
在我们查看代码之前,我们首先必须确保我们拥有所有必需的库。每个 JSON 库都可以通过添加单个 SBT 依赖项来添加。以下依赖项集添加了所有库:
val backendDeps = Seq (
"io.argonaut" %% "argonaut" % "6.0.4",
"org.json4s" %% "json4s-native" % "3.2.10",
"io.spray" %% "spray-json" % "1.3.2",
"com.typesafe.play" %% "play-json" % "2.4.0"
)
我们为每个框架解释的第一个步骤是如何将字符串转换为 JSON 对象。对于每个框架,我们将使用以下输入字符串:
val json = """{
"id": 1,
"title": "The Title",
"content": "This is the data to create a new task",
"assignedTo": {
"name": "pietje"
},
"notes": [],
"status": {
"status": "New"
}
}"""
您还可以直接从各种库中运行代码。您可以在chapter7
文件夹中找到源代码,并通过运行 SBT 来运行示例。从 SBT 运行以下命令:
> chapter07/run-main chapter7.json.PlayJson
> chapter07/run-main chapter7.json.Argonaut
> chapter07/run-main chapter7.json.Json4S
> chapter07/run-main chapter7.json. SprayJson
现在,让我们看看第一个 JSON 库,Json4s。
使用 Json4s
首先,我们将展示如何解析我们刚才看到的字符串值:
val parsedJson = parse(json);
您所需要做的就是调用字符串上的parse
函数,结果是一个JValue
对象。如果字符串无法解析,将抛出ParseException
。打印的结果看起来类似于以下内容:
JObject(List((id,JInt(1)), (title,JString(The Title)), (content,JString(This is the data to create a new task)), (assignedTo,JObject(List((name,JString(pietje))))), (notes,JArray(List())), (status,JObject(List((status,JString(New)))))))
如您所见,字符串被解析为一系列 Json4s 特定的类。现在我们已经得到了一个JValue
对象,我们也可以将其再次转换为字符串:
pretty(render(parsedJson)); // or compact
通过调用pretty(render(parsedJson))
,您将得到一个pretty
打印的字符串值。如果您想要一个紧凑的字符串,您可以调用compact(render(parsedJson))
代替。pretty
函数的结果如下所示:
{
"id":1,
"title":"The Title",
"content":"This is the data to create a new task",
"assignedTo":{
"name":"pietje"
},
"notes":[],
"status":{
"status":"New"
}
}
下一步是手动创建一个 JSON 对象(一个JValue
对象)。Json4s 提供了一个非常方便的方式来做到这一点:
val notesList = SeqNote, Note(2, "Note 2"))
val jsonManually =
("id" -> 1) ~
("title" -> "title") ~
("content" -> "the content") ~
("assignedTo" ->
("name" -> "pietje")) ~
("notes" ->
notesList.map { note =>
(("id" -> note.id) ~
("content" -> note.content))}) ~
("status" ->
("status" -> "new"))
如您所见,您只需指定键并提供一个值;Json4s 将自动创建相应的 JSON 对象。当我们打印它时,这次使用compact
函数,我们将看到以下内容:
{"id":1,"title":"title","content":"the content","assignedTo":{"name":"pietje"},"notes":[{"id":1,"content":"Note 1"},{"id":2,"content":"Note 2"}],"status":{"status":"new"}}
要查询 JSON 对象,Json4s 提供了两种方法。您可以使用类似于 XPath 的表达式,就像我们在以下代码片段中所做的那样,或者您可以使用一个理解(更多关于这个的信息可以在 Json4s 网站上找到):
println(jsonManually \\ "content") // all the content
println(jsonManually \ "assignedTo" \ "name") // single name
// allows unboxing
println(jsonManually \\ "id" \\ classOf[JInt])
在这个代码片段中,我们使用\
和\\
运算符来遍历 JSON 对象。使用\
运算符,我们可以选择单个节点,而使用\\
运算符,我们可以遍历所有子节点。上一个println
语句的结果如下:
JObject(List((content,JString(the content)), (content,JString(Note 1)), (content,JString(Note 2))))
JString(pietje)
List(1, 1, 2)
除了这些运算符之外,Json4s 还提供了一些函数来遍历 JSON 对象。您可以通过查看MonadicJValue
类来查看可用的函数。我们接下来要查看的最后一个功能是如何将 case 类转换为 JSON 以及从 JSON 转换回来。如果我们已经有了 Json4s JSON 对象,我们可以使用 extract 函数:
implicit val formats = DefaultFormats
val task = jsonManually.extract[Task]
这的结果是一个Task
实例。您也可以直接将其序列化为字符串值:
import org.json4s.native.Serialization
import org.json4s.native.Serialization.{read, write}
implicit val autoFormat = Serialization.formats(NoTypeHints)
val taskAsJson: String = write(task)
val backToTask: Task = readTask
}
使用 Json4s 非常简单。它提供了易于创建、查询和序列化 JSON 的核心功能。接下来是 Argonaut。
使用 Argonaut
Argonaut 采用更函数式的方法来创建和解析 JSON,您将在以下示例中看到。我们再次开始,将一个字符串对象转换为 JSON 对象:
val parsed = json.parse // returns a scalaz disjunction
val parsedValue = parsed | jString("Error parsing")
Argonaut 通过一个 parse
函数扩展了字符串对象。这个函数的结果是一个 \/
实例:
\/-({"id":1,"status":{"status":"New"},"content":"This is the data to create a new task","notes":[],"title":"The Title","assignedTo":{"name":"pietje"}})
这与 Either
对象类似,但 Either
对象不是偏向右侧或左侧的,而 \/
实例是偏向右侧的(这意味着你还可以轻松地在 for
语句中使用它)。要从 \/
实例中获取值,我们使用 |
操作符。
一旦我们有了 JSON 值,我们就可以轻松地将其转换为字符串:
println(parsedValue.spaces4)
这将产生以下输出:
{
"id" : 1,
"status" : {
"status" : "New"
},
"content" : "This is the data to create a new task",
"notes" : [
],
"title" : "The Title",
"assignedTo" : {
"name" : "pietje"
}
}
对于更紧凑的输出,你也可以使用 spaces2
或 nospaces
。Argonout 还提供了一种灵活的方式来手动创建 JSON 对象:
val notesList = ListNote, Note(2, "Note 2"))
val jsonObjectBuilderWithCodec: Json =
("status" := Json("status" := "New")) ->:
("notes" := notesList.map(
note => Json("id" := note.id,
"content" := note.content)) ) ->:
("assignedTo" := Json("name" := "Someone")) ->:
("content" := "This is the content") ->:
("title" := "The Title") ->:
("id" := 1) ->: jEmptyObject
Argonaut 提供了一些操作符,你可以使用它们来构建 JSON 对象:
操作符 | 描述 |
---|---|
->: |
如果设置了,这个操作符会将给定的值添加到 JSON 对象中。 |
->? |
如果设置了,这个操作符会将给定的可选值添加到 JSON 对象中。 |
-->>: |
这个操作符会将给定的值添加到 JSON 数组中。 |
-->>:? |
如果设置了,这个操作符会将给定的可选值添加到 JSON 数组中。 |
在 Argonaut 网站上,还可以找到创建 JSON 对象的几种替代方法。
使用 Argonaut 查询对象也可以有几种不同的方式。在我们的例子中,我们将使用透镜进行查询:
val innerKey2StringLens = jObjectPL >=>
jsonObjectPL("notes") >=>
jArrayPL >=>
jsonArrayPL(0) >=>
jObjectPL >=>
jsonObjectPL("id") >=>
jStringPL
使用这段代码,我们定义了一个匹配 JSON 对象中特定元素的透镜。我们始终从一个 jObjectPL
函数开始,它选择 JSON 对象的根节点。接下来,我们使用 jsonObjectPL("notes")
函数来选择 "notes"
键的值。通过使用 jArrayPL
,我们将该值转换为数组,并使用 jsonArrayPL(0)
来选择数组的第一个元素。最后,我们再次使用 JObjectPL
来将其转换为对象,然后查询 "id"
键,并将其最终转换为字符串。现在我们有了透镜,我们可以在特定的 JSON 对象上使用它来提取值(作为一个 Option[String]
实例):
val res = innerKey2StringLens.get(jsonObjectBuilderWithCodec))
当然,Argonaut 也支持将数据转换为和从案例类中转换。我们首先必须定义一个编解码器。编解码器定义了案例类如何映射到 JSON 对象的键:
object Encodings {
implicit def StatusCodecJson: CodecJson[Status] =
casecodec1(Status.apply, Status.unapply)("status")
implicit def NoteCodecJson: CodecJson[Note] =
casecodec2(Note.apply, Note.unapply)("id", "content")
implicit def PersonCodecJson: CodecJson[Person] =
casecodec1(Person.apply, Person.unapply)("name")
implicit def TaskCodecJson: CodecJson[Task] =
casecodec6(Task.apply, Task.unapply)("id", "title",
"content", "assignedTo", "notes", "status")
}
import Encodings._
注意,我们使编解码器成为隐式的。这样,当 Argonaut 需要将字符串转换为案例类或反向转换时,它会自动找到它们:
val task = new Task(
1, "This is the title", "This is the content",
Some(Person("Me")),
ListNote, Note(2, "Note 2")), Status("new"))
val taskAsJson: Json = task.asJson
val taskBackAgain: Task =
Parse.decodeOptionTask
当你为特定的案例类定义了编解码器后,只需调用 asJson
函数即可将案例类转换为 Json
对象。要将 JSON 字符串转换为案例类,我们可以使用 Parse.decodeOption
函数(如果想要其他包装器而不是 Option
,Argonaut 还提供了 decodeEither
函数、decodeValidation
和 decodeOr
)。
在 Akka HTTP 的章节中,我们已经提到,为了支持 JSON,我们使用 spray-json 库。在接下来的部分,我们将更深入地探讨这个库。
使用 spray-json
spray-json 提供了一种非常简单的方式来处理 JSON 字符串。要将字符串解析为 JSON 对象,只需在字符串值上调用 parseJson
即可。
import spray.json._
val parsed = json.parseJson
当打印时,结果看起来类似于这个:
{"id":1,"status":{"status":"New"},"content":"This is the data to create a new task","notes":[],"title":"The Title","assignedTo":{"name":"pietje"}}
当然,我们也可以通过使用prettyPrint
或compactPrint
函数将 JSON 对象转换回字符串:
println(parsed.prettyPrint) // or .compactPrint
prettyPrint
的结果看起来类似于这个:
{
"id": 1,
"status": {
"status": "New"
},
"content": "This is the data to create a new task",
"notes": [],
"title": "The Title",
"assignedTo": {
"name": "pietje"
}
}
当您想手动创建一个 JSON 对象时,spray-json 为您提供了许多基本类,您可以使用这些类来完成这个任务(JsObject
、JsString
、JsNumber
和JsArray
):
val notesList = SeqNote, Note(2, "Note 2"))
val manually = JsObject(
"id" -> JsNumber(1),
"title" -> JsString("title"),
"content" -> JsString("the content"),
"assignedTo" -> JsObject("name" -> JsString("person")),
"notes" -> JsArray(
notesList.map({ note =>
JsObject(
"id" -> JsNumber(note.id),
"content" -> JsString(note.content)
)
}).toVector),
"status" -> JsObject("status" -> JsString("new"))
)
结果几乎与我们在本节第一例中看到的是同一个对象:
{"id":1,"status":{"status":"new"},"content":"the content","notes":[{"id":1,"content":"Note 1"},{"id":2,"content":"Note 2"}],"title":"title","assignedTo":{"name":"person"}}
我们接下来要查看的下一步是如何查询一个 JSON 对象以获取特定的字段。这是 spray-json 没有提供特定函数或操作符的事情。访问特定字段或值的唯一方法是通过使用getFields
或fields
函数:
println(manually.getFields("id"));
println(manually.fields)
getFields
函数返回一个包含当前对象上所有匹配此名称的字段的Vector
对象。字段函数返回所有字段的Map
对象。
我们在每个框架中最后要查看的功能是如何使用它将 case 类转换为 JSON 以及再次转换回来。在 Akka HTTP 章节中,我们已经向您展示了如何使用 spray-json 来完成这个操作:
val task = new Task(
1, "This is the title", "This is the content",
Some(Person("Me")),
ListNote, Note(2, "Note 2")), Status("new"))
object MyJsonProtocol extends DefaultJsonProtocol {
implicit val noteFormat = jsonFormat2(Note)
implicit val personFormat = jsonFormat1(Person)
implicit val statusFormat = jsonFormat1(Status)
implicit val taskFormat = jsonFormat6(Task)
}
import MyJsonProtocol._
val taskAsString = task.toJson
// and back to a task again
val backToTask = taskAsString.convertTo[Task]
我们所做的是扩展DefaultJsonProtocol
并为我们的每个 case 类定义它们应该如何通过 JSON 进行映射。spray-json 为我们提供了一个非常方便的辅助函数,称为jsonFormat#(object)
,其中#
对应于 case 类的参数数量。使用这个函数,我们可以为我们的 case 类定义默认的序列化,就像我们在前面的例子中所做的那样。要使用这些隐式转换,我们只需要将它们引入作用域,然后我们就可以在我们的 case 类上使用toJson
函数将它们序列化为Json
,并使用convertTo
将 JSON 转换回我们的 case 类。
使用 Play JSON
最后一个 JSON 库也是我们在前面的章节中提到过的——Play JSON。正如您将在代码中看到的那样,这个库的工作方式与 spray-json 库非常相似。让我们首先看看如何将字符串转换为 JSON 对象:
import play.api.libs.json._
import play.api.libs.functional.syntax._
val fromJson = Json.parse(json)
非常简单,只需调用parse
函数并提供 JSON,结果就是以下 JSON 对象(当打印时):
{"id":1,"title":"The Title","content":"This is the data to create a new task","assignedTo":{"name":"pietje"},"notes":[],"status":{"status":"New"}}
要将 JSON 对象转换为字符串,我们可以直接在 JSON 对象上调用stringify
函数,或者使用Json.prettyPrint
函数:
println(Json.prettyPrint(fromJson))
prettyPrint
函数返回以下结果:
{
"id" : 1,
"title" : "The Title",
"content" : "This is the data to create a new task",
"assignedTo" : {
"name" : "pietje"
},
"notes" : [ ],
"status" : {
"status" : "New"
}
}
到目前为止没有什么特别的。创建 JSON 对象的手动方式也是如此。就像 spray-json 一样,Play JSON 为您提供了一个可以用来创建 JSON 对象的基类集合(JsObject
、JsNumber
、JsString
、JsObject
和JsArray
):
// 3\. Create JSON object by hand.
val notesList = SeqNote, Note(2, "Note 2"))
val manually = JsObject(Seq(
"id" -> JsNumber(1),
"title" -> JsString("title"),
"content" -> JsString("the content"),
"assignedTo" -> JsObject(Seq("name" -> JsString("person"))),
"notes" -> JsArray(
notesList.map({ note =>
JsObject(Seq(
"id" -> JsNumber(note.id),
"content" -> JsString(note.content)
))
})),
"status" -> JsObject(Seq("status" -> JsString("new")))
))
现在,让我们转向查询。这是 Play JSON 为我们提供了一些非常实用的操作符的地方:
println(manually \\ "content")
println(manually \ "assignedTo" \ "name")
println((manually \\ "id" )(2))
使用\\
运算符,我们查找完整树中匹配的字段,并将其作为List
对象返回,而使用单个\
运算符,我们查找当前对象中的字段。这使得这些运算符非常易于使用,因为你可以从之前的代码片段中看到,这些运算符可以轻松嵌套。当我们查看输出时,我们看到以下内容:
List("the content", "Note 1", "Note 2")
JsDefined("person")
2
使用这个库将案例类转换为 JSON 以及从 JSON 转换回来也非常直接。我们首先定义一组隐式转换,只需调用Json.format[T]
即可:
object Formats {
implicit val noteFormat = Json.format[Note]
implicit val statusFormat = Json.format[Status]
implicit val personFormat = Json.format[Person]
implicit val taskFormat = Json.format[Task]
}
定义了这些隐式转换之后,我们可以使用toJson
和fromJson[T]
函数将我们的案例类转换为 JSON,并从 JSON 转换回来:
import Formats._
val task = new Task(
1, "This is the title", "This is the content", Some(Person("Me")),
ListNote, Note(2, "Note 2")), Status("new"))
val toJson = Json.toJson(task)
val andBackAgain = Json.fromJsonTask
在我们继续下一个主题之前,让我们快速回顾一下这些框架。
JSON 框架概述
那么,哪个 JSON 框架是最好的呢?当然,一般的回答是这取决于。所有框架都有它们的优缺点。如果我真的必须做出选择,我会说对于简单的 JSON 需求,Json4s 是一个非常好的选择。它提供了一种非常简单的方法从头创建 JSON 对象,有直观的数据查询方式,并允许你轻松地将数据转换为案例类。如果你有更复杂的需求,Argonaut 是一个非常有意思的选择。它提供了一种非常功能化的 JSON 处理方式,并为创建新的 JSON 对象和查询现有的 JSON 对象提供了一些有趣的功能。
HATEOAS
在第一章中,我们探讨了 RESTful 服务的定义。该定义的一部分是,REST 服务应该使用 HATEOAS,即“作为应用状态引擎的超文本”的缩写。这意味着要真正实现 RESTful,我们的服务不仅需要提供比资源 JSON 表示更多的信息,还应该提供有关应用程序状态的信息。当我们谈论 HATEOAS 时,我们必须处理以下两个主要原则:
-
超媒体/mime 类型/媒体类型/内容类型:资源的超媒体描述了资源当前的状态。你可以将其视为一种合同,描述了我们正在处理的资源。因此,我们不是将资源的类型设置为
application/json
,而是定义一个自定义的内容类型,如application/vnd.restwithscala.task+json
。 -
链接:HATEOAS 的第二部分是,资源表示需要包含指向资源其他状态和在该资源上可以执行的操作的链接。
例如,以下代码通过self
链接提供有关当前响应的信息,并使用媒体类型来指示从这些链接可以期待什么:
{
"_links" : [ {
"rel" : "self",
"href" : "/tasks/123",
"media-type" : "application/vnd.restwithscala.task+json"
}, {
"rel" : "add",
"href" : "/project/123/note",
"media-type" : "application/vnd.restwithscala.note+json"
} ],
"id" : 1,
"title" : "This is the title",
"content" : "This is the content",
"assignedTo" : {
"name" : "Me"
},
"notes" : [ {
"id" : 1,
"content" : "Note 1"
}, {
"id" : 2,
"content" : "Note 2"
} ],
"status" : {
"status" : "new"
}
}
由于媒体类型是资源的一个重要部分,我们不仅需要能够在响应上设置媒体类型,还需要根据传入的媒体类型进行过滤,因为特定端点的不同媒体类型可以有不同的含义。
处理媒体类型
现在,让我们回顾一下本书中讨论的框架,看看它们是如何处理媒体类型的。当然,您可以从本书提供的代码中运行所有这些示例。您可以使用以下命令来启动各种服务器:
sbt runCH07-Finch
sbt runCH07-Unfiltered
sbt runCH07-Scalatra
sbt runCH07-akkahttp
sbt runCH07-play
我们还提供了一些 Postman 请求,您可以使用这些请求来测试媒体处理是否正常工作。您可以在第七章集合中找到这些请求:
我们将要探索的第一个框架是 Finch。
使用 Finch 处理媒体类型
要处理 Finch 的媒体类型,我们将创建一个过滤器。这个过滤器以及将一切粘合在一起的代码如下所示:
val MediaType = "application/vnd.restwithscala.task+json"
val filter = new SimpleFilter[HttpRequest, HttpResponse] {
def apply(req: HttpRequest,
service: Service[Request, HttpResponse])
: Future[HttpResponse] = {
req.contentType match {
case Some(MediaType) => service.apply(req).map({ resp =>
resp.setContentType(MediaType, "UTF-8")
resp
})
case Some(_) => Future
{BadRequest(s"Media type not understood, use $MediaType")}
case None => Future
{BadRequest(s"Media type not present, use $MediaType")}
}
}
}
val matchTaskFilter: Matcher = "tasksFilter"
val createTask = CreateNewTask()
val createNewTaskFilter = filter andThen createTask
val taskCreateAPI =
Post / matchTaskFilter /> createNewTaskFilter
您可以通过扩展 SimpleFilter
类来创建一个过滤器。这个过滤器提供了对传入的 HttpRequest
实例和传出的 HttpResponse
实例的访问。在这个过滤器中,我们检查媒体类型是否正确,如果是这样,我们处理请求。如果不正确,我们返回一个 BadRequest
响应。为了给客户端一个关于他们正在处理的响应类型的指示,我们还设置了响应对象上的媒体类型。在定义了过滤器之后,我们创建我们的路由并调用 createNewTaskFilter
实例,它首先调用 filter
实例,然后调用 createTask
服务。现在每当收到正确媒体类型的请求时,它都会以正确的方式进行处理。
使用 Unfiltered 处理媒体类型
在 Unfiltered 中对媒体类型进行过滤非常容易。我们使用一些基本的 Scala 模式匹配来检查特定路径上的 POST
是否包含正确的媒体类型:
val MediaType = "application/vnd.restwithscala.task+json"
case req @ Path("/tasks") => (req, req) match {
case (req @ POST(_), (RequestContentType(MediaType))) => handleCreateTask(req).map(Ok ~> ResponseHeader("content-type",
Set(MediaType)) ~> ResponseString(_))
如您所见,我们所做的只是匹配两个请求属性,即动词(POST
)和请求的内容类型;当它们匹配时,我们处理请求,并在响应上设置正确的头信息。
使用 Scalatra 处理媒体类型
在框架列表中接下来要讨论的是 Scalatra。正如我们在讨论 Scalatra 时所看到的,它提供了一种定义特定路径的 before
函数和 after
函数的方法。我们使用这个功能在 before
函数中检查媒体类型是否匹配,并在 after
函数中更新内容类型:
before("/tasks") {
(request.getMethod, request.contentType) match {
case ("POST", Some(MediaType)) => // do nothing
case ("POST", _) => halt(status = 400, reason = "Unsupported Mimetype")
case (_,_) => // do nothing since it isn't a post
}
}
after("/task") {
request.getMethod match {
case "POST" => response.setContentType(MediaType)
case _ => // do nothing since it isn't a post
}
}
如您所见,我们使用模式匹配来匹配特定的动词和内容类型。这意味着如果我们有一个 POST
动词和正确的内容类型,我们将执行请求。如果动词匹配但内容类型不匹配,我们返回一个错误请求,如果动词不匹配,我们则正常处理它。
使用 Akka HTTP 处理媒体类型
使用 Akka HTTP 的媒体类型需要做更多的工作。原因是 Akka HTTP 会从头部提取内容类型并将其添加到实体中。这意味着我们必须检查实体中是否存在特定的内容类型,而不仅仅是检查头部。我们首先定义我们正在寻找的内容类型,以及一个函数,我们可以使用它来转换响应对象并在响应上设置正确的类型:
val CustomContentType =
MediaType.custom("application/vnd.restwithscala.task+json",
Encoding.Fixed(HttpCharsets.`UTF-8`))
def mapEntity(entity: ResponseEntity): ResponseEntity = entity match {
case HttpEntity.Strict(contentType, data) =>
HttpEntity.Strict(CustomContentType, data)
case _ => throw new IllegalStateException(
"Unexpected entity type")
}
如您所见,mapEntity
函数接受一个ResponseEntity
实例作为其参数,并返回一个新的具有正确内容类型的实例。在下一个代码片段中,我们将展示如何检查传入请求的正确内容类型并使用先前定义的函数设置响应:
post {
(entity(as[String]) & (extractRequest)) {
(ent, request) =>
request.entity.contentType() match {
case ContentType(MediaType(
"application/vnd.restwithscala.task+json")
, _) =>
mapRequest({ req => req.copy(entity =
HttpEntity.apply(MediaTypes.`application/json`,
ent)) }) {
(entity(as[Task])) {
task => {
mapResponseEntity(mapEntity) {
complete {
TaskService.insert(task)
}
}
}
}
}
case _ => complete(StatusCodes.BadRequest,
"Unsupported mediatype")
}
}
}
这里发生了很多事情,所以让我们看看我们在这里使用的指令以及为什么:
-
首先,我们使用
entity
和extractRequest
指令提取请求和请求的实体(主体和内容类型)。 -
接下来,我们匹配实体的
request.entity.contentType
属性,如果匹配,我们创建一个新的实体,其内容类型为application/json
。我们这样做是为了确保 Akka HTTP 的标准 JSON 到案例类的映射仍然有效。 -
接下来,我们将实体转换为
Task
实例,调用服务,并创建响应。 -
在响应返回之前,会调用
mapResponseEntity
函数,该函数将内容类型设置为我们的原始值。
注意,除了使用指令的方法之外,我们还可以重新定义所需的隐式转换,以便 JSON 转换可以与我们的自定义内容类型一起工作。
使用 Play 2 处理媒体类型
要在 Play 2 中实现自定义媒体类型,我们将使用ActionBuilder
方法。使用ActionBuilder
方法,我们可以改变调用动作的方式。以下代码显示了此示例的ActionBuilder
方法:
object MediaTypeAction extends ActionBuilder[Request] {
val MediaType = "application/vnd.restwithscala.task+json"
def invokeBlockA => Future[Result]) = {
request.headers.get("Content-Type") match {
case Some(MediaType) => {
block(request)
}
case _ => Future{BadRequest("Unsupported mimetype")}
}
}
}
在这里,我们定义了一种新的动作类型,称为MediaTypeAction
。当我们使用此动作而不是正常的Action
类时,首先检查传入消息的内容类型;如果匹配,则处理请求;如果不匹配,则忽略请求并生成BadRequest
响应。
我们可以使用这种新的MediaTypeAction
以以下方式:
def createTask = MediaTypeAction.async((parse.tolerantJson)) { request =>
val body = request.body.validate[Task]
// option defines whether we have a JSON body or not.
body match {
case JsSuccess(task, _) => TaskService.insert(task).map(
b => Ok(Json.toJson(b)).as(MediaTypeAction.MediaType))
case JsError(errors) => Future{BadRequest(errors.mkString("\n"))}
}
}
如您所见,我们只是用MediaTypeAction
替换了Action
,就是这样。当这个动作被调用时,首先执行MediaTypeAction
中的代码,然后提供代码给Action
。为了正确地将传入数据转换为 JSON 对象,我们需要对我们处理 JSON 的方式做一些小的修改。我们使用显式的主体解析器(parse.tolerantJson
)来解析传入的 JSON。使用这个函数,我们不检查提供的内容类型是否为application/json
,只是转换主体。
在本节的开始部分,我们解释了 HATEOAS 的两个重要部分:媒体类型处理和支持链接。在下一节中,我们将展示一种你可以轻松地向你的案例类添加链接的方法。
使用链接
对于链接,我们将在我们的模型中创建一个非常简单的扩展。我们不仅将序列化的案例类发送到 JSON,还在其中添加了一个_links
对象。这个对象可以包含不同的链接。例如,它不仅可以定义对资源的链接,还可以包含可以在此资源上执行的操作的链接。我们最终希望得到的 JSON 看起来类似于以下内容:
{
"_links" : [ {
"rel" : "self",
"href" : "/tasks/123",
"media-type" : "application/vnd.restwithscala.task+json"
} ],
"id" : 1,
"title" : "This is the title",
"content" : "This is the content",
"assignedTo" : {
"name" : "Me"
},
"notes" : [ {
"id" : 1,
"content" : "Note 1"
}, {
"id" : 2,
"content" : "Note 2"
} ],
"status" : {
"status" : "new"
}
}
作为示例,我们将使用 Play JSON,但基本上可以以本章探索的其他 JSON 库中使用相同的方法。我们首先做的是定义链接的外观。为此,我们定义了一个特性和一个案例类:
import org.restwithscala.common.model.{Note, Person, Status, Task}
import play.api.libs.json._
import play.api.libs.functional.syntax._
object PlayJsonLinks extends App {
trait HasLinks {
val links: List[Links]
}
case class Links(rel: String, href: String, `media-type`: String)
当我们创建一个新的Task
时,我们可以从这个特质扩展以添加链接。正如我们之前解释的,Play JSON 使用隐式值来确定如何将特定类序列化为 JSON。对于这个场景,我们定义以下隐式值:
trait LowPriorityWritesInstances {
// use standard writes for the case classes
implicit val statusWrites = Json.writes[Status]
implicit val noteWrites = Json.writes[Note]
implicit val personWrites = Json.writes[Person]
implicit val taskWrites = Json.writes[Task]
implicit val linkWrites = Json.writes[Links]
// and a custom one for the trait
implicit object hiPriorityWrites extends OWrites[HasLinks] {
def writes(hi: HasLinks) = Json.obj("_links" -> hi.links)
}
}
/**
* The write instance which we include
*/
object WritesInstances extends LowPriorityWritesInstances {
implicit val taskWithLinksWrites = new Writes[Task with HasLinks] {
def writes(o: Task with HasLinks) = {
(implicitly[OWrites[HasLinks]].writes(o)) ++
taskWrites.writes(o).as[JsObject]
}
}
}
这里发生的事情是,我们定义了我们模型各个部分的标准化隐式值,包括我们新的HasLinks
特质。除此之外,我们还定义了一个非常具体的隐式值,用于匹配扩展了HasLinks
特质的Tasks
。因此,当我们对一个扩展了HasLinks
的Task
调用toJson
函数时,taskWithLinksWrites
将匹配。在writes
函数中,我们首先转换Links
对象,并将其与转换后的Task
组合。
要使用这个功能,我们只需要定义一个新的Task
并使用HasLinks
来添加我们拥有的任何链接:
val task = new Task(
1, "This is the title", "This is the content",
Some(Person("Me")), ListNote,
Note(2, "Note 2")), Status("new")) with HasLinks {
val links =
List(Links("self",
"/tasks/123",
"application/vnd.restwithscala.task+json"))
}
// import the implicit convertors
import WritesInstances._
println(Json.prettyPrint(Json.toJson(task)))
}
现在,在导入正确的隐式值之后,我们可以将带有HasLinks
的Task
转换为 JSON,就像我们转换其他对象一样。
摘要
在这一章的最后,我们探讨了 REST 的一些重要方面。我们探讨了可以在您的 REST 服务中使用的各种 JSON 库,用于将对象转换为 JSON 以及从 JSON 转换。除此之外,我们还探讨了 REST 的一个非常重要的方面,即 HATEOAS。HATEOAS 最重要的方面是检测和过滤媒体类型(内容类型)以及向资源添加链接以创建自描述 API 的能力。我们看到了如何在本书讨论的框架中检测和处理媒体类型,以及如何使用本章探索的 JSON 框架之一添加链接。
在本节中,我们讨论了向 JSON 响应添加链接,这标志着本书的结束。在各个章节中,我们探讨了 Scala 生态系统中的多个 REST 框架的最重要功能。
在前面的章节中,我们试图向您展示这些框架的最重要功能,并解释如何使用这些功能来创建可扩展的、异步的和可维护的 REST 服务。请记住,这些框架中的每一个都有比我们在这本书中能探索的更多功能。
我希望你在阅读这本书并尝试示例时玩得开心。如果你喜欢它们,请随意使用、扩展并分享结果!