Kotlin-设计模式最佳实践第二版-全-
Kotlin 设计模式最佳实践第二版(全)
原文:
zh.annas-archive.org/md5/4bae7448a8b1f63eef0c5d7397b964da
译者:飞龙
前言
设计模式通过为你提供经过验证的开发范式来帮助你作为开发者加快开发过程。重用设计模式有助于防止可能导致重大问题的复杂问题,改进你的代码库,促进代码重用,并使架构更加健壮。
本书的目标是简化 Kotlin 中设计模式的采用,并为程序员提供良好的实践。
本书首先向您展示 Kotlin 中更智能编码的实际方面,解释 Kotlin 的基本语法和设计模式的影响。然后,本书深入解释了经典创建型、结构型和行为型设计模式家族,接着转向函数式编程。随后,它将引导你了解反应式和并发模式,教你如何使用流、线程和协程来编写更好的代码。
在阅读完本书之后,你将能够高效地解决在开发应用过程中遇到的常见问题,并且能够舒适地在任何规模的可扩展和可维护的项目上工作。
这本书面向谁
这本书是为那些希望掌握 Kotlin 设计模式以构建可靠、可扩展和可维护应用的开发者而编写的。为了开始阅读这本书,建议具备一定的编程知识。虽然对设计模式的知识有帮助,但并非强制要求。
这本书涵盖的内容
第一章,开始使用 Kotlin,涵盖了 Kotlin 的基本语法,并讨论了设计模式的好处以及为什么应该在 Kotlin 中使用它们。本章的目标不是涵盖 Kotlin 的全部词汇,而是让你熟悉一些基本概念和惯用语。接下来的章节将逐渐向你展示更多与我们将要讨论的设计模式相关的语言特性。
第二章,使用创建型模式,解释了所有经典创建型模式。这些模式处理何时以及如何创建对象。掌握这些模式将使你更好地管理对象的生命周期,并编写易于维护的代码。
第三章,理解结构模式,专注于如何创建灵活且易于扩展的对象层次结构。它涵盖了装饰者和适配器模式等。
第四章,熟悉行为模式,使用 Kotlin 覆盖了行为模式。行为模式处理对象之间的交互以及对象如何动态地改变行为。我们将看到对象如何以高效和松耦合的方式通信。
第五章,介绍函数式编程,涵盖了函数式编程的基本原则以及它们如何融入 Kotlin 编程语言。它将涵盖不可变性、高级函数和函数作为值等主题。
第六章,线程和协程,深入探讨了如何在 Kotlin 中启动新线程,并涵盖了协程为什么比线程具有更好的可扩展性的原因。我们将讨论 Kotlin 编译器如何处理协程以及与协程作用域和调度器的关联。
第七章,控制数据流,涵盖了集合的高级函数。我们将看到序列、通道和流如何以并发和响应式的方式应用这些函数。
第八章,设计并发,解释了并发设计模式如何帮助我们同时管理许多任务并结构化它们的生命周期。通过有效地使用这些模式,我们可以避免资源泄漏和死锁等问题。
第九章,惯用和反模式,讨论了 Kotlin 中的最佳和最差实践。你将了解惯用 Kotlin 代码应该是什么样子,以及哪些模式应该避免。完成本章后,你应该能够编写更易于阅读和维护的 Kotlin 代码,并避免一些常见的陷阱。
第十章,使用 Ktor 构建并发微服务,通过使用 Kotlin 编程语言构建微服务来运用我们迄今为止学到的技能。为此,我们将使用由 JetBrains 开发的 Ktor 框架。
第十一章,使用 Vert.x 构建响应式微服务,通过使用基于响应式设计模式的 Vert.x 框架,展示了构建微服务的另一种方法,使用 Kotlin 语言。我们将讨论这些方法之间的权衡,查看一些真实的代码示例,并找出何时使用它们。
评估包含了本书所有章节的问题答案。
为了充分利用本书
你应该具备基本的 Java 知识,并了解 JVM 是什么。还假设你熟悉使用命令行。本书中使用的几个命令行示例基于 OS X,但可以轻松地适应 Windows 或 Linux。
如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices
。如果代码有更新,它将在 GitHub 仓库中更新。
我们在丰富的图书和视频目录中还有其他代码包可供在github.com/PacktPublishing/
找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801815727_ColorImages.pdf
。
使用的约定
本书中使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“从监听器端,处理异常就像将collect()
函数包裹在try
/catch
块中一样简单。”
代码块如下设置:
val chan = produce(capacity = 10) {
(1..10).forEach {
send(it)
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
flow {
(1..10).forEach {
...
if (it == 9) {
throw RuntimeException()
}
}
}
任何命令行输入或输出都按以下方式编写:
...
4 seconds -> received 30
5 seconds -> received 40
6 seconds -> received 49
...
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在下一屏上,选择JUnit 5作为您的测试框架,并将目标 JVM 版本设置为1.8,然后点击完成。”
小贴士或重要提示
看起来像这样。
联系我们
读者的反馈总是受欢迎的。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
读完Kotlin 设计模式和最佳实践后,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第一部分:经典模式
在本节中,我们将介绍 Kotlin 编程语言的基本语法以及在 Kotlin 中实现所有经典设计模式的方法。
经典设计模式处理系统设计中的三个主要问题:如何高效地创建对象、如何封装对象层次结构以及如何使对象行为更加动态。
我们将讨论哪些设计模式是语言的一部分,以及如何实现那些不是语言一部分的模式。
本节包括以下章节:
-
第一章, Kotlin 入门
-
第二章, 使用创建型模式
-
第三章, 理解结构模式
-
第四章, 熟悉行为模式
第一章:第一章:Kotlin 入门
本章的大部分内容将致力于基本 Kotlin 语法。在我们开始实现任何设计模式之前,对语言感到舒适是很重要的。
我们还将简要讨论设计模式解决的问题以及为什么应该在 Kotlin 中使用它们。这对那些对设计模式概念不太熟悉的人会有所帮助。但对于经验丰富的工程师来说,这也可能提供一个有趣的视角。
本章的目标不是涵盖整个语言词汇,而是让你熟悉一些基本概念和习惯用语。接下来的章节将随着我们讨论的设计模式的相关性,让你接触到更多的语言特性。
在本章中,我们将涵盖以下主要内容:
-
基本语言语法和特性
-
理解 Kotlin 代码结构
-
类型系统和
null
安全性 -
复习 Kotlin 数据结构
-
控制流程
-
处理文本和循环
-
类和继承
-
扩展函数
-
设计模式简介
到本章结束时,你将掌握 Kotlin 的基础知识,这将是后续章节的基础。
技术要求
要遵循本章中的说明,你需要以下内容:
-
IntelliJ IDEA Community Edition (
www.jetbrains.com/idea/download/
) -
OpenJDK 11 或更高版本 (
openjdk.java.net/install/
)
本章的代码文件可在 github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter01
找到。
基本语言语法和特性
无论你是来自 Java、C#、Scala 或任何其他静态类型编程语言,你都会发现 Kotlin 语法非常熟悉。这不是巧合,而是为了让那些在其他语言中有经验的人尽可能顺利地过渡到这门新语言。除了这种熟悉感之外,Kotlin 还带来了大量的特性,如更好的类型安全性。随着我们继续前进,你会发现它们都在试图解决现实世界的问题。这种实用主义方法在整个语言中都非常一致。例如,Kotlin 最强的优势之一是完整的 Java 互操作性。你可以在 Java 和 Kotlin 类旁边使用,并自由地使用 Kotlin 项目中可用的任何 Java 库。
总结来说,该语言的目标如下:
-
实用主义:使经常做的事情变得容易实现
-
可读性:在简洁性和代码所做之事的清晰性之间保持平衡
-
易于重用:支持将代码适应不同的情况
-
安全:使编写崩溃的代码变得困难
-
互操作性:允许使用现有的库和框架
本章将讨论如何实现这些目标。
多范式语言
编程语言中的主要范式包括过程式、面向对象和函数式范式。
作为一种实用主义,Kotlin 允许使用这些范式中的任何一个。它有类和继承,来自面向对象的方法。它有来自函数式编程的高阶函数。不过,如果你不想的话,你不需要将所有内容都封装在类中。如果你需要,Kotlin 允许你将整个代码结构作为一组过程和结构。你将看到所有这些方法是如何结合在一起的,因为不同的示例将结合不同的范式来解决讨论中的问题。
我们不会从头到尾全面覆盖一个主题的所有方面,而是会在前进的过程中构建知识。
理解 Kotlin 代码结构
当你开始用 Kotlin 编程时,你需要做的第一件事是创建一个新文件。Kotlin 的文件扩展名通常是.kt
。
与 Java 不同,文件名和类名之间没有强关联关系。你可以根据需要将尽可能多的公共类放入你的文件中,只要这些类彼此相关,并且你的文件不会因为过长而难以阅读。
命名约定
按照惯例,如果你的文件中只有一个类,那么你应该将文件名与类名相同。
如果你的文件包含多个类,那么文件名应该描述这些类的共同目的。根据 Kotlin 编码约定,在命名文件时使用驼峰式命名法:kotlinlang.org/docs/coding-conventions.html
。
你的 Kotlin 项目中的主文件通常应该命名为Main.kt
。
包
包是一组文件和类,它们具有相似的目的或领域。包是方便地将所有类和函数放在同一个命名空间中,通常在同一个文件夹中的方法。这就是为什么 Kotlin,类似于许多其他语言,使用包的概念。
文件所属的包使用package
关键字声明:
package me.soshin
类似于将类放入文件中,你可以将任何包放在任何目录或文件中,但如果你在 Java 和 Kotlin 之间混合使用,Kotlin 文件应遵循 Java 包规则,如docs.oracle.com/javase/tutorial/java/package/namingpkgs.html
中所述。
在纯 Kotlin 项目中,可以省略文件夹结构中的常见包前缀。例如,如果你的所有项目都在me.soshin
包下,并且你的应用程序的一部分处理抵押贷款,你可以直接将文件放在/mortgages
文件夹中,而不是像 Java 那样放在/me/soshin/mortgages
文件夹中。
对于你的Main.kt
文件,没有必要声明包。
注释
在接下来的工作中,我们将使用//
来注释单行注释,使用/* */
来注释多行注释。
注释是向其他开发者和未来的自己提供更多上下文的有用方式。现在,让我们编写我们的第一个 Kotlin 程序,并讨论 Kotlin 的指导原则是如何应用于其中的。
Hello Kotlin
没有一本关于编程语言的书籍可以避免无处不在的 Hello World 示例。我们当然不会挑战这一光荣传统。
要开始学习 Kotlin 的工作原理,让我们将以下代码放入我们的 Main.kt
文件中并运行它:
fun main() {
println("Hello Kotlin")
}
当你运行这个示例时,例如通过在 IntelliJ IDEA 中按下 运行 按钮,它简单地输出以下内容:
> Hello Kotlin
与下面这段执行相同功能的 Java 代码相比,这段代码中有一些有趣的属性:
class Main {
public static void main(String[] args) {
System.out.println("Hello Java");
}
}
让我们在下一节中关注这些属性。
没有包装类
在 Java、C#、Scala 以及许多其他语言中,为了使函数可执行,必须将其包装在类中。
虽然 Kotlin 有 包级别函数 的概念。如果你的函数不需要访问类的属性,你不需要将其包装在类中。就这么简单。
我们将在接下来的章节中更详细地讨论包级别函数。
重要提示:
从现在开始,我们将使用省略号表示法(三个点)来表示代码中省略了一些部分,以便关注重要部分。你可以在本章的 GitHub 链接中找到完整的代码示例。
没有参数
作为字符串数组提供的参数是配置命令行应用程序的一种方式。在 Java 中,你不能有一个不接受此参数数组的可运行的 main()
函数:
public static void main(String[] args) { ... }
但在 Kotlin 中,这些是完全可以选择的。
没有静态修饰符
一些语言使用 static
关键字来表示类中的函数可以在不实例化类的情况下执行。main()
函数就是一个这样的例子。
在 Kotlin 中,没有这样的限制。如果你的函数没有任何状态,你可以将其放置在类之外,Kotlin 中也没有 static
关键字。
一个更简洁的打印函数
与输出字符串到标准输出的冗长 System.out.println
方法相比,Kotlin 提供了一个名为 println()
的别名,它做的是完全相同的事情。
没有分号
在 Java 以及许多其他语言中,每个语句或表达式都必须以分号结束,如下面的示例所示:
System.out.println("Semicolon =>");
Kotlin 是一种实用主义语言。因此,相反,它在编译期间推断出应该放置分号的位置:
println("No semicolons! =>")
大多数时候,你不需要在代码中放置分号。它们被认为是可选的。
这是一个很好的例子,说明了 Kotlin 如何既实用又简洁。它去掉了许多冗余,让你专注于重要的事情。
重要提示:
对于简单的代码片段,你不必在文件中编写代码。你还可以在线使用该语言:尝试 play.kotlinlang.org/
或者在安装 Kotlin 并运行 kotlinc
后使用 REPL 和交互式 shell。
理解类型
之前,我们提到 Kotlin 是一种类型安全的语言。现在让我们来检查 Kotlin 的类型系统,并将其与 Java 提供的类型进行比较。
重要提示:
Java 示例是为了熟悉,而不是为了证明 Kotlin 在任何方面优于 Java。
基本类型
一些语言在原始类型和对象之间做出区分。以 Java 为例,有 int
类型和 Integer
——前者更节省内存,后者通过支持缺少值和具有方法而更具表现力。
在 Kotlin 中没有这样的区别。从开发者的角度来看,所有类型都是相同的。
但这并不意味着 Kotlin 在这个方面比 Java 效率低。Kotlin 编译器优化类型。所以,你不必过于担心它。
大多数 Kotlin 类型与 Java 类似命名,例外的是 Java 的 Integer
被称为 Int
,Java 的 void 被称为 Unit
。
列出所有类型没有太多意义,但这里有一些例子:
表 1.1 - Kotlin 类型
类型推断
让我们通过从我们的 Hello Kotlin
示例中提取字符串来声明我们的第一个 Kotlin 变量:
var greeting = "Hello Kotlin"
println(greeting)
注意,在我们的代码中并没有声明 greeting
是 String
类型。相反,编译器决定应该使用哪种类型的变量。与 JavaScript、Python 或 Ruby 等解释型语言不同,变量的类型只定义一次。
在 Kotlin 中,这将产生一个错误:
var greeting = "Hello Kotlin"
greeting = 1 // <- Greeting is a String
如果你希望明确定义变量的类型,可以使用以下符号:
var greeting: String = "Hello Kotlin"
值
在 Java 中,变量可以被声明为 final
。final 变量只能赋值一次,并且它们的引用实际上是不可变的:
final String s = "Hi";
s = "Bye"; // Doesn't work
Kotlin 强调我们应该尽可能使用不可变数据。Kotlin 中的不可变变量称为 val
关键字:
val greeting = "Hi"
greeting = "Bye"// Doesn't work, "Val cannot be reassigned"
值比变量更可取。不可变数据更容易推理,尤其是在编写并发代码时。我们将在 第五章 中进一步探讨这一点,介绍函数式编程。
比较和相等
我们在 Java 中很早就被教导,使用 ==
比较对象不会产生预期的结果,因为它是测试引用相等性——两个指针是否相同,而不是两个对象是否相等。
相反,在 Java 中,我们使用 equals()
来比较对象,而使用 ==
仅比较原始数据类型,这可能会导致一些混淆。
JVM 对整数进行缓存和字符串池化,以防止在某些基本情况下发生这种情况,因此为了示例,我们将使用一个大的整数:
Integer a = 1000;
Integer b = 1000;
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
这种行为远非直观。相反,Kotlin 将 ==
转换为 equals()
:
val a = 1000
val b = 1000
println(a == b) // true
println(a.equals(b)) // true
如果你确实想检查引用等价性,请使用===
。但这对于一些基本类型是不适用的:
println(a === b) // Still true
当我们学习如何实例化类时,我们将更详细地讨论引用等价性。
声明函数
在 Java 中,每个方法都必须被一个类或接口包裹,即使它不依赖于任何信息。你可能熟悉 Java 中许多只包含静态方法的Util
类,它们的唯一目的是满足语言要求并将这些方法捆绑在一起。
我们之前已经提到,在 Kotlin 中,函数可以声明在类之外。我们已经通过main()
函数看到了这一点。声明函数的关键字是fun
。参数类型位于参数名称之后,而不是之前:
fun greet(greeting: String) {
println(greeting)
}
如果你需要返回一个结果,其类型将位于函数声明之后:
fun getGreeting(): String {
return "Hello, Kotlin!"
}
你可以亲自尝试一下:
fun main() {
greet(getGreeting())
}
如果函数不返回任何内容,可以完全省略返回类型。无需将其声明为void
,或其 Kotlin 对应类型Unit
。
当一个函数非常短,仅由一个表达式组成,例如我们的getGreeting()
函数,我们可以省略返回类型和大括号,并使用更简短的表示法:
fun getGreeting() = "Hello, Kotlin!"
在这里,Kotlin 编译器会推断出我们返回的是String
类型。
与某些脚本语言不同,函数声明的顺序并不重要。你的main
函数将能够访问其作用域内的所有其他函数,即使这些函数在代码文件中位于其后。
关于函数声明还有很多其他主题,例如命名参数、默认参数和可变数量的参数。我们将在接下来的章节中通过相关示例介绍它们。
重要提示:
本书中的许多示例都假设我们提供的代码被包裹在main
函数中。如果你没有看到函数的签名,它可能应该是main
函数的一部分。作为替代,你还可以在 IntelliJ 的临时文件中运行这些示例。
空安全
在 Java 世界中,最臭名昭著的异常可能是NullPointerException
。这个异常背后的原因是 Java 中的每个对象都可以是null
。这里的代码展示了为什么这是一个问题:
final String s = null;
System.out.println(s.length());
// Causes NullPointerException
尽管 Java 没有尝试解决这个问题,但Optional
构造函数表示可能不存在值的值:
var optional = Optional.of("I'm not null");
if (optional.isPresent()) {
System.out.println(optional.get().length());
}
但这并没有解决我们的问题。如果我们的函数接收Optional
作为参数,我们仍然可以传递一个null
值,并在运行时崩溃程序:
void printLength(Optional<String> optional) {
if (optional.isPresent()) { // <- Missing null check
here
System.out.println(optional.get().length());
}
}
printLength (null); // Crashes!
Kotlin 在编译时检查null
:
val s: String = null // Won't compile
让我们看看用 Kotlin 编写的printLength()
函数:
fun printLength(s: String) {
println(s.length)
}
使用null
调用此函数根本无法编译:
printLength(null)
// Null cannot be a value of a non-null type String
如果你希望你的类型能够接收null
值,你需要使用问号将其标记为可空:
fun printLength(stringOrNull: String?) { ... }
Kotlin 中有多种处理空值的技术,例如智能转换、Elvis 运算符等。我们将在第四章“熟悉行为模式”中讨论空值的替代方案。现在让我们继续讨论 Kotlin 中的数据结构。
回顾 Kotlin 数据结构
在 Kotlin 中,我们应该熟悉三个重要的数据结构组:列表、集合和映射。我们将简要介绍每个,然后讨论一些与数据结构相关的话题,例如可变性和元组。
列表
一个listOf()
函数:
val hobbits = listOf("Frodo", "Sam", "Pippin", "Merry")
注意,我们没有指定列表的类型。原因是 Kotlin 在构建集合时也可以使用类型推断,就像在初始化变量时一样。
如果你想提供列表的类型,你可以在定义函数参数时这样做:
val hobbits: List<String> = listOf("Frodo", "Sam", "Pippin", "Merry")
要访问列表中特定索引的元素,我们使用方括号:
println(hobbits[1])
上一段代码将输出以下内容:
> Sam
集合
一个集合表示一组唯一元素。在集合中查找元素的存在比在列表中查找要快得多。但与列表不同,集合不提供索引访问。
让我们创建一组直到 1994 年之后的足球世界杯冠军:
val footballChampions = setOf("France", "Germany", "Spain", "Italy", "Brazil", "France", "Brazil", "Germany")
println(footballChampions) // [France, Germany, Spain, Italy, Brazil]
你可以看到每个国家在集合中恰好存在一次。要检查元素是否在Set
集合中,你可以使用in
函数:
println("Israel" in footballChampions)
println("Italy" in footballChampions)
这将给我们以下结果:
> false
> true
注意,尽管集合通常不保证元素的顺序,但setOf()
函数的当前实现返回LinkedHashSet
,它保留了插入顺序——“法国”在输出中首先出现,因为它是在输入中第一个国家。
映射
一个to
。实际上,这并不是一个真正的关键字,而是一个特殊函数。我们将在第五章“介绍函数式编程”中了解更多。
同时,让我们创建一个包含一些蝙蝠侠电影及其扮演布鲁斯·韦恩的演员的映射:
val movieBatmans = mapOf(
"Batman Returns" to "Michael Keaton",
"Batman Forever" to "Val Kilmer",
"Batman & Robin" to "George Clooney"
)
println(movieBatmans)
这将打印以下内容:
> {Batman Returns=Michael Keaton,
> Batman Forever=Val Kilmer,
> Batman & Robin=George Clooney}
要通过键访问值,我们使用方括号并提供键:
println(movieBatmans["Batman Returns"])
上一段代码将输出以下内容:
> Michael Keaton
这些数据结构也支持检查元素是否存在:
println(" Batman Begins " !in movieBatmans)
我们得到以下输出:
> true
可变性
我们迄今为止讨论的所有数据结构都是不可变的,或者更准确地说,是只读的。
使用listOf()
函数创建的列表没有添加新元素的方法,我们也不能替换任何元素:
hobbits[0] = "Bilbo " // Unresolved reference!
不可变数据结构非常适合编写并发代码。但有时,我们仍然需要一个可以修改的集合。为了做到这一点,我们可以使用集合函数的可变版本:
val editableHobbits = mutableListOf("Frodo", "Sam", "Pippin", "Merry")
editableHobbits.add("Bilbo")
可编辑的集合类型具有如add()
这样的函数,允许我们修改它们,换句话说,对它们进行变异。
集合的替代实现
如果你之前与 JVM 合作过,你可能知道还有其他集合和映射的实现。例如,TreeMap
按顺序存储键。
这是你在 Kotlin 中实例化它们的方法:
import java.util.*
// Mutable map that is sorted by its keys
val treeMap = java.util.TreeMap(
mapOf(
"Practical Pig" to "bricks",
"Fifer" to "straw",
"Fiddler" to "sticks"
)
)
println(treeMap.keys)
我们将得到以下输出:
> [Fiddler, Fifer, Practical Pig]
注意,三只小猪 的名字是按字母顺序排列的。
数组
在本节中,我们还应介绍另一个数据结构 – String[]
,而字符串列表声明为 List<String>
。Java 数组中的元素使用方括号访问,而列表中的元素使用 get()
方法访问。
要在 Java 中获取数组中的元素数量,我们使用 length()
方法,要获取集合中的相同操作,我们使用 size()
方法。这是 Java 遗产的一部分,以及它试图与 C++相似的努力。
在 Kotlin 中,数组语法与其他类型的集合保持一致。字符串数组声明为 Array<String>
:
val musketeers: Array<String> = arrayOf("Athos", "Porthos", "Aramis")
这是我们第一次在 Kotlin 代码中看到尖括号。类似于 Java 或 TypeScript,它们之间的类型被称为 类型参数。它表示这个数组包含字符串。我们将在 第四章,熟悉行为模式 中详细讨论这个主题,同时介绍泛型。
如果你已经有一个集合并且想要将其转换为数组,请使用 toTypedArray
函数:
listOf(1, 2, 3, 5).toTypedArray()
在其能力方面,Kotlin 数组与列表非常相似。例如,要获取 Kotlin 数组中的元素数量,我们使用与其他集合相同的 size
属性。
你什么时候需要使用数组呢? 一个例子是在 main
函数中接受参数。之前,我们只看到没有参数的 main 函数,但有时你希望从命令行传递它们。
这是一个接受命令行参数并打印所有参数的 main
函数的示例,参数之间用逗号分隔:
fun main(args: Array<String>) {
println(args.joinToString(", "))
}
其他情况包括调用期望数组作为参数的 Java 函数或使用我们将要讨论的 varargs
语法,这将在 第三章,理解结构模式 中介绍。
由于我们现在已经熟悉了一些基本数据结构,现在是时候讨论如何使用 if
和 when
表达式对它们应用逻辑了。
控制流
你可以说,控制流是编写程序的基础。我们将从两个条件表达式开始,if
和 when
。
if 表达式
在 Java 中,if
是一个语句。语句不会返回任何值。让我们看看以下函数,它返回两个可能值中的一个:
public String getUnixSocketPolling(boolean isBsd) {
if (isBsd) {
return "kqueue";
}
else {
return "epoll";
}
}
虽然这个例子很容易理解,但通常情况下,有多个 return
语句被认为是坏习惯,因为它们往往使代码更难理解。
我们可以使用 Java 的 var
关键字重写这个方法:
public String getUnixSocketPolling(boolean isBsd) {
var pollingType = "epoll";
if (isBsd) {
pollingType = "kqueue";
}
return pollingType;
}
现在,我们有一个单独的return
语句,但我们必须引入一个可变变量。同样,在这个简单的例子中,这不是问题。但,一般来说,你应该尽可能避免可变共享状态,因为这样的代码不是线程安全的。
为什么我们一开始就遇到问题呢?
与 Java 不同,在 Kotlin 中,if
是一个表达式,意味着它返回一个值。我们可以将之前的函数重写为以下 Kotlin 版本:
fun getUnixSocketPolling(isBsd: Boolean): String {
return if (isBsd) {
"kqueue"
} else {
"epoll"
}
}
或者我们可以使用更简短的形式:
fun getUnixSocketPolling(isBsd: Boolean): String = if (isBsd) "kqueue" else "epoll"
由于if
是一个表达式,我们不需要引入任何局部变量。
在这里,我们再次利用单表达式函数和类型推断。重要的是if
返回一个String
类型的值。根本不需要多个返回语句或可变变量。
重要提示:
Kotlin 的单行函数非常酷且实用,但你应该确保其他人除了你之外也能理解它们的作用。请谨慎使用。
when
表达式
如果我们想在 if
语句中添加更多条件怎么办?(没有双关语的意思)
在 Java 中,我们使用switch
语句。在 Kotlin 中,有一个when
表达式,它要强大得多,因为它可以嵌入一些其他 Kotlin 特性。让我们创建一个方法,给定一个超级英雄并告诉我们他们的宿敌是谁:
fun archenemy(heroName: String) = when (heroName) {
"Batman" -> "Joker"
"Superman" -> "Lex Luthor"
"Spider-Man" -> "Green Goblin"
else -> "Sorry, no idea"
}
when
表达式非常强大。在接下来的章节中,我们将详细说明如何将它与范围、enums
和sealed
类结合使用。
作为一般规则,如果你有超过两个条件,请使用when
。对于简单情况,请使用if
。
文本处理
我们在上一节中已经看到了许多处理文本的例子。毕竟,没有使用字符串就无法打印Hello Kotlin
,或者至少会非常尴尬和不方便。
在本节中,我们将讨论一些更高级的特性,这些特性允许你有效地操作文本。
字符串插值
假设现在我们想要实际打印上一节的结果。
首先,正如你可能已经注意到的,在之前的某个例子中,Kotlin 提供了一个巧妙的println()
标准函数,它封装了 Java 中更庞大的System.out.println
命令。
但,更重要的是,就像许多其他现代语言一样,Kotlin 支持使用${}
语法进行字符串插值。让我们以前面的例子为例:
val hero = "Batman"
println("Archenemy of $hero is ${archenemy(hero)}")
之前的代码将按以下方式打印:
> Archenemy of Batman is Joker
注意,如果你正在插值一个函数的值,你需要将其包裹在花括号中。如果是变量,则可以省略花括号。
多行字符串
Kotlin 支持多行字符串,也称为原始字符串。这个特性存在于许多现代语言中,并被引入到Java 15中的文本块。
这个想法很简单。如果我们想要打印跨越多行的文本,比如说来自刘易斯·卡罗尔的《爱丽丝梦游仙境》的一段,一种方法是将它们连接起来:
println("Twinkle, Twinkle Little Bat\n" +
"How I wonder what you're at!\n" +
"Up above the world you fly,\n" +
"Like a tea tray in the sky.\n" +
"Twinkle, twinkle, little bat!\n" +
"How I wonder what you're at!")
虽然这种方法确实有效,但它相当繁琐。
相反,我们可以使用三引号定义相同的字符串字面量:
println("""Twinkle, Twinkle Little Bat
How I wonder what you're at!
Up above the world you fly,
Like a tea tray in the sky.
Twinkle, twinkle, little bat!
How I wonder what you're at!""")
这是一种更干净地实现相同目标的方法。如果你执行这个示例,你可能会惊讶地发现诗歌的缩进不正确。原因是多行字符串保留了空白字符,例如制表符。
为了正确打印结果,我们需要添加一个trimIndent()
调用:
println("""
Twinkle, Twinkle Little Bat
How I wonder what you're at!
""".trimIndent())
多行字符串还有另一个好处——不需要在它们中转义引号。让我们看看以下示例:
println("From \" Alice's Adventures in Wonderland\" ")
注意,文本中的引号字符必须使用反斜杠字符进行转义。
现在,让我们使用多行语法查看相同的文本:
println(""" From " Alice's Adventures in Wonderland" """)
注意,不再需要转义字符。
循环
现在,让我们讨论另一个典型的控制结构——循环。循环对于大多数开发者来说是一个非常自然的结构。没有循环,重复相同的代码块将非常困难(尽管我们将在后面的章节中讨论如何在没有循环的情况下做到这一点)。
for-each 循环
在 Kotlin 中,最有助于理解循环类型的是for
-each
循环。这个循环可以遍历字符串、数据结构和基本上所有具有迭代器的对象。我们将在第四章“熟悉行为模式”中了解更多关于迭代器的知识,所以现在让我们通过一个简单的字符串来演示它们的使用:
for (c in "Word") {
println(c)
}
这将打印以下内容:
>W
>o
>r
>d
for
-each
循环适用于我们之前讨论的所有数据结构类型,即列表、集合和映射。让我们以列表为例:
val jokers = listOf("Heath Ledger", "Joaquin Phoenix", "Jack Nicholson")
for (j in jokers) {
println(j)
}
我们将得到以下输出:
> Heath Ledger
> Joaquin Phoenix
> Jack Nicholson
你将在本书中多次看到这个循环,因为它非常有用。
for 循环
虽然在某些语言中for
-each
和for
循环是完全不同的结构,但在 Kotlin 中,for
循环只是一个对范围的for
-each
循环。
为了更好地理解它,让我们看看一个打印所有单个数字的for
循环:
for (i in 0..9) {
println(i)
}
这看起来与 Java 的for
循环完全不同,可能更让你想起 Python。这两个点被称为范围运算符。
如果你运行这段代码,你会注意到这个循环是包含的。它打印了所有的数字,包括9
。这类似于以下 Java 代码:
for (int i = 0; i <= 9; i++)
如果你想要你的范围是排他的,不包括最后一个元素,你可以使用until
函数:
for (i in 0 until 10) {
println("for until $i")
// Same output as the previous
loop
}
如果你想要以相反的顺序打印数字,可以使用downTo
函数:
for (i in 9 downTo 0) {
println("for downTo $i") // 9, 8, 7...
}
虽然看起来它们更像操作符,但until
和downTo
被称为函数可能会让人感到困惑。这是 Kotlin 的另一个有趣特性,称为中缀调用,稍后我们将对其进行讨论。
当循环
与其他一些语言相比,while
循环的功能没有变化,所以我们非常简短地介绍它们:
var x = 0
while (x < 10) {
x++
println("while $x")
}
这将打印从1
到10
的数字。请注意,我们被迫将x
定义为var
。较少使用的do while
循环也存在于该语言中:
var x = 5
do {
println("do while $x")
x--
} while (x > 0)
很可能,你不会在 Kotlin 中经常使用while
循环和do while
循环。在接下来的章节中,我们将讨论更多更符合习惯的做法。
类和继承
虽然 Kotlin 是一种多范式语言,但它与基于类的 Java 编程语言有着强烈的亲和力。考虑到 Java 和 JVM 的互操作性,Kotlin 也有类和经典继承的概念也就不足为奇了。
在本节中,我们将介绍声明类、接口、抽象类和数据类的语法。
类
class
关键字,就像 Java 一样。
让我们想象我们正在开发一个视频游戏。我们可以定义一个类来表示玩家,如下所示:
class Player {
}
类的实例化看起来就像这样:
val player = Player()
注意,Kotlin 中没有new
关键字。Kotlin 编译器知道我们想要通过类名后面的圆括号创建该类的新实例。
如果类没有主体,就像这个简单的例子一样,我们可以省略大括号:
class Player // Totally fine
没有任何函数或属性的类并不特别有用,但我们在第四章中会探讨为什么存在这种语法,以及它如何与其他语言特性保持一致,即熟悉行为模式。
主要构造函数
如果玩家在创建时能够指定他们的姓名将是有用的。为了做到这一点,让我们给我们的类添加一个主要构造函数:
class Player(name: String)
现在,这个声明将不再有效:
val player = Player()
此外,我们还需要为每个新实例化的玩家提供一个名称:
val player = Player("Roland")
我们很快就会回到构造函数。但到目前为止,让我们讨论属性。
属性
在 Java 中,我们习惯于 getter 和 setter 的概念。如果我们用 Java 习惯用法在 Kotlin 中编写表示游戏玩家的类,它可能看起来像这样:
class Player(name: String) {
private var name: String = name
fun getName(): String {
return name
}
fun setName(name: String) {
this.name = name;
}
}
如果我们要获取玩家的姓名,我们调用getName()
方法。如果我们想更改玩家的姓名,我们调用setName()
方法。这很简单,但很冗长。
这是我们在 Kotlin 中第一次看到this
关键字,所以让我们快速解释一下它的含义。类似于许多其他语言,this
持有对该类当前对象的引用。在我们的例子中,它指向Player
类的实例。
那么,我们为什么不这样写我们的类呢?
class Player {
var name: String = ""
}
这种方法似乎有很多好处。当然,它比以前更简洁。现在读取一个人的姓名要短得多——player.name
。
此外,更改名称的方式更加直观——player.name = "Alex";
。
但这样做,我们失去了对对象的很多控制。例如,我们无法使Player
不可变。如果我们想让每个人都能在任何时候读取玩家的姓名,他们也将能够更改它。如果我们以后想更改代码,这将是一个重大问题。使用 setter,我们可以控制这一点,但使用公共字段则不行。
Kotlin 属性为所有这些问题提供了一个解决方案。让我们看看以下类定义:
class Player(val name: String)
注意,这几乎与主构造函数部分中的示例相同,但现在name
有一个val
修饰符。
这看起来与所有问题的PublicPerson
Java 示例相同,但实际上,这种实现与ImmutablePerson
类似,具有所有优点。
这是怎么可能的? 在幕后,Kotlin 将为我们的便利生成具有相同名称的成员和获取器。我们可以在构造函数中设置属性值,然后使用其名称访问它:
val player = Player("Alex")
println(player.name)
尝试更改我们的Player
的名称将导致错误:
player.name = "Alexey" // value cannot be reassigned
由于我们将此属性定义为值,因此它是只读的。要能够更改属性,我们需要将其定义为可变的。在构造函数参数前加上var
会自动生成一个获取器和设置器:
class Player(val name: String, var score: Int)
如果我们不想在构造时提供值的能力,我们可以将属性移动到类体内部:
class Player(val name: String) {
var score: Int = 0
}
注意,现在我们必须为该属性提供一个默认值,因为它不能简单地是null
。
自定义设置器和获取器
尽管我们现在可以轻松地设置分数,但其价值可能无效。以下是一个例子:
player.score = -10
如果我们想要有一个具有一些验证的可变属性,我们需要为它定义一个显式的设置器,使用set
语法:
class Player(val name: String) {
var score: Int = 0
set(value) {
field = if (value >= 0) {
value
} else {
0
}
}
}
在这里,value
是属性的新的值,而field
是其当前值。如果我们的新值是负数,我们决定使用默认值。
来自 Java,你可能会倾向于在你的设置器中编写以下代码:
set(value) {
this.score = if (value >= 0) value else 0
}
但是,在 Kotlin 中,这将创建一个无限递归。你必须记住 Kotlin 为可变属性生成设置器。因此,前面的代码将被翻译成类似以下的内容:
// This is a pseudocode, not real Kotlin code!
...
fun setValue(value: Int) {
setValue(value) // Infinite recursion!
}
...
因此,我们使用自动提供的field
标识符。
以类似的方式,我们可以声明一个自定义获取器:
class Player(name: String) {
val name = name
get() = field.toUpperCase()
}
首先,我们将作为构造函数参数接收的值保存到具有相同名称的字段中。然后,我们定义一个自定义获取器,该获取器将转换此属性中的所有字符为大写:
println(player.name)
我们将得到以下输出:
> ALEX
接口
你可能已经熟悉其他语言中的接口概念。但让我们快速回顾一下。
在类型化语言中,接口提供了一种定义某些类必须实现的行为的方式。定义接口的关键字很简单,就是interface
。
现在让我们定义一个用于掷骰子的接口:
interface DiceRoller {
fun rollDice(): Int
}
要实现接口,一个类在冒号后指定其名称。在 Kotlin 中没有implement
关键字。
import kotlin.random.*
class Player(...) : DiceRoller
{
...
fun rollDice() = Random.nextInt(0, 6)
}
这也是我们第一次看到import
关键字。正如其名所示,它允许我们从 Kotlin 标准库中导入另一个包,例如kotlin.random
。
Kotlin 中的接口也支持默认函数。如果一个函数不依赖于任何状态,例如这个简单地掷出0
到5
之间随机数的函数,我们可以将其移动到接口中:
interface DiceRoller {
fun rollDice() = Random.nextInt(0, 6)
}
抽象类
interface
,一个抽象类可以包含状态。
让我们创建一个抽象类,使其能够移动我们的玩家在棋盘上,或者为了简化,只需存储新的坐标:
abstract class Moveable() {
private var x: Int = 0
private var y: Int = 0
fun move(x: Int, y: Int) {
this.x = x
this.y = y
}
}
任何实现了Moveable
接口的类都将继承一个move()
函数。
现在,让我们更详细地讨论一下这里第一次出现的private
关键字。
可见性修饰符
我们在本章前面提到了private
关键字,但没有机会解释它。private
属性或函数仅对其声明的类(在这种情况下为Moveable
)可访问。
类和属性的默认可见性是公共的,所以没有必要总是使用public
关键字。
为了扩展一个抽象类,我们只需在它的名字后面加上一个冒号。在 Kotlin 中也没有extends
关键字。
class ActivePlayer(name: String) : Moveable(), DiceRoller {
...
}
那么,你如何区分抽象类和接口呢?
抽象类在其名称后面有圆括号,表示它有一个构造函数。在接下来的章节中,我们将看到这个语法的其他用途。
继承
除了扩展抽象类之外,我们还可以扩展常规类。
让我们尝试使用与抽象类相同的语法扩展我们的Player
类。我们将尝试创建一个ConfusedPlayer
类,即当给定(x和y)时,移动到(y和x)的玩家。
首先,让我们创建一个继承自Player
的类:
class ConfusedPlayer(name: String ): ActivePlayer(name)
这里,你可以看到为什么即使在抽象类中也需要圆括号。这允许向父类构造函数传递参数。这类似于在 Java 中使用super
关键字。
意外地,这段代码无法编译。原因是 Kotlin 中的所有类默认都是最终的,不能被继承。
为了允许其他类从它们继承,我们需要将它们声明为open
:
open class ActivePlayer (...) : Moveable(), DiceRoller {
...
}
现在让我们尝试重写move
方法:
class ConfusedPlayer(name : String): Player(name) {
// move() must be declared open
override fun move(x: Int, y: Int) {
this.x = y // must be declared protected
this.y = x // must be declared protected
}
}
重写允许我们重新定义父类中函数的行为。在 Java 中,@Override
是一个可选的注解,而在 Kotlin 中override
是一个强制关键字。你不能隐藏超类型方法,并且没有显式使用override
的代码无法编译。
在那段代码中,我们引入了两个其他问题。首先,我们不能重写未声明为open
的方法。其次,由于两个坐标都是private
的,我们不能从子类中修改我们的玩家坐标。
让我们使用protected
可见性修饰符使属性对子类可访问,并将函数标记为open
以能够重写它:
abstract class Moveable() {
protected var x: Int = 0
protected var y: Int = 0
open fun move(x: Int, y: Int) {
this.x = x
this.y = y
}
}
现在,两个问题都已经解决。你在这里也第一次看到了protected
关键字。类似于 Java,这个可见性修饰符使得属性或方法只对类本身及其子类可见。
数据类
记住,Kotlin 的一切都是关于生产力。Java 开发者最常见的任务之一是创建另一个 equals
或 hashCode
方法。这个任务如此常见,以至于 Kotlin 将其内置到语言中。它被称为 data 类。
让我们看看以下示例:
data class User(val username: String, private val
password: String)
这将为我们生成一个具有两个获取器而没有设置器(注意 val
部分)的类,它还将以正确的方式实现 equals
、hashCode
和 clone
函数。
data
类的引入是 Kotlin 语言在减少样板代码方面最显著的改进之一。就像常规类一样,data
类可以有自己的函数:
data class User(val username: String, private val
password: String) {
fun hidePassword() = "*".repeat(password.length)
}
val user = User("Alexey", "abcd1234")
println(user.hidePassword()) // ********
与常规类相比,data
类的主要限制是它们总是 final
的,这意味着没有其他类可以从它们继承。但为了自动生成 equals
和 hashCode
函数,这只是一个微不足道的代价。
Kotlin data
类与 Java 记录
从 Kotlin 中学习,Java 15 引入了 record
的概念:
public record User(String username, String password) {}
这两种语法都很简洁。但是,它们之间有区别吗?
-
Kotlin
data
类有一个copy()
函数,记录没有。我们将在 第二章 中介绍它,使用创建型模式,同时讨论 原型 设计模式。 -
在记录中,所有属性都必须是
final
的,或者用 Kotlin 的话说,记录只支持值而不是变量。 -
data
类可以继承自其他类,而记录不允许这样做。
总结来说,data
类在许多方面优于记录。但两者都是各自语言的优秀特性。由于 Kotlin 是以互操作性为设计理念的,你还可以轻松地将 data
类标记为记录,以便从 Java 访问:
@JvmRecord
data class User(val username: String, val password: String)
扩展函数
在继续下一章内容之前,我们将介绍最后一个特性 final
。例如,你可能希望有一个字符串具有上一节中的 hidePassword()
函数。
实现这一点的其中一种方法是为我们声明一个包装字符串的类:
data class Password(val password: String) {
fun hidePassword() = "*".repeat(password.length)
}
这个解决方案相当浪费。它增加了另一个间接层。
在 Kotlin 中,有更好的方法来实现这一点。
要扩展一个类而不从它继承,我们可以在函数名前加上我们想要扩展的类的名称:
fun String.hidePassword() = "*".repeat(this.length)
这看起来几乎像是一个常规的顶层函数声明,但有一个关键的变化——在函数名之前是一个类名。这个类被称为 方法接收者。
在函数体内部,this
将指向函数被调用的任何 String
类。
现在,让我们声明一个常规字符串并尝试在其上调用这个新函数:
val password: String = "secretpassword"
println("Password: ${password.hidePassword()}")
这将打印以下内容:
> Password: **************
这是什么黑魔法吗? 我们成功地向一个 final
类添加了一个函数,这在技术上应该是不可行的。
这是 Kotlin 编译器的一个特性,是众多特性之一。这个扩展函数将被编译成以下代码:
// This is not real Kotlin
fun hidePassword(this: String) {
"*".repeat(this.length)
}
你可以看到,实际上,这是一个常规的顶级函数。它的第一个参数是我们扩展的类的实例。这也可能让你想起Go语言中结构体的方法是如何工作的。
打印加密密码的代码将被相应地调整:
val password: String = "secretpassword"
println("Password: ${hidePassword(password)}")
因此,扩展函数不能覆盖类的成员函数,也不能访问其private
或protected
属性。
设计模式简介
现在我们对基本的 Kotlin 语法有了更多的了解,我们可以继续讨论设计模式到底是什么。
什么是设计模式?
围绕设计模式存在不同的误解。一般来说,它们如下:
-
设计模式只是缺少语言特性。
-
动态语言中不需要设计模式。
-
设计模式仅与面向对象的语言相关。
-
设计模式仅在企业中使用。
实际上,设计模式只是解决常见问题的一种经过验证的方法。作为一个概念,它们并不局限于特定的编程语言(例如 Java),也不局限于语言家族(例如 C 家族),也不局限于编程本身。你可能甚至听说过软件架构中的设计模式,它们讨论了不同的系统如何高效地相互通信。有面向服务的架构模式,你可能知道它为服务导向架构(SOA),以及从 SOA 演变而来的微服务设计模式,这些模式在过去几年中涌现出来。未来肯定会带来更多的设计模式家族。
即使在物理世界中,在软件开发之外,我们也被设计模式和针对特定问题的普遍接受解决方案所包围。让我们看看一个例子。
生活中的设计模式
你最近乘坐过电梯吗?电梯墙上有没有镜子?为什么会有镜子?当你最后一次乘坐没有镜子也没有玻璃墙的电梯时,你感觉如何?
我们在电梯中通常放置镜子的主要原因是为了解决一个常见问题——乘坐电梯很无聊。我们本可以放一张图片。但如果每天至少乘坐同一部电梯两次,图片也会很快变得无聊。虽然便宜,但改善不大。
我们可以像一些人做的那样放一个电视屏幕。但这会使电梯更贵。而且它还需要大量的维护。我们需要在屏幕上放置一些内容,以避免重复。所以,要么有一个人负责偶尔更新内容,要么有第三方公司为我们做这件事。我们还得处理屏幕硬件及其背后的软件可能出现的不同问题。当然,看到蓝屏死机是很有趣的,但只是轻微的。
一些建筑师甚至选择将电梯井设置在建筑外部,并使部分墙壁透明。这可能会提供一些令人兴奋的视野。但这个解决方案也需要维护(脏窗户不会带来最佳的视野)以及大量的建筑规划。
因此,我们放了一面镜子。即使你独自乘坐,你也能看到一位有吸引力的人。一些研究表明,无论如何,我们都会觉得自己比实际更有吸引力。也许你有机会在重要会议之前最后一次审视你的外表。镜子从视觉上扩展了空间,使整个旅程不那么压抑或尴尬,尤其是在一天的开始,电梯真的很拥挤。
设计过程
让我们尝试理解我们刚才做了什么。
我们并没有在电梯里发明镜子。我们见过它们成千上万次。但我们正式化了这个问题(乘坐电梯很无聊)并讨论了替代方案(电视屏幕和玻璃墙)以及常用解决方案的好处(解决问题且易于实施)。这就是设计模式的所有内容。
设计过程的基本步骤如下:
-
明确当前问题的定义。
-
考虑不同的替代方案,基于利弊。
-
选择解决问题的方案,同时最好地适应你的具体约束。
为什么在 Kotlin 中使用设计模式?
Kotlin 出现是为了解决当今的现实世界问题。在接下来的章节中,我们将讨论 1994 年由“四人帮”首次提出的 设计模式,以及从函数式编程范式和用于处理应用程序并发的设计模式中出现的模式。
你会发现一些设计模式非常常见或有用,以至于它们已经作为保留关键字或标准函数内置到语言中。其中一些需要结合一组语言特性。而有些则不再那么有用,因为世界已经前进,其他模式正在取代它们。
但无论如何,熟悉设计模式和最佳实践扩展了你的 开发者工具箱,并在你和你同事之间创造了一个共享的词汇。
摘要
在本章中,我们介绍了 Kotlin 编程语言的主要目标。我们学习了如何声明变量、基本类型、null
安全性和类型推断。我们观察到程序流程是通过诸如 if
、when
、for
和 while
等命令控制的,我们还研究了用于定义类和接口的不同关键字:class
、interface
、data
类和 abstract
类。我们学习了如何构建新的类以及如何实现接口和继承其他类。最后,我们讨论了在 Kotlin 中哪些设计模式是合适的以及为什么我们需要它们。
现在,你应该能够编写简单且类型安全的 Kotlin 程序。我们还需要讨论语言的其他许多方面。一旦需要应用它们,我们将在后面的章节中介绍这些内容。
在下一章中,我们将讨论三种设计模式家族中的第一个——创建型模式。
问题
-
Kotlin 中
var
和val
有什么区别? -
你如何在 Kotlin 中扩展一个类?
-
你如何向一个
final
类添加功能?
第二章:第二章:使用创建型模式
在本章中,我们将介绍如何使用Kotlin实现经典创建型模式。这些模式处理如何和何时创建你的对象。对于每个设计模式,我们将讨论它旨在实现的目标以及 Kotlin 如何满足这些需求。
我们在本章中将涵盖以下主题:
-
单例
-
工厂方法
-
抽象工厂
-
构建者
-
原型
精通这些设计模式将使你能够更好地管理你的对象,更好地适应变化,并编写易于维护的代码。
技术要求
对于本章,你需要安装以下内容:
-
IntelliJ IDEA 社区版(
www.jetbrains.com/idea/download/
) -
OpenJDK 11(或更高版本)(
openjdk.java.net/install/
)
你可以在GitHub上找到本章的代码文件,地址为github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter02
。
单例
单例——镇上最受欢迎的单身汉。每个人都认识他,每个人都谈论他,每个人都知道在哪里找到他。
即使不喜欢使用设计模式的人也会知道单例模式。在某个时候,它甚至被宣称为反模式,但这仅仅是因为它的广泛流行。
那么,对于那些第一次遇到它的人来说,这种设计模式到底是什么呢?
通常,如果你有一个类,你可以创建尽可能多的实例。例如,假设我们都要求列出我们最喜欢的电影:
val myFavoriteMovies = listOf("Black Hawk Down", "Blade Runner")
val yourFavoriteMovies = listOf(...)
注意,我们可以创建尽可能多的List
实例,而且这没有问题。大多数类都可以有多个实例。
接下来,如果我们俩都想列出《快速而愤怒》系列中的最佳电影呢?
val myFavoriteQuickAndAngryMovies = listOf()
val yourFavoriteQuickAndAngryMovies = listOf()
注意,这两个列表完全相同,因为它们都是空的。而且它们会保持空的状态,因为它们是不可变的,而且《快速而愤怒》系列电影真的很糟糕。我希望你会同意这一点。
由于这两个类的实例完全相同,根据equals 方法,将它们多次保存在内存中并没有太多意义。如果所有对空列表的引用都指向同一个对象实例,那就太好了。实际上,如果你这么想的话,所有 null 都是相同的。
这就是单例设计模式背后的主要思想。
单例设计模式有几个要求:
-
我们系统中应该只有一个实例。
-
这个实例应该可以从我们系统的任何部分访问。
在private
类中。然后,你还需要确保实例化最好是懒加载的、线程安全的,并且性能良好,以下是一些要求:
-
延迟加载:我们可能不想在程序启动时实例化单例对象,因为这可能是一个昂贵的操作。我们希望只在第一次需要时才实例化它。
-
线程安全:如果有两个线程试图同时实例化一个单例对象,它们都应该接收到相同的实例,而不是两个不同的实例。如果您不熟悉这个概念,我们将在 第五章 介绍函数式编程 中介绍它。
-
高效性:如果有许多线程试图同时实例化一个单例对象,我们不应该长时间阻塞它们,因为这会阻止它们的执行。
在 Java 或 C++ 中满足所有这些要求相当困难,或者至少非常冗长。
Kotlin 通过引入一个名为 object
的关键字使创建单例变得简单。您可能从 Scala 中认识这个关键字。通过使用这个关键字,我们将得到一个单例对象的实现,它满足我们所有的要求。
重要提示:
object
关键字不仅用于创建单例,我们将在本章后面深入讨论这一点。
我们声明对象就像一个普通类一样,但没有构造函数,因为单例对象不能由我们实例化:
object NoMoviesList
从现在起,我们可以在代码的任何地方访问 NoMoviesList
,并且它将只有一个实例:
val myFavoriteQuickAndAngryMovies = NoMoviesList
val yourFavoriteQuickAndAngryMovies = NoMoviesList
println(myFavoriteQuickAndAngryMovies ===
yourFavoriteQuickAndAngryMovies) // true
注意到参照性等号检查两个变量是否指向内存中的同一个对象。这真的是一个列表吗?
让我们创建一个打印我们电影列表的函数:
fun printMovies(movies: List<String>) {
for (m in movies) {
println(m)
}
}
当我们传递一个初始电影列表时,代码可以正常编译:
// Prints each movie on a newline
printMovies(myFavoriteMovies)
但如果我们传递一个空的电影列表给它,代码将无法编译:
printMovies(myFavoriteQuickAndAngryMovies)
// Type mismatch: inferred type is NoMoviesList but // List<String> was expected
原因在于我们的函数只接受 字符串列表 类型的参数,而没有任何东西告诉函数 NoMoviesList
是这种类型(尽管它的名字暗示了这一点)。
幸运的是,在 Kotlin 中,单例对象可以实现接口,并且有一个通用的 List
接口可用:
object NoMoviesList : List<String>
现在,我们的编译器将提示我们实现所需的函数。我们将通过为 object
添加一个主体来实现这一点:
object NoMoviesList : List<String> {
override val size = 0
override fun contains(element: String) = false
... /
}
如果您愿意,我们可以将其他函数的实现留给您。这将是对您至今为止所学的 Kotlin 知识的良好练习。然而,您不必这样做。Kotlin 已经提供了一个创建任何类型空列表的函数:
printMovies(emptyList())
如果您好奇,这个函数返回一个实现 List
的单例对象。您可以使用 IntelliJ IDEA 或在 GitHub 上查看其完整实现(github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/collections/Collections.kt
)。这是一个设计模式在现代软件中仍然积极应用的优秀例子。
Kotlin 的object
与类有一个主要区别——它不能有构造函数。如果你需要为你的 Singleton 实现初始化,例如第一次从配置文件加载数据,你可以使用init
块代替:
object Logger {
init {
println("I was accessed for the first time")
// Initialization logic goes here
}
// More code goes here
}
注意,如果 Singleton 从未被调用,它根本不会运行其初始化逻辑,从而节省资源。这被称为延迟初始化。
现在我们已经学会了如何限制对象的创建,让我们讨论如何在不直接使用构造函数的情况下创建对象。
工厂方法
工厂方法设计模式完全是关于创建对象。
但为什么我们需要一个创建对象的方法?构造函数不是用来这个的吗?
好吧,构造函数有其局限性。
例如,假设我们正在构建一个棋盘游戏。我们希望允许我们的玩家将游戏状态保存到文本文件中,然后从该位置恢复游戏。
由于棋盘的大小是预先确定的,我们只需要记录每个棋子的位置和类型。我们将使用代数符号来表示这一点——例如,位于 C3 的皇后棋子将被存储在我们的文件中为qc3
,位于 A8 的兵棋子将被存储为pa8
,以此类推。
假设我们已将此文件读入一个字符串列表(顺便说一句,这将是一个很好的 Singleton 设计模式的早期讨论的应用)。
给定符号列表,我们希望用它们填充我们的棋盘:
// More pieces here
val notations = listOf("pa8", "qc3", ...)
val pieces = mutableListOf<ChessPiece>()
for (n in notations) {
pieces.add(createPiece(n))
}
println(pieces)
在我们可以实现我们的createPiece
函数之前,我们需要决定所有棋子共有的东西。我们将为此创建一个接口:
interface ChessPiece {
val file: Char
val rank: Char
}
注意,Kotlin 中的接口可以声明属性,这是一个非常强大的功能。
每个棋子都将是一个实现我们接口的data class
:
data class Pawn(
override val file: Char,
override val rank: Char
) : ChessPiece
data class Queen(
override val file: Char,
override val rank: Char
) : ChessPiece
其他棋子的实现留给你作为练习来完成。
现在,剩下的就是实现我们的createPiece
函数:
fun createPiece(notation: String): ChessPiece {
val (type, file, rank) = notation.toCharArray()
return when (type) {
'q' -> Queen(file, rank)
'p' -> Pawn(file, rank)
// ...
else -> throw RuntimeException("Unknown piece: $type")
}
}
在我们可以讨论这个函数实现了什么之前,让我们先介绍三个我们之前没有见过的新的语法元素。
首先,toCharArray
函数将字符串分割成一个字符数组。由于我们假设所有的符号都是三个字符长,0
位置的元素将代表棋子的类型,1
位置的元素将代表其垂直列——也称为file
,最后一个元素将代表其水平列——也称为rank
。
接下来,我们可以看到三个值:type
、file
和rank
,它们被括号包围。这被称为data class
可以解构。
之前的代码示例类似于以下更冗长的代码:
val type = notation.toCharArray()[0]
val file = notation.toCharArray()[1]
val rank = notation.toCharArray()[2]
现在,让我们专注于when
表达式。根据表示类型的字母,它实例化ChessPiece
接口的一个实现。记住,这正是工厂方法设计模式的核心。
为了确保你很好地掌握这个设计模式,请随意将其他棋子的类和逻辑实现作为练习。
最后,让我们看看函数的底部,在那里我们看到第一个throw
表达式的使用。
这个表达式,正如其名所示,抛出一个异常,这将停止我们简单程序的正常执行。我们将在第五章,“介绍函数式编程”中讨论如何处理异常。
在现实世界中,工厂方法设计模式通常被需要将配置文件(无论是 XML、JSON 还是 YAML 格式)解析为运行时对象的库所使用。
静态工厂方法
有一个类似命名的模式(实现略有不同),它经常与工厂方法设计模式混淆,并在四人帮的书中描述——这是静态工厂方法设计模式。
静态工厂方法设计模式是由 Joshua Bloch 在他的书《Effective Java》中推广的。为了更好地理解这一点,让我们看看 Java 标准库中的几个例子:valueOf()
方法。从字符串构建Long
(即 64 位整数)至少有两种方式:
Long l1 = new Long("1"); // constructor
Long l2 = Long.valueOf("1"); // static factory method
构造函数和valueOf()
方法都接收字符串作为输入,并产生Long
作为输出。
那么,为什么我们应该更喜欢静态工厂方法设计模式而不是简单的构造函数呢?
与构造函数相比,使用静态工厂方法的一些优点如下:
-
它提供了显式命名不同对象构造函数的机会。当你的类有多个构造函数时,这特别有用。
-
我们通常不期望构造函数抛出异常。这并不意味着类的实例化不能失败。另一方面,常规方法的异常则更被接受。
-
说到期望,我们期望构造函数运行得快。但某些对象的构建本质上可能很慢。考虑使用静态工厂方法。
这些主要是风格上的优势;然而,这种方法也有技术上的优势。
缓存
静态工厂方法设计模式可能提供的Long
实际上确实如此。而不是总是为任何值返回一个新实例,valueOf()
会在缓存中检查此值是否已经被解析。如果是,它将返回缓存的实例。重复使用相同的值调用静态工厂方法可能产生的垃圾回收比始终使用构造函数要少。
子类化
当调用构造函数时,我们总是实例化我们指定的类。另一方面,调用静态工厂方法限制较少,可能产生类的实例或其子类之一。在讨论了在 Kotlin 中实现此设计模式之后,我们将讨论这一点。
Kotlin 中的静态工厂方法
我们在本章的单例部分之前讨论了object
关键字。现在,我们将看到它作为伴随对象的另一种用途。
在 Java 中,静态工厂方法被声明为static
。但在 Kotlin 中,没有这样的关键字。相反,不属于类实例的方法可以声明在companion object
内部:
class Server(port: Long) {
init {
println("Server started on port $port")
}
companion object {
fun withPort(port: Long) = Server(port)
}
}
重要提示:
伴随对象可以有名称——例如,companion object
解析器。但这只是为了提供关于对象目标的清晰度。
如您所见,这次,我们声明了一个以companion
关键字为前缀的对象。它位于类内部,而不是像我们在单例设计模式中看到的那样位于包级别。
这个对象有自己的方法,您可能会想知道这有什么好处。就像 Java 静态方法一样,当第一次访问包含的类时,会惰性实例化companion
object
:
Server.withPort(8080) // Server started on port 8080
此外,在类的实例上调用它根本不起作用,与 Java 不同:
Server(8080) // Won't compile, constructor is private
重要提示:
一个类可能只有一个companion object
。
有时候,我们也希望静态工厂方法是实例化我们的对象的唯一方式。为了做到这一点,我们可以将对象的默认构造函数声明为private
:
class Server private constructor(port: Long) {
...
}
这意味着现在构建我们类实例的唯一方式是通过我们的静态工厂方法:
val server = Server(8080)) // Doesn't compile
val server = Server.withPort(8080) // Works!
现在我们来讨论另一个经常与工厂方法混淆的设计模式——抽象工厂。
抽象工厂
抽象工厂是一个被广泛误解的设计模式。它因非常复杂和奇特而臭名昭著。实际上,它相当简单。如果您理解了工厂方法设计模式,您会立刻理解这个。这是因为抽象工厂设计模式是工厂的工厂。仅此而已。工厂是一个能够创建其他类的函数或类。换句话说,抽象工厂是一个封装多个工厂方法的类。
您可能已经理解了这一点,但仍会想知道这种设计模式有什么用途。在现实世界中,抽象工厂设计模式通常用于从文件中获取配置的框架和库。Spring 框架就是这些中的一个例子。
为了更好地理解设计模式的工作原理,让我们假设我们有一个用 YAML 文件编写的服务器配置:
server:
port: 8080
environment: production
我们的任务是从这个配置中构建对象。
在上一节中,我们讨论了如何使用工厂方法从同一系列中构建对象。但在这里,我们有两个相互关联但不是兄弟姐妹的对象系列。
首先,让我们将它们描述为接口:
interface Property {
val name: String
val value: Any
}
我们将返回一个接口而不是data class
。您将在本节后面看到这如何帮助我们:
interface ServerConfiguration {
val properties: List<Property>
}
然后,我们可以提供基本实现供以后使用:
data class PropertyImpl(
override val name: String,
override val value: Any
) : Property
data class ServerConfigurationImpl(
override val properties: List<Property>
) : ServerConfiguration
服务器配置仅包含属性列表——而属性是由一个name
对象和一个value
对象组成的对。
这是我们第一次看到 Any
类型被使用。Any
类型是 Kotlin 对 Java 的 object
的版本,但有一个重要的区别:它不能为 null。
现在,让我们编写我们的第一个工厂方法,它将根据给定的字符串创建 Property
:
fun property(prop: String): Property {
val (name, value) = prop.split(":")
return when (name) {
"port" -> PropertyImpl(name, value.trim().toInt())
"environment" -> PropertyImpl(name, value.trim())
else -> throw RuntimeException("Unknown property: $name")
}
}
与许多其他语言一样,trim()
是一个在字符串上声明的函数,用于删除字符串中的任何空格。现在,让我们创建两个属性来表示我们的服务的端口 (port
) 和环境 (environment
):
val portProperty = property("port: 8080")
val environment = property("environment: production")
这段代码有一个小问题。为了理解它是什么,让我们尝试将 port
属性的值存储到另一个变量中:
val port: Int = portProperty.value
// Type mismatch: inferred type is Any but Int was expected
我们已经在工厂方法中确保 port
被解析为 Int
。但现在,由于值的类型被声明为 Any
,这个信息丢失了。它可以是一个 String
、Int
或任何其他类型。我们需要一个新的工具来解决这个问题,所以让我们短暂地偏离一下,讨论 Kotlin 中的转换。
转换
在类型语言中,转换是一种尝试强制编译器使用我们指定的类型,而不是它推断出的类型。如果我们确定值的类型,我们可以在它上面使用一个 不安全的 转换:
val port: Int = portProperty.value as Int
它被称为 不安全的,是因为如果值不是我们预期的类型,我们的程序将崩溃,而编译器无法警告我们。
或者,我们可以使用 安全的 转换:
val port: Int? = portProperty.value as? Int
安全的转换不会使我们的程序崩溃,但如果对象的类型不是我们预期的,它将返回 null。注意,我们的 port
变量现在被声明为可空的 Int
类型,因此在编译时我们必须显式处理可能得不到我们想要的结果的情况。
继承
而不是求助于转换,让我们尝试另一种方法。我们不会使用一个具有 Any
类型值的单个实现,而是使用两个独立的实现:
data class IntProperty(
override val name: String,
override val value: Int
) : Property
data class StringProperty(
override val name: String,
override val value: String
) : Property
我们的工厂方法需要稍作修改才能返回这两个类中的一个:
fun property(prop: String): Property {
val (name, value) = prop.split(":")
return when (name) {
"port" -> IntProperty(name, value.trim().toInt())
"environment" -> StringProperty(name, value.trim())
else -> throw RuntimeException("Unknown property: $name")
}
}
这看起来不错,但如果我们再次尝试编译我们的代码,它仍然不会工作:
val portProperty = Parser.property("port: 8080")
val port: Int = portProperty.value
虽然我们现在有两个具体的类,但编译器不知道解析的属性是 IntProperty
还是 StringProperty
。它只知道它是 Property
,并且值的类型仍然是 Any
:
> Type mismatch: inferred type is Any but Int was expected
我们需要另一个技巧,这个技巧被称为 智能转换。
智能转换
我们可以使用 is
关键字来检查一个对象是否为给定的类型:
println(portProperty is IntProperty) // true
然而,Kotlin 编译器非常智能。如果我们在一个 if
表达式中执行类型检查,这意味着 portProperty
确实是 IntProperty
,对吧? 因此,它可以安全地进行转换。
Kotlin 编译器会为我们做这件事:
if (portProperty is IntProperty) {
val port: Int = portProperty.value // works!
}
现在不再有编译错误,我们也不必处理可空值。
智能转换也适用于 null。在 Kotlin 的类型层次结构中,不可为 null 的 Int
类型是可空类型 Int?
的子类,这对于所有类型都是真的。之前,我们提到,如果 安全的 转换失败,它将返回 null
:
val port: Int? = portProperty.value as? Int
我们可以检查 port
是否为 null,如果不是,它将智能地转换为非可空类型:
if (port != null) {
val port: Int = port
}
太好了! 但是等等,这段代码中发生了什么?
在上一章中,我们提到值不能被重新赋值。但在这里,我们定义了两次 port
值。这是怎么可能的? 这不是一个错误,而是 Kotlin 的另一个特性,称为 变量遮蔽。
变量遮蔽
首先,让我们考虑如果没有遮蔽,我们的代码会是什么样子。我们必须声明两个不同名称的变量:
val portOrNull: Int? = portProperty.value as? Int
if (portOrNull != null) {
val port: Int = portOrNull // works
}
然而,这造成了浪费,原因有两个。首先,变量名变得相当冗长。其次,portOrNull
变量很可能在此之后就不会再被使用了,因为 null 本身并不是一个非常有用的值。相反,我们可以在不同的作用域中声明具有相同名称的值,这些作用域由花括号({}
)表示。
请注意,变量遮蔽可能会让你感到困惑,并且它本质上是有缺陷的。然而,重要的是要意识到它的存在,但建议尽可能明确地命名你的变量。
工厂方法集合
既然我们已经对类型转换和变量遮蔽有了了解,让我们回到之前的代码示例,并实现第二个工厂方法,该方法将创建一个 server
配置对象:
fun server(propertyStrings: List<String>):
ServerConfiguration {
val parsedProperties = mutableListOf<Property>()
for (p in propertyStrings) {
parsedProperties += property(p)
}
return ServerConfigurationImpl(parsedProperties)
}
此方法将配置文件中的行转换为 Property
对象,使用的是我们之前已经实现的 property()
工厂方法。
我们可以测试我们的第二个工厂方法是否正常工作:
println(server(listOf("port: 8080", "environment:
production")))
> ServerConfigurationImpl(properties=[IntProperty(name=port, value=8080), StringProperty(name=environment, value=production)])
由于这两个方法相关联,将它们放在同一个类下会更好。让我们称这个类为 Parser
。尽管我们还没有解析任何实际的文件,并且已经同意我们可以逐行获取其内容,但到这本书的结尾,你可能会同意实现实际的读取逻辑相当简单。
我们还可以使用静态工厂方法和我们在上一节中学到的 companion object
语法。
结果实现将看起来像这样:
class Parser {
companion object {
fun property(prop: String): Property {
...
}
fun server(propertyStrings: List<String>): ...{
...
}
}
}
这种模式允许我们创建 家族 的对象——在这种情况下,ServerConfig
是属性的一个 父类。
之前的代码只是实现抽象工厂的一种方式。你可能会发现一些实现依赖于实现接口:
interface Parser {
fun property(prop: String): Property
fun server(propertyStrings: List<String>): ServerConfiguration
}
class YamlParser : Parser {
// Implementation specific to YAML files
}
class JsonParser : Parser {
// Implementation specific to JSON files
}
如果你的工厂方法变得很长,这种方法可能更好。
你可能还有一个问题,那就是在哪里可以看到实际代码中使用的抽象工厂。一个例子是 java.util.Collections
类。它有 emptyMap
、emptyList
和 emptySet
等方法,每个方法都生成一个不同的类。然而,它们共同的特点是它们都是集合。
构建器
有时,我们的对象非常简单,只有一个构造函数,无论是空的还是非空的。但有时,它们的创建非常复杂,基于很多参数。我们已经看到了一个提供更好的构造函数的模式——静态工厂方法设计模式。现在,我们将讨论Builder设计模式,它将帮助我们创建复杂对象。
作为这样一个对象的例子,想象我们需要设计一个发送电子邮件的系统。我们不会实现发送它们的实际机制,我们只是设计一个代表它的类。
一封电子邮件可能具有以下属性:
-
地址(至少一个必填)
-
CC(可选)
-
标题(可选)
-
正文(可选)
-
重要标志(可选)
我们可以在我们的系统中将电子邮件描述为一个data class
:
data class Mail_V1(
val to: List<String>,
val cc: List<String>?,
val title: String?,
val message: String?,
val important: Boolean,
)
重要提示:
看一下前面代码中最后一个参数的定义。这个逗号不是打字错误。它被称为尾随逗号,这些是在Kotlin 1.4中引入的。这样做是为了你可以轻松地改变参数的顺序。
接下来,让我们尝试创建一封致我们经理的电子邮件:
val mail = Mail_V1(
listOf("manager@company.com"), // To
null, // CC
"Ping ", // Title
null, // Message,
true)) // Important
注意,我们已将carbon copy(这就是CC
所代表的意思)定义为可空的,这样它就可以接收电子邮件列表或 null。另一个选项是将它定义为List<String>
并强制我们的代码传递listOf()
。
由于我们的构造函数接收了大量的参数,我们不得不添加一些注释以避免混淆。
但是,如果我们现在需要更改这个类会怎样呢?
首先,我们的代码将停止编译。其次,我们需要跟踪注释。简而言之,具有长参数列表的构造函数很快就会变得混乱。
这正是 Builder 设计模式试图解决的问题。它将参数的分配与对象的创建解耦,并允许逐步创建复杂对象。在本节中,我们将看到解决这个问题的多种方法。
让我们先创建一个新的类,MailBuilder
,它将包装我们的Mail
类:
class MailBuilder {
private var to: List<String> = listOf()
private var cc: List<String> = listOf()
private var title: String = ""
private var message: String = ""
private var important: Boolean = false
class Mail internal constructor(
val to: List<String>,
val cc: List<String>?,
val title: String?,
val message: String?,
val important: Boolean
)
... // More code will come here soon
}
我们的构建器具有与我们的结果类完全相同的属性。但这些属性都是可变的。
注意,构造函数使用internal
可见性修饰符标记。这意味着我们的Mail
类将对我们模块内的任何代码都是可访问的。
为了最终创建我们的类,我们将引入build()
函数:
fun build(): Mail {
if (to.isEmpty()) {
throw RuntimeException("To property is empty")
}
return Mail(to, cc, title, message, important)
}
对于每个属性,我们还需要另一个函数来设置它:
fun message(message: String): MailBuilder {
this.message = message
return this
}
// More functions for each of the properties
现在,我们可以使用我们的构建器以以下方式创建一个电子邮件:
val email = MailBuilder("hello@hello.com").title("What's up?").build()
在设置新值后,我们通过使用this
返回对对象的引用,这为我们提供了访问下一个 setter 的权限,以便我们可以执行链式操作(请参阅本章的流畅设置器部分以了解解释)。
这是一个有效的方法。但它有几个缺点:
-
我们结果类的属性必须在构建器内部重复。
-
对于每个属性,我们需要声明一个函数来设置其值。
Kotlin 提供了两种其他方法,你可能觉得它们更有用。
流畅设置器
使用 data class
构造函数的方法将仅包含必填字段。所有其他字段将变为 private
,我们将为这些字段提供设置器:
data class Mail_V2(
val to: List<String>,
private var _message: String? = null,
private var _cc: List<String>? = null,
private var _title: String? = null,
private var _important: Boolean? = null
) {
fun message(message: String) = apply {
_message = message
}
// Pattern repeats for every other field
//...
}
重要提示:
在 Kotlin 中,使用下划线为 private
变量是常见的约定。这允许我们避免重复 this.message = message
和像 message = message
这样的错误。
在这个代码示例中,我们使用了 apply
函数。这是可以调用在每个 Kotlin 对象上的作用域函数系列的一部分,我们将在 第九章,惯用和反模式 中详细讨论它们。apply
函数在执行代码块后返回对象的引用。因此,它是上一个示例中设置器函数的简短版本:
fun message(message: String): MailBuilder {
this.message = message
return this
}
这为我们提供了与上一个示例相同的 API:
val mailV2 = Mail_V2(listOf("manager@company.com")).message("Ping")
然而,我们可能根本不需要设置器。相反,我们可以使用之前讨论过的 apply()
函数在对象本身上。这是 Kotlin 中每个对象都有的扩展函数之一。这种方法仅当所有可选字段都是 *变量*
而不是 *值*
时才有效。
然后,我们可以这样创建我们的电子邮件:
val mail = Mail_V2("hello@mail.com").apply {
message = "Something"
title = "Apply"
}
这是一个很好的方法,它需要更少的代码来实现。然而,这种方法也有一些缺点:
-
我们不得不将所有可选参数变为可变的。应始终优先考虑不可变字段,因为它们是线程安全的,并且更容易推理。
-
我们的所有可选参数也都是可空的。Kotlin 是一个空安全语言,所以每次我们访问它们时,我们首先必须检查它们的值是否已设置。
-
这种语法非常冗长。对于每个字段,我们需要一次又一次地重复相同的模式。
现在,让我们讨论这个问题的最后一种方法。
默认参数
在 Kotlin 中,我们可以为构造函数和函数参数指定默认值:
data class Mail_V3(
val to: List<String>,
val cc: List<String> = listOf(),
val title: String = "",
val message: String = "",
val important: Boolean = false
)
默认参数使用类型后面的 =
运算符设置。这意味着尽管我们的构造函数仍然有所有参数,但我们不需要提供它们。
所以,如果你想创建一个没有正文的电子邮件,你可以这样做:
val mail = Mail_V3(listOf("manager@company.com"), listOf(), "Ping")
然而,请注意,我们必须通过提供一个空列表来指定我们不想在 CC 字段中包含任何人,这有点不方便。
如果我们只想发送一个标记为重要的电子邮件怎么办?
不需要使用流畅设置器指定顺序非常方便。Kotlin 有 *命名参数*
来实现这一点:
val mail = Mail_V3(title = "Hello", message = "There", to = listOf("my@dear.cat"))
将默认参数与命名参数结合使用使得在 Kotlin 中创建复杂对象变得相当容易。因此,在 Kotlin 中,你几乎根本不需要 Builder 设计模式。
在实际应用中,你经常会看到使用 Builder 设计模式来构建服务器的实例。服务器将接受可选的主机、可选的端口等,然后在所有参数都设置好之后,你会调用一个监听方法来启动它。
原型
原型设计模式全部关于定制和创建相似但略有不同的对象。为了更好地理解它,让我们来看一个例子。
想象我们有一个管理系统,用于管理用户及其权限。表示用户的 data class
可能看起来像这样:
data class User(
val name: String,
val role: Role,
val permissions: Set<String>,
) {
fun hasPermission(permission: String) = permission in permissions
}
每个用户都必须有一个角色,每个角色都有一组权限。
我们将把角色描述为一个 enum
类:
enum class Role {
ADMIN,
SUPER_ADMIN,
REGULAR_USER
}
enum
类是一种表示常量集合的方式。这比将角色表示为字符串更方便,例如,我们在编译时检查此类对象是否存在。
当我们创建一个新的 用户 时,我们将为他们分配与具有相同 角色 的另一个用户相似的权限:
// In real application this would be a database of users
val allUsers = mutableListOf<User>()
fun createUser(name: String, role: Role) {
for (u in allUsers) {
if (u.role == role) {
allUsers += User(name, role, u.permissions)
return
}
}
// Handle case that no other user with such a role exists
}
让我们假设现在我们需要向 User
类添加一个新字段,我们将它命名为 tasks
:
data class User(
val name: String,
val role: Role,
val permissions: Set<String>,
val tasks: List<String>,
) {
...
}
我们的 createUser
函数将停止编译。我们将不得不通过将新添加字段的值复制到我们类的新的实例中来更改它:
allUsers += User(name, role, u.permissions, u.tasks)
每次更改 User
类时,都必须重复这项工作。
然而,还有一个更大的问题:如果引入了新的需求,使得 permissions
属性,例如,变为 private
,会发生什么?
data class User(
val name: String,
val role: Role,
private val permissions: Set<String>,
val tasks: List<String>,
) {
...
}
我们的代码将再次停止编译,我们又将不得不再次更改它。代码更改的这种持续需求是我们需要另一种方法来解决这个问题的一个明显迹象。
从原型开始
原型的整个想法是能够轻松地克隆一个对象。至少有两个原因你可能想要这样做:
-
当创建对象非常昂贵时,例如需要从数据库中获取它时,这很有帮助。
-
如果你需要创建相似但略有不同的对象,并且不想反复重复相似的部分,这会很有帮助。
重要提示:
使用原型设计模式还有更多高级的理由。例如,JavaScript 使用原型来实现类似于类的继承行为,而不需要类。
幸运的是,Kotlin 修复了 Java clone()
方法的某些缺陷。数据类有一个 copy()
方法,它接受一个现有的 data class
,并创建它的一个新副本,在此过程中可以选择更改一些属性:
// Name argument is underscored here simply not to confuse
// it with the property of the same name in the User object
fun createUser(_name: String, role: Role) {
for (u in allUsers) {
if (u.role == role) {
allUsers += u.copy(name = _name)
return
}
}
// Handle case that no other user with such a role exists
}
类似于我们之前看到的 Builder 设计模式,命名参数允许我们以任何顺序指定可以更改的属性。我们只需要指定我们想要更改的属性。所有其他数据都将为我们复制,即使是 private
属性。
data class
是一种非常常见的设计模式,它已经成为语言语法的一部分。这是一个极其有用的特性,我们将在本书中多次看到它的应用。
摘要
在本章中,我们学习了何时以及如何使用创建型设计模式。我们首先讨论了如何使用object
关键字来构造单例类,然后讨论了如果需要静态工厂方法时如何使用companion object
。我们还介绍了如何使用解构声明一次性分配多个变量。
然后,我们讨论了智能转换,以及它们如何在抽象工厂设计模式中应用以创建对象系列。接着,我们转向建造者设计模式,并了解到函数可以有默认参数值。然后我们学习了我们可以不仅通过位置,还可以通过名称来引用它们的参数。
最后,我们介绍了数据类的copy()
函数,以及它在实现原型设计模式时如何帮助我们产生略有不同的相似对象。你现在应该理解了如何使用创建型设计模式来更好地管理你的对象。
在下一章中,我们将介绍设计模式的第二组:结构型模式。这些设计模式将帮助我们创建可扩展和维护的对象层次结构。
问题
-
列出我们在本章中学到的
object
关键字的两个用途。 -
apply()
函数是用来做什么的? -
提供一个静态工厂方法的示例。
第三章:第三章:理解结构型模式
本章涵盖了Kotlin中的结构型模式。一般来说,结构型模式处理对象之间的关系。
我们将讨论如何在不产生复杂类层次结构的情况下扩展我们对象的功能。我们还将讨论如何适应未来的变化或修复过去所做的某些设计决策,以及如何减少我们程序的内存占用。
在本章中,我们将涵盖以下模式:
-
装饰器
-
适配器
-
桥接
-
组合
-
外观
-
享元
-
代理
到本章结束时,你将更好地理解如何组合你的对象,以便它们可以更容易地扩展并适应不同类型的变更。
技术要求
本章的要求与前面的章节相同——你需要IntelliJ IDEA和JDK。
你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter03
。
装饰器
在上一章中,我们讨论了原型设计模式,它允许我们创建具有略微(或不是那么略微)不同数据的类的实例。这引发了一个问题:
如果我们想创建一组所有都具有略微不同行为的类会发生什么?
好吧,由于 Kotlin 中的函数是一等公民(我们将在本章中解释这一点),你可以使用原型设计模式来实现这个目标。毕竟,创建一组具有略微不同行为的类是 JavaScript 成功做到的事情。但本章的目标是讨论对同一问题的另一种方法。毕竟,设计模式都是关于方法的。
通过实现装饰器设计模式,我们允许我们的代码用户指定他们想要添加的能力。
增强类
假设我们有一个相当简单的类,它注册了星际迷航宇宙中的所有船长及其船只:
open class StarTrekRepository {
private val starshipCaptains = mutableMapOf("USS
Enterprise" to "Jean-Luc Picard")
open fun getCaptain(starshipName: String): String {
return starshipCaptains[starshipName] ?: "Unknown"
}
open fun addCaptain(starshipName: String, captainName: String) {
starshipCaptains[starshipName] = captainName
}
}
一天,你的船长——抱歉,敏捷大师——来找你,有一个紧急要求。从现在起,每次有人搜索船长时,我们必须也将此记录到控制台。然而,这个简单任务有一个陷阱:你不能直接修改StarTrekRepository
类。这个类还有其他消费者,他们不需要这个记录行为。
但在我们深入探讨这个问题之前,让我们讨论一下我们可以在我们的类中观察到的某个特性——那就是在getCaptain
函数中可以看到的一个奇怪的运算符。
爱丽丝运算符
在第一章 Kotlin 入门中,我们了解到 Kotlin 不仅强类型,而且还是一种空安全语言。
如果,像我们的例子一样,某个键在映射中可能没有存储值会发生什么?
如果我们在处理一个映射,一个选项是使用 Kotlin 提供的 getOrDefault
方法。在这种情况下,这可能是一个可行的选项,但在你可能需要处理空值的情况下,它将不起作用。
另一个选项是使用 ?:
操作符。如果你想知道这个操作符名字的由来,它确实在一定程度上与猫王艾维斯·普雷斯利的发型相似:
![图 3.1 – 如果我们将 Elvis 操作符顺时针旋转 90 度,它看起来有点像庞克发型]
![图 3.1 – 如果我们将 Elvis 操作符顺时针旋转 90 度,它看起来有点像庞克发型]
Elvis 操作符的目标是在我们收到空值时提供一个默认值。再次看看 getCaptain
函数,看看这是如何实现的。该函数的 去糖化 形式如下:
return if (starshipCaptains[starshipName] == null)
"Unknown" else starshipCaptains[starshipName]
因此,你可以看到这个操作符为我们节省了很多打字。
继承问题
让我们回到手头的任务。由于我们的类及其方法被声明为公开的,我们可以扩展这个类并覆盖我们需要的函数:
class LoggingGetCaptainStarTrekRepository :
StarTrekRepository() {
override fun getCaptain(starshipName: String): String {
println("Getting captain for $starshipName")
return super.getCaptain(starshipName)
}
}
这相当简单!尽管那个类的名字变得越来越长。
注意我们是如何通过使用 super
关键字将实现委托给父类的。然而,第二天,你的老板(抱歉,敏捷大师)又来要求另一个功能。在添加船长时,我们需要检查他们的名字长度不超过 15 个字符。这可能对一些克林贡人来说是个问题,但你决定无论如何都要实现它。顺便说一下,这个功能不应与之前开发的日志记录功能相关。有时我们只想记录日志,有时我们只想进行验证。所以,我们的新类将如下所示:
class ValidatingAddCaptainStarTrekRepository :
StarTrekRepository() {
override fun addCaptain(starshipName: String,
captainName: String) {
if (captainName.length > 15) {
throw RuntimeException("$captainName is longer than 20 characters!")
}
super.addCaptain(starshipName, captainName)
}
}
另一个任务已完成。
然而,第二天,又出现了另一个需求:在某些情况下,我们需要 StarTrekRepository
具有日志记录功能并执行验证。我想我们现在得把它命名为 LoggingGetCaptainValidatingAddCaptainStarTrekRepository
。
这种问题出奇地常见,并且它们是设计模式可能在这里帮助我们的一个明确迹象。
装饰器设计模式的目的是动态地为我们的对象添加新行为。在我们的例子中,日志记录 和 验证 是我们有时希望应用到对象上,有时又不想应用的行为。
我们首先将 StarTrekRepository
转换为一个接口:
interface StarTrekRepository {
fun getCaptain(starshipName: String): String
fun addCaptain(starshipName: String, captainName: String)
}
然后,我们将使用之前的逻辑实现该接口:
class DefaultStarTrekRepository : StarTrekRepository {
private val starshipCaptains = mutableMapOf("USS Enter prise" to "Jean-Luc Picard")
override fun getCaptain(starshipName: String): String {
return starshipCaptains[starshipName] ?: "Unknown"
}
override fun addCaptain(starshipName: String, captain Name: String) {
starshipCaptains[starshipName] = captainName
}
}
接下来,我们不会扩展我们的具体实现,而是实现接口并使用一个新的关键字 by
:
class LoggingGetCaptain(private val repository:
StarTrekRepository): StarTrekRepository by repository {
override fun getCaptain(starshipName: String): String {
println("Getting captain for $starshipName")
return repository.getCaptain(starshipName)
}
}
by
关键字将接口的实现委托给另一个对象。这就是为什么 LoggingGetCaptain
类不需要实现接口中声明的任何函数。它们都由实例包装的另一个对象默认实现。
在这种情况下,最难理解的部分是签名。我们需要从装饰器设计模式中得到如下内容:
-
我们需要能够接收我们装饰的对象。
-
我们需要能够保留对象的引用。
-
当我们的装饰器被调用时,我们需要能够决定是否要改变我们持有的对象的行为,或者要委托调用。
-
我们需要能够提取一个接口或者由(库)作者提供。
注意,我们不再使用super
关键字。如果我们尝试这样做,它将不会工作,因为我们现在正在实现一个类。相反,我们使用对wrapped
接口的引用。
为了确保我们理解这个模式,让我们实现我们的第二个装饰器:
class ValidatingAdd(private val repository:
StarTrekRepository): StarTrekRepository by repository {
private val maxNameLength = 15
override fun addCaptain(starshipName: String,
captainName: String) {
require (captainName.length < maxNameLength) {
"$captainName name is longer than $maxNameLength characters!"
}
repository.addCaptain(starshipName, captainName)
}
}
前面的例子和ValidatingAddCaptainStarTrekRepository
实现之间的唯一区别是我们使用require
函数而不是if
表达式。这通常更易于阅读,如果表达式为false
,它还会抛出IllegalArgumentException
。
让我们看看它是如何工作的:
val starTrekRepository = DefaultStarTrekRepository()
val withValidating = ValidatingAdd(starTrekRepository)
val withLoggingAndValidating = LoggingGetCaptain(withValidating)
withLoggingAndValidating.getCaptain("USS Enterprise")
withLoggingAndValidating.addCaptain("USS Voyager", "Kathryn Janeway")
最后一行将抛出异常:
> Kathryn Janeway name is longer than 15 characters!
正如你所看到的,这个模式允许我们组合行为,正如我们希望的那样。现在,让我们短暂地偏离一下,讨论一下 Kotlin 中的操作符重载,因为这将帮助我们进一步改进我们的设计模式。
操作符重载
让我们再次看看提取的接口。在这里,我们描述了通常与数组/映射访问和赋值相关的基本映射操作。在 Kotlin 中,我们有一些称为DefaultStarTrekRepository
的语法糖,我们可以看到在 Kotlin 中处理映射是非常直观的:
starshipCaptains[starshipName]
starshipCaptains[starshipName] = captainName
如果我们能够像使用地图一样使用我们的仓库,那将很有用:
withLoggingAndValidating["USS Enterprise"]
withLoggingAndValidating["USS Voyager"] = "Kathryn Janeway"
使用 Kotlin,我们实际上可以非常容易地实现这种行为。首先,让我们改变我们的接口:
interface StarTrekRepository {
operator fun get(starshipName: String): String
operator fun set(starshipName: String, captainName: String)
}
注意,我们在函数定义前添加了operator
关键字。让我们理解这个关键字的意义。
大多数编程语言都支持某种形式的操作符重载。让我们以Java为例,看看以下两行代码:
System.out.println(1 + 1); // Prints 2
System.out.println("1" + "1") // Prints 11
我们可以看到,+
操作符根据参数是字符串还是整数而表现出不同的行为。也就是说,它可以添加两个数字,也可以连接两个字符串。你可以想象,加操作可以在其他类型上定义。例如,使用相同的操作符连接两个列表是非常有意义的:
List.of("a") + List.of("b")
不幸的是,这段代码在 Java 中无法编译,我们对此无能为力。这是因为操作符重载是语言本身保留的功能,而不是为用户保留的。
让我们看看另一个极端,Scala编程语言。在 Scala 中,任何一组字符都可以被定义为操作符。因此,你可能会遇到以下这样的代码:
Seq("a") ==== Seq("b") // You'll have to guess what this code does
Kotlin 在这两种方法之间采取中间立场。它允许你重载某些 知名 操作,但限制了可以和不可以重载的内容。尽管这个列表有限,但它相当长,所以我们不会在这里全部列出。然而,你可以在官方 Kotlin 文档中找到它:kotlinlang.org/docs/operator-overloading.html
。
如果你使用 operator
关键字与不支持或参数设置错误的函数一起使用,你将得到一个编译错误。我们在上一个代码示例中开始使用的方括号称为索引访问运算符,与我们所定义的 get(x)
和 set(x, y)
方法相关。
装饰器设计模式的注意事项
装饰器设计模式很棒,因为它允许我们即时组合对象。使用 Kotlin 的 by
关键字使其易于实现。但仍然存在一些限制,你需要注意。
首先,你无法看到装饰器的 内部。这意味着没有办法知道它包装了哪个特定的对象:
println(withLoggingAndValidating is LoggingGetCaptain)
// This is our top level decorator, no problem here
println(withLoggingAndValidating is StarTrekRepository)
// This is the interface we implement, still no problem
println(withLoggingAndValidating is ValidatingAdd)
// We wrap this class, but compiler cannot validate it
println(withLoggingAndValidating is DefaultStarTrekRepository)
// We wrap this class, but compiler cannot validate it
虽然 withLoggingAndValidating
包含 ValidatingAdd
(并且它可能表现得像这样),但它并不是 ValidatingAdd
的一个实例!在进行类型转换和类型检查时,请记住这一点。
因此,你可能会想知道这个模式在现实世界中会用在何处。一个例子是 java.io.*
包,其中包含实现 Reader
和 Writer
接口类的类。
例如,如果你想高效地读取文件,你可以使用 BufferedReader
,它将另一个读取器作为其构造函数参数:
val reader = BufferedReader(FileReader("/some/file"))
FileReader
用于此目的,因为它实现了 Reader
接口。BufferedReader
本身也实现了。
让我们继续到我们的下一个设计模式。
适配器
适配器设计模式的主要目标是转换一个接口到另一个接口。在现实世界中,这个想法的最好例子可能是电源插头适配器或 USB 适配器。
想象一下自己在晚上很晚的时候在酒店房间里,手机只剩下 7% 的电量。你的手机充电器被遗忘在城市的另一端的办公室里。你只有一个 EU 插头充电器和 Mini USB 线。但你的手机使用 USB-C,因为你不得不升级。你在纽约,所以所有的插座当然都是 USB-A。那么,你该怎么办?哦,很简单。你在半夜找 Mini USB 到 USB-C 适配器,并希望你还记得带上 EU 到 US 插头适配器。只剩下 5% 的电量——时间正在流逝!
因此,现在我们了解了适配器在现实世界中的作用,让我们看看我们如何在代码中应用同样的原则。
让我们从接口开始。
USPlug
假设电源是 Int
。如果它有电源,其值为 1
;如果没有,则值为任何其他值:
interface USPlug {
val hasPower: Int
}
EUPlug
将电源视为 String
,其值为 TRUE
或 FALSE
:
interface EUPlug {
val hasPower: String // "TRUE" or "FALSE"
}
对于 UsbMini
,电源是一个 enum
:
interface UsbMini {
val hasPower: Power
}
enum class Power {
TRUE, FALSE
}
最后,对于 UsbTypeC
,电源是一个 Boolean
值:
interface UsbTypeC {
val hasPower: Boolean
}
我们的目标是将美国电源插座中的功率值传输到我们的手机上,这将被这个函数表示:
fun cellPhone(chargeCable: UsbTypeC) {
if (chargeCable.hasPower) {
println("I've Got The Power!")
} else {
println("No power")
}
}
让我们先声明一下在我们的代码中美国电源插座将是什么样子。它将是一个返回USPlug
的函数:
// Power outlet exposes USPlug interface
fun usPowerOutlet(): USPlug {
return object : USPlug {
override val hasPower = 1
}
}
在上一章中,我们讨论了object
关键字的不同用法。在全局范围内,它创建一个单例对象。当与类内部的companion
关键字一起使用时,它为定义static
函数提供了一个位置。相同的关键字也可以用来生成匿名类。匿名类是在即时创建的类,通常用于以临时方式实现接口。
我们的可充电器将是一个函数,它以EUPlug
作为输入并输出UsbMini
:
// Charger accepts EUPlug interface and exposes UsbMini
// interface
fun charger(plug: EUPlug): UsbMini {
return object : UsbMini {
override val hasPower=Power.valueOf(plug.hasPower)
}
}
接下来,让我们尝试组合我们的cellPhone
、charger
和usPowerOutlet
函数:
cellPhone(
// Type mismatch: inferred type is UsbMini but // UsbTypeC was expected
charger(
// Type mismatch: inferred type is USPlug but // EUPlug was expected
usPowerOutlet()
)
)
正如你所见,我们得到了两个不同的类型错误——适配器设计模式应该帮助我们解决这些问题。
适配现有代码
我们需要两种类型的适配器:一种用于我们的电源插座,另一种用于我们的 USB 端口。
在 Java 中,你通常会为此目的创建一对类。在 Kotlin 中,我们可以用扩展函数来替换这些类。我们已经在第一章,“Kotlin 入门”中简要提到了扩展函数。现在,是时候更详细地介绍它们了。
我们可以通过定义以下扩展函数来使 US 插头与 EU 插头兼容:
fun USPlug.toEUPlug(): EUPlug {
val hasPower = if (this.hasPower == 1) "TRUE" else "FALSE"
return object : EUPlug {
// Transfer power
override val hasPower = hasPower
}
}
扩展函数中的this
关键字指的是我们要扩展的对象——就像我们在这个类的定义内部实现这个方法一样。再次强调,我们使用匿名类来即时实现所需的接口。
我们可以用类似的方式在 Mini USB 和 USB-C 实例之间创建一个 USB 适配器:
fun UsbMini.toUsbTypeC(): UsbTypeC {
val hasPower = this.hasPower == Power.TRUE
return object : UsbTypeC {
override val hasPower = hasPower
}
}
最后,我们可以通过组合所有这些适配器来重新上线:
cellPhone(
charger(
usPowerOutlet().toEUPlug()
).toUsbTypeC()
)
正如你所见,我们不必创建任何实现这些接口的新类。通过使用 Kotlin 的扩展函数,我们的代码保持简短且直接。
适配器设计模式比其他设计模式更直接,你将看到它被广泛使用。现在,让我们更详细地讨论一些其现实世界的应用。
现实世界中的适配器
你可能已经遇到了许多适配器设计模式的用法。这些通常用于在概念和实现之间进行适配。例如,让我们以 JVM 集合的概念与 JVM 流的概念为例。
我们已经讨论了listOf()
函数:
val list = listOf("a", "b", "c")
流是一个延迟的元素集合。你不能简单地将一个集合传递给接收流的函数,即使这可能是有意义的:
fun printStream(stream: Stream<String>) {
stream.forEach(e -> println(e))
}
printStream(list) // Doesn't compile
幸运的是,集合为我们提供了.stream()
适配器方法:
printStream(list.stream()) // Adapted successfully
许多其他 Kotlin 对象都有以to
为前缀的适配器方法。例如,toTypedArray()
将列表转换为数组。
使用适配器的注意事项
你有没有把 110 伏的美国电器通过适配器插到 220 伏的欧盟插座上,然后完全烧毁过?
如果你不够小心,你的代码也可能发生这种情况。以下示例使用另一个适配器,它也能编译:
val stream = Stream.generate { 42 }
stream.toList()
但是它永远不会完成,因为Stream.generate()
产生了一个无限整数列表。所以,要小心,并且明智地采用这种设计模式。
桥接
虽然适配器设计模式可以帮助你处理遗留代码,但桥接设计模式可以帮助你避免滥用继承。它的工作方式实际上非常简单。
让我们想象一下,我们想要构建一个系统来管理银河帝国的不同类型的冲锋队员。
我们从一个接口开始:
interface Trooper {
fun move(x: Long, y: Long)
fun attackRebel(x: Long, y: Long)
}
我们将为不同类型的冲锋队员创建多个实现:
class StormTrooper : Trooper {
override fun move(x: Long, y: Long) {
// Move at normal speed
}
override fun attackRebel(x: Long, y: Long) {
// Missed most of the time
}
}
class ShockTrooper : Trooper {
override fun move(x: Long, y: Long) {
// Moves slower than regular StormTrooper
}
override fun attackRebel(x: Long, y: Long) {
// Sometimes hits
}
}
它们也有更强的版本:
class RiotControlTrooper : StormTrooper() {
override fun attackRebel(x: Long, y: Long) {
// Has an electric baton, stay away!
}
}
class FlameTrooper : ShockTrooper() {
override fun attackRebel(x: Long, y: Long) {
// Uses flametrower, dangerous!
}
}
此外,还有能够比其他人跑得快的侦察兵:
class ScoutTrooper : ShockTrooper() {
override fun move(x: Long, y: Long) {
// Runs faster
}
}
这有很多类!
有一天,我们亲爱的设计师来了,要求所有的冲锋队员都应该能够喊话,每个人都会有一个不同的短语。没有多想,我们在我们的接口中添加了一个新的功能:
interface Infantry {
fun move(x: Long, y: Long)
fun attackRebel(x: Long, y: Long)
fun shout(): String
}
这样做,所有实现这个接口的类都无法编译了。我们有很多这样的类。这意味着我们需要做出很多修改。所以,我们只能硬着头皮去工作了。
我们真的会这样做吗?
我们去修改了五个不同类的实现,感到幸运的是只有五个而不是五十个。
桥接更改
桥接设计模式背后的思想是简化类层次结构,并在我们的系统中拥有更少的专用类。它还帮助我们避免在修改超类时引入对继承它的类产生微妙错误的脆弱基类问题。
首先,让我们尝试理解为什么我们有这样一个复杂的层次结构和许多类。这是因为我们有两个正交、无关的属性:武器类型和移动速度。
假设我们想要将这些属性传递给一个实现我们一直在使用的相同接口的类的构造函数:
data class StormTrooper(
private val weapon: Weapon,
private val legs: Legs
) : Trooper {
override fun move(x: Long, y: Long) {
legs.move(x, y)
}
override fun attackRebel(x: Long, y: Long) {
weapon.attack(x, y)
}
}
StormTrooper
接收的属性应该是接口,这样我们就可以稍后选择它们的实现:
typealias PointsOfDamage = Long
typealias Meters = Int
interface Weapon {
fun attack(): PointsOfDamage
}
interface Legs {
fun move(): Meters
}
注意,这些方法返回Meters
和PointsOfDamage
而不是简单地返回Long
和Int
。这个特性被称为类型别名。为了理解它是如何工作的,让我们短暂地偏离一下。
类型别名
Kotlin 允许我们为现有类型提供替代名称。这些被称为别名。
要声明一个别名,我们使用一个新的关键字:typealias
。从现在开始,我们可以使用Meters
而不是普通的Int
来从我们的move()
方法返回。这些不是新类型。Kotlin 编译器在编译时始终将PointsOfDamage
转换为Long
。使用它们提供了两个优点:
-
第一个优点是更好的语义(就像我们的情况一样)。我们可以确切地知道我们返回的值的含义。
-
第二个优点是简洁。类型别名允许我们隐藏复杂的泛型表达式。我们将在接下来的章节中进一步探讨这一点。
常量
让我们回到我们的StormTrooper
类。现在是时候为Weapon
和Legs
接口提供一些实现了。
首先,让我们定义StormTrooper
的常规伤害和速度,使用帝国单位:
const val RIFLE_DAMAGE = 3L
const val REGULAR_SPEED: Meters = 1
这些值在编译时已知,因此非常有效。
与 Java 中的static final
变量不同,它们不能放在类内部。你应该将它们放在包的顶层,或者将它们嵌套在对象内部。
重要提示:
虽然 Kotlin 有类型推断,但我们仍然可以明确指定常量的类型,甚至可以使用类型别名。那么,在你的代码中使用DEFAULT_TIMEOUT : Seconds = 60
而不是DEFAULT_TIMEOUT_SECONDS = 60
怎么样?
现在,我们可以为我们的接口提供一些实现:
class Rifle : Weapon {
override fun attack(x: Long, y: Long) = RIFLE_DAMAGE
}
class Flamethrower : Weapon {
override fun attack(x: Long, y: Long)= RIFLE_DAMAGE * 2
}
class Batton : Weapon {
override fun attack(x: Long, y: Long)= RIFLE_DAMAGE * 3
}
接下来,让我们看看我们如何移动以下内容:
class RegularLegs : Legs {
override fun move() = REGULAR_SPEED
}
class AthleticLegs : Legs {
override fun move() = REGULAR_SPEED * 2
}
最后,我们需要确保我们可以实现相同的功能,而不需要之前复杂类层次结构:
val stormTrooper = StormTrooper(Rifle(), RegularLegs())
val flameTrooper = StormTrooper(Flamethrower(), RegularLegs())
val scoutTrooper = StormTrooper(Rifle(), AthleticLegs())
现在我们有一个扁平的类层次结构,这使它更容易扩展和理解。如果我们需要更多的功能,比如我们之前提到的呼喊能力,我们就会为我们的类添加一个新的接口和一个新的构造函数参数。
在现实世界中,这种模式通常与依赖注入框架结合使用。例如,这将允许我们用一个模拟接口替换使用真实数据库的实现。这将使我们的代码更容易设置,并且测试速度更快。
组合
本章致力于介绍如何在对象之间组合对象,因此单独有一个关于组合设计模式的章节可能会显得有些奇怪。因此,这引发了一个问题:
这个设计模式难道不应该包含所有其他的设计模式吗?
就像桥接设计模式的情况一样,名称可能并不反映其真正的用途和好处。
让我们继续之前的StormTrooper
例子。帝国的尉官们很快发现,无论装备多么精良,风暴兵都无法抵挡叛军的进攻,因为他们缺乏协调。
为了提供更好的协调,帝国决定为风暴兵引入一个名为squad
的概念。一个squad
应该包含一个或多个任何类型的风暴兵,并且当接到命令时,它应该表现得就像一个单一的单元。
Squad
显然是由一组风暴兵组成的:
class Squad(val units: List<Trooper>)
让我们先添加几个:
val bobaFett = StormTrooper(Rifle(), RegularLegs())
val squad = Squad(listOf(bobaFett.copy(), bobaFett.copy(), bobaFett.copy()))
为了让我们的squad
表现得像一个单一的单元,我们将向其中添加两个名为move
和attack
的方法:
class Squad(private val units: List<Trooper>) {
fun move(x: Long, y: Long) {
for (u in units) {
u.move(x, y)
}
}
fun attack(x: Long, y: Long) {
for (u in units) {
u.attackRebel(x, y)
}
}
}
这两个函数都会将接收到的命令重复给它们包含的所有单位。起初,这个方法看起来似乎有效。然而,如果我们通过添加一个新函数来更改Trooper
接口,会发生什么呢?考虑以下代码:
interface Trooper {
fun move(x: Long, y: Long)
fun attackRebel(x: Long, y: Long)
fun retreat()
}
似乎没有出现问题,但我们的Squad
类不再执行它应该执行的操作——即表现得像一个单一的单位。现在,单一的单位有一个我们的组合类没有的方法。
为了防止未来发生这种情况,让我们看看如果我们的Squad
类实现了与包含的单位相同的接口会发生什么:
class Squad(private val units: List<StormTrooper>): Trooper { ... }
这个更改将迫使我们实现retreat
函数,并用override
关键字标记其他两个函数:
class Squad(private val units: List<StormTrooper>): Trooper {
override fun move(x: Long, y: Long) {
...
}
override fun attackRebel(x: Long, y: Long) {
...
}
override fun retreat() {
...
}
}
现在,我们将短暂地偏离主题,讨论一种替代且更方便的方法来处理这个例子——一种可以构建相同对象但结果更易于使用组合的方法。
次级构造函数
我们的代码确实实现了其目标。然而,如果我们可以直接传递我们的风暴兵,而不是像现在这样传递风暴兵的列表给构造函数,那会更好:
val squad = Squad(bobaFett.copy(), bobaFett.copy(),
bobaFett.copy())
实现这一目标的一种方法是为Squad
类添加。
到目前为止,我们一直在使用类的主构造函数。这是在类名之后声明的构造函数。但我们可以为类定义多个构造函数。我们可以在类体内部使用constructor
关键字为类定义次级构造函数:
class Squad(private val units: List<Trooper>): Trooper {
constructor(): this(listOf())
constructor(t1: Trooper): this(listOf(t1))
constructor(t1: Trooper, t2: Trooper): this(listOf(t1,
t2))
}
与 Java 不同,没有必要为每个构造函数重复类名。这也意味着,如果你决定重命名类,所需进行的更改会更少。
注意,每个次级构造函数都必须调用主构造函数。这类似于在 Java 中使用super
关键字。
vararg
关键字
显然,这不是一个好的方法,因为我们无法预测有人可能会想传递给我们多少个元素。如果你来自 Java,你可能已经考虑过Trooper... units
。
Kotlin 为我们提供了vararg
关键字来实现相同的目的。通过将次级构造函数与varargs
结合,我们得到以下代码,这非常不错:
class Squad(private val units: List<Trooper>): Trooper {
constructor(vararg units: Trooper):
this(units.toList())
...
}
现在,我们能够创建包含任意数量风暴兵的班,而无需首先将它们包装在列表中:
val squad = Squad(bobaFett.copy(), bobaFett.copy(), bobaFett.copy())
让我们尝试理解这是如何工作的。Kotlin 编译器将vararg
参数转换为相同类型的Array
:
constructor(units: Array<Trooper>) : this(units.toList())
Kotlin 中的数组有一个适配器方法,允许它们被转换为相同类型的列表。有趣的是,我们可以使用适配器设计模式来帮助我们实现组合设计模式。
嵌套组合
组合设计模式还有一个有趣的特性。之前,我们证明了我们可以创建包含多个风暴兵的班。我们还可以创建班的班:
val platoon = Squad(Squad(), Squad())
现在,向排下达命令的方式将与向班下达命令的方式完全相同。实际上,这种模式允许我们支持任意复杂度的树状结构,并对所有节点执行操作。
组合设计模式可能在我们达到下一章之前看起来有点不完整,在那里我们将发现它的伙伴:迭代器设计模式。当这两种设计模式结合使用时,它们真的会发光。如果你在完成本节后仍然不确定这个模式有什么用,在你学习了迭代器设计模式之后,再回过头来复习它。
在现实世界中,组合设计模式在View
接口中的Group
小部件中被广泛使用,以便能够代表它们执行操作。
只要层次结构中的所有对象都实现了相同的接口,无论嵌套有多深,我们都可以要求顶层对象对其下方的所有对象执行一个操作。
外观模式
将“外观”作为一个术语来指代设计模式直接来源于建筑学。也就是说,外观是建筑物的正面,通常被设计得比其他部分更有吸引力。在编程中,“外观”可以帮助隐藏实现中的丑陋细节。
外观模式设计模式本身旨在提供一种更优雅、更简单的方式来处理一组类或接口。我们之前在介绍抽象工厂设计模式时讨论了类族的概念。抽象工厂设计模式侧重于创建相关类,而外观模式设计模式侧重于创建后如何使用它们。
为了更好地理解这一点,让我们回顾一下我们用于抽象工厂设计模式的示例。为了能够从配置文件使用我们的抽象工厂启动服务器,我们可以向我们的库用户提供一组指令:
-
通过尝试使用JSON解析器解析它来检查给定的文件是否为
.json
或.yaml
。 -
如果我们收到错误,尝试使用YAML解析器解析它。
-
如果没有错误,将结果传递给抽象工厂以创建必要的对象。
虽然有帮助,但遵循这一系列指令可能需要相当多的技能和知识。开发者可能难以找到正确的解析器,或者他们可能会忽略在处理.yaml
文件等情况下从 JSON 解析器抛出的任何异常。
我们的用户目前面临什么问题?
为了加载配置,它们至少需要与三个不同的接口进行交互:
-
JSON 解析器(在第二章的抽象工厂部分中介绍,使用创建型模式)
-
YAML 解析器(在第二章的抽象工厂部分中介绍,使用创建型模式)
-
服务器工厂(在第二章的工厂方法部分中介绍,使用创建型模式)
相反,有一个单独的函数(startFromConfiguration()
)会很好,它将接受一个配置文件的路径,解析它,然后,如果在过程中没有错误,启动我们的服务器。
我们将为用户提供一个 门面,以简化与一组类的交互。实现这一目标的一种方法是为我们提供一个新的类来封装所有这些逻辑。这在大多数语言中是一种常见的策略。
然而,在 Kotlin 中,我们有一个更好的选择,这个选择使用了一种我们在本章讨论适配器设计模式时已经讨论过的技术。我们可以将 startFromConfiguration()
作为 Server
类的一个 扩展函数:
@ExperimentalPathApi
fun Server.startFromConfiguration(fileLocation: String) {
val path = Path(fileLocation)
val lines = path.toFile().readLines()
val configuration = try {
JsonParser().server(lines)
}
catch (e: RuntimeException) {
YamlParser().server(lines)
}
Server.withPort(configuration.port)
}
你可以看到,这个实现与适配器设计模式中的实现完全相同。唯一的区别是最终目标。在适配器设计模式的情况下,目标是使一个原本 不可用 的类 可用。记住,Kotlin 语言的一个目标就是尽可能多地 重用。对于门面设计模式,目标是使一个 复杂 的类组 易于使用。
重要提示:
根据你阅读这本书的时间,你可能不再需要 ExperimentalPathApi
注解。这个特性是在 Kotlin 1.4 中引入的,一旦它稳定,它将成为语言的一个组成部分。
我们已经讨论过,在 Kotlin 中,try
是一个 表达式,它会返回一个 值。在这里,你可以看到我们也可以从 catch
块中返回一个值,这进一步减少了对于可变变量的需求。
接下来,让我们了解这个函数的前两行发生了什么。Path
是一个相对较新的 API,它在 toFile
中是一个适配器设计模式的例子,它将路径转换为实际的文件。最后,readLine()
函数将尝试将整个文件读入内存,按行分割。考虑在使用任何可以从简化中受益的代码库时使用门面设计模式。
享元
data
类。但 data
类完全是关于状态的。
那么,数据类与享元设计模式有什么关系吗?
为了更好地理解这个设计模式,我们需要回顾二十年前。在 1994 年,当原始的 设计模式 书籍出版时,你的普通 PC 只有 4 MB 的 RAM。在这个时期,任何进程的主要目标都是节省那宝贵的 RAM,因为你可以放入其中的东西是有限的。
现在,一些 手机 有 8 GB 的 RAM。当我们讨论本节中享元设计模式的内容时,请记住这一点。
话虽如此,让我们看看我们如何更有效地使用我们的资源,因为这始终很重要!
保守
想象一下,我们正在构建一个 2D 侧滚动街机平台游戏。也就是说,你有你的游戏角色,你可以用箭头键或游戏手柄来控制它。你的角色可以左右移动,并且可以跳跃。
由于我们是一家非常小的独立公司,由一位开发者(同时也是图形设计师、产品经理和销售代表)、两只猫和一只名叫 Michael 的金丝雀组成,我们在游戏中只使用了 16 种颜色。而且我们的角色高 64 像素,宽 64 像素。
我们的角色有很多敌人,主要由肉食性的坦桑尼亚蜗牛组成:
class TanzanianSnail
由于它是一个 2D 游戏,每只蜗牛只有两个移动方向:LEFT
和RIGHT
。我们可以使用enum
类来表示这些方向:
enum class Direction {
LEFT,
RIGHT
}
为了能够在屏幕上绘制自己,每只蜗牛将保存一对图像和一个方向:
class TansanianSnail {
val directionFacing = Direction.LEFT
val sprites = listOf(File("snail-left.jpg"),
File("snail-right.jpg"))
// More information about the state of a snail comes
here
// This may include its health, for example
}
重要提示:
File
类的定义来自java.io.File
。请记住,您始终可以参考我们的 GitHub 项目以查看所需的导入。
根据方向,我们可以获取当前精灵,它显示了蜗牛面向的方向,并使用它来绘制蜗牛:
fun getCurrentSprite(): File {
return when (directionFacing) {
Direction.LEFT -> sprites[0]
Direction.RIGHT -> sprites[1]
}
}
当任何敌人移动时,它们基本上只是向左或向右滑动。
我们希望拥有多个动画精灵来再现蜗牛在每个方向上的移动。我们可以使用List
生成器为每只蜗牛敌人生成这样的精灵列表:
class TansanianSnail {
val directionFacing = Direction.LEFT
val sprites = List(8) { i ->
File(when(i) {
0 -> "snail-left.jpg"
1 -> "snail-right.jpg"
in 2..4 -> "snail-move-left-${i-1}.jpg"
else -> "snail-move-right${(4-i)}.jpg"
})
}
}
在这里,我们初始化一个包含八个元素的列表,将一个block
函数作为构造函数传递。这种方法的优点是在创建集合的同时,我们仍然可以有效地保持其不可变。
对于每个元素,我们决定获取哪个图像:
-
位置
0
和1
用于静止图像,面向左和右。 -
位置
2
到4
用于向左移动。 -
位置
5
到7
用于向右移动。
现在我们来做一些数学计算。每只蜗牛由一个 64 x 64 的图像表示。假设每种颜色恰好占用一个字节,单个图像将占用 4 KB 的 RAM。由于我们每只蜗牛有八张图像,因此每只蜗牛需要 32 KB 的 RAM,这使得我们只能在 1 MB 的内存中容纳 32 只蜗牛。
由于我们希望在屏幕上显示成千上万这种危险且极其快速的生物,并且能够在 10 年前的手机上运行我们的游戏,我们显然需要一个更好的解决方案。
节省内存
我们所有的蜗牛有什么问题?
它们实际上相当胖,是重型的蜗牛。我们希望给它们节食。每只蜗牛在其蜗牛状身体内存储了八张图像。但这些图像对每只蜗牛来说实际上是相同的。这引发了一个问题:
如果我们将这些精灵提取到一个单例对象或工厂方法中,然后只从每个实例中引用它们会怎样?
例如,考虑以下代码:
object SnailSprites {
val sprites = List(8) { i ->
java.io.File(when (i) {
0 -> "snail-left.jpg"
1 -> "snail-right.jpg"
in 2..4 -> "snail-move-left-${i-1}.jpg"
else -> "snail-move-right${(4-i)}.jpg"
})
}
}
class TansanianSnail() {
val directionFacing = Direction.LEFT
val sprites = SnailSprites.sprites
}
这样,我们的getCurrentSprite
函数可以保持不变,并且无论我们生成多少只蜗牛,我们只会消耗 256 KB 的内存。我们可以生成数百万只蜗牛,而不会影响我们程序的体积。
这正是 Flyweight 设计模式的理念。也就是说,通过在轻量级对象(在我们的例子中是蜗牛)之间共享它们来限制重量级对象(在我们的例子中是图像文件)的数量。
Flyweight 设计模式的注意事项
我们应该特别注意我们传递的数据的不可变性。例如,如果我们在一个单例中用var
代替val
,这可能会对我们的代码造成灾难。同样,对于可变数据结构也是如此。我们不希望有人删除图片、替换它,或者完全清除图片列表。
幸运的是,Kotlin 使得处理这些情况变得相当容易。只需确保在你的外部状态中始终使用值而不是变量,并记住使用不可变数据结构,这些数据结构在创建后不能被更改。
你可以辩论在这个内存丰富的时代这个模式的有用性。然而,正如我们之前所说的,工具箱中的工具并不占用多少空间,而且拥有另一个设计模式在你的口袋里可能仍然是有用的。
代理
与装饰者设计模式类似,代理设计模式扩展了一个对象的功能。然而,与总是按指示行事装饰者不同,拥有一个代理可能意味着当被要求做某事时,对象会做完全不同的事情。
当我们在第二章“使用创建型模式”中讨论创建型模式时,我们已经提到了昂贵对象的概念。例如,一个访问网络资源或需要很长时间创建的对象。
我们在Funny Cat App上为用户提供每日的搞笑猫咪图片。在我们的主页和移动应用中,每位用户都能看到许多搞笑猫咪的图片。当他们点击或触摸这些图片中的任何一张时,它就会扩展到全屏的辉煌。
通过网络获取猫咪图片非常昂贵,并且消耗大量内存,尤其是如果这些是那些晚餐后倾向于再来一份甜点的猫咪的图片。我们想要做的是,在请求时只获取一次全尺寸图片。如果它被多次请求,我们希望能够向家人或朋友展示它。简而言之,我们不想每次都要获取它。
没有办法避免加载图片一次。但当它第二次被访问时,我们希望避免再次通过网络获取,而是返回内存中缓存的那个结果。这就是代理设计模式的想法;而不是每次都通过网络获取预期的行为,我们变得有点懒惰,返回我们已经准备好的结果。
这有点像走进一家便宜的餐厅,点了一份汉堡,两分钟后就拿到了,但却是冷的。嗯,那是因为有人不喜欢洋葱,所以之前就把它退回厨房了。这是真的。
这听起来可能需要很多逻辑。但正如你可能猜到的(尤其是在遇到装饰者设计模式之后),Kotlin 可以通过减少你需要编写的样板代码来达到你的目标,从而创造奇迹:
data class CatImage(val thumbnailUrl: String,
val url: String) {
val image: ByteArray by lazy {
// Read image as bytes
URL(url).readBytes()
}
}
之前,我们在不同的上下文中看到了 by
关键字——即,当将接口的实现委托给另一个类时(如本章中“装饰器设计模式”部分所述)。
正如你可能注意到的,在这种情况下,我们使用 by
关键字将字段的初始化委托给稍后进行。我们使用一个名为 lazy
的函数,它是 image
属性之一,它将执行我们的代码块并将结果保存到 image
属性中。对该属性的后续调用将简单地返回其值。
有时,代理设计模式被分为三个子模式:
-
虚拟代理:延迟缓存结果
-
远程代理:向远程资源发出调用
-
保护或访问控制代理:拒绝未经授权的访问
你可以将我们之前的示例视为虚拟代理或虚拟和远程代理类型的组合。
懒加载委托
你可能会想知道如果两个线程同时尝试初始化图像会发生什么。默认情况下,lazy()
函数是同步的。只有一个线程会获胜,其他线程将等待图像准备就绪。
如果你不在乎两个线程执行懒加载块(例如,如果它并不那么昂贵),你可以使用 lazy(LazyThreadSafetyMode.PUBLICATION)
代替。
如果性能对你至关重要,并且你绝对确信两个线程永远不会同时执行相同的代码块,你可以使用 LazyThreadSafetyMode.NONE
,它不是线程安全的。
代理和委托是解决许多复杂问题的非常有用方法,我们将在接下来的章节中探讨这一点。
摘要
在本章中,我们学习了结构型设计模式如何帮助我们创建更灵活的代码,这些代码可以轻松适应变化,有时甚至在运行时。我们介绍了如何使用装饰器设计模式向现有类添加功能,以及我们探讨了如何通过重载运算符提供更直观的语法来执行常见操作。
我们学习了如何使用扩展方法将一个接口适配到另一个接口,并且我们也学习了如何创建匿名对象以实现接口的一次性实现。接下来,我们讨论了如何使用桥接设计模式简化类层次结构。你现在应该知道如何使用 typealias
为类型名创建快捷方式,以及如何使用 const
定义高效的常量。
接下来,我们研究了组合设计模式,并考虑了它如何帮助你设计需要以相同方式处理对象组和常规对象系统的系统。我们还学习了辅助构造函数以及当使用 vararg
关键字时,一个函数可以接收任意数量的参数。我们学习了外观设计模式如何通过提供一个简单的接口来帮助我们简化与复杂系统的交互,而享元设计模式允许我们减少应用程序的内存占用。
最后,我们已经介绍了在 Kotlin 中如何通过委托给另一个类来实现功能,实现相同的接口,并在 Proxy 设计模式中使用 by
关键字,并通过一个 lazy
委托来展示其用法。使用这些设计模式,你应该能够以更可扩展和可维护的方式构建你的系统。
在下一章中,我们将讨论经典设计模式的第三大家族:行为模式。
问题
-
Decorator 和 Proxy 设计模式的实现之间有什么区别?
-
Flyweight 设计模式的主要目标是什么?
-
Facade 和 Adapter 设计模式之间有什么区别?
第四章:第四章:熟悉行为模式
本章将基于 Kotlin 讨论行为模式。行为模式处理对象之间如何相互交互。
我们将学习一个对象如何根据情况改变其行为,对象如何在不了解彼此的情况下进行通信,以及如何轻松地遍历复杂结构。我们还将简要介绍 Kotlin 中的函数式编程概念,这将帮助我们轻松实现一些这些模式。
在本章中,我们将涵盖以下主题:
-
策略
-
迭代器
-
状态
-
命令
-
责任链
-
解释器
-
调解者
-
备忘录
-
访问者
-
模板方法
-
观察者
到本章结束时,您将能够以高度解耦和灵活的方式组织代码。
技术要求
除了前几章的要求外,您还需要一个Gradle启用的Kotlin项目,以便能够添加所需的依赖项。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter04
。
策略
策略设计模式的目标是允许对象在运行时改变其行为。
让我们回顾一下我们在第三章中设计的平台游戏,在讨论外观设计模式时,理解结构模式。
加那利迈克尔,在我们这家小型独立游戏开发公司担任游戏设计师,想出了一个绝妙的主意。如果我们给我们的英雄配备一套武器来保护我们免受那些可怕的肉食性蜗牛的伤害会怎么样呢?
武器都会朝英雄面对的方向发射弹丸(你不想离那些危险的蜗牛太近):
enum class Direction {
LEFT, RIGHT
}
所有弹丸都应该有一对坐标(记住,我们的游戏是 2D 的)和一个方向:
data class Projectile(private var x: Int,
private var y: Int,
private var direction: Direction)
如果我们只发射一种类型的弹丸,那会很简单,因为我们已经在第二章中介绍了工厂模式,使用创建型模式。
我们可以在这里做类似的事情:
class OurHero {
private var direction = Direction.LEFT
private var x: Int = 42
private var y: Int = 173
fun shoot(): Projectile {
return Projectile(x, y, direction)
}
}
但迈克尔希望我们的英雄至少拥有三种不同的武器:
-
豌豆射手:发射直线飞行的豌豆。我们的英雄一开始就有它。
-
石榴:击中敌人时会爆炸,就像手榴弹一样。
-
香蕉:当它到达屏幕末端时会像回旋镖一样返回。
迈克尔,快点,给我们点空间!你不能就只坚持使用那些都一样的普通枪吗?
水果武器库
首先,让我们讨论一下我们如何用 Java 的方式解决这个问题。
在 Java 中,我们会创建一个接口来抽象这些变化。在我们的情况下,变化的是英雄的武器:
interface Weapon {
fun shoot(x: Int,
y: Int,
direction: Direction): Projectile
}
然后,所有其他武器都将实现这个接口。由于我们不处理诸如渲染或动画对象等方面,这里不会实现特定的行为:
// Flies straight
class Peashooter : Weapon {
override fun shoot(
x: Int,
y: Int,
direction: Direction
) = Projectile(x, y, direction)
}
// Returns back after reaching end of the screen
class Banana : Weapon {
override fun shoot(
x: Int,
y: Int,
direction: Direction
) = Projectile(x, y, direction)
}
// Other similar implementations here
我们游戏中的所有武器都将实现相同的接口,并覆盖其单个方法。
我们的英雄一开始会持有一把武器的引用,Peashooter
:
private var currentWeapon: Weapon = Peashooter()
这个引用将实际射击过程委托给它:
fun shoot(): Projectile = currentWeapon.shoot(x, y, direction)
剩下的就是能够装备另一件“武器”:
fun equip(weapon: Weapon) {
currentWeapon = weapon
}
这就是策略设计模式的核心所在。它使得我们的算法——在这个例子中,是我们游戏中的武器——可以互换。
市民函数
使用 Kotlin,有更有效的方法来实现相同的功能,同时使用更少的类。这要归功于 Kotlin 中函数是第一类公民的事实。但这意味着什么呢?
首先,我们可以将函数赋值给我们的类的变量,就像任何其他标准值一样。将原始值赋给变量是有意义的:
val x = 7
你也可以像我们已经多次做的那样,将一个对象赋值给一个变量:
var myPet = Canary("Michael")
那么,为什么你不能将一个函数赋值给变量呢?
在 Kotlin 中,你可以轻松做到这一点。以下是一个例子:
val square = fun(x: Int): Long {
return (x * x).toLong()
}
让我们看看这可能如何帮助我们简化我们的设计。
首先,我们将为所有武器定义一个命名空间。我们可以使用一个对象来做到这一点。这不是强制性的,但它有助于保持一切井然有序。然后,而不是类,我们的每件武器都将成为一个函数:
object Weapons {
// Flies straight
fun peashooter(x: Int, y: Int, direction: Direction):
Projectile {
return Projectile(x, y, direction)
}
// Returns back after reaching end of the screen
fun banana(x: Int, y: Int, direction: Direction):
Projectile {
return Projectile(x, y, direction)
}
// Other similar implementations here
}
正如你所见,我们没有实现接口,而是有多个函数接收相同的参数并返回相同的对象。
最有趣的部分是我们的英雄。OurHero
类现在包含两个值,都是函数:
class OurHero {
var currentWeapon = Weapons::peashooter
val shoot = fun() {
currentWeapon(x, y, direction)
}
}
可以互换的部分是 currentWeapon
,而 shoot
现在是一个包装它的匿名函数。
为了测试我们的想法是否可行,我们可以先射击默认武器一次,然后切换到另一件武器并再次射击:
val hero = OurHero()
hero.shoot()
hero.currentWeapon = Weapons::banana
hero.shoot()
注意,这大大减少了我们需要编写的类的数量,同时保持了相同的功能。如果你的可互换算法没有状态,你可以用简单的函数来替换它。否则,引入一个接口,并让每个策略模式实现它。
这也是我们第一次使用函数引用操作符 ::
。这个操作符允许我们将函数当作变量来引用,而不是调用它。
策略是一个非常有价值的模式,当你的应用程序需要在运行时改变其行为时。一个例子是航班预订系统,允许超售;也就是说,将比座位更多的乘客放在航班上。你可能会决定你希望在航班前一天允许超售,然后禁止。你可以通过切换策略而不是在代码中添加复杂的检查来实现这一点。
现在,让我们看看另一个可以帮助我们处理复杂数据结构的模式。
迭代器
当我们在上一章讨论组合设计模式时,我们注意到这个设计模式感觉有点不完整。现在是时候让出生时分开的双胞胎重聚了。就像阿诺德·施瓦辛格和丹尼·德维托一样,他们非常不同,但彼此很好地补充。
如您从上一章所记得的,一个小队由士兵或其他小队组成。现在让我们创建一个:
val platoon = Squad(
Trooper(),
Squad(
Trooper(),
),
Trooper(),
Squad(
Trooper(),
Trooper(),
),
Trooper()
)
在这里,我们创建了一个由四个士兵组成的小队。
如果我们能够使用我们之前在第一章中学习的for-each
循环打印出这个小队中的所有士兵,那将是有用的。
让我们试着写这段代码,看看会发生什么:
for (trooper in platoon) {
println(trooper)
}
虽然这段代码无法编译,但 Kotlin 编译器为我们提供了一个有用的提示:
>For loop range must have an iterator method
在我们遵循编译器的指导并实现方法之前,让我们简要讨论一下目前我们面临的问题。
我们的小队实现了组合设计模式,它不是一个扁平的数据结构。它可以包含包含其他对象的对象——小队可以包含士兵以及其他小队。然而,在这种情况下,我们想要抽象这种复杂性,并像处理一个士兵列表一样处理它。迭代器模式正是这样做的——它将我们的复杂数据结构简化为一个简单的元素序列。元素的顺序和要忽略的元素由迭代器决定。
要在for-each
循环中使用我们的Squad
对象,我们需要实现一个特殊函数,称为iterator()
。由于这是一个特殊函数,我们需要使用operator
关键字:
operator fun iterator() = ...
我们函数返回的是一个实现了Iterator<T>
接口的匿名对象:
operator fun iterator() = object: Iterator<Trooper> {
override fun hasNext(): Boolean {
// Are there more objects to iterate over?
}
override fun next(): Trooper {
// Return next Trooper
}
}
再次强调,我们可以看到 Kotlin 中泛型的使用。Iterator<Trooper>
意味着我们的next()
方法返回的对象将始终是Trooper
类型。
为了能够迭代所有元素,我们需要实现两个方法——一个用于获取下一个元素,另一个让循环知道何时停止。让我们通过执行以下步骤来实现:
-
首先,我们需要为我们的迭代器提供一个状态。它将记住最后一个返回的元素:
operator fun iterator() = object: Iterator<Trooper> { private var i = 0 // More code here }
-
接下来,我们需要告诉它何时停止。在简单的情况下,这等于底层数据结构的大小:
override fun hasNext(): Boolean { return i < units.size }
这将会有点复杂,因为我们需要处理一些边缘情况。你可以在本书的 GitHub 仓库中找到完整的实现。
-
最后,我们需要知道要返回哪个单位。在简单的情况下,我们只需返回当前元素,并将元素计数增加一个:
override fun next() = units[i++]
在我们的情况下,这要复杂一些,因为小队可以包含其他小队。再次,你可以在本书的 GitHub 仓库中找到完整的实现。
有时候,将迭代器作为函数的参数也是有意义的:
fun <T> printAnything(iter: Iterator<T>) { while (iter.hasNext()) { println(iter.next()) } }
这个函数将遍历任何提供迭代器的对象。这也是 Kotlin 中泛型函数的一个例子。注意函数名称前的
<T>
。
作为一名普通的开发者,你不会为了谋生而发明新的数据结构,你可能不会经常实现迭代器。然而,了解它们在幕后是如何工作的仍然很重要。
下一个部分将展示如何有效地设计有限状态机。
状态
你可以把状态设计模式看作是一个有偏见的策略模式,我们在本章开头讨论过。但是,虽然策略模式通常是由客户端从外部替换的,状态可能会根据它接收到的输入而内部改变。
看看客户使用策略模式编写的这个对话:
-
客户端:这里有一件新的事情要做,从现在开始做吧。
-
策略:好的,没问题。
-
客户端:我喜欢你的地方是你从不会和我争论。
与此相比:
-
客户端:这里有一些我从你那里得到的新输入。
-
状态:哦,我不知道。也许我会开始做一些不同的事情。也许不会。
客户端还应预期状态可能会拒绝其一些输入:
-
客户端:这里有一些东西让你思考,状态。
-
状态:我不知道那是什么!你没看到我很忙吗?去找策略处理这件事吧!
那么,客户为什么还要容忍我们这种状态呢? 好吧,状态擅长控制一切。
状态的五十种色彩
我们平台游戏中的肉食性蜗牛已经受够了这种虐待。所以,玩家向它们扔豌豆和香蕉,结果只是到达另一个可怜的城堡。现在,它们要采取行动了!
让我们看看状态设计模式如何帮助我们模拟一个角色的行为变化——在我们的案例中,是平台游戏中的敌人。默认情况下,蜗牛应该静止不动以节省蜗牛的能量。但是当英雄靠近时,它应该积极地冲向他们。
如果英雄设法伤害了它,它应该撤退舔舐伤口。然后,它将重复攻击直到其中一方死亡。
首先,我们将声明蜗牛生命中可能发生的事情:
interface WhatCanHappen {
fun seeHero()
fun getHit(pointsOfDamage: Int)
fun calmAgain()
}
我们的蜗牛实现了这个接口,以便在它可能发生的事情上得到通知并相应地行动:
class Snail : WhatCanHappen {
private var healthPoints = 10
override fun seeHero() {
}
override fun getHit(pointsOfDamage: Int) {
}
override fun calmAgain() {
}
}
现在,我们可以声明一个 Mood
类,我们将用 sealed
关键字标记它:
sealed class Mood {
// Some abstract methods here, like draw(), for example
}
密封类是抽象的,不能被实例化。我们将在稍后看到使用它们的优点。但在那之前,让我们声明其他状态:
object Still : Mood()
object Aggressive : Mood()
object Retreating : Mood()
object Dead : Mood()
这些都是我们蜗牛的不同状态——抱歉,心情。
在状态设计模式中,Snail
是上下文。它持有状态。因此,我们为它声明一个成员:
class Snail : WhatCanHappen {
private var mood: Mood = Still
// As before
}
现在,让我们定义当蜗牛看到我们的英雄时 Snail
应该做什么:
override fun seeHero() {
mood = when(mood) {
is Still -> Aggressive
}
}
注意,这不能编译。这就是sealed
类发挥作用的地方。就像enum
一样,Kotlin 知道从它扩展的类数量是有限的。因此,它要求我们的when
是详尽的,并指定其中的所有不同情况。
重要提示:
如果你使用 IntelliJ 作为你的 IDE,它甚至会建议你自动添加剩余的分支
。
我们可以使用else
来描述没有状态变化:
override fun seeHero() {
mood = when(mood) {
is Still -> Aggressive
else -> mood
}
}
当蜗牛被击中时,我们需要判断它是死是活。为此,我们可以使用不带参数的when
:
override fun getHit(pointsOfDamage: Int) {
healthPoints -= pointsOfDamage
mood = when {
(healthPoints <= 0) -> Dead
mood is Aggressive -> Retreating
else -> mood
}
}
注意,我们在这里使用了is
关键字,这与 Java 中的instanceof
相同,但更简洁。
国家状况
之前的方法包含了我们上下文的大多数逻辑。你有时可能会看到不同的方法,这是有效的,因为你的上下文变得更大。
在这种方法中,Snail
会变得很瘦:
class Snail {
internal var mood: Mood = Still(this)
private var healthPoints = 10
// That's all!
}
注意,我们将mood
标记为internal
。这允许同一包中的其他类修改它。而不是让Snail
实现WhatCanHappen
,我们的Mood
将实现它:
sealed class Mood : WhatCanHappen
现在,逻辑位于我们的状态对象中:
class Still(private val snail: Snail) : Mood() {
override fun seeHero() {
snail.mood = Aggressive
}
override fun getHit(pointsOfDamage: Int) {
// Same logic from before
}
override fun calmAgain() {
// Return to Still state
}
}
注意,我们的状态对象现在在构造函数中接收对其上下文的引用。
如果你的状态代码量相对较小,请使用第一种方法。如果变体差异很大,请使用第二种方法。一个现实世界的例子,其中广泛使用此模式,是 Kotlin 的协程机制。我们将在第五章,介绍函数式编程中详细讨论。
现在,让我们看看另一个封装动作的模式。
命令
这种设计模式允许你将动作封装在对象中,稍后执行。此外,如果我们可以在稍后执行一个动作,我们也可以执行多个,甚至可以精确地安排何时执行它们。
让我们回到我们的Stormtrooper
管理系统,从第三章,理解结构型模式。这里是一个实现之前提到的attack
和move
函数的例子:
class Stormtrooper(...) {
fun attack(x: Long, y: Long) {
println("Attacking ($x, $y)")
// Actual code here
}
fun move(x: Long, y: Long) {
println("Moving to ($x, $y)")
// Actual code here
}
}
我们甚至可以使用上一章中提到的桥接设计模式来提供实际的实现。
我们现在需要解决的问题是我们的小兵只能记住一个命令。仅此而已。如果他们从(0, 0)
开始,这是屏幕的顶部,我们可以告诉他们move(20, 0)
,即向右移动 20 步,然后move(20, 20)
。在这种情况下,他们会直接移动到(20, 20)
,并可能被摧毁,因为我们必须不惜一切代价避免那些叛军:
storm trooper -> good direction -> (20, 0)
[rebel] [rebel]
[rebel] [rebel] [rebel]
[rebel] [rebel]
(5, 20) (20, 20)
如果你从本书的开头开始跟随,或者至少在第三章,理解结构型模式中加入了进来,你可能已经有一个想法我们需要做什么,因为我们已经讨论了语言中函数作为一等公民的概念。
让我们为这个草拟一个草案。我们知道我们想要持有对象列表,但我们还不知道它们应该是哪种类型。所以,我们现在将使用Any
:
class Trooper {
private val orders = mutableListOf<Any>()
fun addOrder(order: Any) {
this.orders.add(order)
}
// More code here
}
然后,我们想要遍历列表并执行我们拥有的命令:
class Trooper {
...
// This will be triggered from the outside once in a while
fun executeOrders() {
while (orders.isNotEmpty()) {
val order = orders.removeFirst()
order.execute() // Compile error for now
}
}
...
}
注意,Kotlin 为我们提供了isNotEmpty()
函数,用于集合,作为!orders.isEmpty()
检查的替代,以及一个removeFirst()
函数,它允许我们像使用队列一样使用我们的集合。
即使你不熟悉命令设计模式,你也可以猜到,如果我们想让我们的代码编译,我们可以定义一个只有一个方法execute()
的接口:
interface Command {
fun execute()
}
然后,我们可以在成员属性中同时持有列表:
private val commands = mutableListOf<Command>()
每种类型的命令,无论是移动命令还是攻击命令,都需要根据需要实现这个接口。这基本上就是 Java 实现这种模式的大多数情况下的建议。但是有没有更好的方法呢?
让我们再次看看Command
。execute()
方法不接受任何东西,也不返回任何东西,只是做些事情。这就像写以下代码:
fun command(): Unit {
// Some code here
}
这与我们之前看到的不同。我们可以进一步简化这个:
() -> Unit
而不是有一个名为Command
的接口,我们将使用typealias
:
typealias Command = ()-> Unit
这使得我们的Command
接口变得冗余,并允许我们移除它。
现在,这一行再次停止编译:
command.execute() // Unresolved reference: execute
这是因为execute()
只是我们发明的一个名字。在 Kotlin 中,函数使用invoke()
:
command.invoke() // Compiles
我们还可以省略invoke()
,这将给我们以下代码:
fun executeOrders() {
while (orders.isNotEmpty()) {
val order = orders.removeFirst()
order() // Executed the next order
}
}
这很好,但当前,我们的函数没有任何参数。如果我们的函数接收参数会发生什么?
一个选择是改变我们的Command
签名,以便我们接收两个参数:
(x: Int, y: Int)-> Unit
但是,如果某些命令没有接收任何参数,或者只接收一个,或者接收超过两个参数怎么办? 我们还需要记住在每一步传递给invoke()
的内容。
一个更好的方法是有一个函数生成器。这是一个返回另一个函数的函数。如果你曾经使用过 JavaScript 语言,那么你会知道使用闭包来限制作用域和记住东西是一种常见的做法。我们在这里也会这样做:
val moveGenerator = fun(trooper: Trooper,
x: Int,
y: Int): Command {
return fun() {
trooper.move(x, y)
}
}
当使用适当的参数调用时,moveGenerator
将返回一个新的函数。这个函数可以在我们觉得合适的时候被调用,并且它会记住三件事:
-
要调用哪个方法
-
要使用哪些参数
-
在哪个对象上使用它
现在,我们的Trooper
可能有一个这样的方法:
fun appendMove(x: Int, y: Int) = apply {
commands.add(moveGenerator(this, x, y))
}
这为我们提供了一个很好的流畅语法:
val trooper = Trooper()
trooper.appendMove(20, 0)
.appendMove(20, 20)
.appendMove(5, 20)
.execute()
流畅语法意味着我们可以轻松地在同一对象上链式调用方法,而无需多次重复其名称。
这段代码将打印以下输出:
> Moving to (20, 0)
> Moving to (20, 20)
> Moving to (5, 20)
现在,我们可以向我们的Trooper
发出任意数量的命令,而无需了解它们是如何在内部执行的。
接收或返回另一个函数的函数被称为高阶函数。在这本书中,我们将多次探索这样的函数。
撤销命令
虽然不是直接相关,但命令设计模式的一个优点是能够撤销命令。如果我们想支持这样的功能会怎样呢?
撤销操作通常非常棘手,因为它涉及到以下之一:
-
返回到之前的状态(如果有多个客户端,这是不可能的,因为这需要大量的内存)
-
计算增量(实现起来很棘手)
-
定义相反的操作(不一定总是可能的)
在我们的情况下,与从(0,0)移动到(0, 20)的命令相反的命令将是从你现在所在的位置移动到(0,0)。这可以通过存储一对命令来实现:
private val commands = mutableListOf<Pair<Command, Command>>()
我们需要修改我们的appendMove
函数,使其每次也存储反向命令:
fun appendMove(x: Int, y: Int) = apply {
val oppositeMove = /* If it's the first command, generate move to current location. Otherwise, get the previous command */
commands.add(moveGenerator(this, x, y) to oppositeMove)
}
计算相反的移动相当复杂,因为我们没有保存我们的士兵当前的位置(这本来就是我们应该实现的)。我们还将不得不处理一些边缘情况。但这应该能给你一个这样的行为是如何实现的思路。
命令设计模式是功能已经嵌入到语言中的另一个例子。在这种情况下,它作为一个一等公民存在,这减少了你自己实现设计模式的需求。在现实世界中,当你想要排队多个动作或安排稍后执行的动作时,这个模式是实用的。
职责链
我是一个糟糕的软件架构师,我不太喜欢与人交谈。因此,当我坐在象牙塔(这是我经常去的咖啡馆的名字)时,我写了一个小的网络应用程序。如果一个开发者有问题,他们不应该直接找我,哦不!他们需要通过这个系统给我一个适当的请求,而我只有在我认为他们的请求有价值时才会回答他们。
过滤器链是网络服务器中的一个普遍概念。通常,当一个请求到达你这里时,预期以下情况是真实的:
-
它的参数已经被验证过了。
-
如果可能的话,用户已经被认证了。
-
用户角色和权限已知,并且用户有权执行操作。
所以,我最初写的代码看起来像这样:
data class Request(val email: String, val question: String)
fun handleRequest(r: Request) {
// Validate
if (r.email.isEmpty() || r.question.isEmpty()) {
return
}
// Authenticate
// Make sure that you know whos is this user
if (r.isKnownEmail()) {
return
}
// Authorize
// Requests from juniors are automatically ignored by
architects
if (r.isFromJuniorDeveloper()) {
return
}
println("I don't know. Did you check StackOverflow?")
}
它有点乱,但它是有效的。
然后,我注意到一些开发者决定他们可以一次性问我两个问题。我们必须给这个函数添加一些更多的逻辑。但是等等——毕竟,我是一个架构师。那么,有没有更好的方法来委派这个任务呢?
职责链设计模式的目标是将一个复杂的逻辑部分分解成一系列较小的步骤,其中每个步骤,或链中的链接,决定是否继续到下一个步骤或返回一个结果。
这次,我们不会学习新的 Kotlin 技巧,而是使用我们已知的那些。所以,例如,我们可以从实现一个像这样的接口开始:
interface Handler {
fun handle(request: Request): Response
}
我们从未讨论过我回应一位开发者时的样子。那是因为我保持的责任链非常长且复杂,通常情况下,他们倾向于自行解决问题。坦白说,我从未需要回答过他们中的任何一个。但让我们假设回应看起来可能像这样:
data class Response(val answer: String)
我们可以用 Java 方式 来做这件事,并在每个处理器内部实现每块逻辑:
class BasicValidationHandler(private val next: Handler) : Handler {
override fun handle(request: Request): Response {
if (request.email.isEmpty() ||
request.question.isEmpty()) {
throw IllegalArgumentException()
}
return next.handle(request)
}
}
如你所见,这里,我们正在实现一个接口,它有一个方法,我们用我们期望的行为来覆盖它。
其他过滤器看起来会非常类似于这个。我们可以以任何我们想要的顺序来组合它们:
val req = Request("developer@company.com", "Who broke my build?")
val chain = BasicValidationHandler(
KnownEmailHandler(
JuniorDeveloperFilterHandler(
AnswerHandler()
)
)
)
val res = chain.handle(req)
但这次我不会问你关于更好做事方式的修辞问题。当然,有更好的方法。我们现在处于 Kotlin 世界。我们已经在上一节中看到了如何使用各种函数。所以,让我们为这个任务定义一个函数:
typealias Handler = (request: Request) -> Response
对于仅仅接收请求并返回响应的东西,我们没有单独的类和接口。以下是我们如何通过使用一个简单的函数作为值在我们的应用程序中实现身份验证的示例:
val authentication = fun(next: Handler) =
fun(request: Request): Response {
if (!request.isKnownEmail()) {
throw IllegalArgumentException()
}
return next(request)
}
在这里,authentication
是一个接收函数并返回函数的函数。这种模式允许我们轻松地组合这些函数:
val req = Request("developer@company.com", "Why do we need Software Architects?")
val chain = basicValidation(authentication
(finalResponse()))
val res = chain(req)
println(res)
你选择使用哪种方法取决于你。例如,使用接口更加明确,如果你正在创建一个其他人可能想要扩展的库或框架,这可能更适合你。
使用函数更加简洁,如果你只是想以更可管理的方式拆分你的代码,这可能是一个更好的选择。
你可能已经在现实世界中多次看到这种方法。例如,许多网络服务器框架使用它来处理跨切面关注点,如身份验证、授权、日志记录,甚至路由请求。有时,这些被称为 过滤器 或 中间件,但最终都是相同的责任链设计模式。我们将在 第十章,使用 Ktor 的并发微服务 和 第十一章,使用 Vert.x 的响应式微服务 中更详细地讨论它,我们将看到一些最受欢迎的 Kotlin 框架是如何实现它的。
下一个设计模式将与其他所有设计模式都略有不同,并且也更为复杂。
解释器
这个设计模式可能看起来非常简单或非常复杂,这取决于你在计算机科学方面的背景知识。一些讨论经典软件设计模式的书籍甚至决定完全省略它,或者把它放在某个地方,仅供好奇的读者阅读。
这背后的原因是 解释器 设计模式处理特定语言的翻译。但我们为什么需要这个?我们不是已经有编译器来做这个了吗?
我们需要更深入
所有开发者都必须掌握多种语言或子语言。即使是普通开发者,我们也使用不止一种语言。想想那些构建你项目的工具,比如 Maven 或 Gradle。你可以将它们的配置文件和构建脚本视为具有特定语法的语言。如果你将元素顺序打乱,你的项目将无法正确构建。这是因为这样的项目有解释器来分析配置文件并对其采取行动。
其他示例包括查询语言,无论是 SQL 的某个变体还是 NoSQL 数据库的特定语言。如果你是 Android 开发者,你可能也会将 XML 布局视为此类语言。甚至 HTML 也可以被认为是定义用户界面的语言。当然,还有其他语言。
可能你已经使用过一些定义了自定义测试语言的测试框架,例如Cucumber(github.com/cucumber)。
这些示例中的每一个都可以被称为领域特定语言(DSL)。DSL 是语言中的语言,为特定领域构建。我们将在下一节讨论它们是如何工作的。
您自己的语言
在本节中,我们将定义一个简单的SQL 领域特定语言(DSL)。我们不会定义其格式或语法;相反,我们将提供一个示例,说明它应该看起来像什么:
val sql = select("name, age") {
from("users") {
where("age > 25")
} // Closes from
} // Closes select
println(sql)
我们语言的目标是提高可读性并防止一些常见的 SQL 错误,例如拼写错误(例如使用FORM而不是FROM
)。我们将在过程中介绍编译时验证和自动完成。
上一段代码将输出以下内容:
> SELECT name, age FROM users WHERE age > 25
我们将从最简单的一部分开始——实现select
函数:
fun select(columns: String, from: SelectClause.()->Unit):
SelectClause {
return SelectClause(columns).apply(from)
}
我们可以使用单表达式符号来编写这个,但在这里我们使用更冗长的版本以提高可读性。这是一个有两个参数的函数。第一个是一个String
,很简单。第二个是一个接收无参数并返回无参数的函数。
最有趣的部分是我们指定了 lambda 的接收者:
SelectClause.()->Unit
这是一个非常聪明的技巧,所以请务必跟上。记得我们在第一章,从 Kotlin 入门中讨论的扩展函数,并在第二章,使用创建型模式中进行了扩展。上一段代码可以转换为以下代码:
(SelectClause)->Unit
在这里,你可以看到尽管这个 lambda 看起来接收不到任何东西,但它接收一个参数:SelectClause
类型的对象。第二个技巧在于apply()
函数的使用,我们之前已经见过。
让我们看看这一行:
SelectClause(columns).apply(from)
这可以转换为以下代码片段:
val selectClause = SelectClause(columns)
from(selectClause)
return selectClause
以下是将执行的前一段代码的步骤:
-
初始化
SelectClause
,这是一个简单的对象,其构造函数接收一个参数。 -
使用
from()
函数,并传入一个SelectClause
实例作为其唯一参数。 -
返回一个
SelectClause
实例。
这段代码只有在 from()
对 SelectClause
做一些有用的事情时才有意义。
让我们再次看看我们的 DSL 示例:
select("name, age", {
this@select.from("users", {
where("age > 25")
})
})
我们现在明确指出了接收者,这意味着 from()
函数将调用 SelectClause
对象上的 from()
方法。
你可以开始猜测这个方法的样子了。它接收 String
作为其第一个参数,并接收另一个 lambda 作为第二个参数:
class SelectClause(private val columns: String) {
private lateinit var from: FromClause
fun from(
table: String,
where: FromClause.() -> Unit
): FromClause {
this.from = FromClause(table)
return this.from.apply(where)
}
override fun toString() = "SELECT $columns $from"
}
这个例子可以缩短,但那样我们就需要在 apply()
中使用 apply()
,这在这个阶段可能会让人感到困惑。
这是我们第一次看到 lateinit
关键字。记住,Kotlin 编译器对空安全非常严格。如果我们省略 lateinit
,它将要求我们用默认值初始化变量。但由于我们将在稍后才知道这一点,我们是在请求编译器稍微放松一下。
重要提示:
注意,如果我们没有履行承诺并忘记初始化变量,当我们第一次访问它时,我们会得到 UninitializedPropertyAccessException
。
这个关键字相当危险,所以请谨慎使用。
让我们回到我们之前的代码;我们只是做了以下操作:
-
创建一个
FromClause
的实例。 -
将
FromClause
存储为SelectClause
的成员。 -
将
FromClause
的实例传递给where
lambda。 -
返回一个
FromClause
的实例。
希望你现在开始理解其精髓:
select("name, age", {
this@select.from("users", {
this@from.where("age > 25")
})
})
这是什么意思? 在理解了 from()
方法之后,这应该会简单得多。FromClause
必须有一个名为 where()
的方法,它接收一个 String
类型的参数:
class FromClause(private val table: String) {
private lateinit var where: WhereClause
fun where(conditions: String) = this.apply {
where = WhereClause(conditions)
}
override fun toString() = "FROM $table $where"
}
注意,我们已经履行了承诺,这次缩短了方法。
我们使用接收到的字符串初始化了一个 WhereClause
的实例,并返回了它——就这么简单:
class WhereClause(private val conditions: String) {
override fun toString() = "WHERE $conditions"
}
WhereClause
只打印 WHERE
和它接收到的条件:
class FromClause(private val table: String) {
// More code here...
override fun toString() = "FROM $table $where"
}
FromClause
打印 FROM
以及它接收到的表名和 WhereClause
打印的内容:
class SelectClause(private val columns: String) {
// More code here...
override fun toString() = "SELECT $columns $from"
}
SelectClause
打印 SELECT
、它得到的列以及 FromClause
打印的内容。
休息一下
Kotlin 提供了创建可读性和类型安全的 DSL 的美好功能。但解释器设计模式是工具箱中最难的一个。如果你一开始没有理解,请花些时间调试之前的代码。理解每一步中的 this
表达式,以及我们调用对象函数和对象方法的时候。
调用后缀
我们在这个部分的最后才提到了 Kotlin DSL 的最后一个概念,以免让你感到困惑。
让我们再次看看我们的 DSL:
val sql = select("name, age") {
from("users") {
where("age > 25")
} // Closes from
} // Closes select
注意,尽管 select
函数接收两个参数——一个字符串和一个 lambda ——但 lambda 是写在圆括号外面的,而不是里面。
这被称为 调用后缀,在 Kotlin 中是一种常见的做法。如果我们的函数接收另一个函数作为其最后一个参数,我们可以将其传递出圆括号。
这使得语法更加清晰,特别是对于像这样的 DSL。
解释器设计模式和 Kotlin 生成类型安全的构建器的能力非常有吸引力。但正如他们所说,权力越大,责任越大。所以,考虑一下你的情况是否足够复杂,以至于需要构建一种语言中的语言,或者使用 Kotlin 的基本语法是否足够。
现在,让我们回到我们正在构建的游戏中,看看我们如何可以解耦对象通信。
中介
我们游戏开发团队有一些真正的问题——这些问题与代码没有直接关系。如您所知,我们的小型独立公司只有我一个人,一只名叫迈克尔的金丝雀,他担任产品经理,以及两名猫设计师,他们大部分时间都在睡觉,但偶尔会制作一些不错的原型。我们完全没有质量保证(QA)。这可能也是我们的游戏总是崩溃的原因之一。
最近,迈克尔向我介绍了一只名叫肯尼的鹦鹉,他恰好是质量保证(QA)人员:
interface QA {
fun doesMyCodeWork(): Boolean
}
interface Parrot {
fun isEating(): Boolean
fun isSleeping(): Boolean
}
object Kenny : QA, Parrot {
// Implements interface methods based on parrot // schedule
}
Kenny
是一个简单的对象,实现了两个接口:QA
,用于进行质量保证工作,以及Parrot
,因为它是一只鹦鹉。
鹦鹉质量保证人员非常积极。他们随时准备测试我游戏的最新版本。但他们不喜欢在睡觉或吃饭时被打扰:
object Me
object MyCompany {
val cto = Me
val qa = Kenny
fun taskCompleted() {
if (!qa.isEating() && !qa.isSleeping()) {
println(qa.doesMyCodeWork())
}
}
}
如果肯尼有任何问题,我给了他我的直拨电话号码:
object Kenny : ... {
val developer = Me
}
肯尼是一只勤奋的鹦鹉。但我们有如此多的错误,我们不得不雇佣第二只鹦鹉质量保证人员,布拉德。如果肯尼有空,我会把工作交给他,因为他更熟悉我们的项目。但如果他忙,我会检查布拉德是否有空,然后把任务交给他:
class MyCompany {
...
val qa2 = Brad
fun taskCompleted() {
...
else if (!qa2.isEating() && !qa2.isSleeping()) {
println(qa2.doesMyCodeWork())
}
}
}
布拉德作为初级员工,通常会先向肯尼确认。而且肯尼也把我的电话号码给了他:
object Brad : QA, Parrot {
val senior = Kenny
val developer = Me
...
}
然后,布拉德向我介绍了乔治。乔治是一只猫头鹰,所以他的睡眠时间与肯尼和布拉德不同。这意味着他可以在晚上检查我的代码。
乔治会与肯尼和我一起检查一切:
object George : QA, Owl {
val developer = Me
val mate = Kenny
...
}
问题在于乔治是一个狂热的足球迷。所以在给他打电话之前,我们需要检查他是否在看比赛:
class MyCompany {
...
val qa3 = George
fun taskCompleted() {
...
else if (!qa3.isWatchingFootball()) {
println(qa3.doesMyCodeWork())
}
}
}
肯尼出于习惯,也会向乔治确认,因为乔治是一只知识渊博的猫头鹰:
object Kenny : QA, Parrot {
val peer = George
...
}
然后,还有桑德拉。她是一种不同类型的鸟,因为她不是 QA 的一部分,而是一名文案:
interface Copywriter {
fun areAllTextsCorrect(): Boolean
}
interface Kiwi
object Sandra : Copywriter, Kiwi {
override fun areAllTextsCorrect(): Boolean {
return ...
}
}
我尽量不去打扰她,除非有重大发布:
class MyMind {
...
val translator = Sandra
fun taskCompleted(isMajorRelease: Boolean) {
...
if (isMajorRelease) {
println(translator.areAllTranslationsCorrect())
}
}
}
我在这里有几个问题:
-
第一,我试图记住所有那些名字时,我的大脑几乎要爆炸了。你们的可能也是。
-
第二点,我需要记住如何与每个人互动。我是那个在打电话给他们之前做所有检查的人。
-
第三,注意乔治如何试图与肯尼确认一切,肯尼也与乔治确认。幸运的是,到目前为止,当肯尼给他打电话时,乔治总是在看足球比赛。而肯尼在乔治需要确认某事时正在睡觉。否则,他们可能会在电话上永远卡住。
-
第四点,也是我最烦恼的事情,是肯尼计划很快就要离开去开设自己的初创公司,ParrotPi。想象一下我们现在得改多少代码!
我只想检查我的代码是否一切正常。其他人应该做所有的谈话!
中介
中介设计模式就是一个控制狂。它不喜欢一个对象直接与另一个对象交谈。有时当这种情况发生时,它会生气。不——每个人都应该只通过他来发言。这个解释是什么?它减少了对象之间的耦合。而不是了解一些其他对象,每个人都应该只了解他们,即调解人。
我决定迈克尔应该管理所有这些流程,并作为它们的调解人:
interface Manager {
fun isAllGood(majorRelease: Boolean): Boolean
}
只有迈克尔会知道所有的其他鸟:
object Michael : Canary, ProductManager {
private val kenny = Kenny(this)
private val brad = Brad(this)
override fun isAllGood(majorRelease: Boolean): Boolean {
if (!kenny.isEating() && !kenny.isSleeping()) {
println(kenny.doesMyCodeWork())
} else if (!brad.isEating() && !brad.isSleeping()) {
println(brad.doesMyCodeWork())
}
return true
}
}
注意调解人如何封装不同对象之间的复杂交互,同时提供一个非常简单的接口。
我只会记住迈克尔,他会做剩下的:
class MyCompany(private val manager: Manager) {
fun taskCompleted(isMajorRelease: Boolean) {
println(manager.isAllGood(isMajorRelease))
}
}
我也会更改我的电话号码,并确保每个人都只得到迈克尔的:
class Brad(private val manager: Manager) : ... {
// No reference to Me here
...
}
现在,如果有人需要别人的意见,他们需要先通过迈克尔:
class Kenny(private val manager: Manager) : ... {
// No reference to George, or anyone else
...
}
正如你所见,我们通过这个模式无法学到关于 Kotlin 的新知识。
调解人类型
中介模式有两种类型。我们将它们称为严格和宽松。我们之前看到了严格版本。我们告诉调解人确切要做什么,并期望从它那里得到答复。
简单版本会要求我们通知调解人发生了什么,但不要期望立即得到答复。相反,如果他们需要反过来通知我们,他们应该给我们打电话。
调解人注意事项
迈克尔突然变得非常重要。每个人都只知道他,只有他才能管理他们的互动。他甚至可能成为一个全能对象,无所不知、无所不能,这是来自第九章,惯用和反模式的反模式。即使他那么重要,也要确保定义这个调解人应该做什么,以及——更重要的是——不应该做什么。
让我们继续我们的例子,并讨论另一个行为模式。
记忆
自从迈克尔成为经理以来,如果我有问题,就很难找到他。而且当我确实问他问题时,他只是扔下东西就跑去下一个会议。
昨天,我问他我们游戏中应该引入什么新武器。他告诉我应该是一个椰子加农炮,一目了然。但今天,当我向他展示这个功能时,他生气地对我尖叫!最后,他说他告诉我实现一个菠萝发射器。我很幸运他只是一个金丝雀。
如果我能记录他,那么当我们再次开会,因为他的注意力不集中而出现混乱时,我就可以简单地重放他说的所有内容。
让我们先总结一下我的问题——迈克尔的想法是他的,而且只有他的:
class Manager {
private var thoughts = mutableListOf<String>()
...
}
问题在于,由于迈克尔是一个金丝雀,他只能在他脑海中保留2
个想法:
class Manager {
...
fun think(thought: String) {
thoughts.add(thought)
if (thoughts.size > 2) {
thoughts.removeFirst()
}
}
}
如果迈克尔一次思考超过2
件事情,他会忘记他最初思考的事情:
michael.think("Need to implement Coconut Cannon")
michael.think("Should get some coffee")
michael.think("Or maybe tea?") // Forgot about Coconut Cannon
michael.think("No, actually, let's implement Pineapple Launcher") // Forgot that he wanted coffee
即使在录音中,他说的内容也很难理解(因为他没有给出任何回应)。
即使我真的记录了他,迈克尔也可以声称那是他说的,而不是他真正想说的。
备忘录设计模式通过保存对象的内部状态来解决此问题,该状态不能从外部更改(这样迈克尔就不能否认他说过的话)并且只能由对象本身使用。
在 Kotlin 中,我们可以使用一个inner
类来实现这一点:
class Manager {
...
inner class Memory(private val mindState: List<String>) {
fun restore() {
thoughts = mindState.toMutableList()
}
}
}
在这里,我们可以看到一个新关键字inner
,用于标记我们的类。如果我们省略这个关键字,类将被称为Nested
,类似于 Java 中的静态嵌套类。内部类可以访问外部类的私有字段。因此,我们的Memory
类可以轻松地改变Manager
类的内部状态。
现在,我们可以通过创建当前状态的印记来记录迈克尔此刻所说的话:
fun saveThatThought(): Memory {
return Memory(thoughts.toList())
}
在这一点上,我们可以通过一个对象来捕捉他的想法:
val michael = Manager()
michael.think("Need to implement Coconut Cannon")
michael.think("Should get some coffee")
val memento = michael.saveThatThought()
michael.think("Or maybe tea?")
michael.think("No, actually, let's implement Pineapple Launcher")
现在,我们需要添加一种回到先前思考路径的方法:
class Manager {
...
fun `what was I thinking back then?`(memory: Memory) {
memory.restore()
}
}
在这里,我们可以看到,如果我们想在函数名中使用特殊字符,如空格,我们可以这样做,但前提是函数名被反引号包围。通常这不是最好的主意,但它有其用途,我们将在第十章中讨论,使用 Ktor 的并发微服务。
剩下的就是使用memento
回到过去:
with(michael) {
think("Or maybe tea?")
think("No, actually, let's implement Pineapple Launcher")
}
michael.`what was I thinking back then?`(memento)
最后一次调用会将迈克尔的思想引回到思考椰子炮上。
注意我们如何使用with
标准函数来避免在每一行重复michael.think()
。这个函数在你需要在同一代码块中经常引用同一对象且希望避免重复时很有帮助。
我不期望你在现实世界中经常看到备忘录设计模式的实现。但它在某些需要恢复到先前状态的应用类型中可能仍然有用。
在本章的开头,我们讨论了迭代器设计模式,它帮助我们处理复杂的数据结构。接下来,我们将探讨另一个具有类似目标的设计模式。
访问者
这种设计模式通常是组合设计模式的亲密朋友,我们在第三章中讨论过,理解结构型模式。它可以从复杂的树状结构中提取数据,或者为树的每个节点添加行为,就像装饰器设计模式对一个单一对象所做的那样。
我的计划,作为一个懒惰的软件架构师,实施得相当顺利。我的请求响应系统基于责任链模式运作得很好,以至于我没有太多时间喝咖啡。但我担心一些开发者开始怀疑我有点儿骗子。
为了让他们困惑,我计划每周发送包含所有最新流行词汇文章链接的电子邮件。当然,我并不打算亲自阅读它们——我只是想从一些流行的技术网站上收集它们。
编写爬虫
让我们看看以下数据结构,它与我们在讨论迭代器设计模式时的情况非常相似:
Page(Container(Image(),
Link(),
Image()),
Table(),
Link(),
Container(Table(),
Link()),
Container(Image(),
Container(Image(),
Link())))
Page
是其他 HTML 元素的容器,但本身不是 HtmlElement
。Container
包含其他容器、表格、链接和图像。Image
在 src
属性中持有其链接。Link
则有 href
属性。
我们想要做的是从对象中提取所有 URL。
我们将首先创建一个函数,该函数将接收我们对象树的根——在这种情况下是一个 Page
容器——并返回所有可用链接的列表:
fun collectLinks(page: Page): List<String> {
// No need for intermediate variable there
return LinksCrawler().run {
page.accept(this)
this.links
}
}
使用 run
允许我们控制从块的主体返回的内容。在这种情况下,我们将返回我们收集到的 links
对象。在 run
块内部,这指的是它所操作的对象——在我们的例子中,是 LinksCrawler
。
在 Java 中,实现访问者设计模式的建议是为每个将接受我们新功能类的类添加一个方法。我们将这样做,但不是针对所有类。相反,我们只为容器元素定义此方法:
private fun Container.accept(feature: LinksCrawler) {
feature.visit(this)
}
// Or using a shorter syntax:
private fun Page.accept(feature: LinksCrawler) = feature.visit(this)
我们的功能需要内部持有集合并公开它以供读取。在 Java 中,我们只为这个成员指定 getter;不需要 setter。在 Kotlin 中,我们可以指定值而不需要后置字段:
class LinksCrawler {
private var _links = mutableListOf<String>()
val links
get()= _links.toList()
...
}
我们希望我们的数据结构是不可变的。这就是我们调用 toList()
的原因。
重要提示:
如果我们使用迭代器设计模式,遍历分支的函数可以进一步简化。
对于容器,我们只需将它们的元素传递下去:
class LinksCrawler {
...
fun visit(page: Page) {
visit(page.elements)
}
fun visit(container: Container) = visit(container.elements)
...
}
将父类指定为 sealed
帮助编译器进一步优化。我们在本章讨论状态设计模式时讨论了密封类。以下是代码:
sealed class HtmlElement
class Container(...) : HtmlElement(){
...
}
class Image(...) : HtmlElement() {
...
}
class Link(...) : HtmlElement() {
...
}
class Table : HtmlElement()
我们树状结构中最有趣的逻辑在叶子节点:
class LinksCrawler {
...
private fun visit(elements: List<HtmlElement>) {
for (e in elements) {
when (e) {
is Container -> e.accept(this)
is Link -> _links.add(e.href)
is Image -> _links.add(e.src)
else -> {}
}
}
}
}
注意,在某些情况下,我们可能不想做任何事情。这通过我们的 else
子句中的空块来指定,else -> {}
。这是 Kotlin 中 智能转换 的另一个例子。
注意,在我们检查元素是 Link
之后,我们获得了对其 href
属性的类型安全访问。这是因为编译器为我们做了转换。对于 Image
元素也是如此。
尽管我们实现了目标,但这个模式的可用性可以讨论。正如你所见,它是我们更冗长的元素之一,并在接收额外行为和访问者模式本身之间引入了紧密耦合。
模板方法
一些懒惰的人将他们的懒惰变成艺术。以我为例。以下是我的日常日程:
-
上午 8:00 – 上午 9:00:到达办公室
-
上午 9:00 – 上午 10:00:喝咖啡
-
上午 10:00 – 中午 12:00:参加一些会议或审查代码
-
中午 12:00 – 下午 1:00:外出吃午餐
-
下午 1:00 – 下午 4:00:参加一些会议或审查代码
-
下午 4:00:偷偷溜回家
我日程表中的某些部分永远不会改变,而有些则会。具体来说,我的日程表中有两个时间段,任何数量的会议都可能占据。
起初,我以为我可以使用那种设置和销毁逻辑来装饰我的变动日程,这些逻辑发生在前后。但后来有午餐,对于建筑师来说这是神圣的,而且发生在中间。
Java 在你该做什么方面非常明确。首先,你创建一个抽象类。然后,你将所有你想自己实现的方法标记为 private
:
abstract class DayRoutine {
private fun arriveToWork() {
println("Hi boss! I appear in the office
sometimes!")
}
private fun drinkCoffee() {
println("Coffee is delicious today")
}
...
private fun goToLunch() {
println("Hamburger and chips, please!")
}
...
private fun goHome() {
// Very important no one notices me, so I must keep // quiet!
println()
}
...
}
所有每天都会变化的方法都应该定义为 abstract
:
abstract class DayRoutine {
...
abstract fun doBeforeLunch()
...
abstract fun doAfterLunch()
...
}
如果你想要能够替换一个函数,同时也想提供一个默认实现,你应该将其留为 public
:
abstract class DayRoutine {
...
open fun bossHook() {
// Hope he doesn't hook me there
}
...
}
记住,在 Kotlin 中 public
是默认的可访问性。
最后,你有一个执行你的算法的方法。它默认是 final
的:
abstract class DayRoutine {
...
fun runSchedule() {
arriveToWork()
drinkCoffee()
doAfterLunch()
goToLunch()
doAfterLunch()
goHome()
}
}
现在,如果我们想有一个周一的日程表,我们可以简单地实现缺失的部分:
class MondaySchedule : DayRoutine() {
override fun doBeforeLunch() {
println("Some pointless meeting")
println("Code review. What this does?")
}
override fun doAfterLunch() {
println("Meeting with Ralf")
println("Telling jokes to other architects")
}
override fun bossHook() {
println("Hey, can I have you for a sec in my office?")
}
}
Kotlin 在这个基础上又添加了什么? 它通常做的事情 – 简洁。正如我们之前看到的,这可以通过函数来实现。
我们有三个 动态部分 – 两个强制性的活动(软件架构师必须在午餐前后做些事情)和一个可选的(老板可能在他在家之前阻止他):
fun runSchedule(beforeLunch: () -> Unit,
afterLunch: () -> Unit,
bossHook: (() -> Unit)? = fun() { println() }) {
...
}
我们将有一个函数,它接受最多三个其他函数作为其参数。前两个是强制性的,而第三个可能根本不提供或用 null
分配,以明确表示我们不希望该函数发生:
fun runSchedule(...) {
...
arriveToWork()
drinkCoffee()
beforeLunch()
goToLunch()
afterLunch()
bossHook?.let { it() }
goHome()
}
在这个函数内部,我们将有我们的算法。beforeLunch()
和 afterLunch()
的调用应该是清晰的;毕竟,这些是我们作为参数传递给我们的函数。第三个,bossHook
可能是 null
,所以我们只有在它不是 null
的情况下才执行它。我们可以使用以下结构来实现这一点:
?.let { it() }
那么其他函数呢 – 我们总是想自己实现的函数呢?
Kotlin 有一个关于 局部函数 的概念。这些是位于其他函数中的函数:
fun runSchedule(...) {
fun arriveToWork(){
println("How are you all?")
}
val drinkCoffee = { println("Did someone left the milk out?") }
fun goToLunch() = println("I would like something italian")
val goHome = fun () {
println("Finally some rest")
}
arriveToWork()
drinkCoffee()
...
goToLunch()
...
goHome()
}
这些都是声明局部函数的有效方式。无论你如何定义它们,它们的调用方式都是相同的。局部函数只能由它们被声明的父函数访问,并且是提取常见逻辑而不需要暴露的好方法。
有了这个,我们就剩下了代码结构。定义算法的结构,但让其他人决定在某些点做什么 – 这就是模板方法的核心。
我们几乎到了本章的结尾。还有最后一个设计模式要讨论,但它是最重要的之一。
观察者
这可能是本章的一个亮点,这个设计模式为我们提供了一个通往以下章节的桥梁,这些章节专门讨论函数式编程。
那么,观察者模式究竟是什么? 你有一个 发布者,也可以称为 主题,它可能有多个 订阅者,也称为 观察者。每当发布者发生有趣的事情时,所有订阅者都应该得到更新。
这可能看起来很像中介者设计模式,但有一个转折。订阅者应该能够在运行时注册或注销自己。
在经典实现中,所有订阅者/观察者都需要实现一个特定接口,以便发布者可以更新它们。但由于 Kotlin 有高阶函数,我们可以省略这部分。发布者仍然需要提供一种方法,让观察者能够订阅和取消订阅。
这可能听起来有点复杂,所以让我们看看以下示例。
动物合唱团示例
因此,一些动物决定拥有自己的合唱团。猫被选为合唱团的指挥(它根本不喜欢唱歌)。
问题在于这些动物逃离了 Java 世界,因此它们没有共同的接口。相反,每个都有不同的发声方式:
class Bat {
fun screech() {
println("Eeeeeee")
}
}
class Turkey {
fun gobble() {
println("Gob-gob")
}
}
class Dog {
fun bark() {
println("Woof")
}
fun howl() {
println("Auuuu")
}
}
幸运的是,猫不仅因为嗓音不佳而被选为指挥,而且还因为足够聪明,能够一直跟随到这一章。所以,它知道在 Kotlin 世界中,它可以接受函数:
class Cat {
fun joinChoir(whatToCall: ()->Unit) {
...
}
fun leaveChoir(whatNotToCall: ()->Unit) {
...
}
}
之前,我们学习了如何传递一个新函数作为参数,以及一个字面量函数。但是,我们如何传递成员函数的引用呢?
我们可以像在策略设计模式中做的那样来做这件事;也就是说,通过使用成员引用操作符(::
):
val catTheConductor = Cat()
val bat = Bat()
val dog = Dog()
val turkey = Turkey()
catTheConductor.joinChoir(bat::screech)
catTheConductor.joinChoir(dog::howl)
catTheConductor.joinChoir(dog::bark)
catTheConductor.joinChoir(turkey::gobble)
现在,猫需要以某种方式保存所有这些订阅者。幸运的是,我们可以将它们放在一个映射中。那这个键是什么? 这应该是函数本身:
class Cat {
private val participants = mutableMapOf<()->Unit, ()- >Unit>()
fun joinChoir(whatToCall: ()->Unit) {
participants[whatToCall] = whatToCall
}
...
}
如果所有那些()->Unit
实例让你感到头晕,请务必使用typealias
来赋予它们更多的语义意义,例如 订阅者。
现在,蝙蝠决定离开合唱团。毕竟,没有人能听到它那美妙的歌声:
class Cat {
...
fun leaveChoir(whatNotToCall: ()->Unit) {
participants.remove(whatNotToCall)
}
...
}
所有的bat
需要做的只是再次传递其订阅者函数:
catTheConductor.leaveChoir(bat::screech)
这就是为什么我们最初使用映射的原因。现在,猫可以调用所有合唱团成员,告诉他们唱歌——好吧,发声:
typealias Times = Int
class Cat {
...
fun conduct(n: Times) {
for (p in participants.values) {
for (i in 1..n) {
p()
}
}
}
}
因此,排练进行得很顺利。但猫在完成所有那些循环后非常累。它宁愿将任务委托给合唱团成员。这没问题:
class Cat {
private val participants = mutableMapOf<(Int)->Unit,
(Int)->Unit>()
fun joinChoir(whatToCall: (Int)->Unit) {
...
}
fun leaveChoir(whatNotToCall: (Int)->Unit) {
...
}
fun conduct(n: Times) {
for (p in participants.values) {
p(n)
}
}
}
我们需要稍微修改订阅者以接收一个新参数。以下是对Turkey
类的示例:
class Turkey {
fun gobble(repeat: Times) {
for (i in 1..repeat) {
println("Gob-gob")
}
}
}
这有点问题。如果猫要告诉每只动物发出什么样的声音:高音还是低音? 我们将不得不再次更改所有订阅者以及猫。
在设计发布者时,传递具有许多属性的单个数据类,而不是数据类集合或其他类型。这样,如果添加了新属性,你就不必对订阅者进行太多重构:
enum class SoundPitch {HIGH, LOW}
data class Message(val repeat: Times, val pitch: SoundPitch)
class Bat {
fun screech(message: Message) {
for (i in 1..message.repeat) {
println("${message.pitch} Eeeeeee")
}
}
}
在这里,我们使用enum
来描述不同的音调类型,并使用数据类来封装要使用的音调以及消息应该重复多少次。
确保你的消息是不可变的。否则,你可能会遇到奇怪的行为!如果你有来自同一发布者的不同消息集怎么办?我们可以使用智能转换来解决:
interface Message {
val repeat: Times
val pitch: SoundPitch
}
data class LowMessage(override val repeat: Times) : Message {
override val pitch = SoundPitch.LOW
}
data class HighMessage(override val repeat: Times) :
Message {
override val pitch = SoundPitch.HIGH
}
class Bat {
fun screech(message: Message) {
when (message) {
is HighMessage -> {
for (i in 1..message.repeat) {
println("${message.pitch} Eeeeeee")
}
}
else -> println("Can't :(")
}
}
}
观察者设计模式非常有用。其力量在于其灵活性。发布者不需要了解任何关于订阅者的信息,除了它调用的函数的签名。在现实世界中,它在反应式框架中得到了广泛的应用,我们将在第六章“线程和协程”和第十一章“使用 Vert.x 的反应式微服务”中讨论,以及在 Android 中,所有 UI 事件都实现为订阅。
摘要
这是一章很长的内容,但我们也学到了很多。我们完成了对所有经典设计模式的覆盖,包括 11 个行为模式。在 Kotlin 中,函数可以被传递到其他函数中,从函数中返回,并分配给变量。这就是高阶函数和函数作为一等公民的概念。如果你的类主要是关于行为,那么用函数替换它通常是有意义的。这个概念帮助我们实现了策略和命令设计模式。
我们了解到迭代器设计模式是语言中的另一个operator
。密封类使when
语句变得详尽,我们使用它们来实现状态设计模式。
我们还研究了解释器设计模式,并了解到带有接收者的 lambda 表达式可以使你的领域特定语言(DSL)的语法更清晰。另一个关键字lateinit
告诉编译器在执行其空安全检查时可以稍微放松一些。请谨慎使用!
最后,在讨论观察者设计模式时,我们介绍了如何使用函数引用引用现有方法。
在下一章中,我们将从面向对象编程范式及其众所周知的设计模式转移到另一个范式——函数式编程。
问题
-
中介者和观察者设计模式之间的区别是什么?
-
什么是领域特定语言(DSL)?
-
使用密封类或接口的好处是什么?
第二部分:响应式和并发模式
本节重点介绍现代设计模式的方法,例如响应式和并发设计模式,以及一般意义上的函数式编程。
我们将从函数式编程的基本原理及其在 Kotlin 中的概念嵌入介绍本节,然后我们将检查 Kotlin 中的并发原语,其中最重要的是协程。一旦我们很好地掌握了函数式编程和协程,我们就会看到如何通过结合它们,我们可以创建允许我们精细控制数据流和结构化并发代码的设计模式的并发数据结构。
本节包括以下章节:
-
第五章, 介绍函数式编程
-
第六章, 线程和协程
-
第七章, 控制数据流
-
第八章, 为并发设计
第五章:第五章:介绍函数式编程
本章将讨论函数式编程的基本原则以及它们如何融入Kotlin编程语言。
正如你将发现的,我们已经在本章中触及了一些概念,因为如果不涉及函数式编程的概念,如数据不可变性和函数作为值,那么讨论语言的优点将非常困难。但就像我们之前做的那样,我们将从不同的角度来探讨这些特性。
在本章中,我们将涵盖以下主题:
-
函数式方法背后的原因
-
不可变性
-
函数作为值
-
表达式,而非语句
-
递归
完成本章后,你将了解函数式编程的概念如何嵌入到 Kotlin 语言中,以及何时使用它们。
技术要求
对于本章,你需要安装以下内容:
-
IntelliJ IDEA 社区版 (
www.jetbrains.com/idea/download/
) -
OpenJDK 11(或更高版本)(
openjdk.java.net/install/
)
你可以在GitHub上找到本章的代码文件,地址为github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter05
。
函数式方法背后的原因
函数式编程与其他编程范式一样历史悠久,例如过程式和面向对象编程。但在过去的 15 年里,它已经取得了显著的进展。原因在于其他方面的发展停滞了:CPU速度。我们无法像过去那样大幅提高 CPU 速度,因此我们必须并行化我们的程序。而且结果证明,函数式编程范式在运行并行任务方面非常出色。
多核处理器的演变本身就是一个迷人的话题,但在这里我们只会简要介绍。工作站自 20 世纪 80 年代以来就已经拥有多个处理器,以支持并行运行来自不同用户的任务。由于这个时代的工作站体积庞大,它们不需要担心将所有内容都挤在一个芯片上。但到了 2005 年左右,消费市场出现了多处理器,这时就需要一个能够并行工作的物理单元。这就是为什么我们的 PC 或笔记本电脑中有一个多核芯片。
但这并不是我们使用函数式编程的唯一原因。这里还有更多:
-
函数式编程倾向于纯函数,而纯函数通常更容易推理和测试。
-
以函数式方式编写的代码通常比命令式代码更具声明性,处理的是什么而不是如何,这可以是一个优点。
在接下来的章节中,我们将探讨函数式编程的不同方面,从不可变性开始。
不可变性
函数式编程的一个基本概念是不可变性。这意味着从函数接收输入的那一刻起,到函数返回输出的那一刻,对象不会改变。但是它怎么能改变呢?好吧,让我们看看一个简单的例子:
fun <T> printAndClear(list: MutableList<T>) {
for (e in list) {
println(e)
list.remove(e)
}
}
printAndClear(mutableListOf("a", "b", "c"))
这段代码将首先输出a
,然后我们会收到ConcurrentModificationException
。
原因在于,for-each
循环使用了一个迭代器(我们已经在上一章中讨论过),而在循环内部修改列表会干扰其操作。然而,这引发了一个问题:
如果我们可以从一开始就保护自己免受这些运行时异常的影响,那岂不是很好?
让我们看看不可变集合如何帮助我们解决这个问题。
不可变集合
在第一章,“Kotlin 入门”,我们已经提到 Kotlin 中的集合默认是不可变的,这与许多其他语言不同。
前一个问题是由我们没有遵循单一职责原则引起的,该原则指出,一个函数应该只做一件事,并且要做好。我们的函数试图同时从数组中删除元素并打印它们。
如果我们将参数从MutableList
改为List
,我们就无法调用其上的remove()
函数,从而解决我们当前的问题。但这又提出了另一个问题:
如果我们需要一个空列表怎么办?
在这种情况下,我们的函数应该返回一个新的对象:
private fun <T> printAndClear(list: MutableList<T>):
MutableList<T> {
for (e in list) {
println(e)
}
return mutableListOf()
}
通常,在函数式编程中应避免不返回任何值的函数,因为这通常意味着它们有副作用。
然而,集合类型不可变还不够。集合的内容也应该不可变。为了更好地理解这一点,让我们看看以下简单的类:
data class Player(var score: Int)
您可以看到,这个类只有一个变量:score
。
接下来,我们将创建一个data
class
的单个实例并将其放入不可变集合中:
val scores = listOf(Player(0))
我们可以在集合中放置这个类的多个实例,但为了说明我们的观点,只需要一个实例。
接下来,让我们介绍线程的概念。
共享可变状态的问题
如果您对线程不熟悉,不要担心,我们将在第六章,“线程和协程”中详细讨论它们。现在您需要知道的是,线程允许代码并发运行。当使用并发代码和利用多个 CPU 的代码时,函数式编程非常有帮助。您可能会发现,任何不涉及并发的其他示例都可能显得相当复杂或人为。
现在,让我们创建一个包含两个线程的列表:
val threads = List(2) {
thread {
for (i in 1..1000) {
scores[0].score++
}
}
}
如您所见,每个线程使用常规的for
循环总共增加score
1000。
我们通过使用join()
等待线程完成,然后检查计数器的值:
for (t in threads) {
t.join()
}
println(scores[0].score) // Less than 2000 for sure
如果你自己运行代码,值将在2000
以下。
这是一个可变变量的经典竞态条件案例。每次运行此代码时,你得到的结果都会不同。如果你之前遇到过并发,这个原因可能对你来说很熟悉。顺便说一句,这与线程没有完成它们的工作无关。你可以在循环后添加一个打印消息来确保这一点:
thread {
for (i in 1..1000) {
scores[0].score = scores[0].score + 1
}
println("Done")
}
这也不是使用增量(++
)操作符的错。正如你所见,我们使用了长格式来增加值,但如果你尽可能多次地运行它,你仍然会得到错误的结果。
这种行为的原因是加法操作和赋值操作不是原子的。两个线程可能会覆盖彼此的加法操作,导致数值增加的次数不够。
在这里,我们使用了一个包含恰好一个元素的集合的极端例子。在现实世界中,你将要处理的集合通常包含多个元素。例如,你可能需要跟踪多个玩家的得分,甚至同时维护数千个玩家的排名系统。这会使例子变得更加复杂。
你需要记住的是以下内容:即使一个集合是不可变的,它仍然可能包含可变对象。可变对象不是线程安全的。
接下来,让我们看看元组,它们是不可变对象的一个例子。
元组
在函数式编程中,一个pair
:
val pair = "a" to 1
pair
包含两个属性,称为first
和second
,是不可变的:
pair.first = "b" // Doesn't work
pair.second = 2 // Still doesn't
我们可以使用解构声明
将pair
解构为两个单独的值:
val (key, value) = pair
println("$key => $value")
当迭代映射时,我们还会处理另一种类型的元组:Map.Entry
:
for (p in mapOf(1 to "Sunday", 2 to "Monday")) {
println("${p.key} ${p.value}")
}
这个元组已经包含了key
和value
成员,而不是first
和second
。
除了pair
之外,还有一个包含third
值的Triple
元组:
val firstEdition = Triple("Design Patterns with Kotlin", 310, 2018)
通常,data
类是元组的一个很好的实现,因为它们提供了清晰的命名。如果你看前面的例子,并不立即明显地看出310
值代表页数。
然而,正如我们在上一节中看到的,并不是每个data
类都是合适的元组。你需要确保它的所有成员都是值而不是变量。你还需要检查它是否有嵌套的不可变集合或类。
现在,让我们讨论函数式编程中的另一个重要主题:函数作为语言的第一等公民。
函数作为值
我们已经在关于设计模式的章节中介绍了 Kotlin 的一些函数式能力。策略和命令设计模式只是两个依赖于接受函数作为参数、返回函数、将函数作为值存储或将函数放入集合中的能力的例子。在本节中,我们将介绍 Kotlin 函数式编程的一些其他方面,例如函数纯度和柯里化。
了解高阶函数
如我们之前讨论的,在 Kotlin 中,一个函数可以返回另一个函数。让我们看看以下简单的函数,以深入了解这个语法:
fun generateMultiply(): (Int) -> Int {
return fun(x: Int): Int {
return x * 2
}
}
在这里,我们的 generateMultiply
函数返回另一个没有名字的函数。没有名字的函数被称为匿名函数。
我们也可以使用更短的语法重写前面的代码:
fun generateMultiply(): (Int) -> Int {
return { x: Int ->
x * 2
}
}
如果一个没有名字的函数使用短语法,它被称为lambda 函数。
接下来,让我们看看返回类型的签名:
(Int) -> Int
从这个签名中,我们知道我们返回的函数将接受一个整数作为输入并产生一个整数作为输出。
如果一个函数不接受任何参数,我们使用空圆括号来表示:
() -> Int
如果一个函数不返回任何内容,我们使用 Unit
类型来指定:
(Int) -> Unit
Kotlin 中的函数可以被分配给变量或值,稍后调用:
val multiplyFunction = generateMultiply()
...
println(multiplyFunction(3, 4))
分配给变量的函数通常被称为字面函数。
我们在第四章“熟悉行为模式”中讨论策略设计模式时应用了这一点。
也可以将函数指定为参数:
fun mathInvoker(x: Int, y: Int, mathFunction: (Int, Int) -> Int) {
println(mathFunction(x, y))
}
mathInvoker(5, 6, multiplyFunction)
如果函数是最后一个参数,它也可以以临时方式提供,在括号之外:
mathInvoker(7, 8) { x, y ->
x * y
}
这种语法也被称为尾随 lambda或调用后缀。我们在第四章“熟悉行为模式”中讨论解释器设计模式时看到了这个例子。
现在我们已经了解了函数的基本语法,让我们看看它们是如何被使用的。
标准库中的高阶函数
当使用 Kotlin 时,你将每天都会做的一件事是与集合一起工作。正如我们在第一章“开始使用 Kotlin”中简要提到的,集合支持高阶函数。
例如,在前面的章节中,为了逐个打印集合中的元素,我们使用了无聊的 for-each
循环:
val dwarfs = listOf("Dwalin", "Balin", "Kili", "Fili", "Dori", "Nori", "Ori", "Oin", "Gloin", "Bifur", "Bofur", "Bombur", "Thorin")
for (d in dwarfs) {
println(d)
}
很多人看到这个可能都会感到沮丧。但我希望你没有完全停止阅读这本书。当然,在许多编程语言中,还有另一种实现相同目标的方法:一个 forEach
函数:
dwarfs.forEach { d ->
println(d)
}
这个函数是高阶函数最基本的一个例子。让我们看看它是如何声明的:
fun <T> Iterable<T>.forEach(action: (T) -> Unit)
在这里,action
是一个接收集合中元素但不返回任何内容的函数。这个函数提供了一个讨论 Kotlin 另一个方面的机会:it
语法。
it
语法
在函数式编程中,保持函数小而简单是非常常见的。函数越简单,理解起来就越容易,也越有可能在其他地方被重用。而重用代码的目标是 Kotlin 的基本原则之一。
注意,在先前的例子中,我们没有指定d
变量的类型。我们可以使用我们之前使用过的相同冒号符号来完成此操作:
dwarfs.forEach { d: String ->
println(d)
}
然而,通常我们不需要这样做,因为编译器可以从我们使用的泛型类型中推断出这一点。毕竟,dwarfs
是List<String>
类型,所以d
也是String
类型。
当我们编写像这样简短的 lambda 表达式时,我们不需要省略的不仅仅是参数的类型。如果一个 lambda 只有一个参数,我们可以使用它的隐含名称,在这种情况下,是it
:
dwarfs.forEach {
println(it)
}
在我们需要将单个函数调用到单个参数的情况下,我们也可以使用函数引用。我们在第四章中看到了一个例子,熟悉行为模式,在讨论策略设计模式时:
dwarfs.forEach(::println)
在接下来的大多数例子中,我们将使用最简短的表示法。建议在像嵌套在一个 lambda 中的 lambda这样的情况下使用较长的语法。在这些情况下,为参数提供适当的名称比简洁更重要。
闭包
在面向对象范式中,状态始终存储在对象中。但在函数式编程中,这并不一定是这种情况。让我们以以下函数为例:
fun counter(): () -> Int {
var i = 0
return { i++ }
}
之前的例子显然是一个高阶函数,正如您可以通过其return
类型看到的那样。它返回一个不带参数的函数,该函数产生一个整数。
让我们按照我们已经学到的这种方式将其存储在一个变量中,并多次调用它:
val next = counter()
println(next())
println(next())
println(next())
如您所见,该函数能够保持状态,在这种情况下,计数器的值,即使它不是对象的一部分。
这被称为闭包。lambda 可以访问包装它的函数的所有局部变量,并且只要保持对 lambda 的引用,这些局部变量就会持续存在。
闭包的使用是函数式编程工具箱中的另一个工具,它减少了定义大量仅用一些状态包装单个函数的类的需求。
纯函数
纯函数是一个没有副作用的功能。副作用可以被认为是任何访问或改变外部状态的行为。外部状态可以是非局部变量(其中闭包中的变量仍然被认为是非局部的)或任何类型的 IO(即,读取或写入文件或使用任何类型的网络功能)。
重要提示:
对于不熟悉这个术语的人来说,IO代表输入/输出,这涵盖了任何超出我们程序范围的外部交互,例如写入文件或从网络读取。
例如,我们在闭包部分讨论的 lambda 不被认为是纯的,因为它在多次调用时对相同的输入可以返回不同的输出。
不纯函数通常很难测试和推理,因为它们返回的结果可能取决于执行顺序或我们无法控制的因素(例如网络问题)。
要记住的一件事是,记录日志或甚至打印到控制台仍然涉及 I/O,并受到相同问题的影响。
让我们看看以下简单的函数:
fun sayHello() = println("Hello")
那么,在这种情况下,你如何确保打印出 Hello 呢? 这个任务并不像看起来那么简单,因为我们需要某种方式来捕获标准输出——即我们通常看到打印内容的同一个控制台。
我们将把它与以下函数进行比较:
fun hello() = "Hello"
以下函数没有任何副作用。这使得测试它变得容易得多:
fun testHello(): Boolean {
return "Hello" == hello()
}
hello()
函数可能看起来有点没有意义,但实际上这是纯函数的一个特性。如果我们提前知道结果,它们的调用可以被它们的结果所替代。这通常被称为引用透明性。
如我们之前提到的,不是每个用 Kotlin 编写的函数都是纯函数:
fun <T> removeFirst(list: MutableList<T>): T {
return list.removeAt(0)
}
如果我们在同一个列表上两次调用该函数,它将返回不同的结果:
val list = mutableListOf(1, 2, 3)
println(removeFirst(list)) // Prints 1
println(removeFirst(list)) // Prints 2
将前面的函数与这个函数比较:
fun <T> withoutFirst(list: List<T>): T {
return ArrayList(list).removeAt(0)
}
现在,我们的函数是完全可预测的,无论我们调用多少次:
val list = mutableListOf(1, 2, 3)
println(withoutFirst(list)) // It's 1
println(withoutFirst(list)) // Still 1
如您所见,在这个例子中,我们使用了一个不可变的接口List<T>
,它通过防止我们修改输入来帮助我们。当与上一节中讨论的不可变值结合使用时,纯函数通过提供可预测的结果和算法的并行化,使得测试更容易。
利用纯函数的系统更容易推理,因为它不依赖于任何外部因素——所见即所得。
柯里化
柯里化是将接受多个参数的函数转换为一系列函数的方法,其中每个函数只接受一个参数。这听起来可能有些令人困惑,所以让我们看看一个简单的例子:
fun subtract(x: Int, y: Int): Int {
return x - y
}
println(subtract(50, 8))
这是一个接受两个参数作为输入并返回它们之间差的函数。然而,一些语言允许我们使用以下语法调用此函数:
subtract(50)(8)
这就是柯里化的样子。柯里化允许我们将具有多个参数的函数(在我们的例子中是两个)转换为一系列函数,其中每个函数只接受一个参数。
让我们看看如何在 Kotlin 中实现这一点。我们已经看到如何从一个函数中返回另一个函数:
fun subtract(x: Int): (Int) -> Int {
return fun(y: Int): Int {
return x - y
}
}
这里是前面代码的简短形式:
fun subtract(x: Int) = fun(y: Int): Int {
return x - y
}
在前面的例子中,我们使用单表达式语法返回一个匿名函数,而不需要声明return
类型或使用return
关键字。
这里是它的更简短形式:
fun subtract(x: Int) = {y: Int -> x - y}
现在,一个匿名函数被转换为一个 lambda,lambda 的return
类型也被推断出来。
虽然它本身可能不是非常有用,但它仍然是一个有趣的概念,值得掌握。如果你是一位正在寻找新工作的JavaScript开发者,确保你完全理解它,因为几乎在每次面试中都会问到它。
你可能会想要使用柯里化的一个真实世界场景是日志记录。一个log
函数通常看起来像这样:
enum class LogLevel {
ERROR, WARNING, INFO
}
fun log(level: LogLevel, message: String) = println("$level: $message")
我们可以通过将函数存储在变量中来设置日志级别:
val errorLog = fun(message: String) {
log(LogLevel.ERROR, message)
}
注意到errorLog
函数比常规的log
函数更容易使用,因为它只接受一个参数而不是两个。然而,这引发了一个问题:
如果我们不想提前创建所有可能的记录器呢?
在这种情况下,我们可以使用柯里化。这个代码的柯里化版本将看起来像这样:
fun createLogger(level: LogLevel): (String) -> Unit {
return { message: String ->
log(level, message)
}
}
现在,取决于谁使用我们的代码,他们可以创建他们想要的记录器:
val infoLogger = createLogger(LogLevel.INFO)
infoLogger("Log something")
实际上,这与我们在第二章中讨论的工厂设计模式非常相似,使用创建型模式。同样,现代语言的力量减少了我们需要实现相同行为所需的自定义类的数量。
接下来,让我们谈谈另一种强大的技术,它可以让我们免于重复进行相同的计算。
记忆化
如果我们的函数总是对相同的输入返回相同的输出,我们就可以轻松地将输入映射到输出,并在过程中缓存结果。这种技术称为记忆化。
在开发不同类型的系统或解决问题时,一个常见的任务是找到一种方法来避免多次重复相同的计算。假设我们收到多个整数列表,并且对于每个列表,我们希望打印其总和:
val input = listOf(
setOf(1, 2, 3),
setOf(3, 1, 2),
setOf(2, 3, 1),
setOf(4, 5, 6)
)
看一下输入,你可以看到前三个集合实际上是相等的——区别只在于元素的顺序,所以计算三次总和将是浪费的。
求和计算可以很容易地描述为一个纯函数:
fun sum(numbers: Set<Int>): Double {
return numbers.sumByDouble { it.toDouble() }
}
这个函数不依赖于任何外部状态,并且以任何方式都不会改变外部状态。因此,对于相同的输入,可以用它之前返回的值替换对这个函数的调用是安全的。
我们可以在可变映射中存储相同集合的前一次计算结果:
val resultsCache = mutableMapOf<Set<Int>, Double>()
为了避免创建过多的类,我们可以使用一个高阶函数,它会将结果包装在我们之前创建的缓存中:
fun summarizer(): (Set<Int>) -> Double {
val resultsCache = mutableMapOf<Set<Int>, Double>()
return { numbers: Set<Int> ->
resultsCache.computeIfAbsent(numbers, ::sum)
}
}
这里,我们使用方法引用操作符(::
)告诉computeIfAbsent
在输入尚未缓存的情况下使用sum()
方法。
注意,sum()
是一个纯函数,而summarize()
则不是。后者对于相同的输入会有不同的行为。但这正是我们想要的。
在前面的输入上运行以下代码将只调用求和函数两次:
val summarizer = summarizer()
input.forEach {
println(summarizer(it))
}
不变对象、纯函数和闭包的结合为我们提供了强大的性能优化工具。只需记住:没有什么是免费的。我们用 CPU 时间这种资源交换另一种资源,即内存。而决定每种情况下哪种资源更昂贵的是你。
使用表达式而不是语句
语句是一段不返回任何内容的代码块。相反,表达式返回一个新的值。由于语句不产生结果,它们唯一有用的方式是改变状态,无论是改变一个变量、改变数据结构还是执行某种类型的 I/O。
函数式编程试图尽可能避免改变状态。从理论上讲,我们越依赖表达式,我们的函数就越纯,从而获得函数式纯度的所有好处。
我们已经多次使用过if
表达式,因此它的一些好处应该是显而易见的:它比其他语言的if
语句更简洁,因此更不容易出错。
模式匹配
switch
/case
的强化概念。我们已经看到了when
表达式是如何被使用的,这在第一章,“Kotlin 入门”中进行了探讨,所以让我们简要讨论一下为什么这个概念对于函数式范式很重要。
你可能知道,在 Java 中,switch
只能接受一些原始类型、字符串或枚举。
考虑以下代码,这通常用来演示语言中如何实现多态性:
class Cat : Animal {
fun purr(): String {
return "Purr-purr";
}
}
class Dog : Animal {
fun bark(): String {
return "Bark-bark";
}
}
interface Animal
如果我们要决定调用哪个函数,我们需要编写类似于以下代码:
fun getSound(animal: Animal): String {
var sound: String? = null;
if (animal is Cat) {
sound = (animal as Cat).purr();
}
else if (animal is Dog) {
sound = (animal as Dog).bark();
}
if (sound == null) {
throw RuntimeException();
}
return sound;
}
这段代码试图在运行时确定getSound
类实现了哪些方法。
这个方法可以通过引入多个返回值来缩短,但在实际项目中,通常认为多个返回值是不良实践。
由于我们没有为类提供switch
语句,我们需要用if
语句来代替。
现在,让我们将前面的代码与以下 Kotlin 代码进行比较:
fun getSound(animal: Animal) = when(animal) {
is Cat -> animal.purr()
is Dog -> animal.bark()
else -> throw RuntimeException("Unknown animal")
}
由于when
是一个表达式,我们完全避免了之前声明的中间变量。此外,使用模式匹配的代码不需要任何类型检查和转换。
现在我们已经学会了如何用更函数式的when
表达式替换命令式的if
语句,让我们看看我们如何通过使用递归来替换代码中的命令式循环。
递归
递归是一个函数用新的参数调用自身。许多著名的算法,如深度优先搜索,都依赖于递归。
这里是一个使用递归计算给定列表中所有数字之和的非常低效的函数示例:
fun sumRec(i: Int, sum: Long, numbers: List<Int>): Long {
return if (i == numbers.size) {
return sum
} else {
sumRec(i+1, numbers[i] + sum, numbers)
}
}
我们经常试图避免递归,因为我们可能会收到栈溢出错误,如果我们的调用栈太深的话。你可以用一个包含一百万个数字的列表来调用这个函数,以演示这一点:
val numbers = List(1_000_000) {it}
println(sumRec(0, numbers))
// Crashed pretty soon, around 7K
然而,Kotlin 支持一种称为尾递归的优化。尾递归的一个巨大好处是它避免了可怕的栈溢出异常。如果我们的函数中只有一个递归调用,我们可以使用这个优化。
让我们使用一个新的关键字 tailrec
重写我们的递归函数,以避免这个问题:
tailrec fun sumRec(i: Int, sum: Long, numbers: List<Int>):
Long {
return if (i == numbers.size) {
return sum
} else {
sumRec(i+1, numbers[i] + sum, numbers)
}
}
现在,编译器将优化我们的调用并完全避免异常。
然而,如果你有多个递归调用,这种优化就不起作用了,例如在归并排序算法中。
让我们检查以下函数,这是归并排序算法的排序部分:
tailrec fun mergeSort(numbers: List<Int>): List<Int> {
return when {
numbers.size <= 1 -> numbers
numbers.size == 2 -> {
return if (numbers[0] < numbers[1]) {
numbers
} else {
listOf(numbers[1], numbers[0])
}
}
else -> {
val left = mergeSort(numbers.slice (0..numbers.size / 2))
val right = mergeSort(numbers.slice (numbers.size / 2 + 1 until numbers.size))
return merge(left, right)
}
}
}
注意,这里有两个递归调用而不是一个。Kotlin 编译器随后将发出以下警告:
> "A function is marked as tail-recursive but no tail calls are found"
摘要
你现在应该对函数式编程及其优势以及 Kotlin 如何处理这个主题有更好的理解。我们讨论了不可变性和纯函数的概念,以及将它们结合起来如何产生更容易测试且更容易维护的代码。
我们讨论了 Kotlin 如何支持闭包,这允许一个函数访问其包装函数的变量,并有效地在执行之间存储状态。这使我们可以使用柯里化和记忆化等技术,这些技术允许我们固定一些函数参数(通过充当默认值)并记住函数返回的值,以避免重新计算。
我们了解到 Kotlin 使用 tailrec
关键字来允许编译器优化尾递归。我们还探讨了高阶函数、表达式与语句的区别以及模式匹配。所有这些概念都使我们能够编写更容易测试且并发错误风险更低的代码。
在下一章中,我们将将这些知识应用于实践,并了解响应式编程是如何建立在函数式编程之上以创建可扩展和健壮的系统的。
问题
-
什么是高阶函数?
-
Kotlin 中的
tailrec
关键字是什么? -
什么是纯函数?
第六章:第六章:线程和协程
在上一章中,我们简要地了解了我们的应用程序如何高效地每秒处理数千个请求——为了讨论不可变性为什么很重要,我们使用两个线程引入了竞争条件问题。
在本章中,我们将更深入地探讨如何在 Kotlin 中启动新线程以及为什么协程比线程具有更好的可扩展性。我们将讨论 Kotlin 编译器如何处理协程以及协程作用域和调度器之间的关系。我们将讨论结构化并发的概念,以及它是如何帮助我们防止程序中的资源泄漏的。
本章我们将涵盖以下主题:
-
深入了解线程
-
介绍协程和挂起函数
-
启动协程
-
任务
-
协程的内部机制
-
调度器
-
结构化并发
阅读本章后,您将熟悉 Kotlin 的并发原语以及如何最佳地利用它们。
技术要求
除了前几章的要求外,您还需要一个启用了Gradle的Kotlin项目,以便能够添加所需的依赖项。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter06
.
深入了解线程
在我们深入探讨细节之前,让我们讨论一下线程可以解决哪些类型的问题。
在您的笔记本电脑中,您有一个具有多个核心的 CPU——可能是四个,甚至八个。这意味着它可以并行执行四个不同的计算,考虑到 15 年前,单核 CPU 是默认的,甚至双核也仅限于爱好者。
但即使在当时,您也不仅仅局限于一次只执行一个任务,对吧? 您可以在单核 CPU 上同时听音乐和浏览互联网。您的 CPU 是如何做到这一点的? 嗯,就像您的头脑一样。它处理任务。当您一边读书一边听朋友说话时,您的一部分时间不是在阅读,另一部分时间不是在听——也就是说,直到我们的大脑至少有两个核心。
您运行代码的服务器基本上拥有相同的 CPU。这意味着它们可以同时处理四个请求。但是,如果您每秒有 10,000 个请求怎么办? 您无法并行处理它们,因为您没有 10,000 个 CPU 核心。但是您可以尝试并发处理它们。
JVM 提供的最基本并发模型被称为线程。线程允许我们并发运行代码(但不一定是并行),这样我们就可以更好地利用多个 CPU 核心,例如。它们比进程更轻量级。一个进程可能产生数百个线程。与进程不同,线程之间共享数据很容易。但这也引入了许多问题,我们将在后面看到。
让我们先学习如何在 Java 中创建两个线程。每个线程将输出0
到100
之间的数字:
for (int t = 0; t < 2; t++) {
int finalT = t;
new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.println("Thread " + finalT + ": " + i);
}
}).start();
}
输出将类似于以下内容:
> ...
> T0: 12
> T0: 13
> T1: 60
> T0: 14
> T1: 61
> T0: 15
> T1: 16
> ...
注意,输出将在不同的执行之间有所不同,并且在任何时候都没有保证它是交错进行的。
Kotlin 中的相同代码如下所示:
repeat(2) { t ->
thread {
for (i in 1..100) {
println("T$t: $i")
}
}
}
在 Kotlin 中,由于有一个帮助我们创建新线程的函数,所以代码更简洁。注意,与 Java 不同,我们不需要调用start()
来启动线程。它默认启动。如果我们想稍后启动它,我们可以将start
参数设置为false
:
val t = thread(start = false)
...
// Later
t.start()
Java 中另一个有用的概念是守护线程。这些线程不会阻止 JVM 退出,非常适合非关键的后台任务。
在 Java 中,API 不够流畅,因此我们需要将我们的线程分配给一个变量,将其设置为守护线程,然后启动它。在 Kotlin 中,这要简单得多:
thread(isDaemon = true) {
for (i in 1..1_000_000) {
println("daemon thread says: $i")
}
}
注意,尽管我们要求这个线程打印到一百万,但它只打印了数百个。这是因为它是一个守护线程。当父线程停止时,所有守护线程也会停止。
线程安全
关于线程安全的书籍有很多,这有很好的理由。由于缺乏线程安全而引起的并发错误是最难追踪的。它们很难重现,因为你通常需要很多线程竞争相同的资源,以便实际发生竞争。因为这本书是关于 Kotlin 而不是一般的线程安全,所以我们只触及了这个话题的表面。如果你对 JVM 语言中的线程安全主题感兴趣,你应该查看 Brian Goetz 所著的《Java 并发实践》这本书。
我们将从以下示例开始,该示例创建 100,000 个线程来增加一个counter
。为了确保在检查值之前所有线程都完成了它们的工作,我们将使用CountDownLatch
:
var counter = 0
val latch = CountDownLatch(100_000)
repeat(100) {
thread {
repeat(1000) {
counter++
latch.countDown()
}
}
}
latch.await()
println("Counter $counter")
这段代码没有打印出正确的数字的原因是我们引入了数据竞争,因为++
操作不是原子的。所以,如果有更多线程尝试增加我们的计数器,那么数据竞争的机会就更多了。
与 Java 不同,Kotlin 中没有synchronized
关键字。这是因为 Kotlin 的设计者认为一种语言不应该针对特定的并发模型进行定制。相反,我们可以使用synchronized()
函数:
thread {
repeat(1000) {
synchronized(latch) {
counter++
latch.countDown()
}
}
}
现在,我们的代码打印出预期的100,000
。
如果你怀念 Java 中的同步方法,Kotlin 中有@Synchronized
注解。Java 的volatile
关键字也被@Volatile
注解所替代。下表展示了这种比较的例子:
表 6.1 – Java 和 Kotlin(同步和 volatile 方法)之间的比较
Synchronized
和Volatile
是注解而不是关键字的原因是,Kotlin 除了可以在 JVM 上编译之外,还可以在其他平台上编译。但是,synchronized
方法或volatile
变量的概念是针对 JVM 特定的。
为什么线程这么贵?
每次我们创建一个新的线程时,都需要付出代价。每个线程都需要一个新的内存栈。
如果我们通过让每个线程休眠来模拟一些工作会发生什么?
在以下代码片段中,我们将尝试创建 10,000 个线程,每个线程休眠一个相对较短的时间:
val counter = AtomicInteger()
try {
for (i in 0..10_000) {
thread {
counter.incrementAndGet()
Thread.sleep(100)
}
}
} catch (oome: OutOfMemoryError) {
println("Spawned ${counter.get()} threads before crashing")
System.exit(-42)
}
每个线程需要 1 兆字节的 RAM 来为其栈分配空间。创建如此多的线程将需要与操作系统进行大量通信,并且需要大量内存。我们通过捕获相关异常来尝试识别是否已耗尽内存。
根据你的操作系统,这可能会导致OutOfMemoryError
或整个系统变得非常缓慢。
当然,有方法可以限制同时运行的线程数量,使用Executors API。这个 API 是在Java 5中引入的,所以你应该很熟悉。
使用该 API,我们可以创建一个指定大小的新的线程池。尝试将pool
的大小设置为1
,机器上的核心数设置为100
和2000
,看看会发生什么:
val pool = Executors.newFixedThreadPool(100)
现在,我们想要提交一个新的任务。我们可以通过调用pool.submit()
来实现:
val counter = AtomicInteger(0)
val start = System.currentTimeMillis()
for (i in 1..10_000) {
pool.submit {
// Do something
counter.incrementAndGet()
// Simulate wait on IO
Thread.sleep(100)
// Do something again
counter.incrementAndGet()
}
}
在sleep
之前和之后各增加一次counter
,我们正在模拟一些业务逻辑 – 例如,准备一些 JSON 然后解析响应 – 而sleep
本身则模拟网络操作。
然后,我们需要确保池终止,并使用以下行给它20
秒的时间来完成:
pool.awaitTermination(20, TimeUnit.SECONDS)
pool.shutdown()
println("Took me ${System.currentTimeMillis() - start} millis to complete ${counter.get() / 2} tasks")
注意,这花了我们 20 秒来完成。这是因为新的任务不能开始,直到前面的任务醒来并完成它们的工作。
这正是多线程系统在不充分并发的情况下发生的情况。
在下一节中,我们将讨论协程如何尝试解决这个问题。
协程介绍
除了 Java 提供的线程模型之外,Kotlin 还有一个协程模型。协程可能被认为是轻量级的线程,我们很快就会看到它们相对于现有线程模型的优势。
首先你需要知道的是,协程不是语言的一部分。它们只是 JetBrains 提供的另一个库。因此,如果我们想使用它们,我们需要在 Gradle 配置文件中指定这一点;即build.gradle.kts
:
dependencies {
...
implementation("org.jetbrains.kotlinx:kotlinx- coroutines-core:1.5.1")
}
重要提示:
到你阅读这本书的时候,协程库的最新版本将是 1.6 或更高。
首先,我们将比较启动一个新的线程和一个新的协程。
启动协程
在 深入探讨线程 部分中,我们已经看到了如何在 Kotlin 中启动一个新的线程。现在,让我们启动一个新的协程。
我们将创建几乎与线程相同的示例。每个协程将增加某个计数器,休眠一段时间来模拟某种类型的 I/O,然后再次增加它:
val latch = CountDownLatch(10_000)
val c = AtomicInteger()
val start = System.currentTimeMillis()
for (i in 1..10_000) {
GlobalScope.launch {
c.incrementAndGet()
delay(100)
c.incrementAndGet()
latch.countDown()
}
}
latch.await(10, TimeUnit.SECONDS)
println("Executed ${c.get() / 2} coroutines in ${System.currentTimeMillis() - start}ms")
启动新协程的第一种方式是使用 launch()
函数。再次注意,这只是一个函数,而不是语言结构。
另一个有趣的地方是对 delay()
函数的调用,我们使用它来模拟一些 I/O 密集型的工作,例如从数据库或网络上获取数据。
就像 Thread.sleep()
方法一样,它使当前协程进入休眠状态。但与 Thread.sleep()
不同,其他协程可以在它安静地休眠时工作。这是因为 delay()
被标记为 suspend
关键字,我们将在 作业 部分讨论它。
如果你运行这段代码,你会看到任务在协程中大约需要 200 毫秒,而在线程中,要么需要 20 秒,要么耗尽内存。而且我们并没有对代码做太多改变。这都要归功于协程的高度并发性。它们可以在不阻塞运行它们的线程的情况下挂起。不阻塞线程是件好事,因为我们可以使用更少的操作系统线程(这些线程成本高昂)来完成更多的工作。
如果你在这个 IntelliJ IDEA 中运行这段代码,你会注意到 GlobalScope
被标记为不应该在现实世界的项目中使用,除非开发者理解其底层工作原理。否则,它可能会导致意外的泄漏。我们将在本章后面学习更好的启动协程的方法。
虽然我们已经看到协程比线程具有更高的并发性,但它们并没有什么神奇之处。现在,让我们了解另一种启动协程的方法,以及协程可能仍然会遇到的一些问题。
我们刚才讨论的 launch()
函数启动一个不返回任何内容的协程。相比之下,async()
函数启动一个返回某些值的协程。
调用 launch()
与调用返回 Unit
的函数非常相似。但我们的大多数函数都返回某种类型的结果。为此,我们有了 async()
函数。它也启动一个协程,但它返回的是 Deferred<T>
,其中 T
是你期望稍后获得的数据类型。
例如,以下函数将启动一个异步生成 UUID 并返回它的协程:
fun fastUuidAsync() = GlobalScope.async {
UUID.randomUUID()
}
println(fastUuidAsync())
如果你从我们的 main
方法中运行以下代码,则不会打印出预期的结果。这段代码打印出的结果而不是某个 UUID 值如下:
> DeferredCoroutine{Active}
协程返回的对象称为作业。让我们了解这是什么以及如何正确使用它。
Jobs
异步任务运行的结果称为Thread
对象代表一个实际的操作系统线程,job
对象代表一个实际的协程。
这意味着我们试图做的是这个:
val job: Job = fastUuidAsync()
println(job)
job
有一个简单的生命周期。它可能处于以下状态之一:
-
新:已创建但尚未开始。
-
例如,
launch()
函数。这是默认状态。 -
完成:一切顺利。
-
已取消:出了点问题。
对于具有子任务的作业,还有两个相关的状态:
-
完成:在完成之前等待完成执行子任务
-
取消:在取消之前等待完成执行子任务
如果你想了解更多关于父任务和子任务的信息,请跳转到本章的父任务部分。
我们混淆了其值的任务处于活动状态,这意味着它还没有完成计算我们的 UUID。
拥有值的任务被称为Deffered
:
val job: Deferred<UUID> = fastUuidAsync()
我们将在第八章,“设计并发”中更详细地讨论Deferred
值。
为了等待任务完成并获取实际值,我们可以使用await()
函数:
val job: Deferred<UUID> = fastUuidAsync()
println(job.await())
这段代码无法编译:
> Suspend function 'await' should be called only from a coroutine or another suspend function
这是因为,正如错误信息本身所说明的,我们的main()
函数没有标记为suspend
关键字,也不是协程。
我们可以通过将我们的代码包裹在runBlocking
函数中来修复这个问题:
runBlocking {
val job: Deferred<UUID> = fastUuidAsync()
println(job.await())
}
此函数将阻塞主线程,直到所有协程完成。这是从第四章,“熟悉行为模式”,中桥接设计模式的实现,它允许我们连接常规代码和使用了协程的代码。
现在运行此代码将产生预期的随机 UUID 输出。
重要提示:
在本章中,在讨论协程时,我们有时会省略runBlocking
以保持简洁。你可以在本书的 GitHub 仓库中找到完整的示例。
job
对象还有一些其他有用的方法,我们将在接下来的几节中讨论。
协程的内部机制
因此,我们已经提到了以下事实几次:
-
协程就像轻量级线程。它们需要的资源比常规线程少,因此你可以创建更多。
-
协程不会像阻塞整个线程那样阻塞自己,而是在等待执行子任务的同时允许线程执行另一段代码。
但协程是如何工作的呢?
例如,让我们看看一个组合用户配置文件的函数:
fun profileBlocking(id: String): Profile {
// Takes 1s
val bio = fetchBioOverHttpBlocking(id)
// Takes 100ms
val picture = fetchPictureFromDBBlocking(id)
// Takes 500ms
val friends = fetchFriendsFromDBBlocking(id)
return Profile(bio, picture, friends)
}
在这里,我们的函数大约需要 1.6 秒才能完成。它的执行是完全顺序的,执行线程将在整个过程中被阻塞。
我们可以重新设计这个函数,使其与协程一起工作,如下所示:
suspend fun profile(id: String): Profile {
// Takes 1s
val bio = fetchBioOverHttpAsync(id)
// Takes 100ms
val picture = fetchPictureFromDBAsync(id)
// Takes 500ms
val friends = fetchFriendsFromDBAsync(id)
return Profile(bio.await(), picture.await(), friends.await())
}
没有使用suspend
关键字,我们的异步代码将无法编译。我们将在本节稍后讨论suspend
关键字的意义。
为了理解每个异步函数看起来像什么,让我们以其中一个为例:
fun fetchFriendsFromDBAsync(id: String) = GlobalScope.async
{
delay(500)
emptyList<String>()
}
现在,让我们比较这两个函数的性能:一个是以阻塞方式编写的,另一个是使用协程的。
我们可以使用之前看到的 runBlocking
函数包装这两个函数,并使用 measureTimeMillis
测量它们完成所需的时间:
runBlocking {
val t1 = measureTimeMillis {
blockingProfile("123")
}
val t2 = measureTimeMillis {
profile("123")
}
println("Blocking code: $t1")
println("Async: $t2")
}
输出将类似于以下内容:
> Blocking code: 1623
> Coroutines: 1021
并发协程的执行时间是最长协程的最大值,而顺序代码则是所有函数的总和。
在理解了前两个示例之后,让我们看看另一种编写相同代码的方法。
我们将使用 suspend
关键字标记每个函数:
suspend fun fetchFriendsFromDB(id: String): List<String> {
delay(500)
return emptyList()
}
如果你运行这个示例,性能将与阻塞代码相同。那么,我们为什么要使用可挂起函数呢?
可挂起函数不会阻塞线程。从更大的角度来看,通过使用相同数量的线程,我们可以服务更多的用户,这都要归功于 Kotlin 智能地重写可挂起函数。
当 Kotlin 编译器看到 suspend
关键字时,它知道它可以分割并重新编写函数,如下所示:
fun profile(state: Int, id: String, context: ArrayList<Any>): Profile {
when (state) {
0 -> {
context += fetchBioOverHttp(id)
profile(1, id, context)
}
1 -> {
context += fetchPictureFromDB(id)
profile(2, id, context)
}
2 -> {
context += fetchFriendsFromDB(id)
profile(3, id, context)
}
3 -> {
val (bio, picture, friends) = context
return Profile(bio, picture, friends)
}
}
}
重新编写的代码使用了来自 第四章,熟悉行为模式 的 状态设计模式,将函数的执行分解成许多步骤。通过这样做,我们可以在状态机的每个阶段释放执行协程的线程。
重要提示:
这并不是生成代码的完美描述。目标是展示 Kotlin 编译器背后的理念,但为了简洁,省略了一些细微的实现细节。
注意,与之前我们产生的异步代码不同,状态机本身是顺序的,执行所有步骤所需的时间与阻塞代码相同。
事实上,这些步骤中没有任何一个会阻塞任何线程,这在本例中非常重要。
取消协程
如果你是一名 Java 开发者,你可能知道停止一个线程相当复杂。
例如,Thread.stop()
方法已被弃用。有 Thread.interrupt()
,但并非所有线程都在检查这个标志,更不用说设置一个 volatile
标志,这通常被建议,但非常繁琐。
如果你使用线程池,你会得到 Future
,它有 cancel(boolean mayInterruptIfRunning)
方法。在 Kotlin 中,launch()
函数返回一个作业。
这个作业可以被取消。尽管如此,前一个示例中的相同规则仍然适用。如果你的协程从未调用另一个 suspend
函数或 yield
函数,它将忽略 cancel()
。
为了展示这一点,我们将创建一个偶尔产生 yield
的协程:
val cancellable = launch {
try {
for (i in 1..10_000) {
println("Cancellable: $i")
yield()
}
}
catch (e: CancellationException) {
e.printStackTrace()
}
}
如你所见,在每次 print
语句之后,协程调用 yield
函数。如果它被取消,它将打印堆栈跟踪。
我们还将创建另一个不产生 yield
的协程:
val notCancellable = launch {
for (i in 1..10_000) {
if (i % 100 == 0) {
println("Not cancellable $i")
}
}
}
这个协程从不让出,并在每 100
次迭代时打印其结果以避免垃圾邮件。
现在,让我们尝试取消两个协程:
println("Canceling cancellable")
cancellable.cancel()
println("Canceling not cancellable")
notCancellable.cancel()
然后,我们将等待结果:
runBlocking {
cancellable.join()
notCancellable.join()
}
通过调用 join()
,我们可以等待协程的执行完成。
让我们看看我们代码的输出:
> Canceling cancellable
> Cancellable: 1
> Not cancellable 100
>...
> Not cancellable 1000
> Canceling not cancellable
从这个实验中我们可以学到关于协程行为的几个有趣点如下:
-
取消
cancellable
协程不会立即发生。在被取消之前,它可能还会打印一两行。 -
我们可以捕获
CancellationException
,但我们的协程仍然会被标记为已取消。捕获该异常并不会自动允许我们继续。
现在,让我们了解发生了什么。协程检查它是否被取消,但只有在它切换状态时才会这样做。由于非可取消协程没有任何挂起函数,它从未检查是否被要求停止。
在 cancellable
协程中,我们使用了一个新函数:yield()
。我们本可以在每次循环迭代时调用 yield()
,但决定每 100
次迭代做一次。这个函数检查是否有人想要做些工作。如果没有其他人,当前协程的执行将恢复。否则,另一个协程将开始或从之前停止的地方继续。
注意,如果没有在函数或协程生成器(如 launch()
)上使用 suspend
关键字,我们无法调用 yield()
。这对于任何标记为 suspend
的函数都适用:它应该要么从另一个 suspend
函数调用,要么从协程调用。
设置超时
让我们考虑以下情况。如果,在某些情况下,获取用户资料花费的时间过长怎么办?如果我们决定如果资料返回时间超过 0.5 秒,我们就显示没有资料怎么办?
这可以通过使用 withTimeout()
函数来实现:
val coroutine = async {
withTimeout(500) {
try {
val time = Random.nextLong(1000)
println("It will take me $time to do")
delay(time)
println("Returning profile")
"Profile"
}
catch (e: TimeoutCancellationException) {
e.printStackTrace()
}
}
}
我们将超时设置为 500
毫秒,我们的协程将在 0
到 1000
毫秒之间延迟,有 50% 的失败概率。
我们将等待协程的结果并查看会发生什么:
val result = try {
coroutine.await()
}
catch (e: TimeoutCancellationException) {
"No Profile"
}
println(result)
在这里,我们得益于 Kotlin 中 try
是一个表达式的这一事实。因此,我们可以立即从它返回一个结果。
如果协程在超时之前成功返回,则 result
的值变为 profile
。否则,我们收到 TimeoutCancellationException
,并将 result
的值设置为 no profile
。
超时和 try
-catch
表达式的组合是一个非常强大的工具,它允许我们创建健壮的交互。
分派器
当我们使用 runBlocking
函数运行我们的协程时,它们的代码是在主线程上执行的。
你可以通过运行以下代码来检查:
runBlocking {
launch {
println(Thread.currentThread().name) // Prints
"main"
}
}
相比之下,当我们使用 GlobalScope
运行协程时,它运行在称为 DefaultDispatcher
的东西上:
GlobalScope.launch {
println("GlobalScope.launch:
${Thread.currentThread().name}")
}
这会打印以下输出:
> DefaultDispatcher-worker-1
DefaultDispatcher
是一个用于短生命周期协程的线程池。
协程生成器,如 launch()
和 async()
,依赖于默认参数,其中一个参数是它们将要启动的分发器。要指定替代分发器,您可以将它作为参数提供给协程构建器:
runBlocking {
launch(Dispatchers.Default) {
println(Thread.currentThread().name)
}
}
上述代码将打印以下输出:
> DefaultDispatcher-worker-1
除了我们已讨论的 Main
和 Default
分发器之外,还有一个 IO
分发器,用于长时间运行的任务。您可以通过将其提供给协程构建器以类似方式使用它,如下所示:
async(Dispatchers.IO) {
// Some long running task here
}
结构化并发
从另一个协程内部生成协程是一个非常常见的做法。
结构化并发的第一规则是父协程应该始终等待所有其子协程完成。这可以防止资源泄露,这在没有结构化并发概念的编程语言中非常常见。
这意味着如果我们查看以下代码,它启动了 10 个子协程,父协程不需要显式等待它们全部完成:
val parent = launch(Dispatchers.Default) {
val children = List(10) { childId ->
launch {
for (i in 1..1_000_000) {
UUID.randomUUID()
if (i % 100_000 == 0) {
println("$childId - $i")
yield()
}
}
}
}
}
现在,让我们决定其中一个协程在一段时间后抛出异常:
...
if (i % 100_000 == 0) {
println("$childId - $i")
yield()
}
if (childId == 8 && i == 300_000) {
throw RuntimeException("Something bad happened")
}
...
如果你运行此代码,会发生一些有趣的事情。不仅协程本身会终止,而且所有其兄弟协程也会终止。
这里发生的事情是,一个未捕获的异常向上冒泡到父协程并取消它。然后,父协程终止所有其他子协程以防止任何资源泄露。
通常,这是期望的行为。如果我们想防止子异常停止父协程,我们可以使用 supervisorScope
:
val parent = launch(Dispatchers.Default) {
supervisorScope {
val children = List(10) { childId ->
...
}
}
}
通过使用 supervisorScope
,即使其中一个协程失败,父任务也不会受到影响。
父协程仍然可以通过使用 cancel()
函数来终止所有其子协程。一旦我们在父任务上调用 cancel()
,所有其子协程也会被取消。
既然我们已经讨论了结构化并发的优点,让我们重申本章开头的一个观点:使用 GlobalScope
以及它被标记为 GlobalScope
的事实,它暴露了 launch()
和 async()
等函数,它不受益于结构化并发原则,并且在使用不当时容易发生资源泄露。因此,你应该避免在现实世界的应用程序中使用 GlobalScope
。
摘要
在本章中,我们介绍了如何在 Kotlin 中创建线程和协程,以及协程相对于线程的优点。
与 Java 相比,Kotlin 创建线程的语法已经简化。但它仍然有内存和,通常,性能的开销。协程可以解决这些问题;在 Kotlin 中需要并发执行某些代码时,请始终使用协程。
到目前为止,你应该知道如何启动协程以及如何等待其完成,在此过程中获取其结果。我们还介绍了协程的结构以及它们与分发器的交互。
最后,我们简要提到了结构化并发这个现代概念,它帮助我们轻松地防止并发代码中的资源泄漏。
在下一章中,我们将讨论如何使用这些并发原语来创建适合我们需求的可扩展和健壮的系统。
问题
-
在 Kotlin 中启动协程的不同方法有哪些?
-
在结构化并发中,如果其中一个协程失败,所有兄弟协程也会被取消。我们如何防止这种行为?
-
yield()
函数的目的是什么?
第七章:第七章:控制数据流
上一章介绍了重要的Kotlin并发原语:协程。在这一章中,我们将讨论 Kotlin 中的另外两个重要的并发原语:通道和流。我们还将简要介绍集合的高阶函数,因为它们的 API 与通道和流非常相似。
充分利用小型、可重用和可组合函数的想法直接来自我们在上一章讨论的函数式编程范式。这些函数允许我们用描述我们想要做什么而不是我们想要如何做的方式来编写代码。
在这一章中,我们将涵盖以下主题:
-
反应式原则
-
集合的高阶函数
-
并发数据结构
-
序列
-
通道
-
流
在阅读本章后,你将能够高效地在不同的协程之间进行通信,并轻松处理你的数据。
技术要求
除了前几章的技术要求外,你还需要一个启用了Gradle的Kotlin项目,以便能够添加所需的依赖项。
你可以在以下位置找到本章使用的源代码,位于GitHub上:
github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter07
反应式原则
我们将从这个章节开始,简要地探讨反应式编程,因为它构成了数据流概念的基础。
反应式编程是一种基于函数式编程的范式,其中我们将我们的逻辑建模为数据流中的一系列操作。反应式编程的基本概念在反应式宣言(www.reactivemanifesto.org
)中得到了很好的总结。
根据这份宣言,反应式程序应该是以下所有内容:
-
响应式
-
弹性
-
弹性
-
消息驱动
要理解这四个原则,我们将使用一个例子。
让我们想象你正在给你的互联网服务提供商打电话,比如你的互联网速度很慢。你脑海中有没有这样的画面? 那我们就开始吧。
响应式原则
你愿意花多少时间排队等待? 这取决于情况的紧迫性和你拥有的时间。如果你很着急,你可能会早点挂断电话,因为你不知道在听那糟糕的音乐时你需要等待多长时间。
这就是系统对你无响应的情况。这种情况也发生在网络系统中。当等待其他请求被处理时,对网络服务器的请求可能会在队列中卡住。
另一方面,一个响应式的呼叫中心可能会偶尔用愉快的声音告诉你,在你之前有多少人在排队——甚至告诉你你需要等待多长时间。
在这两种情况下,结果都是一样的。你在等待在线上浪费了时间。但第二个系统对你的需求做出了响应,你可以据此做出决定。
弹性原则
让我们继续讨论弹性原则。想象一下,你在电话线上等了 10 分钟,然后线路断了。这就是系统对故障缺乏弹性的例子。
反应式宣言建议了实现弹性的几种方法:
-
委派:你可能听到,“我们当前的代表无法解决你的慢速互联网问题;我们将把你转接到其他人。”
-
复制:然后,你可能听到,“我们意识到很多人都在线上;我们正在说话时增加更多代表。”这也与弹性有关,我们将在下一节中介绍。
-
限制和隔离:最后,自动语音告诉你,“如果你不想等待,请留下你的电话号码,我们会给你回电。”限制意味着你现在与系统所面临的可扩展性问题(即系统代表不足)解耦了。相比之下,隔离意味着即使系统在电话线路不可靠方面存在问题,你也不关心。
弹性原则
在上一节中,我们讨论了复制。为了防止故障,我们的客服中心总是至少有三名代表在班上。也许他们都在接听电话,或者他们可能只是在耐心地等待。
然而,如果某个狂热的鼹鼠咬断了互联网电缆会发生什么呢?
突然,有大量愤怒的客户来电。
如果我们的客服中心只有三部电话,我们对此无能为力。但如果我们有一些额外的资源,我们可以增加代表来处理事件并安抚我们的客户。而且,当电缆最终修好时,我们可以让他们回去工作。这就是系统对工作量做出弹性的响应。
弹性建立在可扩展性的基础上。例如,如果我们每个代表都能通过拥有自己的电话独立工作,我们就可以管理所有 incoming calls。如果我们有比电话更多的代表,电话的数量就会成为瓶颈,一些代表将无法接听任何电话。
信息驱动原则
信息驱动原则也被称为异步消息传递。所以,在上一节中,我们看到了如果你可以为任何代表留下信息让他们回电,这可以使系统更具弹性。
那么,如果所有客户都只留下信息怎么办?
然后,每个代表都可以优先处理这些信息或批量处理它们。例如,一次性打印所有账单收据,而不是随机顺序处理信息。
使用消息还可以应用背压。如果一个代表收到太多的消息,他们可能会因为压力而崩溃。为了避免这种情况,他们可能会给你发短信说你需要等待更长一点时间才能收到你的回复。同样,我们在这里也在谈论 委托,因为所有这些原则都是重叠的。
消息也是 非阻塞的。在你发送消息后,你不需要在那里等待代表的回复。相反,你通常会回到你的常规任务。在等待时能够执行其他任务是 并发 的基石之一。
在本节中,我们学习了四个反应性原则。反应性应用是响应的、弹性的、可伸缩的,并且以消息驱动。在接下来的章节中,我们将看到这些原则如何在 Kotlin 中应用。我们将从 集合 开始,或者在反应式编程术语中,称为 静态数据流。
集合上的高阶函数
我们在 第一章,Kotlin 入门 中简要提到了这个话题,但在我们讨论流之前,让我们确保那些来自没有集合高阶函数的语言的人知道它们是什么,它们做什么,以及使用它们的优点是什么。
我们无法涵盖集合上所有可用的函数,但我们将涵盖最广泛使用的那些。
映射元素
map()
函数接受集合中的每个元素,并返回一个可能不同类型的新元素。为了更好地理解这个想法,让我们假设我们有一个字母列表,我们想要输出它们的 ASCII 值。
首先,让我们以命令式的方式实现它:
val letters = 'a'..'z'
val ascii = mutableListOf<Int>()
for (l in letters) {
ascii.add(l.toInt())
}
注意,即使是这样一个简单的任务,我们也不得不编写相当多的代码。我们还必须将输出列表定义为可变的。
现在,使用 map()
函数的相同代码将看起来像这样:
val result: List<Int> = ('a'..'z').map { it.toInt() }
注意实现有多么简短。我们不需要定义一个可变列表,也不需要自己编写 for-each
循环。
过滤元素
另一个常见的任务是过滤集合。你知道该怎么做——你遍历它,只将符合你标准的值放入一个新的集合中。例如,如果给定一个介于 1
和 100
之间的数字范围,我们只想返回那些可以被 3
或 5
整除的数字。
在命令式方式中,这个函数可能看起来像这样:
val numbers = 1..100
val notFizzbuzz = mutableListOf<Int>()
for (n in numbers) {
if (n % 3 == 0 || n % 5 == 0) {
notFizzbuzz.add(n)
}
}
在其函数式变体中,我们会使用 filter()
函数:
val filtered: List<Int> = (1..100).filter { it % 3 == 0 ||
it % 5 == 0 }
再次注意,我们的代码变得更加简洁。我们只指定 需要做什么,过滤符合标准的元素,而不是 如何做(例如,使用 if
语句)。
查找元素
在集合中查找第一个元素是另一个常见任务。如果我们编写一个查找既能被 3
又能被 5
整除的数字的函数,我们可以这样实现它:
fun findFizzbuzz(numbers: List<Int>): Int? {
for (n in numbers) {
if (n % 3 == 0 && n % 5 == 0) {
return n
}
}
return null
}
同样的功能可以使用 find
函数实现:
val found: Int? = (1..100).find { it % 3 == 0 && it % 5 == 0 }
与前面提到的命令式函数类似,如果没有任何元素符合我们的标准,find
函数将返回 null
。
此外,还有一个配套的 findLast()
方法,它执行相同的操作,但以集合的最后一个元素开始。
为每个元素执行代码
所有之前的函数族都有一个共同的特点:它们都产生一个流。但并非所有的高阶函数都返回流。有些会返回单个值,例如 Unit
或,例如,一个数字。这些函数被称为 终结函数。
在本节中,我们将处理第一个终结函数。终结函数返回的不是新集合,因此你不能将此调用的结果链式调用到其他调用。因此,它们 终结 链式调用。
在 forEach()
的情况下,它返回 Unit
类型的结果。Unit
类型类似于 forEach()
函数中的 void
。forEach()
函数就像普通的 for
循环:
val numbers = (0..5)
numbers.map { it * it} // Can continue
.filter { it < 20 } // Can continue
.forEach { println(it) } // Cannot continue
注意,与传统的 for
循环相比,forEach()
有一些轻微的性能影响。
此外,还有 forEachIndexed()
,它提供了一个索引,与集合中的实际值一起:
numbers.map { it * it }
.forEachIndexed { index, value ->
print("$index:$value, ")
}
上述代码的输出将如下所示:
> 0:1, 1:4, 2:9, 3:16, 4:25,
自 Kotlin 1.1 以来,还有一个 onEach()
函数,它更有用一些,因为它会返回集合本身:
numbers.map { it * it}
.filter { it < 20 }
.sortedDescending()
.onEach { println(it) } // Can continue now
.filter { it > 5 }
正如你所见,这个函数不是终结的。
求和元素
与 forEach()
类似,reduce()
是一个终结函数。但它不是以 Unit
终结,Unit
不是很有用,而是以与操作集合相同类型的单个值终结。
为了看到 reduce()
在实际中的应用,让我们总结一下从 1
到 100
之间的所有数字:
val numbers = 1..100
var sum = 0
for (n in numbers) {
sum += n
}
现在,让我们使用 reduce
来编写相同的代码:
val reduced: Int = (1..100).reduce { sum, n -> sum + n }
注意,这里它让我们避免了声明一个用于存储元素总和的可变变量。与之前我们看到的高阶函数不同,reduce()
接收的不是单个参数,而是两个参数。第一个参数是累加器。在命令式示例中,它是 sum
变量。第二个参数是下一个元素。我们使用了相同的参数名,所以应该相对容易比较这两种实现。
移除嵌套
有时在处理集合时,我们可能会得到一个 集合的集合。例如,考虑以下代码:
val listOfLists: List<List<Int>> = listOf(listOf(1, 2), listOf(3, 4, 5), listOf(6, 7, 8))
但如果我们想将这个集合转换成一个包含所有嵌套元素的单一列表呢?
然后,输出将如下所示:
> [1, 2, 3, 4, 5, 6, 7, 8]
一个选择是迭代我们的输入并使用可变集合的 addAll
方法:
val flattened = mutableListOf<Int>()
for (list in listOfLists) {
flattened.addAll(list)
}
一个更好的选择是使用 flatMap()
函数,它将执行相同操作:
val flattened: List<Int> = listOfLists.flatMap { it }
通过使用 flatten()
函数,这个具体的例子可以进一步简化:
val flattened: List<Int> = listOfLists.flatten()
但 flatMap()
函数通常更有用,因为它允许你将其他函数应用于每个集合,以一种 适配器 模式。
在集合上声明了许多其他高阶函数,所以我们无法在这个简短的章节中涵盖所有这些。你必须浏览官方文档并了解它们。尽管如此,之前讨论的函数应该为我们将要讨论的下一个主题提供一个坚实的基础。
现在,当你熟悉了如何转换和迭代静态数据流时,让我们看看我们如何将这些相同的操作应用到动态数据流上。
探索并发数据结构
现在我们熟悉了集合上的一些最常见的高阶函数,让我们将这个知识与我们在上一章中关于 Kotlin 并发原语的知识结合起来,讨论 Kotlin 提供的并发数据结构。
最基本的并发数据结构是通道和流。然而,在我们可以讨论它们之前,我们需要看看另一个数据结构:序列。虽然这个数据结构本身不是并发的,但它将为我们提供进入并发世界的一个桥梁。
序列
在许多函数式编程语言中,集合上的高阶函数已经存在很长时间了。但对于 Java 开发者来说,集合上的高阶函数首次出现在 Java 8 中,随着Stream API的引入。
尽管 Stream API 为开发者提供了如map()
、filter()
等有价值的函数以及我们之前讨论的一些其他函数,但 Stream API 有两个主要的缺点。首先,为了使用这些函数,你必须迁移到 Java 8。其次,你的集合必须转换成称为流的东西,它上面定义了所有这些函数。如果你想在映射和过滤流之后再次返回一个集合,你可以将其收集回来。
流和集合之间还有一个重要的区别。与集合不同,流可以是无限的。由于 Kotlin 不仅限于JVM,并且向后兼容 Java 6,因此它需要提供另一个解决方案来处理无限集合的可能性。这个解决方案被命名为序列,以避免与 Java 流冲突,当 Java 流可用时。
我们可以使用generateSequence()
函数创建一个新的序列。例如,下一个函数将创建一个无限数字序列:
val seq: Sequence<Long> = generateSequence(1L) { it + 1 }
作为第一个参数,我们指定初始值,而第二个参数是一个 lambda 表达式,它基于前一个值生成下一个值。如您所见,返回的类型是Sequence
。
可以使用asSequence()
函数将常规集合或范围转换为序列:
(1..100).asSequence()
如果我们需要使用更复杂的逻辑来构建序列,可以使用sequence()
构建器:
val fibSeq = sequence {
var a = 0
var b = 1
yield(a)
yield(b)
while (true) {
yield(a + b)
val t = a
a = b
b += t
}
}
在这个例子中,我们创建了一个斐波那契数列。然后,我们使用yield()
函数来返回序列中的下一个值。每次使用序列时,代码将从最后一个调用的yield()
函数处恢复。
虽然序列的概念本身似乎并不很有用,但序列和集合之间存在显著的差异。序列是懒加载的,而集合是急加载的。
这意味着在集合上使用高阶函数对于超过一定大小的集合来说有一个隐藏的成本。大多数情况下,它们会为了不可变性而复制整个集合。
为了理解这种差异,让我们看看以下代码。首先,我们将创建一个包含一百万个数字的列表,并测量平方列表中每个数字所需的时间——一次在操作集合时,另一次在操作序列时:
val numbers = (1..1_000_000).toList()
println(measureTimeMillis {
numbers.map {
it * it
}.take(1).forEach { it }
}) // ~50ms
println(measureTimeMillis {
numbers.asSequence().map {
it * it
}.take(1).forEach { it }
}) // ~5ms
我们使用take()
函数,这是集合上的另一个高阶函数,用于仅获取计算的第一个元素。
您可以看到,使用序列的代码执行得更快。这是因为序列是懒加载的,它会为每个元素执行链式操作。这意味着整个列表中只有一个数字被平方。
另一方面,集合上的函数作用于整个集合。这意味着首先,所有的数字都被平方,然后放入一个新的集合中,然后只从结果中取出一个数字。
序列、通道和流程遵循响应式原则,因此在继续之前理解它们是至关重要的。请注意,响应式原则并不局限于函数式编程。在编写面向对象或过程式代码时,您也可以是响应式的。然而,在学习了函数式编程及其基础之后,讨论这些原则仍然更容易。
通道
在上一章中,我们学习了如何生成协程并控制它们。
但是,如果两个协程需要相互通信怎么办?
在 Java 中,线程通过使用wait()
/notify()
/notifyAll()
模式或使用java.util.concurrent
包中的一系列丰富的类(例如,BlockingQueue
)进行通信。
在 Kotlin 中,如您所注意到的,没有wait()
/notify()
方法。相反,为了在协程之间进行通信,Kotlin 使用通道。BlockingQueue
,但通道不会阻塞线程,而是挂起协程,这要便宜得多。我们将使用以下步骤来创建通道和协程:
-
首先,让我们创建一个通道:
val chan = Channel<Int>()
通道是有类型的。这个通道只能接收整数。
-
然后,让我们创建一个从该通道读取的协程:
launch { for (c in chan) { println(c) } }
从通道读取就像使用
for-each
循环一样简单。 -
现在,让我们向这个通道发送一些值。这和使用
send()
函数一样简单:(1..10).forEach { chan.send(it) } chan.close()
-
最后,我们关闭通道。一旦关闭,监听通道的协程也将退出
for-each
循环,如果没有其他事情要做,协程将终止。
这种通信方式被称为通信顺序进程,或者更简单地说,CSP。
如您所见,通道是在不同协程之间通信的一种方便且类型安全的途径。但我们必须手动定义通道。在接下来的两个部分中,我们将看到如何进一步简化这一点。
生产者
如果我们需要一个提供值流的协程,我们可以使用produce()
函数。这个函数创建了一个由ReceiveChannel<T>
支持的协程,其中T
是协程生成的类型。
我们可以通过使用produce()
函数重写上一节中的例子,如下所示:
val chan = produce {
(1..10).forEach {
send(it)
}
}
launch {
for (c in chan) {
println(c)
}
}
注意,在produce()
块内部,send()
函数随时可用,我们可以用它将新值推送到通道。
在我们的消费者协程中,我们不需要使用for-each
循环,我们可以使用consumeEach()
函数:
launch {
chan.consumeEach {
println(it)
}
}
现在,是时候看看另一个例子,其中协程被绑定到通道上了。
演员
与producer()
类似,actor()
是一个与通道绑定的协程。但与通道从协程出去不同,这里有一个通道进入协程。
让我们看看以下例子:
val actor = actor<Int> {
channel.consumeEach {
println(it)
}
}
(1..10).forEach {
actor.send(it)
}
在这个例子中,我们的主函数再次生成值,并通过通道让演员消费它们。这与我们看到的第一个例子非常相似,但不同之处在于我们没有显式创建通道和单独的协程,而是将它们捆绑在一起。
如果你曾经使用过Scala或任何其他具有演员的编程语言,你可能对我们描述的演员模型略有不同。例如,在某些实现中,演员既有输入通道也有输出通道(通常称为邮箱)。但在 Kotlin 中,演员只有一个以通道形式存在的输入邮箱。
缓冲通道
在所有之前的例子中,无论是显式还是隐式地创建通道,我们实际上使用的是它们的未缓冲版本。
为了演示这意味着什么,让我们看看上一节中略微修改过的例子:
val actor = actor<Long> {
var prev = 0L
channel.consumeEach {
println(it - prev)
prev = it
delay(100)
}
}
这里,我们几乎有相同的actor
对象,它接收时间戳并打印出它接收到的每个两个时间戳之间的差异。我们还在它能够读取下一个值之前引入了一个小延迟。
而不是发送一系列数字,我们将当前时间戳发送到这个actor
对象:
repeat(10) {
actor.send(System.currentTimeMillis())
}
actor.close().also { println("Done sending") }
现在,让我们看看我们代码的输出:
> ...
> 101
> 103
> 101
> Done sending
注意,我们的生产者在通道准备好接受下一个值之前是挂起的。因此,actor
对象能够对生产者施加回压,告诉它不要发送下一个值,直到actor
对象准备好。
现在,让我们对我们的actor
对象的定义方式做一些小的改动:
val actor = actor<Long>(capacity = 10) {
...
}
每个通道都有一个容量,默认为零。这意味着在从通道消耗一个值之前,无法通过它发送其他值。
现在,如果我们再次运行我们的代码,我们将看到完全不同的输出:
> Done sending
> ...
> 0
> 0
由于通道现在缓冲消息,生产者不再需要等待消费者。因此,消息尽可能快地发送,而 actor 仍然可以以自己的节奏消费它们。
以类似的方式,capacity
也可以定义在生产者通道上:
val chan = produce(capacity = 10) {
(1..10).forEach {
send(it)
}
}
它也可以定义在原始通道上:
val chan = Channel<Int>(10)
缓冲通道是一个非常强大的概念,它允许我们将生产者与消费者解耦。不过,您应该谨慎使用它们,因为通道的容量越大,所需的内存就越多。
通道是一种相对底层的并发结构。因此,让我们看看另一种类型的流,它为我们提供了更高层次的抽象。
流
流是一个冷、异步的流,是我们在第四章,“熟悉行为模式”中介绍的可观察设计模式的实现。
作为快速提醒,可观察的设计模式有两个方法:subscribe()
(允许消费者订阅消息)和publish()
(向所有订阅者发送新消息)。
Flow
对象的发布方法是emit()
,而订阅方法是collect()
。
我们可以使用flow()
函数创建一个新的流:
val numbersFlow: Flow<Int> = flow {
...
}
在flow
构造函数内部,我们可以使用emit()
函数向所有监听器发布新的值。
例如,这里我们创建了一个使用flow
构造函数发布十个数字的流:
flow {
(0..10).forEach {
println("Sending $it")
emit(it)
}
}
现在我们已经介绍了如何发布消息,让我们讨论如何订阅流。
为了做到这一点,我们可以使用flow
对象上可用的collect()
函数:
numbersFlow.collect { number ->
println("Listener received $number")
}
如果现在运行此代码,您会看到监听器打印出它从流中接收到的所有数字。
与一些其他响应式框架和库不同,没有特殊的语法来向监听器抛出异常。相反,我们可以简单地使用标准的throw
表达式来完成这个操作:
flow {
(1..10).forEach {
...
if (it == 9) {
throw RuntimeException()
}
}
}
从监听器方面来看,处理异常就像将collect()
函数包裹在try
/catch
块中一样简单:
try {
numbersFlow.collect { number ->
println("Listenerreceived $number")
}
}
catch (e: Exception) {
println("Got an error")
}
与通道一样,Kotlin 的流是挂起的,但它们不是并发的。流支持背压,尽管这对用户来说是完全透明的。为了了解这意味着什么,让我们为同一个流创建多个订阅者:
(1..4).forEach { coroutineId ->
delay(5000)
launch(Dispatchers.Default) {
numbersFlow.collect { number ->
delay(1000)
println("Coroutine $coroutineId received
$number")
}
}
}
每个订阅者都在自己的协程中运行,每个新订阅之间有五秒的延迟。这允许我们看到它们并发运行。
现在,让我们看看输出结果:
> ...
> Sending 1
> Coroutine 1 received 5
> Sending 6
> Coroutine 2 received 1
> Sending 2
> Coroutine 1 received 6
> ...
从这个输出中,我们可以学习两个重要的教训:
-
1
。 -
流使用背压:请注意,下一个数字只有在接收到前一个数字之后才会发送。这与未缓冲通道的行为相似,与缓冲通道不同,在缓冲通道中,生产者可以比消费者更快地发送数字。
接下来,让我们看看这些流的两个属性如何被修改(如果需要的话)。
缓冲流
在某些情况下,例如,当我们有足够的可用内存时,我们并不急于对生产者应用背压。为了做到这一点,每个消费者都可以指定使用buffer()
函数来缓冲流程:
numbersFlow.buffer().collect { number ->
delay(1000)
println("Coroutine $coroutineId received $number")
}
如果我们再次查看上述代码的输出,我们会看到显著的变化:
> ...
> Sending 8
> Sending 9
> Sending 10
> Coroutine 1 received 1
> Coroutine 1 received 2
> ...
使用缓冲区,流程在缓冲区填满之前不会受到消费者任何背压的影响。然后,消费者仍然能够以自己的节奏收集值。这种行为类似于缓冲通道,实际上,实现使用了一个底层的通道。
缓冲流程在处理每条消息需要相当多的时间时非常有用。以从手机上传图片为例。当然,上传所需的时间会根据图片的大小而有所不同。你不想在上传图片之前阻止用户界面,因为这会是一个糟糕的用户体验,并且违反了响应式原则。
相反,你可以定义一个适合内存的缓冲区,以自己的节奏上传图片,并且只有在缓冲区充满任务时才阻止用户界面。
在图片的情况下,我们处理的是一系列我们不希望丢失的元素。所以,让我们考虑一个不同的例子,在这个例子中,我们可以允许在我们的流程中丢弃一些元素。
合并流程
想象一下,我们有一个以每秒十次的速度产生股价变化的流程,并且我们有一个需要显示最新股价的 UI。为此,我们只需使用一个每次滴答上升 1 的数字:
val stock: Flow<Int> = flow {
var i = 0
while (true) {
emit(++i)
delay(100)
}
}
然而,UI 本身不需要每秒刷新十次。每秒一次就足够了。如果我们简单地尝试使用collect()
,就像前面的例子一样,我们将会不断落后于生产者:
var seconds = 0
stock.collect { number ->
delay(1000)
seconds++
println("$seconds seconds -> received $number")
}
上述代码输出以下内容:
> 1 seconds -> received 1
> 2 seconds -> received 2
> 3 seconds -> received 3
> ...
上述输出是不正确的。原因是我们对流程应用了背压,使其减慢。另一个选择是缓冲 10 个值,就像我们在前面的例子中看到的那样。但因为我们希望 UI 的刷新速度比流程本身快十倍,所以我们将不得不丢弃十个值中的九个。我们将把这个逻辑的实现留给读者去尝试。
一个更好的解决方案是合并流程。合并流程不会存储所有消息。相反,它只保留最新的值。我们在以下代码中实现了这一点:
stock.conflate().collect { number ->
delay(1000)
seconds++
println("$seconds seconds -> received $number")
}
让我们先看看输出:
> ...
> 4 seconds -> received 30
> 5 seconds -> received 40
> 6 seconds -> received 49
> ...
你可以看到现在值是正确的。平均来说,我们的计数器每秒增加十次。
现在,我们的流程将永远不会中断,并且订阅者将只接收到流程计算出的最新值。
摘要
本章致力于练习使用响应式原则进行函数式编程,并学习 Kotlin 中函数式编程的构建块。我们还了解了响应式系统的主要好处。例如,这样的系统应该是响应的、弹性的、可伸缩的,并由消息驱动。
现在,你应该知道如何转换你的数据,过滤你的集合,并找到满足你标准的集合中的元素。
你也应该更好地理解冷流和热流的区别。冷流,例如流,只有当有人订阅它时才开始工作。新订阅者通常会接收到所有的事件。另一方面,热流,例如通道,会持续发出事件,即使没有人监听它们。新订阅者只会接收到订阅后发送的事件。
我们还讨论了背压的概念,这可以在流中实现。例如,如果消费者无法处理所有的事件,它可能会暂停生产者,缓冲事件以期望赶上,或者合并流,只处理一些事件。
下一章将介绍并发设计模式,这些模式允许我们以可扩展、可维护和可扩展的方式使用协程和响应式流作为构建块来构建并发系统。
问题
-
集合上的高阶函数与并发数据结构上的高阶函数有什么区别?
-
冷流和热流数据有什么区别?
-
何时应该使用合并的通道或流?
第八章:第八章:设计并发
并发设计模式帮助我们同时管理许多任务并结构化它们的生命周期。通过有效地使用这些模式,我们可以避免资源泄露和死锁等问题。
在本章中,我们将讨论并发设计模式及其在Kotlin中的实现方式。为此,我们将使用前几章中的构建块:协程、通道、流以及来自函数式编程的概念。
本章我们将涵盖以下内容:
-
延迟值
-
障碍
-
调度器
-
管道
-
扇出
-
扇入
-
竞赛
-
互斥锁
-
辅助通道
完成本章学习后,你将能够高效地处理异步值,协调不同协程的工作,以及分配和汇总工作,同时拥有解决过程中可能出现的任何并发问题的工具。
技术要求
除了前几章的技术要求外,你还需要一个启用了Gradle的 Kotlin 项目,以便能够添加所需的依赖项。
你可以在以下位置在GitHub上找到本章使用的源代码:
github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter08
延迟值
延迟值设计模式的目的是返回异步计算结果的引用。在Java和Scala中,Future,在JavaScript中则是Promise,都是延迟值设计模式的实现。
我们已经讨论了async()
函数返回一个名为Deferred
的类型,这同样也是该设计模式的一个实现。
有趣的是,Deferred
值本身是我们在第三章“理解结构型模式”中看到的代理设计模式以及第四章“熟悉行为型模式”中的状态设计模式的实现。
我们可以使用CompletableDeferred
构造函数创建一个新的异步计算结果的容器:
val deferred = CompletableDeferred<String>()
要用结果填充Deferred
值,我们使用complete()
函数,如果在过程中发生错误,我们可以使用completeExceptionally()
函数将异常传递给调用者。为了更好地理解它,让我们编写一个返回异步结果的函数。一半的时间结果将包含OK
,另一半的时间它将包含一个异常。
suspend fun valueAsync(): Deferred<String> = coroutineScope {
val deferred = CompletableDeferred<String>()
launch {
delay(100)
if (Random.nextBoolean()) {
deferred.complete("OK")
}
else {
deferred.completeExceptionally(
RuntimeException()
)
}
}
deferred
}
你可以看到我们几乎立即返回Deferred
值,然后我们开始使用launch
启动异步计算,并使用delay()
函数模拟一些计算。
由于过程是异步的,结果不会立即准备好。为了等待结果,我们可以使用我们已经在 第六章 中讨论过的 await()
函数,线程和协程:
runBlocking {
val value = valueAsync()
println(value.await())
}
确保您始终通过调用 complete()
或 completeExceptionally()
函数之一来完成您的 Deferred
值非常重要。否则,您的程序可能会无限期地等待结果。如果您不再对 deferred
的结果感兴趣,也可以取消它。为此,只需调用其上的 cancel()
即可:
deferred.cancel()
您很少需要创建自己的 deferred 值。通常,您会使用 async()
函数返回的那个。
接下来,让我们讨论如何一次性等待多个异步结果。
屏障
屏障设计模式为我们提供了在进一步操作之前等待多个并发任务完成的机制。这种用法的一个常见场景是从不同的来源组合对象。
例如,考虑以下类:
data class FavoriteCharacter(
val name: String,
val catchphrase: String,
val picture: ByteArray = Random.nextBytes(42)
)
假设 catchphrase
数据来自一个服务,而 picture
数据来自另一个服务。我们希望并发获取这两份数据:
fun CoroutineScope.getCatchphraseAsync
(
characterName: String
) = async { … }
fun CoroutineScope.getPicture
(
characterName: String
) = async { … }
实现并发获取的最基本方式如下:
suspend fun fetchFavoriteCharacter(name: String) = coroutineScope {
val catchphrase = getCatchphraseAsync(name).await()
val picture = getPicture(name).await()
FavoriteCharacter(name, catchphrase, picture)
}
但这个解决方案有一个主要问题——我们只有在 catchphrase
数据被获取之后才开始获取 picture
数据。换句话说,代码是不必要地 顺序的。让我们看看如何改进这一点。
使用数据类作为屏障
我们可以稍微修改之前的代码,以实现我们想要的并发:
suspend fun fetchFavoriteCharacter(name: String) = coroutineScope {
val catchphrase = getCatchphraseAsync(name)
val picture = getPicture(name)
FavoriteCharacter(name, catchphrase.await(), picture.await())
}
将 await
函数移动到数据类构造函数的调用中,使我们能够同时启动所有协程,然后等待它们完成,正如我们想要的。
使用数据类作为屏障的额外好处是能够轻松地进行 解构:
val (name, catchphrase, _) = fetchFavoriteCharacter("Inigo Montoya")
println("$name says: $catchphrase")
如果我们从不同的异步任务中接收到的数据类型是异构的,这效果很好。在某些情况下,我们从不同的来源接收相同类型的数据。
例如,让我们询问 Michael
(我们的金丝雀产品所有者),Taylor
(我们的咖啡师),以及 Me
我们最喜欢的电影角色是谁:
object Michael {
suspend fun getFavoriteCharacter() = coroutineScope {
async {
FavoriteCharacter("Terminator",
"Hasta la vista, baby")
}
}
}
object Taylor {
suspend fun getFavoriteCharacter() = coroutineScope {
async {
FavoriteCharacter("Don Vito Corleone", "I'm
going to make him an offer he can't refuse")
}
}
}
object Me {
suspend fun getFavoriteCharacter() = coroutineScope {
async {
// I already prepared the answer!
FavoriteCharacter("Inigo Montoya", "Hello, my name is...")
}
}
}
在这里,我们有三个非常相似的对象,它们之间的区别仅在于它们返回的异步结果的内容。
在这种情况下,我们可以使用一个列表来收集结果:
val characters: List<Deferred<FavoriteCharacter>> = listOf(
Me.getFavoriteCharacter(),
Taylor.getFavoriteCharacter(),
Michael.getFavoriteCharacter(),
)
注意列表的类型。它是一个包含 FavoriteCharacter
类型 Deferred
元素的集合。在这样的集合上,有一个可用的 awaitAll()
函数,它也充当一个屏障:
println(characters.awaitAll())
当处理一组同构的异步结果,并且需要在进一步操作之前等待所有结果完成时,请使用 awaitAll()
。
屏障设计模式为多个异步任务创建了一个 rendezvous 点。下一个模式将帮助我们抽象这些任务的执行。
调度器
调度器 设计模式的目标是将 做什么 与 怎么做 解耦,并在这样做时优化资源的使用。
在 Kotlin 中,分发器 是调度器设计模式的一种实现,它将协程(即,做什么)与底层的线程池(即,怎么做)解耦。
我们已经在 第六章,线程和协程 中简要介绍了分发器。
为了提醒你,协程构建器如 launch()
和 async()
可以指定要使用哪个分发器。以下是如何明确指定它的一个示例:
runBlocking {
// This will use the Dispatcher from the parent
// coroutine
launch {
// Prints: main
println(Thread.currentThread().name)
}
launch(Dispatchers.Default) {
// Prints DefaultDispatcher-worker-1
println(Thread.currentThread().name)
}
}
默认分发器会根据底层线程池中的 CPU 数量创建线程。你还可以使用另一个分发器,即 IO 分发器:
async(Dispatchers.IO) {
for (i in 1..1000) {
println(Thread.currentThread().name)
yield()
}
}
这将输出以下内容:
> …
> DefaultDispatcher-worker-2
> DefaultDispatcher-worker-1
> DefaultDispatcher-worker-1
> DefaultDispatcher-worker-1
> DefaultDispatcher-worker-3
> DefaultDispatcher-worker-3
> ...
IO 分发器用于可能运行时间较长或阻塞的操作,并将为此创建多达 64 个线程。由于我们的示例代码没有做太多,IO 分发器不需要创建很多线程。这就是为什么你在这个例子中只会看到少量工作者。
创建自己的调度器
我们不仅限于 Kotlin 提供的分发器。我们还可以定义自己的分发器。
下面是一个创建分发器的例子,该分发器将使用基于 ForkJoinPool
的专用线程池,其中包含 4
个线程,这对于 分而治之 任务是高效的:
val forkJoinPool = ForkJoinPool(4).asCoroutineDispatcher()
repeat(1000) {
launch(forkJoinPool) {
println(Thread.currentThread().name)
}
}
如果你创建了自己的分发器,请确保你使用 close()
释放它或者重用它,因为创建新的分发器并持有它会在资源方面造成开销。
管道
管道 设计模式允许我们通过将工作分解成更小的、并发的部分来扩展异构工作,这些工作由多个具有不同复杂性的步骤组成,跨越多个 CPU。让我们通过以下示例来更好地理解它。
回到 第四章,熟悉行为模式,我们编写了一个 HTML 页面解析器。假设 HTML 页面本身已经被我们获取了。我们现在想要设计一个可能无限生成页面的过程。
首先,我们希望偶尔抓取新闻页面。为此,我们将有一个生产者:
fun CoroutineScope.producePages() = produce {
fun getPages(): List<String> {
// This should actually fetch something
return listOf(
"<html><body><h1>
Cool stuff</h1></body></html>",
"<html><body><h1>
Even more stuff</h1></body></html>"
)
}
val pages = getPages()
while (this.isActive) {
for (p in pages) {
send(p)
}
}
}
isActive
标志将在协程运行且未被取消的情况下为真。在可能运行时间较长的循环中检查这个属性是一个好习惯,这样它们就可以在迭代之间停止,如果需要的话。
每次我们收到新的标题时,我们会将它们发送到下游。由于技术新闻更新并不频繁,我们可以通过使用 delay()
来偶尔检查更新。在实际代码中,延迟可能是几分钟,甚至几小时。
下一步是创建一个由包含 HTML 的原始字符串组成的 文档对象模型(DOM)。为此,我们将有一个第二个生产者,这个生产者接收一个连接到第一个生产者的通道:
fun CoroutineScope.produceDom(pages: ReceiveChannel<String>) = produce {
fun parseDom(page: String): Document {
// In reality this would use a DOM library to parse
// string to DOM
return Document(page)
}
for (p in pages) {
send(parseDom(p))
}
}
只要通道仍然打开,我们就可以使用for
循环遍历通道。这是一种非常优雅地从异步源消费数据的方式,无需定义回调函数。
我们将有一个第三个函数,它接收解析后的文档并从每个文档中提取标题:
fun CoroutineScope.produceTitles(parsedPages: ReceiveChannel<Document>) = produce {
fun getTitles(dom: Document): List<String> {
return dom.getElementsByTagName("h1").map {
it.toString()
}
}
for (page in parsedPages) {
for (t in getTitles(page)) {
send(t)
}
}
}
我们正在寻找标题,所以我们使用getElementsByTagName("H1")
。对于找到的每个标题,我们将其转换为字符串表示形式。
现在,我们将继续将我们的协程组合成管道。
构建管道
现在我们已经熟悉了管道的组件,让我们看看如何将多个组件组合在一起:
runBlocking {
val pagesProducer = producePages()
val domProducer = produceDom(pagesProducer)
val titleProducer = produceTitles(domProducer)
titleProducer.consumeEach {
println(it)
}
}
生成的管道将如下所示:
Input=>pagesProducer=>domProducer=>titleProducer=>Output
管道是将一个长过程分解成更小步骤的绝佳方式。请注意,每个生成的协程都是一个纯函数,因此它也易于测试和推理。
可以通过在第一协程上调用cancel()
来停止整个管道。
Fan Out
Fan Out 设计模式的目的是在多个并发处理器之间分配工作,也称为工作者。为了更好地理解它,让我们再次查看前面的部分,但考虑以下问题:
如果我们在管道中不同步骤的工作量非常不同怎么办?
例如,获取 HTML 内容比解析它花费的时间要多得多。在这种情况下,我们可能希望将这项繁重的工作分配给多个协程。在先前的例子中,只有一个协程从每个通道读取。但多个协程也可以从单个通道消费,从而分担工作。
为了简化我们即将讨论的问题,让我们只有一个协程产生一些结果:
fun CoroutineScope.generateWork() = produce {
for (i in 1..10_000) {
send("page$i")
}
close()
}
我们将有一个函数来创建一个新的协程,该协程读取这些结果:
fun CoroutineScope.doWork(
id: Int,
channel: ReceiveChannel<String>
) = launch(Dispatchers.Default) {
for (p in channel) {
println("Worker $id processed $p")
}
}
这个函数将生成一个在Default
调度器上执行的协程。每个协程将监听一个通道,并将接收到的每条消息打印到控制台。
现在,让我们启动我们的生产者。记住,所有以下代码片段都需要包裹在runBlocking
函数中,但为了简单起见,我们省略了这部分:
val workChannel = generateWork()
然后,我们可以创建多个工作者,他们通过从相同的通道读取来相互分配工作:
val workers = List(10) { id ->
doWork(id, workChannel)
}
现在让我们检查这个程序的输出的一部分:
> ...
> Worker 4 processed page9994
> Worker 8 processed page9993
> Worker 3 processed page9992
> Worker 6 processed page9987
注意,没有两个工作进程收到相同的信息,并且消息的打印顺序与发送顺序不同。Fan Out 设计模式允许我们高效地将工作分配给多个协程、线程和 CPU。
接下来,让我们讨论一个与 Fan Out 经常一起出现的伴随设计模式。
Fan In
Fan In 设计模式的目的是将多个工作者的结果合并起来。当我们的工作者产生结果而我们又需要收集它们时,这个设计模式非常有用。
这种设计模式是我们在上一节中讨论的扇出设计模式的对立面。不是多个协程从同一个通道中 读取,而是多个协程可以将它们的结果 写入 到同一个通道。
结合扇出和扇入设计模式是 MapReduce 算法的好基础。为了演示这一点,我们将对前一个例子中的工作进程进行轻微的修改,如下所示:
private fun CoroutineScope.doWorkAsync(
channel: ReceiveChannel<String>,
resultChannel: Channel<String>
) = async(Dispatchers.Default) {
for (p in channel) {
resultChannel.send(p.repeat(2))
}
}
现在,一旦完成,每个工作进程将其计算结果发送到 resultChannel
。
注意,这种模式与我们之前看到的演员和生产者构建器不同。每个演员都有自己的通道,而在这个例子中,resultChannel
是所有工作进程共享的。
为了收集来自工作进程的结果,我们将使用以下代码:
runBlocking {
val workChannel = generateWork()
val resultChannel = Channel<String>()
val workers = List(10) {
doWorkAsync(workChannel, resultChannel)
}
resultChannel.consumeEach {
println(it)
}
}
让我们现在明确一下这段代码的作用:
-
首先,我们创建
resultChannel
,所有我们的工作进程都将共享。 -
然后,我们将它提供给每个工作进程。我们总共有十个工作进程。每个工作进程将其接收到的消息重复两次,并将其发送到
resultChannel
。 -
最后,我们在主协程中从通道中消费结果。这样,我们就可以在同一个地方累积来自多个并发工作进程的结果。
这是前面代码的输出样本:
> ...
> page9995page9995
> page9996page9996
> page9997page9997
> page9999page9999
> page9998page9998
> page10000page10000
接下来,让我们讨论另一种设计模式,它将有助于我们在某些情况下提高代码的响应性。
竞赛
竞赛是一种并发运行多个作业的设计模式,选择第一个返回的结果作为 赢家,并将其他作为 输家。
我们可以使用 Kotlin 中的 select()
函数在通道上实现竞赛。
让我们想象你正在构建一个天气应用程序。为了冗余,你从两个不同的来源获取天气,Precise Weather 和 Weather Today。我们将它们描述为两个返回其名称和温度的生产者。
如果我们有多个生产者,我们可以订阅它们的通道,并获取第一个可用的结果。
首先,让我们声明两个天气生产者:
fun CoroutineScope.preciseWeather() = produce {
delay(Random.nextLong(100))
send("Precise Weather" to "+25c")
}
fun CoroutineScope.weatherToday() = produce {
delay(Random.nextLong(100))
send("Weather Today" to "+24c")
}
它们的逻辑几乎相同。两者都等待随机的毫秒数,然后返回温度读数和来源名称。
我们可以使用 select
表达式同时监听两个通道:
runBlocking {
val winner = select<Pair<String, String>> {
preciseWeather().onReceive { preciseWeatherResult ->
preciseWeatherResult
}
weatherToday().onReceive { weatherTodayResult ->
weatherTodayResult
}
}
println(winner)
}
使用 onReceive()
函数允许我们同时监听多个通道。
运行此代码多次将随机打印 (Precise Weather, +25c)
和 (Weather Today, +24c)
,因为它们到达第一个的机会是均等的。
竞赛是一个非常有用的概念,当你愿意牺牲资源以从你的系统中获得最大的响应性时,我们就使用 Kotlin 的 select
表达式实现了这一点。现在,让我们进一步探索 select
表达式,以发现它实现的另一个并发设计模式。
无偏选择
当使用 select
子句时,顺序很重要。因为它固有的偏差,如果两个事件同时发生,它将选择第一个子句。
让我们看看以下示例中的含义。
这次我们只有一个生产者,它通过通道发送我们应该观看的下一部电影:
fun CoroutineScope.fastProducer(
movieName: String
) = produce(capacity = 1) {
send(movieName)
}
由于我们在通道上定义了非零容量,值将在这个协程运行时立即可用。
现在,让我们启动两个生产者,并使用select
表达式来查看哪部电影将被选中:
runBlocking {
val firstOption = fastProducer("Quick&Angry 7")
val secondOption = fastProducer(
"Revengers: Penultimatum")
delay(10)
val movieToWatch = select<String> {
firstOption.onReceive { it }
secondOption.onReceive { it }
}
println(movieToWatch)
}
无论你运行此代码多少次,获胜者总是相同的:Quick&Angry 7
。这是因为如果两个值同时准备好,select
子句总是会选择它们声明的第一个通道。
现在,让我们使用selectUnbiased
而不是select
子句:
...
val movieToWatch = selectUnbiased<String> {
firstOption.onReceive { it }
secondOption.onReceive { it }
}
...
现在运行此代码有时会产生Quick&Angry 7
,有时会产生Revengers: Penultimatum
。与常规的select
子句不同,selectUnbiased
不关心顺序。如果有多个结果可用,它将随机选择一个。
互斥锁
也称为互斥,互斥锁提供了一种保护共享状态的方法,该状态可以被多个协程同时访问。
让我们从那个古老的令人讨厌的counter
例子开始,其中多个并发任务尝试更新同一个counter
:
var counter = 0
val jobs = List(10) {
async(Dispatchers.Default) {
repeat(1000) {
counter++
}
}
}
jobs.awaitAll()
println(counter)
如你可能猜到的,打印出的结果小于 10,000 – 完全尴尬!
为了解决这个问题,我们可以引入一个锁定机制,它将允许只有一个协程一次与变量交互,使操作原子化。
每个协程都会尝试获取counter
的所有权。如果另一个协程正在更新counter
,我们的协程将耐心等待,然后再次尝试获取锁。一旦更新完成,它必须释放锁,以便其他协程可以继续:
var counter = 0
val mutex = Mutex()
val jobs = List(10) {
launch {
repeat(1000) {
mutex.lock()
counter++
mutex.unlock()
}
}
}
现在,我们的例子总是打印出正确的数字:10,000
。
Kotlin 中的互斥锁与 Java 中的互斥锁不同。在 Java 中,互斥锁上的lock()
会阻塞线程,直到锁可以被获取。而 Kotlin 互斥锁会挂起协程,从而提供更好的并发性。Kotlin 中的锁更便宜。
这对于简单情况很好。但如果在临界区(即lock()
和unlock()
之间)的代码抛出异常怎么办?
我们不得不将我们的代码包裹在try...catch
中,这并不方便:
try {
mutex.lock()
counter++
}
finally {
mutex.unlock()
}
然而,如果我们省略了finally
块,我们的锁将永远不会释放,这将阻止所有其他协程继续进行并导致死锁。
正是为了这个目的,Kotlin 还引入了withLock()
:
mutex.withLock {
counter++
}
注意,与之前的例子相比,这个语法要简洁得多。
Sidekick channel
Sidekick channel设计模式允许我们将一些工作从主工作线程卸载到后台工作线程。
到目前为止,我们只讨论了将select
用作接收者的使用。但我们也可以使用select
将项目发送到另一个通道。让我们看看以下示例。
首先,我们将batman
声明为一个处理每秒 10 条消息的 actor 协程:
val batman = actor<String> {
for (c in channel) {
println("Batman is beating some sense into $c")
delay(100)
}
}
接下来,我们将声明 robin
作为另一个协程演员,它稍微慢一些,每秒只处理四条消息:
val robin = actor<String> {
for (c in channel) {
println("Robin is beating some sense into $c")
delay(250)
}
}
因此,我们有两个演员:一个超级英雄和他的助手。由于超级英雄更有经验,他通常需要更少的时间来击败他面对的反派。
但在某些情况下,他仍然手头很忙,所以需要一个助手介入。我们将向这对组合投掷五个反派,并观察他们的表现:
val epicFight = launch {
for (villain in listOf("Jocker", "Bane", "Penguin", "Riddler", "Killer Croc")) {
val result = select<Pair<String, String>> {
batman.onSend(villain) {
"Batman" to villain
}
robin.onSend(villain) {
"Robin" to villain
}
}
delay(90)
println(result)
}
}
注意,select
表达式的参数类型指的是从块中返回的内容,而不是被发送到通道的内容。这就是我们在这里使用 Pair<String, String>
的原因。
这段代码打印以下内容:
> Batman is beating some sense into Jocker
> (Batman, Jocker)
> Robin is beating some sense into Bane
> (Robin, Bane)
> Batman is beating some sense into Penguin
> (Batman, Penguin)
> Batman is beating some sense into Riddler
> (Batman, Riddler)
> Robin is beating some sense into Killer Croc
> (Robin, Killer Croc)
使用助手通道是一种有用的技术,可以提供回退值。考虑在需要消费一致数据流且无法轻松扩展消费者的情况下使用它。
摘要
在本章中,我们介绍了与 Kotlin 中并发相关的各种设计模式。大多数模式都是基于协程、通道、延迟值或这些构建块的组合。
延迟值用作异步值的占位符。屏障设计模式允许多个异步任务在继续之前进行会合。调度器设计模式将任务代码与其在运行时执行的方式解耦。
管道、扇入和扇出设计模式帮助我们分配工作并收集结果。互斥锁帮助我们控制同时执行的任务数量。竞态设计模式允许我们提高应用程序的响应性。最后,助手通道设计模式在主任务无法快速处理传入事件时,将工作卸载到备用任务上。
所有这些模式都应该帮助你以高效和可扩展的方式管理应用程序的并发。在下一章中,我们将讨论 Kotlin 的惯用用法和最佳实践,以及随着语言出现的某些反模式。
问题
-
当我们说 Kotlin 中的
select
表达式是有偏好的时,这是什么意思? -
你应该在什么情况下使用互斥锁而不是通道?
-
哪些并发设计模式可以帮助你有效地实现 MapReduce 或分而治之算法?
第三部分:设计模式的实际应用
在本节中,您将应用您对设计模式的新知识来实现一个真实世界的应用程序,并学习一些最佳实践和反模式。
本节首先介绍在开发使用 Kotlin 的应用程序时应该遵循的最佳实践和应避免的事项。然后,在接下来的两个章节中,我们将构建两个微服务,首先使用名为 Ktor 的并发框架,最后在最后一章中使用名为 Vert.x 的响应式框架。
我们还将利用这个机会来检查我们在前几章中看到的设计模式如何在真实世界的应用中发挥作用。
本节包含以下章节:
-
第九章,惯用语句和反模式
-
第十章,使用 Ktor 的并发微服务
-
第十一章,使用 Vert.x 的响应式微服务
第九章:第九章:惯用和反模式
在前面的章节中,我们讨论了 Kotlin 编程语言的不同方面、函数式编程的好处以及并发设计模式。
本章讨论了 Kotlin 的最佳和最差实践。你将了解惯用的 Kotlin 代码应该是什么样子,以及哪些模式应该避免。本章包含了一个涵盖这些不同主题的最佳实践集合。
在本章中,我们将涵盖以下主题:
-
使用作用域函数
-
类型检查和转换
-
try-with-resources 语句的替代方案
-
内联函数
-
实现代数数据类型
-
重新泛型
-
高效使用常量
-
构造函数重载
-
处理 null 值
-
明确异步性
-
验证输入
-
优先使用密封类而不是枚举
完成本章后,你应该能够编写更易于阅读和维护的 Kotlin 代码,以及避免一些常见的陷阱。
技术要求
除了前几章的要求之外,你还需要一个Gradle启用的Kotlin项目,以便能够添加所需的依赖项。
你可以在这里找到本章的源代码:github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter09
。
使用作用域函数
Kotlin 有作用域函数的概念,这些函数在任何对象上都是可用的,可以替代编写重复代码的需要。除了其他好处之外,这些作用域函数帮助我们简化单表达式函数。它们被认为是高阶函数,因为每个作用域函数都接收一个 lambda 表达式作为参数。在本节中,我们将讨论所有必要的函数,并使用对象作为它们的作用域来执行它们的代码块。在本节中,我们将交替使用作用域和上下文对象来描述这些函数操作的对象。
令函数
我们可以使用let()
函数在可空对象上调用一个函数,但前提是这个对象不是 null。
让我们以以下引言地图(我们在第一章,Kotlin 入门)为例:
val clintEastwoodQuotes = mapOf(
"The Good, The Bad, The Ugly" to "Every gun makes its own tune.",
"A Fistful Of Dollars" to "My mistake: four coffins."
)
现在,让我们从一个可能不在集合中的电影中获取一个引言并打印它,但前提是它不是 null:
val quote = clintEastwoodQuotes["Unforgiven"]
if (quote != null) {
println(quote)
}
同样的代码可以使用let
作用域函数重写:
clintEastwoodQuotes["Unforgiven"]?.let {
println(it)
}
一个常见的错误是忘记在let
之前使用安全导航操作符,因为let()
本身也可以在 null 上工作:
clintEastwoodQuotes["Unforgiven"].let {
println(it)
}
这段代码将在控制台打印null
。确保在使用let()
进行 null 检查时不要忘记问号(?
)。
Apply 函数
我们在前面章节中讨论了apply()
。它返回它操作的对象,并将上下文设置为this
。如果你需要初始化一个可变对象,可以使用apply()
。
想想你有多少次不得不创建一个具有空构造函数的类,然后依次调用很多设置器。以下是一个例子,这可能是一个来自库的类。例如:
class JamesBond {
lateinit var name: String
lateinit var movie: String
lateinit var alsoStarring: String
}
当我们需要创建此类的新实例时,我们可以以过程式的方式这样做:
val agent = JamesBond()
agent.name = "Sean Connery"
agent.movie = "Dr. No"
或者,我们可以只设置 name
和 movie
,并使用 apply()
函数将 alsoStarring
留空:
val `007` = JamesBond().apply {
this.name = "Sean Connery"
this.movie = "Dr. No"
}
println(`007`.name)
由于代码块的范围被设置为 this
,我们可以进一步简化前面的代码:
val `007` = JamesBond().apply {
name = "Sean Connery"
movie = "Dr. No"
}
使用 apply()
函数在处理通常有很多设置器和默认空构造函数的 Java 类时特别有用。
also
函数
正如我们在本节介绍的引言中提到的,单表达式函数非常简洁。让我们看看以下简单的函数,它乘以两个数字:
fun multiply(a: Int, b: Int): Int = a * b
但通常,你有一个单语句函数,还需要执行其他操作,例如写入日志或产生其他副作用。为了实现这一点,我们可以将我们的函数重写如下:
fun multiply(a: Int, b: Int): Int {
val c = a * b
println(c)
return c
}
我们不得不在这里使我们的函数更加冗长,并引入另一个变量。让我们看看我们如何使用 also()
函数来替代:
fun multiply(a: Int, b: Int): Int =
(a * b).also { println(it) }
此函数将表达式的结果分配给 it
并返回表达式的结果。also()
函数在你想在一系列调用中产生副作用时也非常有用:
val l = (1..100).toList()
l.filter{ it % 2 == 0 }
// Prints, but doesn't mutate the collection
.also { println(it) }
.map { it * it }
在这里,你可以看到我们可以继续使用 map()
函数来继续我们的调用链,即使我们使用了 also()
函数来打印列表中的每个元素。
运行函数
run()
函数与 let()
函数非常相似,但它将代码块的范围设置为 this
而不是使用 it
。
让我们通过一个例子来更好地理解这一点:
val justAString = "string"
val n = justAString.run {
this.length
}
在这个例子中,this
被设置为引用 justAString
变量。
通常,this
可以省略,所以代码将如下所示:
val n = justAString.run {
length
}
run()
函数主要用于初始化对象,就像我们之前讨论的 apply()
函数一样。然而,与 apply()
不同,它返回对象本身,而不是返回某些计算的结果:
val lowerCaseName = JamesBond().run {
name = "ROGER MOORE"
movie = "THE MAN WITH THE GOLDEN GUN"
name.toLowerCase() // <= Not JamesBond type
}
println(lowerCaseName)
上述代码打印以下输出:
> roger moore
在这里,对象使用 "ROGER MOORE"
初始化。注意,在这里,我们对 JamesBond
对象进行了操作,但我们的返回值是一个 String
。
使用函数
与其他四个作用域函数不同,with()
不是一个扩展函数。这意味着你不能这样做:
"scope".with { ... }
相反,with()
接收你想要作用域的对象作为参数:
with("scope") {
println(this.length) // "this" set to the argument of // with()
}
并且,像往常一样,我们可以省略 this
:
with("scope") {
length
}
就像 run()
和 let()
一样,你可以从 with()
中返回任何结果。
在本节中,我们学习了各种作用域函数如何通过定义要在对象上执行的代码块来帮助减少样板代码的数量。在下一节中,我们将看到 Kotlin 还允许我们比其他语言编写更少的实例检查。
类型检查和转换
在编写代码时,你可能会倾向于检查你的对象类型,使用is
,然后使用as
进行转换。作为一个例子,让我们想象我们正在构建一个超级英雄系统。每个超级英雄都有自己的方法集:
interface Superhero
class Batman : Superhero {
fun callRobin() {
println("To the Bat-pole, Robin!")
}
}
class Superman : Superhero {
fun fly() {
println("Up, up and away!")
}
}
此外,还有一个函数,超级英雄试图施展他们的超能力:
fun doCoolStuff(s: Superhero) {
if (s is Superman) {
(s as Superman).fly()
}
else if (s is Batman) {
(a as Batman).callRobin()
}
}
但正如你所知,Kotlin 有智能转换,所以在这种情况下,隐式转换不是必需的。让我们使用智能转换重写这个函数,看看它们如何改进我们的代码。我们只需要从我们的代码中移除显式转换:
fun doCoolStuff(s: Superhero) {
if (s is Superman) {
s.fly()
}
else if (s is Batman) {
s.callRobin()
}
}
此外,在大多数情况下,使用when()
进行智能转换会产生更干净的代码:
fun doCoolStuff(s : Superhero) {
when(s) {
is Superman -> s.fly()
is Batman -> s.callRobin()
else -> println("Unknown superhero")
}
}
作为一条经验法则,你应该避免使用转换,并尽可能多地依赖智能转换:
// Superhero is clearly not a string
val superheroAsString = (s as String)
但如果你绝对必须,还有一个安全转换运算符:
val superheroAsString = (s as? String)
安全转换运算符如果对象无法转换,将返回 null,而不是抛出异常。
try-with-resources 语句的替代方案
AutoCloseable
和 try-with-resources 语句。
这个声明使我们能够提供一组资源,一旦代码使用完毕,这些资源将被自动关闭。因此,将不再有忘记关闭文件的风险(或者至少风险更小)。
在 Java 7 之前,这完全是混乱的,如下面的代码所示:
BufferedReader br = null; // Nulls are bad, we know that
try {
br = new BufferedReader(new FileReader
("./src/main/kotlin/7_TryWithResource.kt "));
System.out.println(br.readLine());
}
finally {
if (br != null) { // Explicit check
br.close(); // Boilerplate
}
}
在 Java 7 发布后,前面的代码可以写成以下形式:
try (BufferedReader br = new BufferedReader(new FileReader("/some/path"))) {
System.out.println(br.readLine());
}
Kotlin 不支持这种语法。相反,try-with-resource 语句被use()
函数所取代:
val br = BufferedReader(FileReader("./src/main /kotlin/7_TryWithResource.kt"))
br.use {
println(it.readLines())
}
对象必须实现Closeable
接口,use()
函数才可用。Closeable
对象将在我们退出use{}
块时关闭。
内联函数
你可以将inline
函数视为编译器复制和粘贴你的代码的指令。每次编译器看到标记为inline
关键字的函数调用时,它都会将调用替换为具体的函数体。
如果它是一个接受 lambda 作为其参数之一的高阶函数,那么使用inline
函数是有意义的。这是你想要使用inline
的最常见用例。
让我们看看这样的高阶函数,看看编译器将输出什么伪代码。
首先,这是函数定义:
inline fun logBeforeAfter(block: () -> String) {
println("Before")
println(block())
println("After")
}
在这里,我们向函数传递一个 lambda,或一个block
。这个block
简单地返回单词"Inlining"
作为一个String
:
logBeforeAfter {
"Inlining"
}
如果你查看反编译的字节码的 Java 等效版本,你会看到根本就没有调用我们的makesSense
函数。相反,你会看到以下内容:
String var1 = "Before"; <- Inline function call
System.out.println(var1);
var1 = "Inlining";
System.out.println(var1);
var1 = "After";
System.out.println(var1);
由于inline
函数是代码的复制/粘贴,如果你有超过几行代码,就不应该使用它。将其作为常规函数会更有效率。但如果你有接受 lambda 作为参数的单表达式函数,使用inline
关键字来优化性能是有意义的。最终,这是应用程序大小和性能之间的权衡。
实现代数数据类型
代数数据类型,或简称 ADTs,是函数式编程中的一个概念,它与我们在 第三章 理解结构模式 中讨论的 组合设计模式 非常相似。
为了理解 ADTs 的工作原理以及它们的优点,让我们讨论如何在 Kotlin 中实现一个简单的二叉树。
首先,让我们为我们的树声明一个接口。由于树数据结构可以包含任何类型的数据,我们可以用类型(T
)来参数化它:
sealed interface Tree<out T>
类型用 out
关键字标记,这意味着这个类型是 协变的。如果你不熟悉这个术语,我们将在实现接口时再讨论它。
协变的对立面是 逆变的。逆变类型应该使用 in
关键字标记。
我们还可以用 sealed
关键字标记这个接口。我们在 第四章 熟悉行为模式 中讨论了将此关键字应用于常规类,而 sealed
接口是一个相对较新的特性,它是在 Kotlin 1.5 中引入的。
意义是相同的:只有接口的所有者才能实现它。这意味着在编译时就可以知道接口的所有实现。
接下来,让我们声明一个空树看起来像什么:
object Empty : Tree<Nothing> {
override fun toString() = "Empty"
}
由于所有空树都是相同的,我们将其声明为一个对象。这是 Nothing
作为空树类型的另一个用途。这是 Kotlin 对象层次结构中的一个特殊类。
重要提示:
在 Any
和 Nothing
之间存在一些混淆,Any
代表任何类,类似于 Java 中的 Object
,而 Nothing
代表没有类。我们将在本章后面看到为什么 Any
在这个情况下不起作用。
接下来,让我们定义一个非空树节点:
data class Node<T>(
val value: T,
val left: Tree<T> = Empty,
val right: Tree<T> = Empty
) : Tree<T>
Node
也实现了 Tree
接口,但它是一个数据类,而不是一个对象,因为每个节点都是不同的。Node
的值类型是 T
,这意味着它可以包含任何类型的值,但同一树中的所有节点都将包含相同类型的值。这是泛型真正的力量。
节点也有两个子节点,左和右,因为它是一个二叉树。默认情况下,它们都是空的。
由于类型是协变的,并且 Empty
是 Nothing
类型,我们可以指定节点子节点的默认值。Nothing
在类层次结构的底部,而 Any
在顶部。
当我们声明 Tree
的类型为 out T
时,我们的意思是 Tree
可以包含类型 T
的值或继承自该类型的任何值。
由于 Nothing
在类层次结构的底部,它 继承 自所有类型。
现在一切都已经设置好了,让我们学习如何创建我们刚刚定义的树的实例:
val tree = Node(
1,
Empty,
Node(
2,
Node(3)
)
)
println(tree)
在这里,我们创建了一个以 1 为根节点值和具有 2 值的右节点的树。右节点有一个值为 3 的左子节点。这就是我们的树看起来像什么:
图 9.1 – 树形图
上述代码输出以下内容:
> Node(value=1, left=Empty, right=Node(value=2, left=Node(value=3, left=Empty, right=Empty), right=Empty))
然而,以这种形式打印树并不很有趣。所以,让我们实现一个函数,如果树是数值型的,它会总结树的所有节点:
fun Tree<Int>.sum(): Long = when (this) {
Empty -> 0
is Node -> value + left.sum() + right.sum()
}
这也称为 ADT 上的操作。这是一个仅在包含整数的树上声明的扩展函数。
对于每个节点,我们检查它是否是Empty
或Node
。这就是sealed
类和接口的美丽之处。由于编译器知道Tree
接口恰好有两个实现,因此我们不需要在when
表达式中使用else
块。
如果它是一个Empty
节点,我们使用0
作为中性值。如果它不为空,那么我们将它的值与其左右子节点的值相加。
这个函数也是递归算法的另一个例子,我们在第五章,“介绍函数式编程”中讨论过。
现在,让我们讨论与 Kotlin 中的泛型相关的话题。
实化泛型
在本章前面,我们提到了inline
函数。由于inline
函数被复制,我们可以消除 JVM 的一个主要限制:类型擦除。毕竟,在函数内部,我们知道我们得到的确切类型。
让我们看看以下示例。我们希望创建一个泛型函数,该函数将接收一个Number
(Number
可以是Int
或Long
),但只有当它的类型与函数类型相同时才会打印它。
我们将从一种简单的实现开始,直接尝试在类型上执行实例检查:
fun <T> printIfSameType(a: Number) {
if (a is T) { // <== Error
println(a)
}
}
然而,这段代码无法编译,我们会得到以下错误:
> Cannot check for instance of erased type: T
在这种情况下,我们通常在 Java 中传递类作为参数。我们可以在 Kotlin 中尝试类似的方法。如果你之前使用过 Android,你会立即认出这个模式,因为它在标准库中用得很多:
fun <T : Number> printIfSameType(clazz: KClass<T>, a:
Number) {
if (clazz.isInstance(a)) {
println("Yes")
} else {
println("No")
}
}
我们可以通过运行以下行来检查代码是否正确工作:
printIfSameType(Int::class, 1) // Prints yes, as 1 is Int
printIfSameType(Int::class, 2L) // Prints no, as 2 is Long
printIfSameType(Long::class, 3L) // Prints yes, as 3 is Long
这段代码可以工作,但有一些缺点:
-
我们不能使用
is
运算符,而必须使用isInstance()
函数。 -
我们必须传递正确的类;即
clazz: KClass<T>
。
这段代码可以通过使用reified
类型来改进:
inline fun <reified T : Number> printIfSameReified(a: Number) {
if (a is T) {
println("Yes")
} else {
println("No")
}
}
这个函数与上一个函数的工作方式相同,但不需要输入一个类即可工作。使用reified
类型的函数必须声明为inline
。这是由于 JVM 上的类型擦除。
我们可以通过以下方式测试我们的代码是否仍然按预期工作:
printIfSameReified<Int>(1) // Prints yes, as 1 is Int
printIfSameReified<Int>(2L) // Prints no, as 2 is Long
printIfSameReified<Long>(3L) // Prints yes, as 3 is Long
注意,现在,我们在尖括号中指定函数操作的类型,例如Int
或Long
,而不是将其作为参数传递给函数。使用reified
函数我们可以获得以下好处:
-
清晰的方法签名,无需传递一个类作为参数。
-
在函数内部使用
is
构造的能力。 -
它对类型推断友好,这意味着如果类型参数可以被编译器推断出来,它就可以完全省略。
当然,常规 inline
函数的相同规则也适用于这里。这段代码将被复制,所以它不应该太大。
现在,让我们考虑 reified
类型的另一个用例——函数重载。我们将尝试定义两个具有相同名称但操作类型不同的函数:
fun printList(list: List<Int>) {
println("This is a list of Ints")
println(list)
}
fun printList(list: List<Long>) {
println("This is a list of Longs")
println(list)
}
这将无法编译,因为存在平台声明冲突。它们在 JVM 方面具有相同的签名:printList(list: List)
。这是因为类型在编译过程中被擦除。
但使用 reified
,我们可以轻松实现这一点:
inline fun <reified T : Any> printList(list: List<T>) {
when {
1 is T -> println("This is a list of Ints")
1L is T -> println("This is a list of Longs")
else -> println("This is a list of something else")
}
println(list)
}
由于整个函数是内联的,我们可以检查列表的实际类型并输出正确的结果。
高效使用常量
由于 Java 中的所有内容都是对象(除非是原始类型),我们习惯于将所有常量作为静态成员放入我们的对象中。
由于 Kotlin 有 companion
对象,我们通常尝试将它们放在那里:
class Spock {
companion object {
val SENSE_OF_HUMOR = "None"
}
}
这将有效,但你应该记住,companion object
仍然是一个对象。
因此,这将大致转换为以下代码:
public class Spock {
private static final String SENSE_OF_HUMOR = "None";
public String getSENSE_OF_HUMOR() {
return Spock.SENSE_OF_HUMOR;
}
...
}
在这个例子中,Kotlin 编译器为我们的常量生成一个 getter,这增加了另一个间接层。
如果我们查看使用常量的代码,我们会看到以下内容:
String var0 = Spock.Companion.getSENSE_OF_HUMOR();
System.out.println(var0);
我们可以调用一个方法来获取常量值,这并不高效。
现在,让我们将此值标记为常量,看看编译器生成的代码如何变化:
class Spock {
companion object {
const val SENSE_OF_HUMOR = "None"
}
}
这里是字节码的变化:
public class Spock {
public static final String SENSE_OF_HUMOR = "None";
...
}
下面是这个调用的示例:
String var1 = "None";
System.out.println(var1);
注意,代码中已经没有 Spock
类的引用了。编译器已经为我们内联了它的值。毕竟,它是常量,所以它永远不会改变,可以安全地内联。
如果你只需要一个常量,你也可以在类外部设置它:
const val SPOCK_SENSE_OF_HUMOR = "NONE"
如果你需要命名空间,你可以将其包裹在一个对象中:
object SensesOfHumor {
const val SPOCK = "None"
}
现在我们已经学会了如何更有效地使用常量,让我们学习如何以惯用的方式处理构造函数。
构造函数重载
在 Java 中,我们习惯于使用重载构造函数。例如,让我们看看以下需要 a
参数并将 b
的值默认设置为 1
的 Java 类:
class User {
private final String name;
private final boolean resetPassword;
public User(String name) {
this(name, true);
}
public User(String name, boolean resetPassword) {
this.name = name;
this.resetPassword = resetPassword;
}
}
我们可以通过使用 constructor
关键字定义多个构造函数来在 Kotlin 中模拟相同的行为:
class User(val name: String, val resetPassword: Boolean) {
constructor(name: String) : this(name, true)
}
次构造函数,如类体中定义的,将调用主构造函数,为第二个参数提供默认值 1
。
然而,通常最好有默认参数值和命名参数:
class User(val name: String, val resetPassword: Boolean = true)
注意,所有次构造函数都必须使用 this
关键字委托给主构造函数。唯一的例外是当你有一个默认的主构造函数:
class User {
val resetPassword: Boolean
val name: String
constructor(name: String, resetPassword: Boolean =
true) {
this.name = name
this.resetPassword = resetPassword
}
}
接下来,让我们讨论如何在 Kotlin 代码中高效地处理空值。
处理空值
Kotlin 中的 null
;例如:
// Will return "String" half of the time and null the other
// half
val stringOrNull: String? = if (Random.nextBoolean())
"String" else null
// Java-way check
if (stringOrNull != null) {
println(stringOrNull.length)
}
我们可以使用 Elvis
操作符(?:
)重写此代码:
val alwaysLength = stringOrNull?.length ?: 0
如果长度不是 null
,这个操作符将返回其值。否则,它将返回我们提供的默认值,在这种情况下是 0
。
如果你有一个嵌套对象,你可以链式调用这些检查。例如,让我们有一个包含 Profile
的 Response
对象,而 Profile
又包含名字和姓氏字段,这些字段可能是可空的:
data class Response(
val profile: UserProfile?
)
data class UserProfile(
val firstName: String?,
val lastName: String?
)
这种链式调用将看起来像这样:
val response: Response? = Response(UserProfile(null, null))
println(response?.profile?.firstName?.length)
如果链中的任何字段为空,我们的代码不会崩溃。相反,它会打印 null
。
最后,你可以使用 let()
块来进行空检查,正如我们在 使用作用域函数 部分简要提到的。使用 let()
函数的相同代码将看起来像这样:
println(response?.let {
it.profile?.let {
it.firstName?.length
}
})
如果你想要在所有地方去掉 it
,你可以使用另一个作用域函数,run()
:
println(response?.run {
profile?.run {
firstName?.length
}
})
尽量避免在生产代码中使用不安全的 !!
空操作符:
println(json!!.User!!.firstName!!.length)
这将导致 KotlinNullPointerException
。然而,在测试期间,!!
操作符可能很有用,因为它可以帮助你更快地发现空安全的问题。
明确异步性
正如你在上一章中看到的,在 Kotlin 中创建异步函数非常简单。以下是一个示例:
fun CoroutineScope.getResult() = async {
delay(100)
"OK"
}
然而,这种异步性可能对函数的用户来说是一个意外的行为,因为他们可能期望一个简单的值。
你认为以下代码会打印什么?
println("${getResult()}")
对于用户来说,前面的代码有些意外地打印了以下内容而不是 "OK"
:
> Name: DeferredCoroutine{Active}@...
当然,如果你已经阅读了 第六章,线程和协程,你就会知道这里缺少的是 await()
函数:
println("${getResult().await()}")
但如果我们给我们的函数添加一个 async
后缀,那么它就会更加明显:
fun CoroutineScope.getResultAsync() = async {
delay(100)
"OK"
}
Kotlin 的约定是在函数名末尾添加 Async
来区分异步函数和常规函数。如果你在使用 IntelliJ IDEA,它甚至会建议你重命名它。
现在,让我们来谈谈一些用于验证用户输入的内置函数。
验证输入
输入验证是一项必要但非常繁琐的任务。你有多少次不得不编写如下代码?
fun setCapacity(cap: Int) {
if (cap < 0) {
throw IllegalArgumentException()
}
...
}
相反,你可以使用 require()
函数来检查参数:
fun setCapacity(cap: Int) {
require(cap > 0)
}
这使得代码更加流畅。你可以使用 require()
来检查空值:
fun printNameLength(p: Profile) {
require(p.firstName != null)
}
但也有 requireNotNull()
可以做到这一点:
fun printNameLength(p: Profile) {
requireNotNull(p.firstName)
}
使用 check()
来验证你对象的状态。这在提供用户可能没有正确设置的对象时非常有用:
class HttpClient {
var body: String? = null
var url: String = ""
fun postRequest() {
check(body != null) {
"Body must be set in POST requests"
}
}
}
再次提醒,这里也有一个快捷方式:checkNotNull()
。
require()
和 check()
函数之间的区别在于 require()
会抛出 IllegalArgumentException
,这意味着提供给函数的输入是错误的。另一方面,check()
会抛出 IllegalStateException
,这意味着对象的状态已被破坏。
考虑使用 require()
和 check()
函数来提高你代码的可读性。
最后,让我们讨论如何在 Kotlin 中有效地表示不同的状态。
优先考虑 sealed
类而不是枚举
来自 Java 的你可能会倾向于在 enum
上添加功能。
例如,假设你开发了一个允许用户订购披萨并跟踪其状态的程序。我们可以使用以下代码来完成这个任务:
// Java-like code that uses enum to represent State
enum class PizzaOrderStatus {
ORDER_RECEIVED, PIZZA_BEING_MADE, OUT_FOR_DELIVERY, COMPLETED;
fun nextStatus(): PizzaOrderStatus {
return when (this) {
ORDER_RECEIVED -> PIZZA_BEING_MADE
PIZZA_BEING_MADE -> OUT_FOR_DELIVERY
OUT_FOR_DELIVERY -> COMPLETED
COMPLETED -> COMPLETED
}
}
}
或者,你可以使用 sealed
类:
sealed class PizzaOrderStatus(protected val orderId: Int) {
abstract fun nextStatus(): PizzaOrderStatus
}
class OrderReceived(orderId: Int) :
PizzaOrderStatus(orderId) {
override fun nextStatus() = PizzaBeingMade(orderId)
}
class PizzaBeingMade(orderId: Int) :
PizzaOrderStatus(orderId) {
override fun nextStatus() = OutForDelivery(orderId)
}
class OutForDelivery(orderId: Int) :
PizzaOrderStatus(orderId) {
override fun nextStatus() = Completed(orderId)
}
class Completed(orderId: Int) : PizzaOrderStatus(orderId) {
override fun nextStatus() = this
}
在这里,我们为每个对象状态创建了一个单独的类,这些类扩展了 PizzaOrderStatus
sealed
类。
这种方法的优点是,我们现在可以更容易地存储状态及其 status
。在我们的例子中,我们可以存储订单的 ID:
var status: PizzaOrderStatus = OrderReceived(123)
while (status !is Completed) {
status = when (status) {
is OrderReceived -> status.nextStatus()
is PizzaBeingMade -> status.nextStatus()
is OutForDelivery -> status.nextStatus()
is Completed -> status
}
}
通常情况下,如果你想要与状态关联数据,sealed
类是好的选择,你应该优先考虑它们而不是枚举。
摘要
在本章中,我们回顾了 Kotlin 的最佳实践以及语言的一些注意事项。现在,你应该能够编写更符合语言习惯、性能良好且易于维护的代码。
在必要时,你应该使用作用域函数,但确保不要过度使用它们,因为它们可能会使代码变得混乱,尤其是对于那些刚开始使用该语言的人来说。
一定要正确处理空值和类型转换,使用 let()
、Elvis
操作符以及语言提供的智能转换功能。最后,泛型和 sealed
类和接口是描述不同类之间复杂关系和行为的有力工具。
在下一章中,我们将通过编写实际的微服务响应式设计模式来应用这些技能。
问题
-
Kotlin 中 Java 的 try-with-resources 的替代方案是什么?
-
Kotlin 中处理空值的不同选项有哪些?
-
重新实例化的泛型可以解决哪些问题?
第十章:第十章:使用 Ktor 的并发微服务
在上一章中,我们探讨了如何编写符合 Kotlin 习惯的代码,这些代码将易于阅读和维护,同时性能良好。
本章中,我们将通过构建一个使用 Ktor 框架的微服务来运用我们迄今为止学到的技能。我们还想让这个微服务是反应式的,并且尽可能接近现实生活。为此,我们将使用 Ktor 框架,其优点将在本章的第一节中列出。
本章中,我们将涵盖以下主题:
-
开始使用 Ktor
-
路由请求
-
测试服务
-
应用程序模块化
-
连接到数据库
-
创建新实体
-
使测试保持一致
-
获取实体
-
在 Ktor 中组织路由
-
在 Ktor 中实现并发
到本章结束时,您将拥有一个用 Kotlin 编写的微服务,该服务经过良好测试,可以从 PostgreSQL 数据库中读取数据并将其存储在其中。
技术要求
这是您开始所需的:
-
JDK 11 或更高版本
-
IntelliJ IDEA
-
Gradle 6.8 或更高版本
-
PostgreSQL 14 或更高版本
本章将假设您已经安装了 PostgreSQL
,并且您具备使用它的基本知识。如果您没有,请参阅官方文档:www.postgresql.org/docs/14/tutorial-install.html
。
您可以在此处找到本章的源代码:github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter10
。
开始使用 Ktor
你可能已经厌倦了创建待办事项或购物清单。
因此,在本章中,我们将微服务用于 猫舍
。该微服务应该能够执行以下操作:
-
提供一个端点,我们可以 ping 它来检查服务是否正在运行
-
列出目前在该庇护所中的猫
-
提供一种添加新猫的方法
本章中我们将使用的微服务框架称为 Ktor。它是由 Kotlin 编程语言的创建者开发和维护的并发框架。
让我们先创建一个新的 Kotlin Gradle 项目:
-
从您的 IntelliJ IDEA 中选择 文件 | 新建 | 项目,然后在 新建项目 中选择 Kotlin,在 构建系统 中选择 Gradle Kotlin。
-
给您的项目起一个描述性的名称 - 例如,我的项目名为
CatsHostel
- 并选择 项目 JDK(在这种情况下,我们使用 JDK 15):图 10.1 – 选择项目 JDK 类型
-
在下一屏上,选择 JUnit 5 作为您的 测试框架,并将 目标 JVM 版本 设置为 1.8。然后,点击 完成:
图 10.2 – 选择测试框架和目标 JVM 版本
-
现在,你应该看到以下结构:
图 10.3 – 项目结构
接下来,让我们打开 build.gradle.kts
文件。此文件控制项目的构建方式、其依赖项以及项目将要使用的库。根据 IntelliJ IDEA 的版本,文件的内容可能略有不同,但总体结构保持不变。
.kts
扩展名意味着我们的 Kotlin 项目的配置文件是用 Kotlin 编写的,或者更准确地说,是在 dependencies
块中,它应该看起来像这样:
dependencies {
implementation(...)
testImplementation("org.junit.jupiter:junit-jupiter- api:5.6.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter- engine:5.6.0")
}
前面的代码提到了项目中将要使用到的所有库。implementation()
配置意味着库将始终被使用。testImplementation()
配置意味着库仅在测试期间被使用。
现在,让我们看看以下示例中是如何定义库的:
"org.junit.jupiter:junit-jupiter-api:5.6.0"
这是一个被分成三个部分的常规字符串,如下所示:
"group:name:version"
group
和 name
字符串用于标识库;version
配置应该是自解释的。
现在,让我们修改 dependencies
块,如下所示:
val ktorVersion = "1.6.0"
dependencies {
implementation("io.ktor:ktor-server-
netty:$ktorVersion")
...
}
由于具有 .kts
扩展名的文件是 Kotlin 文件,我们可以在其中使用常规的 Kotlin 语法。在这种情况下,我们正在使用值和字符串插值来提取库的版本。
到目前为止,Ktor 的最新版本是 1.6.4,但当你阅读这本书时,它将大于这个版本。你可以在以下位置找到最新版本:ktor.io/
。
作为一般规则,所有 Ktor 库应该使用相同的版本,这时变量就变得有用。
小贴士:
如果你已经遵循了本节开头的步骤,你应该在你的项目中 src/main/kotlin
文件夹中有一个名为 server.kt
的文件。如果没有,现在就创建一个。
现在,让我们将以下内容添加到 server.kt
文件中:
fun main() {
embeddedServer(Netty, port = 8080) {
routing {
get("/") {
call.respondText("OK")
}
}
}.start(wait = true)
println("open http://localhost:8080")
}
这就是我们启动一个将响应 OK
的网络服务器所需编写的所有代码,当你打开浏览器中的 http://localhost:8080
时。
现在,让我们理解这里发生了什么:
-
要与请求交互并返回响应,我们可以使用
call
对象,也称为 上下文。此对象提供了所有方便的方法来解析请求并以不同格式返回响应,我们将在本章中看到可用于它的不同方法。 -
embeddedServer()
函数是 Builder 模式的一个实现,我们在 第二章 中讨论了这种模式,使用创建型模式。它允许我们配置服务器。大多数参数都有相同的默认值。我们仅为了方便将port
覆盖为8080
。 -
我们指定
wait
参数为true
,这样我们的服务器将等待传入的请求。 -
embeddedServer
函数的唯一必需参数是服务器引擎。在我们的例子中,我们使用Netty
,这是一个非常著名的 JVM 库,但还有其他库。其中最有趣的是CIO
,它是 JetBrains 自己开发的。
现在,让我们了解什么是 CIO
和 Netty
。它们都是 工厂 模式,在调用时创建我们服务器的实际实例。这是一个非常有趣的设计模式组合,在一个地方创建一个非常灵活和可扩展的架构。
要切换到使用 CIO
,我们只需要添加一个新的依赖项:
dependencies {
...
implementation("io.ktor:ktor-server-cio:$ktorVersion")
...
}
然后,我们需要将另一个服务器引擎 CIO
传递给 embeddedServer
函数:
embeddedServer(CIO, port = 8080) {
...
}.start(wait = true)
注意,当我们切换服务器引擎时,我们不需要更改代码中的其他任何内容。这是因为 embeddedServer()
使用桥接设计模式使组件可互换。
现在,我们的服务器已经启动,让我们调查我们如何为每个对服务器的请求定义不同的响应。
路由请求
现在,让我们看看 routing
块:
routing {
get("/") {
call.respondText("OK")
}
}
此块描述了我们的服务器将处理的所有 URL。在这种情况下,我们只处理根 URL。当请求该 URL 时,将向用户返回文本响应 OK
。
以下代码返回文本响应。现在,让我们看看我们如何返回 JSON 响应:
get("/status") {
call.respond(mapOf("status" to "OK"))
}
我们将不再使用 respondText()
方法,而是使用 respond()
,它接收一个对象而不是字符串。在我们的例子中,我们向 respond()
函数传递一个字符串映射。尽管如此,如果我们运行此代码,我们仍会得到一个异常。
这是因为默认情况下,对象不会被序列化为 JSON。多个库可以为我们完成这项工作。在这个例子中,我们将使用 kotlinx-serialization
库。让我们首先将其添加到我们的依赖项中:
dependencies {
...
implementation("org.jetbrains.kotlinx:kotlinx- serialization-json-jvm:1.3.0")
...
}
接下来,我们需要在我们的 routing
块之前添加以下行:
install(ContentNegotiation) {
json()
}
现在,如果我们再次运行我们的代码,它将在我们的浏览器上输出以下内容:
> {"status":"OK"}
我们刚刚创建了一条返回作为 JSON 序列化对象的第一个路由。现在,我们可以通过在浏览器中打开 http://localhost:8080/status
来检查我们的应用程序是否工作。但这有点麻烦。在下一节中,我们将学习如何为 /status
端点编写测试。
测试服务
要编写我们的第一个测试,让我们在 src/test/kotlin
目录下创建一个名为 ServerTest.kt
的新文件。
现在,让我们添加一个新的依赖项:
dependencies {
...
testImplementation("io.ktor:ktor-server-
tests:$ktorVersion")
}
接下来,让我们将以下内容添加到我们的 ServerTest.kt
文件中:
internal class ServerTest {
@Test
fun testStatus() {
withTestApplication {
val response = handleRequest(HttpMethod.Get, "/status").response
assertEquals(HttpStatusCode.OK, response.status())
assertEquals("""{"status": "OK"}""", response.content)
}
}
}
Kotlin 中的测试被分组到类中,每个测试都是类中的一个方法,该方法带有 @Test
注解。
在 test
方法内部,我们启动一个测试服务器,向 /status
端点发出 GET
请求,并检查该端点是否以正确的状态码和 JSON 主体响应。
如果你现在运行这个测试,它将会失败,因为我们还没有启动我们的服务器。为此,我们需要对其进行一些重构,我们将在下一节中这样做。
应用程序模块化
到目前为止,我们的服务器是从main()
函数启动的。这很简单设置,但这不允许我们测试我们的应用程序。
在 Ktor 中,代码通常组织成模块。让我们重写我们的main
函数,如下所示:
fun main() {
embeddedServer(
CIO,
port = 8080,
module = Application::mainModule
).start(wait = true)
}
在这里,我们不是在块中提供我们服务器的逻辑,而是指定了一个将包含我们服务器所有配置的模块。
此模块定义为Application
对象上的扩展函数:
fun Application.mainModule() {
install(ContentNegotiation) {
json()
}
routing {
get("/status") {
call.respond(mapOf("status" to "OK"))
}
}
println("open http://localhost:8080")
}
如你所见,此函数的内容与之前传递给我们的embeddedService
函数的块的内容相同。
现在,我们只需要回到我们的测试中,并指定我们想要测试哪个模块:
@Test
fun testStatus() {
withTestApplication(Application::mainModule) {
...
}
}
如果你现在运行这个测试,它应该会通过,因为我们的服务器已经在测试模式下正确启动。
到目前为止,我们只处理了服务的基础设施;我们没有触及其业务逻辑:管理猫。为此,我们需要一个数据库。在下一节中,我们将讨论 Ktor 如何使用 Exposed 库解决这个问题。
连接到数据库
为了存储和检索猫,我们需要连接到数据库。我们将为此目的使用 PostgreSQL,尽管使用其他 SQL 数据库不会有任何不同。
首先,我们需要一个新的库来连接到数据库。我们将使用由 JetBrains 开发的 Exposed 库。
让我们在build.gradle.kts
文件中添加以下依赖项:
dependencies {
implementation("org.jetbrains.exposed:exposed:0.17.14")
implementation("org.postgresql:postgresql:42.2.24")
...
}
一旦库就位,我们需要连接到它们。为此,让我们在/src/main/kotlin
下创建一个名为DB.kt
的新文件,并包含以下内容:
object DB {
private val host=System.getenv("DB_HOST")?:"localhost"
private val port = System.getenv("DB_PORT")?.toIntOrNull() ?: 5432
private val dbName = System.getenv("DB_NAME") ?: "cats_db"
private val dbUser = System.getenv("DB_USER") ?: "cats_admin"
private val dbPassword = System.getenv("DB_PASSWORD") ?: "abcd1234"
fun connect() = Database.connect( "jdbc:postgresql://$host:$port/$dbName", driver = "org.postgresql.Driver", user = dbUser, password = dbPassword
)
}
由于我们的应用程序需要一个数据库的确切实例,DB
对象可以使用我们之前讨论的 Singleton 模式,即使用object
关键字。
然后,对于我们需要连接到数据库的每个变量,我们将尝试从我们的环境中读取它们。如果环境
变量未设置,我们将使用Elvis运算符来使用默认值。
提示:
创建数据库和用户超出了本书的范围,但你可以参考官方文档,在www.postgresql.org/docs/14/app-createuser.html
和www.postgresql.org/docs/14/app-createdb.html
。
或者,你可以在命令行中简单地运行以下两个命令:
$ createuser cats_admin -W –d
$ createdb cats_db -U cats_admin
第一个命令创建了一个名为cats_admin
的数据库用户,并要求你为该用户指定一个密码。我们的应用程序将使用这个cats_admin
用户与数据库交互。第二个命令创建了一个名为cats_db
的数据库,属于cats_admin
用户。现在我们的数据库已经创建,我们只需要创建一个表来存储我们的猫。
为了做到这一点,让我们在 DB.kt
文件中定义另一个 Singleton 对象,它将代表一个表:
object CatsTable : IntIdTable() {
val name = varchar("name", 20).uniqueIndex()
val age = integer("age").default(0)
}
让我们理解一下前面的定义意味着什么:
-
IntIdTable
表示我们想要创建一个以Int
类型为主键的表。 -
在对象的体中,我们定义列。除了
ID
列之外,我们还将有一个name
列,它是varchar
类型,换句话说,是一个字符串,最多20
个字符。 -
猫的
name
列也是唯一的,这意味着没有两只猫可以拥有相同的名字。 -
我们还有一个第三列,它是
integer
类型,或者用 Kotlin 的话说,是Int
类型,默认值为0
。
我们还将有一个 data
类来表示单个猫:
data class Cat(val id: Int,
val name: String,
val age: Int)
我们剩下要做的唯一一件事是将以下代码行添加到我们的 mainModule()
函数中:
DB.connect()
transaction {
SchemaUtils.create(CatsTable)
}
每次我们的应用程序启动时,前面的代码将连接到数据库。然后,它将尝试创建一个存储我们实体的表。如果表已经存在,则不会发生任何操作。
现在我们已经建立了与数据库的连接,让我们看看我们如何使用这个连接在数据库中存储几只猫。
创建新实体
我们接下来的任务是向我们的虚拟收容所添加第一只猫。
遵循 REST 原则,它应该是一个 POST
请求,其中请求的体可能看起来像这样:
{"name": "Meatloaf", "age": 4}
我们将首先编写一个新的测试:
@Test
fun `POST creates a new cat`() {
...
}
反引号是 Kotlin 中的一个有用特性,它允许我们在函数的名称中包含空格。这有助于我们创建描述性的测试名称。
接下来,让我们看看我们的测试体:
withTestApplication(Application::mainModule) {
val response = handleRequest(HttpMethod.Post, "/cats") {
addHeader(
HttpHeaders.ContentType,
ContentType.Application.FormUrlEncoded.toString()
)
setBody(
listOf(
"name" to "Meatloaf",
"age" to 4.toString()
).formUrlEncode()
)
}.response
assertEquals(HttpStatusCode.Created, response.status())
}
我们在上一节中讨论了 withTestApplication
和 handleRequest
函数。这次,我们使用了一个 POST
请求。这类请求应该有正确的头信息,因此我们必须使用 addHeader()
函数设置这些头信息。我们还必须将体设置为之前讨论的内容。
最后,我们必须检查响应头是否设置为 Created
HTTP 状态码。
如果我们现在运行这个测试,它将因为还没有实现 post
/cats
端点而以 404
HTTP 状态码失败。
让我们回到我们的 routing
块并添加一个新的端点:
post("/cats") {
...
call.respond(HttpStatusCode.Created)
}
要创建一个新的猫,我们需要读取 POST
请求的体。我们将为此使用 receiveParameters()
函数:
val parameters = call.receiveParameters()
val name = requireNotNull(parameters["name"])
val age = parameters["age"]?.toInt() ?: 0
receiveParameters
函数返回一个不区分大小写的映射。首先,我们将尝试从这个映射中获取猫的 name
,如果没有请求中的名字,我们将调用失败。这将由 Ktor 处理。
然后,如果我们没有收到 age
,我们将使用 Elvis 操作符将其默认设置为 0
。
现在,我们必须将这些值插入到数据库中:
transaction {
CatsTable.insert { cat ->
cat[CatsTable.name] = name
cat[CatsTable.age] = age
}
}
在这里,我们打开一个 transaction
块来对数据库进行更改。然后,我们使用 insert()
方法,该方法在每个表上都是可用的。在 insert
lambda 中,cat
变量指的是我们将要填充的新行。我们将该行的名称设置为 name
参数的值,并为 age
做同样的操作。
如果你现在运行测试,它应该会通过。但如果你再次运行它,它将会失败。这是因为数据库中猫的名字是唯一的。此外,我们在测试运行之间没有清理数据库。所以,第一次运行创建了一个名为Meatloaf
的猫,而第二次运行失败。这是因为这样的猫已经存在。
为了使我们的测试保持一致,我们需要一种方法在运行之间清理我们的数据库。
使测试保持一致
让我们回到我们的测试并添加以下代码片段:
@BeforeEach
fun setup() {
DB.connect()
transaction {
SchemaUtils.drop(CatsTable)
}
}
在这里,我们在一个函数上使用了@BeforeEach
注解。正如其名所示,这段代码将在每个测试之前运行。该函数将建立与数据库的连接并完全删除表。然后,我们的应用程序将重新创建该表。
现在,我们的测试应该能够一致地通过。在下一节中,我们将学习如何使用 Exposed 库从数据库中获取猫。
获取实体
遵循 REST 实践,获取所有猫的 URL 应该是/cats
,而对于获取单个猫,应该是/cats/123
,其中123
是我们试图获取的猫的 ID。
让我们添加两个新的路由来实现这一点:
get("/cats") {
...
}
get("/cats/{id}") {
...
}
第一个路由与我们在本章早期引入的/status
路由非常相似。但第二个路由略有不同:它使用 URL 中的查询参数。你可以通过它们名字周围的括号来识别查询参数。
为了读取查询参数,我们可以访问parameters
映射:
val id = requireNotNull(call.parameters["id"]).toInt()
如果 URL 上有 ID,我们需要尝试从数据库中获取一只猫:
val cat = transaction {
CatsTable.select {
CatsTable.id.eq(id)
}.firstOrNull()
}
在这里,我们开启一个事务并使用select
语句获取一个 ID 等于我们之前提供的 ID 的猫。
如果返回了一个对象,我们会将其转换为 JSON。否则,我们会返回 HTTP 代码404
,即Not Found
:
if (row == null) {
call.respond(HttpStatusCode.NotFound)
} else {
call.respond(
Cat(
row[CatsTable.id].value,
row[CatsTable.name],
row[CatsTable.age]
)
)
}
现在,让我们添加一个用于获取单个猫的测试:
@Test
fun `GET with ID fetches a single cat`() {
withTestApplication(Application::mainModule) {
val id = transaction {
CatsTable.insertAndGetId { cat ->
cat[name] = "Fluffy"
}
}
val response = handleRequest(HttpMethod.Get,
"/cats/$id").response
assertEquals("""{"id":1,"name":
"Fluffy","age":0}""", response.content)
}
}
在这个测试中,我们使用 Exposed 创建了一只猫。在这里,我们使用了一个新方法insertAndGetId
。正如其名所示,它将返回新创建行的 ID。然后,我们尝试使用我们新创建的端点获取那只猫。
如果我们尝试运行这个测试,它将会因为以下异常而失败:
> Serializer for class 'Cat' is not found.
默认情况下,Ktor 不知道如何将我们的自定义数据类转换为 JSON。为了解决这个问题,我们需要在我们的build.gradle.kts
文件中添加一个新的插件:
plugins {
kotlin("jvm") version "1.5.10"
application
kotlin("plugin.serialization") version "1.5.10"
}
此插件将为任何带有@Serializable
注解的类在编译时创建序列化器。为了使测试通过,我们现在需要将此注解添加到我们的Cat
类中:
@Serializable
data class Cat(
val id: Int,
val name: String,
val age: Int
)
就这样;现在,我们通过 ID 获取猫的测试应该会通过。
最后,我们希望能够从数据库中获取所有猫。为了做到这一点,我们必须稍微改变我们的测试设置:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ServerTest {
@BeforeAll
fun setup() {
DB.connect()
transaction {
SchemaUtils.create(CatsTable)
}
}
@AfterAll
fun cleanup() {
DB.connect()
transaction {
SchemaUtils.drop(CatsTable)
}
}
...
}
在这里,我们改变了测试的设置,以便在所有测试执行完毕后删除表。因此,我们使用@AfterAll
注解,它在所有测试执行完毕后执行函数,而不是在每次测试之前执行的@BeforeEach
注解。
为了使这个注释生效,我们还需要在我们的类中添加@TestInstance
注解。它的默认值是PER_METHOD
,但由于我们希望一次性执行多个测试并在之后进行清理,我们需要将测试类的生命周期设置为PER_CLASS
。
接下来,让我们将我们的测试封装到一个嵌套类中,如下所示:
@Nested
inner class `With cat in DB` {
@Test
fun `GET with ID fetches a single cat`() {
...
}
}
嵌套测试类是封装特定测试情况的好方法。在我们的例子中,我们希望在数据库中已经存在猫的情况下运行两个测试。
现在,让我们将以下设置代码添加到我们的嵌套测试中:
lateinit var id: EntityID<Int>
@BeforeEach
fun setup() {
DB.connect()
id = transaction {
CatsTable.insertAndGetId { cat ->
cat[name] = "Fluffy"
}
}
}
@AfterEach
fun teardown() {
DB.connect()
transaction {
CatsTable.deleteAll()
}
}
在我们执行这个嵌套类中的每个测试之前,我们将在数据库中创建一只猫,并在每个测试之后,我们将从我们的数据库中删除所有猫。由于我们希望跟踪我们创建的猫的 ID,我们将它存储在一个变量中。
现在,我们的用于检索单个实体的测试类看起来像这样:
@Test
fun `GET with ID fetches a single cat`() {
withTestApplication(Application::mainModule) {
val response = handleRequest(HttpMethod.Get, "/cats/$id").response assertEquals("""{"id":$id,"name":"Fluffy", "age":0}""", response.content)
}
}
注意我们如何将猫的 ID 插入到我们期望的响应中,因为每次测试执行时它都会变化。
从数据库中检索所有猫的测试看起来几乎一样:
@Test
fun `GET without ID fetches all cats`() {
withTestApplication(Application::mainModule) {
val response = handleRequest(HttpMethod.Get, "/cats").response assertEquals("""[{"id":$id,"name":"Fluffy", "age":0}]""", response.content)
}
}
我们只是没有指定 ID,并且响应被包装成一个 JSON 数组,正如你通过方括号看到的。
现在,我们只需要实现这个新路由:
get("/cats") {
val cats = transaction {
CatsTable.selectAll().map { row ->
Cat(
row[CatsTable.id].value,
row[CatsTable.name],
row[CatsTable.age]
)
}
}
call.respond(cats)
}
如果你遵循了从数据库中检索单个实体(从本节开头)的示例,那么这个示例对你来说不会有很大不同。我们使用selectAll()
函数从表中检索所有行。然后,我们将每一行映射到我们的data
类。我们剩下要解决的问题是我们代码相当杂乱,并且位于一个单独的文件中。如果我们能将所有猫的路由拆分到一个单独的文件中会更好。我们将在下一节中这样做。
在 Ktor 中组织路由
在本节中,我们将看到 Ktor 中结构属于同一域的多个路由的惯用方法。
我们当前的routing
块看起来像这样:
routing {
get("/status") {
...
}
post("/cats") {
...
}
get("/cats") {
…
}
get("/cats/{id}") {
...
}
}
如果我们能将所有与猫相关的路由提取到一个单独的文件中会更好。让我们首先用函数替换所有猫的路由:
routing {
get("/status") {
...
}
cats()
}
如果你使用 IntelliJ IDEA,它甚至会建议你在Routing
类上生成一个扩展函数:
fun Routing.cats() {
...
}
现在,我们可以将所有我们的猫路由移动到这个函数中:
fun Routing.cats() {
post("/cats") {
...
}
get("/cats") {
...
}
get("/cats/{id}") {
...
}
}
现在,你可以看到 /cats
URL 被重复多次。我们可以使用route()
块来提升它:
表 10.1 - 使用 route() 块后的更简洁代码
注意到我们的代码现在变得多么简洁。
现在,我们还有一个最后的重要主题需要讨论。在本章的开头,我们提到 Ktor 是一个高度并发的框架。在第六章“线程和协程”中,我们提到 Kotlin 中的并发主要通过使用协程来实现。但我们在本章中只启动了一个协程。我们将在下一节中探讨这个问题。
在 Ktor 中实现并发
回顾本章中我们编写的代码,你可能会有一种印象,认为 Ktor 的代码根本不是并发的。然而,这离事实相差甚远。
本章中我们使用的所有 Ktor 函数都是基于协程和挂起函数的概念。
对于每一个进入的请求,Ktor 将启动一个新的协程来处理它,这得益于其核心基于协程的 CIO 服务器引擎。在 Ktor 中,拥有一个既高效又不会干扰的并发模型是一个非常重要的原则。
此外,我们用来指定所有端点的 routing
块可以访问 CoroutineScope
,这意味着我们可以在这些块中调用挂起函数。
挂起函数的一个例子是 call.respond()
,我们在这章中一直在使用。挂起函数为我们提供了进行上下文切换和执行其他代码并发的机会。这意味着相同数量的资源可以服务比其他情况下多得多的请求。我们在这里停下来,总结一下我们关于使用 Ktor 开发应用程序所学到的东西。
摘要
在本章中,我们使用 Kotlin 构建了一个经过良好测试的服务,该服务使用 Ktor 框架将实体存储在数据库中。我们还讨论了我们在本书开头遇到的多个设计模式,如工厂、单例和桥接模式,如何在 Ktor 框架中用于为我们提供灵活的代码结构。
现在,你应该能够使用 Exposed 框架与数据库进行交互。我们学习了如何声明、创建和删除表,如何插入新实体,以及如何检索和删除它们。
在下一章中,我们将探讨开发 Web 应用的另一种方法,但这次使用的是名为 Vert.x 的响应式框架。这将使我们能够比较开发 Web 应用时的并发和响应式方法,并讨论每种方法的权衡。
问题
-
Ktor 应用是如何构建的,它们有哪些好处?
-
Ktor 中的插件是什么,它们有什么用途?
-
Exposed 库解决的主要问题是什么?
第十一章:第十一章:使用 Vert.x 的响应式微服务
在上一章中,我们熟悉了 Ktor 框架。我们创建了一个可以存储猫的数据库的 Web 服务。
在本章中,我们将继续使用上一章的例子,但这次使用 Vert.x 框架和 Kotlin。Vert.x 是一个基于响应式原则构建的响应式框架,我们在 第七章 控制数据流 中讨论了这些原则。在本章中,我们还将列出 Vert.x 框架的一些其他优点。您可以通过访问官方网站了解更多关于 Vert.x 的信息:vertx.io
。
本章中我们将开发的微服务将提供一个健康检查的端点——与我们在 Ktor 中创建的相同——并且能够删除和更新我们数据库中的猫。
在本章中,我们将涵盖以下主题:
-
开始使用 Vert.x
-
Vert.x 中的路由
-
Verticles
-
处理请求
-
测试 Vert.x 应用程序
-
与数据库一起工作
-
理解事件循环
-
通过事件总线进行通信
技术要求
对于本章,您需要以下内容:
-
JDK 11 或更高版本
-
IntelliJ IDEA
-
Gradle 6.8 或更高版本
-
PostgreSQL 14 或更高版本
与上一章类似,本章也将假设您已经安装了 PostgreSQL,并且您对其有基本的了解。我们还将使用与 Ktor 创建的相同的表结构。
您可以在此处找到本章的完整源代码:github.com/PacktPublishing/Kotlin-Design-Patterns-and-Best-Practices/tree/main/Chapter11
。
开始使用 Vert.x
Vert.x 是一个异步且非阻塞的响应式框架。让我们通过一个具体的例子来理解这意味着什么。
我们将首先创建一个新的 Kotlin Gradle 项目,或者使用 start.vertx.io:
-
从您的 IntelliJ IDEA 应用程序中选择 文件 | 新建 | 项目,并在 新建项目 向导中选择 Kotlin。
-
然后,为您的项目指定一个名称——例如,我的项目名为
CatsShelterVertx
——并选择 Gradle Kotlin 作为您的 构建系统。 -
然后,从下拉菜单中选择您已安装的 项目 JDK 版本。输出应如下所示:
图 11.1 – 创建 Kotlin 应用程序
接下来,将以下依赖项添加到您的 build.gradle.kts
文件中:
val vertxVersion = "4.1.5"
dependencies {
implementation("io.vertx:vertx-core:$vertxVersion")
implementation("io.vertx:vertx-web:$vertxVersion")
implementation("io.vertx:vertx-lang-
kotlin:$vertxVersion")
implementation("io.vertx:vertx-lang-kotlin-
coroutines:$vertxVersion")
...
}
与上一章中讨论的类似,所有依赖项都必须是同一版本,以避免任何冲突。这就是我们为什么使用库版本变量——以便能够一起更改它们。
以下是对每个依赖项的解释:
-
vertx-core
是核心库。 -
vertx-web
是必需的,因为我们希望我们的服务是基于 REST 的。 -
vertx-lang-kotlin
提供了使用 Vert.x 编写 Kotlin 代码的惯用方法。 -
最后,
vertx-lang-kotlin-coroutines
与协程集成,我们在第六章,线程和协程中详细讨论了它。
然后,我们必须在src/main/kotlin
文件夹中创建一个名为server.kt
的文件,并包含以下内容:
fun main() {
val vertx = Vertx.vertx()
vertx.createHttpServer().requestHandler{ ctx ->
ctx.response().end("OK")
}.listen(8081)
println("open http://localhost:8081")
}
这就是你需要启动一个 web 服务器的所有内容,当你在浏览器中打开http://localhost:8081
时,它会返回OK
。
现在,让我们理解这里发生了什么。首先,我们使用第三章,理解结构模式中的工厂方法创建一个 Vert.x 实例。
requestHandler
方法只是一个简单的监听器或订阅者。如果你不记得它是如何工作的,请查看第四章,熟悉行为模式,了解 Observable 设计模式。在我们的情况下,它将为每个新的请求被调用。这就是 Vert.x 的异步特性在起作用。
接下来,让我们学习如何在 Vert.x 中添加路由。
Vert.x 中的路由
注意,无论我们指定哪个 URL,我们总是得到相同的结果。当然,这不是我们想要达到的效果。让我们先添加最基础的端点,它只会告诉我们服务正在运行。
为了做到这一点,我们将使用Router
:
val vertx = Vertx.vertx()
val router = Router.router(vertx)
...
Router
允许你为不同的 HTTP 方法和 URL 指定处理器。
现在,让我们添加一个/status
端点,它将返回 HTTP 状态码200
和一个消息,告知用户OK
:
router.get("/status").handler { ctx ->
ctx.response()
.setStatusCode(200)
.end("OK")
}
vertx.createHttpServer()
.requestHandler(router)
.listen(8081)
现在,我们不再将请求处理器指定为一个块,而是将这个函数传递给我们的router
对象。这使得我们的代码更容易管理。
我们在第一个示例中学习了如何返回纯文本响应。所以,现在,让我们返回 JSON。大多数实际应用都使用 JSON 进行通信。让我们用以下代码替换状态处理器的主体:
val json = json {
obj(
"status" to "OK"
)
}
ctx.response()
.setStatusCode(200)
.end(json.toString())
在这里,我们使用了一种 DSL,我们在第四章,熟悉行为模式中讨论了它,来创建一个 JSON 对象。
你可以在浏览器中打开http://localhost:8081/status
并确保你得到{"status": "OK"}
作为响应。
现在,让我们讨论如何使用 Vert.x 框架更好地组织我们的代码。
Verticles
我们当前的代码存储在server.kt
文件中,这个文件正在变得越来越庞大。我们需要找到一种方法来将其拆分。在 Vert.x 中,代码被拆分成称为verticles的类。
你可以将 verticle 视为一个轻量级的 actor。我们在第五章,介绍函数式编程中讨论了 Actors。
让我们看看我们如何创建一个新的 verticle,它将封装我们的服务器:
class ServerVerticle : CoroutineVerticle() {
override suspend fun start() {
val router = router()
vertx.createHttpServer()
.requestHandler(router)
.listen(8081)
println("open http://localhost:8081")
}
private fun router(): Router {
// Our router code comes here now
val router = Router.router(vertx)
...
return router
}
}
每个垂直方向都有一个start()
方法,用于处理其初始化。正如你所见,我们将所有代码从main()
函数移动到了start()
方法。然而,如果我们现在运行代码,什么也不会发生。这是因为垂直方向还没有被启动。
开始一个垂直方向有多种方法,但最简单的方法是将类的实例传递给deployVerticle()
方法。在我们的例子中,这是ServerVerticle
类:
fun main() {
val vertx = Vertx.vertx()
vertx.deployVerticle(ServerVerticle())
}
这里是另一种更灵活的方法来指定类名作为字符串:
fun main() {
val vertx = Vertx.vertx()
vertx.deployVerticle("ServerVerticle")
}
如果我们的垂直方向类不在默认包中,我们需要指定完全限定的路径,以便 Vert.x 能够初始化它。
现在,我们的代码已经分为两个文件,ServerVerticle.kt
和server.kt
,并且组织得更好。接下来,我们将学习如何以相同的方式进行重构,以更好地组织我们的路由。
处理请求
如我们在本章前面讨论的,Vert.x 中所有请求都由Router
类处理。我们在上一章中介绍了路由的概念,现在,让我们仅讨论 Ktor 和 Vert.x 在处理请求路由方面的不同方法。
我们将声明两个端点,用于从数据库中删除猫和更新特定猫的信息。我们将分别使用delete
和put
动词:
router.delete("/cats/:id").handler { ctx ->
// Code for deleting a cat
}
router.put("/cats/:id").handler { ctx ->
// Code for updating a cat
}
两个端点都接收一个 URL 参数。在 Vert.x 中,我们使用冒号表示法来表示。
为了能够解析 JSON 请求和响应,Vert.x 有一个BodyHandler
类。现在,让我们也声明它。这应该在创建我们的路由对象之后进行:
val router = Router.router(vertx)
router.route().handler(BodyHandler.create())
这将告诉 Vert.x 解析任何请求的请求体为 JSON。
注意,现在我们的代码中/cat
前缀重复多次。为了避免这种情况并使我们的代码更加模块化,我们可以使用子路由,我们将在下一节中讨论。
子路由请求
子路由允许我们将路由拆分成多个类,以使我们的代码更加有序。让我们按照以下步骤将新路由移动到新函数中:
-
我们将保持
/alive
端点不变,但将所有其他端点提取到一个单独的函数中:private fun catsRouter(): Router { val router = Router.router(vertx) router.delete("/:id").handler { ctx -> // Code for deleting a cat } router.put("/:id").handler { ctx -> // Code for updating a cat } return router }
在这个函数内部,我们创建了一个单独的
Router
对象,它将仅处理猫的路由,而不是状态路由。 -
现在,我们需要将
SubRouter
连接到我们的主路由:router.mountSubRouter("/cats", catsRouter())
保持我们的代码干净和分离非常重要。将路由提取到子路由中帮助我们做到这一点。
现在,让我们讨论如何测试这段代码。
测试 Vert.x 应用程序
为了测试我们的 Vert.x 应用程序,我们将使用我们在上一章中讨论的JUnit 5框架。
你需要在你的build.gradle.kts
文件中添加以下两个依赖项:
dependencies {
...
testImplementation("org.junit.jupiter:junit-jupiter-
api:5.6.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-
engine:5.6.0")
}
我们的第一项测试将位于/src/test/kotlin/ServerTest.kt
文件中。
所有集成测试的基本结构看起来像这样:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ServerTest {
private val vertx: Vertx = Vertx.vertx()
@BeforeAll
fun setup() {
runBlocking {
vertx.deployVerticle(ServerVerticle()).await()
}
}
@AfterAll
fun tearDown() {
// And you want to stop your server once
vertx.close()
}
@Test
fun `status should return 200`() {
}
}
这种结构与我们在 Ktor 中看到的不同。在这里,我们自己在setup()
方法中启动服务器。
由于 Vert.x 是响应式的,deployVerticle()
方法将立即返回一个 Future
对象,释放线程,但这并不意味着服务器 verticle 已经启动。
为了避免这种竞争,我们可以使用 await()
方法,这将阻塞我们的测试执行,直到服务器准备好接收请求。
现在,我们想要向我们的 /status
端点发出实际的 HTTP 请求,例如,并检查响应代码。为此,我们将使用 Vert.x 网络客户端。
让我们将它添加到我们的 build.gradle.kts
依赖项部分:
testImplementation("io.vertx:vertx-web-client:$vertxVersion")
由于我们只计划在测试中使用 WebClient
,所以我们指定 testImplementation
而不是 implementation
。但 WebClient
非常有用,你可能会最终在生产代码中也要使用它。
添加了这个新的依赖项之后,我们需要在 setup
方法中实例化我们的网络客户端:
lateinit var client: WebClient
@BeforeAll
fun setup() {
vertx.deployVerticle(ServerVerticle())
client = WebClient.create(
vertx,
WebClientOptions()
.setDefaultPort(8081)
.setDefaultHost("localhost")
)
}
setup()
方法将在所有测试开始之前被调用一次。在这个方法中,我们部署我们的服务器 verticle,并为所有测试创建一个带有一些默认值的网络客户端,以便所有测试可以共享。
现在,让我们编写一个测试来检查我们的服务器是否正在运行:
@Test
fun `status should return 200`() {
runBlocking {
val response = client.get("/status").send().await()
assertEquals(201, response.statusCode())
}
}
现在,让我们了解这个测试中发生了什么:
-
client
是一个WebClient
的实例,它被所有我们的测试共享。我们使用get
动词调用/status
端点。这是一个 Builder 设计模式,因此要发出我们的请求,我们需要使用send()
方法。否则,什么都不会发生。 -
由于 Vert.x 是响应式框架,所以
send()
方法不会阻塞我们的线程直到收到响应,而是返回一个 Future。然后,我们使用await()
,它将 Future 转换为 Kotlin 协程,以便能够并发地等待结果。 -
一旦收到响应,我们就像在其他测试中做的那样进行检查——使用来自 JUnit 的
assertEquals
函数。
既然我们已经知道了如何在 Vert.x 中编写测试,那么让我们讨论如何以响应式的方式与数据库进行交互。
与数据库交互
为了能够进一步进行我们的测试,我们需要在数据库中创建实体的能力。为此,我们需要连接到数据库。
首先,让我们将以下两行添加到我们的 build.gradle.kts
依赖项部分:
implementation("org.postgresql:postgresql:42.3.0")
implementation("io.vertx:vertx-pg-client:$vertxVersion")
第一行代码获取 PostgreSQL 驱动程序。第二行添加了 Vert.x JDBC 客户端,这使得拥有驱动程序的 Vert.x 能够连接到任何支持 JDBC 的数据库。
管理配置
现在,我们希望将数据库配置保存在某个地方。对于本地开发,可能将配置硬编码是可行的。我们将执行以下步骤来完成此操作:
-
当我们连接到数据库时,我们至少需要指定以下参数:
-
用户名
-
密码
-
主机
-
数据库名
我们将存储上述参数在一个
Singleton
对象中:object Db { val username = System.getenv("DATABASE_USERNAME") ?: "cats_admin" val password = System.getenv("DATABASE_PASSWORD") ?: "abcd1234" val database = System.getenv("DATABASE_NAME") ?: "cats_db" val host = System.getenv("DATABASE_HOST") ?: "localhost" }
我们的
Singleton
对象有四个成员。对于每个成员,我们检查是否设置了环境变量,如果没有设置这样的环境变量,我们将使用 Elvis 运算符提供默认值。 -
-
现在,让我们添加一个函数,该函数将返回一个连接池:
fun connect(vertx: Vertx): SqlClient { val connectOptions = PgConnectOptions() .setPort(5432) .setHost(host) .setDatabase(database) .setUser(username) .setPassword(password) val poolOptions = PoolOptions() .setMaxSize(20) return PgPool.client( vertx, connectOptions, poolOptions ) }
我们的
connect()
方法创建了两个配置对象:PgConnectOptions
设置了我们要连接的数据库的配置,而PoolOptions
指定了连接池的配置。 -
现在,我们只需要在我们的测试中实例化数据库客户端:
... lateinit var db: SqlClient @BeforeAll fun setup() { runBlocking { ... db = Db.connect(vertx) } }
-
做完这些之后,让我们在我们的测试文件中创建一个新的
Nested
类,用于处理我们预期数据库中会有猫的情况:@Nested inner class `With Cat` { @BeforeEach fun createCats() { ... } @AfterEach fun deleteAll() { ... } }
与我们在上一章中讨论的 Exposed 框架不同,Vert.x 中的数据库客户端没有特定的插入、删除等方法。相反,它提供了一个更底层的 API,允许我们在数据库上执行任何类型的查询。
-
首先,让我们编写一个查询来清理我们的数据库:
@AfterEach fun deleteAll() { runBlocking { db.preparedQuery("DELETE FROM cats") .execute().await() } }
在 Vert.x 中与数据库客户端一起工作的基本结构是将查询传递给
prepareQuery()
方法,然后使用execute()
方法执行它。我们希望在继续下一个测试之前等待查询完成,因此我们使用
await()
函数等待当前的协程,并使用runBlocking()
适配器方法来创建一个协程上下文以实现这一点。 -
现在,让我们编写另一个查询,在每次测试运行之前将猫添加到数据库中:
lateinit var catRow: Row @BeforeEach fun createCats() { runBlocking { val result = db.preparedQuery( """INSERT INTO cats (name, age) VALUES ($1, $2) RETURNING ID""".trimIndent() ).execute(Tuple.of("Binky", 7)).await() catRow = result.first() } }
在这里,我们再次使用
preparedQuery()
方法,但这次我们的 SQL 查询字符串包含占位符。每个占位符都以美元符号开始,它们的索引从1
开始。然后,我们将这些占位符的值传递给
execute()
方法。Tuple.of
是一个你应该现在已经很熟悉的工厂方法设计模式。我们还想要记住我们创建的猫的 ID,因为我们将会使用这个 ID 来删除或更新猫。出于这个原因,我们将创建的行存储在一个
lateinit
变量中。 -
现在我们已经为编写测试做好了准备:
@Test fun `delete deletes a cat by ID`() { runBlocking { val catId = catRow.getInteger(0) client.delete("/cats/${catId}").send().await() val result = db.preparedQuery("SELECT * FROM cats WHERE id = $1") .execute(Tuple.of(catId)).await() assertEquals(0, result.size()) } }
首先,我们使用
getInteger()
方法从数据库行中获取我们想要删除的猫的 ID。与以1
开始的参数不同,数据库行的列从0
开始。因此,通过获取索引为0
的整数,我们得到了我们猫的 ID。然后,我们调用网络客户端的
delete()
方法并等待其完成。之后,我们在数据库上执行一个
SELECT
语句,检查该行确实已被删除。
如果你现在运行这个测试,它将会失败,因为我们还没有实现delete
端点。我们将在下一节中完成这个任务。
理解事件循环
事件循环设计模式的目的是在队列中持续检查新事件,并且每次有新事件到来时,都要快速将其派发给知道如何处理它的人。这样,单个线程或非常有限数量的线程就可以处理大量的事件。
在 Vert.x 等 Web 框架的情况下,事件可能是对服务器的请求。
为了更好地理解事件循环的概念,让我们回到我们的服务器代码,并尝试实现一个删除猫的端点:
val db = Db.connect(vertx)
router.delete("/:id").handler { ctx ->
val id = ctx.request().getParam("id").toInt()
db.preparedQuery("DELETE FROM cats WHERE ID = $1") .execute(Tuple.of(id)).await()
ctx.end()
}
这段代码与我们之前章节测试中写的非常相似。我们使用 getParam()
函数从请求中读取 URL 参数,然后将此 ID 传递给预准备的查询。不过,这次我们不能使用 runBlocking
适配器函数,因为它会阻塞事件循环。
Vert.x 使用有限数量的线程,大约是 CPU 核心数的两倍,以高效地运行所有代码。然而,这意味着我们无法在这些线程上执行任何阻塞操作,因为这会负面影响我们应用程序的性能。
为了解决这个问题,我们可以使用我们已熟悉的协程构建器:launch()
。让我们看看它是如何工作的:
router.delete("/:id").handler { ctx ->
launch {
val id = ctx.request().getParam("id").toInt()
db.preparedQuery("DELETE FROM cats WHERE ID = $1") .execute(Tuple.of(id)).await()
ctx.end()
}
}
由于我们的垂直扩展了 CoroutineVerticle
,我们可以访问所有将在事件循环上运行的常规协程构建器。
现在,我们只需要将我们的路由函数标记为 suspend
关键字:
private suspend fun router(): Router {
...
}
private suspend fun catsRouter(): Router {
...
}
现在,让我们添加另一个测试来更新一只猫:
@Test
fun `put updates a cat by ID`() {
runBlocking {
val catId = catRow.getInteger(0)
val requestBody = json {
obj("name" to "Meatloaf", "age" to 4)
}
client.put("/cats/${catId}")
.sendBuffer(Buffer.buffer(requestBody.toString()))
.await()
val result = db.preparedQuery("SELECT * FROM cats
WHERE id = $1")
.execute(Tuple.of(catId)).await()
assertEquals("Meatloaf", result.first().getString("name"))
assertEquals(4, result.first().getInteger("age"))
}
}
这个测试与删除测试非常相似,唯一的重大区别是我们使用 sendBuffer
而不是 send()
方法,这样我们就可以向我们的 put
端点发送 JSON body。
我们创建 JSON 的方式与我们在本章前面实现 /status
端点时看到的方式类似。
现在,让我们实现 put
端点以通过测试:
router.put("/:id").handler { ctx ->
launch {
val id = ctx.request().getParam("id").toInt()
val body = ctx.bodyAsJson
db.preparedQuery("UPDATE cats SET name = $1, age = $2 WHERE ID = $3")
.execute(
Tuple.of(
body.getString("name"),
body.getInteger("age"),
id
)
).await()
ctx.end()
}
}
在这里,与之前我们实现的端点的主要区别是,这次我们需要解析我们的请求 body
。我们可以通过使用 bodyAsJson
属性来完成,然后我们可以使用 JSON 中可用的 getString
和 getInteger
方法来获取 name
和 age
的新值。
通过这种方式,你应该拥有所有所需的知识来实现其他端点。现在,让我们学习如何使用事件总线概念以更好的方式来结构化我们的代码,因为所有内容都位于一个单一的大类中。
通过事件总线进行通信
事件总线 是观察者设计模式的实现,我们在 第四章,熟悉行为模式 中讨论过。
我们已经提到,Vert.x 基于垂直的概念,这些是隔离的演员。我们已经在 第六章,线程和协程 中看到了其他类型的演员。Kotlin 的 coroutines
库提供了 actor()
和 producer()
协程生成器,它们创建一个与通道绑定的协程。
同样,Vert.x 框架中的所有垂直都由事件总线绑定,并且可以使用它相互传递消息。现在,让我们将 ServerVerticle
类中的代码提取到一个新的类中,我们将称之为 CatVerticle
。
任何垂直都可以通过选择以下方法之一通过事件总线发送消息:
-
request()
将向单个订阅者发送消息并等待响应。 -
send()
将向单个订阅者发送消息,而不等待响应。 -
publish()
将向所有订阅者发送消息,而不等待响应。
无论使用哪种方法发送消息,你都可以使用 Event Bus 上的consumer()
方法来订阅它。
现在,让我们在我们的CatsVerticle
类中订阅一个事件:
class CatsVerticle : CoroutineVerticle() {
override suspend fun start() {
val db = Db.connect(vertx)
vertx.eventBus().consumer<Int>("cats:delete"){req->
launch {
val id = req.body()
db.preparedQuery("DELETE FROM cats WHERE ID = $1")
.execute(Tuple.of(id)).await()
req.reply(null)
}
}
}
}
consumer()
方法的泛型类型指定了我们将接收的消息类型。在这种情况下,它是Int
。
我们提供给方法的字符串——在我们的例子中,cats:delete
——是我们订阅的地址。它可以是一个任何字符串,但有一个约定会更好,比如我们操作的对象类型以及我们想要对其做什么。
一旦执行了删除操作,我们就使用reply()
方法对我们的发布者做出响应。由于我们没有要发送的信息,我们简单地发送null
。
现在,让我们用以下代码替换我们之前的delete
路由:
router.delete("/:id").handler { ctx ->
val id = ctx.request().getParam("id").toInt()
vertx.eventBus().request<Nothing>("cats:delete", id) {
ctx.end()
}
}
在这里,我们使用request()
方法将我们从请求中接收到的猫的 ID 发送给我们的一个监听器,并指定我们的消息类型是Int
。我们还使用了与消费者代码中指定的相同地址。
由于我们将代码拆分成了一个新的 verticle,我们需要记住也要启动它。在你的测试中,向main()
函数和setup()
方法中添加以下行:
vertx.deployVerticle(CatsVerticle())
接下来,让我们学习如何通过 Event Bus 发送复杂对象。
通过 Event Bus 发送 JSON
作为我们的最终练习,让我们学习如何更新一只猫。为此,我们需要通过 Event Bus 发送比 ID 更多的信息。
让我们重写我们的put
处理器,如下所示:
router.put("/:id").handler { ctx ->
launch {
val id = ctx.request().getParam("id").toInt()
val body: JsonObject = ctx.bodyAsJson.mergeIn(json{ obj("id" to id)
})
vertx.eventBus().request<Int>("cats:update", body)
{ res ->
ctx.end(res.result().body().toString())
}
}
}
在这里,你可以看到我们可以轻松地通过 Event Bus 发送 JSON 对象。我们将接收到的 ID 与请求的其余body
合并,并通过 Event Bus 发送这个 JSON。当收到响应时,我们将它输出给用户。
现在,让我们看看我们如何消费我们刚刚发送的事件:
vertx.eventBus().consumer<JsonObject>("cats:update"){req -> launch {
val body = req.body()
db.preparedQuery("UPDATE cats SET name = $1, age = $2 WHERE ID = $3")
.execute(
Tuple.of(
body.getString("name"),
body.getInteger("age"),
body.getInteger("id")
)
).await()
req.reply(body.getInteger("id"))
}
}
我们将逻辑从Router
移动到了我们的CatsVerticle
类,但由于我们使用 JSON 进行通信,代码几乎保持不变。在我们的 verticle 中,我们监听cats:update
事件,一旦我们收到响应,我们就从 JSON 对象中提取name
、age
和id
以确认操作成功。
这就结束了本章。如果你对 Vert.x 框架感兴趣,还有很多东西要学,但凭借你从本章中获得的知识,你应该能够有信心地这样做。
摘要
本章结束了我们对 Kotlin 中设计模式的探索。Vert.x 使用称为 verticle 的 actor 来组织应用程序的逻辑。actor 通过 Event Bus 进行通信,Event Bus 是 Observable 设计模式的一种实现。
我们还讨论了 Event Loop 模式,它如何允许 Vert.x 并发处理大量事件,以及为什么不要阻塞其执行很重要。
现在,你应该能够使用两种不同的框架用 Kotlin 编写微服务,你可以选择最适合你的方法。
Vert.x 提供的 API 比 Ktor 更底层,这意味着我们可能需要更多地考虑如何结构化我们的代码,但生成的应用程序也可能更高效。由于这是本书的结尾,我剩下的只是祝愿你在学习 Kotlin 及其生态系统方面一切顺利。你总是可以通过访问 stackoverflow.com/questions/tagged/kotlin
和 discuss.kotlinlang.org/
来从我和其他 Kotlin 爱好者那里获得一些帮助。
快乐学习!
问题
-
在 Vert.x 中,“verticle”是什么意思?
-
事件总线(Event Bus)的目标是什么?
-
为什么我们不应该阻塞事件循环(Event Loop)?
第十二章:评估
第一章,Kotlin 入门
问题 1
Kotlin 中 var
和 val
之间的区别是什么?
回答
val
关键字声明了一个不可变值,一旦分配后就不能修改。var
关键字声明了一个可变变量,可以被多次赋值。
问题 2
你如何在 Kotlin 中扩展一个类?
回答
要扩展一个类,你可以在分号后指定其名称和构造函数。如果它是一个普通类,它必须被声明为open
,以便你的代码能够扩展它。
问题 3
你如何向一个final
类添加功能?
回答
要向一个无法继承的类添加功能,我们可以使用扩展函数。扩展函数将只能访问类本身及其公共字段和函数。
第二章,使用创建型模式
问题 1
列出本章中我们学到的object
关键字的两个用途。
回答
当在全局作用域中使用或在与类内部的companion
关键字一起使用时,object
关键字用于声明单例,如果它与companion
关键字一起使用,则作为静态方法的集合。
问题 2
apply()
函数用于什么?
回答
当我们想要改变一个对象的状态并立即返回它时,使用apply()
函数。
问题 3
提供本章中讨论的静态工厂方法的一个示例。
回答
Long
对象的 JVM valueOf()
方法是静态工厂方法。
第三章,理解结构型模式
问题 1
装饰器模式和代理设计模式的实现之间有什么区别?
回答
装饰器模式和代理设计模式可以以相同的方式实现。唯一的区别在于它们的意图——装饰器设计模式向对象添加功能,而代理设计模式可能会改变对象的功能。
问题 2
飞舞设计模式的主要目标是什么?
回答
飞舞设计模式的目标是通过在多个轻量级对象之间重用相同的不可变状态来节省内存。
问题 3
外观模式和适配器设计模式之间的区别是什么?
回答
外观设计模式创建了一个新的接口,以简化与复杂代码的交互,而适配器设计模式允许一个接口替代另一个接口。
第四章,熟悉行为型模式
问题 1
中介和观察者设计模式之间的区别是什么?
回答
它们都服务于类似的目的。中介引入了可能服务于不同目的的组件之间的紧密耦合,而观察者操作的是松散耦合的类似组件。
问题 2
什么是领域特定语言(DSL)?
回答
DSL 是一种专注于解决特定领域问题的语言。这与像 Kotlin 这样的通用语言不同,Kotlin 可以应用于不同的领域。Kotlin 鼓励开发者根据需要创建 DSL。
问题 3
使用密封类或接口的好处是什么?
答案
由于密封类的所有类型在编译时都是已知的,Kotlin 编译器可以验证 when
语句涵盖了所有情况,换句话说,是详尽的。
第五章,介绍函数式编程
问题 1
什么是高阶函数?
答案
任何接收另一个函数作为输入或返回函数作为输出的函数都是高阶函数。
问题 2
Kotlin 中的 tailrec
关键字是什么意思?
答案
tailrec
关键字的目的在于允许 Kotlin 编译器优化尾递归并避免栈溢出。
问题 3
什么是纯函数?
答案
纯函数是没有任何副作用(如 I/O)的函数。
第六章,线程和协程
问题 1
在 Kotlin 中,有哪几种方式可以启动协程?
答案
Kotlin 中的协程可以用 launch()
或 async()
函数启动。区别在于 async()
也会返回一个值,而 launch()
则不会。
问题 2
在结构化并发中,如果一个协程失败,所有兄弟协程也会被取消。我们如何防止这种行为?
答案
我们可以通过使用 supervisorScope
而不是 coroutineScope
来防止取消兄弟协程。
问题 3
yield()
函数的目的是什么?
答案
yield()
函数返回一个值并挂起协程,直到它被恢复。
第七章,控制数据流
问题 1
集合上的高阶函数与并发数据结构上的高阶函数之间有什么区别?
答案
集合上的高阶函数会在进行下一步之前处理整个集合,创建其副本。并发数据结构上的高阶函数是反应式的,逐个处理元素。
问题 2
数据的冷流和热流之间有什么区别?
答案
冷流会为每个新的消费者重复自身,而热流将只从订阅时开始将可用的数据发送给新的消费者。
问题 3
应该在什么情况下使用合并通道/流?
答案
当消费者比生产者慢,并且一些消息可能会丢失,只留下最新消息供消费时,可以使用合并流。
第八章,设计并发
问题 1
当我们说 Kotlin 中的 select
表达式是偏斜的,这意味着什么?
答案
偏斜的 select
表达式意味着在两个通道之间发生 平局 的情况下,select
表达式中列出的第一个通道将始终被选中。
问题 2
应该在什么情况下使用互斥锁而不是通道?
答案
互斥锁用于保护多个协程之间共享的资源。通道用于在协程之间传递数据。
问题 3
哪种并发设计模式可以帮助你有效地实现 MapReduce 或 分而治之 算法?
答案
对于分而治之算法,可以使用扇出设计模式来分割数据,而可以使用扇入设计模式来合并结果。
第九章,惯用和反模式
问题 1
Kotlin 中 Java 的 try
-with-resources 的替代方案是什么?
答案
在 Kotlin 中,use()
函数在 Closeable
接口上工作,以确保在使用后释放资源。
问题 2
在 Kotlin 中处理空值的不同选项有哪些?
答案
处理空值有多种选择:Elvis 操作符、智能转换以及 let
和 run
范围函数都可以帮助处理这个问题。
问题 3
通过具体化泛型可以解决哪些问题?
答案
在 JVM 上,类型在运行时被擦除。通过将泛型函数体内联到调用点,它允许保留编译器使用的实际类型。
第十章,使用 Ktor 的并发微服务
问题 1
Ktor 应用是如何构建的,它们有什么好处?
答案
Ktor 应用被划分为模块,每个模块都是 Application
对象上的扩展函数。模块化我们的应用程序允许我们单独测试其不同方面。
问题 2
Ktor 中的插件是什么,它们用于什么?
答案
插件是 Ktor 解决横切关注点的一种方式。它们用于序列化和反序列化请求和响应,设置头部,甚至路由本身也是一个插件。
问题 3
Exposed
库主要解决了什么问题?
答案
Exposed
库提供了一个高级 API,用于处理数据库。
第十一章,使用 Vert.x 的响应式微服务
问题 1
Vert.x 中的 verticle 是什么?
答案
Verticle 是一种轻量级演员,允许我们将业务逻辑分离成小的反应单元。
问题 2
Vert.x 中的事件总线有什么目标?
答案
事件总线允许 verticles 通过发送和消费消息间接地相互通信。
问题 3
为什么我们不应该阻塞事件循环?
答案
事件循环使用有限数量的线程来并发处理多个请求。如果任何一个线程被阻塞,它都会降低 Vert.x 应用的性能。