Scala-编程项目-全-
Scala 编程项目(全)
原文:
zh.annas-archive.org/md5/1fce7f0ad9f295877de723ebf139e713译者:飞龙
前言
Scala 是一种类型安全的 JVM 语言,它结合了面向对象编程(OOP)和函数式编程(FP)的特性。本书通过引导你了解 Scala 编程的不同方面,帮助你从软件开发的本质开始,从而帮助你连接学习和实施之间的差距。你将通过多样化的应用学习 Scala 的独特特性,并遇到简单而强大的软件开发方法。你将了解如何使用基本工具,设置环境,并编写 Scala 程序。
Scala 编程项目将帮助你构建多个应用程序,从简单的项目开始,例如财务独立计算器,然后逐步发展到其他项目,如购物应用程序或比特币交易分析器。你将能够使用 Scala 的各种特性,如它的 OOP 和 FP 能力,并学习以类型安全的方式编写简洁、反应式和并发应用程序的方法。你还将学习如何使用顶级的库,如 Akka 和 Play,以及将 Scala 应用程序与 Kafka、Spark 和 Zeppelin 集成;此外,你还将探索在云平台上部署应用程序。
到本书结束时,你将掌握 Scala 的方方面面,能够将其应用于解决各种现实世界的问题。
本书面向对象
如果你是一名希望学习如何使用 Scala 的业余程序员,这本书适合你。了解 Java 将有助于理解本书中涵盖的概念,但不是必需的。
本书涵盖内容
第一章,编写你的第一个程序,解释了如何设置你的环境以开始使用 Scala 编程,涵盖了所需的基本工具以及最简单的 Scala 应用程序可能的样子。
第二章,开发退休计算器,将第一章中看到的 Scala 语言特性付诸实践。我们还将介绍 Scala 语言和 SDK 的其他元素,以开发退休计算器的模型和逻辑。这个计算器将帮助人们计算出他们需要存多少钱以及需要存多久才能有一个舒适的退休生活。
第三章,处理错误,让你继续上一章的退休计算器工作,处理其中的错误。例如,只要传递正确的参数,计算器就能正确工作,但如果任何参数错误,它就会产生一个糟糕的堆栈跟踪并严重失败。
第四章,高级特性,探讨了 Scala 的更高级特性。与任何编程语言一样,一些高级构造在实际中可能很少使用,或者可能会使代码变得晦涩难懂。我们将旨在仅解释我们在实际项目中遇到并已部署到生产环境中的特性。
第五章,类型类,你将了解什么是类型类,cats 库中最常见的类型类是什么,以及如何在你的项目中使用它们。
第六章,在线购物 – 持久化,解释了如何在关系型数据库中持久化数据。这些数据将是购物网站购物车的内容。
第七章,在线购物 – REST API,介绍了如何使用 Play 框架开发 REST API。API是应用程序编程接口的缩写,而REST的缩写代表表示性状态转移。基本上,我们将为我们的应用程序提供一个接口,以便其他程序可以与之交互。
第八章,在线购物 – 用户界面,教你如何使用 Scala.js 为在线购物应用程序构建用户界面。在这个界面中,你将能够选择一个产品添加到你的购物车,更新你希望购买的产品数量,并在需要时从购物车中移除它们。
第九章,交互式浏览器,通过扩展我们的购物项目介绍了演员模型。扩展将包括一个通知,提供给任何连接到网站的人,关于谁正在将产品添加到购物车或从购物车中移除。
第十章,获取和持久化比特币市场数据,探讨了如何开发一个数据管道来获取、存储和分析比特币交易数据。我们将使用 Apache Spark 的批处理模式来完成这项工作。
第十一章,批处理和流分析,专注于如何使用 Zeppelin 和 Spark 查询我们的历史数据存储。我们将使用 Zeppelin 的绘图能力以及 Apache Kafka 与 Spark 流处理来分析实时交易。
要充分利用这本书
在这本书的学习过程中,任何编程语言的基本知识都会对你有所帮助。此外,对 Java 的进一步了解也将有助于理解这本书中的一些概念。要充分利用这本书,有几种参与程度,从最快到最有效:
-
你可以只阅读它,并在你的 IDE 中查看代码。
-
当你阅读它时,你可以在你的 IDE 中复制并粘贴代码示例并运行它们。
-
与之前相同,但这次您需要重新输入所有的代码示例。使用自动完成将使您发现更多 API 的功能。输入代码也将使您更容易记住它。您利用了您的视觉和触觉记忆。
-
本杰明·富兰克林的方法。您一次性阅读整章或一节,然后合上书。之后,尝试根据记忆重写代码示例。如果您卡住了,可以重新打开书。这将迫使您的头脑对项目有一个完整的认识。您将更深入地记住并理解概念。
下载示例代码文件
您可以从www.packt.com的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的软件解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip。
-
适用于 Mac 的 Zipeg/iZip/UnRarX。
-
适用于 Linux 的 7-Zip/PeaZip。
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Scala-Programming-Projects。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,网址为[github.com/PacktPublishing/](https://github.com/PacktPublishing/)。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:https://www.packtpub.com/sites/default/files/downloads/9781788397643_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
class LazyDemo {
lazy val lazyVal = {
println("Evaluating lazyVal")
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将被设置为粗体:
def lazyEvenPlusOne(xs: Vector[Int]): Vector[Int] =
xs.withFilter { x => println(s"filter $x"); x % 2 == 0 }
任何命令行输入或输出都应如下编写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至customercare@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packt.com.
第一章:编写你的第一个程序
2001 年,Martin Odersky 开始设计 Scala 语言——他花了三年时间发布了第一个公开版本。这个名字来源于 Scalable language。之所以选择这个名字,是因为 Scala 被设计成随着用户需求的增长而发展——你可以使用 Scala 编写小脚本,也可以用它开发大型企业级应用。
Scala 自那时起一直在不断进化,其受欢迎程度也在不断增长。作为一种通用语言,它被广泛应用于金融、电信、零售和媒体等多个行业。在分布式可扩展系统和大数据处理方面,Scala 尤其引人注目。许多领先的开源软件项目都是用 Scala 开发的,例如 Apache Spark、Apache Kafka、Finagle(由 Twitter 开发)和 Akka。许多公司都在生产中使用 Scala,例如摩根士丹利、巴克莱斯、Twitter、LinkedIn、卫报和索尼。
Scala 不是 Java 的扩展,但它与 Java 完全兼容。你可以从 Scala 调用 Java 代码,也可以从 Java 调用 Scala 代码。还有一个编译器可以将 Scala 代码编译成 JavaScript,我们将在本书的后续章节中探讨。因此,你可以在浏览器中运行 Scala 代码。
Scala 是面向对象和函数式编程范式的混合体,并且是静态类型的。因此,它可以作为从面向对象或命令式背景的人逐渐过渡到函数式编程的桥梁。
在本章中,我们将涵盖以下主题:
-
设置你的环境
-
使用基本功能
-
运行 Scala 控制台
-
使用 Scala 控制台和工作表
-
创建我的第一个项目
设置你的环境
首先,我们需要设置我们的工作环境。在本节中,我们将获取所有工具和库,然后在你的计算机上安装和配置它们。
Scala 程序被编译成 Java 字节码,这是一种可以由Java 虚拟机(JVM)执行的汇编语言。因此,你需要在你的计算机上安装 Java 编译器和 JVM。Java 开发工具包(JDK)提供了这两个组件以及其他工具。
你可以使用简单的文本编辑器在 Scala 中开发,并使用 Scala 的简单构建工具(SBT)编译你的程序。然而,这不会是一个愉快或高效的经验。大多数专业的 Scala 开发者使用集成开发环境(IDE),它提供了许多有用的功能,如语法高亮、自动完成、代码导航、与 SBT 集成等。Scala 最广泛使用的 IDE 是来自 JetBrains 的 IntelliJ Idea,这是我们将在本书中安装和使用的 IDE。其他选项包括 Eclipse 的 Scala IDE 和 ENSIME。ENSIME 是一个开源项目,为流行的文本编辑器(如 Emacs、Vim、Atom、Sublime 和 VSC)带来了类似 IDE 的功能。
安装 Java SDK
我们将安装 Oracle JDK,它包括 JVM 和 Java 编译器。在许多 Linux 发行版中,开源的 OpenJDK 已经预安装。OpenJDK 与 Oracle JDK 完全兼容,因此如果您已经有了它,您不需要安装任何其他东西来遵循这本书。
您可能已经在计算机上安装了 Java SDK。我们将检查这是否属实。如果您使用 Windows,请打开 DOS 命令提示符。如果您使用 macOS 或 Linux,请打开终端。在提示符后,输入以下内容:
javac -version
如果你已经安装了 JDK,将打印出已安装编译器的版本:
javac 1.8.0_112
如果已安装的版本大于或等于 1.8.0_112,您可以跳过 JDK 安装。我们将使用的 Scala 版本与 JDK 版本 1.8 或 1.9 兼容。
如果没有,请打开以下网址,下载您平台上的 SDK,并按照提供的安装说明进行操作:www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html。
安装 IntelliJ IDEA
前往 www.jetbrains.com/idea/download. 下载您平台上的社区版。终极版提供更多功能,但在这本书中我们不会使用它们。
以下是在安装 IntelliJ IDEA 时的步骤:
-
运行 IntelliJ Idea。
-
选择“不导入设置”选项:

- 选择 UI 主题。我个人更喜欢 Dracula,因为深色背景在笔记本电脑上可以节省电池,并且对眼睛更温和:

- 通过勾选提供的选项来创建桌面条目:

- 在“创建启动器脚本”对话框窗口中,勾选创建脚本...复选框。这将允许您从命令行打开 IntelliJ 中的文件:

- 自定义插件。对于每个组件,点击“自定义...”或“全部禁用”。我们不需要大多数插件。您只能选择以下选项:
-
构建工具:全部禁用。
-
版本控制:仅保留 Git 和 GitHub。
-
测试工具:全部禁用。
-
Swing:禁用。
-
Android:禁用。
-
其他工具:全部禁用并保留字节码查看器、终端和 YAML。
-
插件开发:禁用。
您可以在以下截图中看到上述插件:

-
安装特色插件——还为您推荐了一些额外的插件,例如 Scala 插件和用于学习 IntelliJ 基本功能的工具。
-
如下截图所示,点击安装按钮为 Scala 和 IDE 功能训练师,然后通过点击“开始使用 IntelliJ IDEA”继续:

如果你已经是 Vim 爱好者,你可以安装 IdeaVim。否则,我建议你避免使用它。我个人每天都在使用它,但花了一些时间才习惯。
- 点击创建新项目 | Scala | sbt:

- 填写以下详细信息,如以下截图所示:
-
名称:
scala_fundamentals。 -
JDK:点击新建,然后选择 Oracle JDK 的安装目录。
-
sbt:选择版本 1.0.4,勾选源代码。
-
Scala:选择最新版本 2.12.x,例如 2.12.4(IntelliJ 列出了所有可能的版本,并将下载你选择的版本),勾选源代码。
-
点击完成。
这将取决于你的互联网连接速度所需的时间:

- 你应该看到以下项目结构:

使用基本功能
在本节以及本书的其余部分,我们将突出显示一些关键的快捷键(斜体)。我强烈建议你使用并记住这些快捷键。它们可以节省大量的时间,并帮助你专注于当前的任务。如果你记不住一个快捷键,你可以使用所有快捷键之母,即Ctrl + Shift + A(Windows/Linux)或cmd + shift + A(macOS),然后输入你正在寻找的操作名称。
如果你第一次使用 IntelliJ,我发现显示所有工具按钮很有用。转到视图菜单,勾选工具栏和工具按钮。
SBT 同步
现在,让我们看看我们的构建配置。SBT(简称Simple Build Tool)是 Scala 社区中的事实上的构建工具。双击build.sbt:
name := 'scala_fundamentals"
version := "0.1"
scalaVersion := "2.12.4"
此文件描述了 SBT 将如何编译、测试和部署我们的项目。目前,它相当简单。
需要记住的一个重要事情是,IntelliJ 管理自己的文件集来定义项目结构。它们位于你的项目.idea目录中。
每次你更改build.sbt时,IntelliJ 都必须解释这些更改并将其翻译。
例如,如果我将 Scala 版本更改为2.12.3并保存(Ctrl + S 或 cmd + S),IntelliJ 将建议同步更改或启用自动导入:

在一个小型项目中,使用自动导入是可以的,但在大型项目中,可能会有些烦人。同步可能需要时间,并且可能会过于频繁地启动。
因此,当你使用 IntelliJ 在 Scala 中编程时,你有两种编译项目的方式:
-
如果你使用 SBT,那么你将只使用 IntelliJ 作为高级文本编辑器
-
IntelliJ
理论上,你可以混合搭配:开始使用 SBT,然后继续使用 IntelliJ 或反之亦然。然而,我强烈不建议这样做,因为你可能会遇到一些意外的编译错误。当你想切换到某个工具时,最好先清理所有编译文件。
我们将在本书的后面进一步介绍 SBT,但到目前为止,我们只将使用 IntelliJ 自带的构建。
构建
项目已创建并准备好构建。构建过程执行以下操作:
-
编译源路径和测试路径中存在的源文件
-
复制输出路径中需要的任何资源文件
-
在消息工具窗口中报告任何错误/警告
有两种方式可以构建项目:
-
如果你想要增量构建你的项目,请转到构建 | 构建项目 (Ctrl + F9 或 cmd + F9)
-
如果你想要删除所有文件并重新构建一切,请转到构建 | 重新构建所有
由于我们还没有源代码,构建过程很快,消息工具窗口中不应出现错误。
运行 Scala 控制台
在 IntelliJ 中,每次你想运行某些内容时:一个程序、一个单元测试、一个外部工具,都需要有一个运行配置:一个运行配置设置了运行可执行文件所需的类路径、参数和环境变量。
我们第一次想要运行 Scala 控制台时,需要创建一个运行配置:
- 转到运行 | 编辑配置。点击绿色+按钮,选择 Scala 控制台。你应该看到以下屏幕:

- 进行以下更改并点击确定:
-
名称:
Scala Console。 -
选择仅单实例复选框 - 我们很少需要同时运行两个控制台。
-
在发射前,点击构建,然后点击移除按钮。这样,即使你的代码无法编译,你也能快速运行控制台。
-
随后,点击确定。
- 在顶部工具栏上,你应该看到 IntelliJ 创建了一个新的 Scala 控制台运行配置:

- 点击绿色箭头以运行控制台。你应该在屏幕底部的运行窗口中看到以下内容:我们现在可以在 Scala 提示符后输入我们的第一个 Scala 表达式:

使用 Scala 控制台和工作表
到目前为止,所有必要的工具和库都应该已经安装。让我们通过在不同的环境中进行实验来开始玩 Scala 的基础。尝试 Scala 最简单的方法是使用 Scala 控制台。随后,我们将介绍 Scala 工作表,它允许你将输入的所有指令保存在一个文件中。
使用 Scala 控制台
Scala 控制台,也称为 Scala REPL(代表读取-评估-打印-循环),允许你执行代码片段,而无需事先编译。这是一个非常方便的工具,用于实验语言或当你想探索库的功能时。
在控制台中,在scala>提示符后输入1+1并按Ctrl + Enter或cmd + Enter:
scala> 1+1
控制台显示评估结果,如下所示:
res0: Int = 2
这里发生了什么?REPL 编译了表达式1+1,并自动将其赋值给名为res0的变量。这个变量的类型是Int,其值是2。
声明变量
在 Scala 中,可以使用val或var声明变量。val是不可变的,这意味着你永远不能改变它的值。var是可变的。声明变量的类型不是强制性的。如果你没有声明它,Scala 会为你推断它。
让我们定义一些不可变变量:
在所有以下代码示例中,你只需要输入 Scala 命令提示符后面的代码,然后按Ctrl + Enter或cmd + return来评估。我们将在提示符下方显示评估结果,就像它出现在你的屏幕上一样。
scala> val x = 1 + 1
x: Int = 2
scala> val y: Int = 1 + 1
y: Int = 2
在这两种情况下,变量的类型都是Int。编译器推断出x的类型是Int。y的类型通过变量名后的: Int显式指定。
我们可以定义一个可变变量并按以下方式修改它:
scala> var x = 1
x: Int = 1
scala> x = 2
x: Int = 2
在大多数情况下使用val是一个好习惯。每当我看到声明了val,我就知道其内容将不会随后的改变。这有助于对程序进行推理,尤其是在多线程运行时。你可以在多个线程之间共享不可变变量,而不必担心某个线程可能会在某个时刻看到不同的值。每当你在 Scala 程序中看到var时,它应该让你皱眉:程序员应该有很好的理由使用可变变量,并且应该进行文档记录。
如果我们尝试修改一个val,编译器将抛出一个错误信息:
scala> val y = 1
y: Int = 1
scala> y = 2
<console>:12: error: reassignment to val
y = 2
^
这是一件好事:编译器帮助我们确保没有任何代码片段可以修改一个val。
类型
在前面的示例中,我们看到了 Scala 表达式有类型。例如,值1是Int类型,表达式1+1也是Int类型。类型是数据的分类,提供有限或无限值的集合。给定类型的表达式可以取其提供的任何值。
下面是 Scala 中可用的一些类型的示例:
-
Int提供有限值的集合,这些值是介于-2³¹和 2³¹-1 之间的所有整数。 -
Boolean提供有限值的集合:true和false。 -
Double提供有限值的集合:所有 64 位和 IEEE-754 浮点数。 -
String提供无限值的集合:所有任意长度的字符序列。例如,"Hello World"或"Scala is great !"。
类型决定了可以对数据进行哪些操作。例如,你可以使用+运算符与类型为Int或String的两个表达式一起使用,但不能与类型为Boolean的表达式一起使用:
scala> val str = "Hello" + "World"
str: String = HelloWorld
scala> val i = 1 + 1
i: Int = 2
scala> val b = true + false
<console>:11: error: type mismatch;
found : Boolean(false)
当我们尝试对一个不支持该操作的类型使用操作时,Scala 编译器会报类型不匹配错误。
Scala 的重要特性之一是它是一种静态类型语言。这意味着变量或表达式的类型在编译时是已知的。编译器还会检查你是否调用了不适用于此类型的操作或函数。这极大地减少了在 运行时(程序运行时)可能出现的错误数量。
如我们之前所见,表达式的类型可以通过冒号 : 后跟类型名称来显式指定,或者在许多情况下,编译器可以自动推断出来。
如果你不习惯使用静态类型语言,你可能会对与编译器斗争以使其接受你的代码感到沮丧,但你会逐渐习惯于你遇到的错误类型以及如何解决它们。你很快会发现,编译器不是一个阻止你运行代码的敌人;它更像是一个好朋友,它会指出你犯的逻辑错误,并给你一些如何解决的提示。
来自动态类型语言,如 Python,或来自不那么强静态类型语言,如 Java 或 C++ 的人,往往会惊讶地发现,一个编译过的 Scala 程序在第一次运行时出现错误的概率要低得多。
IntelliJ 可以自动将推断的类型添加到你的定义中。
例如,在 Scala 控制台中输入 val a = 3,然后将光标移到 a 的开头。你应该看到一个灯泡图标。当你点击它时,你会看到一个提示 为值定义添加类型注解。点击它,IntelliJ 将在 a 后面添加 : Int。
你的定义将变为 val a: Int = 3。
声明和调用函数
Scala 函数可以接受从 0 到 n 个 参数 并返回一个值。每个参数的类型必须声明。返回值的类型是可选的,因为 Scala 编译器在未指定时可以推断出来。然而,始终指定返回类型是一种良好的做法,因为它使代码更易于阅读:
scala> def presentation(name: String, age: Int): String =
"Hello, my name is " + name + ". I am " + age + " years old."
presentation: (name: String, age: Int)String
scala> presentation(name = "Bob", age = 25)
res1: String = Hello, my name is Bob. I am 25 years old.
scala> presentation(age = 25, name = "Bob")
res2: String = Hello, my name is Bob. I am 25 years old.
我们可以通过按正确的顺序传递参数来调用函数,但也可以命名参数并以任何顺序传递它们。当一些参数具有相同的类型,或者函数接受许多参数时,命名参数是一种良好的做法。这可以避免传递错误的参数并提高可读性。
副作用
当一个函数或表达式修改某些状态或对外部世界有某些作用时,我们称它具有副作用。例如,将字符串打印到控制台、写入文件和修改 var 都是副作用。
在 Scala 中,所有表达式都有一个类型。执行副作用(side effect)的语句类型为 Unit。Unit 类型提供的唯一值是 ():
scala> val x = println("hello")
hello
x: Unit = ()
scala> def printName(name: String): Unit = println(name)
printName: (name: String)Unit
scala> val y = {
var a = 1
a = a+1
}
y: Unit = ()
scala> val z = ()
z: Unit = ()
纯函数是一个结果只依赖于其参数,并且没有可观察副作用的函数。Scala 允许你混合副作用代码和纯代码,但将副作用代码推到应用程序的边界是一个好的实践。我们将在第三章 确保引用透明性 部分中更详细地讨论这一点,处理错误。
良好的实践:当一个没有参数的函数有副作用时,你应该声明它,并用空括号 () 调用它。这会通知用户你的函数有副作用。相反,没有参数的纯函数不应该有空括号,也不应该用空括号调用。IntelliJ 帮助你保持一致性:如果你用 () 调用一个无参数函数,或者如果你在调用声明为 () 的函数时省略了 (),它将显示警告。
这里有一个带有副作用的方法调用的例子,其中我们必须使用空括号,以及一个纯函数的例子:
scala> def helloWorld(): Unit = println("Hello world")
helloWorld: ()Unit
scala> helloWorld()
Hello world
scala> def helloWorldPure: String = "Hello world"
helloWorldPure: String
scala> val x = helloWorldPure
x: String = Hello world
如果...否则表达式
在 Scala 中,if (condition) ifExpr else if ifExpr2 else elseExpr 是一个表达式,并且具有类型。如果所有子表达式都具有类型 A,则 if ... else 表达式的类型也将是 A:
scala> def agePeriod(age: Int): String = {
if (age >= 65)
"elderly"
else if (age >= 40 && age < 65)
"middle aged"
else if (age >= 18 && age < 40)
"young adult"
else
"child"
}
agePeriod: (age: Int)String
如果子表达式有不同的类型,编译器将推断一个公共超类型,或者如果它是一个数值类型,则将其类型扩展:
scala> val ifElseWiden = if (true) 2: Int else 2.0: Double
ifElseWiden: Double = 2.0
scala> val ifElseSupertype = if (true) 2 else "2"
ifElseSupertype: Any = 2
在前面代码中第一个表达式中,第一个子表达式是 Int 类型,第二个是 Double 类型。ifElseWiden 的类型被扩展为 Double。
在第二个表达式中,ifElseSupertype 的类型是 Any,这是 Int 和 String 的公共超类型。
没有 else 的 if 等价于 if (condition) ifExpr else ()。最好总是指定 else 表达式,否则 if/else 表达式的类型可能不是我们所期望的:
scala> val ifWithoutElse = if (true) 2
ifWithoutElse: AnyVal = 2
scala> val ifWithoutElseExpanded = if (true) 2: Int else (): Unit
ifWithoutElseExpanded: AnyVal = 2
scala> def sideEffectingFunction(): Unit = if (true) println("hello world")
sideEffectingFunction: ()Unit
在前面的代码中,Int 和 Unit 之间的公共超类型是 AnyVal。这可能会让人有些惊讶。在大多数情况下,你可能会想要避免这种情况。
类
我们之前提到,所有 Scala 表达式都有类型。class 是一种可以创建特定类型对象的模板。当我们想要获得某种类型的值时,我们可以使用 new 后跟类名来 实例化 一个新的 对象:
scala> class Robot
defined class Robot
scala> val nao = new Robot
nao: Robot = Robot@78318ac2
对象的实例化在 JVM 中分配了一部分 堆 内存。在先前的例子中,值 nao 实际上是新 Robot 对象内容所保持的堆内存部分的 引用。你可以观察到,当 Scala 控制台打印变量 nao 时,它输出了类的名称,后面跟着 @78318ac2。这个十六进制数实际上是对象在堆中存储的内存地址。
eq 运算符可以用来检查两个引用是否相等。如果它们相等,这意味着它们指向相同的内存部分:
scala> val naoBis = nao
naoBis: Robot = Robot@78318ac2
scala> nao eq naoBis
res0: Boolean = true
scala> val johnny5 = new Robot
johnny5: Robot = Robot@6b64bf61
scala> nao eq johnny5
res1: Boolean = false
一个类可以有零个或多个成员。一个成员可以是:
-
一个属性,也称为字段。它是一个变量,其内容对类的每个实例都是唯一的。
-
一个方法。这是一个可以读取和/或写入实例属性的函数。它可以有额外的参数。
这里是一个定义了一些成员的类:
scala> class Rectangle(width: Int, height: Int) {
val area: Int = width * height
def scale(factor: Int): Rectangle = new Rectangle(width * factor, height * factor)
}
defined class Rectangle
在括号 () 内声明的属性有些特殊:它们是构造函数参数,这意味着在实例化类的新对象时必须指定它们的值。其他成员必须在花括号 {} 内定义。在我们的例子中,我们定义了四个成员:
-
两个构造函数参数属性:
width和height。 -
一个属性,
area。它的值是在使用其他属性创建实例时定义的。 -
一个名为
scale的方法,它使用属性来创建类Rectangle的新实例。
你可以通过使用后缀表示法 myInstance.member 来调用类的一个实例的成员。让我们创建我们类的一些实例并尝试调用这些成员:
scala> val square = new Rectangle(2, 2)
square: Rectangle = Rectangle@2af9a5ef
scala> square.area
res0: Int = 4
scala> val square2 = square.scale(2)
square2: Rectangle = Rectangle@8d29719
scala> square2.area
res1: Int = 16
scala> square.width
<console>:13: error: value width is not a member of Rectangle
square.width
我们可以调用 area 和 scale 成员,但不能调用 width。为什么?
这是因为,默认情况下,构造函数参数不可从外部访问。它们是实例的私有属性,只能从其他成员中访问。如果你想使构造函数参数可访问,你需要用 val 前缀:
scala> class Rectangle(val width: Int, val height: Int) {
val area: Int = width * height
def scale(factor: Int): Rectangle = new Rectangle(width * factor, height * factor)
}
defined class Rectangle
scala> val rect = new Rectangle(3, 2)
rect: Rectangle = Rectangle@3dbb7bb
scala> rect.width
res3: Int = 3
scala> rect.height
res4: Int = 2
这次,我们可以访问构造函数参数。请注意,你可以使用 var 而不是 val 来声明属性。这将使你的属性可修改。然而,在函数式编程中,我们避免修改变量。类中的 var 属性在特定情况下应该谨慎使用。经验丰富的 Scala 程序员会在代码审查中立即标记它,并且其使用应该在代码注释中得到合理说明。
如果你需要修改一个属性,最好是返回一个带有修改后属性的新类实例,就像我们在前面的 Rectangle.scale 方法中所做的那样。
你可能会担心所有这些新对象会消耗太多内存。幸运的是,JVM 有一个称为垃圾回收器的机制。它会自动释放那些没有任何变量引用的对象所占用的内存。
使用工作表
IntelliJ 提供了另一个方便的工具来实验语言:Scala 工作表。
前往文件 | 新建 | Scala 工作表。将其命名为 worksheet.sc。然后你可以在屏幕的左侧输入一些代码。右上角的红/绿指示器会显示你输入的代码是否有效。一旦编译成功,结果就会出现在右侧:

你会注意到,直到整个工作表编译完成之前,没有任何东西会被评估。
类继承
Scala 类是可扩展的。你可以扩展一个现有的类来继承其所有成员。如果B扩展了A,我们可以说B是A的子类,是B的派生,或者是B的特殊化。A是B的超类或B的泛化。
让我们通过一个例子看看它是如何工作的。在工作表中输入以下代码:
class Shape(val x: Int, val y: Int) {
val isAtOrigin: Boolean = x == 0 && y == 0
}
class Rectangle(x: Int, y: Int, val width: Int, val height: Int)
extends Shape(x, y)
class Square(x: Int, y: Int, width: Int)
extends Rectangle(x, y, width, width)
class Circle(x: Int, y: Int, val radius: Int)
extends Shape(x, y)
val rect = new Rectangle(x = 0, y = 3, width = 3, height = 2)
rect.x
rect.y
rect.isAtOrigin
rect.width
rect.height
Rectangle和Circle类是Shape的子类。它们继承了Shape的所有成员:x、y和isAtOrigin。这意味着当我实例化一个新的Rectangle时,我可以调用在Rectangle中声明的成员,例如width和height,我也可以调用在Shape中声明的成员。
当声明一个子类时,你需要传递超类的构造函数参数,就像你正在实例化它一样。由于Shape声明了两个构造函数参数,x和y,我们必须在声明extends Shape(x, y)中传递它们。在这个声明中,x和y本身是Rectangle的构造函数参数。我们只是将这些参数向上传递了链。
注意,在子类中,构造函数参数x和y没有声明为val。如果我们用val声明它们,它们将被提升为公开可用的属性。问题是Shape也有x和y作为公开属性。在这种情况下,编译器会引发编译错误以突出显示冲突。
子类赋值
考虑两个类,A和B,其中B extends A。
当你声明一个类型为A的变量时,你可以将它赋值给B的一个实例,使用val a: A = new B。
另一方面,如果你声明一个类型为B的变量,你不能将它赋值给A的一个实例。
这里是一个使用前面描述的相同Shape和Rectangle定义的例子:
val shape: Shape = new Rectangle(x = 0, y = 3, width = 3, height = 2)
val rectangle: Rectangle = new Shape(x = 0, y = 3)
第一行可以编译,因为Rectangle是一个Shape。
第二行无法编译,因为不是所有形状都是矩形。
覆盖方法
当你派生一个类时,你可以覆盖超类的成员以提供不同的实现。以下是一个你可以重新输入到新工作表中的例子:
class Shape(val x: Int, val y: Int) {
def description: String = s"Shape at (" + x + "," + y + ")"
}
class Rectangle(x: Int, y: Int, val width: Int, val height: Int)
extends Shape(x, y) {
override def description: String = {
super.description + s" - Rectangle " + width + " * " + height
}
}
val rect = new Rectangle(x = 0, y = 3, width = 3, height = 2)
rect.description
当你运行工作表时,它会在右侧评估并打印以下description:
res0: String = Shape at (0,3) - Rectangle 3 * 2
我们在Shape类上定义了一个名为description的方法,它返回一个 String。当我们调用rect.description时,调用的是在Rectangle类中定义的方法,因为Rectangle用不同的实现覆盖了description方法。
Rectangle类中description的实现引用了super.description。super是一个关键字,它让你可以使用超类的成员,而不考虑任何覆盖。在我们的情况下,这是必要的,这样我们才能使用super引用,否则description会陷入无限循环中调用自己!
另一方面,关键字this允许你调用同一类的成员。将Rectangle修改为添加以下方法:
class Rectangle(x: Int, y: Int, val width: Int, val height: Int)
extends Shape(x, y) {
override def description: String = {
super.description + s" - Rectangle " + width + " * " + height
}
def descThis: String = this.description
def descSuper: String = super.description
}
val rect = new Rectangle(x = 0, y = 3, width = 3, height = 2)
rect.description
rect.descThis
rect.descSuper
当评估工作表时,它打印以下字符串:
res0: String = Shape at (0,3) - Rectangle 3 * 2
res1: String = Shape at (0,3) - Rectangle 3 * 2
res2: String = Shape at (0,3)
this.description的调用使用了在Rectangle类中声明的description定义,而super.description的调用使用了在Shape类中声明的description定义。
抽象类
抽象类是一个可以有多个抽象成员的类。一个抽象成员只定义了一个属性或方法的签名,而不提供任何实现。你不能实例化一个抽象类:你必须创建一个子类来实现所有抽象成员。
将工作表中的Shape和Rectangle的定义替换如下:
abstract class Shape(val x: Int, val y: Int) {
val area: Double
def description: String
}
class Rectangle(x: Int, y: Int, val width: Int, val height: Int)
extends Shape(x, y) {
val area: Double = width * height
def description: String =
"Rectangle " + width + " * " + height
}
我们现在的类Shape现在是抽象的。我们不能再直接实例化一个Shape类了:我们必须创建一个Rectangle或Shape的其他子类的实例。Shape定义了两个具体成员,x和y,以及两个抽象成员,area和description。子类Rectangle实现了这两个抽象成员。
当实现抽象成员时,你可以使用前缀override,但这不是必要的。我建议不要添加它,以保持代码更简洁。此外,如果你随后在超类中实现了抽象方法,编译器将帮助你找到所有已实现该方法的子类。如果它们使用override,则不会这样做。
特性
特性类似于抽象类:它可以声明多个抽象或具体成员,并且可以被扩展。它不能被实例化。区别在于一个给定的类只能扩展一个抽象类,然而,它可以混合多个特性。此外,特性不能有构造函数参数。
例如,我们可以声明几个特性,每个特性声明不同的抽象方法,并将它们全部混合到Rectangle类中:
trait Description {
def description: String
}
trait Coordinates extends Description {
def x: Int
def y: Int
def description: String =
"Coordinates (" + x + ", " + y + ")"
}
trait Area {
def area: Double
}
class Rectangle(val x: Int,
val y: Int,
val width: Int,
val height: Int)
extends Coordinates with Description with Area {
val area: Double = width * height
override def description: String =
super.description + " - Rectangle " + width + " * " + height
}
val rect = new Rectangle(x = 0, y = 3, width = 3, height = 2)
rect.description
当评估rect.description时,打印以下字符串:
res0: String = Coordinates (0, 3) - Rectangle 3 * 2
类Rectangle混合了特性Coordinates、Description和Area。我们需要在trait或class之前使用关键字extends,并在所有后续特性中使用关键字with。
注意,Coordinates特性也混合了Description特性,并提供了默认实现。正如我们在有Shape类时所做的,我们在Rectangle中覆盖了这个实现,我们仍然可以调用super.description来引用trait Coordinates中description的实现。
另一个有趣的观点是,你可以使用val来实现一个抽象方法——在trait Area中,我们定义了def area: Double,并在Rectangle中使用val area: Double实现了它。使用def定义抽象成员是一个好习惯。这样,特性的实现者可以决定是否使用方法或变量来定义它。
Scala 类层次结构
所有 Scala 类型都扩展了一个名为Any的内建类型。这个类型是所有 Scala 类型层次结构的根。它有两个直接子类型:
-
AnyVal是所有值类型的根类。这些类型在 JVM 中表示为原始类型。 -
AnyRef是所有对象类型的根类。它是java.lang.Object类的别名。 -
类型
AnyVal的变量直接包含值,而类型AnyRef的变量包含存储在内存中某个位置的对象的地址。
以下图表显示了该层次结构的部分视图:

当您定义一个新类时,它会间接扩展 AnyRef。由于这是一个 java.lang.Object 的别名,因此您的类继承了 Object 中实现的所有默认方法。它最重要的方法如下:
-
def toString: String返回对象的字符串表示形式。当您使用println打印对象时,会调用此方法。默认实现返回类的名称后跟对象在内存中的地址。 -
def equals(obj: Object): Boolean如果对象等于另一个对象,则返回true,否则返回false。当您使用==比较两个对象时,会调用此方法。默认实现仅比较对象的引用,因此与eq等价。幸运的是,Java 和 Scala SDK 中的大多数类都重写了此方法以提供良好的比较。例如,java.lang.String类重写了equals方法以逐字符比较字符串的内容。因此,当您使用==比较两个字符串时,如果字符串相同,即使它们存储在内存中的不同位置,结果也将是true。 -
def hashCode: Int在您将对象放入Set或将其用作Map中的键时被调用。默认实现基于对象的地址。如果您想使Set或Map中的数据分布更好,从而提高这些集合的性能,您可以重写此方法。但是,如果您这样做,您必须确保hashCode与equals一致:如果两个对象相等,它们的hashCodes也必须相等。
为所有类重写这些方法将会非常繁琐。幸运的是,Scala 提供了一个特殊的构造,称为 case class,它会自动为我们重写这些方法。
情况类
在 Scala 中,我们使用情况类定义大多数数据结构。case class 具有一到多个不可变属性,并且与标准类相比提供了几个内置函数。
将以下内容输入到工作表中:
case class Person(name: String, age: Int)
val mikaelNew = new Person("Mikael", 41)
// 'new' is optional
val mikael = Person("Mikael", 41)
// == compares values, not references
mikael == mikaelNew
// == is exactly the same as .equals
mikael.equals(mikaelNew)
val name = mikael.name
// a case class is immutable. The line below does not compile:
//mikael.name = "Nicolas"
// you need to create a new instance using copy
val nicolas = mikael.copy(name = "Nicolas")
在前面的代码中,// 后面的文本是一个注释,解释了前面的语句。
当您将类声明为 case class 时,Scala 编译器会自动生成默认构造函数、equals 和 hashCode 方法、copy 构造函数以及每个属性的访问器。
这里是我们拥有的工作表的截图。您可以在右侧看到评估结果:

伴随对象
一个类可以有一个伴随对象。它必须在与类相同的文件中声明,使用关键字 object 后跟伴随对象的名称。伴随对象是一个单例 - 在 JVM 中只有一个此对象的实例。它有自己的类型,不是伴随类的实例。
此对象定义了与伴随类紧密相关的静态函数或值。如果你熟悉 Java,它替换了关键字 static:在 Scala 中,类的所有静态成员都在伴随对象中声明。
伴随对象中的某些函数具有特殊含义。名为 apply 的函数是类的构造函数。当我们调用它们时,可以省略 apply 名称:
case class City(name: String, urbanArea: Int)
object City {
val London = City("London", 1738)
val Lausanne = City("Lausanne", 41)
}
case class Person(firstName: String, lastName: String, city: City)
object Person {
def apply(fullName: String, city: City): Person = {
val splitted = fullName.split(" ")
new Person(firstName = splitted(0), lastName = splitted(1), city = city)
}
}
// Uses the default apply method
val m1 = Person("Mikael", "Valot", City.London)
// Call apply with fullName
val m2 = Person("Mikael Valot", City.London)
// We can omit 'apply'
val n = Person.apply("Nicolas Jorand", City.Lausanne)
在前面的代码中,我们为 City 类定义了一个伴随对象,它定义了一些常量。常量的约定是将第一个字母大写。
Person 类的伴随对象定义了一个额外的 apply 函数,它充当构造函数。其实现调用方法 split(" "),该方法将用空格分隔的字符串分割成字符串数组。它允许我们使用单个字符串来构造 Person 实例,其中第一个名字和姓氏由空格分隔。然后我们演示了我们可以调用随 case 类提供的默认 apply 函数,或者我们实现的那个。
创建我的第一个项目
如你所知,你已经掌握了在 REPL 和表格中运行代码的基础,现在是时候创建你的第一个“Hello World”项目了。在本节中,我们将过滤一组人员并将他们的姓名和年龄打印到控制台。
创建项目
重复你在 安装 IntelliJ 部分中完成的相同步骤来创建一个新项目。以下是你必须完成的任务摘要:
-
运行 IntelliJ 并选择创建新项目
-
选择 Scala 和 sbt
-
输入项目的名称,例如
Examples -
如果选定的目录不存在,IntelliJ 会询问你是否想要创建它 - 选择“确定”
一旦你接受将创建目录,IntelliJ 就会下载所有必要的依赖项并构建项目结构。请耐心等待,因为这可能需要一段时间,尤其是如果你没有良好的互联网连接。
一切下载完毕后,你的 IDE 应该处于以下状态:

注意文件夹结构。源代码位于 src/main/scala 目录下,测试代码位于 src/test/scala 目录下。如果你之前使用过 Maven,这种结构应该很熟悉。
创建 Main 对象
到这里了!让我们创建我们的第一个应用程序。首先,创建程序的入口点。如果你来自 Java,它相当于定义 public static void main(String[] args)。
右键单击 src/main/scala 文件夹,然后选择 New | Scala Class。将类名命名为 Main,将类型设置为 Object:

我们已经创建了第一个对象。这个对象是一个单例。在 JVM 中只能有一个实例。在 Java 中的等效物是一个具有静态方法的静态类。
我们希望将其用作程序的主要入口点。Scala 提供了一个方便的名为 App 的类,需要对其进行扩展。让我们用这个类扩展我们的 Main 对象:
object Main extends App {
}
App 超类定义了一个静态的 main 方法,它将执行您在 Main 对象内部定义的所有代码。就这样——我们创建了第一个版本,它什么也不做!
我们现在可以在 IntelliJ 中运行程序。如下所示,点击对象定义区域中的小绿色三角形:

程序被编译并执行,如下面的截图所示:

这并不引人注目,但让我们来改进它。为了养成正确的习惯,我们将使用TDD技术来进一步进行。
编写第一个单元测试
TDD 是一种非常强大的技术,可以编写高效、模块化和安全的程序。它非常简单,玩这个游戏只有三条规则:
-
除非是为了使失败的单元测试通过,否则不允许编写任何生产代码。
-
您不允许编写比足以失败的单元测试更多的代码,编译失败也是失败。
-
您不允许编写比通过一个失败的单元测试所需的生产代码更多的代码。
在这里可以查看 Bob Uncle 的完整文章:butunclebob.com/ArticleS.UncleBob.TheThreeRulesOfTdd。
Scala 中有多个测试框架,但我们选择了 ScalaTest (www.scalatest.org/),因为它简单。
为了在项目中添加 ScalaTest 库,请按照以下步骤操作:
-
编辑
build.sbt文件。 -
添加一个新的仓库解析器以搜索 Scala 库。
-
添加 ScalaTest 库:
name := "Examples"
version := "0.1"
scalaVersion := "2.12.4"
resolvers += "Artima Maven Repository" at "http://repo.artima.com/releases"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.4" % "test"
注意屏幕顶部的信息栏。它告诉您文件已更改,并要求进行多项选择。由于这是一个小型项目,您可以选择启用自动导入。
- 通过右键单击
test/scala文件夹并创建一个新类来创建测试类。将其命名为MainSpec。
ScalaTest 提供了多种定义测试的方法——完整的列表可以在官方网站上找到 (www.scalatest.org/at_a_glance/WordSpec)。我们将使用 WordSpec 风格,因为它相当具体,提供了层次结构,并且在大型 Scala 项目中常用。
您的 MainSpec 应该扩展 WordSpec 类和 Matchers 类,如下所示:
class MainSpec extends WordSpec with Matchers {
}
Matchers类提供了should这个关键字来在测试中执行比较。
WordSpec和Matchers被红色下划线标注,这意味着类没有被解析。要使其解析,将光标移至类上,并按键盘上的Alt + Enter。如果你位于WordSpec这个词上,应该会出现一个弹出窗口。这是正常的,因为不同包中存在多个名为WordSpec的类:

选择第一个选项,IntelliJ 会自动在代码顶部添加导入。在Matchers类中,只要你输入Alt + Enter,就会直接添加导入。
最终的代码应该如下所示:
import org.scalatest.{WordSpec, Matchers}
class MainSpec extends WordSpec with Matchers {
}
我们的课程框架现在为我们的第一个测试做好了准备。我们想要创建Person类并测试其构造函数。
让我们用简单的句子解释我们想要测试的内容。用以下代码完成测试类:
class MainSpec extends WordSpec with Matchers {
"A Person" should {
"be instantiated with a age and name" in {
val john = Person(firstName = "John", lastName = "Smith", 42)
john.firstName should be("John")
john.lastName should be("Smith")
john.age should be(42)
}
}
}
IntelliJ 抱怨它无法解析符号Person、name、surname和age。这是预期的,因为Person类不存在。让我们在src/main/scala文件夹中创建它。右键单击文件夹,创建一个名为Person的新类。
在类的情况下,通过添加case关键字并使用name、surname和age定义构造函数来转换它:
case class Person(firstName: String, lastName: String, age: Int)
如果你回到MainSpec.scala文件,你会注意到类现在编译时没有任何错误和警告。代码窗口右上角的绿色勾号(
)确认了这一点。
通过右键单击MainSpec.scala文件并选择Run 'MainSpec'或使用键盘快捷键Ctrl + Shift + F10或Ctrl + Shift + R来运行测试:

MainSpec中的测试运行,结果出现在运行窗口中:

实现另一个功能
现在,我们想要通过说明他的/她的姓名和年龄来有一个关于这个人的良好表示。测试应该看起来像以下这样:
"Get a human readable representation of the person" in {
val paul = Person(firstName = "Paul", lastName = "Smith", age = 24)
paul.description should be("Paul Smith is 24 years old")
}
再次运行测试。我们将得到一个编译错误:

这是预期的,因为Person类中不存在这个函数。要实现它,在MainSpec.scala类中的description()错误上设置光标,按Alt + Enter,并选择创建方法描述。
IntelliJ 为你生成方法并设置实现为???。将???替换为预期的代码:
def description = s"$firstName $lastName is $age ${if (age <= 1) "year" else "years"} old"
通过这样做,我们定义了一个不接受任何参数并返回表示 Person 的字符串的方法。为了简化代码,我们使用 字符串插值 来构建字符串。要使用字符串插值,只需在第一个引号前加上 s。在引号内,您可以使用通配符 $,这样我们就可以使用外部变量,并在美元符号后使用括号来输入比变量名更多的代码。
执行测试,结果应该是绿色的:

下一步是编写一个实用函数,给定一个人员列表,只返回成年人。
对于测试,定义了两个情况:
"The Person companion object" should {
val (akira, peter, nick) = (
Person(firstName = "Akira", lastName = "Sakura", age = 12),
Person(firstName = "Peter", lastName = "Müller", age = 34),
Person(firstName = "Nick", lastName = "Tagart", age = 52)
)
"return a list of adult person" in {
val ref = List(akira, peter, nick)
Person.filterAdult(ref) should be(List(peter, nick))
}
"return an empty list if no adult in the list" in {
val ref = List(akira)
Person.filterAdult(ref) should be(List.empty[Person])
}
}
在这里,我们使用元组定义了三个变量。这是一种方便定义多个变量的方式。变量的作用域由包围的括号限制。
使用 IntelliJ 通过 Alt + Enter 快捷键创建 filterAdult 函数。IDE 会理解该函数应该位于 Person 伴生对象中,并为您生成它。
如果您没有使用命名参数并希望使用它们,IntelliJ 可以帮助您:当光标在括号后时,按 Alt + Enter 并选择 "使用命名参数 ...".
我们使用 Scala 的 for 推导 功能来实现此方法:
object Person {
def filterAdult(persons: List[Person]) : List[Person] = {
for {
person <- persons
if (person.age >= 18)
} yield (person)
}
}
定义方法的返回类型是一个好习惯,特别是当这个方法作为公共 API 公开时。
for 推导仅用于演示目的。我们可以使用 List 上的 filter 方法来简化它。filter 是 Scala 集合 API 的一部分,并且适用于许多类型的集合:
def filterAdult(persons: List[Person]) : List[Person] = {
persons.filter(_.age >= 18)
}
实现 Main 方法
现在我们所有的测试都是绿色的,我们可以实现 main 方法。实现变得非常简单,因为所有代码已经在测试中:
object Main extends App {
val persons = List(
Person(firstName = "Akira", lastName = "Sakura", age = 12),
Person(firstName = "Peter", lastName = "Müller", age = 34),
Person(firstName = "Nick", lastName = "Tagart", age = 52))
val adults = Person.filterAdult(persons)
val descriptions = adults.map(p => p.description).mkString("\n\t")
println(s"The adults are \n\t$descriptions")
}
第一件事是定义一个 Person 列表,这样 Person.filterAdult() 就可以用来移除所有人,而不是成年人。adults 变量是一个 Person 列表,但我想将这个 Person 列表转换为描述 Person 的列表。为了执行此操作,使用集合的 map 函数。map 函数通过在参数中应用函数来转换列表中的每个元素。
map() 函数内的符号定义了一个匿名函数,该函数以 p 作为参数。函数体是 p.description。这种符号在函数接受另一个函数作为参数时常用。
一旦我们有了描述列表,我们使用 mkString() 函数创建一个字符串。它使用特殊字符 \n\t 连接列表的所有元素,其中 \n 分别是换行符,\t 是制表符。
最后,我们执行副作用,即控制台打印。要在控制台打印,使用 println 别名。它是 System.out.println 的语法糖。
摘要
我们已经完成了第一章,现在你应该有了自己开始项目的必备基础知识。我们涵盖了在 Scala 中使用 IDE 进行编码的安装,以及专用构建工具 SBT 的基本用法。展示了探索 Scala 的三种方法,包括 REPL 来测试简单的 Scala 特性,IntelliJ 工作表来在小环境中进行实验,最后是一个真实的项目。
为了编写我们的第一个项目,我们使用了 ScalaTest 和 TDD 方法,以确保我们从一开始就有良好的代码质量。
在下一章中,我们将编写一个完整的程序。这是一个财务应用程序,允许用户估算他们何时可以退休。我们将继续使用 TDD 技术,并进一步探索 Scala 语言、其开发工具包以及最佳实践。
第二章:开发退休计算器
在本章中,我们将将第一章节中看到的 Scala 语言特性付诸实践。我们还将介绍 Scala 语言和 SDK 的其他元素,以开发退休计算器的模型和逻辑。这个计算器将帮助人们计算出需要存多少钱以及存多久才能有一个舒适的退休生活。
我们将使用测试驱动开发(TDD)技术来开发不同的功能。我鼓励你在查看解决方案之前先尝试自己编写函数的主体。此外,最好是重新输入代码而不是复制粘贴。这样你会记得更牢,并且会有一种使用 IntelliJ 的自动完成和编辑器的感受。不要过度使用Ctrl + 空格键的自动完成功能。你不仅会打字更快,还会发现给定类中可用的函数。
你将获得使用 Scala 语言构建更复杂应用程序的坚实基础。
在本章中,我们将涵盖以下主题:
-
计算未来资本
-
计算何时可以退休
-
使用市场汇率
-
打包应用程序
项目概述
使用一些参数,例如你的净收入、你的开支、你的初始资本等,我们将创建函数来计算以下内容:
-
退休时的未来资本
-
退休多年后的资本
-
你需要存多少钱才能退休
我们将首先使用固定利率进行这些计算。之后,我们将从 .tsv 文件中加载市场数据,然后重构之前的函数以模拟投资期间可能发生的情况。
计算未来资本
在规划退休时,你需要知道的第一件事是在你选择的退休日期可以获得的资本量。现在,我们将假设你以恒定的速率每月投资你的储蓄。为了简化问题,我们将忽略通货膨胀的影响,因此计算出的资本将是今天的货币,利率将计算为实际利率 = 名义利率 - 通货膨胀率。
我们故意在本章的其余部分不提及任何货币。你可以认为金额是以美元(USD)、欧元(EUR)或任何其他货币计算的。只要所有金额都使用相同的货币表示,结果就不会改变。
编写积累阶段的单元测试
我们需要一个与 Excel 中的 FV 函数行为相似的函数:它根据恒定的利率计算投资的未来价值。由于我们遵循 TDD 方法,首先要做的事情是创建一个失败的测试:
-
创建一个名为
retirement_calculator的新 Scala 项目。遵循第一章中“编写你的第一个程序”的相同说明。 -
右键单击
src/main/scala目录并选择 New | Package。将其命名为retcalc。 -
右键单击新包,选择 New | Scala class。将其命名为
RetCalcSpec。 -
输入以下代码:
package retcalc
import org.scalactic.{Equality, TolerantNumerics, TypeCheckedTripleEquals}
import org.scalatest.{Matchers, WordSpec}
class RetCalcSpec extends WordSpec with Matchers with TypeCheckedTripleEquals {
implicit val doubleEquality: Equality[Double] =
TolerantNumerics.tolerantDoubleEquality(0.0001)
"RetCalc.futureCapital" should {
"calculate the amount of savings I will have in n months" in {
val actual = RetCalc.futureCapital(
interestRate = 0.04 / 12, nbOfMonths = 25 * 12,
netIncome = 3000, currentExpenses = 2000,
initialCapital = 10000)
val expected = 541267.1990
actual should ===(expected)
}
}
如第一章中所述,在创建我的第一个项目部分,我们使用了WordSpec ScalaTest 风格。我们还使用了一个方便的功能,称为TypeCheckedTripleEquals。它提供了一个强大的断言should ===,确保在编译时等式的两边具有相同的类型。默认的 ScalaTest 断言should在运行时验证类型相等性。我们鼓励您始终使用should ===,因为它在重构代码时可以节省大量时间。
此外,它让我们在比较双精度值时可以使用一定程度的容差。考虑以下声明:
implicit val doubleEquality: Equality[Double] =
TolerantNumerics.tolerantDoubleEquality(0.0001)
如果double1和double2之间的绝对差异小于0.0001,它将允许double1 should === (double2)断言通过。这允许我们只指定小数点后第四位的预期值。它还避免了遇到浮点计算问题。例如,在 Scala 控制台中输入以下代码:
scala> val double1 = 0.01 -0.001 + 0.001
double1: Double = 0.010000000000000002
scala> double1 == 0.01
res2: Boolean = false
这可能有点令人惊讶,但这在任何将浮点数编码为二进制的语言中都是一个众所周知的问题。我们本可以使用BigDecimal而不是Double来避免这类问题,但就我们的目的而言,我们不需要BigDecimal提供的额外精度,而且BigDecimal的计算速度要慢得多。
测试的主体相当直接;我们调用一个函数并期望得到一个值。由于我们首先编写了测试,我们必须在编写生产代码之前确定预期的结果。对于非平凡的计算,我通常使用 Excel 或 LibreOffice。对于这个函数,预期的值可以通过使用公式=-FV(0.04/12,25*12,1000,10000,0)获得。我们假设用户每月将其收入与支出的全部差额存入。因此,FV函数中的 PMT 参数是1,000 = netIncome - currentExpenses。
现在我们有一个失败的测试,但它无法编译,因为RetCalc对象及其futureCapital函数尚不存在。在src/main/scala中的新包retcalc中创建一个RetCalc对象,然后在单元测试中选择红色的futureCapital调用,按Alt + Enter生成函数体。填写参数的名称和类型。你应该在RetCalc.scala中得到以下代码:
package retcalc
object RetCalc {
def futureCapital(interestRate: Double, nbOfMonths: Int, netIncome:
Int, currentExpenses: Int, initialCapital: Double): Double = ???
}
打开RetCalcSpec,按Ctrl + Shift + R来编译和运行它。一切都应该编译,测试应该失败。
实现未来资本
现在我们有一个失败的测试,所以是时候通过编写生产代码让它通过了。如果我们使用initialCapital = 10,000和monthlySavings = 1,000,我们需要执行的计算可以分解如下:
-
对于第
0个月,在没有任何储蓄之前,我们有capital0 = initialCapital = 10,000。 -
对于第一个月,我们的初始资本产生了一些利息。我们还额外存入了 1,000 元。因此,我们有
capital1 = capital0 *(1 + monthlyInterestRate) + 1,000 -
对于第二个月,我们有
capital2 = capital1 *(1 + monthlyInterestRate) + 1,000
存在一种数学公式可以从参数计算 capitalN,但在这里我们不会使用它。这个公式在固定利率下工作得很好,但我们在本章后面将使用可变利率。
这里是函数的主体:
def futureCapital(interestRate: Double, nbOfMonths: Int, netIncome: Int, currentExpenses: Int, initialCapital: Double): Double = {
val monthlySavings = netIncome - currentExpenses
def nextCapital(accumulated: Double, month: Int): Double =
accumulated * (1 + interestRate) + monthlySavings
(0 until nbOfMonths).foldLeft(initialCapital)(nextCapital)
}
我们首先使用 0 to nbOfMonths 生成一个整数集合,然后使用 foldLeft 遍历它。foldLeft 是 Scala 集合库中最强大的函数之一。collections 库中的许多其他函数可以通过使用 foldLeft 来实现,例如 reverse、last、contains、sum 等。
在 Scala SDK 中,foldLeft 的签名如下:
def foldLeftB(op: (B, A) => B): B
你可以通过将鼠标悬停在 IntelliJ 中的定义上并使用 cmd + left-click 来查看其定义。这引入了一些新的语法:
[B]表示该函数有一个名为B的 类型参数。当我们调用函数时,编译器会根据z: B参数的类型自动推断B是什么。在我们的代码中,z参数是initialCapital,类型为Double。因此,我们在futureCapital中对foldLeft的调用将表现得好像函数是用B = Double定义的:
def foldLeft(z: Double)(op: (Double, A) => Double): Double.
-
该函数有两个参数列表。Scala 允许你拥有多个参数列表。每个列表可以有一个或多个参数。这不会改变函数的行为;这只是分离每个参数列表关注点的一种方式。
-
op: (B, A) => B表示op必须是一个有两个参数类型为B和A并返回类型为B的函数。由于foldLeft是一个接受另一个函数作为参数的函数,我们说foldLeft是一个 高阶函数。
如果我们考虑一个 coll 集合,foldLeft 的工作方式如下:
- 它创建一个
var acc = z累加器,然后调用op函数:
acc = op(acc, coll(0))
- 它继续对集合的每个元素调用
op。
acc = op(acc, coll(i))
- 它在遍历完集合的所有元素后返回
acc。
在我们的 futureCapital 函数中,我们传递 op = nextCapital。foldLeft 将遍历从 1 到 nbOfMonths 的所有 Int,每次使用前一个资本计算资本。注意,目前我们不在 nextCapital 中使用 month 参数。尽管如此,我们必须声明它,因为 foldLeft 中的 op 函数必须有两个参数。
你现在可以再次运行 RetCalcSpec 单元测试。它应该通过。
重构生产代码
在 TDD 方法中,一旦我们有了通过测试的代码,重构代码是很常见的。如果我们的测试覆盖率良好,我们不应该害怕更改代码,因为任何错误都应该由失败的测试标记出来。这被称为红-绿-重构循环。
使用以下代码更改futureCapital的主体:
def futureCapital(interestRate: Double, nbOfMonths: Int, netIncome: Int, currentExpenses: Int, initialCapital: Double): Double = {
val monthlySavings = netIncome - currentExpenses
(0 until nbOfMonths).foldLeft(initialCapital)(
(accumulated, _) => accumulated * (1 + interestRate) +
monthlySavings)
}
在这里,我们在foldLeft调用中内联了nextCapital函数。在 Scala 中,我们可以使用以下语法定义一个匿名函数:
(param1, param2, ..., paramN) => function body.
我们之前看到,nextCapital中的month参数没有被使用。在匿名函数中,将任何未使用的参数命名为_是一个好的实践。名为_的参数不能在函数体中使用。如果你尝试将_字符替换为其他名称,IntelliJ 会将其下划线。如果你将鼠标悬停在其上,你会看到一个弹出窗口,显示“声明从未使用”。然后你可以按Alt + Enter键并选择删除未使用的元素,以自动将其更改回_。
编写减值阶段的测试
现在你已经知道你退休时可以期待多少资本。结果是你可以重用相同的futureCapital函数,来计算为你继承人留下的资本。
在RetCalcSpec中添加以下测试,在之前的单元测试下方,并运行它。它应该通过:
"RetCalc.futureCapital" should {
"calculate how much savings will be left after having taken a pension
for n months" in {
val actual = RetCalc.futureCapital(
interestRate = 0.04/12, nbOfMonths = 40 * 12,
netIncome = 0, currentExpenses = 2000, initialCapital =
541267.1990)
val expected = 309867.53176
actual should ===(expected)
}
}
因此,如果你在退休后活 40 年,每个月支出相同的金额,并且没有其他收入,你仍然会为你的继承人留下相当可观的资本。如果剩余的资本为负,这意味着你在退休期间某个时候可能会用完钱,这是我们想要避免的结果。
你可以随意从 Scala 控制台调用该函数并尝试更接近你个人情况的值。尝试不同的利率值,观察如果利率低,你最终可能会得到负资本。
注意,在生产系统中,你肯定会添加更多的单元测试来覆盖一些其他边缘情况,并确保函数不会崩溃。由于我们将在第三章“处理错误”中介绍错误处理,我们可以假设futureCapital的测试覆盖率现在已经足够好了。
模拟退休计划
现在我们知道了如何计算退休和死亡时的资本,将这两个调用合并到一个函数中会很有用。这个函数将一次性模拟退休计划。
编写失败的单元测试
这里是你需要添加到RetCalcSpec中的单元测试:
"RetCalc.simulatePlan" should {
"calculate the capital at retirement and the capital after death" in {
val (capitalAtRetirement, capitalAfterDeath) =
RetCalc.simulatePlan(
interestRate = 0.04 / 12,
nbOfMonthsSaving = 25 * 12, nbOfMonthsInRetirement = 40 * 12,
netIncome = 3000, currentExpenses = 2000,
initialCapital = 10000)
capitalAtRetirement should === (541267.1990)
capitalAfterDeath should === (309867.5316)
}
}
选择调用simulatePlan,然后按Alt + Enter键让 IntelliJ 在RetCalc中为你创建函数。它应该具有以下签名:
def simulatePlan(interestRate: Double,
nbOfMonthsSavings: Int, nbOfMonthsInRetirement: Int,
netIncome: Int, currentExpenses: Int, initialCapital:
Double) : (Double, Double) = ???
现在用 cmd + F9 编译项目,并运行 RetCalcSpec。它应该会失败,因为 simulatePlan 函数必须返回两个值。最简单的建模返回类型的方法是使用 Tuple2。在 Scala 中,元组是一个不可变的数据结构,可以包含不同类型的多个对象。元组中包含的对象数量是固定的。它与没有特定属性名称的案例类类似。在类型理论中,我们说元组或案例类是 积类型。
操作元组
在 Scala 控制台中输入以下内容,以熟悉元组。你可以自由地尝试不同类型和大小的元组:
scala> val tuple3 = (1, "hello", 2.0)
tuple3: (Int, String, Double) = (1,hello,2.0)
scala> tuple3._1
res1: Int = 1
scala> tuple3._2
res2: String = hello
scala> val (a, b, c) = tuple3
a: Int = 1
b: String = hello
c: Double = 2.0
你可以创建长度最多为 22 的元组,并使用 _1、_2 等来访问它们的元素。你还可以一次性为元组的每个元素声明多个变量。
实现 simulatePlan
simulatePlan 的实现方法很简单;我们用不同的参数两次调用 futureCapital:
def simulatePlan(interestRate: Double,
nbOfMonthsSaving: Int, nbOfMonthsInRetirement: Int,
netIncome: Int, currentExpenses: Int, initialCapital:
Double) : (Double, Double) = {
val capitalAtRetirement = futureCapital(
interestRate = interestRate, nbOfMonths = nbOfMonthsSaving,
netIncome = netIncome, currentExpenses = currentExpenses,
initialCapital = initialCapital)
val capitalAfterDeath = futureCapital(
interestRate = interestRate, nbOfMonths = nbOfMonthsInRetirement,
netIncome = 0, currentExpenses = currentExpenses,
initialCapital = capitalAtRetirement)
(capitalAtRetirement, capitalAfterDeath)
}
再次运行 RetCalcSpec,这次应该能通过了。你可以随意在 Scala 控制台中用不同的值调用 simulatePlan 进行实验。
计算何时可以退休
如果你尝试从 Scala 控制台调用 simulatePlan,你可能尝试了不同的 nbOfMonths 值,并观察了退休和去世后的资本结果。有一个函数可以找到最佳的 nbOfMonths,这样你就有足够的资本在退休期间永远不会耗尽。
编写针对 nbOfMonthsSaving 的失败测试
如同往常,让我们从一个新的单元测试开始,以明确我们从这个函数期望什么:
"RetCalc.nbOfMonthsSaving" should {
"calculate how long I need to save before I can retire" in {
val actual = RetCalc.nbOfMonthsSaving(
interestRate = 0.04 / 12, nbOfMonthsInRetirement = 40 * 12,
netIncome = 3000, currentExpenses = 2000, initialCapital = 10000)
val expected = 23 * 12 + 1
actual should ===(expected)
}
}
在这个测试中,预期的值可能有点难以确定。一种方法是在 Excel 中使用 NPM 函数。或者,你可以在 Scala 控制台中多次调用 simulatePlan,并逐渐增加 nbOfMonthsSaving 以找到最佳值。
编写函数体
在函数式编程中,我们避免修改变量。在命令式语言中,你通常会通过使用 while 循环来实现 nbOfMonthsSaving。在 Scala 中也可以这样做,但更好的做法是只使用不可变变量。解决这个问题的方法之一是使用递归:
def nbOfMonthsSaving(interestRate: Double, nbOfMonthsInRetirement: Int,
netIncome: Int, currentExpenses: Int, initialCapital: Double): Int = {
def loop(months: Int): Int = {
val (capitalAtRetirement, capitalAfterDeath) = simulatePlan(
interestRate = interestRate,
nbOfMonthsSaving = months, nbOfMonthsInRetirement =
nbOfMonthsInRetirement,
netIncome = netIncome, currentExpenses = currentExpenses,
initialCapital = initialCapital)
val returnValue =
if (capitalAfterDeath > 0.0)
months
else
loop(months + 1)
returnValue
}
loop(0)
}
我们在函数体内声明递归函数,因为它不打算在其他任何地方使用。loop 函数通过将 months 增加 1 直到计算出的 capitalAfterDeath 为正来递增 months。loop 函数在 nbMonthsSaving 的函数体内以 months = 0 初始化。请注意,IntelliJ 使用一种 @ 符号突出显示 loop 函数是递归的。
现在,我们可以再次运行我们的单元测试,它应该会通过。然而,我们还没有完成。如果你永远无法达到满足条件 capitalAfterDeath > 0.0 的月份,会发生什么?让我们通过编写另一个测试来找出答案。
理解尾递归
在上一个测试下面添加以下测试:
"RetCalc.nbOfMonthsSaving" should {
"calculate how long I need to save before I can retire" in {...}
"not crash if the resulting nbOfMonths is very high" in {
val actual = RetCalc.nbOfMonthsSaving(
interestRate = 0.01 / 12, nbOfMonthsInRetirement = 40 * 12,
netIncome = 3000, currentExpenses = 2999, initialCapital = 0)
val expected = 8280
actual should ===(expected)
}
"not loop forever if I enter bad parameters" in pending
我们将在稍后实现not loop forever。编写悬而未决的测试是一个好习惯,当你想到你函数的新边缘情况或其他用例时立即编写。这有助于保持对最终目标的指向,并提供一些动力——一旦你通过了之前的测试,你就知道接下来需要编写什么测试。
运行测试,它将因StackOverflowError而失败。这是因为每次loop递归调用时,局部变量都会保存到 JVM 堆栈上。堆栈的大小相当小,正如我们所看到的,它很容易被填满。幸运的是,Scala 编译器中有一个机制可以自动将尾递归调用转换为while循环。我们说递归调用(loop函数内部的loop函数调用)是尾递归的,当它是函数的最后一个指令时。
我们可以轻松地将之前的代码更改为使其成为尾递归。选择returnValue,然后按 cmd + O将变量内联。loop函数的主体应该看起来像这样:
@tailrec
def loop(months: Int): Int = {
val (capitalAtRetirement, capitalAfterDeath) = simulatePlan(
interestRate = interestRate,
nbOfMonthsSaving = months, nbOfMonthsInRetirement =
nbOfMonthsInRetirement,
netIncome = netIncome, currentExpenses = currentExpenses,
initialCapital = initialCapital)
if (capitalAfterDeath > 0.0)
months
else
loop(months + 1)
IntelliJ 将编辑器边缘的小符号更改为突出显示我们的函数现在是尾递归。再次运行测试,它应该通过。在函数定义之前也放置一个注释@tailrec是一个好习惯。这告诉编译器它必须验证函数确实是尾递归。如果你注释了一个不是尾递归的@tailrec函数,编译器将引发错误。
当你知道递归调用的深度可以很高(超过 100)时,始终确保它是尾递归。
当你编写尾递归函数时,始终使用@tailrec进行注释,以便让编译器验证它。
确保终止
我们还没有完成,因为我们的函数可能会无限循环。想象一下,你总是花费比你赚的更多的钱。即使你活上一百万年,你也永远无法存足够的钱来退休!以下是一个突出显示这一点的单元测试:
"RetCalc.nbOfMonthsSaving" should {
"calculate how long I need to save before I can retire" in {...}
"not crash if the resulting nbOfMonths is very high" in {...}
"not loop forever if I enter bad parameters" in {
val actual = RetCalc.nbOfMonthsSavingV2(
interestRate = 0.04 / 12, nbOfMonthsInRetirement = 40 * 12,
netIncome = 1000, currentExpenses = 2000, initialCapital = 10000)
actual should === (Int.MaxValue)
}
}
我们决定使用一个特殊的值Int.MaxValue来表示nbOfMonths是无限的。这看起来并不美观,但我们在下一章中将会看到如何使用Option或Either来更好地建模这一点。现在这已经足够好了。
为了使测试通过,我们只需要添加一个if语句:
def nbOfMonthsSaving(interestRate: Double, nbOfMonthsInRetirement: Int,
netIncome: Int, currentExpenses: Int, initialCapital: Double): Int = {
@tailrec
def loop(nbOfMonthsSaving: Int): Int = {...}
if (netIncome > currentExpenses)
loop(0)
else
Int.MaxValue
}
使用市场利率
在我们的计算中,我们一直假设回报利率是恒定的,但现实情况更为复杂。使用市场数据中的实际利率来获得对我们退休计划的更多信心会更准确。为此,我们首先需要更改我们的代码,以便能够使用可变利率执行相同的计算。然后,我们将加载真实的市场数据,通过跟踪标准普尔 500 指数来模拟基金的定期投资。
定义代数数据类型
为了支持可变利率,我们需要更改接受interestRate: Double的所有函数的签名。我们需要一个可以表示恒定利率或一系列利率的类型,而不是一个双精度浮点数。
考虑两种类型A和B,我们之前看到了如何定义一个可以持有类型A和类型B的值的类型。这是一个产品类型,我们可以使用元组,例如ab: (A, B),或者使用案例类,例如case class MyProduct(a: A, b: B)。
另一方面,可以持有A或B的类型是和类型,在 Scala 中,我们使用sealed特质继承来声明它:
sealed trait Shape
case class Circle(diameter: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
代数数据类型(ADT)是一种由和类型和积类型组成以定义数据结构的类型。在上面的代码中,我们定义了一个Shape ADT,它由一个和类型(Shape可以是Circle或Rectangle)和一个积类型Rectangle(Rectangle包含宽度和高度)组成。
sealed关键字表示特质的所有子类必须在同一个.scala文件中声明。如果你尝试在另一个文件中声明一个扩展sealed特质的类,编译器将拒绝它。这用于确保我们的继承树是完整的,并且正如我们稍后将会看到的,它在使用模式匹配时具有有趣的优点。
回到我们的问题,我们可以定义一个Returns ADT 如下。在src/main/scala中的retcalc包中创建一个新的 Scala 类:
package retcalc
sealed trait Returns
case class FixedReturns(annualRate: Double) extends Returns
case class VariableReturns(returns: Vector[VariableReturn]) extends Returns
case class VariableReturn(monthId: String, monthlyRate: Double)
对于VariableReturn,我们保留月利率和一个标识符monthId,其形式为*2017.02*,代表 2017 年 2 月。我建议你在需要建模元素序列时使用Vector。Vector在追加/插入元素或通过索引访问元素时比List更快。
过滤特定期间的回报
当我们有一个跨越长期期的VariableReturns,例如,从 1900 年到 2017 年,使用较短的时间段来模拟如果较短时间段的历史回报,比如 50 年,被重复会发生什么,这可能是有趣的。
我们将在VariableReturns类中创建一个方法,该方法将仅保留特定期间内的回报。以下是ReturnsSpec.scala中的单元测试:
"VariableReturns.fromUntil" should {
"keep only a window of the returns" in {
val variableReturns = VariableReturns(Vector.tabulate(12) { i =>
val d = (i + 1).toDouble
VariableReturn(f"2017.$d%02.0f", d)
})
variableReturns.fromUntil("2017.07", "2017.09").returns should ===
(Vector(
VariableReturn("2017.07", 7.0),
VariableReturn("2017.08", 8.0)
))
variableReturns.fromUntil("2017.10", "2018.01").returns should ===
(Vector(
VariableReturn("2017.10", 10.0),
VariableReturn("2017.11", 11.0),
VariableReturn("2017.12", 12.0)
))
}
}
首先,我们使用Vector.tabulate函数生成一系列回报,并将它们分配给variableReturns。它生成 12 个元素,每个元素由一个匿名函数生成,该函数接受一个参数i,其值将从0到11。在调用VariableReturn构造函数时,对于monthId参数,我们使用f插值器生成形式为2017.01的字符串,当d = 1时,2017.02当d = 2时,依此类推。
我们指定的fromUntil函数将返回一个包含原始回报中特定窗口的VariableReturns类型。目前,我们假设传递给fromUntil的参数是有效的月份,它们存在于variableReturns中。理想情况下,我们应该添加单元测试来指定如果它们不存在会发生什么。
这里是Returns.scala中的实现:
case class VariableReturns(returns: Vector[VariableReturn]) extends Returns {
def fromUntil(monthIdFrom: String, monthIdUntil: String):
VariableReturns =
VariableReturns(
returns
.dropWhile(_.monthId != monthIdFrom)
.takeWhile(_.monthId != monthIdUntil))
}
我们使用高阶函数dropWhile来丢弃元素,直到我们达到条件monthId == monthIdFrom。然后,我们在得到的集合上调用takeWhile来保留所有元素,直到monthId == monthIdUntil。这将返回一个只包含从monthIdFrom开始到monthIdUntil之前的元素的集合。
运行ReturnsSpec,它应该通过。
模式匹配
现在我们有了表达变量回报的方法,我们需要将我们的futureCapital函数更改为接受Returns类型,而不是每月的利率类型Double。首先更改测试:
"RetCalc.futureCapital" should {
"calculate the amount of savings I will have in n months" in {
// Excel =-FV(0.04/12,25*12,1000,10000,0)
val actual = RetCalc.futureCapital(FixedReturns(0.04),
nbOfMonths = 25 * 12, netIncome = 3000,
currentExpenses = 2000, initialCapital = 10000).right.value
val expected = 541267.1990
actual should ===(expected)
}
"calculate how much savings will be left after having taken a
pension for n months" in {
val actual = RetCalc.futureCapital(FixedReturns(0.04),
nbOfMonths = 40 * 12, netIncome = 0, currentExpenses = 2000,
initialCapital = 541267.198962).right.value
val expected = 309867.5316
actual should ===(expected)
}
}
然后,按照以下方式更改RetCalc中的futureCapital函数:
def futureCapital(returns: Returns, nbOfMonths: Int, netIncome: Int, currentExpenses: Int,
initialCapital: Double): Double = {
val monthlySavings = netIncome - currentExpenses
(0 until nbOfMonths).foldLeft(initialCapital) {
case (accumulated, month) =>
accumulated * (1 + Returns.monthlyRate(returns, month)) +
monthlySavings
}
}
在这里,我们不是只在公式中使用interestRate,而是引入了一个新的函数Returns.monthlyRate,我们现在必须创建它。由于我们遵循相当严格的 TDD 方法,我们首先创建其签名,然后编写单元测试,最后实现它。
在Returns.scala中编写函数签名:
object Returns {
def monthlyRate(returns: Returns, month: Int): Double = ???
}
在src/test/scala中的retcalc包中创建一个新的单元测试ReturnsSpec:
class ReturnsSpec extends WordSpec with Matchers with TypeCheckedTripleEquals {
implicit val doubleEquality: Equality[Double] =
TolerantNumerics.tolerantDoubleEquality(0.0001)
"Returns.monthlyRate" should {
"return a fixed rate for a FixedReturn" in {
Returns.monthlyRate(FixedReturns(0.04), 0) should ===(0.04 / 12)
Returns.monthlyRate(FixedReturns(0.04), 10) should ===(0.04 / 12)
}
val variableReturns = VariableReturns(Vector(
VariableReturn("2000.01", 0.1),
VariableReturn("2000.02", 0.2)))
"return the nth rate for VariableReturn" in {
Returns.monthlyRate(variableReturns, 0) should ===(0.1)
Returns.monthlyRate(variableReturns, 1) should ===(0.2)
}
"roll over from the first rate if n > length" in {
Returns.monthlyRate(variableReturns, 2) should ===(0.1)
Returns.monthlyRate(variableReturns, 3) should ===(0.2)
Returns.monthlyRate(variableReturns, 4) should ===(0.1)
}
}
这些测试充当我们monthlyRate函数的规范。对于VariableRate,monthlyRate必须返回返回的Vector中存储的第n个利率。如果n大于利率的数量,我们决定monthlyRate应该回到Vector的开始,就像我们的变量回报的历史会无限期地重复一样。我们在这里可以做出不同的选择,例如,我们可以取回报的镜像,或者如果我们到达了末尾,我们可以简单地返回一些错误。为了实现这种旋转,我们正在将月份值和向量的长度应用模运算(*%*在 Scala 中)。
实现引入了一个新的语法元素,称为模式匹配:
def monthlyRate(returns: Returns, month: Int): Double = returns match {
case FixedReturns(r) => r / 12
case VariableReturns(rs) => rs(month % rs.length).monthlyRate
}
你现在可以运行ReturnsSpec,所有测试都应该通过。模式匹配允许你解构一个 ADT,并在匹配到某个模式时评估一些表达式。你还可以在过程中分配变量并在表达式中使用它们。在上面的例子中,case FixedReturns(r) => r/12可以解释为“如果变量returns是FixedReturns类型,将r = returns.annualRate赋值,并返回表达式r/12的结果”。
这是一个简单的例子,但你可以使用更复杂的模式。这个特性非常强大,通常可以替代很多if/else表达式。你可以在 Scala 控制台中尝试一些更复杂的模式:
scala> Vector(1, 2, 3, 4) match {
case head +: second +: tail => tail
}
res0: scala.collection.immutable.Vector[Int] = Vector(3, 4)
scala> Vector(1, 2, 3, 4) match {
case head +: second +: tail => second
}
scala> ("0", 1, (2.0, 3.0)) match {
case ("0", int, (d0, d1)) => d0 + d1
}
res2: Double = 5.0
scala> "hello" match {
case "hello" | "world" => 1
case "hello world" => 2
}
res3: Int = 1
scala> def present(p: Person): String = p match {
case Person(name, age) if age < 18 => s"$name is a child"
case p => s"${p.name} is an adult"
}
present: (p: Person)String
对于你的值,全面匹配所有可能的模式是一种良好的实践。否则,如果没有模式与值匹配,Scala 将抛出一个运行时异常,这可能会导致你的程序崩溃。然而,当你使用sealed特质时,编译器会知道特质的所有可能类,如果你没有匹配所有情况,编译器将发出警告。
在Returns.scala中,尝试使用 cmd + /注释掉这一行:
def monthlyRate(returns: Returns, month: Int): Double = returns match {
// case FixedReturns(r) => r / 12
case VariableReturns(rs) => rs(month % rs.length).monthlyRate
}
使用 cmd + F9重新编译项目。编译器将警告你正在做错事:
Warning:(27, 59) match may not be exhaustive.
It would fail on the following input: FixedReturns(_)
def monthlyRate(returns: Returns, month: Int): Double = returns match {
如果你尝试移除sealed关键字并重新编译,编译器将不会发出任何警告。
我们现在对如何使用模式匹配有了很好的掌握。保留sealed关键字,撤销monthlyRate中的注释,并运行ReturnsSpec以确保一切恢复正常。
如果你来自面向对象的语言,你可能会想知道为什么我们没有使用在FixedRate和VariableRate中实现的具体方法的抽象方法来实现monthlyRate。这在 Scala 中是完全可行的,有些人可能更喜欢这种设计选择。
然而,作为功能编程风格的倡导者,我更喜欢在对象中使用纯函数:
-
它们更容易推理,因为整个调度逻辑都在一个地方。
-
它们可以轻松地移动到其他对象中,这有助于重构。
-
它们的范围更有限。在类方法中,你总是有类的所有属性在作用域内。在函数中,你只有函数的参数。这有助于单元测试和可读性,因为你知道函数只能使用其参数,而不能使用其他任何东西。此外,当类有可变属性时,它还可以避免副作用。
-
有时在面向对象的设计中,当一个方法操作两个对象
A和B时,并不清楚该方法应该放在类A还是类B中。
重构 simulatePlan
由于我们更改了futureCapital的签名,我们也需要更改该函数的调用者。唯一的调用者是simulatePlan。在引入变量利率之前,实现很简单:我们只需要用相同的固定利率参数调用futureCapital进行积累和提款阶段。然而,随着变量利率的出现,我们必须确保提款阶段使用的利率是积累阶段利率之后的利率。
例如,假设你在 1950 年开始储蓄,并在 1975 年退休。在积累阶段,你需要使用 1950 年到 1975 年的回报,而在提款阶段,你必须使用 1975 年的回报。我们创建了一个新的单元测试来确保我们为这两个阶段使用了不同的回报:
val params = RetCalcParams(
nbOfMonthsInRetirement = 40 * 12,
netIncome = 3000,
currentExpenses = 2000,
initialCapital = 10000)
"RetCalc.simulatePlan" should {
"calculate the capital at retirement and the capital after death" in {
val (capitalAtRetirement, capitalAfterDeath) =
RetCalc.simulatePlan(
returns = FixedReturns(0.04), params, nbOfMonthsSavings = 25*12)
capitalAtRetirement should === (541267.1990)
capitalAfterDeath should === (309867.5316)
}
"use different returns for capitalisation and drawdown" in {
val nbOfMonthsSavings = 25 * 12
val returns = VariableReturns(
Vector.tabulate(nbOfMonthsSavings +
params.nbOfMonthsInRetirement)(i =>
if (i < nbOfMonthsSavings)
VariableReturn(i.toString, 0.04 / 12)
else
VariableReturn(i.toString, 0.03 / 12)))
val (capitalAtRetirement, capitalAfterDeath) =
RetCalc.simulatePlan(returns, params, nbOfMonthsSavings)
capitalAtRetirement should ===(541267.1990)
capitalAfterDeath should ===(-57737.7227)
}
}
由于 simulatePlan 除了 returns 参数外还有相当多的参数,我们决定将它们放入一个名为 RetCalcParams 的案例类中。这样,我们能够为不同的单元测试重用相同的参数。我们还将能够在 nbMonthsSaving 中重用它。如前所述,我们使用 tabulate 函数生成变量回报的值。
可以使用 Excel 通过 -FV(0.04/12, 25*12, 1000, 10000) 获取 capitalAtRetirement 的预期值。使用 -FV(0.03/12, 40*12, -2000, 541267.20) 可以获取 capitalAfterDeath 的预期值。
这里是 RetCalc 中的实现:
case class RetCalcParams(nbOfMonthsInRetirement: Int,
netIncome: Int,
currentExpenses: Int,
initialCapital: Double)
object RetCalc {
def simulatePlan(returns: Returns, params: RetCalcParams,
nbOfMonthsSavings: Int)
: (Double, Double) = {
import params._
val capitalAtRetirement = futureCapital(
returns = returns,
nbOfMonths = nbOfMonthsSavings,
netIncome = netIncome, currentExpenses = currentExpenses,
initialCapital = initialCapital)
val capitalAfterDeath = futureCapital(
returns = OffsetReturns(returns, nbOfMonthsSavings),
nbOfMonths = nbOfMonthsInRetirement,
netIncome = 0, currentExpenses = currentExpenses,
initialCapital = capitalAtRetirement)
(capitalAtRetirement, capitalAfterDeath)
}
第一行,import params._,将 RetCalcParams 的所有参数引入作用域。这样,你可以直接使用,例如,netIncome,而无需在前面加上 params.netIncome。在 Scala 中,你不仅可以从包中导入类,还可以从对象中导入函数或值。
在对 futureCapital 的第二次调用中,我们引入了一个新的子类 OffsetReturns,它将改变起始月份。我们需要在 ReturnsSpec 中为它编写一个新的单元测试:
"Returns.monthlyReturn" should {
"return a fixed rate for a FixedReturn" in {...}
val variableReturns = VariableReturns(
Vector(VariableReturn("2000.01", 0.1), VariableReturn("2000.02", 0.2)))
"return the nth rate for VariableReturn" in {...}
"return an error if n > length" in {...}
"return the n+offset th rate for OffsetReturn" in {
val returns = OffsetReturns(variableReturns, 1)
Returns.monthlyRate(returns, 0).right.value should ===(0.2)
}
}
并且在 Returns.scala 中的相应实现如下:
sealed trait Returns
case class FixedReturns(annualRate: Double) extends Returns
case class VariableReturn(monthId: String, monthlyRate: Double)
case class OffsetReturns(orig: Returns, offset: Int) extends Returns
object Returns {
def monthlyRate(returns: Returns, month: Int): Double = returns match {
case FixedReturns(r) => r / 12
case VariableReturns(rs) => rs(month % rs.length).monthlyRate
case OffsetReturns(rs, offset) => monthlyRate(rs, month + offset)
}
}
对于偏移回报,我们递归地调用 monthlyRate 并将偏移量加到请求的月份。
现在,你可以使用 cmd + F9 编译所有内容,并重新运行单元测试。它们都应该通过。
加载市场数据
为了计算跟踪 S & P 500 的基金中我们投资的实际月回报率,我们将加载一个包含 S & P 500 价格和股息的制表符分隔文件,以及另一个包含消费者价格指数的文件。这将使我们能够计算一个扣除通货膨胀的实际回报率。
使用列选择模式编写单元测试
首先,将 sp500_2017.tsv 从 github.com/PacktPublishing/Scala-Programming-Projects/blob/master/Chapter02/retirement-calculator/src/main/resources/sp500.tsv 复制到 src/test/resources。然后,在 retcalc 包中创建一个新的单元测试 EquityDataSpec。如果你重新输入这个例子,尝试使用列选择模式 (Alt + Shift + Insert)。将 .tsv 文件的内容复制到测试中,然后使用 Shift + Down 13 次选择第一列,并输入 EquityData("。最后,使用箭头键、删除、逗号等编辑其余的行:
package retcalc
import org.scalatest.{Matchers, WordSpec}
class EquityDataSpec extends WordSpec with Matchers {
"EquityData.fromResource" should {
"load market data from a tsv file" in {
val data = EquityData.fromResource("sp500_2017.tsv")
data should ===(Vector(
EquityData("2016.09", 2157.69, 45.03),
EquityData("2016.10", 2143.02, 45.25),
EquityData("2016.11", 2164.99, 45.48),
EquityData("2016.12", 2246.63, 45.7),
EquityData("2017.01", 2275.12, 45.93),
EquityData("2017.02", 2329.91, 46.15),
EquityData("2017.03", 2366.82, 46.38),
EquityData("2017.04", 2359.31, 46.66),
EquityData("2017.05", 2395.35, 46.94),
EquityData("2017.06", 2433.99, 47.22),
EquityData("2017.07", 2454.10, 47.54),
EquityData("2017.08", 2456.22, 47.85),
EquityData("2017.09", 2492.84, 48.17)
))
}
}
"EquityData.monthlyDividend" should {
"return a monthly dividend" in {
EquityData("2016.09", 2157.69, 45.03).monthlyDividend should ===
(45.03 / 12)
}
}
}
sp500_2017.tsv 的前几行看起来像这样:
month SP500 dividend
2016.09 2157.69 45.03
2016.10 2143.02 45.25
2016.11 2164.99 45.48
使用 Source 加载文件
我们的实现必须删除包含标题的第一行,然后对于每一行,分割并创建一个新的 EquityData 实例:
package retcalc
import scala.io.Source
case class EquityData(monthId: String, value: Double, annualDividend: Double) {
val monthlyDividend: Double = annualDividend / 12
}
object EquityData {
def fromResource(resource: String): Vector[EquityData] =
Source.fromResource(resource).getLines().drop(1).map { line =>
val fields = line.split("\t")
EquityData(
monthId = fields(0),
value = fields(1).toDouble,
annualDividend = fields(2).toDouble)
}.toVector
}
这段代码相当紧凑,你可能会失去对中间调用返回的类型的感觉。在 IntelliJ 中,你可以选择代码的一部分并按 Alt + = 来显示表达式的推断类型。
我们首先使用 scala.io.Source.fromResource 加载 .tsv 文件。它接受位于 resource 文件夹中的文件名,并返回一个 Source 对象。它可以在 src/test/resources 或 src/main/resources 中。当你运行测试时,这两个文件夹都会被搜索。如果你运行生产代码,只有 src/main/resources 中的文件可访问。
getLines 返回 Iterator[String]。迭代器是一个允许你遍历一系列元素的可变数据结构。它提供了许多与其他集合共有的函数。在这里,我们丢弃包含标题的第一行,并使用传递给 map 的匿名函数转换每一行。
匿名函数接受 line 类型的字符串,使用 split 将其转换为 Array[String],并实例化一个新的 EquityData 对象。
最后,我们使用 .toVector 将结果 Iterator[EquityData] 转换为 Vector[EquityData]。这一步非常重要:我们将可变、不安全的迭代器转换为不可变、安全的 Vector。公共函数通常不应接受或返回可变数据结构:
-
这使得代码更难推理,因为你必须记住可变结构的状态。
-
程序的行为将根据函数调用的顺序/重复次数而有所不同。在迭代器的情况下,它只能迭代一次。如果你需要再次迭代,你将不会得到任何数据:
scala> val iterator = (1 to 3).iterator
iterator: Iterator[Int] = non-empty iterator
scala> iterator foreach println
1
2
3
scala> iterator foreach println
scala>
加载通货膨胀数据
现在我们能够加载一些股权数据,我们需要加载通货膨胀数据,以便能够计算通货膨胀调整后的回报。这与加载股权数据非常相似。
首先,将 cpi_2017.tsv 从 github.com/PacktPublishing/Scala-Programming-Projects/blob/master/Chapter02/retirement-calculator/src/main/resources/cpi.tsv 复制到 src/test/resources。然后,在 retcalc 包中创建一个新的单元测试,名为 InflationDataSpec:
package retcalc
import org.scalatest.{Matchers, WordSpec}
class InflationDataSpec extends WordSpec with Matchers {
"InflationData.fromResource" should {
"load CPI data from a tsv file" in {
val data = InflationData.fromResource("cpi_2017.tsv")
data should ===(Vector(
InflationData("2016.09", 241.428),
InflationData("2016.10", 241.729),
InflationData("2016.11", 241.353),
InflationData("2016.12", 241.432),
InflationData("2017.01", 242.839),
InflationData("2017.02", 243.603),
InflationData("2017.03", 243.801),
InflationData("2017.04", 244.524),
InflationData("2017.05", 244.733),
InflationData("2017.06", 244.955),
InflationData("2017.07", 244.786),
InflationData("2017.08", 245.519),
InflationData("2017.09", 246.819)
))
}
}
}
然后,创建相应的 InflationData 类和伴生对象:
package retcalc
import scala.io.Source
case class InflationData(monthId: String, value: Double)
object InflationData {
def fromResource(resource: String): Vector[InflationData] =
Source.fromResource(resource).getLines().drop(1).map { line =>
val fields = line.split("\t")
InflationData(monthId = fields(0), value = fields(1).toDouble)
}.toVector
}
计算实际回报
对于给定的月份,n,实际回报是 return[n] 减去 inflationRate[n],因此以下公式:

我们将在 Returns 中创建一个新的函数,使用 Vector[EquityData] 和 Vector[InflationData] 创建 VariableReturns。将以下单元测试添加到 ReturnsSpec:
"Returns.fromEquityAndInflationData" should {
"compute real total returns from equity and inflation data" in {
val equities = Vector(
EquityData("2117.01", 100.0, 10.0),
EquityData("2117.02", 101.0, 12.0),
EquityData("2117.03", 102.0, 12.0))
val inflations = Vector(
InflationData("2117.01", 100.0),
InflationData("2117.02", 102.0),
InflationData("2117.03", 102.0))
val returns = Returns.fromEquityAndInflationData(equities,
inflations)
returns should ===(VariableReturns(Vector(
VariableReturn("2117.02", (101.0 + 12.0 / 12) / 100.0 - 102.0 /
100.0),
VariableReturn("2117.03", (102.0 + 12.0 / 12) / 101.0 - 102.0 /
102.0))))
}
}
我们创建了两个小的 Vector 实例 EquityData 和 InflationData,并使用前面的公式计算期望值。
这是 Returns.scala 中 fromEquityAndInflationData 的实现:
object Returns {
def fromEquityAndInflationData(equities: Vector[EquityData],
inflations: Vector[InflationData]):
VariableReturns = {
VariableReturns(equities.zip(inflations).sliding(2).collect {
case (prevEquity, prevInflation) +: (equity, inflation) +:
Vector() =>
val inflationRate = inflation.value / prevInflation.value
val totalReturn =
(equity.value + equity.monthlyDividend) / prevEquity.value
val realTotalReturn = totalReturn - inflationRate
VariableReturn(equity.monthId, realTotalReturn)
}.toVector)
}
首先,我们使用 zip 将两个 Vectors 合并,创建一个元组集合,(EquityData, InflationData)。这个操作就像我们正在拉链一件夹克一样将我们的两个集合结合在一起。在 Scala 控制台中玩弄是一个好习惯,以了解它的工作方式:
scala> Vector(1,2).zip(Vector("a", "b", "c"))
res0: scala.collection.immutable.Vector[(Int, String)] = Vector((1,a), (2,b))
注意,结果 Vector 的大小是两个参数中的最小值。最后一个元素 "c" 被丢失,因为没有东西可以与它进行压缩!
这是一个良好的开始,因为我们现在可以迭代一个可以给我们 pricen*、*dividendsn 和 inflation^n 的集合。但为了计算我们的公式,我们还需要 n-1 的先前数据。为此,我们使用 sliding(2)。我鼓励你阅读有关滑动的文档。让我们在控制台上试一试:
scala> val it = Vector(1, 2, 3, 4).sliding(2)
it: Iterator[scala.collection.immutable.Vector[Int]] = non-empty iterator
scala> it.toVector
res0: Vector[scala.collection.immutable.Vector[Int]] =
Vector(Vector(1, 2), Vector(2, 3), Vector(3, 4))
scala> Vector(1).sliding(2).toVector
res12: Vector[scala.collection.immutable.Vector[Int]] = Vector(Vector(1))
sliding(p) 创建一个 Iterator,它将产生大小为 p 的集合。每个集合将包含一个新的迭代元素和所有之前的 p-1 个元素。请注意,如果集合的大小 n 低于 p,则产生的集合大小将为 n。
接下来,我们使用 collect 迭代滑动集合。collect 与 map 类似:它允许你转换集合的元素,但增加了过滤的能力。基本上,每次你想对集合进行 filter 和 map 操作时,你都可以使用 collect。过滤是通过模式匹配来执行的。任何不匹配任何模式的东西都会被过滤掉:
scala> val v = Vector(1, 2, 3)
v: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3)
scala> v.filter(i => i != 2).map(_ + 1)
res15: scala.collection.immutable.Vector[Int] = Vector(2, 4)
scala> v.collect { case i if i != 2 => i + 1 }
res16: scala.collection.immutable.Vector[Int] = Vector(2, 4)
注意,在前面的代码中,我们使用了 map(_ + 1) 而不是 map(i => i + 1)。这是一个匿名函数的简写表示。每次你在匿名函数中使用参数一次时,你可以用 _ 替换它。
最后,我们使用以下方法对压缩和滑动元素进行模式匹配:
case (prevEquity, prevInflation) +: (equity, inflation) +: Vector() =>
如果我们传递大小为 0 或 1 的股票或通货膨胀参数,这将有助于过滤掉大小为 0 或 1 的滑动元素。在这本书中,我们不会为这种边缘情况编写单元测试,但我鼓励你作为练习这样做。
函数的其余部分很简单:我们使用匹配的变量来计算公式并创建一个新的 VariableReturn 实例。结果迭代器被转换为 Vector,然后我们使用它实例化一个 VariableReturns 案例类。
打包应用程序
我们现在已经实现了一些有用的构建块,现在是时候创建一个可执行文件,以便最终用户可以使用他们自己的参数使用我们的计算器。
创建 App 对象
我们将围绕 RetCalc.simulatePlan 构建一个简单的可执行文件。它将接受由空格分隔的参数列表,并在控制台上打印结果。
我们将要编写的测试将集成多个组件,并使用完整的市场数据集。因此,它不再是一个单元测试;它是一个集成测试。出于这个原因,我们在其后面加上 IT 而不是 Spec。
首先,将 sp500.tsv 和 cpi.tsv 从 github.com/PacktPublishing/Scala-Programming-Projects/blob/master/Chapter02/retirement-calculator/src/main/resources/sp500.tsv 和 github.com/PacktPublishing/Scala-Programming-Projects/blob/master/Chapter02/retirement-calculator/src/main/resources/cpi.tsv 复制到 src/main/resources,然后在 src/test/scala 中创建一个新的单元测试,名为 SimulatePlanIT:
package retcalc
import org.scalactic.TypeCheckedTripleEquals
import org.scalatest.{Matchers, WordSpec}
class SimulatePlanAppIT extends WordSpec with Matchers with TypeCheckedTripleEquals {
"SimulatePlanApp.strMain" should {
"simulate a retirement plan using market returns" in {
val actualResult = SimulatePlanApp.strMain(
Array("1997.09,2017.09", "25", "40", "3000", "2000", "10000"))
val expectedResult =
s"""
|Capital after 25 years of savings: 499923
|Capital after 40 years in retirement: 586435
""".stripMargin
actualResult should === (expectedResult)
}
}
}
我们调用一个名为 strMain 的函数,它将返回一个字符串而不是将其打印到控制台。这样,我们可以断言打印到控制台的内容。为了保持简单,我们假设参数以特定的顺序传递。我们将在下一章开发一个更用户友好的界面。参数如下:
-
我们将在变量返回值中使用的时期,以逗号分隔
-
储蓄的年数
-
退休的年数
-
收入
-
支出
-
初始资本
预期值是我们使用三引号定义的字符串。在 Scala 中,三引号包围的字符串允许你输入特殊字符,如引号或换行符。在保持良好缩进的同时输入多行字符串非常方便。| 字符允许你标记每行的开始,而 .stripMargin 函数则移除了 | 前面的空白以及 | 本身。在 IntelliJ 中,当你输入 """ 并按 Enter 键时,它会自动在关闭三引号后添加 | 和 .stripMargin。
实现调用我们之前实现的不同函数。注意,IntelliJ 可以使用 Ctrl + 空格键自动补全文件名,例如,在 EquityData.fromResource("Create a new object SimulatePlanApp in the package retcalc 之后:
package retcalc
object SimulatePlanApp extends App {
println(strMain(args))
def strMain(args: Array[String]): String = {
val (from +: until +: Nil) = args(0).split(",").toList
val nbOfYearsSaving = args(1).toInt
val nbOfYearsInRetirement = args(2).toInt
val allReturns = Returns.fromEquityAndInflationData(
equities = EquityData.fromResource("sp500.tsv"),
inflations = InflationData.fromResource("cpi.tsv"))
val (capitalAtRetirement, capitalAfterDeath) =
RetCalc.simulatePlan(
returns = allReturns.fromUntil(from, until),
params = RetCalcParams(
nbOfMonthsInRetirement = nbOfYearsInRetirement * 12,
netIncome = args(3).toInt,
currentExpenses = args(4).toInt,
initialCapital = args(5).toInt),
nbOfMonthsSavings = nbOfYearsSaving * 12)
s"""
|Capital after $nbOfYearsSaving years of savings:
${capitalAtRetirement.round}
|Capital after $nbOfYearsInRetirement years in retirement:
${capitalAfterDeath.round}
""".stripMargin
}
}
当我们运行可执行文件时,唯一执行的代码将是 println(strMain(args))。将此代码保持尽可能短是一个好习惯,因为它不受任何测试的覆盖。我们的 strMain 函数是受覆盖的,所以我们相当确信不会有任何意外的行为来自单个 println。args 是一个包含传递给可执行文件的所有参数的 Array[String]。
strMain 的第一行使用 List 的模式匹配来分配 from 和 until。只有当拆分的第一个参数,在我们的测试中为 "1997.09,2017.09",是一个包含两个元素的 List 时,变量才会被分配。
然后,我们从 .tsv 文件中加载股票和通货膨胀数据。它们包含从 1900.01 到 2017.09 的数据。然后我们调用 Returns.fromEquityAndInflationData 来计算实际回报。
在将收益分配给allReturns之后,我们用正确的参数调用simulatePlan。使用from和until对收益进行特定时期的过滤。最后,我们使用字符串插值和三引号返回String。
这个实现是第一个草稿,相当脆弱。如果我们没有给我们的可执行文件传递足够的参数,它确实会因ArrayIndexOutOfBoundsException而崩溃,或者如果某些字符串无法转换为Int或Double,则会因NumberFormatException而崩溃。我们将在下一章中看到我们如何优雅地处理这些错误情况,但到目前为止,只要我们给它提供正确的参数,我们的计算器就能完成工作。
您现在可以运行SimulatePlanIT,它应该通过。
由于我们构建了一个应用程序,我们也可以这样运行它。将光标移至SimulatePlanApp,然后按Ctrl + Shift + R。应用程序应该运行并崩溃,因为我们在可执行文件中没有传递任何参数。点击SimulatePlanApp的启动器(位于构建菜单下方),然后点击编辑配置。在程序参数中输入以下内容:
1997.09,2017.09 25 40 3000 2000 10000
然后,点击确定,再次运行SimulatePlanApp。它应该打印出我们在单元测试中拥有的相同内容。您可以尝试用不同的参数调用应用程序,并观察产生的计算资本。
打包应用程序
到目前为止一切顺利,但如果我们想将这个应用程序发送给鲍勃叔叔,以便他也能为退休做准备呢?要求他下载 IntelliJ 或 SBT 并不方便。我们将把我们的应用程序打包成一个.jar文件,这样我们就可以通过单个命令来运行它。
SBT 提供了一个打包任务,可以创建一个.jar文件,但这个文件不会包含依赖项。为了打包我们自己的类以及来自依赖库的类,我们将使用sbt-assembly插件。创建一个名为project/assembly.sbt的新文件,包含以下内容:
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6")
然后,编辑build.sbt以定义我们的主类名称:
name := "retirement_calculator"
version := "0.1"
scalaVersion := "2.12.4"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.4" % "test"
mainClass in Compile := Some("retcalc.SimulatePlanApp")
点击右上角的 SBT 标签,然后点击刷新按钮。然后,展开项目并双击 assembly 任务:

打包任务将编译所有类,运行所有测试,如果它们都通过,则打包一个胖 JAR。您应该在 SBT 控制台底部看到类似的输出:
[info] Checking every *.class/*.jar file's SHA-1.
[info] Merging files...
[warn] Merging 'META-INF/MANIFEST.MF' with strategy 'discard'
[warn] Strategy 'discard' was applied to a file
[info] SHA-1: 7b7710bf370159c549a11754bf66302a76c209b2
[info] Packaging /home/mikael/projects/scala_fundamentals/retirement_calculator/target/scala-2.12/retirement_calculator-assembly-0.1.jar ...
[info] Done packaging.
[success] Total time: 11 s, completed 14-Jan-2018 12:23:39
将.jar文件的路径复制到您的剪贴板。现在,您可以使用 Unix 终端或 Windows 命令提示符来运行应用程序,如下所示:
$ java -jar <path to your .jar file> 1997.09,2017.09 25 40 3000 2000 10000
Capital after 25 years of savings: 499923
Capital after 40 years in retirement: 586435
现在尝试不同的参数更容易了。有趣的是,有些时期比其他时期更有利可图:
$ java -jar <path to your .jar file> 2000.01,2010.01 25 40 3000 2000 10000
Capital after 25 years of savings: 225209
Capital after 40 years in retirement: -510074
$ java -jar <path to your .jar file> 1950.01,1960.01 25 40 3000 2000 10000
Capital after 25 years of savings: 4505196
Capital after 40 years in retirement: 2077953853
摘要
我们已经介绍了如何从头到尾创建一个 Scala 小项目,包括打包。我们在整个过程中使用了 TDD,以确保我们的代码设计良好且健壮。当我们重构代码时,这给了我们信心:只要所有测试都通过,我们就知道我们的代码仍然有效。我们使用不可变数据结构来模拟我们的领域,并使用没有副作用的功能函数来处理它们。
我们使用了大多数项目中都会用到的语言的基本特性,你现在应该熟悉了足够的构建块来实现各种各样的项目。
作为进一步的练习,你可以通过添加一个或多个这些特性来增强这个计算器:
-
为函数
RetCalc.nbOfMonthsSaving创建一个应用程序,该函数计算你退休前需要存多久。 -
创建一个名为
RetCalc.annualizedTotalReturn的函数,该函数计算一系列回报的几何平均值。有关更多详细信息,请参阅www.investopedia.com/terms/a/annualized-total-return.asp。 -
创建一个名为
monthlyPension的函数,该函数计算如果你每个月存入一定金额,在给定月份后你将获得多少退休金。 -
结合其他收入来源。也许在几年后,你将获得国家养老金,或者你可能得到遗产。
-
加载不同的指数,例如 STOXX 欧洲 600、MSCI 世界指数等。
-
大多数人不会将所有储蓄投资于股市,而是明智地通过债券进行多元化,即通常 60%的股票,40%的债券。你可以在
Returns.scala中添加一个新函数来计算混合投资组合的回报。 -
我们观察到有些时期的回报率远高于其他时期。由于预测未来很困难,你可以使用不同的时期运行多个模拟,并计算成功的概率。
在下一章中,我们将通过以功能方式处理错误来进一步改进我们的计算器。
第三章:处理错误
在本章中,我们将继续在 第二章 中实现的退休计算器上进行工作,即 开发退休计算器。只要我们传递了正确的参数,我们的计算器就能正常工作,但如果任何参数错误,它将产生糟糕的堆栈跟踪并严重失败。我们的程序只适用于我们所说的 快乐路径。
编写生产软件的现实是,可能会出现各种错误场景。其中一些是可恢复的,一些必须以吸引人的方式呈现给用户,而对于一些与硬件相关的错误,我们可能需要让程序崩溃。
在本章中,我们将介绍异常处理,解释引用透明性是什么,并试图说服您异常不是处理错误的最佳方式。然后,我们将解释如何使用函数式编程结构有效地处理错误的可能性。
在每个部分中,我们将简要介绍一个新概念,然后在 Scala 工作表中使用它,以了解如何使用它。之后,我们将应用这些新知识来改进退休计算器。
在本章中,我们将探讨以下主题:
-
在必要时使用异常
-
理解引用透明性
-
使用
Option表示可选值 -
使用
Either顺序处理错误 -
使用
Validated并行处理错误
设置
如果您尚未完成 第二章,开发退休计算器,那么您可以在 GitHub 上查看退休计算器项目。如果您还不熟悉 Git,我建议您首先阅读 guides.github.com/introduction/git-handbook/ 中的文档。
要开始设置,请按照以下步骤操作:
-
如果您还没有账户,请在
github.com/上创建一个账户。 -
前往退休计算器项目
github.com/PacktPublishing/Scala-Programming-Projects。点击右上角的 Fork 将项目分叉到您的账户中。 -
一旦项目被分叉,点击 Clone 或下载,并将 URL 复制到剪贴板。
-
在 IntelliJ 中,转到 File | New | Project from Version Control | GitHub 并进行以下编辑:
-
Git 仓库 URL: 粘贴您分叉仓库的 URL
-
父目录: 选择一个位置
-
目录名称: 保持
retirement_calculator -
点击 Clone
-
-
项目应该在 IntelliJ 中导入。点击屏幕右下角的 git: master,然后选择 Remote branches | origin/chapter2 | Checkout as new local branch。将新分支命名为
chapter3_yourusername以区分最终的解决方案,该解决方案位于origin/chapter3分支中。 -
使用 Ctrl + F9 构建 project。一切都应该编译成功。
使用异常
异常是我们可以在 Scala 中使用的用于处理错误场景的机制之一。它由两个语句组成:
-
throw exceptionObject语句停止当前函数并将异常传递给调用者。 -
try { myFunc() } catch { case pattern1 => recoverExpr1 }语句会捕获myFunc()抛出的任何异常,如果该异常与catch块内部的某个模式匹配: -
如果
myFunc抛出异常,但没有模式与该异常匹配,则函数停止,并将异常再次传递给调用者。如果没有try...catch块可以在调用链中捕获该异常,则整个程序停止。 -
如果
myFunc抛出异常,并且pattern1模式与该异常匹配,则try...catch块将返回箭头右侧的recoverExpr1表达式。 -
如果没有抛出异常,则
try...catch块返回myFunc()返回的结果。
这种机制来自 Java,由于 Scala SDK 位于 Java SDK 之上,许多对 SDK 的函数调用可能会抛出异常。如果你熟悉 Java,Scala 的异常机制略有不同。Scala 中的异常总是未检查的,这意味着编译器永远不会强制你捕获异常或声明一个函数可以抛出异常。
抛出异常
下面的代码片段演示了如何抛出异常。你可以将其粘贴到 Scala 控制台或 Scala 工作表中:
case class Person(name: String, age: Int)
case class AgeNegativeException(message: String) extends Exception(message)
def createPerson(description: String): Person = {
val split = description.split(" ")
val age = split(1).toInt
if (age < 0)
throw AgeNegativeException(s"age: $age should be > 0")
else
Person(split(0), age)
createPerson函数如果传入的字符串参数正确,则创建Person对象,如果不正确,则抛出不同类型的异常。在前面的代码中,我们还实现了自己的AgeNegativeException实例,如果字符串中传递的年龄是负数,则会抛出,如下面的代码所示:
scala> createPerson("John 25")
res0: Person = Person(John,25)
scala> createPerson("John25")
java.lang.ArrayIndexOutOfBoundsException: 1
at .createPerson(<console>:17)
... 24 elided
scala> createPerson("John -25")
AgeNegativeException: age: -25 should be > 0
at .createPerson(<console>:19)
... 24 elided
scala> createPerson("John 25.3")
java.lang.NumberFormatException: For input string: "25.3"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:580)
at java.lang.Integer.parseInt(Integer.java:615)
at scala.collection.immutable.StringLike.toInt(StringLike.scala:301)
at scala.collection.immutable.StringLike.toInt$(StringLike.scala:301)
at scala.collection.immutable.StringOps.toInt(StringOps.scala:29)
at .createPerson(<console>:17)
... 24 elided
由于异常没有被任何try...catch块捕获,Scala 控制台显示了堆栈跟踪。堆栈跟踪显示了导致异常抛出的所有嵌套函数调用。在最后一个例子中,createPerson中的val age = split(1).toInt行调用了scala.collection.immutable.StringOps.toInt,它又调用了scala.collection.immutable.StringLike.toInt$,等等,直到最终在Integer.java的第 580 行java.lang.Integer.parseInt函数抛出了异常。
捕获异常
为了说明异常如何在调用栈中向上冒泡,我们将创建一个新的averageAge函数,该函数使用字符串描述计算Person实例列表的平均年龄,如下面的代码所示:
def averageAge(descriptions: Vector[String]): Double = {
val total = descriptions.map(createPerson).map(_.age).sum
total / descriptions.length
}
此函数调用我们之前实现的createPerson函数,因此会抛出createPerson抛出的任何异常,因为在averageAge中没有try...catch块。
现在,我们可以在上面实现另一个函数,该函数将解析包含多个Person描述的输入,并以字符串形式返回摘要。如果输入无法解析,它将打印错误信息,如下面的代码所示:
import scala.util.control.NonFatal
def personsSummary(personsInput: String): String = {
val descriptions = personsInput.split("\n").toVector
val avg = try {
averageAge(descriptions)
} catch {
case e:AgeNegativeException =>
println(s"one of the persons has a negative age: $e")
0
case NonFatal(e) =>
println(s"something was wrong in the input: $e")
0
}
s"${descriptions.length} persons with an average age of $avg"
}
在这个函数中,我们声明了一个avg值,如果没有抛出异常,它将获取averageAge的返回值。如果其中一个描述包含负年龄,我们的捕获块将打印错误信息,并将avg赋值为0。如果抛出了另一种类型的异常,并且这个异常是NonFatal,那么我们将打印另一条信息,并将avg也赋值为0。致命异常是无法恢复的异常,例如OutOfMemoryException。你可以查看scala.util.control.NonFatal的实现以获取更多详细信息。
以下代码展示了personsSummary的一些示例调用:
scala> personsSummary(
"""John 25
|Sharleen 45""".stripMargin)
res1: String = 2 persons with an average age of 35.0
scala> personsSummary(
"""John 25
|Sharleen45""".stripMargin)
something was wrong in the input: java.lang.ArrayIndexOutOfBoundsException: 1
res2: String = 2 persons with an average age of 0.0
scala> personsSummary(
"""John -25
|Sharleen 45""".stripMargin)
one of the persons has a negative age: $line5.$read$$iw$$iw$AgeNegativeException: age should be > 0
res3: String = 2 persons with an average age of 0.0
如我们所见,一旦任何描述无法解析,就会打印错误信息,并将平均年龄设置为0。
使用 finally 块
try...catch块可以可选地跟一个finally {}块。finally块中的代码总是被执行,即使catch块中没有匹配任何模式。finally块通常用于关闭在try块中访问的任何资源,例如文件或网络连接。
以下代码展示了如何使用 URL 将网页读入字符串:
import java.io.IOException
import java.net.URL
import scala.annotation.tailrec
val stream = new URL("https://www.packtpub.com/").openStream()
val htmlPage: String =
try {
@tailrec
def loop(builder: StringBuilder): String = {
val i = stream.read()
if (i != -1)
loop(builder.append(i.toChar))
else
builder.toString()
}
loop(StringBuilder.newBuilder)
} catch {
case e: IOException => s"cannot read URL: $e"
}
finally {
stream.close()
}
finally块允许我们在页面读取成功与否的情况下关闭InputStream。这样,如果存在网络问题或线程中断,我们不会留下悬挂的打开连接。
注意,前面的代码仅用于说明目的。在实际项目中,你应该使用以下代码格式:
val htmlPage2 = scala.io.Source.fromURL("https://www.packtpub.com/").mkString
现在你已经知道了如何使用异常,我们将定义引用透明的概念,并展示如何捕获异常可以破坏它。然后我们将探索更好的数据结构,这将使我们能够在不破坏引用透明性的情况下管理错误。
确保引用透明性
当一个表达式可以被其值替换而不改变程序的行为时,我们称其为引用透明。当一个表达式是一个函数调用时,这意味着我们可以总是用函数的返回值替换这个函数调用。在任何上下文中都能保证这一点的函数被称为纯函数。
纯函数就像数学函数一样——返回值只依赖于传递给函数的参数。你不需要考虑任何其他关于它被调用的上下文。
定义纯函数
在下面的代码中,pureSquare函数是纯函数:
def pureSquare(x: Int): Int = x * x
val pureExpr = pureSquare(4) + pureSquare(3)
// pureExpr: Int = 25
val pureExpr2 = 16 + 9
// pureExpr2: Int = 25
被调用的函数pureSquare(4)和pureSquare(3)是引用透明的——当我们用函数的返回值替换它们时,程序的行为不会改变。
另一方面,以下函数是不纯的:
var globalState = 1
def impure(x: Int): Int = {
globalState = globalState + x
globalState
}
val impureExpr = impure(3)
// impureExpr: Int = 4
val impureExpr2 = 4
我们不能用 impure(3) 的返回值来替换其调用,因为返回值会根据上下文而变化。实际上,任何具有副作用的函数都是不纯的。副作用可以是以下任何一种结构:
-
修改全局变量
-
向控制台打印
-
打开网络连接
-
从文件读取/写入数据
-
从数据库读取/写入数据
-
更普遍地,任何与外部世界的交互
下面是另一个不纯函数的例子:
import scala.util.Random
def impureRand(): Int = Random.nextInt()
impureRand()
//res0: Int = -528134321
val impureExprRand = impureRand() + impureRand()
//impureExprRand: Int = 681209667
val impureExprRand2 = -528134321 + -528134321
你不能将 impureRand() 的结果替换为其值,因为每次调用时值都会变化。如果你这样做,程序的行为会改变。impureRand() 的调用不是引用透明的,因此 impureRand 是不纯的。实际上,random() 函数每次调用都会修改一个全局变量以生成一个新的随机数。我们也可以说这个函数是非确定性的——我们仅通过观察其参数无法预测其返回值。
我们可以重写我们的不纯函数,使其成为纯函数,如下面的代码所示:
def pureRand(seed: Int): Int = new Random(seed).nextInt()
pureRand(10)
//res1: Int = -1157793070
val pureExprRand = pureRand(10) + pureRand(10)
//pureExprRand: Int = 1979381156
val pureExprRand2 = -1157793070 + -1157793070
//pureExprRand2: Int = 1979381156
你可以用 pureRand(seed) 的值替换其调用;给定相同的种子,该函数总是会返回相同的值。pureRand 的调用是引用透明的,因此 pureRand 是一个纯函数。
下面是另一个不纯函数的例子:
def impurePrint(): Unit = println("Hello impure")
val impureExpr1: Unit = impurePrint()
val impureExpr2: Unit = ()
在第二个例子中,impurePrint 函数的返回值是 Unit 类型。在 Scala SDK 中,这种类型的值只有一个:() 值。如果我们用 () 替换对 impurePrint() 的调用,那么程序的行为会改变——在第一种情况下,会在控制台上打印一些内容,但在第二种情况下则不会。
最佳实践
引用透明性是函数式编程中的一个关键概念。如果你的程序大部分使用纯函数,那么以下操作会变得容易得多:
-
理解程序在做什么:你知道函数的返回值只依赖于其参数。你不必考虑在这个或那个上下文中这个或那个变量的状态。你只需看看参数。
-
测试你的函数:我将重申这一点——函数的返回值只依赖于其参数。测试它非常简单;你可以尝试不同的参数值,并确认返回值是你预期的。
-
编写多线程程序:由于纯函数的行为不依赖于全局状态,你可以在多个线程甚至不同的机器上并行执行它。返回值不会改变。随着我们今天构建的 CPU 越来越多核心,这将帮助你编写更快的程序。
然而,在程序中只使用纯函数是不可能的,因为本质上,程序必须与外界交互。它必须打印某些内容,读取一些用户输入,或将某些状态保存到数据库中。在函数式编程中,最佳实践是在大多数代码库中使用纯函数,并将不纯的副作用函数推到程序的边界。
例如,在第二章,“开发退休计算器”,我们实现了一个主要使用纯函数的退休计算器。其中一个副作用函数是SimulatePlanApp对象中的println调用,它在程序的边界上。EquityData.fromFile和InflationData.fromFile中也有其他副作用;这些函数用于读取文件。然而,资源文件在程序运行期间永远不会改变。对于给定的文件名,我们总是会得到相同的内容,并且我们可以替换所有调用中的fromFile的返回值,而不改变程序的行为。在这种情况下,读取文件的副作用是不可观察的,我们可以将这些文件读取函数视为纯函数。另一个副作用函数是strMain,因为它可以抛出异常。在本章的其余部分,我们将看到为什么抛出异常会破坏引用透明性,我们将学习如何用更好的函数式编程结构来替换它。
不纯函数满足以下两个标准:
-
它返回
单位。 -
它不接受任何参数,但返回一个类型。由于它返回的内容无法通过其参数获得,它必须使用全局状态。
注意,纯函数可以在函数体内使用可变变量或副作用。只要这些效果对调用者来说是不可观察的,我们就认为该函数是纯的。在 Scala SDK 中,许多纯函数都是使用可变变量实现的,以提高性能。看看以下TraversableOnce.foldLeft的实现:
def foldLeftB(op: (B, A) => B): B = {
var result = z
this foreach (x => result = op(result, x))
result
}
展示异常如何破坏引用透明性
这可能看起来不明显,但当函数抛出异常时,它会破坏引用透明性。在本节中,我将向您展示原因。首先,创建一个新的 Scala 工作表,并输入以下定义:
case class Rectangle(width: Double, height: Double)
def area(r: Rectangle): Double =
if (r.width > 5 || r.height > 5)
throw new IllegalArgumentException("too big")
else
r.width * r.height
然后,我们将使用以下参数调用area:
val area1 = area(3, 2)
val area2 = area(4, 2)
val total = try {
area1 + area2
} catch {
case e: IllegalArgumentException => 0
}
我们得到total: Double = 14.0。在上面的代码中,area1和area2表达式是引用透明的。我们确实可以替换它们而不改变程序的行为。在 IntelliJ 中,选择try块内的area1变量,然后按Ctrl + Alt + N(内联变量),如下所示。对area2也做同样的操作:
val total = try {
area(3, 2) + area(4, 2)
} catch {
case e: IllegalArgumentException => 0
}
total与之前相同。程序的行为没有改变,因此area1和area2是引用透明的。然而,让我们看看如果我们以以下方式定义area1会发生什么:
val area1 = area(6, 2)
val area2 = area(4, 2)
val total = try {
area1 + area2
} catch {
case e: IllegalArgumentException => 0
}
在这种情况下,我们会得到java.lang.IllegalArgumentException: too big,因为我们的area(...)函数在宽度大于五时抛出异常。现在让我们看看如果我们像之前一样内联area1和area2会发生什么,如下面的代码所示:
val total = try {
area(6, 2) + area(4, 2)
} catch {
case e: IllegalArgumentException => 0
}
在这种情况下,我们得到total: Double = 0.0。当用其值替换area1时,程序的行为发生了变化,因此area1不是引用透明的。
我们已经证明了异常处理会破坏引用透明性,因此抛出异常的函数是不纯的。这使得程序更难以理解,因为你必须考虑变量在哪里定义来了解程序将如何运行。行为将根据变量是在try块内还是外定义而改变。这在一个简单的例子中可能不是什么大问题,但当存在多个带有try块的链式函数调用,并匹配不同类型的异常时,可能会变得令人望而生畏。
当你使用异常时,另一个缺点是函数的签名并没有表明它可以抛出异常。当你调用一个可能抛出异常的函数时,你必须查看其实现来了解它可以抛出什么类型的异常,以及在什么情况下。如果函数调用了其他函数,问题会变得更加复杂。你可以通过添加注释或@throws注解来指明可能抛出的异常类型,但这些在代码重构时可能会过时。当我们调用一个函数时,我们只需要考虑其签名。签名有点像一份合同——给定这些参数,我会返回一个结果。如果你必须查看实现来了解抛出了哪些异常,这意味着合同尚未完成:有些信息被隐藏了。
我们现在知道了如何抛出和捕获异常,以及为什么我们应该谨慎使用它们。最佳实践是执行以下操作:
-
尽早捕获可恢复的异常,并使用特定的返回类型来指示失败的可能性。
-
不要捕获无法恢复的异常,例如磁盘空间不足、内存不足或其他灾难性故障。这样,每当发生此类异常时,你的程序都会崩溃,你应该在程序外部有一个手动或自动的恢复过程。
在本章的其余部分,我将向你展示如何使用Option、Either和Validated类来模拟失败的可能性。
使用 Option
Scala 的 Option 类型是一个 代数数据类型(ADT),它表示一个可选值。它也可以被视为可以包含一个或零个元素的 List。它是您在 Java、C++ 或 C# 编程时可能使用的 null 引用的安全替代品。
操作 Option 实例
以下是对 Option ADT 的简化定义:
sealed trait Option[+A]
case class SomeA extends Option[A]
case object None extends Option[Nothing]
Scala SDK 提供了一个更精细的实现;前面的定义只是为了说明目的。这个定义意味着 Option 可以是以下两种类型之一:
-
Some(value),表示一个可选值,其中值存在 -
None,表示一个可选值,其中值不存在。
在 Option[+A] 声明中 A 类型参数前面的 + 符号表示 Option 在 A 上是协变的。我们将在第四章 高级特性中更详细地探讨逆变。
目前,您只需知道如果 B 是 A 的子类型,那么 Option[B] 就是 Option[A] 的子类型。
此外,您可能会注意到 None 实际上扩展了 Option[Nothing] 而不是 Option[A]。这是因为一个案例对象不能接受类型参数。
在 Scala 中,Nothing 是最低类型,这意味着它是任何其他类型的子类型。
这意味着对于任何 A,None 都是 Option[A] 的子类型。
以下是一些使用不同类型 Option 的示例,您可以将它们粘贴到 Scala 工作表中:
val opt0: Option[Int] = None
// opt0: Option[Int] = None
val opt1: Option[Int] = Some(1)
// opt1: Option[Int] = Some(1)
val list0 = List.empty[String]
list0.headOption
// res0: Option[String] = None
list0.lastOption
// res1: Option[String] = None
val list3 = List("Hello", "World")
list3.headOption
// res2: Option[String] = Some(Hello)
list3.lastOption
// res3: Option[String] = Some(World)
上述代码的解释如下:
-
前两个示例展示了我们如何定义一个可选包含
Int的Option类型。 -
以下示例使用
List中的headOption和lastOption方法来展示 SDK 的许多安全函数返回Option。如果List为空,这些函数总是返回None。请注意,SDK 还提供了等效的 危险head和last方法。如果用空List调用这些危险方法,它们会抛出异常,如果我们没有捕获异常,这可能会使我们的程序崩溃。
SDK 的许多函数提供了等效的安全(返回 Option)和危险(抛出异常)函数。始终使用安全替代方案是一种最佳实践。
由于 Option 是一个 ADT,我们可以使用模式匹配来测试 Option 是 None 还是 Some,如下面的代码所示:
def personDescription(name: String, db: Map[String, Int]): String =
db.get(name) match {
case Some(age) => s"$name is $age years old"
case None => s"$name is not present in db"
}
val db = Map("John" -> 25, "Rob" -> 40)
personDescription("John", db)
// res4: String = John is 25 years old
personDescription("Michael", db)
// res5: String = Michael is not present in db
Map 中的 get(key) 方法返回 Option,包含与键关联的值。如果键不在 Map 中,它返回 None。当您开始使用 Option 时,模式匹配是根据 Option 的内容触发不同行为的最自然方式。
另一种方法是使用 map 和 getOrElse,如下面的代码所示:
def personDesc(name: String, db: Map[String, Int]): String = {
val optString: Option[String] = db.get(name).map(age => s"$name is
$age years old")
optString.getOrElse(s"$name is not present in db")
}
我们之前看到如何使用map转换向量的元素。这对于Option也是完全相同的——我们传递一个匿名函数,如果Option不为空,它将被调用。由于我们的匿名函数返回一个字符串,我们得到Option[String]。然后我们调用getOrElse,它提供了一个值,以防Option是None。getOrElse短语是一种安全提取Option内容的好方法。
永远不要在Option上使用.get方法——始终使用.getOrElse。如果Option是None,.get方法会抛出异常,因此它是不安全的。
使用 for...yield 组合转换
使用相同的db: Map[String, Int]短语,包含不同人的年龄,以下代码是一个返回两个人平均年龄的函数的简单实现:
def averageAgeA(name1: String, name2: String, db: Map[String, Int]): Option[Double] = {
val optOptAvg: Option[Option[Double]] =
db.get(name1).map(age1 =>
db.get(name2).map(age2 =>
(age1 + age2).toDouble / 2))
optOptAvg.flatten
}
val db = Map("John" -> 25, "Rob" -> 40)
averageAge("John", "Rob", db)
// res6: Option[Double] = Some(32.5)
averageAge("John", "Michael", db)
// res7: Option[Double] = None
函数返回Option[Double]。如果name1或name2在db映射中找不到,averageAge返回None。如果两个名字都找到了,它返回Some(value)。实现使用map来转换选项中包含的值。我们最终得到一个嵌套的Option[Option[Double]],但我们的函数必须返回Option[Double]。幸运的是,我们可以使用flatten来移除一层嵌套。
我们成功实现了averageAge,但我们可以使用flatMap来改进它,如下面的代码所示:
def averageAgeB(name1: String, name2: String, db: Map[String, Int]): Option[Double] =
db.get(name1).flatMap(age1 =>
db.get(name2).map(age2 =>
(age1 + age2).toDouble / 2))
如其名所示,flatMap相当于组合flatten和map。在我们的函数中,我们将map(...).flatten替换为flatMap(...)。
到目前为止,一切顺利,但如果我们想得到三或四人的平均年龄怎么办?我们就必须嵌套多个flatMap实例,这不会很漂亮或易于阅读。幸运的是,Scala 提供了一个语法糖,允许我们进一步简化我们的函数,称为for推导式,如下面的代码所示:
def averageAgeC(name1: String, name2: String, db: Map[String, Int]): Option[Double] =
for {
age1 <- db.get(name1)
age2 <- db.get(name2)
} yield (age1 + age2).toDouble / 2
当你编译一个for推导式,例如for { ... } yield { ... }时,Scala 编译器将其转换为flatMap/map操作的组合。以下是它是如何工作的:
-
在
for块内部,可以有一个或多个表达式以variable <- context的形式表达,这被称为生成器。箭头的左侧是绑定到箭头右侧上下文内容的变量的名称。 -
除了最后一个生成器之外,每个生成器都被转换为一个
flatMap表达式。 -
最后一个生成器被转换为一个
map表达式。 -
所有上下文表达式(箭头的右侧)必须具有相同的上下文类型。
在前面的例子中,我们使用了Option作为上下文类型,但for yield也可以与任何具有flatMap和map操作的类一起使用。例如,我们可以使用for..yield与Vector一起运行嵌套循环,如下面的代码所示:
for {
i <- Vector("one", "two")
j <- Vector(1, 2, 3)
} yield (i, j)
// res8: scala.collection.immutable.Vector[(String, Int)] =
// Vector((one,1), (one,2), (one,3), (two,1), (two,2), (two,3))
语法糖是编程语言中的语法,它使得阅读或编写更容易。它让程序员感到更甜蜜。
将退休计算器重构为使用 Option
现在我们知道了Option能为我们做什么,我们将重构我们在第二章,“开发退休计算器”中开发的退休计算器的一个函数,以改进对一些边缘情况的处理。如果你还没有做,请按照第二章,“开发退休计算器”开头的说明来设置项目。
在RetCalc.scala中,我们将更改nbMonthsSaving的返回类型。在第二章,“开发退休计算器”中,如果netIncome <= currentExpense,我们返回Int.MaxValue以避免无限循环。这并不十分健壮,因为这个无限的结果可能会被用于另一个计算,从而导致错误的结果。最好是返回Option[Int]来表示该函数可能不可计算,并让调用者决定如何处理。如果不可计算,我们将返回None,如果可计算,则返回Some(returnValue)。
以下代码是nbMonthsSaving的新实现,其中更改的部分以粗体突出显示:
def nbOfMonthsSaving(params: RetCalcParams,
returns: Returns): Option[Int] = {
import params._
@tailrec
def loop(months: Int): Int = {
val (capitalAtRetirement, capitalAfterDeath) =
simulatePlan(returns, params, months)
if (capitalAfterDeath > 0.0)
months
else
loop(months + 1)
}
if (netIncome > currentExpenses)
Some(loop(0))
else
None
}
现在,尝试编译项目。这个更改破坏了我们项目的许多部分,但 Scala 编译器是一个出色的助手。它将帮助我们识别需要更改的代码部分,以使我们的代码更加健壮。
第一个错误在RetCalcSpec.scala中,如下所示:
Error:(65, 14) types Option[Int] and Int do not adhere to the type constraint selected for the === and !== operators; the missing implicit parameter is of type org.scalactic.CanEqual[Option[Int],Int]
actual should ===(expected)
这个错误意味着在actual should === (expected)表达式中,类型不匹配:actual是Option[Int]0类型,而expected是Int类型。我们需要更改断言,如下所示:
actual should ===(Some(expected))
你可以将相同的修复应用于第二个单元测试。对于最后一个单元测试,我们希望断言返回None而不是Int.MaxValue,如下所示:
"not loop forever if I enter bad parameters" in {
val actual = RetCalc.nbOfMonthsSaving(params.copy(netIncome = 1000), FixedReturns(0.04))
actual should ===(None)
}
你现在可以编译并运行测试。它应该通过。
现在,你能够安全地模拟一个可选值。然而,有时并不总是很明显知道None实际上意味着什么。为什么这个函数返回None?是因为传递的参数错误吗?哪个参数错误?什么值才是正确的?确实很希望有一些解释与None一起出现,以便理解为什么没有值。在下一节中,我们将使用Either类型来实现这个目的。
使用Either
Either类型是一个 ADT,表示一个Left类型或Right类型的值。Either的一个简化定义如下:
sealed trait Either[A, B]
case class LeftA, B extends Either[A, B]
case class RightA, B extends Either[A, B]
当你实例化一个Right类型时,你需要提供一个B类型的值,当你实例化一个Left类型时,你需要提供一个A类型的值。因此,Either[A, B]可以持有A类型的值或B类型的值。
以下代码展示了你可以在一个新的 Scala 工作表中输入的此类用法示例:
def divide(x: Double, y: Double): Either[String, Double] =
if (y == 0)
Left(s"$x cannot be divided by zero")
else
Right(x / y)
divide(6, 3)
// res0: Either[String,Double] = Right(2.0)
divide(6, 0)
// res1: Either[String,Double] = Left(6.0 cannot be divided by zero)
divide函数返回字符串或双精度浮点数:
-
如果函数无法计算值,它将返回一个被
Left类型包裹的String错误。 -
如果函数可以计算正确的值,它将返回被
Right类型包裹的Double值。
按照惯例,我们使用Right来表示正确或右侧的值,而使用Left来表示错误。
操作Either
由于Either是一个 ADT,我们可以使用模式匹配来决定在得到Left或Right类型时该做什么。
下面的代码是我们在使用 Option部分中展示的personDescription函数的修改版本:
def getPersonAge(name: String, db: Map[String, Int]): Either[String, Int] =
db.get(name).toRight(s"$name is not present in db")
def personDescription(name: String, db: Map[String, Int]): String =
getPersonAge(name, db) match {
case Right(age) => s"$name is $age years old"
case Left(error) => error
}
val db = Map("John" -> 25, "Rob" -> 40)
personDescription("John", db)
// res4: String = John is 25 years old
personDescription("Michael", db)
// res5: String = Michael is not present in db
第一个getPersonAge函数如果name参数在db中存在,则产生Right(age)。如果name不在db中,它将返回一个被Left类型包裹的错误信息。为此,我们使用Option.toRight方法。我鼓励你查看该方法的文档和实现。
personDescription的实现很简单——我们使用getPersonAge的结果进行模式匹配,并根据结果是否为Left或Right类型返回适当的String。
与Option一样,我们也可以使用map和flatMap来组合多个Either实例,如下面的代码所示:
def averageAge(name1: String, name2: String, db: Map[String, Int]): Either[String, Double] =
getPersonAge(name1, db).flatMap(age1 =>
getPersonAge(name2, db).map(age2 =>
(age1 + age2).toDouble / 2))
averageAge("John", "Rob", db)
// res4: Either[String,Double] = Right(32.5)
averageAge("John", "Michael", db)
// res5: Either[String,Double] = Left(Michael is not present in db)
注意函数体几乎与Option相同。这是因为Either是右偏的,意味着map和flatMap将Either的右侧进行转换。
如果你想要转换Either的Left侧,你需要调用Either.left方法,如下面的代码所示:
getPersonAge("bob", db).left.map(err => s"The error was: $err")
// res6: scala.util.Either[String,Int] = Left(The error was: bob is not present in db)
由于Either实现了map和flatMap,我们可以重构averageAge以使用for推导式,如下面的代码所示:
def averageAge2(name1: String, name2: String, db: Map[String, Int]): Either[String, Double] =
for {
age1 <- getPersonAge(name1, db)
age2 <- getPersonAge(name2, db)
} yield (age1 + age2).toDouble / 2
再次,代码看起来和用Option时一样。
重构退休计算器以使用Either
现在我们已经很好地理解了如何操作Either,我们将重构我们的退休计算器以利用它。
重构nbOfMonthsSavings
在前面的部分中,我们将nbOfMonthsSavings的返回类型更改为返回Option[Int]。如果expenses参数大于income,函数返回None。我们现在将其更改为返回被Left包裹的错误信息。
我们可以使用一个简单的字符串作为错误信息,但使用Either时的最佳实践是为所有可能的错误信息创建一个 ADT。在src/main/scala/retcalc中创建一个新的 Scala 类RetCalcError,如下面的代码所示:
package retcalc
sealed abstract class RetCalcError(val message: String)
object RetCalcError {
case class MoreExpensesThanIncome(income: Double, expenses: Double)
extends RetCalcError(
s"Expenses: $expenses >= $income. You will never be able to save
enough to retire !")
}
我们定义一个只有message方法的RetCalcError特质。此方法将在我们需要将错误信息返回给用户时产生错误信息。在RetCalcError对象内部,我们为每种错误信息类型定义一个 case 类。然后我们将需要返回错误的函数更改为返回Either[RetCalcError, A]。
与仅使用String相比,这种模式具有许多优点,如下面的列表所示:
-
所有的错误消息都位于一个地方。这允许你立即知道所有可能的错误消息,这些错误消息可以返回给用户。如果你的应用程序使用多种语言,你也可以添加不同的翻译。
-
由于
RetCalcError是一个 ADT(抽象数据类型),你可以使用模式匹配从特定错误中恢复并采取行动。 -
它简化了测试。你可以测试一个函数是否返回特定类型的错误,而无需断言错误消息本身。这样,你可以在不更改任何测试的情况下更改错误消息。
现在,我们可以重新整理我们的RetCalc.nbOfMonthsSavings函数,使其返回Either[RetCalcError, Int],如下所示:
def nbOfMonthsSaving(params: RetCalcParams,
returns: Returns): Either[RetCalcError, Int] = {
import params._
@tailrec
def loop(months: Int): Int = {
val (capitalAtRetirement, capitalAfterDeath) =
simulatePlan(returns, params, months)
if (capitalAfterDeath > 0.0)
months
else
loop(months + 1)
}
if (netIncome > currentExpenses)
Right(loop(0))
else
Left(MoreExpensesThanIncome(netIncome, currentExpenses))
}
我们还必须更改相应的单元测试。ScalaTest 提供了对Either类型进行断言的便利扩展。为了将它们引入作用域,在RetCalcSpec.scala中扩展EitherValues,如下所示:
class RetCalcSpec extends WordSpec with Matchers with TypeCheckedTripleEquals
with EitherValues {
如果你有一个myEither变量,其类型为Either[A, B],那么EitherValues将允许我们使用以下方法:
-
myEither.left.value返回类型为A的左值,或者如果myEither是Right则测试失败 -
myEither.right.value返回类型为B的右值,或者如果myEither是Left则测试失败
现在我们可以更改nbOfMonthsSaving的单元测试,如下所示:
"RetCalc.nbOfMonthsSaving" should {
"calculate how long I need to save before I can retire" in {
val actual = RetCalc.nbOfMonthsSaving(params,
FixedReturns(0.04)).right.value
val expected = 23 * 12 + 1
actual should ===(expected)
}
"not crash if the resulting nbOfMonths is very high" in {
val actual = RetCalc.nbOfMonthsSaving(
params = RetCalcParams(
nbOfMonthsInRetirement = 40 * 12,
netIncome = 3000, currentExpenses = 2999, initialCapital = 0),
returns = FixedReturns(0.01)).right.value
val expected = 8280
actual should ===(expected)
}
"not loop forever if I enter bad parameters" in {
val actual = RetCalc.nbOfMonthsSaving(
params.copy(netIncome = 1000), FixedReturns(0.04)).left.value
actual should ===(RetCalcError.MoreExpensesThanIncome(1000, 2000))
}
}
运行单元测试。它应该通过。
重新整理 monthlyRate
在第二章,“开发退休计算器”中,我们实现了一个Returns.monthlyRate(returns: Returns, month: Int): Double函数,它返回给定月份的月收益率。当我们用超过VariableReturns实例大小的月份调用它时,我们使用模运算滚动到第一个月份。
这并不完全令人满意,因为它可以计算不切实际的模拟。假设你的VariableReturns实例包含从 1950 年到 2017 年的数据。当你要求 2018 年的月收益率时,monthlyRate会给你 1950 年的收益率。与当前的经济前景相比,五十年代的经济前景非常不同,而且 2018 年的收益率不太可能反映 1950 年的收益率。
因此,我们将更改monthlyRate,使其在month参数超出VariableReturn的范围时返回错误。首先,打开RetCalcError.scala并添加以下错误类型:
case class ReturnMonthOutOfBounds(month: Int, maximum: Int) extends RetCalcError(
s"Cannot get the return for month $month. Accepted range: 0 to $maximum")
接下来,我们将更改单元测试以指定我们期望它返回的函数。打开ReturnsSpec.scala并按以下方式更改测试:
"Returns.monthlyReturn" should {
"return a fixed rate for a FixedReturn" in {
Returns.monthlyRate(FixedReturns(0.04), 0).right.value should ===
(0.04 / 12)
Returns.monthlyRate(FixedReturns(0.04), 10).right.value should ===
(0.04 / 12)
}
val variableReturns = VariableReturns(
Vector(VariableReturn("2000.01", 0.1), VariableReturn("2000.02",
0.2)))
"return the nth rate for VariableReturn" in {
Returns.monthlyRate(variableReturns, 0).right.value should ===(0.1)
Returns.monthlyRate(variableReturns, 1).right.value should ===(0.2)
}
"return None if n > length" in {
Returns.monthlyRate(variableReturns, 2).left.value should ===(
RetCalcError.ReturnMonthOutOfBounds(2, 1))
Returns.monthlyRate(variableReturns, 3).left.value should ===(
RetCalcError.ReturnMonthOutOfBounds(3, 1))
}
"return the n+offset th rate for OffsetReturn" in {
val returns = OffsetReturns(variableReturns, 1)
Returns.monthlyRate(returns, 0).right.value should ===(0.2)
}
}
然后,打开Returns.scala并按以下方式更改monthlyRate:
def monthlyRate(returns: Returns, month: Int): Either[RetCalcError, Double] = returns match {
case FixedReturns(r) => Right(r / 12)
case VariableReturns(rs) =>
if (rs.isDefinedAt(month))
Right(rs(month).monthlyRate)
else
Left(RetCalcError.ReturnMonthOutOfBounds(month, rs.size - 1))
case OffsetReturns(rs, offset) => monthlyRate(rs, month + offset)
}
现在尝试编译项目。由于monthlyRate被其他函数调用,我们将得到一些编译错误,这实际上是一件好事。我们只需修复编译错误,使我们的代码能够处理错误的可能性。每个修复都需要思考如何处理这种可能性。
另一方面,如果我们抛出异常而不是返回Either,那么一切都会编译,但每当月份超出范围时程序都会崩溃。要实现所需的行为会更困难,因为编译器不会帮助我们。
第一个编译错误在RetCalc.scala中的futureCapital,如下所示代码:
Error:(55, 26) overloaded method value + with alternatives:
(...)
cannot be applied to (Either[retcalc.RetCalcError,Double])
accumulated * (1 + Returns.monthlyRate(returns, month)) +
monthlySavings
这意味着我们不能在Either[RetCalcError, Double]上调用+方法。如果monthlyRate返回Left,我们无法计算累积资本。最好的做法是在这里停止并返回错误。为此,我们需要将futureCapital的返回类型也改为Either[RetCalcError, Double]。
以下是该函数的修正版本:
def futureCapital(returns: Returns, nbOfMonths: Int, netIncome: Int, currentExpenses: Int,
initialCapital: Double): Either[RetCalcError, Double] = {
val monthlySavings = netIncome - currentExpenses
(0 until nbOfMonths).foldLeft[Either[RetCalcError, Double]] (Right(initialCapital)) {
case (accumulated, month) =>
for {
acc <- accumulated
monthlyRate <- Returns.monthlyRate(returns, month)
} yield acc * (1 + monthlyRate) + monthlySavings
}
}
在第二行,我们更改了传递给foldLeft的初始元素。我们现在正在累积Either[RetCalcError, Double]。请注意,我们必须显式指定foldLeft的类型参数。在函数的先前版本中,当我们使用Double时,该类型是自动推断的。
如果我们不指定类型参数,编译器将推断它为初始元素的类型。在我们的情况下,Right(initialCapital)是Right[Nothing, Double]类型,它是Either[RetCalcError, Double]的子类。问题在于,在匿名函数内部,我们返回Either[RetCalcError, Double],而不是Right[Nothing, Double]。编译器会抱怨类型不匹配。
在传递给foldLeft的匿名函数内部,我们使用一个for循环来完成以下操作:
-
如果
acc和monthlyRate都是Right,则在Right中返回累积的结果 -
如果
acc或monthlyRate是Left,则返回Left
注意,我们的实现不会在monthlyRate返回Left时立即停止,这有点低效。当我们得到错误时,没有必要遍历其他月份,因为这个函数应该始终返回它遇到的第一个错误。在第四章,高级特性中,我们将看到如何使用foldr进行懒计算以提前停止迭代。
再次编译项目。现在我们需要修复simulatePlan中的编译错误。
重构simulatePlan
由于simulatePlan调用了futureCapital,我们需要更改其实现以考虑新的返回类型,如下所示代码:
def simulatePlan(returns: Returns, params: RetCalcParams, nbOfMonthsSavings: Int,
monthOffset: Int = 0): Either[RetCalcError, (Double,
Double)] = {
import params._
for {
capitalAtRetirement <- futureCapital(
returns = OffsetReturns(returns, monthOffset),
nbOfMonths = nbOfMonthsSavings, netIncome = netIncome,
currentExpenses = currentExpenses,
initialCapital = initialCapital)
capitalAfterDeath <- futureCapital(
returns = OffsetReturns(returns, monthOffset +
nbOfMonthsSavings),
nbOfMonths = nbOfMonthsInRetirement,
netIncome = 0, currentExpenses = currentExpenses,
initialCapital = capitalAtRetirement)
} yield (capitalAtRetirement, capitalAfterDeath)
}
我们将两个对futureCapital的调用移到了一个for循环中。这样,如果这些调用中的任何一个返回错误,simulatePlan将返回它。如果两个调用都成功,simulatePlan将返回一个包含两个双精度值的元组。
编译项目。现在我们需要修复nbOfMonthsSaving中的编译错误,它使用了simulatePlan。以下代码是修复后的版本:
def nbOfMonthsSaving(params: RetCalcParams, returns: Returns): Either[RetCalcError, Int] = {
import params._
@tailrec
def loop(months: Int): Either[RetCalcError, Int] = {
simulatePlan(returns, params, months) match {
case Right((capitalAtRetirement, capitalAfterDeath)) =>
if (capitalAfterDeath > 0.0)
Right(months)
else
loop(months + 1)
case Left(err) => Left(err)
}
}
if (netIncome > currentExpenses)
loop(0)
else
Left(MoreExpensesThanIncome(netIncome, currentExpenses))
}
我们不得不将我们的递归 loop 函数更改为返回 Either[RetCalcError, Int]。循环将在我们得到错误或 if (capitalAfterDeath > 0.0) 时停止。你可能想知道为什么我们没有使用 flatMap 而不是使用模式匹配。这确实会更简洁,但 loop 函数将不再尾递归,因为对循环的递归调用将位于匿名函数内部。作为一个练习,我鼓励你尝试更改代码以使用 flatMap 并观察尾递归编译错误。
编译项目。生产代码中的最后一个编译错误在 SimulatePlanApp.scala。
重构 SimulatePlanApp
我们 SimulatePlanApp 应用程序的入口点调用 simulatePlan。我们需要将其更改为返回可能发生的任何错误的文本。
首先,我们需要更改集成测试以添加一个新的测试用例。打开 SimulatePlanIT.scala 并添加以下测试用例:
"SimulatePlanApp.strMain" should {
"simulate a retirement plan using market returns" in {...}
"return an error when the period exceeds the returns bounds" in {
val actualResult = SimulatePlanApp.strMain(
Array("1952.09,2017.09", "25", "60", "3000", "2000", "10000"))
val expectedResult = "Cannot get the return for month 780\.
Accepted range: 0 to 779"
actualResult should === (expectedResult)
}
}
然后,打开 SimulatePlanApp.scala 并按如下方式更改 SimulatePlanApp 的实现:
object SimulatePlanApp extends App {
println(strMain(args))
def strMain(args: Array[String]): String = {
val (from +: until +: Nil) = args(0).split(",").toList
val nbOfYearsSaving = args(1).toInt
val nbOfYearsRetired = args(2).toInt
val allReturns = Returns.fromEquityAndInflationData(
equities = EquityData.fromResource("sp500.tsv"),
inflations = InflationData.fromResource("cpi.tsv"))
RetCalc.simulatePlan(
returns = allReturns.fromUntil(from, until),
params = RetCalcParams(
nbOfMonthsInRetirement = nbOfYearsRetired * 12,
netIncome = args(3).toInt,
currentExpenses = args(4).toInt,
initialCapital = args(5).toInt),
nbOfMonthsSavings = nbOfYearsSaving * 12
) match {
case Right((capitalAtRetirement, capitalAfterDeath)) =>
s"""
|Capital after $nbOfYearsSaving years of savings:
${capitalAtRetirement.round}
|Capital after $nbOfYearsRetired years in retirement:
${capitalAfterDeath.round}
""".stripMargin
case Left(err) => err.message
}
}
}
我们只需对 simulatePlan 的结果进行模式匹配,如果结果是 Right 值,则返回解释计算结果的字符串;如果是 Left 值,则返回错误信息。
编译项目。现在所有生产代码都应该可以编译,但在单元测试中仍然有几个编译错误。作为一个练习,我鼓励您尝试修复它们。在大多数情况下,您必须使测试扩展 EitherValues,并在 Either 类上调用 .right.value 以获取其右侧值。一旦修复了剩余的错误,编译并运行项目的所有测试。它们都应该通过。
现在您的代码应该看起来像 Scala 基础 GitHub 项目中的 Chapter03 分支,除了我们将改进的 SimulatePlanApp 类。有关更多详细信息,请参阅 github.com/PacktPublishing/Scala-Programming-Projects。
使用 ValidatedNel
在本章中,我们看到了如何使用 Option 模型可选值的可能性,以及使用 Either 模型错误的可能性。我们展示了这些类型如何替换异常,同时保证引用透明性。
我们还看到了如何使用 flatMap 组合几个 Option 或 Either 类型。当我们需要按顺序检查可选值或错误时,这效果很好——调用 function1;如果没有错误,则调用 function2;如果没有错误,则调用 function3。如果这些函数中的任何一个返回错误,我们将返回该错误并停止调用链,如下面的代码所示:
def sequentialErrorHandling(x: String): Either[MyError, String] =
for {
a <- function1(x)
b <- function2(a)
c <- function3(b)
} yield c
然而,在某些情况下,我们可能希望并行调用多个函数并返回可能发生的所有错误。例如,当你输入一些个人详细信息以从在线商店购买产品时,你期望网站在你提交详细信息后突出显示所有字段的错误。在提交详细信息后告诉你说姓氏是必填项,然后在你再次提交详细信息后说你的密码太短,这将是一个糟糕的用户体验。所有字段必须同时验证,并且所有错误必须一次性返回给用户。
可以帮助我们解决此用例的数据结构是 Validated。不幸的是,它不是 Scala SDK 的一部分,我们必须使用一个名为 cats 的外部库将其引入我们的项目中。
添加 cats 依赖项
cats 库提供了函数式编程的抽象。其名称来自短语 category theory 的缩写。它也是对著名笑话的引用,即管理开发者就像放养猫——事实是,你实际上并没有控制权——猫做它们想做的事情。
在本章中,我们将只关注 Validated 和 NonEmptyList,但 cats 提供了许多更强大的抽象,我们将在本书的后续部分中探讨。
首先,编辑 built.sbt 并添加以下行:
libraryDependencies += "org.typelevel" %% "cats-core" % "1.0.1"
scalacOptions += "-Ypartial-unification"
这将 cats 依赖项引入我们的项目,并启用了一个库所需的编译器标志(partial unification),以便正确推断类型。
使用 Ctrl + S 保存项目。IntelliJ 应该会提供更新项目以反映 build 文件中更改的选项。在 built.sbt 顶部单击刷新项目。
介绍 NonEmptyList
如其名所示,cats.data.NonEmptyList 类型代表一个至少包含一个元素的 List 实例。换句话说,它是一个不能为空的 List 实例。以下是一些你可以在新 Scala 工作表中重新输入的此用法示例:
import cats.data.NonEmptyList
NonEmptyList(1, List(2, 3))
// res0: cats.data.NonEmptyList[Int] = NonEmptyList(1, 2, 3)
NonEmptyList.fromList(List(1, 2, 3))
// res3: Option[cats.data.NonEmptyList[Int]] = Some(NonEmptyList(1, 2, 3))
NonEmptyList.fromList(List.empty[Int])
// res4: Option[cats.data.NonEmptyList[Int]] = None
val nel = NonEmptyList.of(1, 2, 3)
// nel: cats.data.NonEmptyList[Int] = NonEmptyList(1, 2, 3)
nel.head
// res0: Int = 1
nel.tail
// res1: List[Int] = List(2, 3)
nel.map(_ + 1)
// res2: cats.data.NonEmptyList[Int] = NonEmptyList(2, 3, 4)
你可以使用以下方式构造 NonEmptyList:
-
apply[A]:你可以传递一个head元素和一个作为尾部的List。 -
fromList[A]:你可以传递一个List。你将得到一个Option[NonEmptyList[A]],如果List参数为空,则它将是None。 -
of[A]:你可以传递一个head元素和一个可变长度的List参数作为尾部。这是当你知道其组成部分时构建NonEmptyList最方便的方式。
由于 NonEmptyList 总是包含至少一个元素,因此我们可以始终调用 head 方法而不会冒着抛出异常的风险。因此,没有 headOption 方法。你可以使用所有在 List 上使用的常规方法来操作 NonEmptyList:map、tail、flatMap、filter 和 foldLeft 等。
介绍 Validated
cats.data.Validated[E, A]类型与Either[E, A]非常相似。它是一个 ADT,表示一个Invalid类型或Valid类型的值。简化的定义如下:
sealed trait Validated[+E, +A]
case class Valid+A extends Validated[Nothing, A]
case class Invalid+E extends Validated[E, Nothing]
我们将在第四章的协变和逆变部分看到类型参数前面+符号的含义,高级特性。不过,现在不必担心它。
与Option的定义类似,定义使用了逆变和Nothing。这样,对于任何E,Valid[A]是Validated[E, A]的子类型;对于任何A,Invalid[E]是Validated[E, A]的子类型。
与Either的主要区别是我们可以累积由多个Validated实例产生的错误。以下是一些你可以在新 Scala 工作表中重新输入的示例。我建议你取消勾选 IntelliJ 右下角的“类型感知高亮”框;否则,IntelliJ 会用红色下划线标记一些表达式,尽管它们可以正常编译:
import cats.data._
import cats.data.Validated._
import cats.implicits._
val valid1: Validated[NonEmptyList[String], Int] = Valid(1)
// valid1: cats.data.Validated[cats.data.NonEmptyList[String],Int] = Valid(1)
val valid2 = 2.validNel[String]
// valid2: cats.data.ValidatedNel[String,Int] = Valid(2)
(valid1, valid2).mapN { case (i1, i2) => i1 + i2 }
// res1: cats.data.ValidatedNel[String,Int] = Valid(3)
val invalid3: ValidatedNel[String, Int] = Invalid(NonEmptyList.of("error"))
val invalid4 = "another error".invalidNel[Int]
(valid1, valid2, invalid3, invalid4).mapN { case (i1, i2, i3, i4) => i1 + i2 + i3 + i4 }
// res2: cats.data.ValidatedNel[String,Int] = Invalid(NonEmptyList(error, another error))
我们首先定义一个值为1的Valid值,具有Int类型的Valid参数和NonEmptyList[String]类型的Invalid参数。每个错误都将是一个String类型,NonEmptyList实例将强制我们在产生Invalid值时至少有一个错误。这种用法非常常见,因此cats在cats.data包中提供了一个类型别名ValidatedNel,如下面的代码所示:
type ValidatedNel[+E, +A] = Validated[NonEmptyList[E], A]
回到我们的例子,在第二行,我们使用一个方便的cats方法.validNel定义了一个值为2的Valid值。在调用validNel时,我们必须传递错误类型,因为在这种情况下,编译器没有任何信息可以推断它。在我们的情况下,错误类型是String。valid2的结果类型是ValidatedNel[String, Int],它是Validated[NonEmptyList[String], Int]的别名。
在第三行,我们通过将两个有效值放入一个元组中并调用mapN来组合这两个有效值。mapN短语接受一个f函数,该函数接受与元组中元素数量相同的参数。如果元组的所有元素都是Valid值,则调用f,其结果将被包裹在一个Valid值中。如果元组内部的任何元素是Invalid值,则所有Invalid值将被合并在一起并包裹在一个Invalid值中。
我们可以观察到,当我们组合valid1和valid2(它们都是Valid)时,mapN返回一个Valid值。当我们组合valid1、valid2、invalid3和invalid4时,mapN返回一个Invalid值。这个Invalid值包裹了一个包含invalid3和invalid4错误的NonEmptyList。
我们现在知道了两种表示失败可能性的机制:
-
使用
for...yield的Either可以用于顺序验证,在遇到第一个错误时停止。 -
Validated与mapN可以用于并行验证,将所有错误累积在NonEmptyList中。
重构退休计算器以使用 ValidatedNel
带着这些新知识,我们已准备好进一步改进我们的退休计算器。我们将改进SimulatePlanApp,以便在程序传递给用户的参数中有一个或多个错误时提供更多信息。
当许多参数错误时,例如,如果用户传递了一些随机文本而不是可解析的数字,我们希望为每个错误参数报告一个错误。
添加单元测试
首先,我们需要更改与SimulatePlanApp相关的测试。打开SimulatePlanAppIT.scala并按照以下内容更改内容:
package retcalc
import cats.data.Validated.{Invalid, Valid}
import org.scalactic.TypeCheckedTripleEquals
import org.scalatest.{Matchers, WordSpec}
class SimulatePlanAppIT extends WordSpec with Matchers with TypeCheckedTripleEquals {
"SimulatePlanApp.strMain" should {
"simulate a retirement plan using market returns" in {
val actualResult = SimulatePlanApp.strMain(
Array("1952.09,2017.09", "25", "40", "3000", "2000", "10000"))
val expectedResult =
s"""
|Capital after 25 years of savings: 468925
|Capital after 40 years in retirement: 2958842
|""".stripMargin
actualResult should ===(Valid(expectedResult))
}
"return an error when the period exceeds the returns bounds" in {
val actualResult = SimulatePlanApp.strMain(
Array("1952.09,2017.09", "25", "60", "3000", "2000", "10000"))
val expectedResult = "Cannot get the return for month 780\.
Accepted range: 0 to 779"
actualResult should ===(Invalid(expectedResult))
}
"return an usage example when the number of arguments is incorrect"
in {
val result = SimulatePlanApp.strMain(
Array("1952.09:2017.09", "25.0", "60", "3'000", "2000.0"))
result should ===(Invalid(
"""Usage:
|simulatePlan from,until nbOfYearsSaving nbOfYearsRetired
netIncome currentExpenses initialCapital
|
|Example:
|simulatePlan 1952.09,2017.09 25 40 3000 2000 10000
|""".stripMargin))
}
"return several errors when several arguments are invalid" in {
val result = SimulatePlanApp.strMain(
Array("1952.09:2017.09", "25.0", "60", "3'000", "2000.0",
"10000"))
result should ===(Invalid(
"""Invalid format for fromUntil. Expected: from,until, actual:
1952.09:2017.09
|Invalid number for nbOfYearsSaving: 25.0
|Invalid number for netIncome: 3'000
|Invalid number for currentExpenses: 2000.0""".stripMargin))
}
}
}
让我们详细看看前面的代码:
-
前两个测试变化不大——我们只是将期望更改为
Valid(expectedResult)。我们将改变SimulatePlanApp.strMain的返回类型——而不是返回一个字符串,我们将将其更改为返回Validated[String, String]。我们期望strMain在所有参数正确的情况下返回一个包含结果的Valid值。如果某些参数不正确,它应该返回一个包含String的Invalid值,解释哪些参数不正确。 -
第三个测试是一个新测试。如果我们没有传递正确的参数数量,我们期望
strMain返回一个包含使用示例的Invalid值。 -
第四个测试检查每个错误参数都会报告一个错误。
实现解析函数
下一步是添加新的错误类型,当某些参数错误时,这些错误将在ValidateNel中返回。我们需要按照以下方式更改RetCalcError.scala:
object RetCalcError {
type RetCalcResult[A] = ValidatedNel[RetCalcError, A]
case class MoreExpensesThanIncome(income: Double, expenses: Double)
extends RetCalcError(...)
case class ReturnMonthOutOfBounds(month: Int, maximum: Int) extends
RetCalcError(...)
case class InvalidNumber(name: String, value: String) extends
RetCalcError(
s"Invalid number for $name: $value")
case class InvalidArgument(name: String,
value: String,
expectedFormat: String) extends
RetCalcError(
s"Invalid format for $name. Expected: $expectedFormat, actual:
$value")
}
在这里,我们引入了一个InvalidNumber错误,当字符串无法解析为数字时将返回。另一个错误InvalidArgument将在参数错误时返回。我们将使用它来处理from和until参数错误(参见前面的单元测试)。此外,由于我们将使用许多类型的ValidatedNel[RetCalcError, A]形式,我们创建了一个类型别名RetCalcResult。它还将帮助 IntelliJ 自动完成cats库的函数。
之后,我们需要更改SimulatePlanApp.strMain以验证参数。为此,我们首先编写一个小的函数,该函数解析一个字符串参数以生成Validated Int。
理想情况下,以下所有解析函数都应该进行单元测试。我们确实在SimulatePlanAppIT中为它们提供了间接的测试覆盖率,但这并不充分。在测试驱动开发中,每次你需要编写一个新函数时,你应该先定义其签名,然后在其实现之前编写一个测试。不幸的是,这本书中没有足够的空间来展示你期望在生产应用程序中拥有的所有单元测试。然而,作为一个练习,我鼓励你编写它们。
我们称这个函数为parseInt。它接受一个参数的名称和其值,并返回Validated Int,如下面的代码所示:
def parseInt(name: String, value: String): RetCalcResult[Int] =
Validated
.catchOnlyNumberFormatException
.leftMap(_ => NonEmptyList.of(InvalidNumber(name, value)))
我们首先调用 Validated.catchOnly 方法,该方法执行一个代码块(在我们的情况下,value.toInt)并捕获特定类型的异常。如果代码块没有抛出任何异常,catchOnly 返回一个包含结果的 Valid 值。如果代码块抛出了作为参数传递的异常类型(在我们的情况下,NumberFormatException),则 catchOnly 返回一个包含捕获的异常的 Invalid 值。结果表达式类型为 Validated[NumberFormatException, Int]。然而,我们的 parseInt 函数必须返回 RetCalcResut[Int],它是 ValidatedNel[RetCalcError, Int] 的别名。为了转换错误或左类型,我们调用 Validated.leftMap 方法来生成 NonEmptyList[RetCalcError]。
然后,我们编写另一个函数 parseFromUntil——该函数负责解析 from 和 until 参数。这两个参数由逗号分隔,如下所示:
import cats.implicits._
def parseFromUntil(fromUntil: String): RetCalcResult[(String, String)] = {
val array = fromUntil.split(",")
if (array.length != 2)
InvalidArgument(
name = "fromUntil", value = fromUntil,
expectedFormat = "from,until"
).invalidNel
else
(array(0), array(1)).validNel
}
我们使用 String.split 方法创建一个 Array[String]。如果数组不恰好有两个元素,我们返回一个包含 InvalidArgument 错误的 Invalid 值。如果数组有两个元素,则将它们作为 Valid 值中的元组返回。
最后,我们编写一个 parseParams 函数,该函数接受一个参数数组并生成 RetCalcResult[RetCalcParams]。RetCalcParams 参数是 RetCalc.simulatePlan 所需的参数之一,如下所示:
def parseParams(args: Array[String]): RetCalcResult[RetCalcParams] =
(
parseInt("nbOfYearsRetired", args(2)),
parseInt("netIncome", args(3)),
parseInt("currentExpenses", args(4)),
parseInt("initialCapital", args(5))
).mapN { case (nbOfYearsRetired, netIncome, currentExpenses,
initialCapital) =>
RetCalcParams(
nbOfMonthsInRetirement = nbOfYearsRetired * 12,
netIncome = netIncome,
currentExpenses = currentExpenses,
initialCapital = initialCapital)
}
该函数假设 args 数组至少有六个元素。我们创建一个包含四个元素的元组,每个元素都是 parseInt 的结果,因此它具有 RetCalcResult[Int] 类型。然后,我们在 Tuple4 上调用 mapN 方法,这将累积由 parseInt 调用产生的任何错误。如果所有的 parseInt 调用都返回一个 Valid 值,则调用传递给 mapN 的匿名函数。它接受 Tuple4 (Int, Int, Int, Int) 并返回一个 RetCalcParams 实例。
实现 SimulatePlanApp.strSimulatePlan
为了保持 SimulatePlanApp.strMain 代码小且易于阅读,我们打算提取负责调用 RetCalc.simulatePlan 并返回一个详细描述模拟结果的易读字符串的代码。我们称这个新函数为 strSimulatePlan,并在以下代码中展示其用法:
def strSimulatePlan(returns: Returns, nbOfYearsSaving: Int, params: RetCalcParams)
: RetCalcResult[String] = {
RetCalc.simulatePlan(
returns = returns,
params = params,
nbOfMonthsSavings = nbOfYearsSaving * 12
).map {
case (capitalAtRetirement, capitalAfterDeath) =>
val nbOfYearsInRetirement = params.nbOfMonthsInRetirement / 12
s"""
|Capital after $nbOfYearsSaving years of savings:
${capitalAtRetirement.round}
|Capital after $nbOfYearsInRetirement years in retirement:
${capitalAfterDeath.round}
|""".stripMargin
}.toValidatedNel
}
该函数接受解析后的参数,调用 simulatePlan,并将结果转换为字符串。为了保持与我们的解析函数相同的类型,我们声明函数的返回类型为 RetCalcResult[String]。这是 ValidatedNel[RetCalcError, String] 的别名,但 simulatePlan 返回 Either[RetCalcError, String]。幸运的是,cats 提供了 .toValidatedNel 方法,可以轻松地将 Either 转换为 ValidatedNel。
重构 SimulatePlanApp.strMain
我们实现了一些用于解析整个参数数组的构建块。现在是时候重构 SimulatePlanApp.strMain 以调用它们了。首先,我们需要检查参数数组的大小是否正确,如下面的代码所示:
def strMain(args: Array[String]): Validated[String, String] = {
if (args.length != 6)
"""Usage:
|simulatePlan from,until nbOfYearsSaving nbOfYearsRetired
netIncome currentExpenses initialCapital
|
|Example:
|simulatePlan 1952.09,2017.09 25 40 3000 2000 10000
|""".stripMargin.invalid
else {
val allReturns = Returns.fromEquityAndInflationData(
equities = EquityData.fromResource("sp500.tsv"),
inflations = InflationData.fromResource("cpi.tsv"))
val vFromUntil = parseFromUntil(args(0))
val vNbOfYearsSaving = parseInt("nbOfYearsSaving", args(1))
val vParams = parseParams(args)
(vFromUntil, vNbOfYearsSaving, vParams)
.tupled
.andThen { case ((from, until), nbOfYearsSaving, params) =>
strSimulatePlan(allReturns.fromUntil(from, until),
nbOfYearsSaving, params)
}
.leftMap(nel => nel.map(_.message).toList.mkString("\n"))
}
为了匹配我们在 SimulatePlanAppIT 集成测试中提出的断言,我们将签名更改为返回 Validated[String, String]。如果参数数组的大小不正确,我们返回一个 Invalid 值,其中包含解释我们程序正确用法的字符串。否则,当参数数组的大小正确时,我们首先声明 allReturns 变量,就像之前一样。
然后,我们调用我们之前实现的三个解析函数,并将它们分配给 vFromUntil、vNbOfYearsSaving 和 vParams。它们的类型分别是 RetCalcResult[(String, String)]、RetCalcResult[Int] 和 RetCalcResult[RetCalcParams]。之后,我们将这三个值放入 Tuple3 中,并调用 cats 的 tupled 函数,该函数将元组的三个元素组合起来产生 RetCalcResult[((String, String), Int, RetCalcParams)]。
到目前为止,我们有一个 ValidatedNel 实例,它包含调用我们之前实现的 strSimulatePlan 函数所需的所有参数。在这种情况下,我们需要按顺序检查错误——首先,我们验证所有参数,然后 调用 strSimulatePlan。如果我们使用了 Either 而不是 ValidatedNel,我们会使用 flatMap 来做这件事。幸运的是,ValidatedNel 提供了一个等效的方法,形式为 andThen。
与 Option 和 Either 不同,ValidatedNel 的实例没有 flatMap 方法,因为它不是一个 monad,而是一个 applicative functor。我们将在 第四章,高级特性 中解释这些术语的含义。如果你想按顺序运行验证,你需要使用 andThen 或将其转换为 Either 并使用 flatMap。
在调用 .leftMap 之前,我们有一个 RetCaclResult[String] 类型的表达式,它是 Validated[NonEmptyList[RetCalcError], String] 的别名。然而,我们的函数必须返回 Validated[String, String]。因此,我们使用传递给 .leftMap 的匿名函数将左边的 NonEmptyList[RetCalcError] 类型转换为字符串。
摘要
在本章中,我们看到了如何处理可选值以及如何以纯函数方式处理错误。你现在更有能力编写更安全的程序,这些程序不会抛出异常并意外崩溃。
如果你使用 Java 库或某些非纯函数式 Scala 库,你会注意到它们可以抛出异常。如果你不希望程序在抛出异常时崩溃,我建议你尽早将它们包装在 Either 或 Validated 中。
我们看到了如何使用Either来按顺序处理错误,以及如何使用Validated来并行处理错误。由于这两个类型非常相似,我建议你大多数时候使用Validated。Validated的实例确实可以使用mapN并行处理错误,但它们也可以使用andThen进行顺序验证。
本章在以函数式方式编写程序方面又前进了一步。在下一章中,我们将探索你将在典型的 Scala 项目中必然会遇到的其他语言特性:惰性、协变和逆变,以及隐式。
问题
这里有一些问题来测试你的知识:
-
你可以使用哪种类型来表示可选值?
-
你可以使用哪些类型来表示错误的可能性?
-
什么是引用透明性?
-
抛出异常是良好的实践吗?
这里有一些练习:
-
为
SimulatePlanApp编写单元测试 -
在
RetCalc.scala中使用RetCalcResult代替Either[RetCalcError, X] -
将
VariableReturns.fromUntil修改为在monthIdFrom或monthIdUntil在返回的Vector中找不到时返回错误
进一步阅读
在以下链接中,cats文档关于Either和Validated提供了其他使用示例,以及它们各自主题的更多详细信息:
第四章:高级特性
在本章中,我们将探讨 Scala 的更高级特性。与任何编程语言一样,一些高级构造在实际应用中可能很少使用,或者可能会使代码变得难以理解。
我们将致力于解释我们在实际项目中遇到并部署到生产环境中的特性。有些特性在库或 SDK 中使用得更多,但在典型项目中使用得较少,但理解它们对于有效地使用库来说很重要。
由于这些特性种类繁多,覆盖范围广泛,我们发现使用专门的代码示例来解释它们比使用完整的项目更容易。因此,如果你已经熟悉这些概念中的某些,可以直接跳转到本章的任何部分。
在本章中,我们将涵盖以下主题:
-
严格性和懒性,以及它们对性能的影响
-
协变性和逆变
-
柯里化和部分应用函数
-
隐式使用
项目设置
本章的所有示例都已提交到以下 Git 仓库:
如果你想要运行本章中的代码示例,你需要克隆这个仓库并将项目导入 IntelliJ。每个部分都有一个相应的 Scala 工作表文件——例如,下一节的示例在lazyness.sc文件中。
练习的答案已经包含在这些工作表中,因此在你尝试做练习之前完全阅读它们对你来说可能更有利。
严格性和懒性
Scala 的默认评估策略是严格的。这意味着如果你不做任何特殊处理,任何变量声明或函数调用中的参数都会立即被评估。严格评估策略的反面是懒评估策略,这意味着只有在需要时才会进行评估。
Strict val
以下是一个严格的变量声明:
class StrictDemo {
val strictVal = {
println("Evaluating strictVal")
"Hello"
}
}
val strictDemo = new StrictDemo
//Evaluating strictVal
//strictDemo: StrictDemo = StrictDemo@32fac009
我们可以看到println被立即调用。这意味着赋值右侧的代码块在StrictDemo类被实例化时立即被评估。如果我们想延迟代码块的评估,我们必须使用lazy前缀。
lazy val
当我们在val或var前使用lazy前缀(如下面的代码所示)时,它只会在需要时进行评估:
class LazyDemo {
lazy val lazyVal = {
println("Evaluating lazyVal")
"Hello"
}
}
val lazyDemo = new LazyDemo
//lazyDemo: LazyDemo = LazyDemo@13ca84d5
当我们实例化类时,赋值右侧的代码块不会被评估。它只在我们使用变量时才会被评估,如下面的代码所示:
lazyDemo.lazyVal + " World"
//Evaluating lazyVal
//res0: String = Hello World
这种机制允许你延迟计算密集型操作的评估。例如,你可以用它来快速启动应用程序,并且只有在第一个用户需要时才运行初始化代码。Scala 保证即使有多个线程同时尝试使用该变量,评估也只会执行一次。
你可以有lazy值的链,这些值只有在链的最后一个元素需要时才会被评估,如下面的代码所示:
class LazyChain {
lazy val val1 = {
println("Evaluating val1")
"Hello"
}
lazy val val2 = {
println("Evaluating val2")
val1 + " lazy"
}
lazy val val3 = {
println("Evaluating val3")
val2 + " chain"
}
}
val lazyChain = new LazyChain
// lazyChain: LazyChain = LazyChain@4ca51fa
当我们要求val3时,三个值将被评估,如下面的代码所示:
lazyChain.val3
// Evaluating val3
// Evaluating val2
// Evaluating val1
// res1: String = Hello lazy chain
命名参数
我们可以更进一步,延迟函数参数的评估。命名参数就像一个不接受任何参数的函数。这样,它只有在函数体需要它时才会被评估。
假设你有一个应用程序,它从文件或数据库中加载其配置。你希望应用程序尽可能快地启动,因此你决定使用lazy val按需加载问候消息,如下面的代码所示:
object AppConfig {
lazy val greeting: String = {
println("Loading greeting")
"Hello "
}
}
注意,为了简洁起见,我们在这里实际上没有加载任何内容——只是想象我们已经做了。
如果我们想在函数中使用问候变量但保持延迟其评估,我们可以使用命名参数:
def greet(name: String, greeting: => String): String = {
if (name == "Mikael")
greeting + name
else
s"I don't know you $name"
}
greet("Bob", AppConfig.greeting)
// res2: String = I don't know you Bob
greet("Mikael", AppConfig.greeting)
// Loading greeting
// res3: String = Hello Mikael
当我们第一次用"Bob"调用greet时,AppConfig.greeting短语不会被评估,因为函数体不需要它。它只在我们用"Mikael"调用greet时才会被评估。
在某些情况下,使用命名参数可以增强程序的性能,因为如果不需要,可以跳过昂贵操作的评估。
懒数据结构
这里是一个调用Vector严格方法的函数定义:
def evenPlusOne(xs: Vector[Int]): Vector[Int] =
xs.filter { x => println(s"filter $x"); x % 2 == 0 }
.map { x => println(s"map $x"); x + 1 }
evenPlusOne(Vector(0, 1, 2))
它在控制台上打印以下内容:
filter 0
filter 1
filter 2
map 0
map 2
res4: Vector[Int] = Vector(1, 3)
我们可以看到Vector被迭代了两次:一次用于filter,一次用于map。但是,如果evenPlusOne函数只迭代一次,它将更快。做到这一点的一种方法是将实现方式改变并使用collect。另一种方法是使用非严格的withFilter方法,如下面的代码所示:
def lazyEvenPlusOne(xs: Vector[Int]): Vector[Int] =
xs.withFilter { x => println(s"filter $x"); x % 2 == 0 }
.map { x => println(s"map $x") ; x + 1 }
lazyEvenPlusOne(Vector(0, 1, 2))
withFilter方法打印以下内容:
filter 0
map 0
filter 1
filter 2
map 2
res5: Vector[Int] = Vector(1, 3)
这次,Vector只迭代了一次。每个元素都被过滤,然后逐个映射。这是因为withFilter是一个懒操作——它不会立即创建一个新的过滤Vector,而是创建一个新的withFilter对象,该对象将存储过滤器的谓词。这个 SDK 集合withFilter类型有一个特殊的map实现,它会在调用传递给map的函数之前调用过滤器的谓词。
只要只有一个map或flatMap操作,或者如果你通过另一个withFilter调用进一步细化filter,这种方法就会非常好。然而,如果你调用另一个map操作,集合将被迭代两次,如下面的代码所示:
def lazyEvenPlusTwo(xs: Vector[Int]): Vector[Int] =
xs.withFilter { x => println(s"filter $x"); x % 2 == 0 }
.map { x => println(s"map $x") ; x + 1 }
.map { x => println(s"map2 $x") ; x + 1 }
lazyEvenPlusTwo(Vector(0, 1, 2))
前面的代码打印以下内容:
filter 0
map 0
filter 1
filter 2
map 2
map2 1
map2 3
res6: Vector[Int] = Vector(2, 4)
我们可以看到,对map2的调用是在最后进行的,这意味着我们对Vector(1, 3)进行了第二次迭代。我们需要一个更懒的数据结构,它不会在我们实际需要每个元素之前进行迭代。
在 Scala SDK 中,这种集合类型是Stream。如果我们将Vector替换为Stream在我们的lazyEvenPlusTwo函数中,那么我们就会得到期望的行为,如下面的代码所示:
def lazyEvenPlusTwoStream(xs: Stream[Int]): Stream[Int] =
xs.filter { x => println(s"filter $x") ; x % 2 == 0 }
.map { x => println(s"map $x") ; x + 1 }
.map { x => println(s"map2 $x") ; x + 1 }
lazyEvenPlusTwoStream(Stream(0, 1, 2)).toVector
在调用我们的函数后,我们将结果 Stream 转换为 Vector。正是这个 toVector 调用将流中的元素具体化,并调用传递给 filter 和 map 的匿名函数。以下是在控制台上打印的代码:
filter 0
map 0
map2 1
filter 1
filter 2
map 2
map2 3
res7: Vector[Int] = Vector(2, 4)
我们可以看到 Stream 只被迭代一次。对于每个元素,我们调用 filter,然后 map 和 map2。
但还有更多。由于 Stream 是惰性的,它可以用来表示无限集合。以下代码显示了如何获取所有正偶数的 Stream:
val evenInts: Stream[Int] = 0 #:: 2 #:: evenInts.tail.map(_ + 2)
evenInts.take(10).toVector
// res8: Vector[Int] = Vector(0, 2, 4, 6, 8, 10, 12, 14, 16, 18)
我们使用 Stream 操作符 #::,它使用头部元素和尾部构建 Stream。它的工作方式与 List 操作符 :: 相同,但以惰性的方式进行。以下步骤显示了它是如何工作的:
-
我们构建
Stream 0 #:: 2,其头部为0,尾部有一个元素,2。 -
第三个元素将是
(0 #:: 2).tail.map(_ + 2)。在这个阶段,尾部只有Stream(2),因此第三个元素将是4。 -
第四个元素将是
(0 #:: 2 #:: 4).tail.map(_ + 2)。相同的过程会重复应用于所有后续元素。
由于我们的 Stream 是无限的,我们不能将其全部转换为 Vector,因为这会无限进行。我们只需用 take(10) 取前 10 个元素,然后将它们转换为 Vector。
协变性和反对称性
当 F 类型接受 A 类型的类型参数时,我们可以在参数声明前添加一个 + 或 - 符号来指示 F 在 A 上的 变异性:
-
F[+A]使F在A上 协变。这意味着如果B <:< A(其中B扩展A),那么F[B] <:< F[A]。 -
F[-A]使F在A上 反对称。如果B <:< A,那么F[A] <:< F[B]。 -
F[A]使F在A上 不变。如果B <:< A,则F[A]和F[B]之间没有关系。
InvariantDecoder
现在,我们将通过一个示例来探索这个变异性概念。让我们从一个简单的类层次结构开始,如下面的代码所示:
trait Animal
case class Cat(name: String) extends Animal
case class Dog(name: String) extends Animal
使用这个层次结构,我可以声明一个 Animal 类型的变量,并将其分配给 Cat 或 Dog 类型的实例。以下代码可以编译:
val animal1: Animal = Cat("Max")
val animal2: Animal = Dog("Dolly")
implicitly[Dog <:< Animal]
更一般地,如果 B <:< A,则赋值 val a: A = b: B 可以编译。
你可以用表达式 implicitly[B <:< A] 检查类型 B 是否扩展类型 A;如果它可以编译,那么 B 是 A 的子类型。
然后,我们定义一个只有一个 decode 方法的 InvariantDecoder 特质。没有 + 或 - 符号,因此 InvariantDecoder 在 A 上是 不变的,如下面的代码所示:
trait InvariantDecoder[A] {
def decode(s: String): Option[A]
}
然后,我们为 Cat 实现 InvariantDecoder,如下面的代码所示:
object InvariantCatDecoder extends InvariantDecoder[Cat] {
val CatRegex = """Cat\((\w+\))""".r
def decode(s: String): Option[Cat] = s match {
case CatRegex(name) => Some(Cat(name))
case _ => None
}
}
InvariantCatDecoder.decode("Cat(Max)")
// res0: Option[Cat] = Some(Cat(Max)))
当我们用匹配 CatRegex 正则表达式的字符串调用 decode 时,我们获得一个 Cat 实例包裹在 Option 实例中。
但如果我们声明一个 InvariantDecoder[Animal] 类型的变量呢?我们可以将我们的 InvariantCatDecoder 分配给它吗?让我们试试:
val invariantAnimalDecoder: InvariantDecoder[Animal] = InvariantCatDecoder
上述代码无法编译,但编译器在这种情况下非常有帮助。以下是你将得到的错误:
error: type mismatch;
found : InvariantCatDecoder.type
required: InvariantDecoder[Animal]
Note: Cat <: Animal (and InvariantCatDecoder.type <:
InvariantDecoder[Cat]), but trait InvariantDecoder is invariant in
type A.
You may wish to define A as +A instead. (SLS 4.5)
val invariantAnimalDecoder: InvariantDecoder[Animal] =
InvariantCatDecoder
^
它告诉我们,如果我们想让这一行编译,我们必须使 InvariantDecoder 在类型 A 上协变。为此,我们必须在 InvariantDecoder 中的 A 参数前添加一个 + 符号。
协变解码器
让我们遵循编译器的建议,创建一个新的 CovariantDecoder[+A],以及一个扩展它的 CovariantCatDecoder 实例,如下面的代码所示:
trait CovariantDecoder[+A] {
def decode(s: String): Option[A]
}
object CovariantCatDecoder extends CovariantDecoder[Cat] {
(...)
}
我们没有展示 CovariantCatDecoder 中 decode 的实现;它与 InvariantCatDecoder 中的相同。有了这个协变参数,以下关系得到验证:
implicitly[CovariantDecoder[Cat] <:< CovariantDecoder[Animal]]
这次,我们可以将 CovariantCatDecoder 赋值给 CovariantDecoder[Animal] 的一个实例,如下面的代码所示:
val covariantAnimalDecoder: CovariantDecoder[Animal] = CovariantCatDecoder
covariantAnimalDecoder.decode("Cat(Ulysse)")
// res0: Option[Animal] = Some(Cat(Ulysse)))
当我们调用 decode 时,我们得到一个 Option[Animal]。
初看之下,拥有 CovariantDecoder 似乎是自然的——如果我的解码器可以产生 Cat,而 Cat 是 Animal,那么我的解码器也应该是一个 Animal 的解码器。另一方面,如果我有一个 Decoder[Animal] 的实例,我会期望它能够解码任何 Animal——不仅 Cat,还包括 Dog 实例——但这并不是我们之前 covariantAnimalDecoder 的情况。
这里没有对或错的设计;这只是口味的问题。一般来说,我建议你首先使用不变类型参数,如果你在使用它时遇到一些限制,你可以决定使它们协变或逆变。
获取 Cat 和 Dog 的完整协变实现如下:
object CovariantCatAndDogDecoder extends CovariantDecoder[Animal] {
val CatRegex = """Cat\((\w+\))""".r
val DogRegex = """Dog\((\w+\))""".r
def decode(s: String): Option[Animal] = s match {
case CatRegex(name) => Some(Cat(name))
case DogRegex(name) => Some(Dog(name))
case _ => None
}
}
val covariantCatsAndDogsDecoder = CovariantCatAndDogDecoder
covariantCatsAndDogsDecoder.decode("Cat(Garfield)")
// res4: Option[Animal] = Some(Cat(Garfield)))
covariantCatsAndDogsDecoder.decode("Dog(Aiko)")
// res5: Option[Animal] = Some(Dog(Aiko)))
逆变编码器
现在,我们想要模拟将字符串解码为对象的相反操作——将对象编码为字符串!我们通过在 A 类型参数前添加一个 - 符号使我们的 Encoder 逆变,如下面的代码所示:
trait Encoder[-A] {
def encode(a: A): String
}
以下代码是这个 Encoder 的一个实例:
object AnimalEncoder extends Encoder[Animal] {
def encode(a: Animal): String = a.toString
}
我们有 Cat <:< Animal 的关系,并且 Encoder 在其参数上是逆变的。这意味着 Encoder[Animal] <:< Encoder[Cat],因此我可以将 Encoder[Animal] 赋值给类型为 Encoder[Cat] 的变量,如下面的代码所示:
val catEncoder: Encoder[Cat] = AnimalEncoder
catEncoder.encode(Cat("Luna"))
// res1: String = Cat(Luna)
与协变解码器类似,编码器的逆变看起来很自然——如果我可以编码任何 Animal,我也可以编码 Cat。
然而,如果我们想要创建一个可以编码和解码的单个 Codec 特性,我们就会遇到麻烦。类型参数不能同时是协变和逆变。
使其工作的唯一方法是将类型参数设置为不变,如下面的实现所示:
object CatAndDogCodec extends Codec[Animal] {
val CatRegex = """Cat\((\w+\))""".r
val DogRegex = """Dog\((\w+\))""".r
override def encode(a: Animal) = a.toString
override def decode(s: String): Option[Animal] = s match {
case CatRegex(name) => Some(Cat(name))
case DogRegex(name) => Some(Dog(name))
case _ => None
}
}
但让我们看看如果我们尝试使用协变会发生什么。编译器会返回以下错误:
trait Codec[+A] {
def encode(a: A): String
def decode(s: String): Option[A]
}
Error:(55, 15) covariant type A occurs in contravariant position in
type A of value a
def encode(a: A): String
^
编译器对 A 类型处于逆变位置表示不满。这是因为函数中,参数总是处于逆变位置,而结果总是处于协变位置。例如,如果你打开 scala.Function3,你会看到以下声明:
trait Function3[-T1, -T2, -T3, +R] extends AnyRef { self =>
这意味着以下两点:
-
如果你将类型参数声明为使用
+A进行协变,那么A类型只能出现在方法的 结果 中 -
如果你将类型参数声明为使用
-A进行逆变,那么A类型只能出现在方法的 参数 中
在我们的 decode 方法中,A 出现在结果中,因此它处于协变位置。这就是为什么我们可以通过在 CovariantDecoder 中使用 +A 来使解码器在 A 上协变。
相反,在我们的 encode 方法中,A 出现在参数中,因此它处于逆变位置。这就是为什么我们可以通过在 Encoder 中使用 -A 来使编码器在 A 上逆变。
实现我们的 Codec 的另一种方式是使用类型类。这在上文第五章,类型类中有所解释。
集合中的协变
SDK 中的大多数集合类型都是协变的。例如,如果你打开 Vector 类,你会看到以下内容:
final class Vector[+A] (...)
这允许我们将 Vector[B] 赋值给 Vector[A] 类型的变量,前提是 B <:< A 类型,如下面的代码所示:
val cats: Vector[Cat] = Vector(Cat("Max"))
val animals: Vector[Animal] = cats
现在有一点魔法:
val catsAndDogs = cats :+ Dog("Medor")
// catsAndDogs: Vector[Product with Serializable with Animal] =
// Vector(Cat(Max), Dog(Medor))
Scala 不仅允许我们将 Dog 添加到 Vector[Cat] 中,而且它还会自动推断新的集合类型为 Vector[Product with Serializable with Animal]。
我们之前看到,函数的参数处于逆变位置。因此,甚至不可能有一个 :+(a: A) 方法可以向 Vector 类添加元素,因为 Vector 在 A 上是协变的!但是有一个技巧。如果你查看 Vector 源代码中 :+ 的定义,你会看到以下代码:
override def :+B >: A, That(implicit bf: CanBuildFrom[Vector[A], B, That]): That
该方法接受一个受约束的 B 类型参数,要求 B 必须是 A 的超类型。在我们之前的例子中,A 类型是 Cat,我们的 elem 参数是 Dog 类型。Scala 编译器自动推断 B 类型为 Dog 和 Cat 的最接近的共同超类型,即 Product with Serializable with Animal。
如果我们将 String 添加到这个 Vector 中,结果类型将是 Animal 和 String 之间的下一个公共超类型,即 Serializable,如下面的代码所示:
val serializables = catsAndDogs :+ "string"
// serializables: Vector[Serializable] = Vector(Cat(Max), Dog(Medor),
// string)
val anys = serializables :+ 1
// anys: Vector[Any] = Vector(Cat(Max), Dog(Medor), string, 1)
然后,当我们向 Vector 类添加一个 Int 时,Serializable 和 Int 之间的下一个公共超类型是 Any。
如果你有一个具有协变 MyClass[+A] 类型参数的类,并且你需要实现一个具有 A 类型参数的方法,那么你可以使用 B >: A 类型参数来定义它,写作 def myMethodB >: A = ...。
柯里化和部分应用函数
“柯里化”这个名字是对数学家和逻辑学家 Haskell Curry 的致敬。柯里化的过程包括将接受多个参数的函数转换为一系列函数,每个函数只有一个参数。
函数值
在我们开始柯里化函数之前,我们需要理解函数和函数值的区别。
您已经熟悉函数——它们以关键字 def 开头,在括号 () 符号之间有一个或多个参数列表,可选地在冒号 : 后声明返回类型,并在等号 = 后有一个定义的函数体,如下面的示例所示:
def multiply(x: Int, y: Int): Int = x * y
// multiply: multiply[](val x: Int,val y: Int) => Int
函数值(也称为函数字面量)类似于任何其他值,例如 "hello": String、3: Int 或 true: Boolean。与其他值一样,您可以将函数值作为参数传递给函数,或使用 val 关键字将其分配给变量。
您可以直接声明函数值,如下面的代码所示:
val multiplyVal = (x: Int, y: Int) => x * y
// multiplyVal: (Int, Int) => Int = ...
或者,您可以通过在函数名称末尾添加一个 _ 字符将函数转换为函数值,如下面的代码所示:
val multiplyVal2 = multiply _
// multiplyVal2: (Int, Int) => Int = ...
当涉及到向函数应用参数时,无论是调用函数还是函数值,语法都是相同的,如下面的代码所示:
multiply(2, 3)
multiplyVal(2, 3)
multiplyVal2(2, 3)
柯里化
柯里化函数是一个接受一个参数并返回另一个接受一个参数的函数的函数。您可以通过调用 .curried 方法将函数值转换为柯里化函数值,如下面的代码所示:
val multiplyCurried = multiplyVal.curried
// multiplyCurried: Int => (Int => Int) = ...
.curried 的调用将函数值的类型从 (Int, Int) => Int 转换为 Int => (Int => Int)。multiplyVal 接受两个整数作为参数并返回一个整数。multiplyCurried 接受一个 Int 并返回一个接受 Int 并返回 Int 的函数。这两个函数值具有完全相同的功能——区别在于我们如何调用它们,如下面的代码所示:
multiplyVal(2, 3)
// res3: Int = 6
multiplyCurried(2)
// res4: Int => Int = ...
multiplyCurried(2)(3)
// res5: Int = 6
当我们调用 multiplyCurried(2) 时,我们只应用了第一个参数,这返回了一个 Int => Int 函数。在这个阶段,函数还没有完全应用——它是一个部分应用函数。如果我们想要获得最终结果,我们必须通过调用 multiplyCurried(2)(3) 来应用第二个参数。
部分应用函数
在实践中,没有必要调用 .curried 来定义柯里化函数。您可以直接使用多个参数列表声明柯里化函数。以下是一个计算 Item 类折扣的柯里化函数示例:
case class Item(description: String, price: Double)
def discount(percentage: Double)(item: Item): Item =
item.copy(price = item.price * (1 - percentage / 100))
如果我们提供两个参数列表,我们可以完全应用函数,如下所示:
discount(10)(Item("Monitor", 500))
// res6: Item = Item(Monitor,450.0)
但如果我们只提供第一个参数列表并添加一个 _ 字符来表示我们想要一个函数值,我们也可以部分应用函数,如下面的代码所示:
val discount10 = discount(10) _
// discount10: Item => Item = ...
discount10(Item("Monitor", 500))
// res7: Item = Item(Monitor,450.0)
discount10 函数值是一个部分应用函数,它接受 Item 并返回 Item。然后我们可以通过传递一个 Item 实例来调用它以完全应用它。
部分应用函数在需要将匿名函数传递给高阶函数(接受函数作为参数的函数)时特别有用,例如 map 或 filter,如下面的代码所示:
val items = Vector(Item("Monitor", 500), Item("Laptop", 700))
items.map(discount(10))
// res8: Vector[Item] = Vector(Item(Monitor,450.0), Item(Laptop,630.0))
在这个例子中,map 函数期望一个 Item => B 类型的参数。我们传递了 discount(10) 参数,这是一个 Item => Item 类型的部分应用函数。多亏了部分应用函数,我们才能够在不定义新函数的情况下对一系列商品应用折扣。
隐式
如其名所示,Scala 关键字 implicit 可以用来隐式地向编译器添加一些额外的代码。例如,函数定义中的隐式参数允许你在调用函数时省略此参数。因此,你不必显式地传递此参数。
在本节中,我们将介绍 Scala 中不同类型的隐式:
-
隐式参数在函数定义中被声明
-
一个隐式值被作为参数传递给一个具有隐式参数的函数
-
隐式转换将一种类型转换为另一种类型
这是一个非常强大的功能,有时会感觉有点像魔法。在本节中,我们将看到它如何帮助编写更简洁的代码,以及如何使用它来在编译时验证一些约束。在下一章中,我们将使用它们来定义另一个强大的概念:类型类。
隐式参数
在函数定义中,最后一个参数列表可以被标记为 implicit。这样的函数可以不传递相应的参数就调用。当你省略隐式参数时,Scala 编译器将尝试在当前作用域中查找相同类型的隐式值,并将它们用作函数的参数。
下面是一个你可以将其输入到 Scala 工作表中的这个机制的说明:
case class AppContext(message: String)
implicit val myAppCtx: AppContext = AppContext("implicit world")
def greeting(prefix: String)(implicit appCtx: AppContext): String =
prefix + appCtx.message
greeting("hello ")
// res0: String = hello implicit world
我们首先声明一个新的 AppContext 类,并将这个类的新实例分配给一个 implicit val。val myAppCtx 可以像正常的 val 一样使用,但 implicit 关键字还向编译器指示这个 val 是隐式解析的候选者。
greeting 函数的定义在其最后一个参数列表上有 implicit 标记。当我们不传递 appCtx 参数调用它时,编译器会尝试解析这个隐式参数。这个隐式解析尝试在当前作用域中查找 AppContext 类型的隐式值。唯一具有这种类型的隐式值是 myAppCtx,因此这就是用于 appCtx 的参数。
注意,隐式解析是在编译时进行的。如果编译器无法解析隐式参数,它将引发错误。此外,如果当前作用域中有多个相同类型的隐式值,编译器将无法决定选择哪一个,并会因为模糊的隐式值而引发错误。
在一个大型代码库中,有时很难知道在给定的函数调用中选择了哪个隐式值。幸运的是,IntelliJ 可以显示给你。将光标置于 greeting("hello ") 行,然后转到视图 | 隐式参数,或者按住 Ctrl + Shift + P(Linux/Windows)或 Meta + Shift + P(macOS)。你应该会看到以下提示,如下面的截图所示:

你可以点击提示中显示的参数。IntelliJ 将跳转到隐式值的声明。
隐式参数列表的参数也可以显式传递。以下调用与上一个调用等价:
greeting("hello ")(myAppCtx)
// res0: String = hello implicit world
当你显式传递隐式参数时,隐式解析机制不会启动。
隐式参数应该有一个实例非常少的类型。拥有一个类型为字符串的隐式参数是没有意义的;会有太多的候选者来解析它。这将使代码难以理解。
隐式参数使用
当你需要反复将相同的参数传递给许多函数时,隐式参数非常有用。这种情况在配置参数中经常发生。
传递超时
想象一下,你实现了一个名为 PriceService 的特质,它通过调用外部网站来获取产品的价格。你定义了它的接口如下:
import cats.data.ValidatedNel
case class Timeout(millis: Int)
trait PriceService {
def getPrice(productName: String)(implicit timeout: Timeout):
ValidatedNel[String, Double]
}
外部网站可能不会响应,因此我们的服务必须等待一段时间后才会放弃,这由参数 timeout 表示。正如我们在第三章中看到的,处理错误,如果获取价格时出现任何问题,服务将返回 Invalid[NonEmptyList[String]],如果我们能够获取价格,则返回 Valid[Double]。
在一个大型应用程序中,你可以定义更多这样的服务。使用隐式参数允许你调用这些函数而无需每次都传递 timeout 参数。此外,如果你需要添加其他配置参数,你可以添加更多的隐式参数,而无需更改所有的函数调用。
当你想调用该服务时,你需要在你的作用域中拥有 implicit val timeout: Timeout。这为你提供了很大的灵活性,因为你完全控制在哪里定义这个 timeout 以及如何将其带到当前作用域。以下列出了一些选项:
-
你可以在
object AppConfig { implicit val defaultTimeout: Timeout = ??? }单例中只定义一次,用于整个应用程序。在这个对象中,你可以将其值硬编码或从配置文件中读取。当你需要调用服务时,你只需要import AppConfig.defaultTimeout就可以将它带到当前作用域。 -
你可以为生产代码使用一个值,为测试代码使用另一个值。
-
你的应用程序的某一部分可以使用一个值,比如用于快速服务,而另一部分可以使用不同的值用于慢速服务。
传递应用程序上下文
如果你有很多其他配置参数需要传递给你的函数,将其放在一个ApplicationContext类中并在你的函数中声明这个类的隐式参数会更方便。额外的优势是,这个上下文不仅可以存储配置参数,还可以持有常用服务类的引用。这种机制可以有效地替代你可能使用过的 Java 依赖注入框架,如 Spring 或 Guice。
例如,假设我们有一个实现DataService特质的程序。它有两个方法可以从数据库加载和保存Product对象,如下面的代码所示:
case class Product(name: String, price: Double)
trait DataService {
def getProduct(name: String): ValidatedNel[String, Product]
def saveProduct(product: Product): ValidatedNel[String, Unit]
}
我们通常会为这个特质实现两个版本:
-
另一个在生产代码中,它将交互一个真实的数据库。
-
一个在测试代码中,它将在测试期间将
Product保存在内存中。这将使我们能够更快地运行测试,并使测试独立于任何外部系统。
我们可以定义一个AppContext类,如下所示:
class AppContext(implicit val defaultTimeout: Timeout,
val priceService: PriceService,
val dataService: DataService)
这个上下文将针对生产代码和测试代码有不同的实现。这将让你在运行测试时无需连接到数据库或外部服务就能实现复杂的函数。例如,我们可以使用implicit appContext参数实现一个updatePrice函数:
import cats.implicits._
def updatePrice(productName: String)(implicit appContext: AppContext)
: ValidatedNel[String, Double] = {
import appContext._
(dataService.getProduct(productName),
priceService.getPrice(productName)).tupled.andThen {
case (product, newPrice) =>
dataService.saveProduct(product.copy(price = newPrice)).map(_ =>
newPrice
)
}
}
这个函数从数据库中加载一个产品,通过调用priceService获取其新价格,并使用更新后的价格保存产品。它将返回一个包含新价格的Valid[Double],或者在服务出现错误时返回包含错误信息的Invalid[NonEmptyList[String]]。在为这个函数编写单元测试时,我们会传递AppContext,它包含PriceService和DataService的模拟实现。
SDK 中的示例
Scala 开发工具包(SDK)在多个地方使用了隐式参数。我们将探讨一些作为更有经验的 Scala 开发者你可能会遇到的常见用法。
breakOut
Scala 集合 API 上几个方法的定义,如map,有一个CanBuildFrom类型的隐式参数。这个类型用于构建与输入类型相同类型的集合。
例如,当你对一个Vector调用map时,返回类型仍然是一个Vector,如下面的 REPL 中运行的代码所示:
val vec = Vector("hello", "world").map(s => s -> s.length)
// vec: scala.collection.immutable.Vector[(String, Int)] =
// Vector((hello,5), (world,5))
当你在 IntelliJ 中将光标定位在map方法上并按下 cmd + left-click 时,你会看到map是在TraversableLike中声明的,如下所示:
def mapB, That(implicit bf: CanBuildFrom[Repr, B, That]): That = {...}
TraversableLike特质是许多 Scala 集合(如Vector、List、HashSet等)的父特质。它实现了所有这些集合的共同方法。在这些方法中的许多方法,使用了bf: CanBuildFrom参数来构建与原始集合相同类型的集合。如果你跳转到CanBuildFrom的定义,你会看到它有三个类型参数,如下面的代码所示:
trait CanBuildFrom[-From, -Elem, +To]
第一个参数From是原始集合的类型(Vector、List等)。第二个参数Elem是集合中元素的类型。第三个参数To是目标集合的类型。
回到我们的例子,这意味着当我们对Vector调用.map时,传递了一个隐式参数,其类型为CanBuildFrom。我们可以通过再次将光标定位在map方法上,并转到视图 | 隐式参数,或者按住 cmd + shift + P 来查看其声明位置。如果我们点击工具提示文本,就会跳转到Vector.scala中的这个定义,如下面的代码所示:
object Vector extends IndexedSeqFactory[Vector] {
...
implicit def canBuildFrom[A]: CanBuildFrom[Coll, A, Vector[A]] = ...
我们可以看到,CanBuildFrom中的To目标参数是Vector[A]类型。这解释了为什么在我们的例子中vec变量是Vector[(String, Int)]类型。
这个机制相当复杂,但除非你想实现自己的集合类型,否则你不需要详细了解。当你只是库的用户时,SDK 会很好地隐藏这些细节。
然而,有一件值得记住的事情是,你可以传递不同的CanBuildFrom参数来避免不必要的转换。例如,假设我们想要构建Map[String, Int],其中键是一个字符串,值是该字符串的长度。回到我们的例子,最直接的方法是调用.toMap,如下面的代码所示:
val vec = Vector("hello", "world").map(s => s -> s.length)
vec.toMap
// res0: scala.collection.immutable.Map[String,Int] = Map(hello -> 5, world -> 5)
这种方法的缺点是它将两次遍历Vector类的元素:一次用于映射元素,一次用于构建Map。在小集合中,这并不是问题,但在大集合中,性能可能会受到影响。
幸运的是,我们可以在一次迭代中构建我们的Map。如果我们调用map时传递特殊的breakOut对象,CanBuildFrom的目标类型将是接收变量的类型,如下面的代码所示:
import scala.collection.breakOut
val map: Map[String, Int] = Vector("hello", "world").map(s => s -> s.length)(breakOut)
// map: Map[String,Int] = Map(hello -> 5, world -> 5)
这个简单的技巧可以提高你应用程序的性能,而不会降低可读性。
集合转换操作,如.toMap、toVector等,通常可以移除。尝试在之前的转换中传递breakOut;这将节省一次迭代。
执行上下文
Scala SDK 中的Future类允许你异步运行计算。我们将在第六章中更详细地探讨这一点,在线购物——持久化,但在这个部分,我们将探讨它是如何使用隐式参数来提供执行上下文的。
打开一个 Scala 控制台并输入以下代码。它应该创建一个Future计算,当它执行时将打印当前线程的名称:
scala> import scala.concurrent.Future
import scala.concurrent.Future
scala> Future(println(Thread.currentThread().getName))
由于我们在作用域内缺少隐式转换,你应该看到以下错误:
<console>:13: error: Cannot find an implicit ExecutionContext. You might pass
an (implicit ec: ExecutionContext) parameter to your method
or import scala.concurrent.ExecutionContext.Implicits.global.
Future(println(Thread.currentThread().getName))
编译器告诉我们,我们必须在作用域内有一个implicit ExecutionContext。ExecutionContext是一个可以异步执行一些计算的类,通常使用线程池。如编译器所建议,我们可以通过导入scala.concurrent.ExecutionContext.Implicits.global来使用默认的执行上下文,如下面的代码所示:
scala> import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.ExecutionContext.Implicits.global
scala> import scala.concurrent.Future
import scala.concurrent.Future
scala> Future(println(Thread.currentThread().getName))scala-execution-context-global-11
res1: scala.concurrent.Future[Unit] = Future(Success(()))
在前面的代码片段中,res1的值可以是:
res1: scala.concurrent.Future[Unit] = Future(<未完成>)由于这是一个Future,我们不知道它何时完成;这取决于你的机器。
我们可以看到,用于执行我们的println语句的线程名称是scala-execution-context-global-11。如果我们想使用不同的线程池来运行我们的计算,我们可以声明一个新的ExecutionContext。重新启动 Scala 控制台并输入以下代码:
scala> import scala.concurrent.Future
import scala.concurrent.Future
scala> import java.util.concurrent.Executors
import java.util.concurrent.Executors
scala> import scala.concurrent.{ExecutionContext, Future}
*i*mport scala.concurrent.{ExecutionContext, Future}
scala> implicit val myThreadPool: ExecutionContext = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(2))
myThreadPool: scala.concurrent.ExecutionContext = scala.concurrent.impl.ExecutionContextImpl@7f3c0399
scala> Future(println(Thread.currentThread().getName))
pool-1-thread-1
res0: scala.concurrent.Future[Unit] = Future(<not completed>)
我们可以观察到,运行我们的代码所使用的线程现在来自不同的线程池。Future上的许多方法都有一个隐式的ExecutionContext参数。通过更改作用域内的隐式转换,你可以控制异步计算如何执行。
这在使用某些数据库驱动程序时特别有用——你通常会使用单独的线程池来查询数据库,每个数据库连接一个线程。另一方面,CPU 密集型计算可以使用默认的线程池,该线程池将初始化为你的机器上可用的 CPU 核心数。
隐式转换
隐式转换将源类型转换为目标类型。这允许你做以下操作:
-
将目标类型的方法用作在源类型上声明的方法
-
在接受目标类型的函数中将源类型作为参数传递
例如,我们可以使用以下代码将String类型视为LocalDate:
import java.time.LocalDate
implicit def stringToLocalDate(s: String): LocalDate = LocalDate.parse(s)
注意,IntelliJ 在implicit关键字上突出显示了一个黄色的警告高级语言功能:隐式转换。
如果你想消除这个警告,可以将光标放在implicit关键字上,然后按Alt + Enter,并选择启用隐式转换。
在此声明之后,如果我们有一个可以解析为LocalDate的String对象,我们可以调用LocalDate上正常可用的任何方法,如下面的代码所示:
"2018-09-01".getDayOfWeek
// res0: java.time.DayOfWeek = SATURDAY
"2018-09-01".getYear
// res1: Int = 2018
我们还可以使用普通字符串作为参数调用接受LocalDate的函数,如下面的代码所示:
import java.time.temporal.ChronoUnit.DAYS
DAYS.between("2018-09-01", "2018-10-10")
// res2: Long = 39
这看起来有点像魔术,确实在阅读代码时不容易发现正在发生隐式转换。幸运的是,IntelliJ 可以帮助我们。
首先,你可能会注意到 getDayOfWeek 和 getYear 方法被下划线标注。这是为了表明该方法是在隐式转换的类型上定义的。
IntelliJ 还可以帮助我们找到隐式转换的定义位置。将光标放在其中一个字符串上,然后在 macOS 上按 ctrl + + + Q(或点击“导航”|“隐式转换”)。你应该会看到一个以下弹出窗口:

弹出窗口突出显示了应用的隐式转换函数。然后你可以点击它跳转到其声明。请注意,IntelliJ 还显示了来自 SDK 的其他一些可能的隐式转换。
这种转换为 LocalDate 可能看起来相当不错;然而,如果我们使用一个无法解析的字符串,代码将在运行时抛出异常,如下面的代码所示。正如我们在第三章“处理错误”中看到的,这最好避免:
"2018".getMonth
// java.time.format.DateTimeParseException: Text '2018' could not be parsed at index 4
这个隐式转换的例子只是为了说明。
由于我们的转换可能会抛出异常,如果我们将其用于生产代码中,这将使代码变得不安全。然而,它对于编写更简洁的单元测试却很有用。
隐式转换非常强大,而大权在握也伴随着巨大的责任!不建议你从 SDK 的常见类型(如 String、Int 等)定义隐式转换到其他 SDK 类型。这可能会迅速使你的代码难以阅读。
隐式类
隐式转换通常用于向现有类型添加额外的方法。这被称为“pimp my library”模式。例如,如果我们想在 Int 类型上添加一个 square 方法,我们可以按照以下步骤进行。在 Scala 控制台中输入以下代码:
scala>
class IntOps(val i: Int) extends AnyVal {
def square: Int = i * i
}
scala> implicit def intToIntOps(i: Int): IntOps = new IntOps(i)
intToIntOps: (i: Int)IntOps
scala> 5.square
res0: Int = 25
5 被隐式转换为 IntOps,它提供了 square 方法。
注意 IntOps 扩展了 AnyVal。这种扩展使其成为一个值类。值类的优点是编译器将避免在我们调用 square 方法时分配新对象。生成的字节码将和直接在 Int 类中定义 square 一样高效。编译时的类型是 IntOps,但运行时的类型将是 Int。
值类的一个限制是它们必须在文件的顶层或对象内部定义。如果你尝试在一个 Scala 工作表中运行前面的代码,你将得到一个编译错误:“值类不能是另一个类的成员”。这是 Scala 工作表评估方式的结果——工作表内的代码属于一个非静态对象。
当你想向无法更改的类添加新功能时,这种“pimp my library”模式非常有用,例如以下情况:
-
属于 SDK 或来自第三方库的类。
-
对于您自己的类,您可以使一些方法从服务器模块中访问,但不能从客户端模块中访问。
Scala 提供了一些语法糖来使这种模式更加简洁。我们可以用以下隐式类定义重写前面的代码:
scala>
implicit class IntOps(val i: Int) extends AnyVal {
def square: Int = i * i
}
scala>5.square
res0: Int = 25
编译器将隐式类声明转换为类和隐式转换。这两种形式是等价的。
这种模式在 SDK 中很常见,尤其是在对来自 Java 开发工具包的类进行“增强”时。
例如,java.lang.String可以通过scala.collection.immutable.StringOps进行增强,如下面的代码所示:
"abcd".reverse
val abcd: StringOps = Predef.augmentString("abcd")
abcd.reverse
在第一行,我们调用了reverse方法,这是一个来自StringOps的增强方法。通过下划线reverse方法,IntelliJ 会向您显示它不是在java.lang.String上定义的方法。如果您将光标移到第一个字符串"abcd"上,并按Ctrl + Shift + Q,您应该看到一个弹出窗口显示"abcd"被隐式转换为StringOps,使用的是Predef.augmentString。
在第 2 和第 3 行,我们向您展示如何显式地将我们的字符串转换为StringOps并调用相同的方法。这只是为了说明目的;在实际项目中,您只会依赖于隐式转换。
隐式是如何解决的?
到目前为止,我们已经在它们被使用的作用域中声明了隐式值和隐式转换。但我们也可以在其他文件中定义它们。
Scala 编译器有一套规则来查找隐式参数或隐式转换。编译器会按照以下步骤进行,并遵循以下规则:
- 按照以下方式查看当前作用域:
-
当前作用域中定义的隐式:这些应该在同一个函数、类或对象中。这就是我们在上一节中定义它的方式。
-
显式导入:您可以在对象
myObj中定义一个隐式值implValue,并通过语句import myObj.implValue将其引入当前作用域。 -
通配符导入:
import myObj._。
- 看一下关联的类型:
-
源类型的伴生对象:例如,在
Option的伴生对象中,存在一个到Iterable的隐式转换。这允许您在Option实例上调用任何Iterable的方法。此外,如果函数期望一个Iterable参数,您也可以传递一个Option实例。 -
参数类型的伴生对象:例如,如果您调用
List(1, 2, 3).sorted,sorted方法实际上接受一个Ordering[Int]类型的隐式参数。这个隐式值可以在Ordering的伴生对象中找到。 -
参数类型参数的伴生对象:当一个函数的参数有一个
A类型参数,例如Ordering[A]时,会搜索A的伴生对象中的隐式值。以下是一个示例:
case class Person(name: String, age: Int)
object Person {
implicit val ordering: Ordering[Person] = Ordering.by(_.age)
}
List(Person("Omer", 40), Person("Bart", 10)).sorted
在这个例子中,sorted 方法期望一个隐式参数为 Ordering[Person] 类型,这个类型可以在 Person 类型参数的伴随对象中找到。
摘要
在本章中,我们涵盖了大量的内容。你学习了如何使用 lazy 变量来提高性能,并了解了协变和逆变。你还学习了如何使用柯里化技术部分应用函数,最后,我们探讨了在 Scala 中使用隐式的所有不同方式。一些概念,如柯里化,也用于其他函数式编程语言,如 Haskell。
在下一章中,我们将通过引入类型类来深入探讨类型理论。类型类是分组具有共同行为的相同类型的概念。
第五章:类型类
在本章中,你将了解建立在 Scala 之上的概念。本章中的概念将是抽象的,并且理解它们可能需要一些集中注意力;如果你一开始没有完全理解,请不要感到沮丧。每个单独的部分相对容易理解,但当你把它们全部放在一起时,事情可能会变得复杂。
我们将专注于类型类,并为每个类型类提供一个定义。它们将随后通过一个示例来说明类型类如何在典型程序中发挥作用。由于这些概念可能难以理解,我们还建议一些可选练习,这些练习可以加强你的理解。你不必完成它们就能理解本章的其余部分。练习的解决方案可在 GitHub 上找到。
这里展示的大多数类型类都来自一个名为 Cats 的库,由 Typelevel 创建。
在本章中,我们将介绍以下类型类:
-
scala.math.Ordering
-
org.scalactic.Equality
-
cats.Semigroup
-
cats.Monoid
-
cats.Functor
-
cats.Apply
-
cats.Applicative
-
cats.Monad
理解类型类
类型类代表了一组具有共同行为的类型。类型类对于类型来说,就像类对于对象一样。与传统的类一样,类型类可以定义方法。这些方法可以在属于类型类的所有类型上调用。
类型类是在 Haskell 编程语言中引入的。然而,多亏了隐式的力量,我们也可以在 Scala 中使用它们。在 Scala 中,类型类不是内置的语言结构(就像在 Haskell 中那样),因此我们需要编写一些样板代码来定义它们。
在 Scala 中,我们通过使用trait来声明类型类,它接受一个类型参数。例如,让我们定义一个允许将两个对象合并为一个的Combine类型类,如下所示:
trait Combine[A] {
def combine(x: A, y: A): A
}
然后,我们可以为Combine定义两个类型类实例,如下所示:
-
一个用于
Int,它将两个参数相加 -
一个用于
String,它将它们连接起来
代码定义如下:
object Combine {
def applyA: Combine[A] = combineA
implicit val combineInt: Combine[Int] = new Combine[Int] {
override def combine(x: Int, y: Int): Int = x + y
}
implicit val combineString: Combine[String] = new Combine[String] {
override def combine(x: String, y: String) = x + y
}
}
首先,我们为我们的类型类定义一个apply构造函数,它仅返回隐式参数。然后,我们通过使用implicit val声明类型类实例。这样,编译器将能够通过使用我们在上一节中看到的隐式解析规则自动发现它们。
现在,我们可以实例化和使用我们的类型类,如下所示:
Combine[Int].combine(1, 2)
// res0: Int = 3
Combine[String].combine("Hello", " type class")
// res1: String = Hello type class
当我们调用Combine[Int]时,实际上我们调用的是Combine.apply[Int]。由于我们的apply函数接受类型为Combine[Int]的隐式参数,编译器会尝试找到它。隐式解析规则之一是在参数类型的伴生对象中搜索。
如我们在Combine的伴生对象中声明了combineInt,编译器将其用作Combine.apply的参数。
一旦我们获得了Combine类型类的实例,我们就可以调用它的方法,combine。当我们用两个Int调用它时,它会将它们相加,当我们用两个String调用它时,它会将它们连接起来。
到目前为止,一切顺利;但是,这有点繁琐。如果我们可以像调用Int或String上的方法一样调用combine,那就更实用了。
正如你在前一节中看到的,我们可以在Combine对象内部定义一个隐式类,如下所示:
object Combine {
...
implicit class CombineOpsA(implicit combineA: Combine[A]) {
def combine(y: A): A = combineA.combine(x, y)
}
}
这个隐式类允许我们在任何具有类型类实例Combine[A]的类型A上调用combine。因此,我们现在可以像下面这样调用combine:
2.combine(3)
// res2: Int = 5
"abc" combine "def"
// res3: String = abcdef
它可能看起来并不令人印象深刻;你可能会说我们只是给+方法起了另一个名字。使用类型类的关键好处是,我们可以使combine方法对任何其他类型都可用,而无需更改它。
在传统的面向对象编程中,你必须更改所有类并使它们扩展一个特质,这并不总是可能的。
类型类允许我们在需要时将一个类型转换为另一个类型(在我们的例子中,从Int到Combine)。这就是我们所说的特定多态。
另一个关键好处是,通过使用implicit def,我们可以为参数化类型生成类型类实例,例如Option或Vector。我们只需将它们添加到Combine伴生对象中即可:
object Combine {
...
implicit def combineOptionA
: Combine[Option[A]] = new Combine[Option[A]] {
override def combine(optX: Option[A], optY: Option[A]): Option[A] =
for {
x <- optX
y <- optY
} yield combineA.combine(x, y)
}
}
只要我们有一个类型参数A的隐式Combine[A],我们的函数combineOption就可以生成Combine[Option[A]]。
这种模式非常强大;它允许我们通过使用其他类型类实例来生成类型类实例!编译器将根据其返回类型自动找到正确的生成器。
这非常常见,以至于 Scala 提供了一些语法糖来简化此类函数的定义。我们可以将combineOption重写如下:
implicit def combineOption[A: Combine]: Combine[Option[A]] = new Combine[Option[A]] {
override def combine(optX: Option[A], optY: Option[A]): Option[A] =
for {
x <- optX
y <- optY
} yield Combine[A].combine(x, y)
}
具有一个声明了类型参数A: MyTypeClass的函数,等同于有一个类型MyTypeClass[A]的隐式参数。
然而,当我们使用这种语法时,我们没有为那个隐式指定名称;我们只是在当前作用域中拥有它。拥有它就足够调用任何接受类型MyTypeClass[A]的隐式参数的其他函数。
正是因为我们可以在前面的例子中调用Combine.apply[A]。有了这个combineOption定义,我们现在也可以在Option上调用combine:
Option(3).combine(Option(4))
// res4: Option[Int] = Some(7)
Option(3) combine Option.empty
// res5: Option[Int] = None
Option("Hello ") combine Option(" world")
// res6: Option[String] = Some(Hello world)
练习:为Combine[Vector[A]]定义一个类型类实例,该实例可以连接两个向量。
练习:为Combine[(A, B)]定义一个类型类实例,该实例将两个元组的第一个和第二个元素组合起来。例如,(1, "Hello ") combine (2, "World")应该返回(3, "Hello World")。
类型类配方
总结一下,如果我们想创建一个类型类,我们必须执行以下步骤:
-
创建
trait,MyTypeClass[A],它接受一个参数化类型A。它代表类型类接口。 -
在
MyTypeClass的伴生对象中定义一个apply[A]函数,以便于类型类实例的实例化。 -
为所有期望的类型(
Int、String、Option等)提供trait的隐式实例。 -
定义一个到
Ops类的隐式转换,这样我们就可以像在目标类型中声明一样调用类型类的函数(就像您之前看到的,使用2.combine(3))。
这些定义可以像上一节那样手动编写。或者,您可以使用 simulacrum 生成其中的一些。这有两个好处:减少样板代码并确保一致性。您可以在这里查看它:github.com/mpilquist/simulacrum。
练习:使用 simulacrum 定义 Combine 类型类。
常见类型类
在一个典型的项目中,您不会创建很多自己的类型类。由于类型类捕获了跨多个类型的常见行为,因此很可能有人已经实现了与您所需类似的一个类型类,位于库中。通常,重用 SDK(或第三方库)中定义的类型类比尝试定义自己的类型类更有效率。
通常,这些库为 SDK 类型(String、Int、Option 等)定义了类型类的预定义实例。您通常会重用这些实例来为您自己的类型推导实例。
在本节中,我们将介绍您最有可能遇到的类型类,以及如何使用它们来解决日常编程挑战。
scala.math.Ordering
Ordering 是一个 SDK 类型类,它表示对类型实例进行排序的策略。最常见的用例是按如下方式对集合的元素进行排序:
Vector(1,3,2).sorted
// res0: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3)
如果您查看 sorted 的声明,您将看到它接受一个隐式参数,即 Ordering[B]:
def sortedB >: A: Repr
当我们在 Vector[Int] 上调用 sorted 时,编译器找到了一个类型为 Ordering[Int] 的隐式值,并将其传递给函数。这个隐式值在 Ordering 的伴生对象中找到,它还定义了 String、Long、Option、Tuples 等实例。
我们可以定义 LocalDate 类型类的实例,这样我们就可以比较日期,或者更容易地对它们进行排序:
import java.time.LocalDate
implicit val dateOrdering: Ordering[LocalDate] =
Ordering.fromLessThanLocalDate
import Ordering.Implicits._
LocalDate.of(2018, 5, 18) < LocalDate.of(2017, 1, 1)
// res1: Boolean = false
Vector(
LocalDate.of(2018, 5, 18),
LocalDate.of(2018, 6, 1)
).sorted(dateOrdering.reverse)
// res2: Vector[LocalDate] = Vector(2018-06-01, 2018-05-18)
在作用域内有 Ordering[LocalDate] 和 Ordering.Implicits,我们可以使用 < 操作符来比较日期。"Ordering.Implicits" 还定义了其他有用的改进方法,例如 <、>、<=、>=、max 和 min。
我们也可以通过使用反转的 Ordering[LocalDate] 来轻松地对 Vector[LocalDate] 进行逆序排序。这比先按升序排序然后反转向量更有效。
练习:定义一个 Ordering[Person],它可以按从老到小的顺序对 case class Person(name: String, age: Int) 的实例进行排序。您将需要使用 Ordering.by。
org.scalactic.Equality
Equality 类型类被 ScalaTest 单元测试使用,每当你在断言中写入时,例如以下内容:
actual shouldBe expected
actual should === (expected)
大多数时候,你不必担心它;默认实例通过使用 equals 方法比较类型。然而,每当比较双精度值或包含双精度属性的类时,提供不同的 Equality 实例就变得必要了。
考虑以下单元测试:
class EqualitySpec extends WordSpec with Matchers with TypeCheckedTripleEquals{
"Equality" should {
"allow to compare two Doubles with a tolerance" in {
1.6 + 1.8 should === (3.4)
}
}
}
这个测试应该通过,对吧?错了!尝试运行它;断言失败,如下所示:
3.4000000000000004 did not equal 3.4
这是因为 Double 是一个 IEEE754 双精度浮点数。
因此,一些十进制数字无法用 Double 准确表示。为了使我们的测试通过,我们需要提供一个 Equality[Double] 实例,该实例将在两个数字的绝对差小于某个容差时返回 true:
class EqualitySpec extends WordSpec with Matchers with
TypeCheckedTripleEquals{
implicit val doubleEquality: Equality[Double] =
TolerantNumerics.tolerantDoubleEquality(0.0001)
"Equality" should {...}
再次运行测试;它将通过。
练习:实现一个类型类推导,Equality[Vector[A]],这样我们就可以使用容差比较两个 Vector[Double] 实例。
下面的声明是你必须在练习中实现的等价实例:
class EqualitySpec extends WordSpec with Matchers with TypeCheckedTripleEquals{
implicit val doubleEquality: Equality[Double] =
TolerantNumerics.tolerantDoubleEquality(0.0001)
implicit def vectorEqualityA:
Equality[Vector[A]] = ???
一旦你完成了练习,测试应该会通过,如下所示:
"Equality" should {
(...)
"allow to compare two Vector[Double] with a tolerance" in {
Vector(1.6 + 1.8, 0.0051) should === (Vector(3.4, 0.0052))
}
}
关于浮点数编码的更多信息,请参阅 www.javaworld.com/article/2077257/learn-java/floating-point-arithmetic.html。
cats.Semigroup
在类型类介绍中,我们定义了一个 Combine 类型类。结果发现,这个类型类已经在 Cats 库中定义了。它的名字是 Semigroup;这个名字来源于这种代数结构的数学表示。
在 IntelliJ 中打开它,看看它是如何定义的:
/**
* A semigroup is any set `A` with an associative operation (`combine`).
*/
trait Semigroup[@sp(Int, Long, Float, Double) A] extends Any with Serializable {
/**
* Associative operation taking which combines two values.
*/
def combine(x: A, y: A): A
(...)
}
@sp 注解是一种优化,用于避免原始类型的装箱/拆箱。除此之外,Semigroup 的定义与我们的 Combine 类型类相同。
定律
文档提到一个非常重要的观点:类型类的实例必须实现一个结合函数的关联性。
这意味着它们必须验证以下定律:
a combine (b combine c) = (a combine b) combine c
Cats 中的大多数类型类都有自己的特定定律。库保证它定义的类型类实例验证这个定律。然而,如果你实现了类型类的自己的实例,那么验证它没有违反任何定律是你的责任。
类型类实例的用户期望它验证类型类的所有定律;这是类型类合约的一部分。
类型类合约是类型类特质加上定律。
当一个类型类验证了某些定律时,你可以更容易地推理泛型代码,并且可以自信地应用一些变换。
例如,如果你知道结合性定律已经得到验证,你可以并行评估 a combine b combine c combine d:一个线程可以评估 (a combine b),而另一个线程可以评估 (c combine d)。
Cats 提供了一个laws模块来帮助检查你的类型类实例。你可以通过编写单元测试来检查你的类型类是否遵守等价律。本书中不会详细介绍这一点;如果你对更多信息感兴趣,可以访问typelevel.org/cats/typeclasses/lawtesting.html。
使用示例
Cats 提供了Semigroup类型类的几个推导。它还在SemigroupOps中声明了一个|+|运算符,它是combine的别名。
以下是一些示例:
import cats.implicits._
1 |+| 2
// res0: Int = 3
"Hello " |+| "World !"
// res1: String = Hello World !
(1, 2, "Hello ") |+| (2, 4, "World !")
// res2: (Int, Int, String) = (3,6,Hello World !)
多亏了import cats.implicits._引入的内置推导,我们可以组合Int、String、Tuple2和Tuple3。
我们也可以组合一些参数化类型,例如Option和Vector:
Vector(1, 2) |+| Vector(3, 4)
// res3: Vector[Int] = Vector(1, 2, 3, 4)
Option(1) |+| Option(2)
// res4: Option[Int] = Some(3)
Option(1) |+| None |+| Option(2)
// res5: Option[Int] = Some(3)
对于Option,请注意,当我们将其与非空Option组合时,空Option将被忽略。
在第三章,“处理错误”中,你看到了Option是处理错误的一种方式,并且你了解到每当需要错误消息时,你可以使用Either而不是Option。
那么,如果我们对Either调用combine会发生什么?考虑以下代码:
1.asRight |+| 2.asRight
// res6: Either[B,Int] = Right(3)
1.asRight[String] |+| 2.asRight |+| "error".asLeft
// res7: Either[String,Int] = Left(error)
"error1".asLeft[Int] |+| "error2".asLeft
// res8: Either[String,Int] = Left(error1)
|+|(或combine)函数返回类型为Left的第一个参数。如果所有组合的值都是类型Right,它们的值将组合并放入Right中。
在第一行,所有组合的值都是类型Right,因此结果是Right(3),因为3是1和2应用combine操作的结果。
在第二行,第一个组合值为类型Left的Left("error"),因此结果也是Left("error")。
在第三行,第一个组合值为类型Left的Left("error1"),因此结果是Left("error1")。
练习:使用|+|运算符组合ValidatedNel[String, Int]的实例。
当你组合几个无效值时会发生什么?
cats.Monoid
Monoid是一个带有额外empty函数的Semigroup,也称为单位元素。
以下是从 Cats 中这个特质的定义中提取的内容:
trait Monoid[@sp(Int, Long, Float, Double) A] extends Any with Semigroup[A] {
/**
* Return the identity element for this monoid.
*/
def empty: A
Monoid特质扩展了Semigroup特质。因此,它具有Semigroup的所有方法,加上这个额外的empty方法。我们已经看到了Semigroup的combine操作在不同类型上的几个示例。
让我们看看当我们对相同类型调用empty时会发生什么,如下所示:
import cats.implicits._
import cats.kernel.Monoid
Monoid[Int].empty
// res0: Int = 0
Monoid[String].empty
// res1: String =
Monoid[Option[Double]].empty
// res2: Option[Double] = None
Monoid[Vector[Int]].empty
// res2: Vector[Int] = Vector()
Monoid[Either[String, Int]].empty
// res4: Either[String,Int] = Right(0)
对于每种类型,empty元素相当自然:对于Int是0,对于Option是None,等等。
等价律
我们可以理解为什么这个empty函数被称为单位元素;如果你将任何对象与单位元素组合,它将返回相同的对象,如下所示:
(3 |+| Monoid[Int].empty) == 3
("Hello identity" |+| Monoid[String].empty) == "Hello identity"
(Option(3) |+| Monoid[Option[Int]].empty) == Option(3)
该属性通过以下等价律正式定义:
-
左恒等性:对于所有类型为
A的x,Monoid[A].empty |+| x == x -
右恒等性:对于所有类型为
A的x,x |+| Monoid[A].empty == x
使用示例
这很有趣——我们如何在日常程序中使用 Monoid?最引人注目的用例是折叠数据结构。当你有 Monoid[A] 时,将 Vector[A] 的所有元素组合起来获得一个 A 是非常简单的。例如,我们可以得到 Vector[Int] 中所有元素的总和,如下所示:
Vector(1,2,3).combineAll
// res8: Int = 6
这相当于在 Vector 上调用 foldLeft:
Vector(1, 2, 3).foldLeft(0) { case (acc, i) => acc + i }
事实上,foldLeft 接受两个参数,如下所示:
-
一个起始值,我们可以传递单例的空值
-
一个函数,我们可以传递单例的
combine函数
Cats 还提供了一个 foldMap 函数,它允许你在折叠之前将集合的元素转换为 Monoid:
Vector("1", "2", "3").foldMap(s => (s, s.toInt))
// res10: (String, Int) = (123,6)
练习:实现一个 Monoid[Int] 的实例,用于乘以 Vector[Int] 中所有元素。
练习:使用 foldMap 计算平均值的 Vector[Double]。
提示:foldMap 调用的返回类型应该是 (Int, Double)。
高阶类型
在探索其他类型类之前,熟悉 高阶类型 和 arity 的概念会有所帮助。
你已经熟悉了值和函数。一个值是一个字面量或对象,例如 1,false,或 "hello world"。
Arity
函数接受一个或多个值作为参数并返回另一个值。
函数的 arity 是它接受的参数数量。
例如:
-
一个 一元(arity 0)函数不接受任何参数
-
一个 一元(arity 1)函数只接受一个参数
-
一个 二元(arity 2)函数接受两个参数
一个 类型构造器 是接受参数的类型。它被称为类型构造器,因为它在我们传递一个具体类型给它时构建一个具体类型。例如,Option[A] 是一个类型构造器。当我们传递一个具体类型 Int 给它时,我们获得一个具体类型 Option[Int]。
由于类型构造器可以接受 0 到 n 个参数,arity 的概念也适用于这里:
-
一个 零元类型不接受任何参数。它是一个具体类型——
Int,Boolean以及更多 -
一元类型接受一个参数——
Option[A],Vector[A] -
二元类型接受两个参数——
Either[L, R],Map[K, V] -
三元类型接受三个参数——
Tuple3[A, B, C]
高阶函数
函数的 order 是函数箭头的嵌套深度:
-
零级——值,例如,
1,false或"hello" -
第一级——函数
A => B,例如def addOne: Int => Int = x => x + 1 -
第二级——高阶函数
A => B => C,例如:
def map: Vector[Int] => (Int => Int) => Vector[Int] =
xs => f => xs.map(f)
- 第三级——高阶函数
A => B => C => D
任何阶数严格大于 1 的函数都是高阶函数。
高阶类型
结果表明,与类型和 类型类 存在类似的概念:
-
普通类型,如
Int或Boolean的类型是* -
一元类型构造器的类型是
* -> *,例如,Option或List -
二元类型构造器的类型是
(*, *) -> *,例如,Either或Map
类似于函数和级别,我们可以通过类型箭头 -> 的数量来对类型进行排序:
-
第 0 级(
*):普通类型,如Int、Boolean或String -
第 1 级(
* -> *或(*, *) -> *):类型构造器Option、Vector、Map以及更多 -
第 2 级(
(* -> *) -> *):高阶类型Functor、Monad以及更多
高阶类型是一个具有严格多于一个箭头 -> 的类型构造器。在下一节中,我们将探讨使用高阶类型定义的类型类。
cats.Functor
Functor 是一个一元的高阶类型。它接受一个一元类型参数 F[_]。换句话说,其类型参数必须是一个具有类型参数的类型;例如,Option[A]、Vector[A]、Future[A] 等等。它声明了一个 map 方法,可以转换 F 内部的元素。以下是 cats.Functor 的简化定义:
trait Functor[F[_]] {
def mapA, B(f: A => B): F[B]
这应该很熟悉。我们已经看到了 SDK 中定义了执行相同操作的 map 函数的几个类:Vector、Option 等等。因此,你可能会想知道为什么你需要使用 Functor[Option] 或 Functor[Vector] 的实例;它们只会定义一个已经可用的 map 函数。
在 Cats 中拥有这种 Functor 抽象的一个优点是它让我们能够编写更通用的函数:
import cats.Functor
import cats.implicits._
def addOne[F[_] : Functor](fa: F[Int]): F[Int] = fa.map(_ + 1)
此函数为任何具有 Functor 类型类实例的 F[Int] 添加 1。我对 F 的唯一了解是它有一个 map 操作。因此,此函数将适用于许多参数化类型,例如 Option 或 Vector:
addOne(Vector(1, 2, 3))
// res0: Vector[Int] = Vector(2, 3, 4)
addOne(Option(1))
// res1: Option[Int] = Some(2)
addOne(1.asRight)
// res2: Either[Nothing,Int] = Right(2)
我们的功能函数 addOne 应用最小功率原则;在给定解决方案的选择中,它选择能够解决你问题的最弱解决方案。
我们使用一个更通用的参数类型,因此更弱(它只有一个 map 函数)。这使得我们的函数更可重用,更易读,更易测试:
-
更可重用:相同的函数可以用
Option、Vector、List或任何具有Functor类型类实例的东西使用。 -
更易读:当你阅读
addOne的签名时,你知道它唯一能做的就是转换F内部的元素。例如,它不能重新排列元素的顺序,也不能删除某些元素。这是由Functor法律保证的。因此,你不需要阅读其实现来确保它不会陷入任何麻烦。 -
更容易测试:你可以使用具有
Functor实例的最简单类型来测试函数,即cats.Id。代码覆盖率将相同。一个简单的测试可以是,例如,addOnecats.Id == 2。
法律
为了成为 Functor,Functor 实例的 map 函数必须满足两个法律。在本章的其余部分,我们将使用等式来定义我们的法律:left_expression == right_expression。这些等式必须对任何指定的类型和实例都成立。
给定一个具有 Functor[F] 实例的类型 F,对于任何类型 A 和任何实例 fa: F[A],必须满足以下等式:
-
恒等性保持:
fa.map(identity) == fa。恒等函数总是返回其参数。使用此函数进行映射不应改变fa。 -
组合保持:对于任何函数
f和g,fa.map(f).map(g) == fa.map(f andThen g)。依次映射f和g与使用这些函数的组合映射相同。这个定律允许我们优化代码。当我们发现自己多次在大型向量上调用map时,我们知道我们可以用单个调用替换所有的map调用。
练习:编写一个违反恒等性保持定律的 Functor[Vector] 实例。
练习:编写一个违反组合保持定律的 Functor[Vector] 实例。
使用示例
每当你有一个 A => B 的函数时,只要你有一个作用域内的 Functor[F] 实例,你就可以将其提升为一个函数 F[A] => F[B],如下所示:
def square(x: Double): Double = x * x
def squareVector: Vector[Double] => Vector[Double] =
Functor[Vector].lift(square)
squareVector(Vector(1, 2, 3))
// res0: Vector[Double] = Vector(1.0, 4.0, 9.0)
def squareOption: Option[Double] => Option[Double] =
Functor[Option].lift(square)
squareOption(Some(3))
// res1: Option[Double] = Some(9.0)
另一个实用的函数是 fproduct,它将值与函数应用的结果组合成元组:
Vector("Functors", "are", "great").fproduct(_.length).toMap
//res2: Map[String,Int] = Map(Functors -> 8, are -> 3, great -> 5)
我们通过使用 fproduct 创建了 Vector[(String, Int)],然后将其转换为 Map。我们得到了一个以单词为键的 Map,其关联的值是该单词的字符数。
cats.Apply
Apply 是 Functor 的子类。它声明了一个额外的 ap 函数。以下是 cats.Apply 的简化定义:
trait Apply[F[_]] extends Functor[F] {
def apA, B(fa: F[A]): F[B]
/** Alias for [[ap]]. */
@inline final def <*>A, B(fa: F[A]): F[B] =
ap(ff)(fa)
这个签名意味着对于给定的上下文 F,如果我们有一个在 F 内部的 A => B 函数,我们可以将其应用于另一个 F 内部的 A 以获得 F[B]。我们还可以使用 ap 别名操作符 <*>。让我们用不同的 F 上下文尝试一下,如下所示:
import cats.implicits._
OptionString => String.ap(Some("Apply"))
// res0: Option[String] = Some(Hello Apply)
OptionString => String <*> None
// res1: Option[String] = None
Option.empty[String => String] <*> Some("Apply")
// res2: Option[String] = None
对于 F = Option,ap 仅在两个参数都不为空时才返回一个非空 Option。
对于 F = Vector,我们得到以下结果:
def addOne: Int => Int = _ + 1
def multByTwo: Int => Int = _ * 2
Vector(addOne, multByTwo) <*> Vector(1, 2, 3)
// res3: Vector[Int] = Vector(2, 3, 4, 2, 4, 6)
在 Vector 的情况下,ap 对第一个 Vector 的每个元素进行操作,并将其应用于第二个 Vector 的每个元素。因此,我们获得了将每个函数应用于每个元素的所有组合。
练习:使用 ap 与 Future。
法则
与其他 Cats 类型类一样,Apply 实例必须遵守某些定律。
给定一个具有 Apply[F] 实例的类型 F,对于所有类型 A,给定一个实例 fa: F[A],必须验证以下等式:
- 乘积结合律:对于所有
fb: F[B]和fc: F[C],以下适用:
(fa product (fb product fc)) ==
((fa product fb) product fc).map {
case ((a, b), c) => (a, (b,c))
}
我们可以改变括号,从而改变求值顺序,而不改变结果。
ap函数组合:对于所有类型B和C,给定实例fab: F[A => B]和fbc: F[B => C],以下适用:
(fbc <*> (fab <*> fa)) == ((fbc.map(_.compose[A] _) <*> fab) <*> fa)
这与我们在 Functor 部分看到的函数组合定律相似:fa.map(f).map(g) == fa.map(f andThen g)。
不要在阅读这个定律时迷失方向;<*> 函数是从右到左应用的,用于函数的 andThen 是 Functor 的 .compose[A]。
练习:验证 F = Option 的 product 结合性。你可以为 fa、fc 和 fc 使用特定的值,例如 val (fa, fb, fc) = (Option(1), Option(2), Option(3))。
练习:验证 ap 函数的 F = Option 上的组合。和之前一样,你可以为 fa、fab 和 fbc 使用特定的值。
使用示例
这都很好,但在实践中,我很少在上下文中放置函数。我发现 Apply 中的 map2 函数更有用。它通过使用 product 和 map 在 Apply 中定义。product 通过使用 ap 和 map 来定义自己:
trait Apply[F[_]] extends Functor[F] … {
(...)
def map2A, B, Z(f: (A, B) => Z): F[Z] =
map(product(fa, fb))(f.tupled)
override def productA, B: F[(A, B)] =
ap(map(fa)(a => (b: B) => (a, b)))(fb)
map2 对象允许在 F 上下文中应用一个函数到两个值。这可以用来在 F 内部组合两个值,如下所示:
def parseIntO(s: String): Option[Int] = Either.catchNonFatal(s.toInt).toOption
parseIntO("6").map2(parseIntO("2"))(_ / _)
// res4: Option[Int] = Some(3)
parseIntO("abc").map2(parseIntO("def"))(_ / _)
// res5: Option[Int] = None
在前面的例子中,对于 F = Option,map2 允许我们在两个值都不为空时调用函数 /。
Cats 还为 F = Either[E, ?] 提供了一个 Apply 实例。因此,我们可以将 parseIntOpt 的签名更改为返回 Either[Throwable, Int],其余的代码将保持不变:
def parseIntE(s: String): Either[Throwable, Int] = Either.catchNonFatal(s.toInt)
parseIntE("6").map2(parseIntE("2"))(_ / _)
// res6: Either[Throwable,Int] = Right(3)
parseIntE("abc").map2(parseIntE("3"))(_ / _)
// res7: Either[Throwable,Int] = Left(java.lang.NumberFormatException: For input string: "abc")
这个 map2 函数对两个元素工作得很好,但如果我们有三个、四个或 N 个元素怎么办?Apply 没有定义 map3 或 map4 函数,但幸运的是,Cats 在元组上定义了一个 mapN 函数:
(parseIntE("1"), parseIntE("2"), parseIntE("3")).mapN( (a,b,c) => a + b + c)
// res8: Either[Throwable,Int] = Right(6)
在 第三章,处理错误 中,我们看到当我们要在第一个错误处停止时使用 Either。这就是我们在上一个例子中看到的情况:错误提到 "abc" 无法解析,但没有提到 "def"。
应用我们刚刚学到的知识,如果我们想累积所有错误,我们可以使用 ValidatedNel:
import cats.data.ValidatedNel
def parseIntV(s: String): ValidatedNel[Throwable, Int] = Validated.catchNonFatal(s.toInt).toValidatedNel
(parseIntV("abc"), parseIntV("def"), parseIntV("3")).mapN( (a,b,c) => a + b + c)
// res9: ValidatedNel[Throwable,Int] = Invalid(NonEmptyList(
// java.lang.NumberFormatException: For input string: "abc",
// java.lang.NumberFormatException: For input string: "def")
练习:使用 mapN 与 Future[Int]。这允许你并行运行多个计算,并在它们完成时处理它们的结果。
练习:使用 mapN 与 Vector[Int]。
cats.Applicative
Applicative 是 Apply 的一个子类。它声明了一个额外的函数,称为 pure:
@typeclass trait Applicative[F[_]] extends Apply[F] {
def pureA: F[A]
}
将任何类型的 A 放入 F 上下文的 pure 函数。一个具有 Applicative[F] 实例并且遵守相关法则的类型 F 被称为 Applicative Functor。
让我们尝试使用不同的 F 上下文来使用这个新的 pure 函数,如下所示:
import cats.Applicative
import cats.data.{Validated, ValidatedNel}
import cats.implicits._
Applicative[Option].pure(1)
// res0: Option[Int] = Some(1)
3.pure[Option]
// res1: Option[Int] = Some(3)
type Result[A] = ValidatedNel[Throwable, A]
Applicative[Result].pure("hi pure")
// res2: Result[String] = Valid(hi pure)
"hi pure".pure[Result]
// res3: Result[String] = Valid(hi pure)
在大多数情况下,pure 等同于 apply 构造函数。我们可以通过使用在 Applicative 特质上声明的函数来调用它,或者通过在任意类型上调用 .pure[F] 来调用它。
法则
如你所预期,Applicative 必须遵守某些法则。
给定一个具有 Applicative[F] 实例的类型 F,对于所有类型 A,给定一个实例 fa: F[A],以下等式必须得到验证:
-
Applicative的恒等式如下:((identity[A] _).pure[F] <*> fa) == fa
当我们使用 pure 将 identity 函数放入 F 上下文并调用 fa 上的 <*> 时,它不会改变 fa。这类似于 Functor 中的恒等律。
-
给定实例
fab: F[A => B]和fbc: F[B => C]的Applicative组合如下:(fbc <*> (fab <*> fa)) == ((fbc.map(_.compose[A] _) <*> fab) <*> fa)
它与Functor中的组合保持相似。通过使用compose,我们可以改变<*>表达式周围的括号,而不会改变结果。
-
Applicative同态如下所示:Applicative[F].pure(f) <*> Applicative[F].pure(a) == Applicative[F].pure(f(a))
当我们调用pure(f)然后<*>,它等同于应用f然后调用pure。
-
给定实例
fab: F[A => B]的Applicative交换如下所示:fab <*> Applicative[F].pure(a) == Applicative[F].pure((f: A => B) => f(a)) <*> fab
如果我们在等式的左侧包裹a,或者在等式的右侧包裹f(a),我们可以翻转<*>的fab参数。
作为练习,我鼓励你打开 Cats 源代码中的cats.laws.ApplicativeLaws类。还有其他一些定律要发现,以及所有测试的实现。
使用示例
在关于Apply的cats.Apply部分,你看到我们可以通过使用mapN在F上下文中组合许多值。但是,如果我们想要组合的值在一个集合中而不是一个元组中怎么办?
在那种情况下,我们可以使用Traverse类型类。Cats 为许多集合类型提供了这个类型类的实例,例如List、Vector和SortedMap。
以下是对Traverse的简化定义:
@typeclass trait Traverse[F[_]] extends Functor[F] with Foldable[F] with UnorderedTraverse[F] { self =>
def traverse[G[_]: Applicative, A, B](fa: F[A])(f: A => G[B]):
G[F[B]]
(...)
}
这个签名意味着我可以使用一个集合,fa: F[A](例如,Vector[String]),和一个函数来调用A并返回G[B],其中G是Applicative Functor(例如,Option[Int])。它将在F中的所有值上运行f函数,并以G上下文返回F[B]。
让我们通过具体的例子来看一下它的实际应用,如下所示:
import cats.implicits._
def parseIntO(s: String): Option[Int] =
Either.catchNonFatal(s.toInt).toOption
Vector("1", "2" , "3").traverse(parseIntO)
// res5: Option[Vector[Int]] = Some(Vector(1, 2, 3))
Vector("1", "boom" , "3").traverse(parseIntO)
// res6: Option[Vector[Int]] = None
我们可以安全地将Vector[String]解析为返回Option[Vector[Int]]。如果任何值无法解析,结果将是None。在这个例子中,我们使用Traverse,其中F = Vector、G = Option、A = String和B = Int。
如果我们想要保留一些解析错误的详细信息,我们可以使用G = ValidatedNel,如下所示:
import cats.data.{Validated, ValidatedNel}
def parseIntV(s: String): ValidatedNel[Throwable, Int] = Validated.catchNonFatal(s.toInt).toValidatedNel
Vector("1", "2" , "3").traverse(parseIntV)
// res7: ValidatedNel[Throwable, Vector[Int]] = Valid(Vector(1, 2, 3))
Vector("1", "boom" , "crash").traverse(parseIntV)
// res8: ValidatedNel[Throwable, Vector[Int]] =
// Invalid(NonEmptyList(
// NumberFormatException: For input string: "boom",
// NumberFormatException: For input string: "crash"))
练习:使用Traverse,其中G = Future。这将允许你并行运行集合中每个元素的函数。
另一个常见的用例是使用sequence将结构F[G[A]]翻转成F[G[A]]。
以下是在cats.Traverse特质中sequence的定义:
def sequence[G[_]: Applicative, A](fga: F[G[A]]): G[F[A]] =
traverse(fga)(ga => ga)
我们可以看到sequence实际上是通过traverse实现的。
以下是一个例子,其中F = Vector和G = Option:
val vecOpt: Vector[Option[Int]] = Vector(Option(1), Option(2), Option(3))
val optVec: Option[Vector[Int]] = vecOpt.sequence
// optVec: Option[Vector[Int]] = Some(Vector(1, 2, 3))
下面是另一个例子,其中F = List和G = Future:
import scala.concurrent._
import ExecutionContext.Implicits.global
import duration.Duration
val vecFut: Vector[Future[Int]] = Vector(Future(1), Future(2), Future(3))
val futVec: Future[Vector[Int]] = vecFut.sequence
Await.result(futVec, Duration.Inf)
// res9: Vector[Int] = Vector(1, 2, 3)
sequence的调用返回Future,它只会在vecFut中的三个 future 完成时完成。
cats.Monad
Monad是Applicative的子类。它声明了一个额外的函数,flatMap,如下所示:
@typeclass trait Monad[F[_]] extends FlatMap[F] with Applicative[F]@typeclass trait FlatMap[F[_]] extends Apply[F] {
def flatMapA, B(f: A => F[B]): F[B]
}
这个签名告诉我们,为了产生F[B],flatMap必须以某种方式从fa: F[A]中提取A,然后调用函数f。
之前,你看到 Applicative 和 mapN 允许我们并行处理多个 F[A] 值,并将它们组合成一个单一的 F[B]。Monad 添加的是处理 F[] 值的顺序能力:flatMap 必须首先处理 F 效应,然后调用 f 函数。
与 Functor 和 map 类似,SDK 中的许多类已经有一个 flatMap 方法,例如 Option、Vector、Future 等。拥有这种 Monad 抽象的一个优点是我们可以编写接受 Monad 的函数,这样它就可以与不同类型重用。
法律
给定一个具有 Monad[F] 实例的类型 F,对于所有类型 A 给定一个实例 fa: F[A],以下等式必须得到验证:
-
所有来自超特质的法律:参见
Applicative、Apply和Functor。 -
FlatMap 结合律:给定两个类型
B和C,以及两个函数f: A => F[B]和g: B => F[C],以下适用:
((fa flatMap f) flatMap g) == (fa flatMap(f(_) flatMap g))
- 左单位元:给定一个类型
B,一个值a: A,和一个函数f: A => F[B],以下适用:
Monad[F].pure(a).flatMap(f) == f(a)
在 F 上下文中引入一个值并调用 flatMap f 应该提供与直接调用函数 F 相同的结果。
- 右单位元:
fa.flatMap(Monad[F].pure) == fa
当我们调用 flatMap 和 pure 时,fa 对象不应该改变。
使用示例
假设你正在构建一个用于管理商店库存的程序。以下是一个简化版的 API 来管理项目:
import cats.{Id, Monad}
import cats.implicits._
case class Item(id: Int, label: String, price: Double, category: String)
trait ItemApi[F[_]] {
def findAllItems: F[Vector[Item]]
def saveItem(item: Item): F[Unit]
}
该 API 使用一个 F 上下文进行参数化。这允许你有不同的 API 实现,如下所示:
-
在你的单元测试中,你会使用
class TestItemApi extends ItemApi[cats.Id]。如果你寻找Id的定义,你会找到type Id[A] = A。这意味着这个TestItemApi可以直接在findAllItems中返回Vector[Item],在saveItem中返回Unit。 -
在你的生产代码中,你需要访问数据库,或者调用远程 REST 服务。这些操作需要时间并且可能会失败;因此,你需要使用类似
F = Future或F = cats.effects.IO的东西。例如,你可以定义class DbItemApi extends ItemApi[Future]。
SDK 中的 Future 类有一些问题并违反了一些法律。我鼓励你使用更好的抽象,例如 cats.effects.IO (typelevel.org/cats-effect/datatypes/io.html) 或 monix.eval.Task (monix.io/docs/2x/eval/task.html)。
配置了这个 API 后,我们可以实现一些业务逻辑。以下是一个应用折扣到所有项目的函数实现:
def startSalesSeason[F[_] : Monad](api: ItemApi[F]): F[Unit] = {
for {
items <- api.findAllItems
_ <- items.traverse { item =>
val discount = if (item.category == "shoes") 0.80 else 0.70
val discountedItem = item.copy(price = item.price * discount)
api.saveItem(discountedItem)
}
} yield ()
}
F[_]: Monad类型参数约束意味着我们可以用任何ItemApi[F]调用startSalesSeason,只要F有一个Monad实例。这个隐式参数的存在允许我们在F[A]实例上调用map和flatMap。由于编译器将for推导式转换为map/flatMap的组合,我们可以使用for推导式使我们的函数更易读。在cats.Applicative部分关于Applicative,你看到我们可以对Vector调用traverse,只要函数返回具有Applicative[F]实例的F。由于Monad扩展了Applicative,我们可以使用traverse遍历项目并保存每个项目。
如果你眯着眼睛看这段代码,它看起来非常类似于命令式实现的模样。我们设法编写了一个纯函数,同时保持了可读性。这种技术的优势在于,我们可以轻松地对startSalesSeason的逻辑进行单元测试,使用F = Id,而无需处理Futures。在生产代码中,相同的代码可以使用F = Future,甚至F = Future[Either[Exception, ?]],并在多线程中优雅地处理流程。
练习:实现TestItemApi,它扩展了ItemApi[Id]。你可以使用可变的Map来存储项目。之后,为startSalesSeason编写单元测试。然后,实现一个生产版本的 API,它扩展了ItemApi[Future]。
这种方法被称为无标签最终编码。您可以在www.beyondthelines.net/programming/introduction-to-tagless-final/找到更多关于此模式的信息。
摘要
我们在本章中介绍了一些具有挑战性的概念。类型类也用于其他函数式编程语言,如 Haskell。
为了方便起见,以下表格总结了我们在本章中列举的类型类:
| 名称 | 方法 | 法律 | 示例 |
|---|---|---|---|
Semigroup |
def combine( x: A, y: A) : A |
结合律 |
Option(1) |+| None |+| Option(2)
// res5: Option[Int] = Some(3)
|
Monoid |
def empty: A |
标识符 |
|---|
Vector(1,2,3).combineAll
// res8: Int = 6
Vector("1", "2", "3").foldMap(s => (s,s.toInt))
// res10: (String, Int) = (123,6)
|
| Functor | def map[A, B] (fa: F[A])
(f: A => B): F[B] | 标识符,可组合性 |
def square(x: Double): Double = x * x
def squareVector:
Vector[Double] => Vector[Double] =
Functor[Vector].lift(square)
squareVector(Vector(1, 2, 3))
// res0: Vector[Double] = Vector(1.0, 4.0, 9.0)
Vector("Functors", "are", "great")
.fproduct(_.length)
.toMap
// res2: Map[String,Int] = Map(Functors -> 8, //are -> 3, great -> 5)
|
| Apply | def ap[A, B] (ff: F[A => B])
(fa: F[A]): F[B]
别名 <*> | 结合律,可组合性 |
Option[String => String]
("Hello " + _).ap(Some("Apply"))
// res0: Option[String] = Some(Hello Apply)
OptionString => String <*> None
// res1: Option[String] = None
def addOne: Int => Int = _ + 1
def multByTwo: Int => Int = _ * 2
Vector(addOne, multByTwo) <*> Vector(1, 2, 3)
// res3: Vector[Int] = Vector(2, 3, 4, 2, 4, 6)
|
| Applicative | def pure[A] (x: A): F[A] | 标识符,可组合性,
同态,
交换 |
import cats.data.{Validated, ValidatedNel}
def parseIntV(s: String): ValidatedNel[Throwable, Int] = Validated.catchNonFatal(s.toInt).toValidatedNel
Vector("1", "2" , "3").traverse(parseIntV)
// res7: ValidatedNel[Throwable, Vector[Int]] = Valid(Vector(1, 2, 3))
Vector("1", "boom" , "crash")
.traverse(parseIntV)
// res8: ValidatedNel[Throwable, Vector[Int]] =
// Invalid(NonEmptyList(
// NumberFormatException: For input string: "boom",
// NumberFormatException: For input string: "crash"))
|
| Monad | def flatMap[A, B] (fa: F[A])
(f: A => F[B]): F[B] | 标识符,结合律,
可组合性,
同态,
交换 |
import cats.{Id, Monad}
import cats.implicits._
case class Item(id: Int,
label: String,
price: Double,
category: String)
trait ItemApi[F[_]] {
def findAllItems: F[Vector[Item]]
def saveItem(item: Item): F[Unit]
}
def startSalesSeason[F[_] : Monad](
api: ItemApi[F]): F[Unit] = {
for {
items <- api.findAllItems
_ <- items.traverse { item =>
val discount = if (item.category ==
"shoes") 0.80 else 0.70
val discountedItem = item.copy(price =
item.price * discount)
api.saveItem(discountedItem)
}} yield ()
}
|
如果你想要更详细地了解类型类,我鼓励你查看 Cats 文档typelevel.org/cats。我也发现阅读 SDK 中的源代码和测试,或者在 Cats 等库中阅读源代码和测试非常有帮助。
Cats 是 Typelevel 创新计划的主要库,但在这个旗下还有许多更多令人着迷的项目,如typelevel.org/projects/所示。
在下一章中,我们将使用在 Scala 社区中流行的框架来实现一个购物网站的购物车。
第六章:在线购物 - 持久性
在接下来的四章中,我们将使用 Scala 生态系统中最常见的库和框架来编写一个项目。
我们将从前端到数据库实现一个在线购物网站的购物车管理。
从数据库开始,我们将实现一个持久性层。这个层的责任是将购物车的内 容持久化到关系型数据库中。为此,我们将使用一个名为 Slick 的关系型持久化框架。
然后,我们将花费时间来定义一个访问数据库的 API,这个 API 将使用 RESTful Web 服务架构和 JSON 作为消息协议。API 将完全由生成的网站进行文档化和测试。
最后,我们将实现用户界面层。通过这个界面,用户可以将其产品添加到购物车中,移除产品,并更新购物车中特定产品的数量。Scala.js 被用来实现这个界面。
在本章中,我们将解释如何在关系型数据库中持久化数据。数据将是购物网站购物车的内 容。
如果我们想要构建一个能够接受大量同时连接的健壮网站,就需要特别小心,确保解决方案的所有层都能随着需求进行扩展。
在持久性层级别,一个关键点是不要过度使用系统资源,更确切地说,每次将数据写入数据库时不要过度使用线程。实际上,如果每个对数据库的请求都阻塞了一个线程,那么并发连接的限制将很快达到。
为了这个目的,我们将使用一个名为 Slick 的异步框架来执行数据库操作。
为了使用 Slick 框架,将需要介绍 Scala Future。Future是 Scala 中处理异步代码的基本工具之一。
由于我们很少在家托管网站,我们将使用名为 Heroku 的云服务提供商来部署这一层,以及稍后整个网站。这意味着购物车将从世界各地都可以访问。
在本章中,我们将涵盖以下主题:
-
创建项目
-
持久性
-
部署应用程序
-
Heroku 配置
创建项目
为了方便项目创建,我们提供了一个模板,该模板可以生成项目的骨架。为此,Gitter8将帮助我们根据托管在 Git 中的模板生成完整的项目。
我们不会直接使用 Gitter8 的命令行。相反,我们将使用与 sbt 的集成来生成项目。
模板可以在 Github 上找到,地址为 GitHub - scala-fundamentals/scala-play.g8: 用于在线购物的模板。这个模板是从 github.com/vmunier/play-scalajs.g8. 分支出来的。我们基本上将框架测试从 Specs2 更改为 ScalaTest,并添加了我们购物项目所需的全部依赖项。
要创建项目,请在您的控制台中输入以下内容:
sbt new scala-fundamentals/scala-play.g8 --name=shopping --organization=io.fscala
这将创建一个包含我们项目所需的所有文件和文件夹的新文件夹。
您现在可以将此项目导入 IntelliJ,点击导入项目,在第一个对话框中选择 sbt:

点击下一步,在下一个对话框中,请勾选使用 sbt shell 进行构建和导入(需要 sbt 0.13.5+)选项,如下所示:

就这样,我们为实施做好了准备。
持久性
在在线购物项目的背景下,我们将创建一个简单的数据模型,只包含两个表——购物车和产品表。产品代表我们想要销售的商品。它有一个名称、一个代码、一个描述和一个价格。
购物车是客户即将购买的商品。它有一个 ID,任何新创建的购物车都会自动递增,还有一个用户,代表用户标识。为了本书的目的,我们将使用登录过程中发送的用户名。购物车还有一个数量和代码,代表与产品表的链接。
下面的图表示我们的模型:

为了本书的目的,我们将选择一个无需管理的数据库,速度快,占用空间小,并且可以快速顺利地部署的数据库。H2 数据库满足所有这些要求。
为了访问我们的数据,我们希望利用 Scala 语言在编译时静态检查我们的代码。Slick 库非常适合这项任务。
Slick 可以为多个数据库生成 SQL,并支持以下 RDBMS(以及相应的 JDBC 驱动程序版本):
| 数据库 | JDBC 驱动程序 |
|---|---|
| SQLServer 2008, 2012, 2014 | jTDS - SQL Server and Sybase JDBC driver (sourceforge.net/projects/jtds/) and Microsoft JDBC Driver 6.0 for SQL Server (www.microsoft.com/en-gb/download/details.aspx?id=11774) |
| Oracle 11g | www.oracle.com/technetwork/database/features/jdbc/index-091264.html |
| DB2 10.5 | www-01.ibm.com/support/docview.wss?uid=swg21363866 |
| MySQL | mysql-connector-java:5.1.23 ( dev.mysql.com/downloads/connector/j/) |
| PostgreSQL | PostgreSQL JDBC Driver: 9.1-901.jdbc4 (jdbc.postgresql.org) |
| SQLite | sqlite-jdbc:3.8.7 (bitbucket.org/xerial/sqlite-jdbc/downloads/) |
| Derby/JavaDB | derby:10.9.1.0 (db.apache.org/derby/derby_downloads.html) |
| HSQLDB/HyperSQL | hsqldb:2.2.8 (sourceforge.net/projects/hsqldb/) |
| H2 | com.h2database.h2:1.4.187 (h2database.com/html/download.html) |
设置 Slick
我们需要设置 Slick 吗?在Developing a full project章节中生成的项目build.sbt文件中,请求的库在服务器部分设置。Slick 与 Play 集成良好,依赖项的完整列表如下:
libraryDependencies ++= Seq(
"com.typesafe.play" %% "play-slick" % "3.0.0",
"com.typesafe.play" %% "play-slick-evolutions" % "3.0.0",
"com.h2database" % "h2" % "1.4.196"
)
我们添加了 Slick 依赖项以及 Evolution 模块。Evolution 是一个简化模式管理的模块;我们稍后会回到这一点。
我们还不得不添加 JDBC 驱动;这是因为 Slick 并没有捆绑所有驱动。
设置数据库
数据库设置在conf/application.conf文件中完成。当需要数据库时,必须在文件中进行配置。Slick 提供了一个默认配置,名为default。对于新的数据库,将此名称替换为您的数据库名称。
我们将启用evolution并告诉它自动运行数据库创建和删除的脚本。
在我们的案例中,条目如下:
# Default database configuration
slick.dbs.default.profile="slick.jdbc.H2Profile$"
slick.dbs.default.db.driver="org.h2.Driver"
slick.dbs.default.db.url="jdbc:h2:mem:shopping"
play.evolutions.enabled=true
play.evolutions.db.default.autoApply=true
完整的配置选项可以在 Play 框架文档中找到 (www.playframework.com/documentation/2.6.x/PlaySlick)。
数据库测试
在我们开始之前,我们应该检查evolution创建的数据库是否正确,并定义预期的行为。
产品测试
我们还应该验证在应用程序启动时是否插入了三个产品行。
创建一个名为ProductDaoSpec的测试类,它扩展了PlaySpec。现在,PlaySpec是 ScalaTest 在 Play 中的集成。ProductDaoSpec类还需要扩展GuiceOneAppPerSuite特质。这个特质向 ScalaTest 套件添加了Application对象的新实例:
class ProductDaoSpec extends PlaySpec with ScalaFutures with GuiceOneAppPerSuite {
"ProductDao" should {
"Have default rows on database creation" in {
val app2dao = Application.instanceCache[ProductDao]
val dao: ProductDao = app2dao(app)
val expected = Set(
Product("PEPPER", "ALD2", "PEPPER is a robot moving with wheels
and with a screen as human interaction", 7000),
Product("NAO", "ALD1", "NAO is an humanoid robot.", 3500),
Product("BEOBOT", "BEO1", "Beobot is a multipurpose robot.",
159.0)
)
dao.all().futureValue should contain theSameElementsAs (expected)
}
}
}
Play 提供了一个辅助方法来在缓存中创建实例。正如你所见,app2dao可以创建ProductDao的实例,这是传递给instanceCache的类型参数。
Set上的匹配器不是严格的,它不考虑接收到的行的顺序。如果你想要更严格,ScalaTest 提供了theSameElementsInOrderAs匹配器,该匹配器检查集合中元素的顺序。
由于dao.all()函数返回Future,ScalaTest 提供了.futureValue辅助方法,以便在测试值之前等待Future完成。
购物车测试
那么,购物车呢?我们希望确保当应用程序运行时购物车为空,这样我们就可以向其中添加项目。
就像我们对产品所做的那样,我们将创建一个名为 CartDaoSpec 的类。测试看起来如下:
class CartDaoSpec extends PlaySpec with ScalaFutures with GuiceOneAppPerSuite {
"CartDao" should {
val app2dao = Application.instanceCache[CartDao]
"be empty on database creation" in {
val dao: CartDao = app2dao(app)
dao.all().futureValue shouldBe empty
}
"accept to add new cart" in {
val dao: CartDao = app2dao(app)
val user = "userAdd"
val expected = Set(
Cart(user, "ALD1", 1),
Cart(user, "BEO1", 5)
)
val noise = Set(
Cart("userNoise", "ALD2", 10)
)
val allCarts = expected ++ noise
val insertFutures = allCarts.map(dao.insert)
whenReady(Future.sequence(insertFutures)) { _ =>
dao.cart4(user).futureValue should contain theSameElementsAs expected
dao.all().futureValue.size should equal(allCarts.size)
}
}
}
}
数据库创建时为空 测试确保在应用程序创建时没有购物车存在,而 接受添加新购物车 确保我们可以将产品插入到特定的购物车中;当读取购物车时,只返回该购物车的产品。这通过我们在 user2 的购物车中添加新产品而不是 user1 来测试。
为了保持一致性,我们希望在数据库中有一个约束,其中只有一个唯一的元组 user 和 productCode。如果没有唯一的配对,我们应该期望数据库抛出一个错误,表示购物车已存在:
"error thrown when adding a cart with same user and productCode" in {
val dao: CartDao = app2dao(app)
val user = "userAdd"
val expected = Set(
Cart(user, "ALD1", 1),
Cart(user, "BEO1", 5)
)
val noise = Set(
Cart(user, "ALD1", 10)
)
val allCarts = expected ++ noise
val insertFutures = allCarts.map(dao.insert)
recoverToSucceededIf[org.h2.jdbc.JdbcSQLException]{
Future.sequence(insertFutures)
}
}
在 expected.map(dao.insert(_)) ++ noise.map(dao.insert(_)) 中,我们通过添加预期的购物车插入和噪声购物车插入的 Future 来创建一个 Future 的 Set。
要测试是否抛出错误,ScalaTest 提供了 recoverToSucceededIf[T] 函数,该函数测试作为参数传递的 Future 是否抛出类型 [T] 的错误。
我们还希望测试是否可以从购物车中移除一个项目。
以下代码将执行此测试:
"accept to remove a product from a cart" in {
val dao: CartDao = app2dao(app)
val user = "userRmv"
val initial = Vector(
Cart(user, "ALD1", 1),
Cart(user, "BEO1", 5)
)
val expected = Vector(Cart(user, "ALD1", 1))
whenReady(Future.sequence(initial.map(dao.insert(_)))) { _ =>
dao.remove(ProductInCart(user, "BEO1")).futureValue
dao.cart4(user).futureValue should contain theSameElementsAs
(expected)
}
}
首先,我们添加一个包含两个产品的初始购物车,然后,我们从购物车中移除一个产品。注意,我们引入了一个新的类,名为 ProductInCart,它代表购物车中的一个产品。
为了完整,我们的 CartDao 应该接受更新购物车中的产品数量;这由以下代码表示:
"accept to update quantities of an item in a cart" in {
val dao: CartDao = app2dao(app)
val user = "userUpd"
val initial = Vector(Cart(user, "ALD1", 1))
val expected = Vector(Cart(user, "ALD1", 5))
whenReady(Future.sequence(initial.map(dao.insert(_)))) { _ =>
dao.update(Cart(user, "ALD1", 5)).futureValue
dao.cart4(user).futureValue should contain theSameElementsAs
(expected)
}
}
在这个测试中,我们首先将 userUpd 的购物车设置为 1 单位的 ALD1,然后将其更新为 5 单位的 ALD1。
当然,因为没有实现,测试甚至无法编译;是时候创建数据库并实现 数据访问对象 (DAO)了。在继续之前,请注意带有 .futureValue 的代码片段。这是解释 Futures 是什么的完美时机。
Future
如您在测试代码中所见,wsClient.url(testURL).get() 返回 Future;更确切地说,它返回 Response 的 Future (Future[Response])。
Future 代表一个异步执行的代码片段。代码在创建 Future 时开始执行,并不知道何时会完成执行。
到目前为止,一切顺利;但我们如何获取结果?
在我们回答这个问题之前,有一些重要的观点需要理解。编写异步代码的目的是什么?
我们将其写入以提高性能。确实,如果代码以并行方式运行,我们可以利用现代 CPU 上可用的多个核心。这一切都很好,但在我程序中,我不能并行化每一块代码。有些代码依赖于来自其他代码的值。
如果我能够以这种方式组合代码,一旦值完成评估,程序就继续使用该变量,那岂不是很好?这正是Future的目的。你可以将异步代码片段组合在一起;组合的结果是另一个Future,它可以与另一个Future组合,依此类推。
获取具体值
好的,我们可以组合 Future 以得到新的 Future,但到了某个时候,我们需要一个具体的值而不是Future。
当我们请求获取 REST 调用的响应时,我们从函数中收到Future。Future的特殊之处在于我们不知道它何时会完成,因此在我们的测试中我们需要等待直到我们得到Future的具体值。
要获取具体值,你可以等待Future完成或提供一个回调。让我们深入了解这两种情况。
等待 Future 完成
Await.result方法正在等待结果可用。我们可以给这个方法提供一个超时,这样它就不会永远阻塞。
签名如下:
Await.result(awaitable: Awaitable[T], atMost: Duration)
第一个参数等待Awaitable(Future扩展了Awaitable),第二个是Duration。Duration是在抛出TimeoutException之前等待的时间。
这是在我们的测试中获取值的一种非常方便的方法。
如果你将import scala.concurrent.duration._添加到导入部分,你可以使用领域特定语言(DSL)用普通的英语表达持续时间,如下所示:
1 second
2 minutes
回调
另一种获取结果的方法是使用回调函数。在这种情况下,我们保持异步以获取值。语法如下:
Import scala.concurrent.{Await, Future}
import scala.util.{Failure, Success}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
val f: Future[String] = Future {
Thread.sleep(1000)
“Finished”
}
f.onComplete {
case Success(value) => println(value)
case Failure(e) => e.printStackTrace()
}
首先,创建Future并将其分配给f;然后,我们处理成功情况,然后是失败情况。
组合 Future
你已经学会了如何从Future中获取具体值;现在你将学习如何组合多个 Future。
使用 for-comprehension
Future有一个map和flatMap方法。因此,正如我们在第三章中看到的,处理错误,对于Either,我们可以在for-comprehension 中使用Future。例如,我们可以定义三个 Future,如下所示:
val f1 = Future {1}
val f2 = Future {2}
val f3 = Future {3}
这些 Future 只是成功返回一个整数。如果我们想将所有整数相加,我们可以编写以下代码:
val res = for {
v1 <- f1
v2 <- f2
v3 <- f3
} yield (v1 + v2 + v3)
res变量将是Future[Int];因此,我们可以调用Await来获取最终值:
val response = Await.result(res, 1 second)
在我们的例子中,响应将是6。
你刚刚了解到在 for-comprehension 中,可以从 Future 中使用值,并且可以将这些值与其他值组合。但不仅如此;你还可以在内部添加一个 if 条件,作为过滤器。假设我们想要检查前一个例子中的三个数字相加是否大于五。如果是这样,那么它应该返回一个包含三个数字的元组;否则,它应该返回一个失败。我们可以首先定义一个函数,该函数接受一个未定义数量的参数并返回它们的 sum,如下所示:
def sum(v: Int*) = v.sum
你可以通过在参数定义中的类型后添加 * 来定义多个参数;这被称为 可变参数 或 varargs 函数。
带有我们的过滤器的 for-comprehension 将如下所示:
val minExpected = 5
val res = for {
v1 <- f1
v2 <- f2
v3 <- f3
if (sum(v1, v2, v3) > minExpected)
} yield (v1, v2, v3)
我们可以应用我们在上一节中学到的知识,并使用回调来获取值,如下所示:
res.onComplete {
case Success(result) => println(s"The result is $result")
case Failure(e) => println("The sum is not big enough")
}
以下代码将在控制台打印出来:
The result is (1,2,3)
然而,如果你将 minExpected 设置为 7,你应该得到以下结果:
The sum is not big enough
事实上,Future 是一个失败;其表示如下:
Future(Failure(java.util.NoSuchElementException: Future.filter predicate is not satisfied))
最后一件事——我确信你已经注意到了我们导入的第一段代码中的以下内容:
import scala.concurrent.ExecutionContext.Implicits.global
这个奇怪的导入是什么?它是执行上下文,将在下一节中介绍。
执行上下文
当我们创建一个 Future 时,代码在 JVM 上异步执行,但执行该代码的是什么?实际上,执行并行代码的唯一方法就是使用线程。一个简单的方法可能会建议:每次我想执行新的代码片段时,我只需创建一个新的线程。然而,这真是一个糟糕的想法。首先,线程的数量受操作系统的限制;你不能创建你想要的那么多线程。其次,你可能会遇到线程饥饿(docs.oracle.com/javase/tutorial/essential/concurrency/starvelive.html);这发生在你的 CPU 将所有时间都花在在线程之间切换上下文,而不是执行实际代码。
好吧,但你怎么管理线程的创建?这就是执行上下文的目的。你可以设置你希望管理的线程的策略。Scala 提供了一个默认策略,该策略创建并管理一个线程池;线程的数量由 JVM 可用的处理器数量自动定义。
因此,通过导入 scala.concurrent.ExecutionContext.Implicits.global,你只是在说你想使用默认策略来管理你的线程,这对于你的大部分代码来说应该是可以的。如果你,例如,正在创建从阻止调用的遗留软件获取数据的 Future,你可能需要定义自己的 ExecutionContext。
期货的汇总
Future 的基本操作是 map 和 flatMap。实际上,当我们使用 for-comprehension 时,底层编译器会将我们的循环转换为 map 和 flatMap。
在 Scala 中,未来(Futures)非常重要;我们只学到了未来的基础知识;足够理解这本书中的代码。让我们在这里停下来,回到我们的购物车。
数据库创建
在讨论未来(Futures)之前,我们正在编写测试来检查 Cart 和 Product 的行为。但是,由于类尚未定义,代码甚至无法编译。让我们开始创建数据库并实现 DAO。
我们可以使用 Evolution 在服务器第一次启动时自动创建数据库。
要这样做,我们需要在 conf/evolutions/default/ 中添加一个名为 1.sql 的脚本,其中 default 是在 configuration 文件中使用的数据库名称。这是一个 SQL 文件,包含一些标签来处理应用程序启动和停止时数据库的创建和销毁。
我们将从创建产品表开始;脚本如下所示:
# --- !Ups
CREATE TABLE IF NOT EXISTS PUBLIC.PRODUCTS (
name VARCHAR(100) NOT NULL,
code VARCHAR(255) NOT NULL,
description VARCHAR(1000) NOT NULL,
price INT NOT NULL,
PRIMARY KEY(code)
);
在此脚本中,我们可以在数据库创建时添加一些默认数据;这些数据将用于我们之前定义的测试:
INSERT INTO PUBLIC.PRODUCTS (name,code, description, price) VALUES ('NAO','ALD1','NAO is an humanoid robot.', 3500);
INSERT INTO PUBLIC.PRODUCTS (name,code, description, price) VALUES ('PEPER','ALD2','PEPPER is a robot moving with wheels and with a screen as human interaction',7000);
INSERT INTO PUBLIC.PRODUCTS (name,code, description, price) VALUES ('BEOBOT','BEO1','Beobot is a multipurpose robot.',159);
下一步是添加购物车表,如下所示:
CREATE TABLE IF NOT EXISTS PUBLIC.CART (
id BIGINT AUTO_INCREMENT,
user VARCHAR(255) NOT NULL,
code VARCHAR(255) NOT NULL,
qty INT NOT NULL,
PRIMARY KEY(id),
CONSTRAINT UC_CART UNIQUE (user,code)
);
我们刚刚创建了表。请注意,我们在购物车表中添加了一个约束;我们希望有一个具有相同 user 和 productCode 的唯一行。
我们解释了如何进行表创建和数据插入,现在我们可以专注于何时以及如何执行此脚本。
在脚本中可以看到具有特殊意义的以下一行:
# --- !Ups
这个说明告诉 Play Evolution 在应用程序启动时如何创建数据库。
我们还可以告诉 Play 在应用程序停止时如何清理数据库。说明如下:
# --- !Downs
在我们的情况下,当应用程序退出时,我们只需删除表。实际上,由于我们的数据库在内存中,我们实际上并不需要删除表;这只是为了说明这一点:
# --- !Downs
DROP TABLE PRODUCTS;
DROP TABLE CART;
您现在可以启动 Play 应用程序。一旦您浏览 index.html(http://localhost:9000/index.html),您会注意到 Play 正在请求执行脚本的权限。
点击“应用此脚本”现在!
如果脚本中出错,Play Evolution 将通知您错误,并在错误修复后提供一个“标记为已解决”按钮。
数据库现在已准备好被利用。让我们创建 Slick 架构和数据访问层。
数据访问对象(DAO)创建
要访问我们的数据库,我们需要为 Slick 定义架构以对数据库进行查询,并将所有内容包装在数据访问类中。
ProductsDao 类如下所示:
class ProductsDao @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends HasDatabaseConfigProvider[JdbcProfile] {
import profile.api._
def all(): Future[Seq[Product]] = db.run(products.result)
def insert(product: Product): Future[Unit] = db.run(products
insertOrUpdate product).map { _ => () }
private class ProductsTable(tag: Tag) extends TableProduct {
def name = columnString
def code = columnString
def description = columnString
def price = columnDouble
override def * = (name, code, description, price) <>
(Product.tupled, Product.unapply)
}
private val products = TableQuery[ProductsTable]
}
我们数据库的架构是通过一个名为 ProductsTable 的私有类来表达的。这个类是我们表的类型定义;每个列(name、code 和 description)都是通过使用 column 参数化方法定义的。数据库中列的名称由参数定义。
H2 默认是区分大小写的,并且它将所有列名转换为大写。如果你在ProductTable定义中更改列名的大小写,你会得到一个错误,表明该列不存在。你可以在 IntelliJ 中通过按cmd + Shift + U键来更改所选文本的大小写。
此模式与我们的对象模型之间的联系是通过扩展Table的参数化类型建立的。在我们的例子中,Product类如下所示:
case class Product(name: String,
code : String,
description : String,
price: Double)
这个 case 类是在models包中的Models.scala文件中定义的。
另一个有趣的价值是TableQuery[ProductsTable],赋值给products。这是一个用于对此表创建查询的对象;例如,要创建将产品添加到表中的查询,语法是products += product(其中product是一个新的product实例)。
要对数据库执行查询,你需要以下两个东西:
-
首先,你需要查询;这是通过
products(由TableQuery宏生成的查询对象)构建的。你可以构建一个查询,如products.result来获取表的所有行,或者products.filter(_.price > 10.0)来获取价格高于10.0的所有产品。 -
其次,一旦你构建了查询,你需要执行它以获取一个具体化的值。这是通过使用在
HasDatabaseConfigProvider类中定义的db变量来完成的。例如,要获取表的所有行,你可以使用db.run(products.result)。
对于products,我们只有查询所有产品并将新的Product添加到表中的可能性。这由all()和insert(product: Product)方法表示。在insert方法中,在执行查询后,我们通过使用.map { _ => () }来映射结果;这只是为了返回Unit以执行副作用。
你会注意到所有方法的返回类型都是Future;这意味着代码是由 Slick 完全异步执行的。
对于购物车,代码应该更加复杂;实际上,我们需要创建一个购物车,向其中添加产品,从购物车中移除产品,甚至更新产品的数量,如下所示:
class CartsDao @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends HasDatabaseConfigProvider[JdbcProfile] {
import profile.api._
def cart4(usr : String): Future[Seq[Cart]] =
db.run(carts.filter(_.user === usr).result)
def insert(cart: Cart): Future[_] = db.run(carts += cart)
def remove(cart: ProductInCart): Future[Int] =
db.run(carts.filter(c => matchKey(c, cart)).delete)
def update(cart: Cart): Future[Int] = {
val q = for {
c <- carts if matchKey(c, cart)
} yield c.quantity
db.run(q.update(cart.quantity))
}
private def matchKey(c: CartTable, cart: CartKey): Rep[Boolean] = {
c.user === cart.user && c.productCode === cart.productCode
}
def all(): Future[Seq[Cart]] = db.run(carts.result)
private class CartsTable(tag: Tag) extends TableCart {
def user = columnString
def productCode = columnString
def quantity = columnInt
override def * =
(user, productCode, quantity) <> (Cart.tupled, Cart.unapply)
}
private val carts = TableQuery[CartsTable]
}
我们的购物车模型由以下类定义:
abstract class CartKey {
def user: String
def productCode: String
}
case class ProductInCart(user:String, productCode: String) extends CartKey
case class Cart(user:String, productCode: String, quantity: Int) extends CartKey
在查询中,运算符与 Scala 中使用的相同,只是对于等价运算,你需要使用===运算符。
我们可以花一点时间来讲解一下update方法。正如在未来部分所解释的,你可以使用for-comprehension 来构建你的查询。在我们的例子中,我们希望更新特定用户的购物车中某个产品的数量。我们使用for-comprehension 根据用户选择购物车,然后使用接收到的购物车数量作为参数来更新产品的数量。
运行测试
我们现在已经使用 Evolution 创建了数据库,配置了 Slick,并实现了数据访问对象以访问表。
让我们执行我们最初编写的测试;它们应该能编译,并且应该全部成功。
运行DatabaseSpec以确保,如下所示:

我们的数据持久层现在已准备好使用。我们可以将这一层部署到云上,以确保部署工作顺利。
部署应用程序
服务器在我们的本地主机上运行良好,因为这个应用没有做什么。这正是执行所有部署步骤的完美时机。随着每个新功能的加入,我们将部署它并熟悉这个过程。这样,我们可以逐步解决部署问题,而不是一次性解决所有问题,通常是在压力之下。
我们已经决定在 Heroku 上部署这个应用。
Heroku 是一个平台即服务(PaaS)平台,支持多种语言,包括 Scala。由于其简单性和多功能性,部署过程简单且流畅。
设置账户
首先要做的事情是在 Heroku 平台上创建一个账户。Heroku 提供了一个免费账户,非常适合我们的使用。
前往网站(www.heroku.com/)并注册一个免费账户。您将收到来自 Heroku 的电子邮件以验证账户并设置密码。一旦您设置了密码,您将进入一个创建新应用的页面;这正是我们想要做的。
点击创建新应用按钮并选择一个应用名称。这个名称必须是唯一的,因为它将被用于在互联网上访问应用的 URL。我使用的是shopping-fs;请随意选择您想要的名称。名称应该是唯一的。如果不是,错误信息会告诉您更改名称。
选择离您位置最近的区域,然后点击创建应用按钮。应用的创建是瞬时的,您将被直接重定向到部署选项卡下的仪表板。
部署您的应用程序
在 Heroku 网站上的部署选项卡底部,您将看到部署应用的说明。首先要做的是安装 Heroku CLI;遵循此链接(devcenter.heroku.com/articles/heroku-cli)并选择您的操作系统来安装 Heroku CLI。
一旦安装了 CLI,前往 IntelliJ 并点击窗口底部的终端标签。IntelliJ 会将终端的当前路径设置为当前项目的根路径。
在终端内部,使用以下命令登录 Heroku:
heroku login
输入您的电子邮件地址和密码以登录。
如果你使用 macOS 并且使用 Keychain Access 生成和保存密码,由于某种原因,在注册时生成的密码没有保存在 Keychain 中。如果是这种情况,只需从 Heroku 仪表板注销,然后在登录表单上点击“忘记密码”。你会收到一封更改密码的电子邮件。在该页面上,你可以使用密码生成功能,Keychain 会记住它!
登录后,使用以下命令初始化 Git:
git init
然后,你需要按照以下方式将 Heroku 引用添加到 Git 中:
heroku git:remote -a shopping-fs
将shopping-fs替换为你之前选择的程序名称。
你应该在控制台看到以下输出:
set git remote heroku to https://git.heroku.com/shopping-fs.git
让我们添加该文件并在本地 Git 中提交,如下所示:
git add . git commit -am 'Initial commit'
最后一步是使用以下命令部署它:
git push heroku master
部署是在 Heroku 服务器上执行的,服务器的日志会打印到你的本地控制台。
这个过程需要一点时间。最后,你应该在日志中看到以下内容:
..........
remote: -----> Launching... remote: Released v4 remote:https://shopping-fs.herokuapp.com/ deployed to Heroku remote:
就这样;你的应用程序已经在服务器上编译、打包并执行。
让我们浏览shopping-fs.herokuapp.com/以确认。以下页面应该出现:

恭喜!你已经将你的应用程序部署到了互联网上。请注意,你的应用程序可以通过安全的 HTTP 协议免费访问,并具有有效的证书。
Heroku 配置
Heroku 是如何知道如何执行应用程序的?
Heroku 通过读取位于项目根目录的名为Procfile的文件中的说明来知道如何执行应用程序;内容如下:
web: server/target/universal/stage/bin/server -Dhttp.port=$PORT -Dconfig.file=server/conf/heroku.conf
行首的第一个指示是应用程序的类型。这可以是任何内容。web值是一个特殊类型,告诉 Heroku 这个进程只接收 HTTP 请求。
第二部分是可执行文件的路径。实际上,SBT 项目在构建过程中为我们创建了此可执行文件。
最后部分是 Play 应用程序的属性列表,即
-
-Dhttp.port,它使用 Heroku 变量$PORT设置要监听的端口 -
-Dconfig.file,它设置应用程序使用的配置文件路径。
摘要
在本章中,我们处理了持久化层。我们基于两个表,购物车和产品,创建了一个简单的模型。我们使用了一个名为 H2 的内存数据库。我们配置了一个名为 Slick 的框架,以异步方式从 H2 访问数据,并添加了一个脚本以创建表并在其上插入数据。我们了解了 Play Evolution 创建数据库所使用的机制。
已经编写了测试来定义购物车和产品对象的行为。由于数据查询是异步进行的,我们花了时间了解如何处理Future。最后,我们使用名为 Heroku 的云应用程序服务将这一层部署到云中。
在下一章中,我们将定义一个 RESTful API 来公开本章中持久化的数据。
第七章:在线购物 - REST API
在本章中,我们将解释如何使用 Play 框架开发 REST API。API是应用程序编程接口的缩写。REST的缩写代表表示性状态转移。基本上,我们将为我们的应用程序提供一个接口,以便其他程序可以与之交互。REST 是一种架构模式,它将指导我们设计我们的 API。
通常,调用我们的 API 的程序将是一个在浏览器中运行的用户界面,我们将在下一章中实现它。它也可能是另一个后端应用程序,这可能来自另一个程序,等等。
在本章中,我们将涵盖以下主题:
-
REST 原则
-
实现具有持久性的 API
-
Swagger
-
在 Heroku 上部署
REST API
REST API 的目标是从浏览器中的用户界面与购物车进行交互。主要交互如下:
-
创建购物车
-
在购物车中添加、删除和更新产品
我们将遵循 2000 年由 Roy Fielding 定义的 REST 架构原则来设计我们的 API。
可以在Fielding 论文,第五章,表示性状态转移(REST)中找到 REST API 的正式描述,www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm。
架构的主要原则如下:
-
它基于客户端-服务器架构,这意味着服务器可以为多个客户端提供服务。
-
它应该是无状态的——服务器不应该在客户端调用之间保持任何上下文。上下文应由客户端保持。服务器上处理所需的所有信息都应包含在发送的消息中。
-
由于服务器上没有保持上下文,因此可以在服务器级别缓存响应。
-
系统上的每个资源都应该具有唯一的标识。由于我们的资源是网络资源,我们为此目的使用统一资源标识符(URI)。
考虑到上述所有内容,让我们设计我们的 API。在第六章“在线购物——持久性”中,定义了两个表:
-
购物车
-
产品
直观地,在本章中,我们将保持这两个对象来设计我们的 API。
对于购物车,我们希望执行以下操作:
-
添加产品
-
删除产品
-
更新产品数量
-
获取购物车中的产品列表
对于产品,我们希望执行以下操作:
-
列出产品
-
添加产品
编写测试
首先,让我们编写测试。我们将为每个 API 调用创建一个测试,以覆盖所有情况。通过这样做,我们将为每个情况定义 URI。所有测试实现都将分组在一个测试类中;让我们称它为APISpec。
我们可以创建类并定义 API 的 URL;在这个时候,APISpec类应该如下所示:
class APISpec extends PlaySpec with ScalaFutures with GuiceOneServerPerSuite {
val baseURL = s"localhost:$port/v1"
val productsURL = s"http://$baseURL/products"
val addProductsURL = s"http://$baseURL/products/add"
val productsInCartURL = s"http://$baseURL/cart/products"
def deleteProductInCartURL(productID: String) =
s"http://$baseURL/cart/products/$productID"
def actionProductInCartURL(productID: String, quantity: Int) =
s"http://$baseURL/cart/products/$productID/quantity/$quantity"
"The API" should {
val wsClient = app.injector.instanceOf[WSClient]
}
}
与DatabaseSpec一样,我们扩展了 ScalaTest 集成类PlaySpec以及一个 Play 服务器GuiceOneServerPerSuite,并定义了所有所需的 URL。我们定义了wsClient值,这是一个来自 Play 的帮助器,用于定义一个 Web 服务客户端。
我们将从一个产品 API 的测试开始,更具体地说,是从产品列表开始。测试如下:
"list all the product" in {
val response = Await.result(
wsClient.url(productsURL).get(),
1 seconds)
response.status mustBe OK
}
WSClient是一个方便的类,用于执行 REST 调用;我们只需要设置 URL 并调用 HTTP 方法。
让我们定义添加产品的情况,如下所示:
"add a product" in {
val newProduct =
"""
{
"name" : "NewOne",
"code" : "New",
"description" : "The brand new product",
"price" : 100.0
}
"""
val posted = wsClient.url(addProductsURL).
post(newProduct).futureValue
posted.status mustBe OK
}
首先,我们定义要插入的新产品。请注意,我们正在使用 JSON 格式的字符串表示新产品的形式。我们本来可以将其定义为对象,但这一点将在本章后面进行说明。为了向数据库添加内容,我们使用HTTP POST方法。
我们现在完成了产品部分。现在,我们需要添加新的测试,用于列出购物车中的所有产品、向购物车添加产品、从购物车中删除产品以及更新购物车中产品的数量。相应的单元测试如下:
"add a product in the cart" in {
val productID = "ALD1"
val quantity = 1
val posted = wsClient.url(actionProductInCartURL(productID,
quantity)).post("").futureValue
posted.status mustBe OK
}
"delete a product from the cart" in {
val productID = "ALD1"
val quantity = 1
val posted = wsClient.url(deleteProductInCartURL(productID))
.delete().futureValue
posted.status mustBe OK
}
"update a product quantity in the cart" in {
val productID = "ALD1"
val quantity = 1
val posted = wsClient.url(actionProductInCartURL(productID,
quantity))
.post("").futureValue
posted.status mustBe OK
val newQuantity = 99
val update = wsClient.url(actionProductInCartURL(productID,
newQuantity)).put("").futureValue
update.status mustBe OK
}
我们已经为所有函数定义了基本测试。当我们运行ApiSpec时,所有测试都将因为错误404 was not equal to 200而失败。这是预期的,因为在 Play 中没有定义任何路由。
定义路由
我们在config/routes文件中定义了我们 API 的所有 URL。在这个文件中,我们定义了 URL 和代码之间的映射。在我们的例子中,文件如下所示:
# Product API
GET /v1/products W.listProduct
POST /v1/products/add W.addProduct
# Cart API
GET /v1/cart/products W.listCartProducts()
DELETE /v1/cart/products/:id W.deleteCartProduct(id)
POST /v1/cart/products/:id/quantity/:qty W.addCartProduct(id,qty)
PUT /v1/cart/products/:id/quantity/:qty W.updateCartProduct(id,qty)
为了更清晰,我们将controllers.WebServices包压缩为W,以适应页面宽度。
对于每一行,如果它以#开头,则该行是注释;否则,第一列定义要执行的 HTTP 操作,后面跟着上下文 URL。最后,最后一列是带有参数(如果有)要调用的方法。
在 URL 级别,你可以使用通配符:来在路径中定义一个变量;这个变量可以在方法调用中使用。例如,id变量在cart/products/:id路径中定义,然后在controllers.Cart.deleteProduct(id)方法调用中使用。
我们现在已经定义了 Play 将要创建的路由;下一步是定义这个路由文件中定义的方法。
为了做到这一点,在controllers文件夹中创建一个名为WebServices的新文件。在这个文件中,实现如下:
@Singleton
class WebServices @Inject()(cc: ControllerComponents, productDao: ProductsDao) extends AbstractController(cc) {
// *********** CART Controler ******** //
def listCartProducts() = play.mvc.Results.TODO
def deleteCartProduct(id: String) = play.mvc.Results.TODO
def addCartProduct(id: String, quantity: String) =
play.mvc.Results.TODO
def updateCartProduct(id: String, quantity: String) =
play.mvc.Results.TODO
// *********** Product Controler ******** //
def listProduct() = play.mvc.Results.TODO
def addProduct() = play.mvc.Results.TODO
}
我们已经定义了所有方法,但不是编写所有实现的详细信息,我们将它设置为play.mvc.Results.TODO。在那个点上,我们可以尝试运行测试,以确保我们没有任何编译错误。
运行测试
在运行测试APISpec时,你不再应该遇到 404 错误。然而,现在测试应该会因为错误501 was not equal to 200而失败。
这是预期的。服务器现在可以找到我们 REST 调用的 URL 映射,但在我们的代码中,所有方法都是用play.mvc.Results.TODO实现的。这个特殊的返回值使服务器返回 HTTP 状态错误代码 501。
我们取得了什么成果?嗯,Play 正在为我们 API 的所有 URL 提供服务。对于每一个,它调用相关的方法,并返回一个错误代码而不是实际的实现!
检查 API
在这个阶段,引入外部工具来检查 API 可能很有趣。
的确,这些工具被广泛使用,可以使你的生活更轻松,特别是当你想向某人解释它或在不同的服务器上执行多个调用时。
这些工具如下:
-
Paw:这是一个付费工具,仅适用于 macOS。您可以在
paw.cloud/查看它。 -
邮差:这是一个免费的多平台应用程序。邮差是一个谷歌 Chrome 扩展。您可以在
chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?hl=en查看它。
一旦安装了这些工具之一,就在 IntelliJ 中启动项目,然后浏览到,例如,http://localhost:9000/cart/products。你应该会收到错误 501 Not Implemented:

观察 TODO 部分中的错误
所有的定义 URL 都将具有相同的行为。
这个工具的优势在于你可以看到请求和响应的所有细节。这对于理解带有所有头信息、URL 参数等的 HTTP 协议非常有用。
使用持久性实现 API
在这一章中,我们创建了没有实现的 API 路由。在前一章,第六章,“在线购物 – 持久性”中,我们创建了数据库以持久化购物车。现在是时候将 API 与持久性绑定在一起了。
完成产品测试
我们不仅想检查 HTTP 状态,还想检查返回的内容;例如,对于产品列表,我们想确保我们收到了所有默认产品:
"list all the products" in {
val response = wsClient.url(productsURL).get().futureValue
println(response.body)
response.status mustBe OK
response.body must include("PEPER")
response.body must include("NAO")
response.body must include("BEOBOT")
}
在这个测试中,我们查看响应体并确保存在三个默认产品。
同样,为了检查“添加产品”功能,我们首先添加产品,然后调用产品列表:
"add a product" in {
val newProduct =
"""
{
"name" : "NewOne",
"code" : "New",
"description" : "The brand new product",
"price" : 100.0
}
"""
val posted = wsClient.url(addProductsURL).post(newProduct).
futureValue
posted.status mustBe OK
val response = wsClient.url(productsURL).get().futureValue
println(response.body)
response.body must include("NewOne")
}
实现产品 API
所有测试都已准备就绪,因此让我们为所有方法实现 API。
产品列表
我们将要实现的第一种方法是产品列表。正如你从第六章,“在线购物 – 持久性”中记得的那样,我们在创建应用程序时创建了一个默认产品列表。因此,我们的第一个任务将是获取这些产品并将它们作为 JSON 对象发送回去。
由于我们已经编写了数据访问层,从数据库中提取数据相当简单。实际上,我们只需要从产品 DAO 实例调用 all() 方法,如下所示:
def listProduct() = Action.async { request =>
val futureProducts = productDao.all()
for (
products <- futureProducts
) yield (Ok(products.mkstring(",")))
}
productDao.all() 方法返回数据库中的所有产品作为 Future[Seq[Product]]。我们可以重复使用上一章学到的知识,并使用 for 理解从 Future 中提取 products 变量中的产品序列。
由于 .mkstring(","),我们从 products 变量返回一个包含所有产品的字符串,产品之间用逗号分隔。
但我们还没有完成。我们提到过,我们希望返回这个产品序列的 JSON 表示形式。因此,我们需要某种机制将我们的案例类实例转换为 JSON。
我们可以改进代码并手动创建它,但有许多优秀的库可以帮助我们完成这项工作。最好使用其中之一,并减少样板代码。
使用 Circe 编码 JSON
有许多 Scala 库可以操作 JSON,那么我们为什么选择 Circe 呢?
Circe 是一个非常干净的 JSON 框架,性能良好,但我们真正选择它的原因是 Circe 提供了完整的文档,其中解释了所有用于处理 JSON 的原则。Circe 在底层使用 Cats,我们在第三章中使用了它,处理错误。Cats 是来自 Typelevel 的库。Typelevel 是一个对函数式编程新入门者非常友好的社区。它提供了大量的优秀文档;您可以在typelevel.org/查看。实际上,如果您想深入了解函数式编程,这个地方是开始的地方!Circe 的缺点是它的传递依赖项数量较多;因此,在服务器应用程序中使用它是可以的,但如果您想要更小的占用空间,它可能有点重。
为了将 Circe 与 Play 集成,我们可以使用 Jilen 在github.com/jilen完成的集成。我们已经将依赖项添加到模板中,但为了参考,以下需要在 build.sbt 中的 libraryDependencies 中添加:
libraryDependencies += "com.dripower" %% "play-circe" % "2609.0"
然后,我们需要将 Circe 特质添加到我们的控制器中,如下所示:
class WebServices @Inject()(cc: ControllerComponents, productDao: ProductsDao) extends AbstractController(cc) with Circe
我们将导入所需的类,如下所示:
import play.api.libs.circe.Circe
import io.circe.generic.auto._
import io.circe.syntax._
我们几乎完成了;我们需要将 .mkstring(",") 替换为 .asJson。就这样!
最终的代码如下:
def listProduct() = Action.async { request =>
val futureProducts = productDao.all()
for(
products <- futureProducts
) yield (Ok(products.asJson))
}
现在,我们可以运行 APISpec;我们应该有您为 API 编写的第一个工作测试了!
动作定义
在前面的代码中,for 理解从数据库中检索产品并将它们转换为 JSON。我们已经熟悉这种语法,但 Action.async 和 Ok() 呢?
在 Play 中,所有的 Action 元素都是异步的。Action 的预期返回值是一个状态,它表示 HTTP 状态码(OK() = 200 OK,Created() = 201 CREATED 等)。
如您可能已经注意到的,for推导式的返回类型是包裹在Future中的状态。Action.async辅助函数允许我们从这个Future创建一个异步的Action。
添加产品
下一个要实现的方法是将产品添加到数据库中的能力。
如果你被启动新实现时 IntelliJ 显示的错误所烦恼,你可以添加一个虚拟返回状态。
首先,我们将返回一个虚拟状态以避免 IntelliJ 错误:
def addProduct() = Action.async { request =>
Future.successful(Ok)
}
然后,我们将完成实现,如下所示:
def addProduct() = Action.async { request =>
val productOrNot = decode[Product]
(request.body.asText.getOrElse(""))
productOrNot match {
case Right(product) => {
val futureInsert = productDao.insert(product).recover {
case e => {
Logger.error("Error while writing in the database", e)
InternalServerError("Cannot write in the database")
}
futureInsert.map(_ => Ok)
}
case Left(error) => {
Logger.error("Error while adding a product",error)
Future.successful(BadRequest)
}
}
}
我们期望在请求体中作为 JSON 有效载荷接收新产品详情。因此,在第一行,我们将请求体作为文本获取;如果它未定义,我们将其替换为空字符串。
Circe 提供了一个名为decode的方法,它接受一个字符串作为参数并将其转换为对象。类型参数(在我们的例子中是[Product])定义了目标对象的类。
这个decode方法返回一个Either实例。如果有错误,它将是Left,如果解码工作正常,它将是Right。我们可以对这个Either值进行模式匹配以返回Ok;或者在错误的情况下返回BadRequest状态。
当解码工作正常时,我们调用productDao.insert将新产品存储到数据库中。如果在插入过程中发生任何错误,.recover块将返回内部服务器错误。
完成购物车测试
我们首先想做的事情是测试客户购物车中的产品列表。但我们如何确保客户只能看到他们的购物车,而不是别人的?
登录
为了解决这个问题,我们将在 API 上添加一个login端点。这个端点将创建一个会话 cookie,并将会话 ID 存储在其中。这样,每次向服务器发送请求时,都会传递会话 ID。然后服务器将能够将一个会话 ID 与一个特定的客户关联起来。
单元测试
当客户端调用登录 URL 时,服务器响应一个Set-Cookie头。在这个头中,可以通过使用PLAY_SESSION键来获取加密的会话数据。
以下是对新login端点的单元测试:
"return a cookie when a user logins" in {
val cookieFuture = wsClient.url(login).post("myID").map {
response =>
response.headers.get("Set-Cookie").map(
header => header.head.split(";")
.filter(_.startsWith("PLAY_SESSION")).head)
}
}
val loginCookies = Await.result(cookieFuture, 1 seconds)
val play_session_Key = loginCookies.get.split("=").head
play_session_Key must equal("PLAY_SESSION")
}
测试向login端点发送POST请求。目前,我们发送一个虚拟有效载荷myID,它代表一个用户标识符。一旦发布,我们将返回的响应映射以获取Set-Cookie头。这个header包含多个值,由分号分隔。我们不对其他值感兴趣;因此,我们需要处理这个header以获取只有以PLAY_SESSION键开始的元素。
我们将转换后的响应分配给一个值:cookieFuture。然后我们等待Future完成;然后,响应在=上分割,只保留键并检查。
你现在可以运行测试;它应该失败,显示 404 Not Found 错误。
实现
首先,我们需要将新端点添加到routes文件中,如下所示:
# Login
POST /v1/login controllers.WebServices.login
使用这个新条目,Play 会对POST动作做出反应,并调用Webservices类的login方法。
这个login方法的实现如下:
def login() = Action { request =>
request.body.asText match {
case None => BadRequest
case Some(user) => Ok.withSession("user" -> user)
}
}
如果存在具有用户名的主体,则返回OK状态,并带有新的会话 cookie。用户名被添加到 cookie 中,使用user键,可以在后续请求中检索。
再次运行APISpec;现在登录测试应该是绿色的。
传递 cookie
从现在起,每次我们与购物车交互时,都必须传递带有会话 ID 的 cookie 来绑定我们的用户与购物车。获取购物车产品列表的测试如下:
"list all the products in a cart" in {
val loginCookies =
Await.result(wsClient.url(login).post("me").map(p =>
p.headers.get("Set-Cookie").map(_.head.split(";").head)), 1
seconds)
val play_session = loginCookies.get.split("=").tail.mkString("")
val response = (wsClient.url(productsInCartURL).
addCookies(DefaultWSCookie("PLAY_SESSION",
play_session)).get().futureValue
println(response)
response.status mustBe OK
val listOfProduct = decode[Seq[Cart]](response.body)
listOfProduct.right.get mustBe empty
}
首先,我们调用login端点来构建会话 cookie;然后,在第二次调用中传递 cookie。为了检查购物车中的产品数量,我们使用 Circe 将 JSON 响应转换为购物车序列。
由于会话 cookie 必须被所有后续测试使用,我们可以将获取 cookie 的代码移动到lazy val中,如下所示:
lazy val defaultCookie = {
val loginCookies = Await.result(wsClient.url(login).post("me")
.map(p => p.headers.get("Set-Cookie").map(
_.head.split(";").head)), 1 seconds)
val play_session = loginCookies.get.split("=").tail.mkString("")
DefaultWSCookie("PLAY_SESSION", play_session)
}
lazy关键字表示代码将在首次使用值时尽可能晚地评估。
然后,我们可以重构我们的测试以使用它,如下所示:
"list all the products in a cart" in {
val response = wsClient.url(productsInCartURL)
.addCookies(defaultCookie).get().futureValue
response.status mustBe OK
val listOfProduct = decode[Seq[Cart]](response.body)
listOfProduct.right.get mustBe empty
}
检查添加产品到购物车的操作,如下所示:
"add a product in the cart" in {
val productID = "ALD1"
val quantity = 1
val posted = wsClient.url(actionProductInCartURL(productID,
quantity)).addCookies(defaultCookie).post("").futureValue
posted.status mustBe OK
val response = wsClient.url(productsInCartURL)
.addCookies(defaultCookie).get().futureValue
println(response)
response.status mustBe OK
response.body must include("ALD1")
}
我们必须能够从购物车中删除产品,如下所示:
"delete a product from the cart" in {
val productID = "ALD1"
val posted = wsClient.url(deleteProductInCartURL(productID))
.addCookies(defaultCookie).delete().futureValue
posted.status mustBe OK
val response = wsClient.url(productsInCartURL)
.addCookies(defaultCookie).get().futureValue
println(response)
response.status mustBe OK
response.body mustNot include("ALD1")
}
最后一个测试是更新购物车中产品的数量,如下所示:
"update a product quantity in the cart" in {
val productID = "ALD1"
val quantity = 1
val posted = wsClient.url(actionProductInCartURL(productID,
quantity)).addCookies(defaultCookie).post("").futureValue
posted.status mustBe OK
val newQuantity = 99
val update = wsClient.url(actionProductInCartURL(productID,
newQuantity)).addCookies(defaultCookie).put("").futureValue
update.status mustBe OK
val response = wsClient.url(productsInCartURL)
.addCookies(defaultCookie).get().futureValue
println(response)
response.status mustBe OK
response.body must include(productID)
response.body must include(newQuantity.toString)
}
由于测试现在已定义,让我们实现端点。
列出购物车中的产品
实现中似乎缺少了某些内容;实际上,在我们的WebService类中我们没有cartDao实例。
要添加它,只需将其作为新参数添加;由于所有参数都是注入的,Play 会自动为您完成。WebService类定义变为以下代码:
class WebServices @Inject()(cc: ControllerComponents, productDao: ProductsDao, cartsDao: CartsDao) extends AbstractController(cc) with Circe {
获取所有产品的实现如下:
def listCartProducts() = Action.async { request =>
val userOption = request.session.get("user")
userOption match {
case Some(user) => {
Logger.info(s"User '$user' is asking for the list of product in
the cart")
val futureInsert = cartsDao.all(user)
futureInsert.map(products => Ok(products.asJson)).recover {
case e => {
Logger.error("Error while writing in the database", e)
InternalServerError("Cannot write in the database")
}
}
}
case None => Future.successful(Unauthorized)
}
}
addCartProduct的实现如下:
def addCartProduct(id: String, quantity: String) =
Action.async { request =>
val user = request.session.get("user")
user match {
case Some(user) => {
val futureInsert = cartsDao.insert(Cart(user, id,
quantity.toInt))
futureInsert.map(_ => Ok).recover {
case e => {
Logger.error("Error while writing in the database", e)
InternalServerError("Cannot write in the database")
}
}
}
case None => Future.successful(Unauthorized)
}
}
看起来,addCartProduct和listCartProducts的.recover部分函数中的代码是相同的;为了避免代码重复,我们可以提取如下:
val recoverError: PartialFunction[Throwable, Result] = {
case e: Throwable => {
Logger.error("Error while writing in the database", e)
InternalServerError("Cannot write in the database")
}
}
我们可以重构产品列表并添加产品以使用新变量。删除产品动作如下:
def deleteCartProduct(id: String) = Action.async { request =>
val userOption = request.session.get("user")
userOption match {
case Some(user) => {
Logger.info(s"User '$user' is asking to delete the product
'$id' from the cart")
val futureInsert = cartsDao.remove(ProductInCart(user, id))
futureInsert.map(_ => Ok).recover(recoverError)
}
case None => Future.successful(Unauthorized)
}
}
最后,更新产品动作如下:
def updateCartProduct(id: String, quantity: String) = Action.async {
request =>
val userOption = request.session.get("user")
userOption match {
case Some(user) => {
Logger.info(s"User '$user' is updating the product'$id' in it
is cart with a quantity of '$quantity")
val futureInsert = cartsDao.update(Cart(user, id,
quantity.toInt))
futureInsert.map(_ => Ok).recover(recoverError)
}
case None => Future.successful(Unauthorized)
}
}
恭喜;现在所有测试都通过了!
Swagger
一个 API 需要被文档化才能使用。确实,当你想使用 API 时,你不想事先阅读完整的手册。更好的是有一个自我解释和直观的 API。
为了帮助文档和测试部分,有一个有用的框架:Swagger。
Swagger 不仅有助于编写文档;它还允许你在阅读文档的同时直接测试 API。为了使用 Swagger UI 可视化文档,你必须首先声明一个规范文件,可以是 JSON 或 YAML 格式。这个规范文件定义了构成你的 API 的所有 URL 和数据模型。
使用 Swagger 有多种方式,如下所示:
-
您可以使用 Swagger 编辑器编写您 API 的规范,Swagger 将为您生成代码框架
-
您可以直接在
route.conf文件中添加 Swagger 规范 -
您可以在代码中添加注解来生成 Swagger
specification文件
对于我们的项目,我们将通过在代码中使用注解来生成 Swagger specification 文件。
这样做的优点是所有相关的文档都将集中在一个地方。这使得保持文档与代码同步变得更容易,尤其是在代码重构时。许多 Swagger 选项可以通过添加注解进行配置。
安装 Swagger
安装已经完成,多亏了我们用来生成此项目的 Gitter8 模板,所以以下细节仅供参考。
我们使用的集成基于 swagger-api/swagger-play GitHub 仓库;请参考它以获取任何更新。我们必须在 build.sbt 文件中添加对库的引用。libraryDependencies 变量必须包含以下代码:
"io.swagger" %% "swagger-play2" % "1.6.0"
然后,必须通过将以下内容添加到 application.conf 文件中来启用模块:
play.modules.enabled += "play.modules.swagger.SwaggerModule"
从这里,我们可以在 routes 文件中添加以下路由来发布 JSON 定义:
GET /swagger.json controllers.ApiHelpController.getResources
我们希望直接从我们的服务器提供 API 文档。为了做到这一点,我们需要在 built.sbt 文件中将 swagger-ui 依赖项添加到 libraryDependencies:
"org.webjars" % "swagger-ui" % "3.10.0",
为了在 Play 中暴露 swagger-ui,需要更新 routes 文件,如下所示:
GET /docs/swagger-ui/*file controllers.Assets.at(path:String="/public/lib/swagger-ui", file:String)
Swagger UI 使用内联代码的 JavaScript。默认情况下,Play 的安全策略禁止内联代码。此外,我们希望允许来自本地主机和 Heroku(它将被部署的地方)的请求。因此,需要在 application.conf 文件中添加以下代码:
play.filters.hosts {
# Allow requests from heroku and the temporary domain and localhost:9000.
allowed = ["shopping-fs.herokuapp.com", "localhost:9000"]
}
play.filters.headers.contentSecurityPolicy = "default-src * 'self' 'unsafe-inline' data:"
好的,管道安装已完成。现在是添加我们项目定义的时候了。
声明端点
现在我们项目中已经安装了 Swagger,我们需要提供一些关于我们 API 的信息。首先,我们需要将以下内容添加到 application.conf 文件中:
api.version = "1.0.0"
swagger.api.info = {
description : "API for the online shopping example",
title : "Online Shopping"
}
然后,我们需要声明我们的控制器。为此,我们将向名为 Application 的控制器添加 @Api 注解:
@Singleton
@Api(value = "Product and Cart API")
class WebServices @Inject()(cc: ControllerComponents, productDao: ProductsDao, cartsDao: CartsDao) extends AbstractController(cc) with Circe {
运行应用程序
到目前为止,我们可以运行 Play 来查看结果。
在 IntelliJ 的 sbt 命令行标签中输入 run 然后按 Enter,如下所示:

控制台应该打印:
--- (Running the application, auto-reloading is enabled) ---
[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
(Server started, use Enter to stop and go back to the console...)
现在,您可以安全地浏览 http://localhost:9000/docs/swagger-ui/index.html?url=/v1/swagger.json,Play 将编译所有文件,过一会儿,Swagger UI 将出现,如下所示:

点击产品与购物车 API 链接。所有端点如下所示:

这看起来不错,但我们必须添加一些关于我们 API 的信息。例如,如果您点击 login 端点,就无法测试包含用户名的正文。
登录
要测试 login 端点,转到 WebService 类并在 login 定义之前添加以下注释:
@ApiOperation(value = "Login to the service", consumes = "text/plain")
@ApiImplicitParams(Array(
new ApiImplicitParam(
value = "Create a session for this user",
required = true,
dataType = "java.lang.String", // complete path
paramType = "body"
)
))
@ApiResponses(Array(new ApiResponse(code = 200, message = "login success"), new ApiResponse(code = 400, message =
"Invalid user name supplied")))
def login() = Action { request =>
ApiOperation 对象添加了操作的描述并定义了发送到主体的内容类型。在这种情况下,我们不是发送 JSON,而只是一个纯字符串。ApiImplicitParams 对象定义了发送到服务器的主体。ApiResponses 对象通知用户响应中可能返回的可能状态。login 文档现在如下所示:

如果你点击 Try it out,你可以输入一个名称并通过点击 Execute 来提交:

调用应该返回成功的状态码 200,如下所示:

产品列表
现在,我们可以在产品列表上添加一些注释,如下所示:
@ApiOperation(value = "List all the products")
@ApiResponses(Array(new ApiResponse(code = 200, message = "The list
of all the product")))
def listProduct() = Action.async { request =>
当我们使用 Swagger 调用 GET /v1/products 时,执行结果是所有产品的 JSON 表示,如下所示:

对于 addProduct,我们需要添加以下注释:
@ApiOperation(value = "Add a product", consumes = "text/plain")
@ApiImplicitParams(Array(
new ApiImplicitParam(
value = "The product to add",
required = true,
dataType = "models.Product", // complete path
paramType = "body"
)
))
@ApiResponses(Array(new ApiResponse(code = 200, message = "Product
added"),
new ApiResponse(code = 400, message = "Invalid
body supplied"),
new ApiResponse(code = 500, message = "Internal
server error, database error")))
def addProduct() = Action.async { request =>
多亏了 dataType = "models.Product" 声明,Swagger 中的模型部分显示了与 Product 案例类相对应的 JSON 模型:

购物车端点
现在,让我们用购物车中的产品列表来记录购物车部分:
@ApiOperation(value = "List the product in the cart", consumes =
"text/plain")
@ApiResponses(Array(new ApiResponse(code = 200, message = "Product
added"),
new ApiResponse(code = 401, message = "unauthorized, please login
before to proceed"),
new ApiResponse(code = 500, message = "Internal server error,
database error")))
def listCartProducts() = Action.async { request =>
如果我们调用 listCartProducts,我们会收到一个空数组。为了测试它,用一些产品完成 addCartProduct 的声明,如下所示:
@ApiOperation(value = "Add a product in the cart", consumes =
"text/plain")
@ApiResponses(Array(new ApiResponse(code = 200, message = "Product
added in the cart"),
new ApiResponse(code = 400, message = "Cannot insert duplicates in
the database"),
new ApiResponse(code = 401, message = "unauthorized, please login
before to proceed"),
new ApiResponse(code = 500, message = "Internal server error,
database error")))
def addCartProduct(
@ApiParam(name = "id", value = "The product code", required =
true) id: String,
@ApiParam(name = "quantity", value= "The quantity to add",
required = true) quantity: String) = Action.async { request
=>
在 Swagger 中,我们现在可以添加一个新的产品到购物车,如下所示:

然后,产品列表将返回以下内容:

之后,我们可以尝试更新一个产品。向 updateCartProduct 添加以下注释:
@ApiOperation(value = "Update a product quantity in the cart",
consumes = "text/plain")
@ApiResponses(Array(new ApiResponse(code = 200, message = "Product
added in the cart"),
new ApiResponse(code = 401, message = "unauthorized, please login
before to proceed"),
new ApiResponse(code = 500, message = "Internal server error,
database error")))
def updateCartProduct(@ApiParam(name = "id", value = "The product
code", required = true, example = "ALD1") id: String,
@ApiParam(name = "quantity", value= "The quantity to update",
required = true) quantity: String) = Action.async { request =>
然后,使用 Swagger 更新数量,如下所示:

更新后,产品列表返回,如下所示:

完美;最后要记录的操作是 deleteCartProduct:
@ApiOperation(value = "Delete a product from the cart", consumes =
"text/plain")
@ApiResponses(Array(new ApiResponse(code = 200, message = "Product
delete from the cart"),
new ApiResponse(code = 401, message = "unauthorized, please login
before to proceed"),
new ApiResponse(code = 500, message = "Internal server error,
database error")))
def deleteCartProduct(@ApiParam(name = "id", value = "The product
code", required = true) id: String) = Action.async { request =>
我们现在有了我们 API 的完整 Swagger 文档,用户可以直接从他们的浏览器中测试它。
在 Heroku 上部署
API 现在已经完成。我们可以将其部署到 Heroku,使其在互联网上可用。由于我们已经在上一章中设置了 Heroku,所以只需一个命令就可以完成部署。从项目根目录的命令行中,输入以下命令:
git push heroku master
部署完成后,你可以浏览到 shopping-fs.herokuapp.com/docs/swagger-ui/index.html?url=/v1/swagger.json。
恭喜!你现在可以在线测试 API 了。
摘要
在本章中,你学习了如何设计和实现一个 REST API,同时遵守 REST 架构原则。
我们创建了测试来从客户端的角度检查 API。我们通过使用上一章中编写的 DAO 实现了所有的方法。所有的调用都是异步的,因为我们使用了 Future,这保证了我们的服务器可以处理大量的并发请求。
你还学会了如何使用优秀的 Circe 库在 Scala 中进行 JSON 的编码和解码。最后,我们添加了一个 Web 界面来文档化和测试 API,使用了 Swagger。
在下一章中,我们将使用这个 API 来创建一个基于 Scala.js 的 Web 界面。
第八章:在线购物 - 用户界面
在本章中,我们将使用 Scala.js 来构建用户界面。在这个界面中,你可以选择产品添加到购物车,更新你希望购买的产品数量,如果需要,也可以从购物车中移除它们。
Scala.js 是由 Sébastien Doeraene 在 2013 年启动的项目。该项目已经成熟,提供了一种干净的方式来构建前端应用程序。实际上,你可以使用强类型系统来避免愚蠢的错误,但这不仅是为了强类型;用 Scala 编写的代码被编译成高度高效的 JavaScript。它可以与所有的 JavaScript 框架互操作。此外,代码可以在前端和后端开发者之间共享。这个特性简化了开发者之间的沟通,因为他们使用的是相同的概念和类。
由于其互操作性,Scala.js 有多种使用方式。你可以使用 HTML 模板并将其适配以与 Scala.js 互操作。例如,你可以购买优秀的 SmartAdmin (wrapbootstrap.com/theme/smartadmin-responsive-webapp-WB0573SK0) 模板(HTML5 版本)作为布局和所有组件/小部件的基础,然后使用 Scala.js 实现特定的行为。
另一种方法是从头开始构建 HTML 布局、CSS、组件和行为,使用 Scala.js 生态系统。这是本书中我们将选择的方法。为了生成 HTML 和 CSS,我们将使用 Li Haoyi (www.lihaoyi.com/) 的 ScalaTags (www.lihaoyi.com/scalatags/)。
本章将解释如何使用 Scala.js 开发动态 Web UI。
我们将涵盖以下主题:
-
定义布局
-
创建布局
-
构建布局
-
主布局
-
产品列表面板
-
购物车面板
-
介绍 UI 管理器
学习目标
本章的目标是将用户界面引入我们的项目,并与服务器交互以从其中获取数据。
更具体地说,我们将学习以下技能:
-
如何开发简单的 Web UI
-
如何应用样式
-
如何使用 Web 服务调用与服务器交互
-
如何在客户端调试 Scala 代码
设置
注意,当你开始使用模板时,此设置已经完成。以下步骤仅供参考:
- 要启用 Scala.js 与 Play,你首先需要将以下代码添加到
project/plugins.sbt:
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.24")
addSbtPlugin("com.vmunier" % "sbt-web-scalajs" % "1.0.8-0.6")
- 在
build.sbt文件中,你需要在client变量中添加插件,通过以下代码实现:
.enablePlugins(ScalaJSPlugin, ScalaJSWeb)
- 你需要在客户端配置的
libraryDependencies中添加 ScalaTags 依赖项,如下所示:
"com.lihaoyi" %%% "scalatags" % "0.6.7"
定义布局
为了本书的目的,购物车被设计如下所示:

在左侧,一个面板列出了所有产品及其详细信息。下方有一个按钮可以将产品添加到购物车中。在右侧面板中,有一个已添加到购物车的所有产品的列表。在购物车面板中,可以通过点击数字并输入正确的数字来更改产品的数量。每一行都有一个按钮可以删除列表中的产品。
创建布局
使用<html>和<body>标签以及两个div容器(分别用于产品和购物车面板)来创建布局,使用了多种技术——更具体地说,是顶部布局——这些容器将使用名为 Twirl 的 Play 模板引擎构建。使用这个模板,div实例的产品和购物车内部的 HTML 将被 ScalaTags 填充。
让我们先创建主入口点。我们将其命名为index.html,并在服务器的view包中创建一个名为index.scala.html的文件来实现它。
内容如下:
@(title: String)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
</head>
<body>
<div class="container">
<div class="row">
<div id="productPanel" class="col-8">
<div id="products" class="row">
</div>
</div>
<div id="cartPanel" class="col-4">
</div>
</div>
</div>
@scalajs.html.scripts("client",
routes.Assets.versioned(_).toString,
name => getClass.getResource(s"/public/$name") != null)
</body>
</html>
这个文件看起来像一个标准的 HTML 文件。实际上,这个文件是一个由服务器处理的模板。
第一行以一个@字符开始。它定义了调用者传递的输入参数,但谁在调用这个模板?是Application控制器中的index()函数在调用模板,实际上,模板是通过使用页面标题来调用的。
在以@scalajs.html.scripts开始的行中,我们正在使用由sbt-web-scalajs插件提供的辅助方法。此方法检索由client Scala.js 项目生成的所有脚本。
两个div实例将由代码设置;我们将在下一章中更详细地探讨这一点。
构建页面
我们将在主页上有两个主要部分:产品列表和购物车。
为了创建布局,我们可以使用基本的 HTML 标签,如table和div,但这对于我们这个任务来说相当繁琐。相反,让我们引入一个名为 Bootstrap 的框架(getbootstrap.com/)。
这个开源框架被广泛使用,并且非常成熟。它允许我们基于网格构建一个响应式网站,包含许多组件,如通知、菜单、徽章和工具提示。Bootstrap 需要 CSS 和一些 JavaScript 库才能工作。
目前,我们只需要通过在 HTML 头部添加链接来添加 Bootstrap CSS,如下所示:
<head>
<title>@title</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0
/css/bootstrap.min.css" integrity="sha384-
Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW
/dAiS6JXm" crossorigin="anonymous">
</head>
主布局
在 Bootstrap 中,一个网格是由行组成的容器,每行有 12 列。一个class属性用于定义div的类型。
因此,在我们的情况下,我们希望产品列表占据屏幕的三分之二,购物车则占据剩余的row。
在我们的index.scala.html中的 body 结构如下:
<body>
<div class="container">
<div class="row">
<div id="productPanel" class="col-8">
<div id="products" class="row"></div>
</div>
<div id="cartPanel" class="col-4"></div>
</div>
</div>
</body>
产品列表面板
为了结构化我们的 HTML 页面,我们将创建一个名为productPanel的面板。这个面板是所有产品详细信息的容器。
产品通过名称、描述和添加到购物车的按钮来定义,如下面的截图所示:

由于我们有多个产品,我们希望在productPanel中添加每个产品,并适应productPanel的总宽度,如下面的截图所示:

为了达到这个目标,我们可以在productPanel内部重新创建一个行,其中products形成行的列,如下面的代码所示:
<div id="productPanel" class="col-8">
<div id="products" class="row">
<-- Added programatically -->
</div>
</div>
好的,我们已经完成了主要布局。现在我们需要创建 HTML 产品的表示,作为一个div,包括其名称、描述、价格以及一个添加到购物车的按钮。
这看起来几乎和我们为数据库建模产品时所做的相同。如果我们可以重用服务器端创建的模型在客户端使用,那岂不是很好?因为我们使用 Scala.js,这是可能的;实际上,我们正在使用相同的语言。这就是我们所说的同构****应用程序。
我们所需要做的就是将模型代码从服务器项目移动到共享项目。使用 IntelliJ,只需将Models.scala文件从server/app/models拖到shared/src/main/scala/io/fscala/shopping/shared。这样做,我们就可以使用相同的模型来创建我们的产品表示。
在client/src/main/scala/io/fscala/shopping下创建一个名为ProductDiv的新类。这个类代表一个产品带有添加到购物车按钮的 HTML 内容。
ProductDiv类包含Product模型,如下所示:
case class ProductDiv(product: Product) {
def content: Div = div(`class` := "col")
(getProductDescription, getButton).render
private def getProductDescription =
div(
p(product.name),
p(product.description),
p(product.price))
private def getButton = button(
`type` := "button",
onclick := addToCart)("Add to Cart")
private def addToCart = () => ???
}
主要方法是content方法。它创建产品描述和按钮。
getProductDescription方法创建一个 HTMLdiv,并为每个属性创建一个段落。
getButton()方法创建一个 HTML 按钮,并使用addToCart函数处理onclick事件。现在我们不会查看onclick事件的实现细节。
购物车面板
购物车面板是购物车的表示。它为每个添加的产品有一条线,显示项目数量、项目类型名称、总价以及一个从购物车中移除它的按钮,如下面的截图所示:

我们希望在每次添加新产品到购物车时都添加一行,如下面的截图所示:

在这种情况下,我们不需要修改主要布局,因为我们打算将每一行表示为一个带有列的行。购物车中行的模型如下所示:
case class CartLine(qty: Int, product: Product)
购物车的 HTML 内容如下所示:
def content: Div = div(`class` := "row", id := s"cart-${product.code}-row")(
div(`class` := "col-1")(getDeleteButton),
div(`class` := "col-2")(getQuantityInput),
div(`class` := "col-6")(getProductLabel),
div(`class` := "col")(getPriceLabel)
).render
之前的代码是为有四个列的行。从购物车中删除按钮的代码(getDeleteButton)如下所示:
private def getDeleteButton = button(
`type` := "button",
onclick := removeFromCart)("X").render
注意,你只需在onclick事件上添加方法名,就可以向组件发出的事件添加监听器。目前,我们不会实现这个动作,并将它留作未实现(???),如下面的代码所示:
private def removeFromCart = () => ???
表示购物车中数量(quantityInput)的input文本字段编写如下:
private def getQuantityInput = input(
id := s"cart-${product.code}-qty",
onchange := changeQty,
value := qty.toString,
`type` := "text",
style := "width: 100%;").render
再次,在onchange事件上,我们调用以下定义的changeQty函数:
private def changeQty = () => ???
产品名称(getProductLabel)编写如下:
private def getProductLabel = label(product.name).render
最后,总价格被写入getPriceLabel,如下所示:
private def getPriceLabel = label(product.price * qty).render
由于我们将有购物车中的行定义,我们可以定义购物车的div。
这个div应该提供所有行的 HTML 表示,并允许你添加购物车line。实现如下:
case class CartDiv(lines: Set[CartLine]) {
def content = lines.foldLeft(div.render) { (a, b) =>
a.appendChild(b.content).render
a
}
def addProduct(line: CartLine): CartDiv = {
new CartDiv(this.lines + line)
}
}
在创建时,CartDiv接收由lines值表示的行列表。
要获取 HTML 表示,调用content函数。在这个函数中,我们创建一个空的div,然后将每个CartLine追加到该div中。
这是通过使用foldLeft实现的。空div作为初始值被创建,然后对于每个CartLine,都会调用一个匿名函数,参数为(a, b)。a参数是前一个值(第一次迭代时的空div),而b参数是集合中的下一个CartLine。该方法体只是将CartDiv的内容追加到div中,并返回div以供下一次迭代。
我们添加了一个函数来将产品添加到div中(addProduct())。我们本可以通过创建一个可变变量来持有CartLine列表,并在每次想要添加CartLine时更新它来实现这个方法,但这不符合函数式编程的精神。
相反,从函数调用中返回一个新的CartDiv,其中包含我们添加的new CartLine。
现在我们已经定义了产品div和购物车div,是时候构建这些div实例之间的交互了。
介绍 UI 管理器
在某个时候,我们需要有一个负责用户体验工作流程的类。例如,当用户点击“添加到购物车”按钮时,产品必须在服务器级别添加到购物车中,并且用户界面需要更新。
UI 管理器负责管理用户体验的工作流程,在需要时处理与服务器的所有通信,并用作启动 Scala.js 代码的入口点。这是当应用程序在浏览器中执行时,我们客户端应用程序的主要入口点。
为了与服务器通信,我们将使用 jQuery。这个 JavaScript 库被广泛使用,并在 JavaScript 世界中是一个参考。
这是 Scala.js 的一个优点之一。我们可以从 Scala 中使用现有的 JavaScript 库,如 jQuery。要使用现有的 JavaScript 库,我们只需要定义一个接口,在 Scala.js 中称为facade。facade 可以看作是重新定义 JavaScript 类型和 JavaScript 函数签名的接口。这意味着我们需要为所有我们想要使用的 JavaScript 库创建一个 facade。幸运的是,已经存在许多为最重要的 JavaScript 框架创建的 facade。可以在 Scala 网站上找到可用 facade 的列表(www.scala-js.org/libraries/facades.html)。
将 jQuery 添加到我们的项目中
要将 jQuery 及其 facade 添加到我们的项目中,我们需要添加 Scala facade 和 JavaScript 库。
对于 facade,将以下依赖项添加到build.sbt文件中的libraryDependencies变量中:
"org.querki" %%% "jquery-facade" % "1.2"
要添加 JavaScript 库,将以下依赖项添加到jsDependencies变量中:
jsDependencies +=
"org.webjars" % "jquery" % "2.2.1" / "jquery.js"
minified "jquery.min.js"
这是我们在其中添加 WebJar 作为 JavaScript 库的第一个库。这个 WebJar 是一个将 JavaScript 库打包成 JAR 文件的仓库。
调用我们的 API
我们必须执行的第一步调用是登录到服务器。为了本书的目的,我们没有设计一个合适的登录页面。此外,登录本身也不是真正的登录,因为它接受任何用户!
每次我们浏览网站索引时,我们都会使用一个随机用户登录。
顺便问一下,我们的客户端应用程序的入口点是什么?
设置主方法
默认情况下,Scala.js 只创建一个包含所有依赖项的 JavaScript 库。要使其成为一个应用程序,你必须添加build.sbt文件的客户端配置,如下面的代码所示:
scalaJSUseMainModuleInitializer := true
一旦定义,Scala.js 会寻找包含main方法的对象,就像一个正常的 JVM 应用程序一样。我们可以在client/src/main/scala/io/fscala/shopping/client文件夹中创建该对象。创建一个名为UIManager.scala的 Scala 文件。
在main函数中,我们希望登录到 API,并使用我们之前定义的ProductDiv和CartDiv初始化接口,如下面的代码所示:
object UIManager {
val origin: UndefOr[String] = dom.document.location.origin
val cart: CartDiv = CartDiv(Set.empty[CartLine])
val webSocket: WebSocket = getWebSocket
val dummyUserName = s"user-${Random.nextInt(1000)}"
def main(args: Array[String]): Unit = {
val settings = JQueryAjaxSettings
.url(s"$origin/v1/login")
.data(dummyUserName)
.contentType("text/plain")
$.post(settings._result).done((_: String) => {
initUI(origin)
})
}
}
我们在UIManager对象上定义了三个属性:
-
第一项是
origin属性。该属性使用 Scala.js 的dom实用工具对象;我们将从其中获取document.location.origin。这代表服务器位置,包括协议、主机名和端口。在开发模式下,它看起来像http://locahost:9000。 -
第二个属性是
cart,表示CartDiv。这是为了在管理器中保持对其的引用以供以后使用。在main函数中,我们使用硬编码的用户进行登录,一旦成功,我们初始化用户界面。 -
最后一个属性是
dummyUserName,表示一个随机生成的用户名。这将简化代码,因为我们不会实现真正的登录过程。
注意我们如何从 Scala 使用 jQuery。这是外观模式的美丽之处——我们可以使用几乎与 JavaScript 相同的语法,但具有强 Scala 类型化的优势。
例如,要创建 post 调用的设置,我们可以使用 JQueryAjaxSettings 对象上的一个方法,而不是创建一个以字符串为键、任何内容为值的 Map。这样,它更不容易出错,我们可以利用 IDE 自动完成所有可能的属性。
done jQuery 函数的签名是 Function (PlainObject data, String textStatus, jqXHR)。你可以在 jQuery 网站上了解更多关于这些类型的信息:
这个函数接受三个参数,但因为我们只对第一个参数感兴趣,即 data 响应,我们可以忽略其他参数。这是 JavaScript 的一个特性。相应的外观模式的实现如下:
def done(doneCallbacks: js.Function*): JQueryDeferred = js.native
函数使用可变参数,类型后面带有星号字符。这与 JavaScript 完美匹配,在 JavaScript 中参数不是强制的。
现在是时候查看基于从服务器传来的数据创建用户界面的过程了。
初始化用户界面
为了初始化用户界面,我们需要通过 Web 服务 API 从数据库中获取所有产品——包括用户的购物车(如果有)——并将其添加到布局中。这个代码看起来如下所示:
private def initUI(origin: UndefOr[String]) = {
$.get(url = s"$origin/v1/products", dataType = "text")
.done((answers: String) => {
val products = decode[Seq[Product]](answers)
products.right.map { seq =>
seq.foreach(p =>
$("#products").append(ProductDiv(p).content)
)
initCartUI(origin, seq)
}
})
.fail((xhr: JQueryXHR, textStatus: String, textError: String) =>
println(s"call failed: $textStatus with status code:
${xhr.status} $textError")
)
}
不会令人惊讶的是,我们使用 jQuery 在 API 上执行 GET 方法。dataType 请求文本响应,这样我们就可以使用 Circe 解析响应并将其转换为 Product 序列。
但是,decode[Seq[Product]] 是否与我们在 第二章,“开发退休计算器”,REST API 中使用的相同代码,当时我们接收 JSON 并将其转换为类?
是的,我们正在使用相同的代码和相同的框架(Circe)来解码 JSON 并将类编码为 JSON!在服务器上运行的代码,编译为 JVM 字节码,与在客户端运行的代码,编译为 JavaScript 的代码相同。
一旦我们获取到产品列表,对于每一个产品,我们在 products 容器中添加 ProductDiv。再次,jQuery 被用来通过其 id 属性获取 HTML 元素。在这个阶段,jQuery 的知识比 Scala 语法更重要。
产品面板已设置。现在轮到购物车了。
initCartUI()函数负责创建表示购物车的 HTML 代码。用户的购物车从服务器获取。我们将其转换为Cart序列,并为每个序列,我们获取相应的产品以获取名称和价格。最后,我们将行追加到CartDiv中,如下面的代码所示:
private def initCartUI(origin: UndefOr[String], products: Seq[Product]) = {
$.get(url = s"$origin/v1/cart/products", dataType = "text")
.done((answers: String) => {
val carts = decode[Seq[Cart]](answers)
carts.right.map { cartLines =>
cartLines.foreach { cartDao =>
val product = products.find(
_.code == cartDao.productCode)
product match {
case Some(p) =>
val cartLine = CartLine(cartDao.quantity, p.name,
cartDao.productCode, p.price)
val cartContent = UIManager.cart.addProduct(cartLine)
.content
$("#cartPanel").append(cartContent)
case None =>
println(
s"product code ${cartDao.productCode} doesn't
exists in the catalog")
}
}
}
})
.fail((xhr: JQueryXHR, textStatus: String, textError: String) =>
println(
s"call failed: $textStatus with status code:
${xhr.status} $textError")
)
}
如果发生失败,我们只需在浏览器控制台中打印错误。
通过所有这些代码,我们的用户界面现在已初始化。我们现在可以实现对用户界面中未实现的操作。
实现 UI 操作
当应用程序启动时,用户界面是数据库中产品和用户购物车的表示。
在本章中,我们将实现添加产品到购物车、更新购买数量和从购物车中删除产品等操作。
添加产品到购物车
要将产品添加到购物车,我们必须点击产品面板上的“添加到购物车”按钮。我们需要再次编辑ProductDiv并实现addToCart方法。
如同我们在本章的“介绍 UI 管理器”部分所说,我们希望将用户界面操作委托给UIManager类,因此addToCart方法的实现如下:
private def addToCart() = () => UIManager.addOneProduct(product)
事实上,我们正在请求UIManager将产品添加到购物车。UIManager构建一个表示购物车中产品的div,如果它已经在购物车中,则不会发生任何操作。
实现如下:
def addOneProduct(product: Product): JQueryDeferred = {
val quantity = 1
def onDone = () => {
val cartContent = cart.addProduct(CartLine(quantity, product)
).content
$("#cartPanel").append(cartContent)
println(s"Product $product added in the cart")
}
postInCart(product.code, quantity, onDone)
}
使用产品代码和初始数量一,调用postInCart方法在Cart表中创建一个新条目。一旦创建,就调用onDone()方法。此方法添加了在用户界面中可视化购物车行的 HTML 元素。
postInCart方法接收productCode、数量以及一旦网络服务调用成功要调用的方法,如下面的代码所示:
private def postInCart(productCode: String, quantity: Int, onDone: () => Unit) = {
val url = s"${UIManager.origin}/v1/cart/products/$productCode
/quantity/$quantity"
$.post(JQueryAjaxSettings.url(url)._result)
.done(onDone)
.fail(() => println("cannot add a product twice"))
}
如果网络服务调用失败,我们只需在浏览器控制台中打印错误,并且不会向用户界面添加任何内容。
从购物车中删除产品
当点击与购物车条目相关的 X 按钮时,触发从购物车中删除产品的操作。这是在CartLine类中的removeFromCart()方法中实现的。这与我们在上一节中使用的方法类似。代码如下:
private def removeFromCart() =
() => UIManager.deleteProduct(product)
我们将操作委托给UIManager,实现如下:
def deleteProduct(product: Product): JQueryDeferred = {
def onDone = () => {
val cartContent = $(s"#cart-${product.code}-row")
cartContent.remove()
println(s"Product ${product.code} removed from the cart")
}
deletefromCart(product.code, onDone)
}
这次,我们调用deleteFromCart方法并删除与相关 ID 关联的行。
网络服务调用的实现如下:
private def deletefromCart(
productCode: String,
onDone: () => Unit) = {
val url = s"${UIManager.origin}/v1/cart/products/$productCode"
$.ajax(JQueryAjaxSettings.url(url).method("DELETE")._result)
.done(onDone)
}
由于 jQuery 没有delete()方法,我们必须使用ajax()方法并设置 HTTP 方法。
更新数量
要更新购物车中产品的数量,使用 HTML 输入文本。一旦值改变,我们就使用新的值更新数据库。为此目的使用输入文本的onchange()事件。
毫不奇怪,在 CartDiv 中,正如我们之前所做的那样,我们将调用委托给 UIManager,如下代码所示:
private def changeQty() =
() => UIManager.updateProduct(product)
updateProduct 的实现如下:
def updateProduct(productCode: String): JQueryDeferred = {
putInCart(product.code, quantity(product.code))
}
我们使用在 inputText 中设置的 quantity 调用网络服务。获取数量的方法是如下:
private def quantity(productCode: String) = Try {
val inputText = $(s"#cart-$productCode-qty")
if (inputText.length != 0)
Integer.parseInt(inputText.`val`().asInstanceOf[String])
else 1
}.getOrElse(1)
我们从 HTML 输入文本元素中获取数量。如果它存在,我们将其解析为整数。如果字段不存在或我们遇到解析错误(输入了字母),我们返回数量 1。
更新产品数量的网络服务调用如下:
private def putInCart(productCode: String, updatedQuantity: Int) = {
val url =
s"${UIManager.origin}/v1/cart/products/
$productCode/quantity/$updatedQuantity"
$.ajax(JQueryAjaxSettings.url(url).method("PUT")._result)
.done()
}
到这里为止。我们已经完成了购物车用户界面的实现。
是时候部署它并检查它是否正常工作了。
部署用户界面
要在项目根目录的命令行中部署,请输入以下代码:
git push heroku master
一旦部署成功,您可以通过浏览 shopping-fs.herokuapp.com/ 来查看。界面将显示如下截图所示:

您现在可以玩转界面。
调试界面
在开发过程中,我们不会在第一稿中写出正确的代码。作为人类,我们会犯错误,并且不会完美地记住我们使用的所有框架。
在本章中,我们希望提供一个调试代码的入口点。最明显的调试系统是在浏览器的控制台中打印。这是通过直接在 Scala 中使用 println() 来完成的,然后查看控制台显示的日志。
要查看控制台和其他调试工具,您必须在浏览器中启用开发者工具。我使用的是 Macintosh 上的 Safari,但如果您不想使用它,我推荐使用 Google Chrome;功能几乎相同。
在 Safari 中,通过点击菜单栏中的“显示开发”菜单旁边的复选框来启用开发者工具。
完成后,将出现一个新的“开发”菜单。打开此菜单并选择“显示 JavaScript 控制台”。在 Safari 窗口中将出现一个包含控制台的新部分。如果您点击删除购物车行按钮,控制台将打印出日志,如下截图所示:

您可以通过在控制台最后一行输入任何内容与 JavaScript 进行交互。
例如,如果您输入 $("#productPanel"),则产品 div 被选中,您可以检查它,如下截图所示:

网页的检查元素代码
您甚至可以运行测试。如果您输入 $("#productPanel").remove(),则 div 将从 dom 中移除,您的页面将看起来如下截图所示:

测试的检查元素代码
刷新页面以返回产品列表。你甚至可以从浏览器内部调试 Scala 代码。
你需要将项目置于开发模式,以便生成调试所需的必要文件(源映射文件)。
点击调试选项卡,在左侧面板的Sources/client-fast-opt.js下查找UIManager.scala,如下截图所示:

UIManager.scala 源代码
一旦选择UIManager.scala,你可以在中间面板上看到 Scala 源代码。点击第30行的侧边栏。当 UI 初始化并且产品div实例被附加时,将会设置一个断点。
如果你刷新页面,引擎将在该点停止,在右侧面板上,你将拥有所有变量,包括局部变量,例如p,代表此点要添加的产品。
点击继续脚本执行按钮,如下截图所示:

脚本将继续执行,直到集合中的下一个元素,并且右侧面板上的p变量将更新为下一个元素。
我刚刚触及了“开发”菜单所有可能性的表面。你可以有度量标准来控制页面元素加载和处理的耗时,以及检查和更改页面中的任何 HTML 元素。
更多信息,请参阅 Safari 的官方文档(support.apple.com/en-in/guide/safari-developer/welcome/mac)和 Google Chrome(developers.google.com/web/tools/chrome-devtools/)。
摘要
在本章中,我们学习了如何从头开始构建用户界面,首先创建界面的原型。
然后,我们实现了主要布局,用所有需要链接的文件表示应用程序的骨架,例如 CSS 文件和脚本。一旦布局准备就绪,我们就用 Scala 建模用户界面的不同 HTML 部分,例如产品面板和购物车面板。最后一步是创建导航系统和用户交互。为此,我们创建了一个 UI 管理器,负责所有交互。
作为旁注,我们的用户界面相当简单,没有太多交互。这就是我们选择手动编写 UI 管理器的原因。如果界面变得更加复杂,那么使用框架来管理它可能是有用的。在撰写本文时,React.js 和 Angular 是最受欢迎的两个框架。然而,请注意,框架的学习曲线可能很陡峭,并且可能会迅速过时。
另一种解决方案是使用 Akka.js,更具体地说,使用 FSM actor 来管理您的用户界面。毕竟,这是一个状态机,根据事件进行反应和行动。这将在下一章关于自动价格更新器的内容中展开。
我们还研究了浏览器提供的调试功能。到现在为止,您应该已经意识到了在编写完整解决方案的后端和前端时使用相同原则和代码的优势。
我们将在下一章中更进一步。我们将使我们的应用程序能够从外部来源获取数据,并使用 Akka/Akka.js 异步更新用户界面。
第九章:交互式浏览器
在本章中,我们将通过扩展我们的购物项目来介绍 actor 模型。扩展将包括一个通知,提供给所有连接到网站的人,关于谁正在将产品添加到/从购物车中移除。
事实上,每当有人对购物车进行操作时,都会向所有连接的浏览器广播一条消息,其中包括用户名、操作(添加或删除)和产品名称。
工作流程将如下。当有人连接到网站时,浏览器和服务器之间将打开一个 WebSocket;在服务器级别,在 Actor 内部将保留对该 WebSocket 的引用。
一旦对购物车执行操作,将包含用户名、操作和产品名称的消息将通过 WebSocket 发送到服务器;服务器将接收此消息,将其转换为警报消息,并广播给所有连接的浏览器。然后,每个浏览器将显示警报作为通知。一旦浏览器断开连接(或达到 WebSocket 超时),将从服务器中移除 WebSocket 引用。
如你所注意到的,在先前的流程中使用了 Actor 这个术语。Actor 模型的理论起源于 1973 年 (channel9.msdn.com/Shows/Going+Deep/Hewitt-Meijer-and-Szyperski-The-Actor-Model-everything-you-wanted-to-know-but-were-afraid-to-ask),自那时起,已经创建了多个语言实现。
在这本书中,我们将使用 Akka 框架。它由 Jonas Bonér 在 2009 年编写,基于由 Philipp Haller 创建的 Scala Actor 实现。我们将通过介绍它来仅触及框架的表面。要解释框架的所有功能和可能的模式,需要整本书。
在本章中,我们将解释如何使用 WebSocket 在客户端和服务器之间进行通信。
在本章中,我们将涵盖以下主题:
-
Actor 模型
-
实现服务器端
-
实现客户端
本章的目标是使用 WebSocket 在浏览器和服务器之间建立异步通信,并使用 Actor 在服务器级别处理通信。你将学习以下内容:
-
如何在客户端和服务器之间创建异步通信
-
如何使用 Actor
Actor
我们如何定义 Actor 这个术语?在我们的第一次尝试中,我们考虑使用线程模型、并发、调用栈、邮箱等来技术性地解释它。然后,我们意识到技术描述并不能反映基于 Actor 的解决方案的本质。
事实上,每次我们必须基于 Actors 设计解决方案时,我们都可以将 Actor 视为在公司工作的一个人;这个人有一个名字,也许有一个电子邮件地址(Actor 引用)。第一个重要的事实是,他并不孤单;他将与其他人互动(消息传递),从他的层级接收消息,并将其他消息传递给同事或下属(监督者)。
这个想象中的公司使用层次结构进行组织;一个监督者(用户守护者)正在检查其下属的健康状况,当出现问题时,如果监督者可以处理,他们将执行操作来修复它。如果错误无法管理,监督者将将其升级到自己的上级(监督策略),依此类推,直到达到总监(根守护者)。
与人类之间的沟通的另一个相似之处是,当你要求同事做某事而他们没有回答时。经过一段时间(一个超时),你可能会决定再次询问。如果你仍然没有收到回答,你可能会认为他们太忙了,然后询问其他人。所有这些协议都是异步执行的,并且基于你愿意等待多长时间(延迟)。
现在,有了这些概念在心中,技术上,我们可以将 Actor 定义为一个仅在一个线程上运行的轻量级进程单元,它依次处理消息;Actor 接收消息,处理它们,并根据消息可能改变其内部状态。然后,它向初始发送者或任何其他 Actor 发送另一条消息。
为了执行所有这些工作流程,Actor 需要以下内容:
-
一个参考,可以从同一房间(JVM)或远程访问
-
一个邮箱,用于排队等待接收的消息
-
一个状态,用于保存其私有状态
-
一个行为,根据接收到的消息和当前状态进行行动
-
一个子 Actor,因为每个 Actor 都可能是一个监督者
这本书的目的不是让你成为 Akka 框架的专家;相反,这本书应该为你提供基本知识,让你对基本概念感到舒适。你将学习到的概念将允许你构建一个应用程序,如果你愿意,还可以深入了解框架的其他组件。
作为参考,完整的 Akka 文档可以直接在 Akka 项目的网站上找到,网址为doc.akka.io/docs/akka/current/general/index.html。
让我们直接开始工作,看看如何在现实世界中实现这一点。
设置
为了设置我们的项目,我们需要 Akka 库,以便在服务器上创建 Actors,以及 Notify.js。Notify.js 是一个用于在浏览器上弹出通知的 JavaScript 库;我们选择这个库是因为它没有依赖其他框架。
要添加这个 JavaScript 库,只需将以下内容添加到build.sbt中的client变量的jsDependencies下:
jsDependencies ++= Seq(
...,
"org.webjars" % "notifyjs" % "0.4.2" / "notify.js")
上述代码是项目配置的代码。
实现服务器端
在服务器级别,我们需要在服务器和浏览器之间打开一个通信通道;一旦通信打开,我们需要实现消息接收并将它广播给所有已连接的浏览器,使用 Actor 来完成。
创建 WebSocket 路由
要创建路由,需要修改 conf/routes 文件,添加以下内容:
GET /v1/cart/events controllers.WebSockets.cartEventWS
注意,路由的配置方式与常规的 Web 服务调用相同;因此,对 /v1/cart/events 的 GET 调用被路由到 controllers.WebSockets 实例的 cartEventWS 方法。
接下来,我们需要在服务器模块的 controllers 包中创建 WebSockets 类,并添加 cartEventsWS 方法,如下所示:
@Singleton
class WebSockets @Inject()(
implicit actorSystem: ActorSystem,
materializer: Materializer,
cc: ControllerComponents) extends AbstractController(cc) {
def cartEventWS = WebSocket.accept[String, String] {
implicit request
=>
ActorFlow.actorRef { out =>
// handle upstream
}
}
}
}
代码行数不多,但在这个片段中发生了很多事情。
在类构造函数中,Google Guice(Play 中使用的依赖注入)将注入 ActorSystem。ActorSystem 是系统的根守护者;这是 Actor 层次结构中的顶级,对于每个 JVM 都是唯一的。
Play 在底层使用 Akka-stream;需要一个 materializer。首先,让我们解释这些新术语。Akka-stream 是 Akka 组件,用于优雅地处理流,这正是我们需要处理服务器和浏览器之间流的工具。Akka-stream 设计得很好;在流定义中有一个清晰的分离,例如数据应该从哪里获取,如何处理它以及应该移动到哪里,以及流运行时。为了定义流,有一个 领域特定语言 (DSL) 可用,而 materializer 是流的运行时。这就是为什么我们需要在我们的代码中提供 Materializer。
上游是通过 ActorFlow.actorRef { out => } 创建的,其中 out 是代表浏览器的 Actor。这个函数应该返回一个处理来自浏览器的消息的 Actor。我们稍后会回到实现细节。
总结一下,到目前为止,我们的服务器在 /v1/cart/events 上打开了一个新的路由。在那个入口点,预期会有一个 WebSocket 连接,并且对于每个新的连接,都会启动一个新的通信流。
好吧,现在是时候编写通信处理代码了;但我们想做什么呢?
实现 BrowserManager
每次接受新的连接(代表新的浏览器)时,我们希望保留对该连接的引用,以便稍后可以向它发送事件。这个连接容器由一个 Actor 处理。这个 Actor 将需要一个包含已连接浏览器列表的内部状态;我们应该添加一个新的浏览器,并在它断开连接时移除它。
要创建一个 Actor,我们使用 Akka 的 Props 类,如下所示:
val managerActor = actorSystem.actorOf(
BrowserManagerActor.props(),
"manager-actor")
这个 Actor 是从守护根 Actor 创建的;在我们的例子中,它被命名为actorSystem。从系统 Actor,我们调用actorOf方法;这个方法期望Props作为第一个参数,代表我们的 Actor 工厂,以及 Actor 的名称作为第二个参数。BrowserManagerActor由一个类和它的伴生对象组成。伴生对象用于创建 Actor 的实例,并且定义与该 Actor 相关的消息是一个好的实践,如下所示:
object BrowserManagerActor {
def props() = Props(new BrowserManagerActor())
case class AddBrowser(browser: ActorRef)
}
我们定义了用于创建 Actor 实例的props()方法。这里没有什么特别的地方;工厂是在伴生对象上定义的,这是创建 Actor 的最佳模式。在这个类中,我们还定义了该 Actor 的特定消息;在这种情况下,我们只有一个,名为AddBrowser。
BrowserManagerActor类的实现如下:
private class BrowserManagerActor() extends Actor with ActorLogging {
val browsers: ListBuffer[ActorRef] = ListBuffer.empty[ActorRef]
def receive: Receive = {
case AddBrowser(b) =>
context.watch(b)
browsers +=b
log.info("websocket {} added", b.path)
case CartEvent(user, product, action) =>
val messageText = s"The user '$user' ${action.toString}
${product.name}"
log.info("Sending alarm to all the browser with '{}' action: {}",
messageText,
action)
browsers.foreach(_ ! Alarm(messageText, action).asJson.noSpaces)
case Terminated(b) =>
browsers -= b
log.info("websocket {} removed", b.path)
}
}
要成为 Actor,该类需要扩展Actor类;我们同样扩展了ActorLogging。这将为我们提供log对象,该对象可以用来记录有趣的信息。
如前所述,我们希望保留连接到服务器的浏览器列表。为此,我们使用browsers变量,其类型为ListBuffer[ActorRef]。
注意,我们正在使用一个可变集合来定义这个列表;在这个上下文中,这是完全可行的,因为这个列表只能被这个 Actor 访问,并且保证是线程安全的。
可以通过使用 Akka 框架的另一个组件来避免这个可变变量。这个组件被命名为最终状态机(FSM)。FSM 实现的全部细节超出了本书的范围。如果您感兴趣,完整的文档链接可以在doc.akka.io/docs/akka/current/fsm.html找到。
之前我们提到过,Actor 会接收消息;这就是receive方法的作用。它是一个部分函数,其签名是Any -> Unit。为了实现这个函数,我们定义了我们想要处理的案例;换句话说,我们定义了 Actor 要处理的消息。
我们的管理员 Actor 处理了三条消息,具体如下:
-
case AddBrowser(b): 在这里,创建了一个新的连接,b代表浏览器 Actor。首先,通过执行context.watch(b),我们请求 Akka 框架监视bActor,并在它死亡时通过发送终止消息来通知我们。 -
case CartEvent(user, product, action): 在这里,来自浏览器的消息,即CartEvent。我们希望通知所有连接的浏览器关于这个事件。这是通过向我们的浏览器列表中的所有浏览器发送警报消息来完成的。注意,我们使用 Circe 将消息转换为 JSON 格式。 -
case Terminate(b): 接收这个消息是因为我们在监督浏览器 Actor。bActor 死亡,我们唯一要做的就是将其从我们的浏览器列表中移除。
我们几乎完成了。有了这个 Actor,我们跟踪连接的浏览器,并在其中任何一个发出事件时发送警报。
但等等;有些东西看起来可疑。确实,我们从未向我们的管理者发送过 AddBrowser 和 CartEvent 消息。谁应该发送它们?答案在下一节。
处理 WebSocket
回到 Websockets 类,更具体地说,到 cartEventWS 方法,我们可以完成实现,如下所示:
def cartEventWS = WebSocket.accept[String, String] { implicit request =>
ActorFlow.actorRef{out =>
Logger.info(s"Got a new websocket connection from
${request.host}")
managerActor ! BrowserManagerActor.AddBrowser(out)
BrowserActor.props(managerActor)
}
}
在记录日志后,我们通过使用 ! 命令(发音为 bang)将 AddBrowser 消息发送给管理者;这是一个语法糖,我们也可以使用 .tell() 方法。
ActorFlow.actorRef 需要使用 ActorRef 来处理 WebSocket 的上游;为此,我们通过使用 BrowserActor 伴生对象的 props 函数创建 BrowserActor,如下所示:
object BrowserActor {
def props(browserManager :ActorRef) =
Props(new BrowserActor(browserManager))
}
BrowserActor 引用管理者;实际上,管理者有责任向所有浏览器发送消息。BrowserActor 类的实现如下:
class BrowserActor(browserManager: ActorRef) extends Actor with ActorLogging {
def receive = {
case msg: String =>
log.info("Received JSON message: {}", msg)
decodeCartEvent match {
case Right(cartEvent) =>
log.info("Got {} message", cartEvent)
browserManager forward cartEvent
case Left(error) => log.info("Unhandled message : {}", error)
}
}
}
此实现获取来自套接字的所有消息,使用 Circe 将它们转换为 CartEvent,并将它们转发给浏览器管理者。
请记住,消息流可能会变得更加复杂;这就是为什么创建一个 Actor 系统图表是一个好主意。
Actors 的示意图
有时有必要使用图表来表示 Actor 流程。您的系统中 Actor 越多,整个工作流程就越难以想象,尤其是如果您有一段时间没有在代码上工作,然后又回到它。
下图是我们项目的示意图,说明了当新的浏览器连接时的工作流程:

使用这种图表,您可以清楚地了解谁在创建 Actors,也可以了解它们之间消息的顺序。
下图说明了从浏览器发送的消息:

注意,第三条消息的开始表明警报被发送到多个 WebSocket 实例。
对于 OmniGraffle 用户,您可以在 Diagramming Reactive Systems | Graffletopia (www.graffletopia.com/stencils/1540) 找到创建这些图表的模板。
现在我们已经完成了对 Actors 的查看;我们缺少许多功能,但目标是提供足够的知识,让您了解这个美丽框架的基本知识。
服务器现在已完全实现,我们可以安全地移动到客户端。
实现客户端
在接下来的章节中,我们将查看客户端。在客户端,我们必须与服务器初始化 WebSocket 连接,当产品被添加或从购物车中移除时发送 CartEvent,并在其他浏览器更改购物车时显示警报。
首先,让我们使用 WebSocket 与服务器建立连接。
添加 WebSocket
要将 WebSocket 添加到客户端,我们将使用 UIManager 对象,它是客户端的入口点。在 Scala.js 中,WebSocket 是框架的一部分;编辑 UIManager 并将其添加到其中,如下所示:
val webSocket: WebSocket = getWebSocket
创建 WebSocket 需要一些配置。我们将所有初始化封装到一个名为 getWebSocket 的函数中,如下所示:
private def getWebSocket: WebSocket = {
val ws = new WebSocket(getWebsocketUri(dom.document,
"v1/cart/events"))
ws.onopen = { (event: Event) ⇒
println(s"webSocket.onOpen '${event.`type`}'")
event.preventDefault()
}
ws.onerror = { (event: Event) =>
System.err.println(s"webSocket.onError '${event.getClass}'")
}
ws.onmessage = { (event: MessageEvent) =>
println(s"[webSocket.onMessage] '${event.data.toString}'...")
val msg = decodeAlarm
msg match {
case Right(alarm) =>
println(s"[webSocket.onMessage] Got alarm event : $alarm)")
notify(alarm)
case Left(e) =>
println(s"[webSocket.onMessage] Got a unknown event : $msg)")
}
}
ws.onclose = { (event: CloseEvent) ⇒
println(s"webSocket.onClose '${event.`type`}'")
}
ws
}
要创建 WebSocket,我们首先需要给出服务器的 URL,然后处理套接字中发生的所有事件。要获取服务器的 URL,我们使用一个名为 getWebsocketUri 的实用函数:
private def getWebsocketUri(document: Document, context: String): String = {
val wsProtocol =
if (dom.document.location.protocol == "https:")
"wss"
else
"ws"
s"$wsProtocol://${
dom.document.location.host
}/$context"
}
此函数只是检查协议,如果加密则将 WebSocket 协议定义为 wss,如果不加密则定义为 ws。然后,通过字符串插值构建完整的 URL。在生产环境中,我们通常使用 SSL,但在开发时我们不需要加密。
一旦定义了 URL,我们就定义所有套接字事件处理程序,如下所示:
-
onopen:当创建新的连接时,我们只是记录它并将事件标记为已取消,这样如果另一个处理程序收到它,它就不会被考虑。 -
onerror:只需在错误管道中记录错误。 -
onmessage:当接收到消息时,我们使用 Circe 对其进行解码并检查它是否是警报消息。如果是这种情况,我们调用notify(alarm),否则,我们只是记录我们收到了一个未知消息的事实。notify(alarm)将在后面解释。 -
onclose:再次,我们只是记录此事件。
现在,我们已经定义了套接字,并且它已经准备好使用;如果运行此代码,当页面被浏览时,将立即创建与服务器的连接。但在那之前,我们需要定义通知系统。
通知用户
为了通知用户,我们选择了一个名为 Notify.js 的 JavaScript 库。Notify.js 是一个没有依赖项的 jQuery 插件,它有一个简单的接口。我们将只实现一个方法:$.notify(string, options)。
由于 Notify.js 是一个 jQuery 插件,我们需要使用此函数扩展 jQuery。
扩展 jQuery
使用 Scala.js 扩展 jQuery 是通过扩展著名的 jQuery $ 符号来完成的。我们可以在 io.fscala.shopping.client 包中创建一个名为 Notify.scala 的文件,在客户端项目中。
在此文件中,我们可以首先使用以下代码定义扩展:
@js.native
@JSGlobal("$")
object NotifyJS extends js.Object {
def notify(msg: String, option: Options): String = js.native
}
定义了一个名为 NotifyJS 的对象,它扩展了名为 js.Object 的 Scala.js 对象。有必要通知编译器我们正在创建现有 JavaScript 库的包装器。
第一个注解是@js.native;这个注解告诉编译器实现完全是在 JavaScript 中完成的。第二个注解是@JSGlobal("$");这是为了表达我们扩展的 API 是一个 JavaScript 类,并且这个类被命名为$。最后,我们需要定义我们想要调用的函数的签名,并使用js.native作为实现;编译器将再次在我们的代码和 JavaScript 实现之间建立桥梁。
函数的参数是String(对于第一个)和Options(对于第二个)。Options需要被定义,因为这是门面的一部分。
通过阅读 Notify.js 文档(notifyjs.jpillora.com/),你可以看到有很多可用的选项,例如通知的位置和通知的动画。
从 Notify.js 文档中,我们可以获取所有选项的定义,如下所示:
{
// whether to hide the notification on click
clickToHide: true,
// whether to auto-hide the notification
autoHide: true,
// if autoHide, hide after milliseconds
autoHideDelay: 5000,
// show the arrow pointing at the element
arrowShow: true,
// arrow size in pixels
arrowSize: 5,
// position defines the notification position though uses the
defaults below
position: '...',
// default positions
elementPosition: 'bottom left',
globalPosition: 'top right',
// default style
style: 'bootstrap',
// default class (string or [string])
className: 'error',
// show animation
showAnimation: 'slideDown',
// show animation duration
showDuration: 400,
// hide animation
hideAnimation: 'slideUp',
// hide animation duration
hideDuration: 200,
// padding between element and notification
gap: 2
}
我们可以在 Scala 中创建一个Options类,如下所示:
@ScalaJSDefined
trait Options extends js.Object {
// whether to hide the notification on click
var clickToHide: js.UndefOr[Boolean] = js.undefined
// whether to auto-hide the notification
var autoHide: js.UndefOr[Boolean] = js.undefined
// if autoHide, hide after milliseconds
var autoHideDelay: js.UndefOr[Int] = js.undefined
// show the arrow pointing at the element
var arrowShow: js.UndefOr[Boolean] = js.undefined
// arrow size in pixels
var arrowSize: js.UndefOr[Int] = js.undefined
// position defines the notification position
// though uses the defaults below
var position: js.UndefOr[String] = js.undefined
// default positions
var elementPosition: js.UndefOr[String] = js.undefined
var globalPosition: js.UndefOr[String] = js.undefined
// default style
var style: js.UndefOr[String] = js.undefined
// default class (string or [string])
var className: js.UndefOr[String] = js.undefined
// show animation
var showAnimation: js.UndefOr[String] = js.undefined
// show animation duration
var showDuration: js.UndefOr[Int] = js.undefined
// hide animation
var hideAnimation: js.UndefOr[String] = js.undefined
// hide animation duration
var hideDuration: js.UndefOr[Int] = js.undefined
// padding between element and notification
var gap: js.UndefOr[Int] = js.undefined
}
@ScalaJSDefined注解告诉编译器这是一个在 Scala 中定义的类型,而不是在 JavaScript 中定义的。
然后,对于每个属性,我们在文档中检查类型候选者,并使用js.UndefOr[Int]来定义它;这种类型在undefinedJavaScript和 Scala 中的Options类型之间起到桥梁的作用。
现在我们已经为我们的门面定义了一切;我们可以使用这个门面并实现UIManager类中缺失的notify(alarm)函数,如下所示:
private def notify(alarm: Alarm): Unit = {
val notifyClass = if (alarm.action == Add) "info" else "warn"
NotifyJS.notify(alarm.message, new Options {
className = notifyClass
globalPosition = "right bottom"
})
}
首先,我们检查动作的类型以设置通知的类名,然后我们通过传递消息和通知的选项来使用notify原生调用。
我们已经完成了所有工作。现在,如果服务器正在运行,每次你在购物车中添加或删除产品时,都会向所有已连接的浏览器发送通知,如下面的截图所示:

带有购物车更新通知的购物页面
摘要
在本章中,你学习了如何在服务器和浏览器之间创建 WebSocket 通信。在服务器层面,我们保持了对所有已连接浏览器的引用,以便可以将事件分发给所有浏览器。系统的一个重要部分是在服务器层面定义的 actor 模型。我们了解到,一旦在异步系统之间有交互,actor 模型编程范式就足够了。
你了解到,当你的系统不断增长时,一个展示 Actors 之间交互的图示可能非常有用。当有人从一段时间后返回代码时,这一点尤其有用。由于我们不是调用方法,而是向ActorRef发送消息,所以在 IDE 中的导航并不容易,因此仅通过阅读代码很难理解流程。
一旦在这个框架中迈出了第一步,开发过程就会变得自然,并且接近现实世界的交互。
我们还介绍了 Akka。Akka 是一个完整的框架,分为不同的模块。我们强烈建议您访问 Akka 网站akka.io/进行一番探索。
在客户端,得益于 Scala.js,框架的集成只需几行代码即可完成;一旦在 Scala 中定义,我们就可以将后端系统中学到的所有知识应用到前端。这一点在我们需要在后端和前端之间共享代码时尤其正确。
这就结束了本章。到目前为止,你应该已经拥有了构建自己的客户端-服务器程序所需的所有信息。在接下来的章节中,我们将向您介绍如何使用 Scala 通过 Apache Spark、Kafka 和 Zeppelin 来处理和分析大量数据。
第十章:获取并持久化比特币市场数据
在本章中,我们将开发一个数据管道来获取、存储和稍后分析比特币交易数据。
在介绍 Apache Spark 之后,我们将看到如何调用 REST API 从加密货币交易所获取交易。加密货币交易所允许客户用数字货币(如比特币)兑换法定货币(如美元)。交易数据将使我们能够追踪在特定时间点的价格和数量。
然后我们将介绍 Parquet 格式。这是一种广泛用于大数据分析的列式数据格式。之后,我们将构建一个独立的应用程序,该应用程序将生成比特币/美元交易的历史记录,并将其保存为 Parquet 格式。在下一章中,我们将使用 Apache Zeppelin 进行交互式查询和分析数据。
我们将要处理的数据量不是很大,但如果数据量增加或我们需要存储更多货币或来自不同交易所的数据,所使用的工具和技术将是相同的。使用 Apache Spark 的好处是它可以水平扩展,你可以只是向你的集群添加更多机器来加速你的处理,而不需要更改你的代码。
使用 Spark 的另一个优点是它使得操作类似表格的数据结构以及从/到不同格式的加载和保存变得容易。即使数据量很小,这个优点仍然存在。
在本章中,我们将涵盖以下主题:
-
Apache Spark
-
调用加密货币交易所的 REST API
-
Parquet 格式和分区
完成本章学习后,我们将了解以下内容:
-
如何存储大量数据
-
如何使用 Spark Dataset API
-
如何使用 IO
Monad控制副作用
设置项目
创建一个新的 SBT 项目。在 IntelliJ 中,转到文件 | 新建 | 项目 | Scala | sbt。
然后编辑build.sbt并粘贴以下内容:
name := "bitcoin-analyser"
version := "0.1"
scalaVersion := "2.11.11"
val sparkVersion = "2.3.1"
libraryDependencies ++= Seq(
"org.lz4" % "lz4-java" % "1.4.0",
"org.apache.spark" %% "spark-core" % sparkVersion % Provided,
"org.apache.spark" %% "spark-core" % sparkVersion % Test classifier
"tests",
"org.apache.spark" %% "spark-sql" % sparkVersion % Provided,
"org.apache.spark" %% "spark-sql" % sparkVersion % Test classifier "tests",
"org.apache.spark" %% "spark-catalyst" % sparkVersion % Test classifier "tests",
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.0",
"org.scalatest" %% "scalatest" % "3.0.4" % "test",
"org.typelevel" %% "cats-core" % "1.1.0",
"org.typelevel" %% "cats-effect" % "1.0.0-RC2",
"org.apache.spark" %% "spark-streaming" % sparkVersion % Provided,
"org.apache.spark" %% "spark-sql-kafka-0-10" % sparkVersion %
Provided exclude ("net.jpountz.lz4", "lz4"),
"com.pusher" % "pusher-java-client" % "1.8.0")
scalacOptions += "-Ypartial-unification"
// Avoids SI-3623
target := file("/tmp/sbt/bitcoin-analyser")
我们使用 Scala 2.11,因为在写作的时候,Spark 没有为 Scala 2.12 提供其库。我们将使用以下内容:
-
spark-core和spark-sql用于读取交易并将它们保存到 Parquet。Provided配置将使 SBT 在将应用程序打包到 assembly JAR 文件时排除这些库。 -
ScalaTest 用于测试我们的代码。
-
scala-logging,这是一个方便且快速的日志库,它封装了 SLF4J。 -
使用
cats-core和cats-effects来管理我们的 IOMonad副作用。 -
为下一章准备
spark-streaming、spark-sql-kafka和pusher。
cats需要-Ypartial-unification编译器选项。
最后一行让 SBT 将类写入/tmp文件夹,以避免 Linux 加密主目录中的文件名过长错误。你可能不需要在你的平台上使用它。
如果你不想重新输入代码示例,你可以从 GitHub(github.com/PacktPublishing/Scala-Programming-Projects)检查完整的项目代码。
理解 Apache Spark
Spark 是一个开源框架,用于对大数据集进行数据分析。与其他使用内存处理的工具(如 R、Python 和 MathLab)不同,Spark 给你提供了扩展的可能性。而且,得益于其表达性和交互性,它还提高了开发者的生产力。
有整本书是专门介绍 Spark 的。它有大量的组件和许多需要探索的领域。在这本书中,我们旨在帮助你从基础知识开始。如果你想要探索文档,你应该会感到更加自在。
Spark 的目的是对集合进行数据分析。这个集合可以是内存中的,你可以使用多线程来运行你的分析,但如果你的集合变得太大,你将接近系统的内存限制。
Spark 通过创建一个对象来解决这个问题,该对象用于存储所有这些数据。Spark 不是将所有内容都保存在本地计算机的内存中,而是将数据分成多个集合,并在多台计算机上分布。这个对象被称为 RDD(即 Resilient Distributed Dataset)。RDD 保留了对所有分布式块的所有权。
RDD、DataFrame 和 Dataset
Spark 的核心概念是 RDD。从用户的角度来看,对于给定的类型 A,RDD[A] 看起来与标准的 Scala 集合,如 Vector[A] 类似:它们都是 不可变 的,并且共享许多知名的方法,如 map、reduce、filter 和 flatMap。
然而,RDD 有一些独特的特性。它们如下:
-
延迟加载:当你调用一个 转换 函数,如
map或filter时,并不会立即发生任何事情。函数调用只是被添加到一个存储在 RDD 类中的计算图中。这个计算图是在你随后调用一个 行动 函数,如collect或take时执行的。 -
分布式:RDD 中的数据被分割成几个分区,这些分区散布在集群中不同的 executors 上。一个 task 代表了一块数据以及必须对其应用的操作。
-
容错性:如果你在执行作业时,一个 executor 死亡,Spark 会自动将丢失的任务重新发送到另一个 executor。
给定两种类型,A 和 B,RDD[A] 和 RDD[B] 可以连接在一起以获得 RDD[(A, B)]。例如,考虑 case class Household(id: Int, address: String) 和 case class ElectricityConsumption(houseHoldId: Int, kwh: Double)。如果你想计算消耗超过 2 kWh 的家庭数量,你可以执行以下任何一个操作:
-
将
RDD[HouseHold]与RDD[ElectricityConsumption]连接,然后对结果应用filter。 -
首先对
RDD[ElectricityConsumption]应用filter,然后将其与RDD[HouseHold]连接
结果将相同,但性能将不同;第二个算法将更快。如果 Spark 能为我们执行这种优化就好了?
Spark SQL
答案是肯定的,该模块称为 Spark SQL。Spark SQL 位于 Spark Core 之上,允许操作结构化数据。与基本的 RDD API 不同,DataFrame API 为 Spark 引擎提供了更多信息。使用这些信息,它可以更改执行计划并优化它。
你还可以使用该模块执行 SQL 查询,就像使用关系数据库一样。这使得熟悉 SQL 的人能够轻松地在异构数据源上运行查询。例如,你可以将来自 CSV 文件的数据表与存储在 Hadoop 文件系统中的另一个 Parquet 文件中的数据表以及来自关系数据库的另一个数据表进行连接。
Dataframe
Spark SQL 由三个主要 API 组成:
-
SQL 字面量语法
-
The
DataFrameAPI -
DataSet
DataFrame在概念上与关系数据库中的表相同。数据分布的方式与 RDD 相同。DataFrame有一个模式,但未指定类型。你可以从 RDD 创建DataFrame或手动构建它。一旦创建,DataFrame将包含一个模式,该模式维护每个列(字段)的名称和类型。
如果你然后想在 SQL 查询中使用DataFrame,你所需要做的就是使用Dataframe.createTempView(viewName: String)方法创建一个命名视图(相当于关系数据库中的表名)。在 SQL 查询中,SELECT语句中可用的字段将来自DataFrame的模式,而FROM语句中使用的表名将来自viewName。
Dataset
作为 Scala 开发者,我们习惯于与类型和友好的编译器一起工作,编译器可以推断类型并告诉我们错误。DataFrame API 和 Spark SQL 的问题在于,你可以编写一个查询,如Select lastname From people,但在你的DataFrame中,你可能没有lastname列,而是有surname列。在这种情况下,你只能在运行时通过一个讨厌的异常来发现这个错误!
难道不好有一个编译错误吗?
这就是为什么 Spark 在 1.6 版本中引入了Dataset。Dataset试图统一 RDD 和DataFrame API。Dataset有一个类型参数,你可以使用匿名函数来操作数据,就像使用 RDD 或向量一样。
实际上,DataFrame实际上是一个DataSet[Row]的类型别名。这意味着你可以无缝地混合这两个 API,并在同一个查询中使用 Lambda 表达式后的过滤器,然后使用DataFrame操作符的另一个过滤器。
在接下来的章节中,我们只将使用Dataset,这是代码质量和性能之间一个好的折衷方案。
使用 Scala 控制台探索 Spark API
如果你还不熟悉 Spark,直接编写 Spark 作业可能会有些令人畏惧。为了使其更容易,我们首先将使用 Scala 控制台来探索 API。在 IntelliJ 中启动一个新的 Scala 控制台(Ctrl + Shift + D),并输入以下代码:
import org.apache.spark.sql.SparkSession
val spark = SparkSession.builder().master("local[*]").getOrCreate()
import spark.implicits._
这将初始化一个新的 Spark 会话,并引入一些有用的隐式函数。主"local[*]"URL 意味着在运行作业时,我们将使用本地主机上可用的所有核心。
Spark 会话可用,可以接受新的作业。让我们用它来创建包含单个字符串的Dataset:
import org.apache.spark.sql.Dataset
val dsString: Dataset[String] = Seq("1", "2", "3").toDS()
// dsString: org.apache.spark.sql.Dataset[String] = [value: string]
我们之前导入的隐式函数允许我们在Seq上使用.toDS()函数来生成Dataset。我们可以观察到,由 Scala 控制台调用的.toString方法是由Dataset的模式输出的——它有一个单列value,其类型为string。
然而,我们看不到Dataset的内容。这是因为Dataset是一个懒数据结构;它只存储一个计算图,直到我们调用其中一个操作方法才会进行评估。尽管如此,对于调试来说,能够评估Dataset并打印其内容是非常方便的。为此,我们需要调用show:
dsString.show()
你应该看到以下输出:
+-----+
|value|
+-----+
| 1|
| 2|
| 3|
+-----+
show()是一个操作;它将提交一个作业到 Spark 集群,在驱动程序中收集结果并打印它们。默认情况下,show限制行数为 20,并截断列。如果你想获取更多信息,可以带额外的参数调用它。
现在我们想将每个字符串转换为Int,以便获得Dataset[Int]。我们有两种方法可以实现这一点。
使用 map 转换行
在 Scala 控制台中输入以下内容:
val dsInt = dsString.map(_.toInt)
// dsInt: org.apache.spark.sql.Dataset[Int] = [value: int]
dsInt.explain()
你应该看到类似以下的内容:
== Physical Plan ==
*(1) SerializeFromObject [input[0, int, false] AS value#96]
+- *(1) MapElements <function1>, obj#95: int
+- *(1) DeserializeToObject value#91.toString, obj#94:
java.lang.String
+- LocalTableScan [value#91]
explain()方法显示了如果调用操作方法(如show()或collect())将运行的执行计划。
从这个计划中,我们可以推断出调用map不是非常高效。实际上,Spark 将Dataset的行以二进制格式存储在 off-heap 中。每次调用map时,它都必须反序列化此格式,应用你的函数,并将结果以二进制格式序列化。
使用选择转换行
转换行的更高效方法是使用select(cols: Column*):
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.types.IntegerType
val df = ds.select($"value".cast(IntegerType))
// df: org.apache.spark.sql.DataFrame = [value: int]
val dsInt = df.as[Int]
// dsInt: org.apache.spark.sql.Dataset[Int] = [value: int]
我们之前导入的隐式函数允许我们使用$"columnName"记法从String生成一个Column对象。$符号后面的字符串必须引用DataFrame源中存在的列;否则,你会得到一个异常。
然后,我们调用.cast方法将每个String转换为Int。但是,在这个阶段,生成的df对象不是Dataset[Int];类型;它是DataFrame。DataFrame实际上是Dataset[Row]的类型别名,Row类似于键值对列表。DataFrame以无类型方式表示分布式数据。编译器不知道每列的类型或名称;它们只在运行时才知道。
为了获得Dataset[Int],我们需要使用.as[Int]将元素的类型进行转换。如果DataFrame的元素无法转换为目标类型,这将导致运行时失败。
强制为你的Dataset元素指定特定类型;这将使你的程序更安全。你应该只在函数真正不知道运行时列的类型时暴露DataFrame;例如,如果你正在读取或写入任意文件。
让我们看看现在我们的explain计划是什么样的:
dsInt.explain()
你应该看到这个:
== Physical Plan ==
LocalTableScan [value#122]
这次我们可以看到没有额外的序列化/反序列化步骤。这个Dataset的评估将比我们使用map时更快。
练习:过滤Dataset[Int]的元素,只保留大于 2 的元素。首先使用filter(func: Int => Boolean),然后使用filter(condition: Column)。比较两种实现的执行计划。
这个结论是,你应该尽可能优先选择使用Column参数的函数。它们可能在运行时失败,与类型安全的替代方案相比,因为它们可能引用你Dataset中不存在的列名。然而,它们更高效。
幸运的是,有一个名为Frameless的开源库,它可以让你以类型安全的方式使用这些高效的方法。如果你正在编写使用Dataset的大型程序,我建议你在这里查看:github.com/typelevel/frameless.
执行模型
Dataset和 RDD 中可用的方法有两种:
-
转换:它们返回一个新的
DatasetAPI,该 API 将在调用动作方法时应用转换。例如,map、filter、join和flatMap。 -
动作:它们触发 Spark 作业的执行,例如
collect、take和count。
当一个作业由动作方法触发时,它被分成几个阶段。阶段是作业的一部分,可以在不跨集群的不同节点洗牌数据的情况下运行。它可以包括几个转换,如map和filter。但是,一旦一个转换,如join,需要移动数据(洗牌),就必须引入另一个阶段。
Dataset中的数据被分成几个分区。一个阶段(要执行的代码)与一个分区(阶段使用的数据)的组合是一个任务。当你在集群中拥有nb of tasks = nb of cores时,可以实现理想的并行性。当你需要优化一个作业时,重新分区你的数据以更好地匹配你拥有的核心数量可能会有所帮助。
当 Spark 开始执行一个作业时,Driver程序将任务分配给集群中的所有执行器:

以下图示描述如下:
-
驱动程序与调用动作方法的代码位于同一个 JVM 上
-
一个 executor 在集群的远程节点上的自己的 JVM 上运行。它可以使用多个核心并行执行多个任务。
Spark master在集群中协调多个worker节点。当你启动一个 Spark 应用程序时,你必须指定 master 的 URL。然后它会要求其 worker 生成专门用于应用程序任务的 executor 进程。在某个特定时间点,一个 worker 可以管理多个运行完全不同应用程序的 executor。
当您想要在不使用集群的情况下快速运行或测试应用程序时,您可以使用本地master。在这种特殊模式下,master、driver 和 executor 只使用一个 JVM。
实现交易批量生产者
在本节中,我们将首先讨论如何调用 REST API 以获取 BTC/USD 交易。然后我们将看到如何使用 Spark 将 JSON 有效负载反序列化为类型良好的分布式Dataset。
之后,我们将介绍 parquet 格式,并看看 Spark 如何使将我们的交易保存为此格式变得容易。
使用所有这些构建块,我们将以纯函数式方式使用测试驱动开发(TDD)技术来实现我们的程序。
调用 Bitstamp REST API
Bitstamp 是一个加密货币交易所,人们用它来用加密货币,如比特币,交换传统货币,如美元或欧元。Bitstamp 的一个好处是它提供了一个 REST API,可以用来获取有关最新交易的信息,如果您有账户,还可以用来发送订单。
您可以在此处了解更多信息:www.bitstamp.net/api/
对于这个项目,我们唯一感兴趣的端点是获取交易所上最近发生的交易的最新交易。这将给我们一个关于在给定时间段内货币交换的价格和数量的指示。此端点可以通过以下 URL 调用:www.bitstamp.net/api/v2/transactions/btcusd/?time=hour
如果您将此 URL 粘贴到您喜欢的浏览器中,您应该会看到一个包含在过去一小时发生的所有 BTC(比特币)/USD(美元)交易的 JSON 数组。它看起来应该类似于以下内容:
[
{
"date": "1534582650",
"tid": "72519377",
"price": "6488.27",
"type": "1",
"amount": "0.05000000"
},
{
"date": "1534582645",
"tid": "72519375",
"price": "6488.27",
"type": "1",
"amount": "0.01263316"
},
...
]
在前面的结果中,如果我们检查第一笔交易,我们可以看到以 6488.27 美元的价格出售了 0.05 个比特币("type": "1"表示出售)。
有许多 Java 和 Scala 库可以调用 REST 端点,但为了保持简单,我们只是将使用 Scala 和 Java SDK 来调用端点。启动一个新的 Scala 控制台,并运行以下代码:
import java.net.URL
import scala.io.Source
val transactions = Source.fromURL(new URL("https://www.bitstamp.net/api/v2/transactions/btcusd/?time=hour")).mkString
在scala.io.Source类的帮助下,我们可以以字符串形式获取 HTTP 响应。这是我们程序的第一个构建块。接下来我们需要做的是将 JSON 对象解析为 Scala 对象的集合,以便更容易地操作。
由于我们一次性将整个 HTTP 响应读入一个字符串,所以在驱动程序进程中需要有足够的内存来在堆中保持这个字符串。你可能认为使用 InputStream 来读取会更好,但不幸的是,使用 Spark Core 无法分割数据流。你必须使用 Spark Streaming 来完成这个操作。
解析 JSON 响应
我们观察到,当我们调用 Bitstamp 端点时,我们得到一个包含 JSON 数组的字符串,数组的每个元素都是一个表示交易的 JSON 对象。但将信息放在 Spark Dataset 中会更好。这样,我们将能够使用所有强大的 Spark 函数来存储、过滤或聚合数据。
单元测试 jsonToHttpTransaction
首先,我们可以从定义一个与我们的 JSON 有效负载中相同数据的 case class 开始。创建一个新的包,coinyser,然后在 src/main/scala 中创建一个类,coinyser.HttpTransaction:
package coinyser
case class HttpTransaction(date: String,
tid: String,
price: String,
`type`: String,
amount: String)
在 Scala 中,如果你想使用已经定义为 Scala 关键字的变量名,你可以用反引号包围变量,例如这个例子中的 type 变量名:`type`: String。
这个类具有与 JSON 对象相同的属性名称和相同的类型(所有都是字符串)。第一步是实现一个函数,将 JSON 字符串转换为 Dataset[HttpTransaction]。为此,让我们在 src/test/scala 中创建一个新的测试类,coinyser.BatchProducerSpec:
package coinyser
import org.apache.spark.sql._
import org.apache.spark.sql.test.SharedSparkSession
import org.scalatest.{Matchers, WordSpec}
class BatchProducerSpec extends WordSpec with Matchers with SharedSparkSession {
val httpTransaction1 =
HttpTransaction("1532365695", "70683282", "7740.00", "0",
"0.10041719")
val httpTransaction2 =
HttpTransaction("1532365693", "70683281", "7739.99", "0",
"0.00148564")
"BatchProducer.jsonToHttpTransaction" should {
"create a Dataset[HttpTransaction] from a Json string" in {
val json =
"""[{"date": "1532365695", "tid": "70683282", "price":
"7740.00", "type": "0", "amount": "0.10041719"},
|{"date": "1532365693", "tid": "70683281", "price":
"7739.99", "type": "0", "amount":
"0.00148564"}]""".stripMargin
val ds: Dataset[HttpTransaction] =
BatchProducer.jsonToHttpTransactions(json)
ds.collect() should contain theSameElementsAs
Seq(httpTransaction1, httpTransaction2)
}
}
}
我们的测试扩展了 SharedSparkSession。这个特质提供了一个隐式的 SparkSession,可以在多个测试之间共享。
首先,我们定义了一个包含两个交易的 JSON 数组字符串,这两个交易是从 Bitstamp 的端点提取出来的。我们定义了两个 HttpTransaction 实例,我们期望在测试之外的数据集中有这些实例,因为我们稍后将在另一个测试中重用它们。
在我们即将实现的 jsonToHttpTransaction 调用之后,我们获得了 Dataset[HttpTransaction]。然而,Spark 的 Dataset 是惰性的——在这个阶段,还没有进行任何处理。为了 物化 Dataset,我们需要通过调用 collect() 来强制其评估。这里 collect() 的返回类型是 Array[HttpTransaction],因此我们可以使用 ScalaTest 的断言,contain theSameElementsAs。
实现 jsonToHttpTransaction
创建 coinyser.BatchProducer 类并输入以下代码:
package coinyser
import java.time.Instant
import java.util.concurrent.TimeUnit
import cats.Monad
import cats.effect.{IO, Timer}
import cats.implicits._
import org.apache.spark.sql.functions.{explode, from_json, lit}
import org.apache.spark.sql.types._
import org.apache.spark.sql.{Dataset, SaveMode, SparkSession}
import scala.concurrent.duration._
object BatchProducer {
def jsonToHttpTransactions(json: String)(implicit spark:
SparkSession): Dataset[HttpTransaction] = {
import spark.implicits._
val ds: Dataset[String] = Seq(json).toDS()
val txSchema: StructType = Seq.empty[HttpTransaction].schema
val schema = ArrayType(txSchema)
val arrayColumn = from_json($"value", schema)
ds.select(explode(arrayColumn).alias("v"))
.select("v.*")
.as[HttpTransaction]
}
}
一些导入将在以后使用。如果它们在 IntelliJ 中显示为未使用,请不要担心。让我们一步一步地解释这里发生的事情。我鼓励你在 Scala 控制台中运行每个步骤,并在每个 Dataset 转换后调用 .show():
def jsonToHttpTransactions(json: String)(implicit spark: SparkSession)
: Dataset[HttpTransaction] =
如单元测试中指定,我们函数的签名接受包含交易 JSON 数组的String,并返回Dataset[HttpTransaction]。由于我们需要生成Dataset,我们还需要传递一个SparkSession对象。在典型应用中,这个类只有一个实例,因此将其作为隐式参数传递是一个好习惯:
import spark.implicits.
val ds: Dataset[String] = Seq(json).toDS()
第一步是从我们的 JSON 字符串生成Dataset[String]。这个Dataset将包含一个包含整个交易 JSON 数组的单行。为此,我们使用了在调用import spark.implicits:时提供的.toDS()方法。
val txSchema: StructType = Seq.empty[HttpTransaction].toDS().schema
val schema = ArrayType(txSchema)
val arrayColumn = from_json($"value".cast(StringType), schema)
在前一个章节中,我们已经看到使用接受Column作为参数的 Spark 函数更有效率。为了解析 JSON,我们使用了from_jso函数,该函数位于org.apache.spark.sql.functions包中。我们使用这个特定的签名:def from_json(e: Column, schema: StructType): Column:
-
第一个参数是我们想要解析的列。我们传递了
"value"列,这是我们的单列Dataset的默认列名。 -
第二个参数是目标模式。
StructType表示Dataset的结构——其列的名称、类型和顺序。传递给函数的模式必须与 JSON 字符串的名称和类型匹配。你可以手动创建一个模式,但为了简化操作,我们首先使用一个空的Dataset[HttpTransaction]创建txSchema。txSchema是单个交易的模式,但由于我们的 JSON 字符串包含一个交易数组,我们必须将txSchema封装在ArrayType中:
ds.select(explode(arrayColumn).alias("v"))
.select("v.*")
.as[HttpTransaction]
如果我们只选择arrayColumn,我们会得到Dataset[Seq[HttpTransaction]]——一个包含集合的单行。但我们的目标是Dataset[HttpTransaction]——数组中的每个元素一行。
为了这个目的,我们使用了explode函数,它类似于向量的flatten。在explode之后,我们获得了多行,但在这一阶段,每一行只有一个包含所需列(date、tid和price)的StructType列。我们的交易数据实际上被封装在一个对象中。为了解包它,我们首先将这个StructType列重命名为"v",然后调用select("v.*")。我们得到了包含date、tid和price列的DataFrame,这样我们就可以安全地将它们转换为HttpTransaction。
你可以运行单元测试;现在它应该可以通过。
单元测试 httpToDomainTransactions
现在我们已经拥有了所有必要的组件来获取交易并将它们放入Dataset[HttpTransaction]中。但将这些对象直接存储并对其进行分析并不是一个明智的选择,原因如下:
-
API 在未来可能会发生变化,但我们希望无论这些变化如何,都能保持相同的存储格式。
-
如我们在下一章中将要看到的,Bitstamp WebSocket API 用于接收实时交易使用的是不同的格式。
-
HttpTransaction的所有属性都是String类型。如果属性有适当的类型,运行分析将更容易
由于这些原因,最好有一个代表交易的不同的类。让我们创建一个新的类,称为 coinyser.Transaction:
package coinyser
import java.sql.{Date, Timestamp}
import java.time.ZoneOffset
case class Transaction(timestamp: Timestamp,
date: Date,
tid: Int,
price: Double,
sell: Boolean,
amount: Double)
它具有与 HttpTransaction 相同的属性,但类型更好。我们必须使用 java.sql.Timestamp 和 java.sql.Date,因为它们是 Spark 向外部公开的时间戳和日期的类型。我们还添加了一个 date 属性,它将包含交易的日期。信息已经包含在 timestamp 中,但这种反规范化将在我们想要过滤特定日期范围的交易时非常有用。
为了避免传递日期,我们可以在伴生对象中创建一个新的 apply 方法:
object Transaction {
def apply(timestamp: Timestamp,
tid: Int,
price: Double,
sell: Boolean,
amount: Double) =
new Transaction(
timestamp = timestamp,
date = Date.valueOf(
timestamp.toInstant.atOffset(ZoneOffset.UTC).toLocalDate),
tid = tid,
price = price,
sell = sell,
amount = amount)
}
现在我们可以为需要创建在现有 BatchProducerSpec 内部的 httpToDomainTransactions 新函数编写单元测试:
"BatchProducer.httpToDomainTransactions" should {
"transform a Dataset[HttpTransaction] into a Dataset[Transaction]"
in {
import testImplicits._
val source: Dataset[HttpTransaction] = Seq(httpTransaction1,
httpTransaction2).toDS()
val target: Dataset[Transaction] =
BatchProducer.httpToDomainTransactions(source)
val transaction1 = Transaction(timestamp = new
Timestamp(1532365695000L), tid = 70683282, price = 7740.00,
sell = false, amount = 0.10041719)
val transaction2 = Transaction(timestamp = new
Timestamp(1532365693000L), tid = 70683281, price = 7739.99,
sell = false, amount = 0.00148564)
target.collect() should contain theSameElementsAs
Seq(transaction1, transaction2)
}
测试相当直接。我们构建 Dataset[HttpTransaction],调用 httpToDomainTransactions 函数,并确保结果包含预期的 Transaction 对象。
实现 httpToDomainTransactions
此实现使用 select 来避免额外的序列化/反序列化。在 BatchProducer 中添加以下函数:
def httpToDomainTransactions(ds: Dataset[HttpTransaction]):
Dataset[Transaction] = {
import ds.sparkSession.implicits._
ds.select(
$"date".cast(LongType).cast(TimestampType).as("timestamp"),
$"date".cast(LongType).cast(TimestampType).
cast(DateType).as("date"),
$"tid".cast(IntegerType),
$"price".cast(DoubleType),
$"type".cast(BooleanType).as("sell"),
$"amount".cast(DoubleType))
.as[Transaction]
}
我们使用 cast 将字符串列转换为适当的类型。对于转换为 TimeStampType,我们必须首先将其转换为 LongType,而对于转换为 DateType,我们必须首先将其转换为 TimestampType。由于所有类型都与目标 Transaction 对象匹配,我们可以在最后调用 .as[Transaction] 以获得 Dataset[Transaction]。
你现在可以运行 BatchProducerSpec 并确保两个测试通过。
保存交易
现在我们已经有了从 Bitstamp API 获取过去 24 小时交易并生成 Dataset 内部有良好类型的交易对象的所需所有函数。接下来我们需要做的是将这些数据持久化到磁盘上。这样,一旦我们运行了我们的程序很多天,我们就能检索过去发生的交易。
介绍 Parquet 格式
Spark 支持多种不同的 Datasets 存储格式:CSV、Parquet、ORC、JSON 以及许多其他格式,例如 Avro,使用适当的库。
使用行格式,如 CSV、JSON 或 Avro,数据是按行保存的。使用列格式,如 Parquet 或 ORC,文件中的数据按列存储。
例如,我们可能有以下交易数据集:
+-------------------+--------+-------+-----+-------+
|timestamp |tid |price |sell |amount |
+-------------------+--------+-------+-----+-------+
|2018-08-02 07:22:34| 0|7657.58|true |0.1 |
|2018-08-02 07:22:47| 1|7663.85|false|0.2 |
|2018-08-02 07:23:09| 2|7663.85|false|0.3 |
+-------------------+--------+-------+-----+-------+
如果我们使用行格式,如 CSV、JSON 或 Avro,数据将按行保存。使用列格式,如 Parquet 或 ORC,文件中的数据按列存储。
2018-08-02 07:22:34|0|7657.58|1|0.1;2018-08-02 07:22:47|1|7663.85|0|0.2;2018-08-02 07:23:09|2|7663.85|0|0.3
相比之下,如果我们使用列式格式编写,文件将看起来像这样:
2018-08-02 07:22:34|2018-08-02 07:22:47|2018-08-02 07:23:09;0|1|2;7657.58|7663.85|7663.85;true|false|false;0.1|0.2|0.3
使用列式数据格式在读取数据时提供几个性能优势,包括以下内容:
-
投影下推:当你需要选择几个列时,你不需要读取整个行。在前面的例子中,如果我只对交易的定价演变感兴趣,我可以只选择时间戳和价格,其余的数据将不会从磁盘读取。
-
谓词下推:当你只想检索具有特定值的列的行时,你可以通过扫描列数据快速找到这些行。在前面的例子中,如果我想检索在 07:22:00 到 07:22:30 之间发生的交易,列式存储将允许我通过只读取磁盘上的时间戳列来找到这些行。
-
更好的压缩:行格式可以在存储到磁盘之前进行压缩,但列式格式有更好的压缩率。数据确实更加均匀,因为连续的列值之间的差异小于连续的行值。
-
可分割的:当 Spark 集群运行一个读取或写入 Parquet 的作业时,作业的任务会分布到许多执行器上。每个执行器将并行地从/向其自己的文件集读取/写入行块。
所有这些优点使得列式格式特别适合运行分析查询。这就是为什么,在我们的项目中,我们将使用 Parquet 来存储交易数据。这个选择有点随意;ORC 也能同样好地工作。
在典型的 Spark 集群生产环境中,你必须将文件存储在 分布式文件系统 中。
集群中的每个节点确实必须能够访问数据中的任何一块。如果你的一个 Spark 节点崩溃了,你仍然希望能够访问它保存的数据。如果文件存储在本地文件系统中,你就无法做到这一点。通常,人们使用 Hadoop 文件系统或 Amazon S3 来存储他们的 parquet 文件。它们都提供了分布式、可靠的文件存储方式,并且具有良好的并行性特征。
在生产项目中,对不同格式的性能进行基准测试可能是有益的。根据你的数据形状和查询类型,一种格式可能比其他格式更适合。
在 Parquet 中写入交易
在 BatchProducer 中添加以下 import 和函数声明:
import java.net.URI
def unsafeSave(transactions: Dataset[Transaction], path: URI): Unit = ???
让我们更详细地看看:
-
写入文件是一个副作用;这就是为什么我们在函数前加了
unsafe前缀。作为函数式程序员,我们努力控制副作用,并且明确命名任何有副作用的函数是一种好习惯。我们将在下一节中看到如何使用 IOMonad将这个副作用推送到我们应用程序的边界。 -
我们使用
java.net.URI来传递文件将被写入的目录路径。这确保了我们传递给函数的路径确实是一个路径。像往常一样,我们尽量避免使用字符串作为参数,以使我们的代码更加健壮。
相应的测试实际上会写入文件系统;因此,它更像是集成测试而不是单元测试。因此,我们将创建一个新的测试,带有 IT 后缀,用于集成测试。
在 src/test/scala 中创建一个新的测试,名为 coinyser.BatchProducerIT:
package coinyser
import java.sql.Timestamp
import cats.effect.{IO, Timer}
import org.apache.spark.sql.test.SharedSparkSession
import org.scalatest.{Matchers, WordSpec}
class BatchProducerIT extends WordSpec with Matchers with SharedSparkSession {
import testImplicits._
"BatchProducer.unsafeSave" should {
"save a Dataset[Transaction] to parquet" in withTempDir { tmpDir =>
val transaction1 = Transaction(timestamp = new
Timestamp(1532365695000L), tid = 70683282, price = 7740.00,
sell = false, amount = 0.10041719)
val transaction2 = Transaction(timestamp = new
Timestamp(1532365693000L), tid = 70683281, price = 7739.99,
sell = false, amount = 0.00148564)
val sourceDS = Seq(transaction1, transaction2).toDS()
val uri = tmpDir.toURI
BatchProducer.unsafeSave(sourceDS, uri)
tmpDir.list() should contain("date=2018-07-23")
val readDS = spark.read.parquet(uri.toString).as[Transaction]
sourceDS.collect() should contain theSameElementsAs
readDS.collect()
}
}
}
我们使用来自 SharedSparkSession 的方便的 withTempDir 函数。它创建一个临时目录,并在测试完成后将其删除。然后,我们创建一个示例 Dataset[Transaction],并调用我们想要测试的函数。
在写入数据集之后,我们断言目标路径包含一个名为 date=2018-07-23 的目录。我们确实希望使用 date 分区来组织我们的存储,以便更快地检索特定日期范围。
最后,当我们读取文件时,我们应该得到与原始 Dataset 中相同的元素。运行测试并确保它按预期失败。
现在我们有一个失败的测试,我们可以实现 BatchProducer.unsafeSave:
def unsafeSave(transactions: Dataset[Transaction], path: URI): Unit =
transactions
.write
.mode(SaveMode.Append)
.partitionBy("date")
.parquet(path.toString)
首先,transactions.write 创建 DataFrameWriter。这是一个接口,它允许我们在调用最终操作方法(如 parquet(path: String): Unit)之前配置一些选项。
我们使用以下选项配置 DataFrameWriter:
-
mode(SaveMode.Append):使用此选项,如果路径中已经保存了一些数据,则Dataset的内容将被追加到其中。当我们在常规间隔调用unsafeSave以获取新事务时,这将很有用。 -
partitionBy("date"):在存储的上下文中,分区是一个将在路径下创建的中间目录。它将有一个名称,例如date=2018-08-16。分区是一种优化存储布局的好技术。这将使我们能够加快所有只需要特定日期范围数据的查询。
不要将存储分区(文件系统中的一个中间文件夹)与 Spark 分区(存储在集群节点上的数据块)混淆。
您现在可以运行集成测试;它应该通过。
存储分区的一个有趣特性是它进一步减少了文件大小。您可能会担心,通过存储时间戳和日期,我们会浪费一些存储空间来存储日期。但事实是,当日期是存储分区时,它根本不会存储在 Parquet 文件中。
为了说服自己,在单元测试中,在调用 unsafeSave 之后添加以下行:
spark.read.parquet(uri + "/date=2018-07-23").show()
然后再次运行单元测试。您应该在控制台看到以下内容:
+-------------------+--------+-------+-----+----------+
| timestamp| tid| price| sell| amount|
+-------------------+--------+-------+-----+----------+
|2018-07-23 18:08:15|70683282| 7740.0|false|0.10041719|
|2018-07-23 18:08:13|70683281|7739.99|false|0.00148564|
+-------------------+--------+-------+-----+----------+
日期列缺失!这意味着 date 列根本未存储在 Parquet 文件中。在单元测试中,当我们从 URI 读取时,Spark 检测到该目录下有一个分区 date=2018-07-23,并为所有值添加了一个包含 2018-07-23 的 date 列。
如果您想添加一个所有行都具有相同值的列,最简单的方法是创建一个中间目录,myColum=value。
使用 IO 模态
我们之前提到,我们的函数unsafeSave有一个副作用,即写入文件。但是作为函数式程序员,我们尽量只编写没有副作用的纯函数。然而,在程序结束时,你仍然希望这个副作用发生;否则,就没有运行它的意义了!
解决这种困境的一种常见方法是使用参数化类型来封装副作用,以便异步运行它。cats.effect库中的cats.effect.IO类是一个很好的候选者(见typelevel.org/cats-effect/datatypes/io.html)。
这里有一个你可以在 Scala 控制台中尝试的示例:
import cats.effect.IO
val io = IO{ println("Side effect!"); 1 }
// io: cats.effect.IO[Int] = …
io.unsafeRunSync()
// Side effect!
// res1: Int = 1
我们可以观察到,当我们声明io变量时,没有发生任何事情。此时,传递给IO构造函数的块仅被注册,将在以后执行。实际的执行只有在调用unsafeRunSync()时才会发生。我们的io变量是一个纯的、不可变的价值,因此保持了引用透明性。
IO是Monad,因此我们可以使用map、flatMap和for表达式来组合副作用:
val program = for {
a <- io
b <- io
} yield a+b
// program: cats.effect.IO[Int]
program.unsafeRunSync()
// IO is run!
// IO is run!
// res2: Int = 2
我们可以多次重用io变量;它封装的副作用将在我们调用unsafeRunSync()时,在“世界末日”时按需运行多次 *。
如果我们使用了scala.concurrent.Future而不是cats.effect.IO,副作用将只运行一次。这是因为Future会记住结果。在某些情况下,Future的行为可能是可取的,但在某些其他情况下,你真的希望你的效果按照你在代码中定义的次数执行。IO的方法也避免了共享状态和内存泄漏。
IO值也可以并行运行。它们可以有效地替换scala.concurrent.Future:
import cats.effect.IO
import cats.implicits._
import scala.concurrent.ExecutionContext.Implicits.global
val io = IO{ Thread.sleep(100); Thread.currentThread().getName }
val program = (io, io, io).parMapN((a, b, c) => s"$a\n$b\n$c")
program.unsafeRunSync()
// res2: String =
// ForkJoinPool-1-worker-5
// ForkJoinPool-1-worker-3
// ForkJoinPool-1-worker-1
IO块返回当前线程的名称作为字符串。我们使用parMapN创建一个IO[String]类型的程序,以表示我们想要并行执行元组中的IO值。unsafeRunSync的输出显示程序在三个不同的线程中执行。
回到我们的交易保存,我们使unsafeSave函数安全所需要做的全部事情就是将其包裹在IO中:
def save(transactions: Dataset[Transaction], path: URI): IO[Unit] =
IO(unsafeSave(transactions, path))
或者,你可以内联unsafeSave并将集成测试改为调用save:
BatchProducer.save(sourceDS, uri).unsafeRunSync()
我们现在可以在控制副作用的同时保存事务,并保持我们的函数纯。
将所有这些放在一起
到目前为止,我们可以从 REST API 读取事务,将Dataset[Transaction]中的 JSON 有效负载进行转换,并将其保存到 parquet。现在是时候将这些部分组合在一起了。
Bitstamp API 允许我们获取过去 24 小时、过去一小时或过去一分钟发生的交易。在一天结束时,我们希望构建一个定期获取并保存新交易以进行长期分析的应用程序。这个应用程序是我们的 批处理 层,它不是为了获取实时交易。因此,获取过去一小时的交易就足够了。在下一章中,我们将构建一个 速度 层来处理实时交易。
我们的 BatchProducer 应用程序将按以下方式工作:
-
在启动时,获取最后 24 小时的交易。将
start设置为当前午夜 UTC 日期,将end设置为最后交易的戳记。 -
过滤交易,仅保留
start和end之间的交易,并将它们保存到 Parquet。 -
等待 59 分钟。
-
获取最后一个小时的交易。我们有一个一分钟的重叠,以确保我们不会错过任何交易。将
start设置为end,将end设置为最后交易的戳记。 -
转到步骤 2。
为了实现这个算法,我们将编写一个 processOneBatch 函数,它包含步骤 2 到 4,然后我们将实现步骤 1 和无限循环。
测试 processOneBatch
我们的功能需要一些配置参数和隐式值。为了保持我们的签名整洁,我们将它们放在一个类中。创建一个新的类,coinyser.AppContext:
class AppContext(val transactionStorePath: URI)
(implicit val spark: SparkSession,
implicit val timer: Timer[IO])
AppContext 包含 Parquet 文件的目标位置、SparkSession 对象以及当需要调用 IO.sleep 时 cats.effect 所需的 Timer[IO] 对象。
然后在 BatchProducer 中声明 processOneBach 函数:
def processOneBatch(fetchNextTransactions: IO[Dataset[Transaction]],
transactions: Dataset[Transaction],
saveStart: Instant,
saveEnd: Instant)(implicit appCtx: AppContext)
: IO[(Dataset[Transaction], Instant, Instant)] = ???
函数接受以下参数:
-
fetchNextTransactions是一个IO操作,当运行时将返回过去一小时的交易。我们将其作为参数传递,以便在单元测试中模拟对 Bitstamp API 的调用。 -
transactions是包含已读取的最后交易的Dataset(我们的算法中的步骤 1 或 4)。 -
saveStart和saveEnd是在保存transactions之前用于过滤的时间间隔。 -
appCtx如前所述。
我们的功能将必须执行副作用;因此,它返回 IO。这个 IO 将包含一个元组,其中包含以下内容:
-
通过运行
fetchNextTransactions获得的Dataset[Transaction]。 -
下一个
saveStart和下一个saveEnd
现在我们已经很好地声明了我们的函数,我们可以为它编写一个集成测试。测试相当长;因此,我们将一点一点地描述它。在 BatchProducerIT 中创建一个新的集成测试:
"BatchProducer.processOneBatch" should {
"filter and save a batch of transaction, wait 59 mn, fetch the next
batch" in withTempDir { tmpDir =>
implicit object FakeTimer extends Timer[IO] {
private var clockRealTimeInMillis: Long =
Instant.parse("2018-08-02T01:00:00Z").toEpochMilli
def clockRealTime(unit: TimeUnit): IO[Long] =
IO(unit.convert(clockRealTimeInMillis, TimeUnit.MILLISECONDS))
def sleep(duration: FiniteDuration): IO[Unit] = IO {
clockRealTimeInMillis = clockRealTimeInMillis +
duration.toMillis
}
def shift: IO[Unit] = ???
def clockMonotonic(unit: TimeUnit): IO[Long] = ???
}
implicit val appContext: AppContext = new
AppContext(transactionStorePath = tmpDir.toURI)
我们首先定义 FakeTimer,它实现了 Timer[IO] 接口。这个计时器让我们模拟一个从 2018-08-02T01:00:00Z 开始的时钟。这样,我们就不必等待 59 分钟来运行我们的测试。实现使用 var clockRealTimeInMillis,它保持我们的假时钟的当前时间,并在调用 sleep 时更新它。
然后,我们使用临时目录和作用域内的隐式转换:FakeTimer 和 SparkSession 创建 AppContext。
测试的下一部分定义了一些交易:
implicit def toTimestamp(str: String): Timestamp =
Timestamp.from(Instant.parse(str))
val tx1 = Transaction("2018-08-01T23:00:00Z", 1, 7657.58, true,
0.021762)
val tx2 = Transaction("2018-08-02T01:00:00Z", 2, 7663.85, false,
0.01385517)
val tx3 = Transaction("2018-08-02T01:58:30Z", 3, 7663.85, false,
0.03782426)
val tx4 = Transaction("2018-08-02T01:58:59Z", 4, 7663.86, false,
0.15750809)
val tx5 = Transaction("2018-08-02T02:30:00Z", 5, 7661.49, true, 0.1)
val txs0 = Seq(tx1)
val txs1 = Seq(tx2, tx3)
val txs2 = Seq(tx3, tx4, tx5)
val txs3 = Seq.empty[Transaction]
implicit 转换 toTimestamp 允许我们使用 String 而不是 Timestamp 来声明我们的交易对象。这使得测试更容易阅读。我们用它来声明五个 Transaction 对象,其时间戳围绕 FakeTimer 的初始时钟。
然后,我们声明了一系列模拟从 Bitstamp API 读取的内容的交易批次。实际上,我们无法从我们的集成测试中调用真实的 Bitstamp API;数据将是随机的,如果 API 不可用,我们的集成测试可能会失败:
-
txs0是Seq[Transaction],它模拟我们在 01:00 读取的初始交易批次。如果你还记得BatchProducer算法,这个初始批次将包含最后 24 小时的交易。在我们的例子中,这个批次只包含tx1,即使tx2的时间戳是 01:00。这是因为,在真实的 API 中,我们不会得到在完全相同时间发生的交易。总会有一些延迟。 -
txs1是我们在 01:59 读取的交易批次。在这个批次中,我们考虑 API 延迟使我们错过了在 01:58:59 发生的tx4。 -
txs2是在txs159 分钟后读取的批次,在 02:58。 -
txs3是在txs259 分钟后读取的批次,在 03:57。
下面的部分实际上调用了被测试的函数 processOneBatch 三次:
val start0 = Instant.parse("2018-08-02T00:00:00Z")
val end0 = Instant.parse("2018-08-02T00:59:55Z")
val threeBatchesIO =
for {
tuple1 <- BatchProducer.processOneBatch(IO(txs1.toDS()),
txs0.toDS(), start0, end0)
(ds1, start1, end1) = tuple1
tuple2 <- BatchProducer.processOneBatch(IO(txs2.toDS()), ds1,
start1, end1)
(ds2, start2, end2) = tuple2
_ <- BatchProducer.processOneBatch(IO(txs3.toDS()), ds2, start2,
end2)
} yield (ds1, start1, end1, ds2, start2, end2)
val (ds1, start1, end1, ds2, start2, end2) =
threeBatchesIO.unsafeRunSync()
对于第一次调用,我们传递以下内容:
-
txs0.toDS()表示初始的交易批次。这将涵盖最后 24 小时的交易。 -
start0= 00:00。在我们的算法中,我们选择将第一个批次从午夜开始。这样,我们就不会保存前一天的任何部分数据。 -
end0= 00:59:55。我们的时钟从 01:00 开始,但 API 总是会有一些延迟来使交易可见。我们估计这个延迟不会超过五秒。 -
IO(txs1.toDS())表示下一个要获取的交易批次。它将在初始批次 59 分钟后获取。
后续调用传递了前一个调用的结果,以及获取下一个批次的 IO 值。
我们随后使用 unsafeRunSync() 运行这三个调用,并在 ds1、start1、end1、ds2、start2 和 end2 中获得前两个调用的结果。这使我们能够通过以下断言来验证结果:
ds1.collect() should contain theSameElementsAs txs1
start1 should ===(end0)
end1 should ===(Instant.parse("2018-08-02T01:58:55Z"))
ds2.collect() should contain theSameElementsAs txs2
start2 should ===(end1)
end2 should ===(Instant.parse("2018-08-02T02:57:55Z"))
val lastClock = Instant.ofEpochMilli(
FakeTimer.clockRealTime(TimeUnit.MILLISECONDS).unsafeRunSync())
lastClock should === (Instant.parse("2018-08-02T03:57:00Z"))
让我们详细看看前面的代码:
-
ds1是通过运行IO(txs1.toDS())获得的批次。因此,它必须与txs1相同 -
start1必须等于end0——我们需要无缝地移动时间周期 -
end1必须等于初始时钟 (01:00) + 59 分钟 (等待时间) - 5 秒 (API 延迟) -
ds2、start2和end2遵循相同的逻辑 -
lastClock必须等于初始时钟 + 3 * 59 分钟
最后,我们可以断言正确的交易已保存到磁盘:
val savedTransactions = spark.read.parquet(tmpDir.toString).as[Transaction].collect()
val expectedTxs = Seq(tx2, tx3, tx4, tx5)
savedTransactions should contain theSameElementsAs expectedTxs
该断言排除了tx1,因为前一天已经发生。它还验证了尽管我们的批次txs1和txs2有一些重叠,但我们的 Parquet 文件中没有重复的交易。
你可以编译并运行集成测试。它应该像预期的那样失败,抛出NotImplementedError异常。
实现 processOneBatch
下面是BatchProducer.processOneBatch的实现。正如通常情况一样,实现比测试要短得多:
val WaitTime: FiniteDuration = 59.minute
val ApiLag: FiniteDuration = 5.seconds
def processOneBatch(fetchNextTransactions: IO[Dataset[Transaction]],
transactions: Dataset[Transaction],
saveStart: Instant,
saveEnd: Instant)(implicit appCtx: AppContext)
: IO[(Dataset[Transaction], Instant, Instant)] = {
import appCtx._
val transactionsToSave = filterTxs(transactions, saveStart, saveEnd)
for {
_ <- BatchProducer.save(transactionsToSave,
appCtx.transactionStorePath)
_ <- IO.sleep(WaitTime)
beforeRead <- currentInstant
end = beforeRead.minusSeconds(ApiLag.toSeconds)
nextTransactions <- fetchNextTransactions
} yield (nextTransactions, saveEnd, end)
}
我们首先使用我们很快将要定义的filterTxs函数过滤交易。然后,使用for comprehension,我们链接几个IO值:
-
使用我们之前实现的
save函数保存过滤后的交易 -
使用
import appCtx._引入的作用域中的隐式Timer等待 59 分钟 -
使用我们很快将要定义的
currentInstant函数获取当前时间 -
使用第一个参数获取下一批交易
下面是辅助函数filterTxs的实现:
def filterTxs(transactions: Dataset[Transaction],
fromInstant: Instant, untilInstant: Instant): Dataset[Transaction] = {
import transactions.sparkSession.implicits._
transactions.filter(
($"timestamp" >=
lit(fromInstant.getEpochSecond).cast(TimestampType)) &&
($"timestamp" <
lit(untilInstant.getEpochSecond).cast(TimestampType)))
}
我们不需要传递隐式的SparkSession,因为它已经在交易Dataset中可用。我们只为(fromInstant, untilInstant)区间保留交易。排除结束时间,这样我们在processOneBatch循环时就不会有任何重叠。
下面是currentInstant的定义:
def currentInstant(implicit timer: Timer[IO]): IO[Instant] =
timer.clockRealTime(TimeUnit.SECONDS) map Instant.ofEpochSecond
我们使用Timer类来获取当前时间。正如我们在编写集成测试时看到的,这允许我们使用一个假的计时器来模拟时钟。
实现 processRepeatedly
我们现在可以开始实现我们的BatchProducer应用程序的算法,该算法将反复执行processOneBatch。我们不会为它编写集成测试,因为它只是组装了已经测试过的其他部分。理想情况下,在一个生产系统中,你应该编写一个端到端测试,该测试将启动应用程序并连接到一个假的 REST 服务器。
下面是processRepeatedly的实现:
def processRepeatedly(initialJsonTxs: IO[Dataset[Transaction]],
jsonTxs: IO[Dataset[Transaction]])
(implicit appContext: AppContext): IO[Unit] = {
import appContext._
for {
beforeRead <- currentInstant
firstEnd = beforeRead.minusSeconds(ApiLag.toSeconds)
firstTxs <- initialJsonTxs
firstStart = truncateInstant(firstEnd, 1.day)
_ <- Monad[IO].tailRecM((firstTxs, firstStart, firstEnd)) {
case (txs, start, instant) =>
processOneBatch(jsonTxs, txs, start, instant).map(_.asLeft)
}
} yield ()
}
在函数的签名中,我们有以下内容:
-
参数
initialJsonTxs,它是一个IO,将获取Dataset中最后 24 小时的交易 -
第二个参数
jsonTxs,用于获取最后小时的交易 -
返回类型
IO[Unit],当我们在主应用程序中调用unsafeRunSync时将无限运行
函数的主体是一个for comprehension,按照以下方式链接IO值:
-
我们首先计算
firstEnd= 当前时间 - 5 秒。通过使用 5 秒的ApiLag,当我们使用initialJsonTxs获取交易时,我们确定我们将获取到firstEnd的所有交易。 -
firstStart设置为当前天的午夜。对于初始批次,我们希望过滤掉前一天的交易。 -
我们在
firstTxs中获取了Dataset[Transaction]类型的最后 24 小时的交易。 -
我们从
Monad中调用tailRecM。它调用块中的匿名函数,直到它返回Monad[Right[Unit]]。但由于我们的函数总是返回Left,它将无限循环。
实现 BatchProducerApp
最后,我们所需做的就是创建一个应用程序,该应用程序将使用正确的参数调用 processRepeatedly。
在 src/main/scala 中创建一个新的类 coinyser.BatchProducerApp 并输入以下内容:
package coinyser
import java.io.{BufferedReader, InputStreamReader}
import java.net.{URI, URL}
import cats.effect.{ExitCode, IO, IOApp}
import coinyser.BatchProducer.{httpToDomainTransactions, jsonToHttpTransactions}
import com.typesafe.scalalogging.StrictLogging
import org.apache.spark.sql.{Dataset, SparkSession}
import scala.io.Source
class BatchProducerApp extends IOApp with StrictLogging {
implicit val spark: SparkSession =
SparkSession.builder.master("local[*]").getOrCreate()
implicit val appContext: AppContext = new AppContext(new
URI("./data/transactions"))
def bitstampUrl(timeParam: String): URL =
new URL("https://www.bitstamp.net/api/v2/transactions/btcusd?time="
+ timeParam)
def transactionsIO(timeParam: String): IO[Dataset[Transaction]] = {
val url = bitstampUrl(timeParam)
val jsonIO = IO {
logger.info(s"calling $url")
Source.fromURL(url).mkString
}
jsonIO.map(json =>
httpToDomainTransactions(jsonToHttpTransactions(json)))
}
val initialJsonTxs: IO[Dataset[Transaction]] = transactionsIO("day")
val nextJsonTxs: IO[Dataset[Transaction]] = transactionsIO("hour")
def run(args: List[String]): IO[ExitCode] =
BatchProducer.processRepeatedly(initialJsonTxs, nextJsonTxs).map(_
=> ExitCode.Success)
}
object BatchProducerAppSpark extends BatchProducerApp
该类扩展了 cats.effect.IOApp。这是一个辅助特质,它将在 run 方法返回的 IO 上调用 unsafeRunSync。它还扩展了 StrictLogging。这个特质在作用域中引入了一个 logger 属性,我们将使用它来记录消息。我们的对象体定义了以下成员:
-
spark是SparkSession,用于操作数据集。主节点设置为local[*],这意味着 Spark 将使用本地主机上所有可用的核心来执行我们的作业。但是,正如我们将在下一节中看到的,当使用 Spark 集群时,这可以被覆盖。 -
appContext需要保存我们的交易的路径。在这里,我们使用本地文件系统上的相对目录。在生产环境中,你通常会使用 S3 或 HDFS 位置。AppContext还需要两个隐式参数:SparkSession和Timer[IO]。我们已定义了前者,后者由IOApp提供。 -
bitstampUrl是一个函数,它返回用于检索过去一天或过去一小时发生的交易的 URL。 -
transactionsIO通过调用 Bitstamp URL 获取交易。如本章开头所见,我们使用scala.io.Source从 HTTP 响应中创建一个字符串。然后,我们使用我们之前实现的两个函数jsonToHttpTransactions和httpToDomainTransactions将其转换为Dataset[Transaction]。 -
initialJsonTxs和nextJsonTxs是 IO 值,分别检索过去 24 小时的交易和过去一小时的交易。 -
run实现了IOApp的唯一抽象方法。它生成IO[ExitCode]以作为应用程序运行。在这里,我们只是使用之前定义的vals调用processRepeatedly。然后,我们必须映射以将单元结果转换为ExitCode.Success以进行类型检查。实际上,这个退出代码永远不会返回,因为processRepeatedly是无限循环的。
如果你现在尝试运行 BatchProducerAppSpark,你将得到关于 Spark 类的 ClassNotFoundException。这是因为我们在 build.sbt 中**,将一些库声明为 % Provided。正如我们将看到的,这种配置对于打包应用程序很有用,但到目前为止,它阻止我们轻松地测试我们的程序。
避免这种情况的技巧是在 src/test/scala 目录中创建另一个对象 coinyser.BatchProducerAppIntelliJ,它也扩展了 BatchProducerApp 类。IntelliJ 确实将所有提供的依赖项带到测试运行时类路径中:
package coinyser
object BatchProducerAppIntelliJ extends BatchProducerApp
这就是为什么我们在 BatchProducerApp.scala 中定义了一个类和一个对象。我们可以有一个实现,它将用于 spark-submit,还有一个可以从 IntelliJ 中运行的实现。
现在运行 BatchProducerAppIntelliJ 应用程序。几秒钟后,你应该在控制台看到类似以下内容:
(...)
18/09/02 22:29:08 INFO BatchProducerAppIntelliJ$: calling https://www.bitstamp.net/api/v2/transactions/btcusd?time=day
18/09/02 22:29:15 WARN TaskSetManager: Stage 0 contains a task of very large size (1225 KB). The maximum recommended task size is 100 KB.
18/09/02 22:29:15 INFO CodecPool: Got brand-new compressor [.snappy]
18/09/02 22:29:16 INFO FileOutputCommitter: Saved output of task 'attempt_20180902222915_0000_m_000000_0' to file:/home/mikael/projects/Scala-Programming-Projects/bitcoin-analyser/data/transactions/_temporary/0/task_20180902222915_0000_m_000000
从这一点开始,你应该在 data/transactions/date=<当前日期> 目录中有一个 Parquet 文件,其中包含从午夜开始当天发生的所有交易。如果你再等一个小时,你将得到另一个包含最后一个小时交易的 Parquet 文件。
如果你不想等一个小时来看到它发生,你可以每分钟获取一次交易:
-
将
BatchProducerApp.nextJsonTxs改为jsonIO("?time=minute")。 -
将
BatchProducer.WaitTime改为45.seconds以实现 15 秒的重叠。
控制台中的 WARN 消息告诉我们 "Stage 0 包含一个非常大的任务"。这是因为包含 HTTP 响应的 String 被作为一个整体发送到单个 Spark 任务。如果我们有一个更大的有效负载(几百 MB),将其分割并写入文件将更节省内存。
在下一章中,我们将看到如何使用 Zeppelin 查询这些 Parquet 文件并绘制一些图表。但我们可以使用 Scala 控制台来检查它们。启动一个新的 Scala 控制台并输入以下内容:
import org.apache.spark.sql.SparkSession
implicit val spark = SparkSession.builder.master("local[*]").getOrCreate()
val ds = spark.read.parquet("./data/transactions")
ds.show()
随意使用 Dataset API 进行实验。你可以尝试计算交易数量,对特定时间段进行过滤,并找到最高价格或数量。
使用 spark-submit 运行应用程序。
我们已经以独立方式运行了我们的应用程序,但当你想要处理大型数据集时,你需要使用 Spark 集群。本书的范围不包括解释如何设置 Spark 集群。如果你想设置一个,你可以参考 Spark 文档或使用云计算供应商提供的现成集群。
不论我们是在本地模式下运行 Spark 还是集群模式下运行,提交过程都是相同的。
安装 Apache Spark。
我们将安装 Spark 以在本地模式下运行它。此模式仅使用本地主机的 CPU 核心来运行作业。为此,从以下页面下载 Spark 2.3.1: spark.apache.org/downloads.html。
然后将其提取到某个文件夹中,例如,Linux 或 macOS 上的 ~/,即你的主目录:
tar xfvz spark-2.3.1-bin-hadoop2.7.tgz ~/
你可以尝试运行 spark shell 来验证安装是否正确:
cd ~/spark-2.3.1-bin-hadoop2.7/bin
./spark-shell
几秒钟后,你应该看到一条欢迎消息,然后是 Scala 控制台中相同的 scala> 提示符:
(...)
Spark context Web UI available at http://192.168.0.11:4040
Spark context available as 'sc' (master = local[*], app id = local-1536218093431).
Spark session available as 'spark'.
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.3.1
/_/
Using Scala version 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_112)
Type in expressions to have them evaluated.
Type :help for more information.
scala>
Spark shell 实际上是一个连接到 Spark 集群的 Scala 控制台。在我们的例子中,它是一个 Spark 本地集群,正如您通过 master = local[*] 看到的。Spark-shell 提供了一个变量,spark: SparkSession,您可以使用它来操作数据集。它可能是一个方便的工具,但我通常更喜欢使用 IntelliJ 的控制台,并手动创建 SparkSession。IntelliJ 的控制台有语法高亮和更好的代码补全的优点。
打包装配 JAR 文件
为了使用 Spark 分发运行我们的应用程序,我们需要将编译好的类及其依赖项打包到一个 JAR 文件中。这就是我们所说的装配 JAR 或胖 JAR:如果有很多依赖项,其大小可能相当大。为此,我们必须修改我们的 SBT 构建文件。
首先,我们需要启用装配插件。在 bitcoin-analyser/project 文件夹中添加一个新文件,assembly.sbt**,并输入以下内容:
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.7"
我们还需要排除所有 Spark 依赖项从我们的装配 JAR 中。没有必要保留它们,因为它们已经在 Spark 分发中存在。排除它们将节省空间和构建时间。为此,我们已经在 build.sbt 中将这些依赖项范围设置为 % Provided。这将确保依赖项在编译项目和运行测试时存在,但在构建装配 JAR 时被排除。
然后,我们必须在 build.sbt 的末尾添加一些配置选项:
assemblyOption in assembly := (assemblyOption in
assembly).value.copy(includeScala = false)
test in assembly := {}
mainClass in assembly := Some("coinyser.BatchProducerAppSpark")
这里有一个简短的解释,说明每一行执行了什么操作:
-
第一行排除了所有 Scala 运行时 JAR 文件。
-
第二行告诉 SBT 在运行装配任务时跳过测试
-
最后一行声明了我们的主类。这个声明最终会出现在
MANIFEST.MF文件中,并被 Spark 用于启动我们的程序。
我们的构建文件已准备好;我们可以运行装配任务。在 IntelliJ 中打开 SBT 控制台(Ctrl + Shift + S),在 sbt> 提示符后输入 assembly。这应该会编译项目并打包装配 JAR。您 SBT 控制台的输出应该如下所示:
[IJ]sbt:bitcoin-analyser> assembly
[info] Strategy 'discard' was applied to 3 files (Run the task at debug level to see details)[info] Packaging /tmp/sbt/bitcoin-analyser/scala-2.11/bitcoin-analyser-assembly-0.1.jar
...
[info] Done packaging.
装配 JAR 已准备好;我们可以将其提交给 Spark。
运行 spark-submit
使用控制台,转到 Spark 分发的 bin 文件夹,并使用装配 JAR 的路径运行 spark-submit:
cd ~/spark-2.3.1-bin-hadoop2.7/bin
./spark-submit /tmp/sbt/bitcoin-analyser/scala-2.11/bitcoin-analyser-assembly-0.1.jar
spark-submit 有很多选项,允许您更改 Spark 主节点、执行器的数量、它们的内存需求等。您可以通过运行 spark-submit -h 来了解更多信息。提交我们的 JAR 后,您应该在控制台看到类似以下内容(我们只显示最重要的部分):
(...)
2018-09-07 07:55:27 INFO SparkContext:54 - Running Spark version 2.3.1
2018-09-07 07:55:27 INFO SparkContext:54 - Submitted application: coinyser.BatchProducerAppSpark
(...)
2018-09-07 07:55:28 INFO Utils:54 - Successfully started service 'SparkUI' on port 4040.
(...)
2018-09-07 07:55:28 INFO SparkContext:54 - Added JAR file:/tmp/sbt/bitcoin-analyser/scala-2.11/bitcoin-analyser-assembly-0.1.jar at spark://192.168.0.11:38371/jars/bitcoin-analyser-assembly-0.1.jar with timestamp 1536303328633
2018-09-07 07:55:28 INFO Executor:54 - Starting executor ID driver on host localhost
(...)
2018-09-07 07:55:28 INFO NettyBlockTransferService:54 - Server created on 192.168.0.11:37370
(...)
2018-09-07 07:55:29 INFO BatchProducerApp$:23 - calling https://www.bitstamp.net/api/v2/transactions/btcusd?time=day
(...)
2018-09-07 07:55:37 INFO SparkContext:54 - Starting job: parquet at BatchProducer.scala:115
(...)
2018-09-07 07:55:39 INFO DAGScheduler:54 - Job 0 finished: parquet at BatchProducer.scala:115, took 2.163065 s
如果您使用的是远程集群,您将看到类似的输出:
-
行
Submitted application:告诉我们我们提交了哪个main类。这对应于我们在 SBT 文件中设置的mainClass设置。这可以通过spark-submit中的--class选项来覆盖。 -
几行之后,我们可以看到 Spark 在端口
4040上启动了一个SparkUI网络服务器。使用您的网页浏览器,访问 URLhttp://localhost:4040来探索这个 UI。它允许您查看运行作业的进度、它们的执行计划、使用的执行器数量、执行器的日志等信息。当您需要优化作业时,SparkUI 是一个宝贵的工具。 -
添加 JAR 文件: 在 Spark 可以运行我们的应用程序之前,它必须将 assembly JAR 分发到所有集群节点。为此,我们可以看到它启动了一个端口为37370的服务器。执行器随后会连接到该服务器以下载 JAR 文件。 -
启动 Executor ID driver: 驱动进程协调作业与执行器的执行。以下行显示它监听端口37370以接收执行器的更新。 -
调用 https//: 这对应于我们在代码中记录的内容,logger.info(s"调用 $url")。 -
开始作业: 我们的应用程序启动了一个 Spark 作业。BatchProducer中的第 115 行对应于BatchProducer.save中的.parquet(path.toString)指令。这个parquet方法确实是一个动作,因此会触发Dataset的评估。 -
作业 0 完成: 作业在几秒钟后完成。
从这一点开始,你应该已经保存了一个包含最后交易的parquet文件。如果您让应用程序继续运行 1 小时,您将看到它开始另一个作业来获取最后 1 小时的交易。
摘要
到目前为止,您应该更熟悉使用 Spark 的Dataset API。我们的小程序专注于获取 BCT/USD 交易,但它可以增强。例如,您可以获取并保存其他货币对,如 ETH/EUR 或 XRP/USD。使用不同的加密货币交易所。这将允许您比较不同交易所的价格,并可能制定套利策略。套利是在不同市场同时购买和出售资产以从价格不平衡中获利。您可以为传统货币对,如 EUR/USD,获取数据,或使用 Frameless 重构Dataset操作,使其更类型安全。请访问网站以获取进一步说明 github.com/typelevel/frameless.
在下一章中,我们将利用保存的交易数据来执行一些分析查询。
第十一章:批量分析和流式分析
在上一章中,我们介绍了 Spark 并从www.bitstamp.net获取了 BTC/USD 交易数据。使用这些数据,我们现在可以对其进行一些分析。
首先,我们将使用名为 Apache Zeppelin 的笔记本工具查询这些数据。之后,我们将编写一个程序,从www.bitstamp.net/接收实时交易并将它们发送到 Kafka 主题。
最后,我们将再次使用 Zeppelin 在到达 Kafka 主题的数据上运行一些流式分析查询。
在本章中,我们将涵盖以下主题:
-
Zeppelin 简介
-
使用 Zeppelin 分析交易
-
介绍 Apache Kafka
-
流式交易到 Kafka
-
Spark 流式简介
-
使用 Zeppelin 分析流式交易
Zeppelin 简介
Apache Zeppelin 是一个开源软件,提供了一个网页界面来创建笔记本。
在笔记本中,您可以注入一些数据,执行代码片段以对数据进行分析,然后可视化。
Zeppelin 是一个协作工具;多个用户可以同时使用它。您可以共享笔记本并定义每个用户的角色。您通常会定义两个不同的角色:
-
作者,通常是开发者,可以编辑所有段落并创建表单。
-
最终用户对技术实现细节了解不多。他只想在表单中更改一些值,然后查看结果的影响。结果可以是表格或图表,并且可以导出为 CSV。
安装 Zeppelin
在安装 Zeppelin 之前,您需要在您的机器上安装 Java 和 Spark。
按照以下步骤安装 Zeppelin:
-
从以下链接下载二进制文件:
zeppelin.apache.org/download.html。 -
使用您喜欢的程序解压缩
.tgz文件
就这样。Zeppelin 已安装并使用默认设置配置。下一步是启动它。
启动 Zeppelin
要启动 Zeppelin 守护进程,请在终端中运行以下命令:
- Linux 和 macOS:
<downloadPath>/zeppelin-0.8.0-bin-all/bin/zeppelin-daemon.sh start
- Windows:
<downloadPath>/zeppelin-0.8.0-bin-all/bin/zepplin.cmd start
Zeppelin 现在正在运行,并准备好在localhost:8080上接受请求。
在您最喜欢的浏览器中打开 URL。您应该看到这个页面:

测试 Zeppelin
让我们创建一个新的笔记本来测试我们的安装是否正确。
从localhost:8080主页,点击创建新笔记,并在弹出窗口中将Demo设置为名称:

如窗口中所述,默认解释器将是 Spark,这正是我们想要的。现在点击创建。
您刚刚创建了您的第一个笔记本。您应该在浏览器中看到以下内容:

笔记本结构
笔记本是一个文档,在其中你可以添加段落。每个段落可以使用不同的解释器,它与特定的框架或语言交互。
在每个段落中,有两个部分:
-
上面一个是编辑器,你可以在这里输入一些源代码并运行它
-
下面一个是显示结果
如果,例如,你选择使用 Spark 解释器,段落中你写的所有代码都将被解释(就像在第一章,编写你的第一个程序中看到的 REPL 一样)。所有定义的变量都将保留在内存中,并与笔记本的所有其他段落共享。类似于 Scala REPL,当你执行它时,执行输出的输出以及定义的变量的类型将在结果部分打印出来。
Zeppelin 默认安装了 Spark、Python、Cassandra、Angular、HDFS、Groovy 和 JDBC 等解释器。
编写段落
好吧,我们的第一个笔记本是完全空的!我们可以重用第十章中使用的示例,使用 Scala 控制台探索 Spark 的 API部分中的获取和持久化比特币市场数据。如果你记得,我们是从字符串序列创建Dataset的。在笔记本中输入以下内容:
val dsString = Seq("1", "2", "3").toDS()
dsString.show()
然后按Shift+Enter(或点击 UI 上的播放三角形)。
解释器运行后,你应该看到以下内容:

笔记本从序列创建数据集并在段落的输出部分打印出来。
注意,在前一章中,当我们从 Scala 控制台执行此代码时,我们必须创建SparkSession并添加一些导入。当我们使用 Zeppelin 中的 Spark 解释器时,所有隐式和导入都是自动完成的,并且SparkSession为我们创建。
SparkSession以变量名spark暴露。例如,你可以在一个段落中使用以下代码获取 Spark 版本:
spark.version
执行段落后,你应该在结果部分看到打印的版本:

在那个时刻,我们测试了安装,并且它运行正常。
绘制图表
在本节中,我们将创建一个基本的Dataset并绘制其数据的图表。
在新段落中,添加以下内容:
case class Demo(id: String, data: Int)
val data = List(
Demo("a",1),
Demo("a",2),
Demo("b",8),
Demo("c",4))
val dataDS = data.toDS()
dataDS.createOrReplaceTempView("demoView")
我们定义一个Demo类,具有id和data属性,然后我们创建一个不同的Demo对象列表,并将其转换为Dataset。
从dataDS数据集中,我们调用.createOrReplaceTempView("demoView")方法。这个函数将数据集注册为临时视图。有了这个视图定义,我们可以使用 SQL 查询这个数据集。我们可以通过添加一个新段落来尝试它,如下所示:
%sql
select * from demoView
新创建的段落以%sql开头。这定义了我们使用的解释器。在我们的例子中,这是 Spark SQL 解释器。
查询选择了demoView中的所有列。在按下Shift + Enter之后,以下表格将会显示:

如您所见,SQL 解释器通过在段落的输出部分显示表格来显示查询的结果。
注意表格顶部的菜单。有多种方式来表示数据——柱状图、饼图、面积图、折线图和散点图。
点击柱状图。笔记本现在看起来是这样的:

可能看起来有些奇怪,我们没有可用的数据。实际上,我们需要配置图表才能使其工作。点击设置,然后定义哪个列是值列,以及您想要对哪个列进行数据聚合。
将id标签拖到groups框中,将data拖到values框中。因为我们选择按 ID 分组,所以执行data的SUM。配置和更新的图表应该看起来像这样:

图表现在正确。所有id a的总和是3,b id的总和是8,c id的总和是4。
我们现在对 Zeppelin 足够熟悉,可以对我们上一章产生的比特币交易数据进行分析。
使用 Zeppelin 分析交易
在上一章中,我们编写了一个程序,将 BTC/USD 交易保存到 Parquet 文件中。在本节中,我们将使用 Zeppelin 和 Spark 读取这些文件并绘制一些图表。
如果您直接来到这一章,您首先需要设置bitcoin-analyser项目,如第十章中所述,获取和持久化比特币市场数据。
然后,您可以:
-
运行
BatchProducerAppIntelliJ。这将保存项目目录下data文件夹中最后 24 小时的交易,然后每小时保存新的交易。 -
使用 GitHub 上提交的示例交易数据。您将需要检出此项目:
github.com/PacktPublishing/Scala-Programming-Projects。
绘制我们的第一个图表
准备好这些 Parquet 文件后,在 Zeppelin 中创建一个新的笔记本,并将其命名为Batch analytics。然后在第一个单元格中输入以下内容:
val transactions = spark.read.parquet("<rootProjectPath>/Scala-Programming-Projects/bitcoin-analyser/data/transactions")
z.show(transactions.sort($"timestamp"))
第一行从交易文件创建DataFrame。您需要将parquet函数中的绝对路径替换为您的 Parquet 文件路径。第二行使用特殊的z变量以表格形式显示DataFrame的内容。这个z变量在所有笔记本中自动提供。它的类型是ZeppelinContext,允许您与 Zeppelin 渲染器和解释器交互。
使用Shift + Enter执行单元格。您应该看到以下内容:

有一个警告信息说输出被截断。这是因为我们检索了太多的数据。如果我们尝试绘制一个图表,一些数据将会缺失。
解决这个问题的方法可以是更改 Zeppelin 的设置并增加限制。但如果我们这样做,浏览器将不得不在内存中保留大量数据,而且保存到磁盘上的笔记本文件也会很大。
一个更好的解决方案是对数据进行聚合。我们无法在图表上显示一天内发生的所有交易,但如果我们可以用20 分钟的时间窗口来聚合它们,这将减少要显示的数据点的数量。创建一个新的单元格,并输入以下代码:
val group = transactions.groupBy(window($"timestamp", "20 minutes"))
val tmpAgg = group.agg(
count("tid").as("count"),
avg("price").as("avgPrice"),
stddev("price").as("stddevPrice"),
last("price").as("lastPrice"),
sum("amount").as("sumAmount"))
val aggregate = tmpAgg.select("window.start", "count", "avgPrice", "lastPrice", "stddevPrice", "sumAmount").sort("start").cache()
z.show(aggregate)
运行这个新单元格。你应该看到以下输出:
group: org.apache.spark.sql.RelationalGroupedDataset = RelationalGroupedDataset: [grouping expressions: [window: struct<start: timestamp, end: timestamp>], value: [timestamp: timestamp, tid: int ... 4 more fields], type: GroupBy]
tmpAgg: org.apache.spark.sql.DataFrame = [window: struct<start: timestamp, end: timestamp>, count: bigint ... 4 more fields]
aggregate: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [start: timestamp, count: bigint ... 4 more fields]
这里是对前面代码的进一步描述:
-
首先,我们以
20 分钟的时间窗口对交易进行分组。在输出中,你可以看到group的类型是RelationalGroupedDataset。这是一个中间类型,我们必须调用一个聚合方法来生成另一个DataFrame。 -
然后我们在
group上调用agg方法一次性计算几个聚合。agg方法接受几个Column参数作为vararg。我们传递的Column对象是通过调用org.apache.spark.sql.functions对象的各种聚合函数获得的。每个列都被重命名,这样我们就可以在以后轻松地引用它。 -
结果变量
tmpAgg是一个DataFrame,它有一个类型为struct的window列。struct嵌套了几个列。在我们的例子中,它有一个类型为timestamp的start列和end列。tmpAgg还包含所有包含聚合的列——count、avgPrice和sumAmount。 -
之后,我们只选择我们感兴趣的列,然后将结果
DataFrame赋值给变量aggregate。注意,我们可以使用“.”符号来引用窗口列的嵌套列。在这里,我们选择除了window.end之外的所有行。然后我们按升序start时间对DataFrame进行sort,这样我们就可以将start作为我们未来图表的x轴。最后,我们cache这个DataFrame,这样 Spark 在创建其他具有不同图表的单元格时就不必重新处理它。
在输出下方,Zeppelin 显示了一个表格,但这次它没有给出任何警告。它能够加载所有数据而不会截断。因此,我们可以绘制一个图表:
-
点击折线图按钮
-
将“开始”列拖放到该部分
-
将
avgPrice和lastPrice拖放到值部分
你应该看到类似这样的:

我们可以看到平均价格和最后价格在 20 分钟增量中的演变。
如果你在图表上悬停鼠标,Zeppelin 会显示对应数据点的信息:

随意尝试不同类型的图表,为y轴设置不同的值。
绘制更多图表
由于我们的aggregate DataFrame在作用域内可用,我们可以创建新的单元格来绘制不同的图表。使用以下代码创建两个新的单元格:
%spark
z.show(aggregate)
然后,在每个新的单元格上,点击折线图按钮并将start列拖到“键”部分。之后:
-
在第一个图表中,将
sumAmount拖放到“值”部分。该图表显示了交易量的演变。 -
在第二个图表中,将
stddevPrice拖放到“值”部分。该图表显示了标准差的演变。 -
在两个单元格上点击“设置”以隐藏设置。
-
在两个单元格上点击“隐藏编辑器”。
你应该得到类似这样的结果:

我们可以观察到在相同时间点出现峰值——当交易量激增时,标准差也会激增。这是因为许多大交易显著地移动了价格,从而增加了标准差。
你现在有了运行自己的分析的基本构建块。借助Dataset API 的力量,你可以使用filter钻取到特定时间段,然后在不同时间段内绘制移动平均线的演变。
我们笔记本的唯一问题是每小时只会得到新的交易。我们在上一章中编写的BatchProducerApp不会频繁产生交易,而且如果你每隔几秒就尝试调用 REST API,你将被 Bitstamp 服务器列入黑名单。获取实时交易的最佳方式是使用 WebSocket API。
为了解决这个问题,在下一节中,我们将构建一个名为StreaminProducerApp的应用程序,该应用程序将实时交易推送到 Kafka 主题。
介绍 Apache Kafka
在上一节“介绍 Lambda 架构”中,我们提到 Kafka 用于流处理。Apache Kafka 是一个高吞吐量的分布式消息系统。它允许解耦进入的数据和出去的数据。
这意味着多个系统(生产者)可以向 Kafka 发送消息。然后 Kafka 将这些消息发送给已注册的消费者。
Kafka 是分布式、弹性、容错的,并且具有非常低的延迟。Kafka 可以通过向系统中添加更多机器来水平扩展。它是用 Scala 和 Java 编写的。
Kafka 被广泛使用;Airbnb、Netflix、Uber 和 LinkedIn 都使用这项技术。
本章的目的不是让你成为 Kafka 的专家,而是让你熟悉这项技术的 fundamentals。到本章结束时,你将能够理解本章中开发的用例——在 Lambda 架构中流式传输比特币交易。
主题、分区和偏移量
为了处理生产者和消费者之间交换的消息,Kafka 定义了三个主要组件——主题、分区和偏移量。
主题将相同类型的消息分组用于流式传输。它有一个名称和分区数。你可以有任意多的主题。由于 Kafka 可以在多个节点上分布式部署,它需要一种方法将这些不同节点上的消息流(主题)分割成多个分区。每个分区包含发送到主题的消息的一部分。
Kafka 集群中的每个节点管理多个分区。一个特定的分区被分配给多个节点。这样,如果一个节点丢失,可以避免数据丢失,并允许更高的吞吐量。默认情况下,Kafka 使用消息的哈希码将其分配到分区。你可以为消息定义一个键来控制这种行为。
在分区中,消息的顺序是有保证的,一旦消息被写入,就不能更改。消息是不可变的。
一个主题可以被零个到多个消费者进程消费。Kafka 的一个关键特性是每个消费者可以以自己的速度消费流:一个生产者可以发送消息 120,而一个消费者正在处理消息 40,另一个消费者正在处理消息 100。
这种不对称性是通过将消息存储在磁盘上实现的。Kafka 将消息保留一定时间;默认设置是一周。
内部,Kafka 使用 ID 来跟踪消息,并使用序列号来生成这些 ID。它为每个分区维护一个唯一的序列号。这个序列号被称为偏移量。偏移量只对特定的分区有意义。
Kafka 集群中的每个节点运行一个称为代理的过程。每个代理管理一个或多个主题的分区。
让我们用一个例子来总结一下。我们可以定义一个名为shapes的主题,其分区数等于两个。该主题接收消息,如下面的图所示:

假设我们集群中有三个节点。代理、分区、偏移量和消息的表示如下:

注意,因为我们只定义了两个分区,而我们有三台机器,所以其中一台机器将不会被使用。
当你定义分区时,另一个可用的选项是副本数。为了提高容错性,Kafka 在多个代理中复制数据,这样如果某个代理失败,可以从另一个代理检索数据。
现在,你应该对 Kafka 架构的基本原理更加熟悉了。我们现在将花一点时间讨论两个其他组件:生产者和消费者。
将数据生产到 Kafka
在 Kafka 中,将消息发送到主题的组件被称为生产者。生产者的责任是通过代理自动选择一个分区来写入消息。在发生故障的情况下,生产者应该自动恢复。
分区选择基于键。生产者将负责将所有具有相同键的消息发送到同一个分区。如果没有提供消息的键,生产者将使用轮询算法进行消息负载均衡。
你可以配置生产者以接收你想要的确认级别。有三个级别:
-
acks=0: 生产者发送数据后就会忘记它;不进行任何确认。没有保证,消息可能会丢失。 -
acks=1: 生产者等待第一个副本的确认。只要确认的代理没有崩溃,你可以确信不会丢失数据。 -
acks=all: 生产者等待所有副本的确认。你可以确信,即使一个代理崩溃,也不会丢失数据。
当然,如果你想要所有副本的确认,你可能会期望更长的延迟。第一个副本(ack=1)的确认是在安全性和延迟之间的一种良好折衷。
从 Kafka 消费数据
要从主题中读取消息,你需要运行一个消费者。与生产者一样,消费者将自动选择读取的代理,并在失败时恢复。
消费者从所有分区读取消息。在分区内部,它保证按它们产生的顺序接收消息。
消费者组
你可以在一个消费者组中为同一个主题拥有多个消费者。如果你在一个组中有相同数量的消费者和分区,每个消费者只会读取一个分区。这允许并行化消费。如果你有比分区更多的消费者,第一个消费者将获取分区,其余的消费者将处于等待模式。只有在读取分区的消费者失败时,它们才会进行消费。
到目前为止,我们只有一个消费者组正在读取分区。在一个典型的系统中,你会有许多消费者组。例如,在比特币交易的情况下,我们可能有一个消费者组读取消息以执行分析,另一个组用于显示所有交易的用户界面。这两种情况之间的延迟不同,我们不希望每个用例之间有依赖关系。为此目的,Kafka 使用了组的概念。
偏移量管理
另一个重要的概念是,当消费者读取一条消息时,它会自动通知 Kafka 已读取的偏移量。这样,如果消费者崩溃,Kafka 就知道最后读取的消息的偏移量。当消费者重新启动时,它可以发送下一条消息。至于生产者,我们可以决定何时提交偏移量。有三个选项:
-
至多一次;一旦收到消息,就提交偏移量。
-
至少一次;消息处理完成后提交偏移量。
-
精确一次;消息处理完成后提交偏移量,并且对生产者有额外的约束——在网络故障的情况下,生产者不得重发消息。生产者必须具有幂等性和事务性能力,这些能力在 Kafka 0.11 中引入。
最常用的选项是 至少一次。如果消息的处理失败,你可以重新处理它,但你可能会偶尔接收到相同的消息多次。在 最多一次 的情况下,如果在处理过程中出现任何问题,消息将会丢失。
好的,理论就到这里。我们已经了解了主题、分区、偏移量、消费者和生产者。最后缺失的知识点是一个简单的问题——我如何将我的生产者或消费者连接到 Kafka?
连接到 Kafka
在 主题、分区和偏移量 这一部分,我们介绍了代理的概念。代理部署在多台机器上,所有代理共同构成了我们所说的 Kafka 集群。
如果你想要连接到 Kafka 集群,你所需要知道的就是其中一个代理的地址。所有代理都知道集群的所有元数据——代理、分区和主题。内部,Kafka 使用一个名为 Zookeeper 的产品。这允许代理之间共享所有这些元数据。
将事务流式传输到 Kafka
在本节中,我们将编写一个程序来生成实时 BTC/USD 交易的流。我们的程序将:
-
订阅 Bitstamp 的 WebSocket API 以获取 JSON 格式的交易流。
-
对于流中进入的每个事务,它将:
-
反序列化它
-
转换为我们在上一章的
BatchProducer中使用的相同的Transactioncase class -
序列化它
-
将其发送到 Kafka 主题
-
在下一节中,我们将再次使用 Zeppelin 和 Spark Streaming 来查询流式传输到 Kafka 主题的数据。
使用 Pusher 订阅
前往 Bitstamp 的 WebSocket API 以获取实时交易:www.bitstamp.net/websocket/。
你会看到这个 API 使用一个名为 Pusher channels 的工具进行实时 WebSocket 流式传输。API 文档提供了我们需要使用的 Pusher Key 来接收实时交易。
Pusher channels 是一个托管解决方案,用于使用发布/订阅模式发送消息流。你可以在他们的网站上了解更多信息:pusher.com/features。
让我们尝试使用 Pusher 接收一些实时的 BTC/USD 交易。打开项目 bitcoin-analyser,启动一个新的 Scala 控制台,并输入以下内容:
import com.pusher.client.Pusher
import com.pusher.client.channel.SubscriptionEventListener
val pusher = new Pusher("de504dc5763aeef9ff52")
pusher.connect()
val channel = pusher.subscribe("live_trades")
channel.bind("trade", new SubscriptionEventListener() {
override def onEvent(channel: String, event: String, data: String):
Unit = {
println(s"Received event: $event with data: $data")
}
})
让我们详细看看:
-
在第一行,我们使用在 Bitstamp 文档中指定的密钥创建 Pusher 客户端。
-
然后我们连接到远程 Pusher 服务器并订阅
"live_trades"通道。我们获得一个channel类型的对象。 -
最后,我们使用
channel注册(绑定)一个回调函数,每当channel收到名为trade的新事件时,该函数将被调用。
几秒钟后,您应该会看到一些交易被打印出来:
Received event: trade with data: {"amount": 0.001, "buy_order_id": 2165113017, "sell_order_id": 2165112803, "amount_str": "0.00100000", "price_str": "6433.53", "timestamp": "1537390248", "price": 6433.5299999999997, "type": 0, "id": 74263342}
Received event: trade with data: {"amount": 0.0089460000000000008, "buy_order_id": 2165113493, "sell_order_id": 2165113459, "amount_str": "0.00894600", "price_str": "6433.42", "timestamp": "1537390255", "price": 6433.4200000000001, "type": 0, "id": 74263344}
(...)
数据以 JSON 格式存储,其模式符合 Bitstamp 的 WebSocket 文档中定义的模式。
通过这几行代码,我们可以编写我们应用程序的第一个构建块。在 src/main/scala 中的 coinyser 包下创建一个新的对象,名为 StreamingProducerApp,内容如下:
package coinyser
import java.sql.Timestamp
import java.text.SimpleDateFormat
import java.util.TimeZone
import cats.effect.IO
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import com.pusher.client.Client
import com.pusher.client.channel.SubscriptionEventListener
import com.typesafe.scalalogging.StrictLogging
object StreamingProducer extends StrictLogging {
def subscribe(pusher: Client)(onTradeReceived: String => Unit):
IO[Unit] =
for {
_ <- IO(pusher.connect())
channel <- IO(pusher.subscribe("live_trades"))
_ <- IO(channel.bind("trade", new SubscriptionEventListener() {
override def onEvent(channel: String, event: String, data:
String): Unit = {
logger.info(s"Received event: $event with data: $data")
onTradeReceived(data)
}
}))
} yield ()
}
我们的 subscribe 函数接受一个 Pusher 实例(类型为 Client)和一个回调函数 onTradeReceived,并返回 IO[Unit]。当 IO 运行时,它将在每次收到新的交易时调用 onTradeReceived。实现与我们在控制台中输入的几行代码类似。它基本上将每个副作用函数包装在 IO 中。
为了简洁性和可读性,我们没有公开此函数的单元测试细节。您可以在 GitHub 仓库中查看它。
为了编写测试,我们不得不创建一个实现 Client 接口几个方法的 FakePusher 类。
反序列化实时交易
当我们订阅实时交易时收到的 JSON 负载数据与我们在 REST 端点获取批量交易时拥有的数据略有不同。
在我们能够将其转换为与上一章中使用的相同的 Transaction case class 之前,我们需要将其反序列化为 case class。为此,首先在 src/main/scala 中创建一个新的 case class coinyser.WebsocketTransaction:
package coinyser
case class WebsocketTransaction(amount: Double,
buy_order_id: Long,
sell_order_id: Long,
amount_str: String,
price_str: String,
timestamp: String,
price: Double,
`type`: Int,
id: Int)
属性的名称和类型与 JSON 属性相对应。
之后,我们可以为一个新的函数 deserializeWebsocketTransaction 编写单元测试。在 src/test/scala 中创建一个新的类 coinyser.StreamingProducerSpec:
package coinyser
import java.sql.Timestamp
import coinyser.StreamingProducerSpec._
import org.scalactic.TypeCheckedTripleEquals
import org.scalatest.{Matchers, WordSpec}
class StreamingProducerSpec extends WordSpec with Matchers with TypeCheckedTripleEquals {
"StreamingProducer.deserializeWebsocketTransaction" should {
"deserialize a valid String to a WebsocketTransaction" in {
val str =
"""{"amount": 0.045318270000000001, "buy_order_id": 1969499130,
|"sell_order_id": 1969495276, "amount_str": "0.04531827",
|"price_str": "6339.73", "timestamp": "1533797395",
|"price": 6339.7299999999996, "type": 0, "id":
71826763}""".stripMargin
StreamingProducer.deserializeWebsocketTransaction(str) should
===(SampleWebsocketTransaction)
}
}
}
object StreamingProducerSpec {
val SampleWebsocketTransaction = WebsocketTransaction(
amount = 0.04531827, buy_order_id = 1969499130, sell_order_id =
1969495276, amount_str = "0.04531827", price_str = "6339.73",
timestamp = "1533797395", price = 6339.73, `type` = 0, id =
71826763)
}
测试很简单——我们定义一个样本 JSON 字符串,调用 test 下的函数,并确保反序列化的对象 SampleWebsocketTransaction 包含相同的值。
现在我们需要实现这个函数。向 StreamingProducer 对象添加一个新的 val mapper: ObjectMapper 和一个新的函数 deserializeWebsocketTransaction:
package coinyser
import java.sql.Timestamp
import java.text.SimpleDateFormat
import java.util.TimeZone
import cats.effect.IO
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import com.pusher.client.Client
import com.pusher.client.channel.SubscriptionEventListener
import com.typesafe.scalalogging.StrictLogging
object StreamingProducer extends StrictLogging {
def subscribe(pusher: Client)(onTradeReceived: String => Unit):
IO[Unit] =
...
val mapper: ObjectMapper = {
val m = new ObjectMapper()
m.registerModule(DefaultScalaModule)
val sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
sdf.setTimeZone(TimeZone.getTimeZone("UTC"))
m.setDateFormat(sdf)
}
def deserializeWebsocketTransaction(s: String): WebsocketTransaction
= {
mapper.readValue(s, classOf[WebsocketTransaction])
}
}
对于这个项目部分,我们使用 Jackson Java 库来反序列化/序列化 JSON 对象。这是 Spark 在从/向 JSON 读取/写入 dataframe 时在底层使用的库。因此,它无需添加任何更多依赖项即可使用。
我们定义一个常量 mapper: ObjectMapper,它是 Jackson 序列化/反序列化类的入口点。我们将其配置为以与 Spark 可以解析的格式写入时间戳。这将在我们使用 Spark 读取 Kafka 主题时变得必要。然后函数的实现调用 readValue 将 JSON 反序列化为 WebsocketTransaction。
转换为交易并序列化
我们能够监听实时交易并将它们反序列化为WebsocketTransaction类型的对象。下一步是:
-
将这些
WebsocketTransaction对象转换为我们在上一章中定义的相同案例类Transaction。 -
将这些
Transaction对象发送到 Kafka 主题。但为此,它们需要先进行序列化。最简单的方法是将它们序列化为 JSON。
如往常一样,我们首先编写测试。将以下测试添加到StreamingProducerSpec:
class StreamingProducerSpec extends WordSpec with Matchers with TypeCheckedTripleEquals {
"StreamingProducer.deserializeWebsocketTransaction" should {...}
"StreamingProducer.convertTransaction" should {
"convert a WebSocketTransaction to a Transaction" in {
StreamingProducer.convertWsTransaction
(SampleWebsocketTransaction) should
===(SampleTransaction)
}
}
"StreamingProducer.serializeTransaction" should {
"serialize a Transaction to a String" in {
StreamingProducer.serializeTransaction(SampleTransaction) should
===(SampleJsonTransaction)
}
}
}
object StreamingProducerSpec {
val SampleWebsocketTransaction = WebsocketTransaction(...)
val SampleTransaction = Transaction(
timestamp = new Timestamp(1533797395000L), tid = 71826763,
price = 6339.73, sell = false, amount = 0.04531827)
val SampleJsonTransaction =
"""{"timestamp":"2018-08-09 06:49:55",
|"date":"2018-08-09","tid":71826763,"price":6339.73,"sell":false,
|"amount":0.04531827}""".stripMargin
}
convertWsTransaction的测试检查一旦转换,SampleWebsocketTransaction与SampleTransaction相同。
serializeTransaction的测试检查一旦序列化,SampleTransaction与SampleJsonTransaction相同。
这两个函数的实现很简单。在StreamingProducer中添加以下定义:
object StreamingProducer extends StrictLogging {
def subscribe(pusher: Client)(onTradeReceived: String => Unit):
IO[Unit] = ...
val mapper: ObjectMapper = {...}
def deserializeWebsocketTransaction(s: String): WebsocketTransaction
= {...}
def convertWsTransaction(wsTx: WebsocketTransaction): Transaction =
Transaction(
timestamp = new Timestamp(wsTx.timestamp.toLong * 1000), tid =
wsTx.id, price = wsTx.price, sell = wsTx.`type` == 1, amount =
wsTx.amount)
def serializeTransaction(tx: Transaction): String =
mapper.writeValueAsString(tx)
}
在convertWsTransaction中,我们必须将时间戳乘以 1,000 以获得毫秒时间。其他属性只是复制。
在serializeTransaction中,我们重用mapper对象将Transaction对象序列化为 JSON。
将所有这些放在一起
现在我们有了创建应用程序的所有构建块。创建一个新的对象coinyser.StreamingProducerApp,并输入以下代码:
package coinyser
import cats.effect.{ExitCode, IO, IOApp}
import com.pusher.client.Pusher
import StreamingProducer._
import org.apache.kafka.clients.producer.{KafkaProducer, ProducerRecord}
import scala.collection.JavaConversions._
object StreamingProducerApp extends IOApp {
val topic = "transactions"
val pusher = new Pusher("de504dc5763aeef9ff52")
val props = Map(
"bootstrap.servers" -> "localhost:9092",
"key.serializer" ->
"org.apache.kafka.common.serialization.IntegerSerializer",
"value.serializer" ->
"org.apache.kafka.common.serialization.StringSerializer")
def run(args: List[String]): IO[ExitCode] = {
val kafkaProducer = new KafkaProducerInt, String
subscribe(pusher) { wsTx =>
val tx = convertWsTransaction(deserializeWebsocket
Transaction(wsTx))
val jsonTx = serializeTransaction(tx)
kafkaProducer.send(new ProducerRecord(topic, tx.tid, jsonTx))
}.flatMap(_ => IO.never)
}
}
我们的对象扩展了cats.IOApp,因此我们必须实现一个返回IO[ExitCode]的run函数。
在run函数中,我们首先创建KafkaProducer[Int, String]。这个来自 Kafka 客户端库的 Java 类将允许我们向一个主题发送消息。第一个类型参数是消息键的类型。我们的消息键将是Transaction案例类中的tid属性,其类型为Int。第二个类型参数是消息本身的类型。在我们的例子中,我们使用String,因为我们打算将消息序列化为 JSON。如果存储空间是一个关注点,我们可以使用Array[Byte]和如 Avro 的二进制序列化格式。
传递给构造KafkaProducer的props Map包含与 Kafka 集群交互的各种配置选项。在我们的程序中,我们传递最小集的属性,并将其他属性保留为默认值,但还有许多更多的微调选项。您可以在以下位置了解更多信息:kafka.apache.org/documentation.html#producerconfigs。
然后我们调用我们之前实现的StreamingProducer.subscribe函数,并传递一个回调函数,每次我们收到新的交易时都会调用该函数。这个匿名函数将:
-
将 JSON 反序列化为
WebsocketTransaction。 -
将
WebsocketTransaction转换为Transaction。 -
将
Transaction序列化为 JSON。 -
使用
kafkaProducer.send将 JSON 交易发送到 Kafka 主题。为此,我们必须创建ProducerRecord,它包含主题名称、键和消息内容。
subscribe 函数返回 IO[Unit]。这将启动一个后台线程,并在我们运行它时立即完成。但我们不想立即停止主线程;我们需要让我们的程序永远运行。这就是为什么我们使用 flatMap 并返回 IO.never,这将使主线程运行直到我们杀死进程。
运行 StreamingProducerApp
在运行我们的应用程序之前,我们需要启动一个 Kafka 集群。为了简化,我们将在你的工作站上启动单个代理。如果你希望设置一个多节点集群,请参阅 Kafka 文档:
-
从此 URL 下载 Kafka 0.10.2.2:
www.apache.org/dyn/closer.cgi?path=/kafka/1.1.1/kafka_2.11-1.1.1.tgz。我们使用 1.1.1 版本,因为我们写作时已经与spark-sql-kafka-0.10库进行了测试。你可以使用 Kafka 的较新版本,但无法保证旧的 Kafka 客户端总能与较新的代理通信。 -
打开一个控制台,然后在你的喜欢的目录中解压缩包:
tar xfvz kafka*.tgz. -
前往安装目录并启动 Zookeeper:
cd kafka_2.11-1.1.1
bin/zookeeper-server-start.sh config/zookeeper.properties
你应该看到大量的日志输出。最后一行应该包含 INFO binding to port 0.0.0.0/0.0.0.0:2181。
- 打开一个新的控制台,并启动 Kafka 服务器:
cd kafka_2.11-1.1.1
bin/kafka-server-start.sh config/server.properties
你应该在最后一行看到一些日志。
- 一旦 Kafka 启动,打开一个新的控制台并运行以下命令:
cd kafka_2.11-1.1.1
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic transactions –from-beginning
此命令启动一个控制台消费者,监听 transactions 主题。任何发送到此主题的消息都将打印在控制台上。这将允许我们测试我们的程序是否按预期工作。
- 现在在 IntelliJ 中运行
StreamingProducerApp。几秒钟后,你应该在 IntelliJ 中得到类似以下的输出:
18/09/22 17:30:41 INFO StreamingProducer$: Received event: trade with data: {"amount": 0.019958119999999999, "buy_order_id": 2180038294, "sell_order_id": 2180031836, "amount_str": "0.01995812", "price_str": "6673.66", "timestamp": "1537633840", "price": 6673.6599999999999, "type": 0, "id": 74611373}
我们的应用程序从 WebSocket API 接收了一些消息并将它们发送到 Kafka 主题 transactions。
- 如果你然后回到启动控制台消费者的控制台,你应该看到新的
Transaction序列化对象实时打印出来:
{"timestamp":"2018-09-22 16:30:40","date":"2018-09-22","tid":74611373,"price":6673.66,"sell":false,"amount":0.01995812}
这表明我们的流式应用程序按预期工作。它监听 Pusher 通道以接收 BTC/USD 交易,并将接收到的所有内容发送到 Kafka 主题 transactions。现在我们已经有一些数据发送到 Kafka 主题,我们可以使用 Spark Streaming 来运行一些分析查询。
介绍 Spark Streaming
在 第十章 10,获取和持久化比特币市场数据,我们使用 Spark 以批处理模式保存交易。当你必须一次性对大量数据进行分析时,批处理模式是可行的。
但在某些情况下,你可能需要处理数据进入系统时的数据。例如,在一个交易系统中,你可能想分析经纪人完成的所有交易以检测欺诈交易。你可以在市场关闭后以批量模式执行此分析;但在这种情况下,你只能在事后采取行动。
Spark Streaming 允许你通过将输入数据划分为许多微批次来消费流式源(文件、套接字和 Kafka 主题)。每个微批次都是一个可以由Spark 引擎处理的 RDD。Spark 使用时间窗口来划分输入数据。因此,如果你定义了一个 10 秒的时间窗口,那么 Spark Streaming 将每 10 秒创建并处理一个新的 RDD:

回到我们的欺诈检测系统,通过使用 Spark Streaming,我们可以在欺诈交易出现时立即检测到其模式,并在代理上采取行动以限制损害。
在上一章中,你了解到 Spark 提供了两个用于处理批量数据的 API——RDD 和Dataset。RDD 是原始和核心 API,而Dataset是较新的一个,它允许执行 SQL 查询并自动优化执行计划。同样,Spark Streaming 提供了两个 API 来处理数据流:
-
离散流(DStream)基本上是一系列连续的 RDD。DStream 中的每个 RDD 都包含一定时间间隔内的数据。你可以在这里了解更多信息:
spark.apache.org/docs/latest/streaming-programming-guide.html。 -
结构化流是较新的。它允许你使用与 Dataset API 相同的方法。区别在于在
Dataset中,你操作的是一个随着新输入数据到达而增长的未绑定表。你可以在这里了解更多信息:spark.apache.org/docs/latest/structured-streaming-programming-guide.html。
在下一节中,我们将使用 Zeppelin 在之前在 Kafka 主题中产生的 BTC/USD 交易数据上运行一个 Spark 结构化流查询。
使用 Zeppelin 分析流式交易
到目前为止,如果你还没有这样做,你应该在你的机器上启动以下进程。如果你不确定如何启动它们,请参考前面的章节:
-
Zookeeper
-
Kafka 代理
-
StreamingProducerApp正在消费实时 BTC/USD 交易并将其推送到名为transactions的 Kafka 主题。 -
Apache Zeppelin
从 Kafka 读取交易
使用你的浏览器,创建一个名为Streaming的新 Zeppelin 笔记本,然后在第一个单元中输入以下代码:
case class Transaction(timestamp: java.sql.Timestamp,
date: String,
tid: Int,
price: Double,
sell: Boolean,
amount: Double)
val schema = Seq.empty[Transaction].toDS().schema
执行单元格以定义Transaction类和变量schema。由于 Zeppelin 无法访问我们的 IntelliJ 项目bitcoin-analyser中的类,我们必须在 Zeppelin 中重新定义Transactioncase 类。我们本可以打包一个.jar文件并将其添加到 Zeppelin 的依赖设置中,但由于我们只需要这个类,我们发现重新定义它更容易。schema变量是DataType类型。我们将在下一段中使用它来反序列化从 Kafka 主题中消费的 JSON 事务。
然后在下面创建一个新的单元格,并使用以下代码:
val dfStream = {
spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "localhost:9092")
.option("startingoffsets", "latest")
.option("subscribe", "transactions")
.load()
.select(
from_json(col("value").cast("string"), schema)
.alias("v")).select("v.*").as[Transaction]
}
这创建了一个类型为DataFrame的新变量dfStream。为了创建这个,我们调用了:
-
在
spark: SparkSession对象上的readStream方法。它返回一个DataStreamReader类型的对象,我们可以进一步配置它。 -
方法
format("kafka")和option("kafka.bootstrap.servers", "localhost:9092")指定我们想要从 Kafka 读取数据,并指向端口9092上的本地主机代理。 -
option("startingoffsets", "latest")表示我们只想从最新偏移量处消费数据。其他选项是"earliest",或一个 JSON 字符串,指定每个TopicPartition的起始偏移量。 -
option("subscribe", "transaction")指定我们想要监听主题transactions。这是我们StreamingProducerApp中使用的主题名称。 -
load()调用返回DataFrame。但在这一阶段,它只包含一个包含原始 JSON 的value列。 -
我们随后使用
from_json函数和上一段中创建的schema对象反序列化 JSON。这返回一个类型为struct的单列。为了使所有列都在根级别,我们使用alias("v")重命名列,并使用select("v.*")选择其内部的所有列。
当你运行段落时,你会得到以下输出:
dfStream: org.apache.spark.sql.DataFrame = [timestamp: timestamp, date: string ... 4 more fields]
到目前为止,我们有一个包含所有我们需要进一步分析的列的DataFrame,对吗?让我们尝试显示它。创建一个新的段落并运行以下代码:
z.show(dfStream)
你应该看到以下错误:
java.lang.RuntimeException: java.lang.reflect.InvocationTargetException at org.apache.zeppelin.spark.SparkZeppelinContext.showData(SparkZeppelinContext.java:112) at org.apache.zeppelin.interpreter.BaseZeppelinContext.show(BaseZeppelinContext.java:238) at org.apache.zeppelin.interpreter.BaseZeppelinContext.show(BaseZeppelinContext.java:224) ... 52 elided Caused by: java.lang.reflect.InvocationTargetException: org.apache.spark.sql.AnalysisException: Queries with streaming sources must be executed with writeStream.start();;
这里的问题是缺少一个写入步骤。尽管DataFrame类型与我们从Batch笔记本中获得的确切相同,但我们不能以完全相同的方式使用它。我们创建了一个可以消费和转换来自 Kafka 的消息的流式DataFrame,但它应该对这些消息做什么呢?
在 Spark 结构化流世界中,不能使用show、collect或take等操作方法。你必须告诉 Spark 它消费的数据应该写入哪里。
写入内存中的接收器
Spark 结构化流处理过程有三个类型的组件:
-
输入源通过在
DataStreamReader上调用format(source: String)方法进行指定。这个源可以是文件、Kafka 主题、网络套接字或恒定速率。一旦通过option配置,调用load()将返回一个DataFrame。 -
操作是经典的
DataFrame/Dataset转换,例如map、filter、flatMap和reduce。它们以Dataset作为输入,并返回另一个带有记录转换的转换后的Dataset。 -
输出目标会将转换后的数据写入。为了指定目标,我们首先需要通过在
Dataset上调用writeStream方法来获取DataStreamWriter,然后进行配置。为此,我们必须在DataStreamWriter上调用format(source: String)方法。输出目标可以是文件、Kafka 主题或接受回调的foreach。为了调试目的,还有一个控制台目标和一个内存目标。
回到我们的流交易,我们在调用z.show(dfStream)后获得的错误表明我们缺少DataFrame的目标。为了解决这个问题,添加一个包含以下代码的新段落:
val query = {
dfStream
.writeStream
.format("memory")
.queryName("transactionsStream")
.outputMode("append")
.start()
}
这段代码为我们的DataFrame配置了一个内存目标。这个目标创建了一个名为queryName("transactionsStream")的内存 Spark 表。每当处理一个新的交易微批处理时,名为transactionsStream的表将被更新。
有几种写入流目标的方法,由outputMode(outputMode: String)指定:
-
"append"表示只有新行会被写入目标。 -
"complete"会在每次更新时写入所有行。 -
"update"将写入所有被更新的行。这在执行一些聚合操作时很有用;例如,计算消息数量。你希望每次有新行进入时,新的计数都会更新。
一旦我们的DataStreamWriter配置完成,我们调用start()方法。这将启动整个工作流程在后台运行。只有从这个点开始,数据才开始从 Kafka 主题中被消费并写入内存表。所有之前的操作都是懒加载的,只是配置工作流程。
现在运行这个段落,你将看到一个类似以下的输出:
query: org.apache.spark.sql.streaming.StreamingQuery = org.apache.spark.sql.execution.streaming.StreamingQueryWrapper@3d9dc86a
这个StreamingQuery对象是后台运行中的流查询的句柄。你可以用它来监控流工作流程的进度,获取一些关于其执行计划的信息,或者完全stop它。请随意探索StreamingQuery的 API 并尝试调用其方法。
绘制散点图
由于我们有一个写入名为transactionsStream的内存表的运行中的StreamingQuery,我们可以使用以下段落显示这个表中的数据:
z.show(spark.table("transactionsStream").sort("timestamp"))
运行这个段落,如果你有交易进入你的 Kafka 主题,你应该会看到一个类似以下的表格:

然后,你可以点击散点图按钮。如果你想绘制价格的变化,你可以将timestamp拖放到x轴上,将price拖放到y轴上。你应该看到类似这样的:

如果你想要用最新的交易刷新图表,你只需重新运行这个段落。
这看起来相当不错,但如果让查询运行一段时间,你可能会得到相当多的交易。最终,你将达到 Zeppelin 可以处理的极限,并且你会得到与绘制第一个图表部分相同的错误,OUTPUT IS TRUNCATED。
为了解决这个问题,我们必须使用时间窗口对交易进行分组,就像我们对从parquet读取的批交易所做的那样。
聚合流式交易
使用以下代码添加一个新的段落:
val aggDfStream = {
dfStream
.withWatermark("timestamp", "1 second")
.groupBy(window($"timestamp", "10 seconds").as("window"))
.agg(
count($"tid").as("count"),
avg("price").as("avgPrice"),
stddev("price").as("stddevPrice"),
last("price").as("lastPrice"),
sum("amount").as("sumAmount")
)
.select("window.start", "count", "avgPrice", "lastPrice",
"stddevPrice", "sumAmount")
}
这段代码与我们绘制第一个图表部分所写的是非常相似的。唯一的区别是调用了withWatermark,但其余的代码是相同的。这是使用 Spark 结构化流的一个主要好处——我们可以重用相同的代码来转换批数据集和流数据集。
当我们聚合流数据集时,水印是强制性的。简单来说,我们需要告诉 Spark 在丢弃之前它将等待多长时间以接收迟到数据,以及它应该使用哪个时间戳列来衡量两行之间的时间。
由于 Spark 是分布式的,它可以使用多个消费者潜在地消费 Kafka 主题,每个消费者消费主题的不同分区。这意味着 Spark Streaming 可以潜在地以乱序处理交易。在我们的案例中,我们只有一个 Kafka 代理,并且不期望有大量的交易;因此,我们使用一秒的低水印。水印是一个相当复杂的话题。你可以在 Spark 网站上找到更多信息:spark.apache.org/docs/2.3.1/structured-streaming-programming-guide.html#window-operations-on-event-time。
一旦运行了这个新段落,你将得到一个新的 Dataframe,aggDfStream,它将在 10 秒的窗口内聚合交易。但在我们能够绘制这个聚合数据的图表之前,我们需要创建一个查询来连接一个内存中的接收器。使用以下代码创建一个新的段落:
val aggQuery = {
aggDfStream
.writeStream
.format("memory")
.queryName("aggregateStream")
.outputMode("append")
.start()
}
这几乎与我们在绘制散点图部分所写的是一样的。我们只是用aggDfStream代替了df,并且输出表名现在称为aggregateStream。
最后,添加一个新的段落来显示aggregateStream表中的数据:
z.show(spark.table("aggregateStream").sort("start")
你需要在运行aggQuery后至少等待 30 秒才能获取一些聚合的交易数据。稍等片刻,然后运行段落。之后,点击折线图按钮,然后将start列拖放到 keys 部分,将avgPrice拖放到 values 部分。你应该会看到一个看起来像这样的图表:

如果你 10 秒或更长时间后重新运行段落,你应该会看到它更新了新的交易数据。
结果表明,这个aggregateStream``DataFrame与我们之前在绘制第一个图表部分中创建的aggregate``DataFrame具有完全相同的列。它们之间的区别在于,aggregate是使用来自 Parquet 文件的历史批量数据构建的,而aggregateStream是使用来自 Kafka 的实时数据构建的。它们实际上是互补的——aggregate包含了过去几小时或几天的所有交易,而aggregateStream包含了从我们开始aggQuery的那个时间点以来的所有交易。如果你想绘制包含最新实时交易的所有数据的图表,你可以简单地使用两个 dataframes 的union,并适当过滤,以确保时间窗口不重复。
摘要
在本章中,你学习了如何使用 Zeppelin 查询 parquet 文件并显示一些图表。然后,你开发了一个小程序,从 WebSocket 流式传输事务数据到 Kafka 主题。最后,你使用 Zeppelin 中的 Spark Streaming 实时查询 Kafka 主题中的数据。
在所有这些构建块就绪的情况下,你拥有了分析比特币交易数据的所有工具。你可以让BatchProducerApp运行几天或几周以获取一些历史数据。借助 Zeppelin 和 Spark,你可以尝试检测模式并提出交易策略。最后,你可以使用 Spark Streaming 流程实时检测何时出现某些交易信号并自动执行交易。
我们只针对一个主题产生了流数据,但要添加其他主题,涵盖其他货币对,例如 BTC/EUR 或 BTC/ETH,将会非常简单。你也可以创建另一个程序,从另一个加密货币交易所获取数据。这将使你能够创建 Spark Streaming 查询,以检测套利机会(当产品在一个市场上的价格比另一个市场便宜时)。
本章中我们实现的基本模块也可以用于 Lambda 架构。Lambda 架构是一种数据处理架构,通过结合批处理和流式处理方法来处理大量数据。许多 Lambda 架构涉及批处理层和流式处理层拥有不同的代码库,但使用 Spark,这一负面因素可以大大减少。您可以在本网站上了解更多关于 Lambda 架构的信息:lambda-architecture.net/.
这完成了我们书籍的最后一章。我们希望您阅读它时能像我们写作它时一样享受。您将通过 Scala 的各种概念的细节而获得力量。Scala 是一种设计良好的语言,它本身不是终点,这是构建更多有趣概念的基础,比如类型理论,当然还有范畴论,我鼓励您的好奇心去寻找更多概念,以不断改进代码的可读性和质量。您还将能够将其应用于解决各种现实世界的问题。


浙公网安备 33010602011771号