真实世界的软件开发-全-
真实世界的软件开发(全)
原文:
zh.annas-archive.org/md5/9fe6488c1d46ccf6de3ab02ce7d234fc
译者:飞龙
前言
掌握软件开发需要学习一系列不同的概念。如果你是初级软件开发者,或者即使你已经有经验了,这看起来也像是一个无法逾越的障碍。你是否应该花时间学习面向对象世界中已被广泛接受的主题,如 SOLID 原则、设计模式或测试驱动开发?你是否应该尝试一些越来越流行的东西,如函数式编程?
即使你已经选择了一些要学习的主题,确定它们是如何相互联系的仍然很难。当你应该在项目中应用函数式编程思想的路线时?什么时候该关注测试?你怎么知道在什么时间引入或改进这些技术?你需要读每个主题的书,然后再看另一系列博客或视频来解释如何将它们结合在一起吗?你甚至不知道从哪里开始?
不用担心,这本书会帮助你。你将通过一种集成、以项目为驱动的学习方式得到帮助。你将学习成为高效开发者所需的核心主题。不仅如此,我们还展示了这些内容是如何融入更大项目中的。
为什么我们写这本书
多年来,我们积累了丰富的经验,帮助开发者学习编程。我们都写过关于 Java 8 及以后的书籍,并开设过专业软件开发的培训课程。在这个过程中,我们被认可为 Java 社区的杰出贡献者和国际会议的演讲者。
多年来,我们发现许多开发者需要对一些核心主题进行入门或复习。设计模式、函数式编程、SOLID 原则和测试等实践经常得到了良好的覆盖,但很少有人展示它们是如何良好地协同工作和相互结合的。有时人们甚至因为选择学习内容的犹豫而放弃提升自己的技能。我们不仅希望教会人们核心技能,还希望用一种易于接近和有趣的方式进行教学。
开发者导向的方法
这本书还为你提供了一种开发者导向的学习机会。它包含了大量的代码示例,每当我们介绍一个主题时,我们总是提供具体的代码示例。你可以得到书中项目的所有代码,所以如果你想跟着做,你甚至可以在集成开发环境(IDE)中逐步运行代码,或者运行程序来试验它们。
技术书籍的另一个常见问题是,它们通常采用正式的讲述风格。这不是普通人之间交流的方式!在这本书中,你将得到一种对话式的风格,能帮助你更好地投入到内容中,而不是让你感到被指责。
书中内容
每一章都围绕一个软件项目进行结构化。在一章的结尾,如果您一直在跟进,您应该能够编写那个项目。这些项目从简单的命令行批处理程序开始,逐渐发展到完整的应用程序。
通过项目驱动的结构,您将从多个方面受益。首先,您可以看到不同的编程技术如何在集成的环境中协同工作。当我们在书的末尾讨论函数式编程时,这不仅仅是抽象的集合处理操作——它们被呈现出来是为了计算实际项目中使用的结果。这解决了教育材料展示好的想法或方法,但开发人员经常不适当或上下文不当使用它们的问题。
其次,项目驱动的方法有助于确保每个阶段您都能看到现实的例子。教育材料通常充满了名为Foo
的示例类和名为bar
的方法。我们的例子与相关的项目有关,并展示如何将这些想法应用到真正的问题中,这些问题与您在职业生涯中可能遇到的类似。
最后,通过这种方式学习更有趣且更引人入胜。每一章都是一个全新的项目和学习新事物的机会。我们希望您能一直读到最后,真正享受阅读过程。每章都以一个挑战开始,并解决该挑战,然后结束评估您学到了什么以及如何解决这个挑战。我们特别在每章的开头和结尾明确指出挑战,以确保您理解其目标。
谁应该阅读这本书?
我们确信,来自各种背景的开发人员会在这本书中找到有用和有趣的内容。话虽如此,有些人会从这本书中获得最大的价值。
初级软件开发人员,通常刚从大学毕业或者从事编程职业几年,是我们认为这本书的核心读者群体。您将学习到我们预计在整个软件开发职业生涯中都具有相关性的基础主题。您并不需要有大学学位,但需要了解编程的基础知识,以便最好地利用这本书。例如,我们不会解释什么是 if 语句或循环。
您不需要对面向对象或函数式编程有很多了解就可以开始学习。在第二章,我们不做任何假设,只要求您知道什么是类,并且能够使用泛型的集合(例如,List<String>
)。我们从基础知识开始。
另一组特别感兴趣的读者是从其他编程语言(如 C#、C ++或 Python)学习 Java 的开发人员。这本书帮助你快速掌握语言结构,以及编写优秀 Java 代码所需的原则、实践和习惯。
如果您是一位经验丰富的 Java 开发者,可能希望跳过第二章,以避免重复已掌握的基础内容,但从第三章开始,将充满对许多开发人员有益的概念和方法。
我们发现学习可以是软件开发中最有趣的部分之一,希望您在阅读本书时也能有同样的感受。祝您在这段旅程中玩得开心。
本书使用的约定
本书采用以下排版约定:
Italic
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及段落内引用程序元素(如变量或函数名称、数据库、数据类型、环境变量、语句和关键字)。
Constant width bold
显示用户应按字面意义输入的命令或其他文本。
Constant width italic
显示应由用户提供值或由上下文确定值的文本。
注意
此元素表示一般说明。
使用代码示例
可以下载补充材料(代码示例、练习等)https://github.com/Iteratr-Learning/Real-World-Software-Development。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
这本书旨在帮助您完成工作。一般而言,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码,否则无需征得我们的许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。出售或分发 O’Reilly 书籍的示例代码需要许可。引用本书并引用示例代码来回答问题无需许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感谢但通常不要求署名。署名通常包括标题、作者、出版社和 ISBN。例如:“《Real-World Software Development》由 Raoul-Gabriel Urma 和 Richard Warburton(O’Reilly)著,版权所有 2020 年由 Functor Ltd.和 Monotonic Ltd.出版,ISBN 978-1-491-96717-1。”
如果您认为您使用的代码示例超出了合理使用范围或上述许可,请随时联系我们:permissions@oreilly.com。
第一章:开始之旅
在本章中,我们将为您介绍本书的概念和原则。总结整体方法的一个好方法是实践和原则优于技术。已经有很多关于特定技术的书籍,我们并不打算增加这堆巨大的书籍。这并不是说专门语言、框架或库的详细知识没有用处。只是相对于适用于更长时间和跨不同语言和框架的一般实践和原则,它的保质期更短。这就是本书能帮助您的地方。
主题
在整本书中,我们采用了基于项目的结构来帮助学习。值得思考的是贯穿各章节的不同主题,它们如何联系在一起,以及我们为什么选择它们。以下是贯穿各章节的四种不同主题。
Java 特性
本书讨论了使用类和接口来结构化代码,详见第二章。我们接着讨论了异常和包,在第三章。您还将简要了解到 lambda 表达式在第三章中的概述。然后在第五章中解释了局部变量类型推断和 switch 表达式,最后在第七章中详细讨论了 lambda 表达式和方法引用。Java 语言特性非常重要,因为许多软件项目都是用 Java 编写的,所以了解它的工作原理是有用的。许多这些语言特性在其他编程语言中也很有用,如 C#、C ++、Ruby 或 Python。尽管这些语言有差异,但理解如何使用类和核心面向对象编程概念将在不同语言中都是宝贵的。
软件设计和架构
在本书中,介绍了一系列设计模式,这些模式帮助您提供了开发人员在开发过程中常见问题的常见解决方案。这些模式很重要,因为尽管每个软件项目可能看起来都不同,都有自己的一套问题,但实际上许多问题以前都遇到过。了解开发人员已解决的常见问题和解决方案,可以避免在新软件项目中重新发明轮子,并使您能够更快速、更可靠地交付软件。
本书的高级耦合与内聚概念在第二章中早早地被引入。通知模式在第三章中被介绍。如何设计用户友好的流畅 API 和建造者模式在第五章中被引入。我们将在第六章中探讨事件驱动和六边形架构的大局观概念,以及在第七章中的仓储模式。最后,在第七章中还介绍了函数式编程。
SOLID
我们在各章节中涵盖了所有的 SOLID 原则。这些原则旨在帮助使软件更易于维护。虽然我们喜欢把编写软件看作是一件有趣的事情,但如果您编写的软件成功了,它将需要不断发展、增长和维护。尽可能地使软件易于维护有助于这种演变、维护和长期功能的增加。我们将讨论 SOLID 原则及其章节:
-
单一职责原则(SRP),讨论在第二章
-
开闭原则(OCP),讨论在第三章
-
里氏替换原则(LSP),讨论在第四章
-
接口隔离原则(ISP),讨论在第五章
-
依赖倒置原则(DIP),讨论在第七章
测试
编写可靠的、随时间易于演变的代码非常重要。自动化测试对此至关重要。随着您编写的软件规模扩大,手动测试不同可能情况变得越来越困难。您需要自动化您的测试流程,以避免在没有自动化的情况下测试软件将需要花费几天人力。
您将在第 2 和 4 章节中学习编写测试的基础知识。这在第五章中扩展为测试驱动开发或 TDD。在第六章中,我们将介绍测试双,包括模拟和存根。
章节总结
这是各章的概要。
第二章,《银行对账单分析器》
您将编写一个程序来分析银行对账单,以帮助人们更好地了解他们的财务状况。这将帮助您更多地了解核心面向对象设计技术,如单一职责原则(SRP)、耦合和内聚。
第三章,《扩展银行对账单分析器》
在这一章中,您将学习如何扩展来自第二章的代码,添加更多功能,使用策略设计模式、开闭原则以及如何使用异常模型故障。
第四章,《文档管理系统》
在这一章中,我们将帮助一位成功的医生更好地管理她的患者记录。这介绍了软件设计中的继承概念,里斯科夫替换原则以及组合与继承之间的权衡。您还将学习如何通过自动化测试代码编写更可靠的软件。
第五章,业务规则引擎
您将了解如何构建核心业务规则引擎 —— 一种定义业务逻辑的灵活且易于维护的方式。本章介绍了测试驱动开发、开发流畅 API 和接口隔离原则的主题。
第六章,Twootr
Twootr 是一个消息平台,使用户能够向关注他们的其他用户广播短消息。本章将构建一个简单的 Twootr 系统的核心部分。您将学习如何从需求出发,一直到应用程序的核心。您还将学习如何使用测试替身来隔离和测试代码库中不同组件之间的交互。
第七章,扩展 Twootr
本书的最后一个基于项目的章节扩展了上一章的 Twootr 实现。它解释了依赖反转原则,并介绍了事件驱动和六边形架构等更大的架构选择。本章还通过涵盖桩和模拟等测试替身以及功能编程技术,帮助您扩展自己的自动化测试知识。
第八章,结论
这个最终总结章节重新审视了本书的主要主题和概念,并提供了进一步的资源,帮助您在编程职业中继续前行。
迭代你自己
作为软件开发人员,您可能会以迭代方式来处理项目。也就是说,分解最高优先级的一两周工作项目,实施它们,然后利用反馈来决定下一组项目。我们发现,评估自己技能进展的方式通常是值得的。
每章的最后都有一个简短的“迭代自己”部分,提出一些建议,帮助您在自己的时间中进一步学习章节内容。
现在您知道本书能为您带来什么,让我们开始工作吧!
第二章:银行对账单分析器
挑战
金融科技行业现在非常热门。马克·厄伯格祖克意识到自己在不同购买上花了很多钱,会受益于自动总结自己的开支。他从银行每月收到对账单,但他觉得有点压力山大。他委托您开发一款软件,可以自动处理他的银行对账单,以便他能更好地了解自己的财务状况。接受挑战!
目标
在本章中,您将学习关于良好软件开发的基础知识,然后在接下来的几章中学习更高级的技术。
您将首先在一个单一类中实现问题陈述。然后您将探讨为什么这种方法在应对不断变化的需求和项目维护方面会面临几个挑战。
但不用担心!您将学习软件设计原则和技术,以确保您编写的代码符合这些标准。您首先将了解单一职责原则(SRP),这有助于开发更易于维护、更容易理解并减少引入新错误范围的软件。在此过程中,您将学习到新概念,如内聚性和耦合性,这些概念对指导您开发的代码和软件的质量非常有用。
注意
本章使用了 Java 8 及以上版本的库和特性,包括新的日期和时间库。
如果您在任何时候想要查看本章的源代码,您可以查看该书代码仓库中的com.iteratrlearning.shu_book.chapter_02
包。
银行对账单分析器需求
您与马克·厄伯格祖克共进了一杯美味的时髦拿铁(没有加糖),以收集需求。因为马克非常精通技术,他告诉您,银行对账单分析器只需要读取一个包含银行交易列表的文本文件。他从他的网上银行门户下载了文件。这个文本是使用逗号分隔的值(CSV)格式结构化的。这是银行交易的样本:
30-01-2017,-100,Deliveroo
30-01-2017,-50,Tesco
01-02-2017,6000,Salary
02-02-2017,2000,Royalties
02-02-2017,-4000,Rent
03-02-2017,3000,Tesco
05-02-2017,-30,Cinema
他想要得到以下问题的答案:
-
从一系列银行对账单中的总利润和损失是多少?是正还是负?
-
特定月份有多少笔银行交易?
-
他的前 10 笔开销是什么?
-
他在哪个类别上花费了大部分的钱?
KISS 原则
让我们从简单的开始。第一个查询如何:“从一系列银行对账单中的总利润和损失是多少?”您需要处理一个 CSV 文件,并计算所有金额的总和。由于没有其他要求,您可以决定不需要创建一个非常复杂的应用程序。
你可以“简洁明了”(KISS),并且将应用程序代码放在一个单独的类中,如示例 2-1 所示。请注意,您现在不必担心可能的异常情况(例如,文件不存在或加载文件失败的情况)。这是您将在第 3 章中学习的一个主题。
注意
CSV 并非完全标准化。它通常被称为由逗号分隔的值。然而,有些人称其为使用不同分隔符(如分号或制表符)的分隔符分隔格式。这些要求可能会增加解析器实现的复杂性。在本章中,我们假设值由逗号(,
)分隔。
示例 2-1. 计算所有语句的总和
public class BankTransactionAnalyzerSimple {
private static final String RESOURCES = "src/main/resources/";
public static void main(final String... args) throws IOException {
final Path path = Paths.get(RESOURCES + args[0]);
final List<String> lines = Files.readAllLines(path);
double total = 0d;
for(final String line: lines) {
final String[] columns = line.split(",");
final double amount = Double.parseDouble(columns[1]);
total += amount;
}
System.out.println("The total for all transactions is " + total);
}
}
这里发生了什么?您正在加载作为应用程序命令行参数传递的 CSV 文件。Path
类表示文件系统中的路径。然后使用Files.readAllLines()
返回行列表。获取文件的所有行后,您可以逐行解析它们:
-
通过逗号拆分列
-
提取金额
-
将金额解析为
double
一旦将给定语句的金额作为double
获取,您可以将其添加到当前总金额中。在处理结束时,您将获得总金额。
示例 2-1 中的代码将可以正常工作,但它忽略了一些边界情况,这些情况在编写生产就绪代码时总是要考虑的:
-
如果文件为空怎么办?
-
如果解析金额失败,因为数据已损坏怎么办?
-
如果语句行数据缺失怎么办?
我们将在第 3 章再次讨论如何处理异常,但保持这类问题的思考习惯是一个好习惯。
如何解决第二个查询:“特定月份有多少银行交易?”你可以做什么?复制粘贴是一种简单的技术,对吧?您可以复制并粘贴相同的代码,并替换逻辑,以选择给定的月份,如示例 2-2 所示。
示例 2-2. 计算一月语句的总和
final Path path = Paths.get(RESOURCES + args[0]);
final List<String> lines = Files.readAllLines(path);
double total = 0d;
final DateTimeFormatter DATE_PATTERN = DateTimeFormatter.ofPattern("dd-MM-yyyy");
for(final String line: lines) {
final String[] columns = line.split(",");
final LocalDate date = LocalDate.parse(columns[0], DATE_PATTERN);
if(date.getMonth() == Month.JANUARY) {
final double amount = Double.parseDouble(columns[1]);
total += amount;
}
}
System.out.println("The total for all transactions in January is " + total);
final 变量
作为一个简短的旁观,我们将解释代码示例中final
关键字的用法。在本书中,我们广泛使用了final
关键字。标记局部变量或字段为final
意味着它不能被重新赋值。在您的项目中是否使用final
是您团队和项目的集体事务,因为其使用既有利也有弊。我们发现,在可能的情况下标记尽可能多的变量为final
,可以清晰地标识对象生命周期内哪些状态是可变的,哪些状态不会被重新赋值。
另一方面,使用final
关键字并不能保证对象的不可变性。你可以有一个final
字段,它引用具有可变状态的对象。我们将在第四章中更详细地讨论不可变性。此外,它的使用还会向代码库中添加大量样板代码。一些团队选择妥协的方法,在方法参数上使用final
字段,以确保它们明确不会被重新赋值,也不是局部变量。
在一个领域中,使用final
关键字几乎没有意义,尽管 Java 语言允许这样做,这是在抽象方法上的方法参数上;例如,在接口中。这是因为缺乏方法体意味着在这种情况下final
关键字没有真正的含义或意义。可以说,自从 Java 10 引入var
关键字以来,final
的使用已经减少,我们稍后在示例 5-15 中讨论这个概念。
代码可维护性和反模式
你认为示例 2-2 中展示的复制粘贴方法是一个好主意吗?是时候退后一步,反思一下发生了什么。当你编写代码时,你应该努力提供良好的代码可维护性。这意味着什么?最好的描述方式是关于你所写代码的属性的愿望清单:
-
应该简单地定位负责特定功能的代码。
-
应该简单地了解代码的功能。
-
添加或删除新功能应该很简单。
-
它应该提供良好的封装性。换句话说,实现细节应该对代码的使用者隐藏起来,这样就更容易理解和进行更改。
考虑到你的一位同事在六个月后查看你的代码,并且你已经去了另一家公司,思考一下你编写的代码对他们的影响是什么。
最终,你的目标是管理你正在构建的应用程序的复杂性。然而,如果随着新需求的出现你继续复制粘贴相同的代码,你将遇到以下问题,这些问题被称为反模式,因为它们是常见的无效解决方案:
-
代码难以理解,因为你有一个庞大的“上帝类”
-
因为代码重复而脆弱且容易受到更改破坏的代码
让我们更详细地解释这两个反模式。
上帝类
将所有代码放在一个文件中,你最终会得到一个巨大的类,这使得理解其目的变得更加困难,因为这个类负责所有事情!如果需要更新现有代码的逻辑(例如,更改解析方式),你如何轻松地定位到该代码并进行更改?这个问题被称为反模式“上帝类”。本质上,你有一个类负责一切。你应该避免这种情况。在下一节中,你将学习单一责任原则,这是一个软件开发指导原则,有助于编写更易理解和维护的代码。
代码重复
对于每一个查询,你都在复制读取和解析输入的逻辑。如果输入要求不再是 CSV 而是 JSON 文件怎么办?如果需要支持多种格式怎么办?添加这样一个功能将是一个痛苦的变更,因为你的代码已经硬编码了一个特定的解决方案,并在多个地方重复了这种行为。因此,所有这些地方都必须更改,你可能会引入新的错误。
注意
你经常会听到“不要重复自己”(DRY)原则。这是一个成功减少重复的想法,逻辑的修改不再需要多次修改你的代码。
相关问题是,如果数据格式发生变化怎么办?代码只支持特定的数据格式模式。如果需要增强(例如,新的列)或支持不同的数据格式(例如,不同的属性名称),你将再次不得不在整个代码中进行多次更改。
结论是,在可能的情况下保持事情简单是好的,但不要滥用 KISS 原则。相反,你需要反思整个应用程序的设计,并理解如何将问题分解为更容易单独管理的子问题。结果是,你将拥有更容易理解、维护和适应新需求的代码。
单一责任原则
单一责任原则(SRP)是一个通用的软件开发指导原则,有助于编写更易管理和维护的代码。
你可以从两个互补的角度思考 SRP:
-
一个类负责一个单一功能
-
一个类只有一个改变的原因¹
SRP 通常应用于类和方法。SRP 关注于一个特定的行为、概念或类别。它导致更健壮的代码,因为它只有一个特定的原因需要更改,而不是多个关注点。多个关注点的原因是问题的,正如你之前看到的那样,它通过可能在多个地方引入错误来复杂化代码的可维护性。它也可能使代码更难理解和更改。
那么在 示例 2-2 中显示的代码中如何应用 SRP 呢?很明显,主类具有多个可以单独分解的责任:
-
读取输入
-
根据给定格式解析输入
-
处理结果
-
报告结果的摘要
本章节将专注于解析部分。在下一章节中,您将学习如何扩展银行对账单分析器,使其完全模块化。
第一个自然的步骤是将 CSV 解析逻辑提取到一个单独的类中,以便您可以将其用于不同的处理查询。让我们称之为 BankStatementCSVParser
,这样就立即清楚它的作用(参见 示例 2-3)。
示例 2-3 将解析逻辑提取到一个单独的类中
public class BankStatementCSVParser {
private static final DateTimeFormatter DATE_PATTERN
= DateTimeFormatter.ofPattern("dd-MM-yyyy");
private BankTransaction parseFromCSV(final String line) {
final String[] columns = line.split(",");
final LocalDate date = LocalDate.parse(columns[0], DATE_PATTERN);
final double amount = Double.parseDouble(columns[1]);
final String description = columns[2];
return new BankTransaction(date, amount, description);
}
public List<BankTransaction> parseLinesFromCSV(final List<String> lines) {
final List<BankTransaction> bankTransactions = new ArrayList<>();
for(final String line: lines) {
bankTransactions.add(parseFromCSV(line));
}
return bankTransactions;
}
}
您可以看到 BankStatementCSVParser
类声明了两个方法,parseFromCSV()
和 parseLinesFromCSV()
,它们生成 BankTransaction
对象,这是一个模拟银行对账单的领域类(参见 示例 2-4 中的声明)。
注意
domain 是什么意思?它指的是使用与业务问题相匹配的词语和术语(即手头的领域)。
BankTransaction
类很有用,因此我们应用程序的不同部分可以共享对银行对账单的相同理解。您会注意到该类为 equals
和 hashcode
方法提供了实现。这些方法的目的以及如何正确实现它们在 第六章 中有所介绍。
示例 2-4 银行交易领域类
public class BankTransaction {
private final LocalDate date;
private final double amount;
private final String description;
public BankTransaction(final LocalDate date, final double amount, final String description) {
this.date = date;
this.amount = amount;
this.description = description;
}
public LocalDate getDate() {
return date;
}
public double getAmount() {
return amount;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return "BankTransaction{" +
"date=" + date +
", amount=" + amount +
", description='" + description + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BankTransaction that = (BankTransaction) o;
return Double.compare(that.amount, amount) == 0 &&
date.equals(that.date) &&
description.equals(that.description);
}
@Override
public int hashCode() {
return Objects.hash(date, amount, description);
}
}
现在您可以重构应用程序,使其使用您的 BankStatementCSVParser
,特别是其 parseLinesFromCSV()
方法,如 示例 2-5 所示。
示例 2-5 使用银行对账单 CSV 解析器
final BankStatementCSVParser bankStatementParser = new BankTransactionCSVParser();
final String fileName = args[0];
final Path path = Paths.get(RESOURCES + fileName);
final List<String> lines = Files.readAllLines(path);
final List<BankTransaction> bankTransactions
= bankStatementParser.parseLinesFromCSV(lines);
System.out.println("The total for all transactions is " + calculateTotalAmount(bankTransactions));
System.out.println("Transactions in January " + selectInMonth(BankTransactions, Month.JANUARY));
您需要实现的不同查询现在不再需要了解内部解析细节,因为您现在可以直接使用 BankTransaction
对象来提取所需的信息。 示例 2-6 中的代码展示了如何声明 calculateTotalAmount()
和 selectInMonth()
方法,它们负责处理交易列表并返回适当的结果。在 第三章 中,您将获得关于 Lambda 表达式和流 API 的概述,这将进一步简化代码。
示例 2-6 处理银行交易列表
public static double calculateTotalAmount(final List<BankTransaction> bankTransactions) {
double total = 0d;
for(final BankTransaction bankTransaction: bankTransactions) {
total += bankTransaction.getAmount();
}
return total;
}
public static List<BankTransaction> selectInMonth(final List<BankTransaction> bankTransactions, final Month month) {
final List<BankTransaction> bankTransactionsInMonth = new ArrayList<>();
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransaction.getDate().getMonth() == month) {
bankTransactionsInMonth.add(bankTransaction);
}
}
return bankTransactionsInMonth;
}
这种重构的主要好处是,您的主要应用程序不再负责实现解析逻辑。它现在将该责任委托给一个单独的类和方法,这些类和方法可以独立维护和更新。随着不同查询的新需求出现,您可以重用 BankStatementCSVParser
类封装的功能。
另外,如果你需要改变解析算法的工作方式(例如,更高效的实现并缓存结果),现在你只需要改变一个地方。此外,你引入了一个名为BankTransaction
的类,其他代码部分可以依赖它而不依赖于特定的数据格式模式。
当你实现方法时,遵循最少惊讶原则是一个好习惯。这将有助于确保在查看代码时清楚地了解发生了什么。这意味着:
-
使用自说明的方法名,这样一看就能立刻知道它们在做什么(例如,
calculateTotalAmount()
)。 -
不要改变参数的状态,因为代码的其他部分可能依赖于它。
最少惊讶原则可能是一个主观的概念。当有疑问时,请与你的同事和团队成员沟通,以确保大家达成一致。
内聚性
到目前为止,你已经学习了三个原则:KISS、DRY和SRP。但你还没有学习到关于代码质量的评估特征。在软件工程中,你经常会听到内聚性作为你编写的代码不同部分的重要特征。听起来很花哨,但这是一个非常有用的概念,可以帮助你评估代码的可维护性。
内聚性关注相关性。更准确地说,内聚性衡量了一个类或方法责任的强相关程度。换句话说,这些事情多么相关?这是一种帮助你理解软件复杂性的方式。你想要实现的是高内聚性,这意味着代码更容易被他人找到、理解和使用。在你之前重构的代码中,BankTransactionCSVParser
类具有很高的内聚性。事实上,它组合了两个与解析 CSV 数据相关的方法。
一般来说,内聚性的概念适用于类(类级内聚性),但也可以应用于方法(方法级内聚性)。
如果你看一下程序的入口点,比如BankStatementAnalyzer
类,你会注意到它的责任是连接你应用程序的不同部分,比如解析器和计算部分,并在屏幕上报告。然而,负责进行计算的逻辑目前被声明为BankStatementAnalyzer
类中的静态方法。这是内聚性差的一个例子,因为在这个类中声明的计算关注点与解析或报告无直接关联。
相反,你可以将计算操作提取到一个名为BankStatementProcessor
的单独类中。你还可以看到,这些操作的方法参数列表是共享的,因此你可以将其包含为该类的一个字段。结果是,你的方法签名变得更简单易懂,类BankStatementProcessor
更加内聚。示例 2-7 中的代码展示了最终的结果。额外的好处是,BankStatementProcessor
的方法可以被应用程序的其他部分重复使用,而不依赖于整个BankStatementAnalyzer
。
示例 2-7. 在 BankStatementProcessor 类中分组计算操作
public class BankStatementProcessor {
private final List<BankTransaction> bankTransactions;
public BankStatementProcessor(final List<BankTransaction> bankTransactions) {
this.bankTransactions = bankTransactions;
}
public double calculateTotalAmount() {
double total = 0;
for(final BankTransaction bankTransaction: bankTransactions) {
total += bankTransaction.getAmount();
}
return total;
}
public double calculateTotalInMonth(final Month month) {
double total = 0;
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransaction.getDate().getMonth() == month) {
total += bankTransaction.getAmount();
}
}
return total;
}
public double calculateTotalForCategory(final String category) {
double total = 0;
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransaction.getDescription().equals(category)) {
total += bankTransaction.getAmount();
}
}
return total;
}
}
现在,你可以像示例 2-8 中所示,使用这个类的方法来处理BankStatementAnalyzer
。
示例 2-8. 使用 BankStatementProcessor 类处理银行交易列表的示例
public class BankStatementAnalyzer {
private static final String RESOURCES = "src/main/resources/";
private static final BankStatementCSVParser bankStatementParser = new BankStatementCSVParser();
public static void main(final String... args) throws IOException {
final String fileName = args[0];
final Path path = Paths.get(RESOURCES + fileName);
final List<String> lines = Files.readAllLines(path);
final List<BankTransaction> bankTransactions = bankStatementParser.parseLinesFrom(lines);
final BankStatementProcessor bankStatementProcessor = new BankStatementProcessor(bankTransactions);
collectSummary(bankStatementProcessor);
}
private static void collectSummary(final BankStatementProcessor bankStatementProcessor) {
System.out.println("The total for all transactions is "
+ bankStatementProcessor.calculateTotalAmount());
System.out.println("The total for transactions in January is "
+ bankStatementProcessor.calculateTotalInMonth(Month.JANUARY));
System.out.println("The total for transactions in February is "
+ bankStatementProcessor.calculateTotalInMonth(Month.FEBRUARY));
System.out.println("The total salary received is "
+ bankStatementProcessor.calculateTotalForCategory("Salary"));
}
}
在接下来的小节中,你将专注于学习指南,帮助你编写更容易理解和维护的代码。
类级别的内聚性
在实践中,你至少会遇到六种常见的方法来分组方法:
-
功能性
-
信息性
-
实用性
-
逻辑性
-
顺序性
-
时间性
请记住,如果你分组的方法之间关系较弱,那么内聚性就较低。我们按顺序讨论它们,表 2-1 提供了一个摘要。
功能性
当你编写BankStatementCSVParser
时所采取的方法是将方法功能分组。方法parseFrom()
和parseLinesFrom()
解决了一个明确定义的任务:解析 CSV 格式的行。事实上,方法parseLinesFrom()
使用了方法parseFrom()
。这通常是实现高内聚性的好方法,因为这些方法一起工作,所以将它们分组以便更容易定位和理解是有意义的。功能内聚的危险在于可能诱导出过多过于简单的类,这些类仅仅分组了一个方法。沿着过于简单的类的道路走下去会增加不必要的冗长和复杂性,因为要考虑的类会更多。
信息性
将方法分组的另一个原因是因为它们作用于相同的数据或域对象。假设你需要一种方式来创建、读取、更新和删除BankTransaction
对象(CRUD 操作);你可能希望有一个专门用于这些操作的类。示例 2-9 中的代码展示了一个具有四个不同方法的信息性内聚类。每个方法都抛出UnsupportedOperationException
以指示当前示例中未实现该方法的体。
示例 2-9. 信息性内聚的示例
public class BankTransactionDAO {
public BankTransaction create(final LocalDate date, final double amount, final String description) {
// ...
throw new UnsupportedOperationException();
}
public BankTransaction read(final long id) {
// ...
throw new UnsupportedOperationException();
}
public BankTransaction update(final long id) {
// ...
throw new UnsupportedOperationException();
}
public void delete(final BankTransaction BankTransaction) {
// ...
throw new UnsupportedOperationException();
}
}
注意
当与维护特定领域对象表的数据库进行接口时,这是一个典型模式。这种模式通常称为数据访问对象(DAO),并需要某种 ID 来识别对象。DAO 实质上是将对数据源的访问抽象和封装,如持久数据库或内存数据库。
这种方法的缺点是这种内聚性会将多个关注点组合在一起,为仅使用和需要某些操作的类引入额外的依赖关系。
实用性
你可能会被诱惑将不同无关的方法组合到一个类中。这种情况通常发生在方法应该放置的位置不明确时,因此你最终得到一个类似于样样通的实用类。
这通常应避免,因为这会导致低内聚性。方法之间没有关联,因此整个类更难推理。此外,实用类展示了发现性差的特征。你希望你的代码易于查找,并且易于理解其应该如何使用。实用类违背了这个原则,因为它们包含了不相关的不同方法,没有明确的分类。
逻辑性
假设你需要为 CSV、JSON 和 XML 提供解析的实现。你可能会被诱惑将负责解析不同格式的方法放到一个类中,如示例 2-10 所示。
示例 2-10. 逻辑内聚性示例
public class BankTransactionParser {
public BankTransaction parseFromCSV(final String line) {
// ...
throw new UnsupportedOperationException();
}
public BankTransaction parseFromJSON(final String line) {
// ...
throw new UnsupportedOperationException();
}
public BankTransaction parseFromXML(final String line) {
// ...
throw new UnsupportedOperationException();
}
}
实际上,这些方法在逻辑上被分类为“解析”。然而,它们的本质是不同的,每个方法都是不相关的。将它们分组也会违反你之前学到的单一责任原则,因为这个类负责多个关注点。因此,不推荐这种方法。
你将在“耦合”中了解到,存在技术来解决在保持高内聚性的同时提供不同解析实现的问题。
顺序性
假设你需要读取文件、解析文件、处理信息并保存信息。你可能会将所有方法都组合到一个单一类中。毕竟,文件读取的输出成为解析的输入,解析的输出成为处理步骤的输入,依此类推。
这被称为顺序内聚性,因为你将方法组合在一起,使它们按照输入到输出的顺序进行。这使得理解操作如何一起工作变得容易。不幸的是,实际操作中,这意味着组合方法的类有多个变更的原因,因此违反了单一责任原则(SRP)。此外,处理、汇总和保存可能有许多不同的方法,因此这种技术很快导致复杂的类。
更好的方法是将每个责任分解到各个内聚力强的类中。
时间性
一个时间上连贯的类是指执行几个仅在时间上相关的操作。一个典型的例子是一个声明某种初始化和清理操作(例如连接和关闭数据库连接)的类,在其他处理操作之前或之后被调用。这些初始化和其他操作之间没有关联,但它们必须按特定的时间顺序调用。
表 2-1. 不同内聚度水平的优缺点总结
内聚度水平 | 优点 | 缺点 |
---|---|---|
功能性(高内聚度) | 易于理解 | 可能导致过于简单的类 |
信息性(中等内聚度) | 易于维护 | 可能导致不必要的依赖关系 |
顺序性(中等内聚度) | 易于定位相关操作 | 鼓励 SRP 的违反 |
逻辑性(中等内聚度) | 提供某种高级别的分类 | 鼓励 SRP 的违反 |
实用性(低内聚度) | 简单实施 | 更难理解类的责任 |
时间性(低内聚度) | 不适用 | 更难理解和使用各个操作 |
方法级内聚度
内聚度原则同样适用于方法。方法执行的功能越多,理解方法实际作用就越困难。换句话说,如果方法处理多个不相关的关注点,则其内聚度较低。内聚度较低的方法也更难测试,因为它们在一个方法中具有多个责任,这使得单独测试这些责任变得困难!通常情况下,如果你发现自己的方法包含一系列的 if/else 块,这些块对类的许多不同字段或方法参数进行修改,则这是你应该将方法拆分为更内聚部分的迹象。
耦合
你编写的代码的另一个重要特征是耦合。而内聚是关于类、包或方法中相关事物的程度,耦合则是关于你对其他类的依赖程度。耦合还可以理解为你对某些类的具体实现(即具体实现细节)的依赖程度。这很重要,因为你依赖的类越多,引入变更时你的灵活性就越低。实际上,受变更影响的类可能会影响到所有依赖它的类。
要理解什么是耦合,可以想象一个时钟。你不需要知道时钟如何工作才能读取时间,因此你并不依赖于时钟的内部机制。这意味着你可以在不影响如何读取时间的情况下更改时钟的内部。这两个关注点(接口和实现)在彼此之间是解耦的。
耦合涉及依赖性有多强。例如,到目前为止,BankStatementAnalyzer
类依赖于BankStatementCSVParser
类。如果需要更改解析器以支持以 JSON 条目编码的对账单或 XML 条目会怎样?这将是一个烦人的重构!但是不用担心,通过使用接口可以解耦不同的组件,这是提供灵活性以适应变化需求的首选工具。
首先,你需要引入一个接口,告诉你如何使用银行对账单的解析器,但不硬编码具体实现,正如示例 2-11 所示。
示例 2-11. 引入一个解析银行对账单的接口
public interface BankStatementParser {
BankTransaction parseFrom(String line);
List<BankTransaction> parseLinesFrom(List<String> lines);
}
现在,你的BankStatementCSVParser
将成为该接口的一个实现:
public class BankStatementCSVParser implements BankStatementParser {
// ...
}
目前为止一切顺利,但如何将BankStatementAnalyzer
从具体的BankStatementCSVParser
实现中解耦?你需要使用接口!通过引入一个名为analyze()
的新方法,该方法接受BankTransactionParser
作为参数,你不再与特定实现耦合(参见示例 2-12)。
示例 2-12. 解耦银行对账单分析器与解析器
public class BankStatementAnalyzer {
private static final String RESOURCES = "src/main/resources/";
public void analyze(final String fileName, final BankStatementParser bankStatementParser)
throws IOException {
final Path path = Paths.get(RESOURCES + fileName);
final List<String> lines = Files.readAllLines(path);
final List<BankTransaction> bankTransactions = bankStatementParser.parseLinesFrom(lines);
final BankStatementProcessor bankStatementProcessor = new BankStatementProcessor(bankTransactions);
collectSummary(bankStatementProcessor);
}
// ...
}
这很棒,因为BankStatementAnalyzer
类不再需要了解不同具体实现的细节,这有助于应对不断变化的需求。图 2-1 展示了在解耦两个类时依赖关系的差异。
图 2-1. 解耦两个类
现在,你可以把所有不同的部分组合起来,创建你的主应用程序,如示例 2-13 所示。
示例 2-13. 运行主应用程序
public class MainApplication {
public static void main(final String... args) throws IOException {
final BankStatementAnalyzer bankStatementAnalyzer
= new BankStatementAnalyzer();
final BankStatementParser bankStatementParser
= new BankStatementCSVParser();
bankStatementAnalyzer.analyze(args[0], bankStatementParser);
}
}
通常,在编写代码时,你会力求实现低耦合。这意味着代码中的不同组件不依赖于内部/实现细节。低耦合的相反称为高耦合,这是你绝对要避免的!
测试
你已经写了一些软件,看起来如果你执行你的应用程序几次,似乎一切都正常工作。然而,你对你的代码会始终工作有多有信心?你能向客户保证你已经满足了需求吗?在本节中,你将学习有关测试以及如何使用最流行和广泛采用的 Java 测试框架 JUnit 编写你的第一个自动化测试。
自动化测试
自动化测试听起来又是一件可能会把你从写代码的有趣部分中带走更多时间的事情!你为什么要在意?
不幸的是,在软件开发中,事情从来不会一次就成功。显然,测试是有益的。你能想象在没有测试软件是否真正有效的情况下集成新的飞机自动驾驶软件吗?
测试并不需要手动操作。在自动化测试中,您拥有一套可以在没有人为干预的情况下自动运行的测试。这意味着当您在代码中引入更改并希望增加对软件行为正确性的信心时,测试可以快速执行。在一个平常的工作日里,专业开发人员通常会运行数百或数千个自动化测试。
在本节中,我们将首先简要回顾自动化测试的好处,以便您清楚地理解为什么测试是良好软件开发核心的一部分。
信心
首先,对软件执行测试以验证行为是否符合规范,可以使您确信已满足客户的要求。您可以将测试规范和结果呈现给客户作为保证。在某种意义上,测试成为了客户的规范。
对变更的鲁棒性
其次,如果您对代码进行更改,如何确保您没有意外破坏任何东西?如果代码很小,您可能认为问题会很明显。但是,如果您正在处理数百万行的代码库呢?对于更改同事的代码,您会有多大的信心?拥有一套自动化测试非常有用,可以检查您是否引入了新的错误。
程序理解
第三,自动化测试对于帮助您理解源代码项目内部不同组件的工作方式非常有用。事实上,测试明确了不同组件的依赖关系以及它们如何相互作用。这对于快速了解软件概览非常有用。比如说,您被分配到一个新项目。您会从哪里开始了解不同组件?测试是一个很好的起点。
使用 JUnit
希望您现在已经认识到编写自动化测试的价值所在。在本节中,您将学习如何使用一种名为 JUnit 的流行 Java 框架创建您的第一个自动化测试。没有免费的午餐。您将看到编写测试需要时间。此外,您还需要考虑编写的测试的长期维护,因为毕竟它是常规代码。然而,前一节列出的好处远远超过了编写测试的不利因素。具体来说,您将编写 单元测试,用于验证小的独立行为单元的正确性,例如方法或小类。在本书中,您将学习编写良好测试的指导方针。在这里,您将首先获得为 BankTransactionCSVParser
编写简单测试的初步概述。
定义一个测试方法
首先的问题是你要在哪里编写你的测试?从 Maven 和 Gradle 构建工具的标准约定来看,你的代码应该放在 src/main/java 中,而测试类则放在 src/test/java 中。你还需要将 JUnit 库作为项目的依赖添加进去。你将在 第三章 中学习更多关于如何使用 Maven 和 Gradle 来组织项目结构的内容。
示例 2-14 展示了对 BankTransactionCSVParser
的简单测试。
注意
我们的 BankStatementCSVParserTest
测试类有 Test
后缀。这并非绝对必要,但通常作为一个有用的提示。
示例 2-14. CSV 解析器的单元测试失败
import org.junit.Assert;
import org.junit.Test;
public class BankStatementCSVParserTest {
private final BankStatementParser statementParser = new BankStatementCSVParser();
@Test
public void shouldParseOneCorrectLine() throws Exception {
Assert.fail("Not yet implemented");
}
}
这里有很多新的部分。让我们逐一分解:
-
单元测试类是一个普通的类,名为
BankStatementCSVParserTest
。按照惯例,在测试类名后面使用Test
后缀是很常见的。 -
这个类声明了一个方法:
shouldParseOneCorrectLine()
。建议总是使用描述性名称,这样一看到测试方法的实现就能立即知道它的作用。 -
这个方法用 JUnit 注解
@Test
进行了标注。这意味着该方法代表一个应该执行的单元测试。你可以在测试类中声明私有的辅助方法,但它们不会被测试运行器执行。 -
此方法的实现调用了
Assert.fail("Not yet implemented")
,这将导致单元测试以诊断消息"Not yet implemented"
失败。你很快将学习如何使用 JUnit 提供的一组断言操作来实际实现一个单元测试。
你可以直接从你喜欢的构建工具(如 Maven 或 Gradle)或使用你的 IDE 执行测试。例如,在 IntelliJ IDE 中运行测试后,你将在 图 2-2 中看到输出。你可以看到测试以诊断信息 “Not yet implemented” 失败。现在让我们看看如何实际实现一个有用的测试,以增加对 BankStatementCSVParser
正确工作的信心。
图 2-2. 在 IntelliJ IDE 中运行失败单元测试的截图
Assert 语句
你刚刚学到了 Assert.fail()
。这是由 JUnit 提供的一个静态方法,称为 断言语句。JUnit 提供了许多断言语句,用于测试特定的条件。它们允许你提供预期结果并将其与某些操作的结果进行比较。
其中一个静态方法叫做 Assert.assertEquals()
。你可以像 示例 2-15 中展示的那样使用它,测试 parseFrom()
的实现对特定输入是否正常工作。
示例 2-15. 使用断言语句
@Test
public void shouldParseOneCorrectLine() throws Exception {
final String line = "30-01-2017,-50,Tesco";
final BankTransaction result = statementParser.parseFrom(line);
final BankTransaction expected
= new BankTransaction(LocalDate.of(2017, Month.JANUARY, 30), -50, "Tesco");
final double tolerance = 0.0d;
Assert.assertEquals(expected.getDate(), result.getDate());
Assert.assertEquals(expected.getAmount(), result.getAmount(), tolerance);
Assert.assertEquals(expected.getDescription(), result.getDescription());
}
那么这里发生了什么?有三个部分:
-
你为你的测试设置上下文。在这种情况下,是一行要解析的内容。
-
你执行一个操作。在这种情况下,你解析输入行。
-
您指定了预期输出的断言。在这里,您检查日期、金额和描述是否被正确解析。
设置单元测试的这种三阶段模式通常被称为Given-When-Then公式。遵循这种模式并拆分不同的部分是一个好主意,因为它有助于清楚地理解测试实际在做什么。
当您再次运行测试时,有点运气的话,您将看到一个漂亮的绿色条表示测试成功,如图 2-3 所示。
图 2-3. 运行通过的单元测试
还有其他可用的断言语句,总结在表 2-2 中。
表 2-2. 断言语句
断言语句 | 目的 |
---|---|
Assert.fail(message) |
让方法失败。这在您实现测试代码之前作为占位符很有用。 |
Assert.assertEquals(expected, actual) |
测试两个值是否相同。 |
Assert.assertEquals(expected, actual, delta) |
断言两个浮点数或双精度数在误差范围内相等。 |
Assert.assertNotNull(object) |
断言对象不为空。 |
代码覆盖率
您编写了您的第一个测试,这很棒!但是如何确定这已经足够了呢?代码覆盖率指的是您的软件源代码(即,多少行或块)被一组测试覆盖的程度。通常,目标是追求高覆盖率,因为它降低了意外错误的几率。没有一个具体的百分比被认为是足够的,但我们建议目标是 70%–90%。在实践中,实际上很难达到 100%的代码覆盖率,因为您可能会测试 getter 和 setter 方法,这提供了较少的价值。
然而,代码覆盖率不一定是测试软件的好指标。事实上,代码覆盖率只告诉您您绝对没有测试的内容。代码覆盖率并不说明您测试的质量。您可能用简单的测试用例覆盖代码的一部分,但不一定覆盖边界情况,这通常会导致问题。
Java 中流行的代码覆盖工具包括JaCoCo、Emma和Cobertura。在实践中,您会看到人们谈论行覆盖率,它告诉您代码覆盖了多少语句。这种技术会给您一种错误的感觉,认为代码覆盖良好,因为条件(if、while、for)将被计算为一个语句。然而,条件具有多个可能的路径。因此,您应该优先考虑分支覆盖,它检查每个条件的真假分支。
收获
-
大类和代码重复导致代码难以推理和维护。
-
单一责任原则帮助您编写更易于管理和维护的代码。
-
内聚性关注一个类或方法的职责之间有多强的相关性。
-
耦合关注的是一个类在代码其他部分的依赖程度。
-
高内聚低耦合是可维护代码的特征。
-
一套自动化测试增加了软件正确性的信心,使其对变更更加健壮,并帮助程序理解。
-
JUnit 是一个 Java 测试框架,允许你指定验证方法和类行为的单元测试。
-
给定-当-那么 是一种将测试分为三个部分的模式,以帮助理解你实现的测试。
在你的迭代中
如果你想深入和巩固本节的知识,可以尝试以下活动:
-
编写几个额外的单元测试用例,以测试 CSV 解析器的实现。
-
支持不同的聚合操作,比如在特定日期范围内查找最大或最小的交易。
-
返回一个按月份和描述分组的支出直方图。
完成挑战
Mark Erbergzuck 对你的银行对账单分析器的第一次迭代非常满意。他采纳了你的想法,并将其重命名为THE Bank Statements Analyzer。他对你的应用非常满意,因此他要求你进行一些增强。事实证明,他希望扩展阅读、解析、处理和汇总功能。例如,他喜欢 JSON。此外,他认为你的测试有些有限,并发现了一些错误。
这是你将在下一章中解决的问题,在那里你将学习异常处理、开闭原则,以及如何使用构建工具构建你的 Java 项目。
¹ 这个定义归因于 Robert Martin。
第三章:扩展银行对账单分析器
挑战
Mark Erbergzuck 对你在前一章的工作非常满意。你建立了一个基本的银行对账单分析器作为最小可行产品。基于这个成功,Mark Erbergzuck 认为你的产品可以进一步发展,并要求你构建一个支持多种功能的新版本。
目标
在上一章中,你学习了如何创建一个分析 CSV 格式银行对账单的应用程序。在这段旅程中,你学习了有助于编写可维护代码的核心设计原则,如单一职责原则,以及应避免的反模式,如上帝类和代码重复。在逐步重构代码的过程中,你还学习了耦合性(你对其他类的依赖程度)和内聚性(类中相关事物的程度)。
尽管如此,该应用目前相当有限。怎么样提供搜索不同类型交易的功能,支持多种格式、处理器,并将结果导出成漂亮的报告,如文本和 HTML?
在本章中,你将深入探索软件开发的路径。首先,你将学习开闭原则,这是为了增加代码灵活性和改善代码维护而必不可少的。你还将学习引入接口的一般准则,以及避免高耦合的其他注意事项。你还将了解在 Java 中使用异常的情况——在定义 API 时包含它们是合适的情况,以及不合适的情况。最后,你将学会如何系统化地使用像 Maven 和 Gradle 这样的成熟构建工具来构建 Java 项目。
注意
如果你想查看本章节的源代码,可以访问本书代码仓库中的com.iteratrlearning.shu_book.chapter_03
包。
扩展银行对账单分析器的需求
你与 Mark Erbergzuck 友好交谈,收集了对银行对账单分析器第二次迭代功能的新要求。他希望扩展你可以执行的操作类型。目前应用程序的功能有限,只能查询特定月份或类别的收入。Mark 提出了两个新功能需求:
-
他还希望能够搜索特定的交易。例如,你应该能够返回在特定日期范围内或特定类别中的所有银行交易。
-
Mark 希望能够生成搜索结果的摘要统计报告,并支持文本和 HTML 等不同格式。
你将按顺序完成这些需求。
开闭原则
让我们从简单的开始。您将实现一个方法,可以找到所有金额超过一定数额的交易。第一个问题是,您应该在哪里声明这个方法?您可以创建一个单独的BankTransactionFinder
类,其中包含一个简单的findTransactions()
方法。但是,在上一章中,您还声明了一个名为BankTransactionProcessor
的类。那么,您应该怎么做呢?在这种情况下,每次需要添加一个单一方法时声明一个新类并没有太多好处。实际上,这会增加整个项目的复杂性,因为它引入了名称的污染,使得理解这些不同行为之间的关系变得更加困难。在BankTransactionProcessor
内声明该方法有助于发现性,因为您立即知道这是一类分组所有执行某种形式处理的方法。既然您已经决定在哪里声明它,您可以按照示例 3-1 中显示的方式实现它。
示例 3-1. 查找金额超过一定数额的银行交易
public List<BankTransaction> findTransactionsGreaterThanEqual(final int amount) {
final List<BankTransaction> result = new ArrayList<>();
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransaction.getAmount() >= amount) {
result.add(bankTransaction);
}
}
return result;
}
这段代码是合理的。但是,如果您还希望在特定月份进行搜索怎么办呢?您需要像示例 3-2 中显示的那样复制此方法。
示例 3-2. 在特定月份查找银行交易
public List<BankTransaction> findTransactionsInMonth(final Month month) {
final List<BankTransaction> result = new ArrayList<>();
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransaction.getDate().getMonth() == month) {
result.add(bankTransaction);
}
}
return result;
}
在前一章中,您已经遇到了代码重复的情况。这是一种代码异味,会导致代码脆弱,特别是如果需求经常变化的情况下。例如,如果迭代逻辑需要更改,您将需要在多个地方重复修改。
这种方法对于更复杂的需求也不起作用。如果我们希望搜索特定月份的交易,并且金额超过一定数额怎么办?您可以按照示例 3-3 中显示的方式实现这个新需求。
示例 3-3. 在特定月份和金额超过一定数额的银行交易
public List<BankTransaction> findTransactionsInMonthAndGreater(final Month month, final int amount) {
final List<BankTransaction> result = new ArrayList<>();
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransaction.getDate().getMonth() == month && bankTransaction.getAmount() >= amount) {
result.add(bankTransaction);
}
}
return result;
}
显然,这种方法表现出了几个缺点:
-
随着必须结合银行交易的多个属性,您的代码会变得越来越复杂。
-
选择逻辑与迭代逻辑耦合在一起,使得更难将它们分开。
-
您继续重复代码。
这就是开闭原则的应用场景。它提倡能够在不修改代码的情况下更改方法或类的行为。在我们的例子中,这意味着可以扩展findTransactions()
方法的行为,而无需复制代码或更改它以引入新的参数。这是如何可能的呢?正如前文所讨论的,迭代和业务逻辑的概念是耦合在一起的。在前一章中,您了解了接口作为一种有用的工具来将概念解耦。在本例中,您将引入一个BankTransactionFilter
接口,它将负责选择逻辑,如示例 3-4 所示。它包含一个名为test()
的方法,返回一个布尔值,并以BankTransaction
对象作为参数。这样,test()
方法就可以访问BankTransaction
的所有属性,以指定任何适当的选择标准。
注意
一个仅包含单个抽象方法的接口自 Java 8 以来被称为函数式接口。你可以使用@FunctionalInterface
注解对其进行注释,以使接口的意图更加明确。
示例 3-4. BankTransactionFilter 接口
@FunctionalInterface
public interface BankTransactionFilter {
boolean test(BankTransaction bankTransaction);
}
注意
Java 8 引入了一个泛型java.util.function.Predicate<T>
接口,它非常适合手头的问题。然而,本章介绍了一个新命名的接口,以避免在书中早期引入过多复杂性。
BankTransactionFilter
接口模拟了BankTransaction
的选择标准概念。现在你可以重构findTransactions()
方法来使用它,如示例 3-5 所示。这种重构非常重要,因为现在你已经通过这个接口引入了一种将迭代逻辑与业务逻辑解耦的方式。你的方法不再依赖于一个特定的过滤器实现。你可以通过将它们作为参数传递来引入新的实现,而无需修改此方法的主体。因此,它现在可以进行扩展而关闭修改。这减少了引入新错误的可能性,因为它最小化了对已实施和测试代码部分所需的级联更改。换句话说,旧代码仍然可以正常工作且未被改动。
示例 3-5. 使用开闭原则灵活的 findTransactions()方法
public List<BankTransaction> findTransactions(final BankTransactionFilter bankTransactionFilter) {
final List<BankTransaction> result = new ArrayList<>();
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransactionFilter.test(bankTransaction)) {
result.add(bankTransaction);
}
}
return result;
}
创建函数式接口的实例
现在 Mark Erbergzuck 很高兴,因为您可以通过调用BankTransactionProcessor
中声明的findTransactions()
方法来实现任何新的需求,并使用BankTransactionFilter
的适当实现。您可以通过实现一个类来实现这一点,如示例 3-6 所示,然后将一个实例作为参数传递给findTransactions()
方法,如示例 3-7 所示。
示例 3-6. 声明实现 BankTransactionFilter 的类
class BankTransactionIsInFebruaryAndExpensive implements BankTransactionFilter {
@Override
public boolean test(final BankTransaction bankTransaction) {
return bankTransaction.getDate().getMonth() == Month.FEBRUARY
&& bankTransaction.getAmount() >= 1_000);
}
}
示例 3-7. 使用特定的 BankTransactionFilter 实现调用 findTransactions()
final List<BankTransaction> transactions
= bankStatementProcessor.findTransactions(new BankTransactionIsInFebruaryAndExpensive());
Lambda 表达式
然而,每当有新需求时,你需要创建特殊的类。这个过程可能会增加不必要的样板代码,并且可能会迅速变得繁琐。自 Java 8 以来,你可以使用一个称为 lambda 表达式 的功能,如 示例 3-8 所示。暂时不要担心这个语法和语言特性。我们将在第七章更详细地学习 lambda 表达式以及一个称为 方法引用 的伴随语言特性。现在,你可以将它看作是,我们不是传递实现接口的对象,而是传递一个代码块——一个没有名称的函数。bankTransaction
是一个参数的名称,箭头 ->
分隔参数和 lambda 表达式的主体,这只是一些代码,用于测试是否应选择银行交易。
示例 3-8. 使用 lambda 表达式实现 BankTransactionFilter
final List<BankTransaction> transactions
= bankStatementProcessor.findTransactions(bankTransaction ->
bankTransaction.getDate().getMonth() == Month.FEBRUARY
&& bankTransaction.getAmount() >= 1_000);
总结一下,开闭原则是一个有用的原则,因为它:
-
通过不更改现有代码来减少代码的脆弱性
-
促进现有代码的重复使用,从而避免代码重复
-
促进解耦,从而实现更好的代码维护
接口的注意事项
到目前为止,你引入了一种灵活的方法来搜索给定选择条件的交易。你经历的重构引发了一个问题,即应该发生什么事情,关于在 BankTransactionProcessor
类中声明的其他方法。它们应该是接口的一部分吗?它们应该包含在一个单独的类中吗?毕竟,在前一章中你实现了另外三个相关方法:
-
calculateTotalAmount()
-
calculateTotalInMonth()
-
calculateTotalForCategory()
我们不建议你采用的一种方法是将所有东西放入一个单一的接口:上帝接口。
上帝接口
你可以采取的一个极端观点是,BankTransactionProcessor
类充当 API。因此,你可能希望定义一个接口,让你能够解耦来自银行交易处理器的多个实现,如 示例 3-9 所示。这个接口包含了银行交易处理器需要实现的所有操作。
示例 3-9. 上帝接口
interface BankTransactionProcessor {
double calculateTotalAmount();
double calculateTotalInMonth(Month month);
double calculateTotalInJanuary();
double calculateAverageAmount();
double calculateAverageAmountForCategory(Category category);
List<BankTransaction> findTransactions(BankTransactionFilter bankTransactionFilter);
}
然而,这种方法显示了几个缺点。首先,随着每一个帮助操作成为显式 API 定义的一个组成部分,这个接口变得越来越复杂。其次,正如你在前一章中看到的,这个接口更像是一个“上帝类”。事实上,这个接口现在已经变成了一个包含所有可能操作的容器。更糟糕的是,你实际上引入了两种额外的耦合形式:
-
在 Java 中,接口定义了每个单独实现必须遵守的契约。换句话说,这个接口的具体实现必须为每个操作提供实现。这意味着改变接口意味着所有具体实现也必须更新以支持这种变化。您添加的操作越多,可能发生的更改就越多,从而增加潜在问题的范围。
-
BankTransaction
的具体属性,如月份和类别,已经成为方法名的一部分;例如,calculateAverageForCategory()
和calculateTotalInJanuary()
。这在接口中更加棘手,因为它们现在依赖于域对象的特定访问器。如果域对象的内部发生变化,那么这也可能导致接口以及所有具体实现的更改。
所有这些原因都是为什么通常建议定义更小的接口。其思想是最小化对域对象多个操作或内部的依赖。
过于细粒度
既然我们刚刚论证过越小越好,您可以采取的另一个极端观点是为每个操作定义一个接口,如示例 3-10 所示。您的BankTransactionProcessor
类将实现所有这些接口。
示例 3-10. 接口过于细粒度
interface CalculateTotalAmount {
double calculateTotalAmount();
}
interface CalculateAverage {
double calculateAverage();
}
interface CalculateTotalInMonth {
double calculateTotalInMonth(Month month);
}
这种方法也不利于改善代码的维护性。实际上,它引入了“反凝聚性”。换句话说,很难发现感兴趣的操作,因为它们隐藏在多个单独的接口中。促进良好的维护的一部分是帮助发现常见操作的可发现性。此外,由于接口过于细粒度,它增加了总体复杂性,并且在项目中引入了许多不同的新类型,需要跟踪。
显式与隐式 API
那么采取实用主义的方法是什么?我们建议遵循开闭原则以增加操作的灵活性,并将最常见的情况定义为类的一部分。它们可以用更一般的方法实现。在这种情况下,接口并不特别适用,因为我们不期望BankTransactionProcessor
有不同的实现。每个这些方法的特殊化并不会使您的整体应用程序受益。因此,在代码库中不需要过度工程化和添加不必要的抽象。BankTransactionProcessor
只是一个允许您对银行交易执行统计操作的类。
这也引发了一个问题,即是否应该声明诸如findTransactionsGreaterThanEqual()
这样的方法,因为这些方法可以很容易地由更通用的findTransactions()
方法实现。这种困境通常被称为显式与隐式 API 的问题。
实际上,有两面考虑的硬币。一方面,像findTransactionsGreaterThanEqual()
这样的方法是不言自明且易于使用的。你不应该担心添加描述性方法名称以帮助提高 API 的可读性和理解性。然而,这种方法限制于特定情况,你很容易会出现为多种需求而创建大量新方法的情况。另一方面,像findTransactions()
这样的方法起初更难使用,需要有良好的文档支持。但它为所有需要查找交易的情况提供了统一的 API。没有一种最佳规则;这取决于你期望的查询类型。如果findTransactionsGreaterThanEqual()
是一个非常常见的操作,将其提取为显式 API 可以让用户更容易理解和使用。
最终的BankTransactionProcessor
的实现如示例 3-11 所示。
示例 3-11. BankTransactionProcessor
类的关键操作
@FunctionalInterface
public interface BankTransactionSummarizer {
double summarize(double accumulator, BankTransaction bankTransaction);
}
@FunctionalInterface
public interface BankTransactionFilter {
boolean test(BankTransaction bankTransaction);
}
public class BankTransactionProcessor {
private final List<BankTransaction> bankTransactions;
public BankStatementProcessor(final List<BankTransaction> bankTransactions) {
this.bankTransactions = bankTransactions;
}
public double summarizeTransactions(final BankTransactionSummarizer bankTransactionSummarizer) {
double result = 0;
for(final BankTransaction bankTransaction: bankTransactions) {
result = bankTransactionSummarizer.summarize(result, bankTransaction);
}
return result;
}
public double calculateTotalInMonth(final Month month) {
return summarizeTransactions((acc, bankTransaction) ->
bankTransaction.getDate().getMonth() == month ? acc + bankTransaction.getAmount() : acc
);
}
// ...
public List<BankTransaction> findTransactions(final BankTransactionFilter bankTransactionFilter) {
final List<BankTransaction> result = new ArrayList<>();
for(final BankTransaction bankTransaction: bankTransactions) {
if(bankTransactionFilter.test(bankTransaction)) {
result.add(bankTransaction);
}
}
return bankTransactions;
}
public List<BankTransaction> findTransactionsGreaterThanEqual(final int amount) {
return findTransactions(bankTransaction -> bankTransaction.getAmount() >= amount);
}
// ...
}
注意
到目前为止,你所见过的许多聚合模式都可以利用 Java 8 引入的 Streams API 来实现,如果你对此熟悉的话。例如,搜索交易可以轻松地指定如下所示:
bankTransactions
.stream()
.filter(bankTransaction -> bankTransaction.getAmount() >= 1_000)
.collect(toList());
尽管如此,Streams API 是使用本节中学到的相同基础和原则实现的。
域类还是原始值?
虽然我们保持了BankTransactionSummarizer
接口定义的简单性,但如果你希望从聚合中返回结果,最好不要返回像double
这样的原始值。这是因为它不能灵活地在以后返回多个结果。例如,summarizeTransaction()
方法返回一个double
。如果你要修改结果签名以包含更多结果,你需要修改每一个BankTransactionProcessor
的实现。
解决这个问题的一个方法是引入一个新的域类,比如Summary
,它包装了double
值。这意味着将来你可以向这个类添加其他字段和结果。这种技术有助于进一步解耦你域中的各种概念,并在需求变化时帮助最小化级联变化。
注意
原始的double
值在存储小数时具有有限的精度。因为有限的位数限制了其精度。考虑的替代方案是java.math.BigDecimal
,它具有任意精度。然而,这种精度是以增加的 CPU 和内存开销为代价的。
多个导出器
在前面的部分中,你了解了开闭原则以及在 Java 中接口的使用。随着 Mark Erbergzuck 有了新的需求,这些知识将会派上用场!你需要导出所选交易列表的摘要统计信息,包括文本、HTML、JSON 等不同格式。从哪里开始?
引入一个领域对象
首先,你需要明确用户想要导出的内容。我们一起探讨各种可能性及其权衡:
一个数字
也许用户只对返回操作结果感兴趣,例如calculateAverageInMonth
。这意味着结果将是一个double
。虽然这是最简单的方法,但正如我们之前提到的,这种方法在应对变化的需求时有些不灵活。假设你创建了一个接受double
作为输入的导出器,这意味着你代码中调用此导出器的每个地方都需要更新,可能会引入新的错误。
一个集合
也许用户希望返回一个交易列表,例如,由findTransaction()
返回的。甚至可以返回一个Iterable
,以提供更多灵活性,指定返回的具体实现。虽然这给了你更多的灵活性,但也将你限制在只能返回一个集合上。如果需要返回多个结果,例如列表和其他摘要信息,该怎么办?
一个专门的领域对象
你可以引入一个新概念,例如SummaryStatistics
,它代表用户有兴趣导出的摘要信息。领域对象只是与你的领域相关的类的实例。通过引入领域对象,你引入了一种解耦形式。实际上,如果有新的需求需要导出额外信息,你可以将其包含为此新类的一部分,而无需引入级联更改。
一个更复杂的领域对象
你可以引入一个称为Report
的概念,它更通用,可以包含各种字段,存储各种结果,包括交易集合。是否需要这样做取决于用户的需求以及是否预期更复杂的信息。再次的好处在于,你能够将生成Report
对象的应用程序的不同部分与消费Report
对象的其他部分解耦。
对于我们的应用程序而言,让我们引入一个领域对象,该对象存储关于交易列表的摘要统计信息。示例 3-12 中的代码显示了其声明。
示例 3-12. 存储统计信息的领域对象
public class SummaryStatistics {
private final double sum;
private final double max;
private final double min;
private final double average;
public SummaryStatistics(final double sum, final double max, final double min, final double average) {
this.sum = sum;
this.max = max;
this.min = min;
this.average = average;
}
public double getSum() {
return sum;
}
public double getMax() {
return max;
}
public double getMin() {
return min;
}
public double getAverage() {
return average;
}
}
定义和实现适当的接口
现在你知道需要导出什么,你将会设计一个 API 来完成它。你需要定义一个名为Exporter
的接口。引入接口的原因是让你能够与多个导出器实现解耦。这符合你在前一节学到的开闭原则。事实上,如果你需要将导出器的实现从 JSON 替换为 XML,这将非常简单,因为它们都将实现相同的接口。你首次尝试定义接口的方法可能如示例 3-13 所示。方法export()
接受一个SummaryStatistics
对象并返回void
。
示例 3-13. 不良的导出器接口
public interface Exporter {
void export(SummaryStatistics summaryStatistics);
}
几个原因应避免这种方法:
-
返回类型
void
毫无用处,也很难理解。我们不知道返回了什么。export()
方法的签名暗示着在某个地方发生了状态改变,或者这个方法将日志记录或信息打印回屏幕。我们不知道! -
返回
void
使得使用断言来测试结果非常困难。实际的结果是什么可以与预期结果进行比较?不幸的是,你无法获取void
的结果。
在这个基础上,你提出了一个返回String
的替代 API,如示例 3-14 所示。现在很明确,Exporter
将返回文本,然后由程序的另一部分决定是否打印、保存到文件,甚至电子发送。文本字符串在测试中也非常有用,因为你可以直接与断言进行比较。
示例 3-14. 良好的导出器接口
public interface Exporter {
String export(SummaryStatistics summaryStatistics);
}
现在你已经定义了一个导出信息的 API,你可以实现各种遵循Exporter
接口契约的导出器。你可以看到在示例 3-15 中实现了一个基本的 HTML 导出器的示例。
示例 3-15. 实现导出器接口
public class HtmlExporter implements Exporter {
@Override
public String export(final SummaryStatistics summaryStatistics) {
String result = "<!doctype html>";
result += "<html lang='en'>";
result += "<head><title>Bank Transaction Report</title></head>";
result += "<body>";
result += "<ul>";
result += "<li><strong>The sum is</strong>: " + summaryStatistics.getSum() + "</li>";
result += "<li><strong>The average is</strong>: " + summaryStatistics.getAverage() + "</li>";
result += "<li><strong>The max is</strong>: " + summaryStatistics.getMax() + "</li>";
result += "<li><strong>The min is</strong>: " + summaryStatistics.getMin() + "</li>";
result += "</ul>";
result += "</body>";
result += "</html>";
return result;
}
}
异常处理
到目前为止,我们还没有讨论当事情出错时会发生什么。你能想到银行分析软件可能失败的情况吗?例如:
-
如果数据无法正确解析会怎么样?
-
如果无法读取包含要导入的银行交易的 CSV 文件会怎么样?
-
如果运行应用程序的硬件资源,如 RAM 或磁盘空间,不足会怎么样?
在这些场景中,你将会收到一个包含堆栈跟踪显示问题来源的可怕错误消息。示例 3-16 中的片段展示了这些意外错误的示例。
示例 3-16. 意外问题
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 0
Exception in thread "main" java.nio.file.NoSuchFileException: src/main/resources/bank-data-simple.csv
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
为什么要使用异常?
让我们暂时专注于BankStatementCSVParser
。我们如何处理解析问题?例如,文件中的 CSV 行可能没有按预期格式编写:
-
CSV 行可能比预期的三列多。
-
CSV 行可能少于预期的三列。
-
一些列的数据格式可能不正确,例如,日期可能是不正确的。
回到 C 编程语言令人恐惧的日子,您将添加许多 if 条件检查,这些检查将返回一个神秘的错误代码。这种方法有几个缺点。首先,它依赖全局共享的可变状态来查找最近的错误。这使得更难以理解代码中单独的部分。因此,您的代码变得更难维护。其次,这种方法容易出错,因为您需要区分作为值编码的真实值和错误。在这种情况下,类型系统是薄弱的,对程序员不够友好。最后,控制流与业务逻辑混合在一起,这导致代码更难维护和独立测试。
为了解决这些问题,Java 将异常作为一流语言特性引入,带来了许多好处:
文档
语言支持异常作为方法签名的一部分。
类型安全性
类型系统确定您是否处理了异常流。
关注点分离
业务逻辑和异常恢复通过 try/catch 块分开。
问题在于作为语言特性的异常也增加了更多的复杂性。您可能熟悉 Java 区分两种异常的事实:
已检查的异常
这些是预期能够从中恢复的错误。在 Java 中,您必须声明一个方法及其可以抛出的已检查异常列表。如果没有,您必须为该特定异常提供合适的 try/catch 块。
未检查的异常
这些是在程序执行期间可以随时抛出的错误。方法不必在其签名中显式声明这些异常,并且调用者不必像处理已检查异常那样显式处理它们。
Java 异常类按照明确定义的层次结构进行组织。 图 3-1 描绘了 Java 中的这种层次结构。 Error
和 RuntimeException
类是未经检查的异常,并且是 Throwable
的子类。您不应该期望捕获并从中恢复。类 Exception
通常表示程序应该能够从中恢复的错误。
图 3-1. Java 中的异常层次结构
异常的模式和反模式
在什么场景下应该使用哪类异常?您可能还想知道应该如何更新BankStatementParser
API 以支持异常。不幸的是,这并没有简单的答案。在决定适合您的正确方法时,需要一些实用主义。
在解析 CSV 文件时,有两个独立的关注点:
-
解析正确的语法(例如,CSV,JSON)
-
数据的验证(例如,文本描述应少于 100 个字符)
首先关注语法错误,然后是数据的验证。
在未检查和已检查之间做出决定
有些情况下,CSV 文件可能不符合正确的语法(例如,缺少分隔逗号)。忽略这个问题将导致应用程序运行时出现混乱的错误。支持在代码中使用异常的部分好处之一是在问题出现时为 API 用户提供更清晰的诊断。因此,您决定添加如下示例代码中所示的简单检查,在 示例 3-17 中抛出 CSVSyntaxException
异常。
示例 3-17. 抛出语法异常
final String[] columns = line.split(",");
if(columns.length < EXPECTED_ATTRIBUTES_LENGTH) {
throw new CSVSyntaxException();
}
CSVSyntaxException
应该是已检查异常还是未检查异常?要回答这个问题,您需要问自己是否需要用户采取强制性的恢复操作。例如,如果是瞬态错误,用户可以实现重试机制;或者在屏幕上显示消息以增加应用程序的响应性。通常,由于业务逻辑验证错误(例如,错误格式或算术错误),应该使用未检查异常,因为它们会在代码中增加大量的 try/catch 代码。恢复机制也可能不明显。因此,在您的 API 用户身上施加这些是没有意义的。此外,系统错误(例如,磁盘空间不足)也应该是未检查异常,因为客户端无能为力。简而言之,建议是尽量使用未检查异常,仅在必要时使用已检查异常,以避免代码中的显著混乱。
现在让我们解决一下当你知道数据遵循正确的 CSV 格式后如何验证数据的问题。你将学习使用异常进行验证时的两种常见反模式。然后,你将学习通知模式,它为这个问题提供了一个可维护的解决方案。
过于具体
第一个浮现在你脑海中的问题是在哪里添加验证逻辑?你可以在 BankStatement
对象的构建时直接添加。然而,我们建议为此创建一个专门的 Validator
类,有几个理由:
-
当需要重用验证逻辑时,您无需重复编写它。
-
您可以确信系统的不同部分以相同的方式进行验证。
-
您可以轻松地单独对这个逻辑进行单元测试。
-
它遵循 SRP 原则,这导致了更简单的维护和程序理解。
有多种方法来使用异常来实现您的验证器。一个过于具体的方法示例在示例 3-18 中展示。您已经考虑了每一个边缘情况来验证输入,并将每个边缘情况转换为一个已检查的异常。异常DescriptionTooLongException
、InvalidDateFormat
、DateInTheFutureException
和InvalidAmountException
都是用户定义的已检查异常(即它们扩展了类Exception
)。尽管这种方法允许您为每个异常指定精确的恢复机制,但显然这是低效的,因为它需要大量设置,声明多个异常,并强制用户明确处理每一个异常。这与帮助用户理解和简单使用您的 API 的初衷背道而驰。此外,您不能将所有错误作为整体收集起来以便向用户提供列表。
示例 3-18. 过于具体的异常
public class OverlySpecificBankStatementValidator {
private String description;
private String date;
private String amount;
public OverlySpecificBankStatementValidator(final String description, final String date, final String amount) {
this.description = Objects.requireNonNull(description);
this.date = Objects.requireNonNull(description);
this.amount = Objects.requireNonNull(description);
}
public boolean validate() throws DescriptionTooLongException,
InvalidDateFormat,
DateInTheFutureException,
InvalidAmountException {
if(this.description.length() > 100) {
throw new DescriptionTooLongException();
}
final LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(this.date);
}
catch (DateTimeParseException e) {
throw new InvalidDateFormat();
}
if (parsedDate.isAfter(LocalDate.now())) throw new DateInTheFutureException();
try {
Double.parseDouble(this.amount);
}
catch (NumberFormatException e) {
throw new InvalidAmountException();
}
return true;
}
}
过于冷漠
另一种极端是将所有东西作为未检查异常处理;例如,通过使用IllegalArgumentException
。示例 3-19 中的代码展示了遵循此方法实现的validate()
方法。这种方法的问题在于您无法有特定的恢复逻辑,因为所有异常都是相同的!此外,您仍然无法将所有错误作为整体收集起来。
示例 3-19. 到处都是 IllegalArgumentException 异常
public boolean validate() {
if(this.description.length() > 100) {
throw new IllegalArgumentException("The description is too long");
}
final LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(this.date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isAfter(LocalDate.now())) throw new IllegalArgumentException("date cannot be in the future");
try {
Double.parseDouble(this.amount);
}
catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid format for amount", e);
}
return true;
}
接下来,您将学习通知模式,该模式提供了解决过于具体和过于冷漠反模式所突出的缺点的解决方案。
通知模式
通知模式旨在为您使用过多未检查异常的情况提供解决方案。解决方案是引入一个域类来收集错误。¹
您首先需要一个Notification
类,其责任是收集错误。示例 3-20 中的代码展示了其声明。
示例 3-20. 引入域类 Notification 来收集错误
public class Notification {
private final List<String> errors = new ArrayList<>();
public void addError(final String message) {
errors.add(message);
}
public boolean hasErrors() {
return !errors.isEmpty();
}
public String errorMessage() {
return errors.toString();
}
public List<String> getErrors() {
return this.errors;
}
}
引入这样一个类的好处是,现在您可以声明一个能够在一次通过中收集多个错误的验证器。这在您之前探索的两种方法中是不可能的。现在,您可以简单地将消息添加到Notification
对象中,如示例 3-21 所示。
示例 3-21. 通知模式
public Notification validate() {
final Notification notification = new Notification();
if(this.description.length() > 100) {
notification.addError("The description is too long");
}
final LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(this.date);
if (parsedDate.isAfter(LocalDate.now())) {
notification.addError("date cannot be in the future");
}
}
catch (DateTimeParseException e) {
notification.addError("Invalid format for date");
}
final double amount;
try {
amount = Double.parseDouble(this.amount);
}
catch (NumberFormatException e) {
notification.addError("Invalid format for amount");
}
return notification;
}
使用异常的指南
现在您已经了解了可能使用异常的情况,让我们讨论一些通用准则,有效地在您的应用程序中使用它们。
不 不要忽略异常
忽略异常永远不是一个好主意,因为你将无法诊断问题的根源。 如果没有明显的处理机制,那么抛出未检查的异常。 这样,如果您确实需要处理已检查的异常,那么在运行时看到问题后,您将被迫返回并处理它。
不要捕获通用的 Exception
尽可能捕获特定的异常以提高可读性和支持更具体的异常处理。 如果捕获通用的Exception
,它还包括RuntimeException
。 一些 IDE 可以生成过于一般化的捕获子句,因此您可能需要考虑使捕获子句更具体。
记录异常
在 API 级别记录异常,包括未检查的异常,以便于故障排除。 实际上,未检查的异常报告应解决的问题的根本。 示例 3-22 中的代码显示了使用@throws
Javadoc 语法记录异常的示例。
示例 3-22. 记录异常
@throws NoSuchFileException if the file does not exist
@throws DirectoryNotEmptyException if the file is a directory and
could not otherwise be deleted because the directory is not empty
@throws IOException if an I/O error occurs
@throws SecurityException In the case of the default provider,
and a security manager is installed, the {@link SecurityManager#checkDelete(String)}
method is invoked to check delete access to the file
注意特定于实现的异常
不要抛出特定于实现的异常,因为它会破坏您 API 的封装性。 例如,在 示例 3-23 中的 read()
的定义迫使任何未来的实现在显然与 Oracle 完全无关的情况下抛出一个 OracleException
!
示例 3-23. 避免特定于实现的异常
public String read(final Source source) throws OracleException { ... }
异常与控制流的比较
不要为控制流使用异常。 Java 中的 示例 3-24 中的代码展示了异常的错误使用。 该代码依赖异常来退出读取循环。
示例 3-24. 用于控制流的异常使用
try {
while (true) {
System.out.println(source.read());
}
}
catch(NoDataException e) {
}
几个理由应该避免此类代码。 首先,它会导致代码可读性差,因为异常 try/catch 语法会增加不必要的混乱。 其次,它使您代码的意图不太容易理解。 异常被设计为处理错误和异常情况的功能。 因此,在确实需要抛出异常之前最好不要创建异常。 最后,与抛出异常相关的堆栈跟踪会带来额外开销。
异常的替代方案
您已经学习了在 Java 中使用异常以使您的银行对账单分析器更健壮和易理解。 然而,除了异常,有哪些替代方案呢? 我们简要描述了四种替代方法及其优缺点。
使用 null
不要像抛出具体异常那样,你可以问为什么不能像在 示例 3-25 中显示的那样返回null
。
示例 3-25. 返回 null 而不是异常
final String[] columns = line.split(",");
if(columns.length < EXPECTED_ATTRIBUTES_LENGTH) {
return null;
}
绝对要避免这种方法。事实上,null
对调用者毫无用处的信息。而且,由于 API 返回 null
,这也很容易出错。实际上,这会导致许多 NullPointerException
和大量不必要的调试!
空对象模式
在 Java 中,有时你会看到采用的一种 空对象模式。简而言之,与其返回一个 null
引用来表示对象的缺失,你可以返回一个实现了期望接口但方法体为空的对象。这种策略的优势在于,你不会遇到意外的 NullPointer
异常以及一长串的 null
检查。事实上,这个空对象非常可预测,因为它在功能上什么也不做!然而,这种模式也可能存在问题,因为你可能会用一个简单忽略真正问题的对象隐藏数据潜在的问题,从而导致故障排除更加困难。
Optional
Java 8 引入了一个内置数据类型 java.util.Optional<T>
,专门用于表示值的存在或缺失。Optional<T>
提供了一组方法来显式处理值的缺失,这对于减少错误的范围非常有用。它还允许你将各种 Optional
对象组合在一起,这些对象可能作为不同 API 的返回类型返回。例如,在流 API 中的 findAny()
方法。你将在第七章学习如何在你的代码中使用 Optional<T>
。
Try
还有另一种数据类型叫做 Try<T>
,它表示可能成功或失败的操作。在某种程度上,它类似于 Optional<T>
,但不是处理值,而是处理操作。换句话说,Try<T>
数据类型带来了类似的代码组合性好处,并且帮助减少代码中的错误。不幸的是,Try<T>
数据类型并没有内置到 JDK 中,而是由你可以查看的外部库支持。
使用构建工具
到目前为止,你已经学习了良好的编程实践和原则。但是关于如何构建、构造和运行你的应用程序呢?本节重点介绍为什么使用构建工具来管理你的项目是必要的,以及如何使用 Maven 和 Gradle 等构建工具以可预测的方式构建和运行你的应用程序。在第五章,你将更多地了解如何有效地使用 Java 包来组织应用程序。
为什么使用构建工具?
让我们考虑执行应用程序的问题。您需要注意几个要素。首先,一旦编写了项目的代码,您将需要编译它。为此,您将必须使用 Java 编译器(javac)。您记得编译多个文件所需的所有命令吗?对于多个包怎么办?如果需要导入其他 Java 库,如何管理依赖关系?如果项目需要以特定格式(如 WAR 或 JAR)打包怎么办?突然间事情变得混乱起来,开发者面临越来越大的压力。
为了自动化所有需要的命令,您需要创建一个脚本,这样您就不必每次重复命令。引入新脚本意味着所有当前和未来的队友都需要熟悉您的思维方式,以便在需求变化时维护和更改脚本。其次,需要考虑软件开发生命周期。这不仅仅是开发和编译代码。测试和部署如何处理呢?
解决这些问题的方法是使用构建工具。您可以将构建工具视为助手,可以自动化软件开发生命周期中的重复任务,包括构建、测试和部署应用程序。构建工具有许多好处:
-
它为您提供了一个通用的项目结构,使您的同事立即感到熟悉和舒适。
-
它为您提供了一个可重复和标准化的过程来构建和运行应用程序。
-
您花费更多时间在开发上,而不是在低级配置和设置上。
-
您通过减少由于错误配置或缺少步骤而引入错误的范围。
-
您可以通过重用常见的构建任务而不是重新实现它们来节省时间。
您现在将探索 Java 社区中使用的两个流行构建工具:Maven 和 Gradle。²
使用 Maven
Maven 在 Java 社区中非常流行。它允许您描述软件的构建过程以及其依赖关系。此外,有一个大型社区在维护仓库,Maven 可以使用这些仓库自动下载应用程序使用的库和依赖项。Maven 最初发布于 2004 年,您可能会想到,那时候 XML 非常流行!因此,Maven 中的构建过程声明是基于 XML 的。
项目结构
Maven 的伟大之处在于,从一开始它就带有帮助维护的结构。一个 Maven 项目始于两个主要文件夹:
/src/main/java
这是您将开发和查找项目所需的所有 Java 类的地方。
src/test/java
这是您将开发和查找项目所有测试的地方。
还有两个有用但不是必需的附加文件夹:
src/main/resources
这里您可以包含应用程序需要的额外资源,例如文本文件。
src/test/resources
这里是您可以包含测试中使用的额外资源的地方。
拥有这种常见的目录布局使得任何熟悉 Maven 的人都能立即找到重要文件。为了指定构建过程,您需要创建一个 pom.xml 文件,在其中指定各种 XML 声明以记录构建应用程序所需的步骤。图 3-2 总结了常见的 Maven 项目布局。
图 3-2. Maven 标准目录布局
示例构建文件
下一步是创建 pom.xml 文件,该文件将指导构建过程。示例 3-26 中的代码片段展示了用于构建银行对账单分析器项目的基本示例。在这个文件中,你将看到几个元素:
project
这是所有 pom.xml 文件的顶级元素。
groupId
此元素指示创建项目的组织的唯一标识符。
artifactId
此元素为构建过程中生成的构件指定一个唯一的基本名称。
packaging
此元素指示要由此构件使用的包类型(例如 JAR、WAR、EAR 等)。如果 XML 元素 packaging
被省略,则默认为 JAR。
version
项目生成的构件的版本。
build
此元素指定各种配置,以指导构建过程,如插件和资源。
dependencies
此元素为项目指定一个依赖项列表。
示例 3-26. Maven 中的构建文件 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.iteratrlearning</groupId>
<artifactId>bankstatement_analyzer</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>9</source>
<target>9</target>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependenciesn>
</project>
Maven 命令
一旦设置了 pom.xml,下一步是使用 Maven 构建和打包您的项目!有各种可用的命令。我们只涵盖基础知识:
mvn clean
清理先前构建的任何生成构件
mvn compile
编译项目的源代码(默认情况下生成到 target 文件夹)
mvn test
测试编译后的源代码
mvn package
将编译后的代码打包成适当的格式,如 JAR
例如,在存放 pom.xml 文件的目录中运行命令 mvn package
将产生类似以下的输出:
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building bankstatement_analyzer 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.063 s
[INFO] Finished at: 2018-06-10T12:14:48+01:00
[INFO] Final Memory: 10M/47M
您将在 target 文件夹中看到生成的 JAR bankstatement_analyzer-1.0-SNAPSHOT.jar。
注意
如果要使用 mvn
命令运行生成构件中的主类,您需要查看 exec 插件。
使用 Gradle
在 Java 领域,Maven 并不是唯一的构建工具解决方案。Gradle 是 Maven 的一个备受欢迎的替代构建工具。但是你可能会想为什么要使用另一个构建工具?难道 Maven 不是被广泛采用吗?Maven 的一个缺点是使用 XML 可能会使事情变得不太可读,且操作起来更加繁琐。例如,作为构建过程的一部分,通常需要提供各种自定义系统命令,如复制和移动文件。使用 XML 语法指定此类命令并不自然。此外,XML 通常被认为是一种冗长的语言,这可能增加维护成本。然而,Maven 提出了很多好的想法,如项目结构的标准化,这些都是 Gradle 的灵感来源之一。Gradle 最大的优势之一是它使用友好的领域特定语言(DSL),使用 Groovy 或 Kotlin 编程语言来指定构建过程。因此,指定构建更加自然,更容易定制,更简单理解。此外,Gradle 支持缓存和增量编译等功能,有助于缩短构建时间。³
示例构建文件
Gradle 与 Maven 遵循类似的项目结构。但是,与 pom.xml 文件不同,你将声明一个 build.gradle 文件。还有一个 settings.gradle 文件,包含多项目构建的配置变量和设置。在 示例 3-27 的代码片段中,你可以找到一个用 Gradle 编写的小型构建文件,与你在 示例 3-26 中看到的 Maven 示例等价。你必须承认,这要简洁得多!
示例 3-27. Gradle 中的构建文件 build.gradle
apply plugin: 'java'
apply plugin: 'application'
group = 'com.iteratrlearning'
version = '1.0-SNAPSHOT'
sourceCompatibility = 9
targetCompatibility = 9
mainClassName = "com.iteratrlearning.MainApplication"
repositories {
mavenCentral()
}
dependencies {
testImplementation group: 'junit', name: 'junit', version:'4.12'
}
Gradle 命令
最后,现在你可以通过运行与 Maven 学到的类似命令来运行构建过程。Gradle 中的每个命令都是一个任务。你可以定义自己的任务并执行它们,或者使用诸如 test
、build
和 clean
等内置任务:
gradle clean
清理上一个构建过程期间生成的文件
gradle build
打包应用程序
gradle test
运行测试
gradle run
运行指定的 mainClassName
中的主类,前提是应用了 application
插件。
例如,运行 gradle build
将会产生类似于以下输出:
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed
你将会在由 Gradle 在构建过程中创建的 build
文件夹中找到生成的 JAR 文件。
主要内容
-
开闭原则促进了能够在不修改代码的情况下改变方法或类的行为的理念。
-
开闭原则通过不改变现有代码减少了代码的脆弱性,促进了现有代码的重用性,并推动了解耦,从而有助于更好地维护代码。
-
太多具体方法的接口会引入复杂性和耦合。
-
如果一个接口过于细粒化,只有单个方法,可能会引入与内聚相反的情况。
-
你不应该担心为提升 API 的可读性和理解性而添加描述性方法名。
-
返回
void
作为操作结果会使其行为难以测试。 -
Java 中的异常有助于文档编写、类型安全和关注点分离。
-
尽量少用已检查异常,而不是默认的,因为它们会导致显著的混乱。
-
过于具体的异常会使软件开发效率降低。
-
通知模式引入了一个领域类来收集错误。
-
不要忽略异常或捕获通用的
Exception
,否则将失去诊断问题根源的好处。 -
构建工具自动化软件开发生命周期中的重复任务,包括构建、测试和部署应用程序。
-
Maven 和 Gradle 是 Java 社区中使用的两种流行的构建工具。
在你的迭代过程中
如果你想扩展和巩固本节的知识,你可以尝试以下活动之一:
-
添加支持以不同数据格式(包括 JSON 和 XML)导出的功能。
-
开发围绕银行对账单分析器的基本 GUI。
完成挑战
Mark Erbergzuck 对你的银行对账单分析器的最终迭代非常满意。几天后,世界迎来了新的金融危机,你的应用程序开始走红。是时候在下一章节上着手新的激动人心的项目了!
¹ 这种模式最初由马丁·福勒提出。
² 在 Java 早期有另一种流行的构建工具,叫做 Ant,但现在被视为终止生命周期,不应再使用。
³ 欲了解更多有关 Maven 与 Gradle 的信息,请参见https://gradle.org/maven-vs-gradle/。
第四章:文档管理系统
挑战
成功为 Mark Erbergzuck 实施了先进的银行对账单分析器后,您决定做些杂事——包括去看牙医。Avaj 博士成功地经营了她的诊所多年。她的快乐患者在老年时依然保持着洁白的牙齿。这样一个成功实践的缺点是,每年都会生成更多的患者文件。每次她需要找到早期治疗记录时,她的助手们花费的时间越来越长。
她意识到现在是自动化管理这些文件并跟踪它们的时间了。幸运的是,她有一个可以为她做这些的患者!您将通过为她编写软件来管理这些文件,并使她能够找到信息,以便她的实践能够茁壮成长。
目标
在本章中,您将学习各种软件开发原则。管理文档设计的关键在于继承关系,这意味着扩展一个类或实现一个接口。为了正确地做到这一点,您将了解 Liskov 替换原则,这是以著名计算机科学家芭芭拉·利斯科夫命名的。
通过讨论“组合优于继承”原则,您将进一步了解何时使用继承。
最后,通过理解如何编写良好且易维护的自动化测试代码来扩展你的知识。既然我们已经剧透了这一章的内容,让我们回到理解 Avaj 博士对文档管理系统的需求。
注意
如果您想随时查看本章的源代码,可以查看书的代码库中的com.iteratrlearning.shu_book.chapter_04
包。
文档管理系统需求
与 Avaj 博士友好地喝茶后,她透露她希望将她想要管理的文件作为计算机上的文件。文档管理系统需要能够导入这些文件,并记录每个文件的一些信息,这些信息可以被索引和搜索。她关心的文档类型有三种:
报告
详细描述对患者进行的某些咨询或手术的文本内容。
信件
发送到地址的文本文档。(想想看,你可能已经很熟悉这些了。)
图像
牙科实践经常记录牙齿和牙龈的 X 光或照片。这些都有一个大小。
此外,所有文档都需要记录被管理文件的路径以及文档所涉及的患者。Avaj 博士需要能够搜索这些文档,并查询关于不同类型文档的每个属性是否包含某些信息;例如,搜索正文包含“Joe Bloggs”的信件。
在谈话中,你还确认 Avaj 博士可能希望在将来添加其他类型的文档。
完善设计
解决这个问题时,有很多重要的设计选择和建模方法可以选择。这些选择是主观的,你可以在阅读本章之前或之后尝试编写解决 Avaj 博士问题的解决方案。在“替代方法”中,你可以看到我们避免不同选择的原因以及背后的基本原则。
接近任何程序的一个很好的第一步是采用测试驱动开发(TDD),这也是我们在编写书中示例解决方案时所做的。我们将在第五章第五章介绍 TDD,所以让我们开始考虑你的软件需要执行的行为,并逐步完善实现这些行为的代码。
文档管理系统应该能够根据请求导入文档,并将它们添加到其内部的文档存储中。为了满足这一要求,让我们创建 DocumentManagementSystem
类并添加两个方法:
void importFile(String path)
接受用户想要导入到文档管理系统的文件的路径。由于这是一个公共 API 方法,在生产系统中可能会接收来自用户的输入,所以我们将路径作为 String
而不是依赖更类型安全的类,如 java.nio.Path
或 java.io.File
。
List<Document> contents()
返回文档管理系统当前存储的所有文档的列表。
你会注意到 contents()
返回一个 Document
类的列表。我们尚未说明这个类包含什么,但它会适时出现。目前,你可以假装它是一个空类。
导入器
这个系统的一个关键特性是,我们需要能够导入不同类型的文档。为了这个系统的目的,你可以依赖文件的扩展名来决定如何导入它们,因为 Avaj 博士一直在保存具有非常特定扩展名的文件。她所有的信件都使用 .letter 扩展名,报告使用 .report,而 .jpg 是唯一使用的图片格式。
最简单的做法是将所有导入机制的代码都放在一个方法中,就像示例 4-1 中所示。
示例 4-1. 扩展名切换示例
switch(extension) {
case "letter":
// code for importing letters.
break;
case "report":
// code for importing reports.
break;
case "jpg":
// code for importing images.
break;
default:
throw new UnknownFileTypeException("For file: " + path);
}
这种方法可以解决问题,但很难扩展。每次想要添加另一种要处理的文件类型时,你都需要在 switch
语句中实现另一个条目。随着时间的推移,这种方法会变得难以管理和阅读。
如果保持你的主类简单明了,并分割出不同的实现类来导入不同类型的文档,那么很容易找到并理解每个导入器的作用。为了支持不同的文档类型,定义了一个Importer
接口。每个Importer
将是一个可以导入不同类型文件的类。
现在我们知道我们需要一个接口来导入文件,那么应该如何表示将要导入的文件呢?我们有几种不同的选择:使用简单的String
表示文件的路径,或者使用表示文件的类,例如java.io.File
。
你可以说我们应该在这里应用强类型原则:选择一个表示文件并减少错误范围的类型,而不是使用String
。让我们采用这种方法,并在我们的Importer
接口中使用java.io.File
对象作为表示要导入的文件的参数,如示例 4-2 所示。
示例 4-2. 导入器
interface Importer {
Document importFile(File file) throws IOException;
}
你可能会问,为什么你不在DocumentManagementSystem
的公共 API 中也使用File
呢?好吧,在这个应用程序的情况下,我们的公共 API 可能会被包装在某种用户界面中,我们不确定以文件形式存在的形式。因此,我们保持事情简单,只使用了String
类型。
文档类
在这一时间点上,让我们也定义Document
类。每个文档将有多个我们可以搜索的属性。不同的文档有不同类型的属性。在定义Document
时,我们有几个不同的选项可以权衡其利弊。
表示文档的第一种最简单的方法是使用Map<String, String>
,这是一个从属性名称到与这些属性相关联的值的映射。那么为什么不在整个应用程序中传递一个Map<String, String>
呢?引入一个领域类来模拟单个文档不仅仅是在遵循面向对象编程思想,而且还提供了一系列实际的应用程序维护性和可读性的改进。
首先,给应用程序中的组件起具体的名称的价值无法估量。沟通至上!优秀的软件开发团队使用普遍语言来描述他们的软件。将你在应用程序代码中使用的词汇与与像阿瓦吉博士这样的客户交流时使用的词汇相匹配,可以极大地简化维护工作。当你与同事或客户交流时,你必须一致同意描述软件不同方面的一些共同语言。通过将其映射到代码本身,可以轻松知道需要更改代码的哪一部分。这被称为可发现性。
注意
普遍语言这个术语是由 Eric Evans 创造的,起源于领域驱动设计。它指的是一种清晰定义且在开发人员和用户之间共享的通用语言。
引入一个类来模拟文档的原则之一是强类型。许多人使用这个术语来指代编程语言的性质,但这里我们讨论的是在实现软件时强类型的更实际用途。类型允许我们限制数据的使用方式。例如,我们的Document
类是不可变的:一旦创建,就无法改变或突变其任何属性。我们的Importer
实现创建文档;没有其他东西可以修改它们。如果你看到某个Document
中有错误的属性,你可以将 bug 的来源缩小到特定创建该Document
的Importer
。你还可以从不可变性推断出,可以对与Document
关联的任何信息进行索引或缓存,并且知道它将永远正确,因为文档是不可变的。
开发人员在建模其Document
时可能考虑的另一个设计选择是将Document
扩展为HashMap<String, String>
。乍看之下,这似乎很棒,因为HashMap
具有建模Document
所需的所有功能。然而,有几个理由说明这是一个糟糕的选择。
软件设计往往不仅仅是关于构建所需功能,还涉及限制不希望的功能。如果仅仅是HashMap
的子类,我们将立即丢弃不可变性带来的前述好处。包装集合还为我们提供了一个机会,可以为方法提供更有意义的名称,而不是例如通过调用get()
方法查找属性,这实际上并不意味着任何东西!稍后我们将更详细地讨论继承与组合,因为这实际上是该讨论的一个具体例子。
简而言之,领域类允许我们命名一个概念,并限制该概念的行为和值的可能性,以提高发现性并减少错误的范围。因此,我们选择如示例 4-3 中所示地对Document
进行建模。如果你想知道为什么它不像大多数接口那样是public
,这将在“作用域和封装选择”中讨论。
示例 4-3. 文档
public class Document {
private final Map<String, String> attributes;
Document(final Map<String, String> attributes) {
this.attributes = attributes;
}
public String getAttribute(final String attributeName) {
return attributes.get(attributeName);
}
}
最后要注意的一点是,Document
类具有包范围的构造函数。通常情况下,Java 类会将它们的构造函数设置为 public
,但这可能是一个不好的选择,因为它允许项目中任何位置的代码创建该类型的对象。只有文档管理系统中的代码应该能够创建 Documents
,因此我们将构造函数的访问权限限制在包内,并仅限于文档管理系统所在的包。
属性和分层文档
在我们的 Document
类中,我们使用 Strings
来表示属性。这难道不违背了强类型的原则吗?答案既是肯定的,也是否定的。我们将属性存储为文本,以便可以通过基于文本的搜索进行搜索。不仅如此,我们还希望确保所有属性都以非常通用的形式创建,这种形式与创建它们的 Importer
无关。在这种上下文中,Strings
并不是一个坏选择。应该注意的是,在整个应用程序中传递 Strings
以表示信息通常被认为是一个不好的做法。与强类型相比,这被称为“stringly typed”!
特别是,如果属性值的使用更加复杂,那么解析出不同的属性类型将会很有用。例如,如果我们想要在某个距离内找到地址,或者找到高度和宽度小于某个尺寸的图片,那么拥有强类型的属性将会是一个福音。使用整数作为宽度值进行比较会更加容易。然而,在这个文档管理系统的情况下,我们并不需要那种功能。
您可以设计文档管理系统,为 Documents
创建一个类层次结构,该结构模拟了 Importer
的层次结构。例如,ReportImporter
导入扩展了 Document
类的 Report
类的实例。这通过了我们的基本合理性检查,即它允许您说 Report
是一个 Document
,这在语义上是有意义的。然而,我们选择不沿着这个方向继续进行,因为在面向对象编程设置中,正确的类建模方法是从行为和数据的角度思考。
所有文档都以命名属性的通用方式进行建模,而不是在不同子类中存在的特定字段。此外,在该系统中,文档几乎没有关联的行为。在这里添加类层次结构毫无意义。您可能认为这个说法本身有些武断,但它告诉我们另一个原则:KISS。
你在第二章学到了 KISS 原则。KISS 的意思是如果设计保持简单,就会更好。要避免不必要的复杂往往非常困难,但努力尝试是值得的。每当有人说“我们可能需要 X”或者“如果我们也做 Y 会很酷”的时候,只需要说不。臃肿和复杂的设计往往以扩展性和代码“好玩而非必需”的良好意图铺成了路。
实现和注册导入者
您可以实现Importer
接口来查找不同类型的文件。示例 4-4 展示了导入图像的方式。Java 核心库的一个伟大之处在于它提供了很多开箱即用的内置功能。在这里,我们使用ImageIO.read
方法读取图像文件,然后从生成的BufferedImage
对象中提取图像的宽度和高度。
示例 4-4. ImageImporter
import static com.iteratrlearning.shu_book.chapter_04.Attributes.*;
class ImageImporter implements Importer {
@Override
public Document importFile(final File file) throws IOException {
final Map<String, String> attributes = new HashMap<>();
attributes.put(PATH, file.getPath());
final BufferedImage image = ImageIO.read(file);
attributes.put(WIDTH, String.valueOf(image.getWidth()));
attributes.put(HEIGHT, String.valueOf(image.getHeight()));
attributes.put(TYPE, "IMAGE");
return new Document(attributes);
}
}
属性名称在Attributes
类中被定义为常量。这样做可以避免不同的导入者使用相同属性名称的不同字符串而导致的错误;例如,"Path"
和"path"
。Java 本身没有常量的直接概念,示例 4-5 展示了通常使用的习语。这个常量是public
的,因为我们希望能够从不同的导入者中使用它,尽管您可能更喜欢使用private
或package
作用域的常量。使用final
关键字确保它不可重新赋值,static
确保每个类只有一个实例。
示例 4-5. 如何在 Java 中定义常量
public static final String PATH = "path";
对于三种不同类型的文件都有导入者,您将看到其他两种实现在“扩展和重用代码”中。别担心,我们没有任何花招。为了能够在导入文件时使用Importer
类,我们还需要注册导入者以进行查找。我们使用要导入的文件的扩展名作为Map
的键,如示例 4-6 所示。
示例 4-6. 注册导入者
private final Map<String, Importer> extensionToImporter = new HashMap<>();
public DocumentManagementSystem() {
extensionToImporter.put("letter", new LetterImporter());
extensionToImporter.put("report", new ReportImporter());
extensionToImporter.put("jpg", new ImageImporter());
}
现在您知道如何导入文档,我们可以实现搜索。我们不会在这里专注于实现文档搜索的最有效方法,因为我们并不打算实现 Google,只是将所需信息传递给 Avaj 博士。与 Avaj 博士的对话表明,她希望能够查找Document
的不同属性的信息。
她的需求可能仅仅是能够在属性值中查找子序列。例如,她可能希望搜索具有名为 Joe 的患者和体内有Diet Coke的文档。因此,我们设计了一个非常简单的查询语言,由一系列以逗号分隔的属性名称和子字符串对组成。我们之前提到的查询将被编写为"patient:Joe,body:Diet Coke"
。
由于搜索实现保持简单而不是试图高度优化,它只是在系统中记录的所有文档上进行线性扫描,并测试每个文档是否符合查询。传递给search
方法的查询String
被解析为一个Query
对象,然后可以与每个Document
进行测试。
里氏替换原则(LSP)
我们已经讨论了与类相关的一些特定设计决策,例如,使用类来建模不同的Importer
实现,以及为什么我们没有为Document
类引入类层次结构,也为什么我们没有将Document
直接扩展为HashMap
。但实际上这里涉及到一个更广泛的原则,一个允许我们将这些例子推广到任何软件片段的原则。这就是里氏替换原则(LSP),它帮助我们正确地子类化和实现接口。LSP 是我们在整本书中一直在提到的 SOLID 原则中的 L。
里氏替换原则通常以非常正式的术语来陈述,但实际上是一个非常简单的概念。让我们揭开其中的一些术语。如果在这个背景下听到类型,只需将其视为类或接口。术语子类型意味着在类型之间建立了父子关系;换句话说,扩展了一个类或实现了一个接口。因此,你可以非正式地将其视为子类应该保持从父类继承的行为。我们知道,听起来像是一个显而易见的陈述,但我们可以更具体地将 LSP 分解为四个不同的部分:
前置条件不能在子类型中被加强。
前置条件建立了某些代码将工作的条件。你不能仅仅假设你所写的无论如何都会工作。例如,我们所有的Importer
实现都有一个前置条件,即要导入的文件存在且可读。因此,在调用任何Importer
之前,importFile
方法都有验证代码,正如在示例 4-7 中所示。
示例 4-7. importFile 定义
public void importFile(final String path) throws IOException {
final File file = new File(path);
if (!file.exists()) {
throw new FileNotFoundException(path);
}
final int separatorIndex = path.lastIndexOf('.');
if (separatorIndex != -1) {
if (separatorIndex == path.length()) {
throw new UnknownFileTypeException("No extension found For file: " + path);
}
final String extension = path.substring(separatorIndex + 1);
final Importer importer = extensionToImporter.get(extension);
if (importer == null) {
throw new UnknownFileTypeException("For file: " + path);
}
final Document document = importer.importFile(file);
documents.add(document);
} else {
throw new UnknownFileTypeException("No extension found For file: " + path);
}
}
LSP 意味着你不能要求比你的父类更严格的前置条件。因此,例如,如果你的父类应该能够导入任何大小的文档,你就不能要求你的文档大小必须小于 100KB。
后置条件不能在子类型中被削弱。
这可能听起来有点混淆,因为它读起来很像第一条规则。后置条件是在某些代码运行后必须为真的事物。例如,在运行importFile()
后,如果所讨论的文件有效,则它必须在contents()
返回的文档列表中。因此,如果父类具有某种副作用或返回某个值,则子类也必须如此。
超类型的不变量必须在子类型中被保留。
不变量是永远不会改变的东西,就像潮汐的涨落一样。在继承的上下文中,我们希望确保父类预期维护的任何不变性也应该由子类维护。
历史规则
这是理解 LSP 最困难的方面。本质上,子类不应允许状态变更,而父类不允许。因此,在我们的示例程序中,我们有一个不可变的 Document
类。换句话说,一旦被实例化,就不能删除、添加或修改任何属性。你不应该派生这个 Document
类并创建一个可变的 Document
类。这是因为父类的任何用户都期望在调用 Document
类的方法时得到特定的行为。如果子类是可变的,它可能会违反调用者对调用这些方法时所期望的行为。
替代方法
当设计文档管理系统时,您完全可以采取完全不同的方法。我们现在将介绍一些这些替代方法,因为我们认为它们是有教育意义的。这些选择没有一个可以被认为是错的,但我们确实认为选择的方法是最好的。
将 Importer 设计为一个类
您可以选择为导入器创建一个类层次结构,而不是一个接口。接口和类提供了不同的功能集。您可以实现多个接口,而类可以包含实例字段,并且在类中具有方法体更为常见。
在这种情况下,构建层次结构的原因是为了使不同的导入器能够被使用。您已经听说过我们避免脆弱的基于类的继承关系的动机,因此在这里使用接口应该是一个很明智的选择。
这并不是说在其他地方类不是更好的选择。如果您想在涉及状态或大量行为的问题域中建模强大的 是一个 关系,则基于类的继承更合适。只是我们认为在这里使用接口是更合适的选择。
作用域和封装选择
如果您花时间查看代码,您可能会注意到 Importer
接口、它的实现以及我们的 Query
类都具有包范围。包范围是默认范围,因此如果您看到一个类文件顶部是 class Query
,您就知道它是包范围的;如果它说 public class Query
,则是公共范围。包范围意味着同一包中的其他类可以看到或访问该类,但其他人不能。这是一种隐身装置。
Java 生态系统的一个奇怪之处在于,尽管包范围是默认范围,但每当我们参与软件开发项目时,始终有比包范围更多的public
范围的类。也许默认应该一直是public
,但无论如何,包范围确实是一个非常有用的工具。它帮助您封装这些设计决策。本节大部分内容都评论了围绕设计系统可用的不同选择,并且在维护系统时可能希望重构为其中一种替代设计。如果我们泄露有关此包之外实现的详细信息,这将更加困难。通过勤奋地使用包范围,您可以阻止包外的类对内部设计做出过多假设。
我们认为值得重申的是,这只是对这些设计选择的辩解和解释。在本节列出的其他选择中,没有任何本质上的错误—它们可能会根据应用程序随时间演变而更合适。
扩展和重用代码
谈到软件,唯一不变的是变化。随着时间的推移,您可能希望向产品添加功能,客户需求可能会改变,法规可能会强制您修改软件。正如我们早些时候所提到的,阿瓦博士可能希望将更多文档添加到我们的文档管理系统中。事实上,当我们首次展示为她编写的软件时,她立即意识到要在此系统中跟踪客户发票。发票是一个具有正文和金额的文档,并具有 .invoice 扩展名。示例 4-8 展示了一个发票示例。
示例 4-8. 发票示例
Dear Joe Bloggs
Here is your invoice for the dental treatment that you received.
Amount: $100
regards,
Dr Avaj
Awesome Dentist
幸运的是,阿瓦博士的所有发票都采用相同的格式。正如您所见,我们需要从中提取一笔金额,而金额行以Amount:
为前缀开始。信件的收件人姓名位于以Dear
为前缀的行开头。事实上,我们的系统实现了一种通用的方法来查找给定前缀行的后缀,如 示例 4-9 所示。在这个例子中,字段lines
已经初始化为我们正在导入的文件的行。我们向这个方法传递一个prefix
—例如,Amount:
—它将将行的其余部分,即后缀,与提供的属性名称关联起来。
示例 4-9. addLineSuffix 定义
void addLineSuffix(final String prefix, final String attributeName) {
for(final String line: lines) {
if (line.startsWith(prefix)) {
attributes.put(attributeName, line.substring(prefix.length()));
break;
}
}
}
实际上,当我们尝试导入一封信时,我们有类似的概念。考虑 示例 4-10 中提供的示例信件。在这里,您可以通过查找以Dear
开头的行来提取患者的姓名。信件还具有地址和文本主体,您希望从文本文件的内容中提取出来。
示例 4-10. 信件示例
Dear Joe Bloggs
123 Fake Street
Westminster
London
United Kingdom
We are writing to you to confirm the re-scheduling of your appointment
with Dr. Avaj from 29th December 2016 to 5th January 2017.
regards,
Dr Avaj
Awesome Dentist
当涉及导入患者报告时,我们也面临类似的问题。Avaj 博士的报告将患者的姓名前缀设置为Patient:
,并包含一段要包含的文本,就像信件一样。你可以在 Example 4-11 中看到一个报告示例。
示例 4-11. 报告示例
Patient: Joe Bloggs
On 5th January 2017 I examined Joe's teeth.
We discussed his switch from drinking Coke to Diet Coke.
No new problems were noted with his teeth.
所以这里的一个选择是让所有三个基于文本的导入器实现同一个方法,用于查找带有在 Example 4-9 中列出的前缀的文本行的后缀。现在,如果我们按照编写的代码行数向 Avaj 博士收费,这将是一个很好的策略。我们可以为基本相同的工作三倍赚取更多的钱!
不幸的是(或者说幸运的是,考虑到上述的激励因素),客户很少根据所产生的代码行数付费。重要的是客户想要的需求。所以我们真的希望能够在三个导入器之间重用这段代码。为了重用这段代码,我们需要确实将其放在某个类中。你基本上有三个选择,每个选择都有其利弊:
-
使用实用类
-
使用继承
-
使用领域类
最简单的开始选项是创建一个实用类。你可以称其为ImportUtil
。然后,每当你想要在不同的导入器之间共享方法时,可以将其放入此实用类中。你的实用类最终将成为一堆静态方法的集合。
虽然实用类很简单,但它并不完全是面向对象编程的顶峰。面向对象的风格包括通过类来模拟应用程序中的概念。如果你想创建一个东西,那么你调用new Thing()
,用于你的东西。与该东西相关的属性和行为应该是Thing
类的方法。
如果你遵循将现实世界对象建模为类的原则,确实会更容易理解你的应用程序,因为它为你提供了一个结构,并将你领域的心理模型映射到你的代码中。你想改变信件导入的方式?那么就编辑LetterImporter
类。
实用类违反了这一预期,通常最终会变成一堆过程化代码,没有单一的责任或概念。随着时间的推移,这往往会导致我们代码库中出现上帝类的出现;换句话说,一个单一的大类最终会占据大量责任。
那么,如果你想将这种行为与一个概念关联起来,你该怎么办?嗯,下一个最明显的方法可能是使用继承。在这种方法中,你可以让不同的导入器扩展TextImporter
类。然后你可以将所有共同的功能放在这个类上,并在子类中重用它。
在许多情况下,继承是一种非常稳固的设计选择。您已经看到了 Liskov 替换原则及其如何对我们的继承关系的正确性施加约束。在实践中,当继承关系未能模拟某些真实世界的关系时,继承往往是一个不好的选择。
在这种情况下,TextImporter
是一个 Importer
,我们可以确保我们的类遵循 LSP 规则,但这似乎并不是一个很强的概念来进行工作。继承关系不符合真实世界关系的问题在于它们往往是脆弱的。随着应用程序随时间的演变,您希望抽象与应用程序一起演变,而不是相反。作为一个经验法则,纯粹为了启用代码重用而引入继承关系是一个不好的想法。
我们的最终选择是使用域类来建模文本文件。要使用这种方法,我们将模拟一些基础概念,并通过调用顶层方法来构建不同的导入器。那么这里问题的概念是什么?嗯,我们真正想做的是操作文本文件的内容,所以让我们称这个类为 TextFile
。这并不是原创或创意,但这正是重点所在。您知道在哪里找到操作文本文件的功能,因为类的命名非常简单明了。
示例 4-12 显示了该类及其字段的定义。请注意,这不是 Document
的子类,因为文档不应仅限于文本文件 - 我们可能还会导入诸如图像等的二进制文件。这只是一个模拟文本文件的基础概念的类,并具有从文本文件中提取数据的相关方法。
示例 4-12. TextFile 定义
class TextFile {
private final Map<String, String> attributes;
private final List<String> lines;
// class continues ...
这是我们在导入器案例中选择的方法。我们认为这样可以以灵活的方式对我们的问题域进行建模。它不会将我们束缚在脆弱的继承层次结构中,但仍然允许我们重用代码。示例 4-13 展示了如何导入发票。为名称和金额添加了后缀,并设置发票类型为金额。
示例 4-13. 导入发票
@Override
public Document importFile(final File file) throws IOException {
final TextFile textFile = new TextFile(file);
textFile.addLineSuffix(NAME_PREFIX, PATIENT);
textFile.addLineSuffix(AMOUNT_PREFIX, AMOUNT);
final Map<String, String> attributes = textFile.getAttributes();
attributes.put(TYPE, "INVOICE");
return new Document(attributes);
}
您还可以看到另一个使用 TextFile
类的导入器示例在 示例 4-14。不需要担心 TextFile.addLines
的实现方式;您可以在 示例 4-15 中看到对其的解释。
示例 4-14. 导入信件
@Override
public Document importFile(final File file) throws IOException {
final TextFile textFile = new TextFile(file);
textFile.addLineSuffix(NAME_PREFIX, PATIENT);
final int lineNumber = textFile.addLines(2, String::isEmpty, ADDRESS);
textFile.addLines(lineNumber + 1, (line) -> line.startsWith("regards,"), BODY);
final Map<String, String> attributes = textFile.getAttributes();
attributes.put(TYPE, "LETTER");
return new Document(attributes);
}
这些类一开始并不是这样编写的。它们逐渐演变到当前的状态。当我们开始编写文档管理系统时,第一个基于文本的导入器 LetterImporter
,它的所有文本提取逻辑都是内联编写在类中的。这是一个很好的开始。试图寻找可以重用的代码通常会导致不适当的抽象化。先学会走再考虑奔跑。
当我们开始编写 ReportImporter
时,越来越明显的是文本提取逻辑可以在这两个导入器之间共享,并且它们实际上应该是基于我们在这里引入的某些共同领域概念的方法调用—TextFile
。事实上,我们甚至复制并粘贴了最初要在两个类之间共享的代码。
这并不意味着复制粘贴代码是好的——远非如此。但是,当您开始编写某些类时,往往最好复制少量的代码。一旦您实现了更多的应用程序,正确的抽象—例如 TextFile
类将变得显而易见。只有当您对去除重复代码的正确方法有了更多了解后,才应该采用去重复的路线。
在 示例 4-15 中,您可以看到 TextFile.addLines
方法的实现方式。这是不同 Importer
实现中常见的代码。它的第一个参数是一个 start
索引,用于指示从哪一行开始。接着是一个 isEnd
断言,用于检查是否到达行的结尾并返回 true
。最后,我们有要与此值关联的属性名称。
示例 4-15. addLines 定义
int addLines(
final int start,
final Predicate<String> isEnd,
final String attributeName) {
final StringBuilder accumulator = new StringBuilder();
int lineNumber;
for (lineNumber = start; lineNumber < lines.size(); lineNumber++) {
final String line = lines.get(lineNumber);
if (isEnd.test(line)) {
break;
}
accumulator.append(line);
accumulator.append("\n");
}
attributes.put(attributeName, accumulator.toString().trim());
return lineNumber;
}
测试卫生
正如您在 第二章 中所学到的,编写自动化测试在软件可维护性方面有很多好处。它使我们能够减少回归范围,并了解导致回归的提交。它还使我们能够自信地重构我们的代码。然而,测试并不是一个神奇的灵丹妙药。它们要求我们编写并维护大量的代码,以便获得这些好处。众所周知,编写和维护代码是一个困难的任务,许多开发者发现,当他们开始编写自动化测试时,这会占用大量的开发时间。
为了解决测试可维护性的问题,您需要掌握测试卫生。测试卫生意味着保持您的测试代码整洁,并确保随着受测试的代码库一起进行维护和改进。如果您不维护和处理您的测试,随着时间的推移,它们将成为影响开发者生产力的负担。在本节中,您将了解到一些关键点,这些点可以帮助保持测试的卫生。
测试命名
谈到测试时,首先要考虑的是它们的命名。开发者对命名可能有很强的个人意见——因为每个人都可以与此相关并思考这个问题,所以这是一个容易大谈特谈的话题。我们认为需要记住的是,很少有一个清晰、真正好的名称可以适用于某件事情,但有很多很多个糟糕的名称。
我们为文档管理系统编写的第一个测试是测试我们导入一个文件并创建一个Document
。这是在我们引入Importer
概念之前编写的,并且没有测试Document
特定的属性。代码在示例 4-16 中。
示例 4-16. 导入文件的测试
@Test
public void shouldImportFile() throws Exception
{
system.importFile(LETTER);
final Document document = onlyDocument();
assertAttributeEquals(document, Attributes.PATH, LETTER);
}
这个测试被命名为shouldImportFile
。在测试命名方面的关键驱动原则是可读性、可维护性和作为可执行文档的功能。当您看到测试类运行的报告时,这些名称应该作为说明文档,记录哪些功能可用,哪些不可用。这允许开发人员轻松地从应用程序行为映射到断言该行为被实现的测试。通过减少行为和代码之间的阻抗不匹配,我们使其他开发人员更容易理解未来发生的情况。这是一个确认文档管理系统导入文件的测试。
然而,还有很多命名反模式。最糟糕的反模式是给一个测试命名为完全不明确的东西——例如,test1
。test1
在测试什么?读者的耐心?对待阅读你代码的人,就像你希望他们对待你一样。
另一个常见的测试命名反模式是仅以概念或名词命名,例如,file
或document
。测试名称应描述测试的行为,而不是一个概念。另一个测试命名反模式是仅将测试命名为在测试期间调用的方法,而不是行为。在这种情况下,测试可能被命名为importFile
。
你可能会问,通过将我们的测试命名为shouldImportFile
,我们是否在这里犯了这个罪?这种指责有一定的道理,但在这里我们只是描述了正在测试的行为。事实上,importFile
方法由各种测试进行测试;例如,shouldImportLetterAttributes
、shouldImportReportAttributes
和shouldImportImageAttributes
。这些测试都没有被称为importFile
——它们都描述了更具体的行为。
好了,现在你知道了不良命名是什么样子,那么好的测试命名是什么呢?您应该遵循三个经验法则,并使用它们来驱动测试命名:
使用领域术语
将测试名称中使用的词汇与描述问题域或应用程序本身引用的词汇保持一致。
使用自然语言
每个测试名称都应该是您可以轻松读成一句话的东西。它应该以可读的方式描述某种行为。
描述性
代码将被阅读的次数比被编写的次数多得多。不要吝惜花更多时间考虑一个好的、描述性的名字,这样后来理解起来会更容易。如果你想不出一个好的名字,为什么不问问同事呢?在高尔夫球中,你通过尽量少的击球次数来获胜。编程不是这样的;最短的不一定是最好的。
你可以按照DocumentManagementSystemTest
中使用的约定,使用“should”作为测试名称的前缀,也可以选择不这样做;这只是个人偏好的问题。
行为而非实现
如果你正在为一个类、一个组件甚至是一个系统编写测试,那么你应该只测试被测试对象的公共行为。在文档管理系统的情况下,我们只测试我们的公共 API 的行为,其形式为DocumentManagementSystemTest
。在这个测试中,我们测试了DocumentManagementSystem
类的公共 API,因此也测试了整个系统。你可以在示例 4-17 中查看该 API。
示例 4-17. DocumentManagementSystem
类的公共 API
public class DocumentManagementSystem
{
public void importFile(final String path) {
...
}
public List<Document> contents() {
...
}
public List<Document> search(final String query) {
...
}
}
我们的测试应该只调用这些公共 API 方法,而不应尝试检查对象或设计的内部状态。这是开发人员常犯的一个关键错误,导致难以维护的测试。依赖于特定的实现细节会导致脆弱的测试,因为如果更改了相关的实现细节,即使行为仍然正常,测试也可能开始失败。查看示例 4-18 中的测试。
示例 4-18. 导入信函的测试
@Test
public void shouldImportLetterAttributes() throws Exception
{
system.importFile(LETTER);
final Document document = onlyDocument();
assertAttributeEquals(document, PATIENT, JOE_BLOGGS);
assertAttributeEquals(document, ADDRESS,
"123 Fake Street\n" +
"Westminster\n" +
"London\n" +
"United Kingdom");
assertAttributeEquals(document, BODY,
"We are writing to you to confirm the re-scheduling of your appointment\n" +
"with Dr. Avaj from 29th December 2016 to 5th January 2017.");
assertTypeIs("LETTER", document);
}
测试这个信函导入功能的一种方式本来可以写成一个关于LetterImporter
类的单元测试。这看起来可能非常相似:导入一个示例文件,然后对导入器返回的结果进行断言。然而,在我们的测试中,LetterImporter
的存在本身就是一个实现细节。在“扩展和重用代码”中,你看到了许多其他布局导入器代码的选择。通过这种方式布置我们的测试,我们给自己提供了在不破坏测试的情况下重构内部结构的选择。
所以我们说依赖于类的行为是通过使用公共 API 来实现的,但也有一些行为部分通常不仅仅通过使方法公共或私有来限制。例如,我们可能不希望依赖于从contents()
方法返回的文档顺序。这不是DocumentManagementSystem
类的公共 API 的属性,而只是需要小心避免的事情。
在这方面的一个常见反模式是通过 getter 或 setter 公开本来是私有状态以便于测试。尽可能避免这样做,因为这会使您的测试变得脆弱。如果您已经公开了这个状态以使测试表面上更容易,那么最终会使得长期维护您的应用程序变得更加困难。这是因为任何涉及更改内部状态表示方式的代码库更改现在也需要修改您的测试。有时这是需要重构出一个新类来更容易和有效地进行测试的一个很好的指示。
不要重复自己
“扩展和重用代码”广泛讨论了如何从我们的应用程序中删除重复代码以及放置生成代码的位置。关于维护的确切推理同样适用于测试代码。不幸的是,开发人员通常简单地不去像处理应用程序代码一样去除测试中的重复代码。如果您查看示例 4-19,您会看到一个测试重复地对生成的Document
的不同属性进行断言。
示例 4-19. 导入图片的测试
@Test
public void shouldImportImageAttributes() throws Exception
{
system.importFile(XRAY);
final Document document = onlyDocument();
assertAttributeEquals(document, WIDTH, "320");
assertAttributeEquals(document, HEIGHT, "179");
assertTypeIs("IMAGE", document);
}
通常情况下,您需要查找每个属性的属性名称并断言它是否等于预期值。在这些测试中,这是一个足够常见的操作,一个通用方法assertAttributeEquals
被提取出来具备这种逻辑。其实现在示例 4-20 中展示。
示例 4-20. 实现一个新的断言
private void assertAttributeEquals(
final Document document,
final String attributeName,
final String expectedValue)
{
assertEquals(
"Document has the wrong value for " + attributeName,
expectedValue,
document.getAttribute(attributeName));
}
良好的诊断信息
如果测试永远不失败,那就没有好的测试。事实上,如果您从未见过测试失败,那么怎么知道它是否有效呢?在编写测试时,最好的做法是优化失败情况。当我们说优化时,我们并不是指测试在失败时运行得更快——而是确保测试编写方式使得理解为什么以及如何失败尽可能容易。这其中的技巧在于良好的诊断信息。
通过诊断信息,我们指的是测试失败时打印出的消息和信息。消息越清晰说明失败原因,调试测试失败就越容易。你可能会问,为什么要在现代 IDE 中运行 Java 测试时还要打印诊断信息?有时测试可能在持续集成环境中运行,有时可能从命令行运行。即使在 IDE 中运行,拥有良好的诊断信息仍然非常有帮助。希望我们已经说服了您需要良好诊断信息的重要性,那么在代码中它们是什么样的呢?
示例 4-21 展示了一种断言系统仅包含单个文档的方法。稍后我们会解释hasSize()
方法。
示例 4-21. 测试系统是否包含单个文档
private Document onlyDocument()
{
final List<Document> documents = system.contents();
assertThat(documents, hasSize(1));
return documents.get(0);
}
JUnit 提供给我们的最简单的断言类型是assertTrue()
,它将采取一个期望为真的布尔值。示例 4-22 展示了我们如何只使用assertTrue
来实现测试。在这种情况下,正在检查的值等于0
,以便它将使shouldImportFile
测试失败,从而展示失败的诊断。问题在于我们得不到很好的诊断信息——只是一个没有任何信息的AssertionError
在图 4-1 中显示。你不知道什么失败了,也不知道比较了什么值。你一无所知,即使你的名字不是乔恩·雪诺。
示例 4-22. assertTrue
示例
assertTrue(documents.size() == 0);
图 4-1. assertTrue
失败的截图
最常用的断言是assertEquals
,它接受两个值并检查它们是否相等,并重载以支持原始值。因此,我们可以断言documents
列表的大小为0
,如示例 4-23 所示。这产生了稍微好一点的诊断,如图 4-2 所示,您知道期望的值是0
,实际值是1
,但它仍然没有给出任何有意义的上下文。
示例 4-23. assertEquals
示例
assertEquals(0, documents.size());
图 4-2. assertEquals
示例失败的截图
关于大小本身进行断言的最佳方法是使用matcher来断言集合的大小,因为这提供了最具描述性的诊断。示例 4-24 采用了这种样式编写的示例,并演示了输出。正如图 4-3 所示,这样更清晰地说明了出了什么问题,而无需再编写任何代码。
示例 4-24. assertThat
示例
assertThat(documents, hasSize(0));
图 4-3. assertThat
示例失败的截图
这里使用了 JUnit 的assertThat()
方法。方法assertThat()
的第一个参数是一个值,第二个参数是一个Matcher
。Matcher
封装了一个值是否符合某些属性以及相关诊断的概念。hasSize
匹配器是从Matchers
实用程序类中静态导入的,该类包含一系列不同的匹配器,并检查集合的大小是否等于其参数。这些匹配器来自Hamcrest 库,这是一个非常常用的 Java 库,可以实现更清晰的测试。
另一个构建更好诊断的示例是在示例 4-20 中展示的。在这里,assertEquals
会为我们提供属性的预期值和实际值的诊断。它不会告诉我们属性的名称是什么,因此将其添加到消息字符串中以帮助我们理解失败。
测试错误情况
写软件时最严重且最常见的错误之一是仅测试你的应用程序的美丽、黄金、幸福路径——即在阳光照耀你,一切都顺利进行的代码路径。 实际上很多事情可能会出错! 如果你不测试应用程序在这些情况下的行为,你就无法得到在生产环境中可靠运行的软件。
当涉及将文档导入我们的文档管理系统时,可能会出现几种错误情况。 我们可能尝试导入一个不存在或无法读取的文件,或者我们可能尝试导入一个我们不知道如何从中提取文本或读取的文件。
我们的DocumentManagementSystemTest
有一些测试,显示在示例 4-25 中,测试这两种情况。 在这两种情况下,我们尝试导入一个会暴露问题的路径文件。 为了对所需行为进行断言,我们使用了 JUnit 的@Test
注解的expected =
属性。 这使您可以说嘿,JUnit,我期望这个测试抛出一个特定类型的异常。
示例 4-25. 错误案例测试
@Test(expected = FileNotFoundException.class)
public void shouldNotImportMissingFile() throws Exception
{
system.importFile("gobbledygook.txt");
}
@Test(expected = UnknownFileTypeException.class)
public void shouldNotImportUnknownFile() throws Exception
{
system.importFile(RESOURCES + "unknown.txt");
}
如果在错误情况下想要替代行为,而不仅仅是抛出异常,了解如何断言异常被抛出肯定是有帮助的。
常量
常量是不变的值。 让我们面对现实——在计算机编程中,它们是少数命名得当的概念之一。 Java 编程语言不像 C++那样使用显式的const
关键字,但是开发人员通常创建static field
字段来代表常量。 由于许多测试由如何使用计算机程序的一部分的示例组成,它们通常包含许多常量。
对于具有某种非明显含义的常量,给它们一个适当的名称是个好主意,这样它们可以在测试中使用。 我们通过DocumentManagementSystemTest
广泛使用这种方式,在顶部有一个专门声明常量的块,显示在示例 4-26 中。
示例 4-26. 常量
public class DocumentManagementSystemTest
{
private static final String RESOURCES =
"src" + File.separator + "test" + File.separator + "resources" + File.separator;
private static final String LETTER = RESOURCES + "patient.letter";
private static final String REPORT = RESOURCES + "patient.report";
private static final String XRAY = RESOURCES + "xray.jpg";
private static final String INVOICE = RESOURCES + "patient.invoice";
private static final String JOE_BLOGGS = "Joe Bloggs";
要点
-
你学会了如何构建文档管理系统。
-
你意识到了不同实现方法之间的不同权衡。
-
你理解了驱动软件设计的几个原则。
-
你被介绍了里斯科夫替换原则,作为思考继承的一种方式。
-
你了解了继承不适合的情况。
迭代在您身上
如果你想要扩展和巩固本节的知识,可以尝试以下活动之一:
-
取现有的示例代码,并添加一个导入处方文档的实现。 处方应包含患者、药品、数量、日期,并说明服药条件。 你还应该编写一个检查处方导入工作的测试。
-
尝试实现生命游戏 Kata。
完成挑战
博士 Avaj 对你的文档管理系统非常满意,现在她已经广泛使用它。系统的特性有效地满足了她的需求,因为你从她的需求出发,推动设计朝向应用行为,并进入实现细节。在下一章节引入 TDD 时,你将会回顾这一主题。
第五章:业务规则引擎
挑战
您的业务现在非常顺利。事实上,您现在已经扩展到一个拥有成千上万名员工的组织。这意味着您已经雇佣了许多人来从事不同的业务职能:市场营销、销售、运营、管理、会计等等。您意识到所有业务职能都需要创建根据某些条件触发操作的规则;例如,“如果潜在客户的职位是'CEO',则通知销售团队”。您可以要求技术团队为每个新需求实现定制软件,但是您的开发人员正在忙于其他产品。为了鼓励业务团队和技术团队之间的协作,您决定开发一个业务规则引擎,这将使开发人员和业务团队能够共同编写代码。这将使您能够提高生产力并减少实施新规则所需的时间,因为您的业务团队将能够直接做出贡献。
目标
在本章中,您将首先学习如何使用测试驱动开发方法来解决新设计问题。您将了解到一个称为模拟的技术概述,这将有助于指定单元测试。然后,您将学习一些 Java 中的现代特性:局部变量类型推断和 switch 表达式。最后,您将学习如何使用建造者模式和接口隔离原则开发友好的 API。
注意
如果您想随时查看本章的源代码,可以查看书籍代码库中的com.iteratrlearning.shu_book.chapter_05
包。
业务规则引擎需求
在开始之前,让我们考虑一下您想要实现的目标。您希望使非程序员能够在其自己的工作流程中添加或更改业务逻辑。例如,市场营销执行人员可能希望在潜在客户询问您的产品并符合某些条件时提供特别折扣。会计主管可能希望在支出异常高时创建警报。这些都是业务规则引擎可以实现的例子。它实质上是执行一个或多个业务规则的软件,通常使用简单的定制语言声明这些规则。业务规则引擎可以支持多个不同的组件:
事实
规则可以访问的可用信息
行动
您要执行的操作
条件
这些指定何时触发操作
规则
这些指定您希望执行的业务逻辑,实质上是将事实、条件和操作分组在一起
业务规则引擎的主要生产力优势在于它使规则能够在一个地方进行维护、执行和测试,而无需与主应用程序集成。
注意
有许多成熟的 Java 业务规则引擎,比如Drools。通常这样的引擎符合诸如决策建模和标记(DMN)的标准,并配备一个集中的规则库,一个使用图形用户界面(GUI)的编辑器和可视化工具,以帮助维护复杂的规则。在本章中,您将开发一个业务规则引擎的最小可行产品,并对其进行迭代,以改进其功能和可访问性。
测试驱动开发
从哪里开始?需求并不是一成不变的,预计会不断演变,因此您开始时只需列出用户需要完成的基本功能即可:
-
添加一个动作
-
运行动作
-
基本报告
这在示例 5-1 中的基本 API 中有所体现。每个方法抛出UnsupportedOperationException
,表示它尚未实现。
示例 5-1. 业务规则引擎的基本 API
public class BusinessRuleEngine {
public void addAction(final Action action) {
throw new UnsupportedOperationException();
}
public int count() {
throw new UnsupportedOperationException();
}
public void run() {
throw new UnsupportedOperationException();
}
}
动作简单地是将要执行的代码片段。我们可以使用Runnable
接口,但引入一个单独的接口Action
更能代表手头的领域。Action
接口将允许业务规则引擎与具体动作解耦。由于Action
接口只声明了一个抽象方法,我们可以将其注释为功能接口,如示例 5-2 所示。
示例 5-2. 动作接口
@FunctionalInterface
public interface Action {
void execute();
}
接下来怎么办?现在是时候真正写些代码了——实现在哪里?你将使用一种称为测试驱动开发(TDD)的方法。TDD 的哲学是首先编写一些测试,这些测试将指导你编写代码的实现。换句话说,你先写测试,再写实现。这有点像迄今为止你所做的相反:你先为一个需求写了完整的代码,然后测试它。现在你会更多地关注测试。
为什么使用 TDD?
为什么要采用这种方法?有几个好处:
-
逐个编写测试将帮助您专注并完善需求,通过逐个正确实现一件事情来实现。
-
这是确保代码有关联组织的一种方式。例如,通过先写测试,你需要仔细考虑代码的公共接口。
-
随着你按需求迭代,构建全面的测试套件,这既增加了你符合需求的信心,也减少了 bug 的范围。
-
你不会写不需要的代码(过度工程),因为你只是写通过测试的代码。
TDD 循环
TDD 方法大致包括以下循环步骤,如图 5-1 所示:
-
写一个失败的测试
-
运行所有测试
-
使实现生效
-
运行所有测试
图 5-1. TDD 循环
在实践中,作为这个过程的一部分,你必须持续重构你的代码,否则它将变得难以维护。此时,当你引入变更时,你知道你有一套可以依赖的测试套件。图 5-2 展示了这一改进的 TDD 过程。
图 5-2. TDD 与重构
在 TDD 的精神下,让我们首先编写我们的第一个测试来验证addActions
和count
的行为是否正确,如示例 5-3 所示。
示例 5-3. 业务规则引擎的基本测试
@Test
void shouldHaveNoRulesInitially() {
final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine();
assertEquals(0, businessRuleEngine.count());
}
@Test
void shouldAddTwoActions() {
final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine();
businessRuleEngine.addAction(() -> {});
businessRuleEngine.addAction(() -> {});
assertEquals(2, businessRuleEngine.count());
}
在运行测试时,你会看到它们失败,并显示UnsupportedOperationException
,如图 5-3 所示。
图 5-3. 失败的测试
所有测试都失败了,但没关系。这给了我们一个可重现的测试套件,将指导代码的实现。现在可以添加一些实现代码,如示例 5-4 所示。
示例 5-4. 业务规则引擎的基本实现
public class BusinessRuleEngine {
private final List<Action> actions;
public BusinessRuleEngine() {
this.actions = new ArrayList<>();
}
public void addAction(final Action action) {
this.actions.add(action);
}
public int count() {
return this.actions.size();
}
public void run(){
throw new UnsupportedOperationException();
}
}
现在你可以重新运行测试,它们通过了!但是,还有一个关键操作缺失。我们如何为run
方法编写测试?不幸的是,run()
不返回任何结果。我们将需要一种称为模拟的新技术,以验证run()
方法的正确操作。
模拟
模拟是一种技术,它允许你验证当执行run()
方法时,业务规则引擎中添加的每个动作是否确实被执行。目前很难做到这一点,因为BusinessRuleEngine
中的run()
方法和Action
中的perform()
方法都返回void
。我们无法编写断言!模拟在第六章中有详细介绍,但现在你将会得到一个简要概述,这样你就能继续编写测试了。你将使用 Mockito,这是一个流行的 Java 模拟库。在其最简单的形式下,你可以做两件事情:
-
创建一个模拟对象。
-
验证方法是否被调用。
那么,如何开始呢?你需要首先导入这个库:
import static org.mockito.Mockito.*;
这个导入允许你使用mock()
和verify()
方法。静态方法mock()
允许你创建一个模拟对象,然后你可以验证某些行为是否发生。方法verify()
允许你设置断言,即特定方法是否被调用。示例 5-5 展示了一个例子。
示例 5-5. 模拟并验证与Action
对象的交互
@Test
void shouldExecuteOneAction() {
final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine();
final Action mockAction = mock(Action.class);
businessRuleEngine.addAction(mockAction);
businessRuleEngine.run();
verify(mockAction).perform();
}
单元测试为Action
创建了一个模拟对象。这通过将类作为参数传递给mock
方法来实现。接下来是测试的when部分,在这里你调用行为。这里我们添加了动作并执行了run()
方法。最后,单元测试的then部分设置了断言。在这种情况下,我们验证了Action
对象上的perform()
方法是否被调用。
如果你运行这个测试,正如预期的那样会失败,并显示 UnsupportedOperationException
。如果 run()
方法体为空会发生什么?你将收到新的异常跟踪:
Wanted but not invoked:
action.perform();
-> at BusinessRuleEngineTest.shouldExecuteOneAction(BusinessRuleEngineTest.java:35)
Actually, there were zero interactions with this mock.
这个错误来自 Mockito,并告诉你 perform()
方法从未被调用。现在是时候为 run()
方法编写正确的实现了,如 示例 5-6 所示。
示例 5-6. run()
方法的实现
public void run() {
this.actions.forEach(Action::perform);
}
重新运行测试,你会看到测试通过了。Mockito 能够验证当业务规则引擎运行时,Action
对象的 perform()
方法是否被调用。Mockito 允许你指定复杂的验证逻辑,比如方法应该被调用多少次,带有特定参数等。你将在 第六章 中了解更多相关信息。
添加条件
你必须承认,到目前为止,业务规则引擎的功能相当有限。你只能声明简单的动作。然而,在实践中,业务规则引擎的使用者需要根据某些条件执行动作。这些条件将依赖于一些事实。例如,仅当潜在客户的职位是 CEO 时,通知销售团队。
建模状态
你可以先编写代码,添加一个动作,并使用匿名类引用本地变量,如 示例 5-7 所示,或者使用 lambda 表达式,如 示例 5-8 所示。
示例 5-7. 使用匿名类添加一个动作
// this object could be created from a form
final Customer customer = new Customer("Mark", "CEO");
businessRuleEngine.addAction(new Action() {
@Override
public void perform() {
if ("CEO".equals(customer.getJobTitle())) {
Mailer.sendEmail("sales@company.com", "Relevant customer: " + customer);
}
}
});
示例 5-8. 使用 lambda 表达式添加一个动作
// this object could be created from a form
final Customer customer = new Customer("Mark", "CEO");
businessRuleEngine.addAction(() -> {
if ("CEO".equals(customer.getJobTitle())) {
Mailer.sendEmail("sales@company.com", "Relevant customer: " + customer);
}
});
然而,出于几个原因,这种方法并不方便:
-
如何测试这个动作?它不是一个独立的功能模块;它对
customer
对象有硬编码的依赖。 -
customer
对象没有与动作分组。它是一种外部状态,被共享使用,导致责任混淆。
那么我们需要什么?我们需要封装状态,使其可供业务规则引擎中的动作使用。让我们通过引入一个名为 Facts
的新类来建模这些需求,Facts
将代表业务规则引擎中可用的状态,并且更新 Action
接口,使其能够操作 Facts
。一个更新后的单元测试显示在 示例 5-9 中。该单元测试检查当业务规则引擎运行时,指定的动作是否确实被调用,并且传递了 Facts
对象作为参数。
示例 5-9. 使用事实测试一个动作
@Test
public void shouldPerformAnActionWithFacts() {
final Action mockAction = mock(Action.class);
final Facts mockFacts = mock(Facts.class);
final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine(mockedFacts);
businessRuleEngine.addAction(mockAction);
businessRuleEngine.run();
verify(mockAction).perform(mockFacts);
}
为了遵循 TDD 哲学,此测试最初将失败。您始终需要运行测试以确保它们失败,否则可能会意外通过一个测试。要使测试通过,您需要更新 API 和实现代码。首先,您将引入Facts
类,它允许您存储以键和值表示的事实。引入一个单独的Facts
类来建模状态的好处是,您可以通过提供公共 API 控制用户可用的操作,并对类的行为进行单元测试。目前,Facts
类仅支持String
键和String
值。Facts
类的代码显示在示例 5-10 中。我们选择名称getFact
和addFact
,因为它们更好地表示手头的领域(处理事实),而不是getValue
和setValue
。
示例 5-10. Facts 类
public class Facts {
private final Map<String, String> facts = new HashMap<>();
public String getFact(final String name) {
return this.facts.get(name);
}
public void addFact(final String name, final String value) {
this.facts.put(name, value);
}
}
现在,您需要重构Action
接口,以便perform()
方法可以使用作为参数传递的Facts
对象。这样一来,清楚地表明了在单个Action
的上下文中可用的事实(示例 5-11)。
示例 5-11. 接受事实的行动接口
@FunctionalInterface
public interface Action {
void perform(Facts facts);
}
最后,您现在可以更新BusinessRuleEngine
类,以利用事实和更新的Action
的perform()
方法,如示例 5-12 所示。
示例 5-12. 带有事实的 BusinessRuleEngine
public class BusinessRuleEngine {
private final List<Action> actions;
private final Facts facts;
public BusinessRuleEngine(final Facts facts) {
this.facts = facts;
this.actions = new ArrayList<>();
}
public void addAction(final Action action) {
this.actions.add(action);
}
public int count() {
return this.actions.size();
}
public void run() {
this.actions.forEach(action -> action.perform(facts));
}
}
现在Facts
对象可用于行动,您可以在代码中指定查找Facts
对象的任意逻辑,如示例 5-13 所示。
示例 5-13. 利用事实的行动
businessRuleEngine.addAction(facts -> {
final String jobTitle = facts.getFact("jobTitle");
if ("CEO".equals(jobTitle)) {
final String name = facts.getFact("name");
Mailer.sendEmail("sales@company.com", "Relevant customer: " + name);
}
});
让我们看一些更多的示例。这也是介绍 Java 中两个最近功能的好机会,我们按顺序探索:
-
局部变量类型推断
-
Switch 表达式
局部变量类型推断
Java 10 引入了局部变量类型推断。类型推断是编译器可以为您确定静态类型,因此您无需输入它们的想法。在示例 5-10 中,您在之前看到了类型推断的示例,当您编写时
Map<String, String> facts = new HashMap<>();
而不是
Map<String, String> facts = new HashMap<String, String>();
这是 Java 7 中引入的一个特性,称为 菱形操作符。基本上,当其上下文确定类型参数(在本例中为 String, String
)时,您可以省略泛型的类型参数。在前面的代码中,赋值的左侧指示Map
的键和值应为 String
。
自 Java 10 起,类型推断已扩展到局部变量上。例如,示例 5-14 中的代码可以使用var
关键字和局部变量类型推断进行重写,如示例 5-15 所示。
示例 5-14. 显式类型声明的局部变量声明
Facts env = new Facts();
BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine(env);
示例 5-15. 局部变量类型推断
var env = new Facts();
var businessRuleEngine = new BusinessRuleEngine(env);
通过在 Example 5-15 中显示的代码中使用var
关键字,变量env
仍具有静态类型Facts
,变量businessRuleEngine
仍具有静态类型BusinessRuleEngine
。
注意
使用var
关键字声明的变量不是final
。例如,以下代码:
final Facts env = new Facts();
不严格等同于:
var env = new Facts();
在使用var
声明后,仍然可以为变量env
分配另一个值。您必须在变量env
前显式添加final
关键字,如下所示:
final var env = new Facts()
在其余章节中,出于简洁性考虑,我们简单使用var
关键字,不使用final
。当我们显式声明变量类型时,我们使用final
关键字。
类型推断有助于减少编写 Java 代码所需的时间。然而,您应该始终使用这个特性吗?值得记住的是,开发人员花费的时间更多是在阅读代码而不是编写代码。换句话说,您应该考虑优化阅读的便利性而不是编写的便利性。var
改善这一点的程度总是主观的。您应该始终专注于帮助您的团队成员阅读您的代码,因此,如果他们乐意使用var
阅读代码,那么您应该使用它,否则不要使用。例如,我们可以重构 Example 5-13 中的代码,使用本地变量类型推断来整理代码,如 Example 5-16 所示。
Example 5-16. 使用事实和本地变量类型推断的操作
businessRuleEngine.addAction(facts -> {
var jobTitle = facts.getFact("jobTitle");
if ("CEO".equals(jobTitle)) {
var name = facts.getFact("name");
Mailer.sendEmail("sales@company.com", "Relevant customer: " + name);
}
});
Switch 表达式
到目前为止,您只设置了处理一个条件的操作。这相当受限制。例如,假设您与销售团队合作。他们可能在他们的客户关系管理(CRM)系统中记录具有不同金额和不同阶段的不同交易。交易阶段可以表示为枚举Stage
,其值包括LEAD
、INTERESTED
、EVALUATING
、CLOSED
,如 Example 5-17 所示。
Example 5-17. 枚举表示不同的交易阶段
public enum Stage {
LEAD, INTERESTED, EVALUATING, CLOSED
}
根据交易阶段,您可以分配一个规则,给出您赢得交易的概率。因此,您可以帮助销售团队生成预测。例如,对于特定团队,LEAD
阶段的转化概率为 20%,那么一个金额为 1000 美元的LEAD
阶段的交易将有一个预测金额为 200 美元。让我们创建一个操作来建模这些规则,并返回一个特定交易的预测金额,如 Example 5-18 所示。
Example 5-18. 计算特定交易预测金额的规则
businessRuleEngine.addAction(facts -> {
var forecastedAmount = 0.0;
var dealStage = Stage.valueOf(facts.getFact("stage"));
var amount = Double.parseDouble(facts.getFact("amount"));
if(dealStage == Stage.LEAD){
forecastedAmount = amount * 0.2;
} else if (dealStage == Stage.EVALUATING) {
forecastedAmount = amount * 0.5;
} else if(dealStage == Stage.INTERESTED) {
forecastedAmount = amount * 0.8;
} else if(dealStage == Stage.CLOSED) {
forecastedAmount = amount;
}
facts.addFact("forecastedAmount", String.valueOf(forecastedAmount));
});
Example 5-18 中显示的代码基本上为每个枚举值提供一个值。更优选的语言构造是switch
语句,因为它更简洁。这在 Example 5-19 中展示。
示例 5-19. 使用switch
语句计算特定交易预测金额的规则
switch (dealStage) {
case LEAD:
forecastedAmount = amount * 0.2;
break;
case EVALUATING:
forecastedAmount = amount * 0.5;
break;
case INTERESTED:
forecastedAmount = amount * 0.8;
break;
case CLOSED:
forecastedAmount = amount;
break;
}
注意示例 5-19 中代码中的所有break
语句。break
语句确保不执行switch
语句中的下一个块。如果您不小心忘记了break
,则代码仍然会编译,并且会出现所谓的穿透行为。换句话说,将执行下一个块,这可能导致微妙的错误。自 Java 12 起(使用语言功能预览模式),您可以通过使用不同的语法来重写此以避免穿透行为和多个break
,来使用switch
作为表达式,如示例 5-20 所示。
示例 5-20. 没有穿透行为的switch
表达式
var forecastedAmount = amount * switch (dealStage) {
case LEAD -> 0.2;
case EVALUATING -> 0.5;
case INTERESTED -> 0.8;
case CLOSED -> 1;
}
除了增加的可读性外,这种增强的switch
形式还有一个好处是穷尽性。这意味着当您使用switch
与枚举时,Java 编译器会检查所有枚举值是否有对应的switch
标签。例如,如果您忘记处理CLOSED
情况,Java 编译器将产生以下错误:
error: the switch expression does not cover all possible input values.
可以像在示例 5-21 中展示的那样,使用switch
表达式重新编写整体操作。
示例 5-21. 用于计算特定交易预测金额的规则
businessRuleEngine.addAction(facts -> {
var dealStage = Stage.valueOf(facts.getFact("stage"));
var amount = Double.parseDouble(facts.getFact("amount"));
var forecastedAmount = amount * switch (dealStage) {
case LEAD -> 0.2;
case EVALUATING -> 0.5;
case INTERESTED -> 0.8;
case CLOSED -> 1;
}
facts.addFact("forecastedAmount", String.valueOf(forecastedAmount));
});
接口隔离原则
现在我们想开发一个检查工具,允许业务规则引擎的用户检查可能的动作和条件的状态。例如,我们希望评估每个动作和相关条件,以便记录它们而不实际执行动作。我们该如何做呢?当前的Action
接口不够用,因为它没有区分执行的代码与触发该代码的条件。目前没有办法将条件与动作代码分开。为了弥补这一点,我们可以引入一个增强的Action
接口,其中内置了评估条件的功能。例如,我们可以创建一个名为ConditionalAction
的接口,其中包括一个新方法evaluate()
,如示例 5-22 所示。
示例 5-22. ConditionalAction 接口
public interface ConditionalAction {
boolean evaluate(Facts facts);
void perform(Facts facts);
}
现在我们可以实现一个基本的Inspector
类,它接受一组ConditionalAction
对象并根据某些事实对它们进行评估,如示例 5-23 所示。Inspector
返回一个报告列表,其中包含事实、条件动作和结果。Report
类的实现如示例 5-24 所示。
示例 5-23. 条件检查器
public class Inspector {
private final List<ConditionalAction> conditionalActionList;
public Inspector(final ConditionalAction...conditionalActions) {
this.conditionalActionList = Arrays.asList(conditionalActions);
}
public List<Report> inspect(final Facts facts) {
final List<Report> reportList = new ArrayList<>();
for (ConditionalAction conditionalAction : conditionalActionList) {
final boolean conditionResult = conditionalAction.evaluate(facts);
reportList.add(new Report(facts, conditionalAction, conditionResult));
}
return reportList;
}
}
示例 5-24. 报告类
public class Report {
private final ConditionalAction conditionalAction;
private final Facts facts;
private final boolean isPositive;
public Report(final Facts facts,
final ConditionalAction conditionalAction,
final boolean isPositive) {
this.facts = facts;
this.conditionalAction = conditionalAction;
this.isPositive = isPositive;
}
public ConditionalAction getConditionalAction() {
return conditionalAction;
}
public Facts getFacts() {
return facts;
}
public boolean isPositive() {
return isPositive;
}
@Override
public String toString() {
return "Report{" +
"conditionalAction=" + conditionalAction +
", facts=" + facts +
", result=" + isPositive +
'}';
}
}
我们如何测试Inspector
?您可以通过编写一个简单的单元测试开始,如示例 5-25 所示。这个测试突显了我们当前设计的一个基本问题。事实上,ConditionalAction
接口违反了接口隔离原则(ISP)。
示例 5-25. 强调 ISP 违规
public class InspectorTest {
@Test
public void inspectOneConditionEvaluatesTrue() {
final Facts facts = new Facts();
facts.setFact("jobTitle", "CEO");
final ConditionalAction conditionalAction = new JobTitleCondition();
final Inspector inspector = new Inspector(conditionalAction);
final List<Report> reportList = inspector.inspect(facts);
assertEquals(1, reportList.size());
assertEquals(true, reportList.get(0).isPositive());
}
private static class JobTitleCondition implements ConditionalAction {
@Override
public void perform(Facts facts) {
throw new UnsupportedOperationException();
}
@Override
public boolean evaluate(Facts facts) {
return "CEO".equals(facts.getFact("jobTitle"));
}
}
}
什么是接口隔离原则?您可能注意到perform
方法的实现是空的。事实上,它抛出了一个UnsupportedOperationException
异常。这是一个情况,您依赖于一个接口(ConditionalAction
),它提供了比您实际需要的更多内容。在这种情况下,我们只是想要建模一个条件——一个求值为真或假的东西。尽管如此,我们还是被迫依赖于perform()
方法,因为它是接口的一部分。
这个通用想法是接口隔离原则的基础。它主张,任何类都不应该被迫依赖它不使用的方法,因为这会引入不必要的耦合。在第二章中,您学习了另一个原则,即单一责任原则(SRP),它促进了高内聚性。SRP 是一个通用的设计指导原则,一个类应该负责一个功能,并且只有一个改变的原因。尽管 ISP 听起来可能像是相同的想法,但它采取了不同的视角。ISP 关注的是接口的使用者而不是其设计。换句话说,如果一个接口最终非常庞大,可能是因为接口的使用者看到了一些它不关心的行为,这会导致不必要的耦合。
为了符合接口隔离原则,我们鼓励将概念分离到更小的接口中,这些接口可以独立演化。这个想法实质上促进了更高的内聚性。分离接口还为引入更接近所需领域的命名提供了机会,比如Condition
和Action
,我们将在下一节中探讨这些内容。
设计流畅的 API
到目前为止,我们为用户提供了一种添加具有复杂条件的操作的方式。这些条件是使用增强的开关语句创建的。然而,对于业务用户来说,语法并不像他们希望的那样友好,以指定简单条件。我们希望允许他们以符合其领域并且更简单的方式添加规则(条件和动作)。在本节中,您将了解建造者模式以及如何开发自己的流畅 API 来解决这个问题。
什么是流畅 API?
流畅 API 是专门为特定领域量身定制的 API,以便您可以更直观地解决特定问题。它还支持链式方法调用的概念,用于指定更复杂的操作。您可能已经熟悉几个知名的流畅 API:
-
Java Streams API 允许您以更符合解决问题需求的方式指定数据处理查询。
-
Spring Integration 提供了一个 Java API,用于使用与企业集成模式领域接近的词汇指定企业集成模式。
-
jOOQ 提供了一个库,使用直观的 API 与不同的数据库进行交互。
领域建模
那么我们希望为我们的业务用户简化什么?我们希望帮助他们指定一个简单的“当某个条件成立时”,“然后执行某事”的组合作为规则。在此领域中有三个概念:
条件
应用于某些事实的条件,将评估为真或假。
动作
一组特定的操作或要执行的代码。
规则
这是一个条件和一个动作在一起。只有在条件为真时动作才会执行。
现在我们已经定义了领域中的概念,我们将其转换为 Java!让我们首先定义Condition
接口,并重用我们现有的Action
接口,如示例 5-26 所示。请注意,我们也可以使用自 Java 8 起可用的java.util.function.Predicate
接口,但是Condition
名称更能代表我们的领域。
注意
在编程中名称非常重要,因为良好的名称有助于理解代码解决的问题。在许多情况下,名称比接口的“形状”(其参数和返回类型)更重要,因为名称向阅读代码的人传达上下文信息。
示例 5-26. Condition 接口
@FunctionalInterface
public interface Condition {
boolean evaluate(Facts facts);
}
现在剩下的问题是如何建模规则的概念?我们可以定义一个带有perform()
操作的接口Rule
。这将允许您提供Rule
的不同实现。这个接口的一个合适的默认实现是一个名为DefaultRule
的类,它将与执行规则相关的适当逻辑一起持有Condition
和Action
对象,如示例 5-27 所示。
示例 5-27. 建模规则的概念
@FunctionalInterface
interface Rule {
void perform(Facts facts);
}
public class DefaultRule implements Rule {
private final Condition condition;
private final Action action;
public Rule(final Condition condition, final Action action) {
this.condition = condition;
this.action = action;
}
public void perform(final Facts facts) {
if(condition.evaluate(facts)){
action.execute(facts);
}
}
}
我们如何使用所有这些不同的元素创建新规则?您可以在示例 5-28 中看到一个示例。
示例 5-28. 构建一个规则
final Condition condition = (Facts facts) -> "CEO".equals(facts.getFact("jobTitle"));
final Action action = (Facts facts) -> {
var name = facts.getFact("name");
Mailer.sendEmail("sales@company.com", "Relevant customer!!!: " + name);
};
final Rule rule = new DefaultRule(condition, action);
构建器模式
然而,即使代码使用了接近我们域的名称(Condition
、Action
、Rule
),这段代码仍然相当手动。用户必须实例化单独的对象并将它们组合在一起。让我们引入所谓的构建器模式来改进使用适当条件和操作创建 Rule
对象的过程。这种模式的目的是以更简单的方式创建对象。构建器模式基本上会解构构造函数的参数,并提供方法来提供每个参数。这种方法的好处在于它允许您声明与手头域相适应的方法名称。例如,在我们的案例中,我们想要使用 when
和 then
的词汇。示例 5-29 中的代码展示了如何设置构建器模式以构建 DefaultRule
对象。我们引入了一个方法 when()
,它提供了条件。方法 when()
返回 this
(即当前实例),这将允许我们进一步链接其他方法。我们还引入了一个方法 then()
,它提供了动作。方法 then()
也返回 this
,这允许我们进一步链接方法。最后,方法 createRule()
负责创建 DefaultRule
对象。
示例 5-29. 用于 Rule 的构建器模式
public class RuleBuilder {
private Condition condition;
private Action action;
public RuleBuilder when(final Condition condition) {
this.condition = condition;
return this;
}
public RuleBuilder then(final Action action) {
this.action = action;
return this;
}
public Rule createRule() {
return new DefaultRule(condition, action);
}
}
使用这个新类,您可以创建 RuleBuilder
并使用 when()
、then()
和 createRule()
方法配置 Rule
,如 示例 5-30 所示。方法链的概念是设计流畅 API 的关键方面之一。
示例 5-30. 使用 RuleBuilder
Rule rule = new RuleBuilder()
.when(facts -> "CEO".equals(facts.getFact("jobTitle")))
.then(facts -> {
var name = facts.getFact("name");
Mailer.sendEmail("sales@company.com", "Relevant customer: " + name);
})
.createRule();
此代码看起来更像一个查询,并利用了所涉领域:规则的概念、when()
和 then()
作为内置结构。但它并不完全令人满意,因为用户还是会遇到两个笨拙的构造体。
-
实例化一个“空的”
RuleBuilder
-
调用方法
createRule()
我们可以通过提出稍微改进的 API 来改进这一点。有三种可能的改进方法:
-
我们将构造函数设置为私有,以防止用户显式调用。这意味着我们需要为我们的 API 设计一个不同的入口点。
-
我们可以将方法
when()
改为静态方法,这样就可以直接调用并且实际上会将调用转发到旧构造函数。此外,静态工厂方法提高了发现正确方法以设置Rule
对象的可读性。 -
方法
then()
将负责最终创建我们的DefaultRule
对象。
示例 5-31 展示了改进的RuleBuilder
。
示例 5-31. 改进的 RuleBuilder
public class RuleBuilder {
private final Condition condition;
private RuleBuilder(final Condition condition) {
this.condition = condition;
}
public static RuleBuilder when(final Condition condition) {
return new RuleBuilder(condition);
}
public Rule then(final Action action) {
return new DefaultRule(condition, action);
}
}
现在,您可以通过从 RuleBuilder.when()
方法开始,然后使用 then()
方法简单地创建规则,如 示例 5-32 所示。
示例 5-32. 使用改进的 RuleBuilder
final Rule ruleSendEmailToSalesWhenCEO = RuleBuilder
.when(facts -> "CEO".equals(facts.getFact("jobTitle")))
.then(facts -> {
var name = facts.getFact("name");
Mailer.sendEmail("sales@company.com", "Relevant customer!!!: " + name);
});
现在我们已经重构了RuleBuilder
,我们可以重构业务规则引擎以支持规则而不仅仅是动作,如示例 5-33 所示。
示例 5-33. 更新的业务规则引擎
public class BusinessRuleEngine {
private final List<Rule> rules;
private final Facts facts;
public BusinessRuleEngine(final Facts facts) {
this.facts = facts;
this.rules = new ArrayList<>();
}
public void addRule(final Rule rule) {
this.rules.add(rule);
}
public void run() {
this.rules.forEach(rule -> rule.perform(facts));
}
}
收获
-
测试驱动开发(Test-Driven Development,TDD)的哲学是从编写一些测试开始,这些测试将指导您实现代码。
-
模拟允许您编写单元测试,以确保触发某些行为。
-
Java 支持局部变量类型推断和 switch 表达式。
-
建造者模式有助于为实例化复杂对象设计用户友好的 API。
-
接口隔离原则通过减少对不必要方法的依赖来促进高内聚。通过将大型接口分解为更小的内聚接口,使用户只看到他们需要的内容,从而实现这一目标。
在你身上循环
如果您想扩展并巩固本章的知识,您可以尝试以下活动之一:
-
增强
Rule
和RuleBuilder
以支持名称和描述。 -
增强
Facts
类,以便可以从 JSON 文件加载事实。 -
增强业务规则引擎以支持具有多个条件的规则。
-
增强业务规则引擎以支持具有不同优先级的规则。
完成挑战
您的业务蒸蒸日上,您的公司已将业务规则引擎作为工作流程的一部分采纳!现在您正在寻找下一个创意,并希望将您的软件开发技能应用到能够帮助世界而不仅仅是公司的新事物上。是时候迈向下一章——Twootr 了!
第六章:Twootr
挑战
Joe 是一个兴奋的年轻小伙子,热衷于向我讲述他的新创业想法。他的使命是帮助人们更好更快地沟通。他喜欢博客,但他在思考如何让人们更频繁地以更少量的内容进行博客。他称之为微博客。大胆的想法是,如果你将消息大小限制在 140 个字符,人们会频繁地发布少量的消息,而不是大段的消息。
我们问 Joe,他觉得这种限制是否会鼓励人们发表毫无意义的简短言论。他说:“Yolo!”我们问 Joe 如何赚钱。他说:“Yolo!”我们问 Joe 打算给产品取什么名字。他说:“Twootr!”我们觉得这听起来是一个很酷和原创的想法,所以我们决定帮助他建立他的产品。
目标
在本章中,你将了解如何将软件应用程序整合成一个大局。本书中以往的应用程序示例大多是小型示例——在命令行上运行的批处理作业。Twootr 是一个服务器端的 Java 应用程序,类似于大多数 Java 开发人员编写的应用程序类型。
在本章中,你将有机会学习到许多不同的技能:
-
如何将一个大局描述拆分成不同的架构关注点
-
如何使用测试替身(test doubles)来隔离和测试代码库中不同组件之间的交互。
-
如何从需求出发,思考到应用程序领域的核心。
在本章的几个地方,我们不仅会谈论软件的最终设计,还会谈论我们如何达到这一设计的过程。有一些地方我们展示了某些方法是如何随着项目的开发和功能列表的扩展而迭代演变的。这将让你了解到软件项目在现实中如何演变,而不只是呈现一个理想化的最终设计抽象的思考过程。
Twootr 需求
本书中你看到的以前的应用程序都是处理数据和文档的业务应用程序。而 Twootr 是一个面向用户的应用程序。当我们和 Joe 谈论他系统需求的时候,显而易见地,他已经对自己的想法进行了一些精炼。每个用户的微博称为 twoot,用户会有一个持续的 twoot 流。为了看到其他用户的 twoot,你可以 follow 这些用户。
Joe 想出了一些不同的使用案例——他的用户使用服务的场景。这些是我们需要使之正常工作以帮助 Joe 实现帮助人们更好更快地沟通的目标的功能:
-
用户使用唯一的用户 ID 和密码登录到 Twootr。
-
每个用户都有一组其他用户,他们在系统中关注这些用户。
-
用户可以发送一个 twoot,任何已登录的跟随者都应该立即看到这个 twoot。
-
用户登录时应该看到自上次登录以来关注者的所有 Twoots。
-
用户应该能够删除 Twoots。已删除的 Twoots 不应再对关注者可见。
-
用户应该能够从手机或网站登录。
解释如何实现适合 Joe 需求的解决方案的第一步是概述并概述我们面临的宏观设计选择。
设计概述
注意
如果您想要查看本章的源代码,您可以查看书籍代码存储库中的com.iteratrlearning.shu_book.chapter_06
包。
如果您想看到项目的实际操作,您应该从您的 IDE 中运行TwootrServer
类,然后浏览到http://localhost:8000。
如果我们先挑出最后一个需求并首先考虑它,那么与本书中许多其他系统相比,我们需要构建一个以某种方式多台计算机进行通信的系统。这是因为我们的用户可能在不同的计算机上运行软件——例如,一个用户可能在家里的桌面电脑上加载 Twootr 网站,另一个用户可能在手机上运行 Twootr。这些不同的用户界面将如何相互通信?
软件开发人员尝试解决这类问题时采取的最常见方法是使用客户端-服务器模型。在开发分布式应用程序的这种方法中,我们将计算机分为两个主要组。我们有客户端请求某种服务的使用和服务器提供所需的服务。所以在我们的情况下,我们的客户端可能是像网站或移动电话应用程序这样的东西,通过它们,我们可以与 Twootr 服务器通信。服务器将处理大部分业务逻辑并将 Twoots 发送和接收到不同的客户端。这在图 6-1 中显示。
图 6-1. 客户端-服务器模型
从需求和与 Joe 的交谈中明显地可以看出,使该系统正常运行的关键部分之一是能够立即查看您关注的用户的 Twoots 的能力。这意味着用户界面必须具有从服务器接收 Twoots 以及发送它们的能力。从宏观上来说,有两种不同的通信风格可以实现这个目标:拉取式或推送式。
拉取式
在拉取式通信风格中,客户端向服务器发出请求并查询信息。这种通信风格通常被称为点对点风格或请求-响应风格的通信。这是一种特别常见的通信方式,被大多数网站使用。当你加载一个网页时,它会向某个服务器发出 HTTP 请求,以获取页面的数据。拉取式通信风格在客户端控制要加载的内容时非常有用。例如,当你浏览维基百科时,你可以控制你有兴趣阅读或查看的页面,内容响应将被发送回给你。这在图 6-2 中有所体现。
图 6-2. 拉取通信
推送式
另一种方法是推送式通信风格。这可以称为一种反应式或事件驱动的通信方法。在这种模型中,由发布者发出一系列事件,许多订阅者监听这些事件。因此,每次通信不再是一对一的,而是一对多的。这是一个对于需要在多个事件的持续通信模式中进行不同组件交流的系统非常有用的模型。例如,如果你正在设计一个股票市场交易所,不同的公司希望看到不断更新的价格或交易信息,而不是每次想看新的信息时都需要发出新的请求。这在图 6-3 中有所体现。
图 6-3. 推送通信
对于 Twootr 来说,事件驱动的通信风格似乎最适合该应用,因为它主要由持续的“twoots”流组成。在这种模型中,事件将是“twoots”本身。我们当然仍然可以设计应用程序,采用请求-响应通信风格。然而,如果我们选择这条路线,客户端将不得不定期轮询服务器,并使用请求询问:“自从上次请求以来有人发了‘twoot’吗?”在事件驱动风格中,你只需订阅你感兴趣的事件——即关注另一个用户——服务器就会将你感兴趣的“twoots”推送给客户端。
这种选择的事件驱动通信风格将从现在开始影响应用程序的其余设计。当我们编写实现应用程序主类的代码时,我们将接收并发送事件。如何接收和发送事件决定了我们代码中的模式,也决定了我们如何为代码编写测试。
从事件到设计
话虽如此,我们正在构建一个客户端-服务器应用程序——本章将专注于服务器端组件而非客户端组件。在“用户界面”中,你将看到如何为这个代码库开发客户端,以及与本书配套的代码示例中实现的示例客户端。我们之所以专注于服务器端组件,有两个原因。首先,这是一本关于如何在 Java 中编写软件的书,Java 在服务器端广泛使用,但在客户端使用并不广泛。其次,服务器端是应用程序的大脑所在:业务逻辑的核心。客户端只是一个非常简单的代码库,只需将 UI 绑定到发布和订阅事件即可。
通信
我们已经确定我们想要发送和接收事件,我们设计中的一个常见的下一步将是选择某种技术来发送这些消息,或者从我们的客户端到我们的服务器。在这个领域有很多选择,以下是我们可以选择的几种途径:
-
WebSockets 是一种现代、轻量级的通信协议,提供在 TCP 流上进行双向事件通信。它们经常用于 web 浏览器和 web 服务器之间的事件驱动通信,并且受到最新浏览器版本的支持。
-
托管的基于云的消息队列,例如亚马逊简单队列服务(Amazon Simple Queue Service),是广播和接收事件的越来越受欢迎的选择。消息队列是通过发送消息来执行进程间通信的一种方式,这些消息可以由单个进程或一组进程接收。作为托管服务的好处是,你的公司不必费力确保它们可靠地托管。
-
有许多优秀的开源消息传输或消息队列,例如 Aeron、ZeroMQ 和 AMPQ 实现。这些开源项目中的许多都避免了供应商锁定,尽管它们可能会限制你选择可以与消息队列交互的客户端类型。例如,如果你的客户端是一个 web 浏览器,它们就不太适合。
这远非详尽的列表,正如你所看到的,不同的技术有不同的权衡和用例。也许在你自己的程序中,你会选择其中一种技术。在以后的某个时候,你可能会决定它不是正确的选择,想要选择另一种技术。也可能是,你希望为不同类型的连接客户端选择不同类型的通信技术。无论哪种方式,最好在项目开始时做出决定,并避免被迫永远地接受它,这不是一个很好的架构决策。在本章的后面,我们将看到如何将这个架构选择抽象化,以避免一个大错误的前期架构决策。
甚至可能出现这样的情况,您可能希望结合不同的通信方法;例如,通过使用不同的通信方法为不同类型的客户端使用不同的通信方法。图 6-4 可视化使用 WebSockets 与网站通信以及为 Android 移动应用程序推送 Android 推送通知。
图 6-4. 不同的通信方法
GUI
将 UI 通信技术或您的 UI 与核心服务器端业务逻辑耦合也具有其他几个缺点:
-
这是测试困难且缓慢的。每个测试都必须通过与主服务器并行运行的发布和订阅事件来测试系统。
-
它违反了我们在第二章讨论的单一责任原则。
-
它假设我们的客户端将有一个 UI。起初,这对 Twootr 可能是一个坚实的假设,但在辉煌的未来,我们可能希望有交互式的人工智能聊天机器人帮助解决用户问题。或者至少发送 twooting 猫的 GIF!
从中得出的结论是,我们应该明智地引入某种抽象来解耦 UI 的消息传递与核心业务逻辑。我们需要一个接口,通过它可以向客户端发送消息,并且需要一个接口,通过它可以从客户端接收消息。
持久性
在应用程序的另一侧也存在类似的问题。我们应该如何存储 Twootr 的数据?我们可以从以下多种选择中进行选择:
-
我们可以自己索引和搜索的纯文本文件。很容易看出已经记录了什么,并避免依赖于另一个应用程序。
-
传统的 SQL 数据库。它经过充分测试和理解,具有强大的查询支持。
-
一个 NoSQL 数据库。这里有多种不同的数据库,具有不同的用例、查询语言和数据存储模型。
在软件项目开始时,我们真的不知道该选择什么,而且随着时间的推移,我们的需求可能会发生变化。我们确实希望将存储后端的选择与应用程序的其余部分解耦。这些不同问题之间存在相似之处——都是关于希望避免与特定技术耦合。
六边形架构
实际上,这里有一个更一般的架构风格的名称,帮助我们解决这个问题。它被称为端口和适配器或六边形架构,并由Alister Cockburn 最初介绍。这个想法如图 6-5 所示,您的应用程序的核心是您正在编写的业务逻辑,您希望将不同的实现选择与此核心逻辑分开。
每当你有一个想要与业务逻辑核心解耦的技术特定关注点时,你引入一个端口。来自外部世界的事件通过端口到达和离开你的业务逻辑核心。适配器是插入端口的技术特定实现代码。例如,我们可能会有一个用于发布和订阅 UI 事件的端口,以及一个与 Web 浏览器通信的 WebSocket 适配器。
图 6-5. 六边形架构
系统中可能有其他组件,你可能希望为其创建端口和适配器抽象。一个可能与扩展的 Twootr 实现相关的组件是通知系统。通知用户有很多可能感兴趣的 Twoots 可以登录查看,这就是一个端口。你可能希望使用电子邮件或短信的适配器来实现这一功能。
另一个例子是身份验证服务端口。你可能希望先用一个仅存储用户名和密码的适配器开始,稍后将其替换为 OAuth 后端或将其绑定到其他系统。在本章描述的 Twootr 实现中,我们并没有像这样抽象出身份验证。这是因为我们的需求和最初的头脑风暴会议尚未提出我们可能需要不同身份验证适配器的充分理由。
或许你会想知道如何区分什么应该是端口,什么应该是核心域的一部分。在一个极端情况下,你的应用程序中可能会有数百甚至数千个端口,几乎所有内容都可以从核心域中抽象出来。在另一个极端情况下,可能根本不需要端口。在这个滑动尺度上决定应用程序应该处于的位置,是个人判断和具体情况的问题:没有硬性规定。
一个很好的决策原则可能是,把解决业务问题中至关重要的内容视为应用核心的一部分,把技术特定的或涉及与外部世界通信的内容视为应用核心外的内容。这就是我们在这个应用程序中使用的原则。因此,业务逻辑是我们核心域的一部分,但负责持久化和与 UI 的事件驱动通信的部分隐藏在端口后面。
如何入门
我们可以在这个阶段继续以更详细的方式概述设计,设计更复杂的图表,并决定哪个功能应该存在于哪个类中。我们从未发现这是一种非常有效的编写软件的方法。它往往会导致大量的假设和设计决策被推送到架构图中的小框中,结果证明这些小框并不那么小。毫无关于整体设计的思考直接潜入编码中,也不太可能产生最好的软件。软件开发需要足够的前期设计来避免其陷入混乱,但没有对代码进行足够的编写部分的架构很快就会变得枯燥和不切实际。
注意
在开始编写代码之前推动所有设计工作的方法被称为大设计上前,或BDUF。 BDUF 通常与过去 10-20 年变得更受欢迎的敏捷或迭代式开发方法相对比。由于我们发现迭代方法更有效,我们将在接下来的几节中以迭代方式描述设计过程。
在上一章节中,您已经看到了 TDD——测试驱动开发——的介绍,所以现在您应该熟悉了这样的事实,即从一个名为TwootrTest
的测试类开始编写项目是个好主意。因此,让我们从一个测试开始,我们的用户可以登录:shouldBeAbleToAuthenticateUser()
。在这个测试中,用户将登录并正确认证。此方法的骨架可以在示例 6-1 中看到。
示例 6-1. shouldBeAbleToAuthenticateUser() 的骨架
@Test
public void shouldBeAbleToAuthenticateUser()
{
// receive logon message for valid user
// logon method returns new endpoint.
// assert that endpoint is valid
}
为了实现这个测试,我们需要创建一个Twootr
类,并有一种对登录事件进行建模的方式。作为惯例,在本模块中,任何与事件发生相对应的方法都将具有前缀on
。因此,例如,在这里我们将创建一个名为onLogon
的方法。但是这个方法的签名是什么——它需要以什么参数,以及应该回复什么?
我们已经作出了将 UI 通信层与端口分离的架构决策。因此,我们需要决定如何定义 API。我们需要一种向用户发出事件的方式——例如,用户正在关注的另一个用户已经发了两推。我们还需要一种接收来自特定用户的事件的方式。在 Java 中,我们可以使用方法调用来代表事件。因此,每当 UI 适配器想向Twootr
发布事件时,它将在系统核心拥有的对象上调用一个方法。每当Twootr
想要发布事件时,它将在适配器拥有的对象上调用一个方法。
但是端口和适配器的目标是将核心与特定适配器实现解耦。这意味着我们需要某种方式来抽象不同的适配器——一个接口。在这一点上,我们本可以选择使用抽象类。虽然这样也可以运行,但接口更加灵活,因为适配器类可以实现多个接口。而且通过使用接口,我们在一定程度上避免了未来添加一些状态到 API 的邪恶诱惑。在 API 中引入状态是不好的,因为不同的适配器实现可能希望以不同的方式表示其内部状态,因此将状态放入 API 可能导致耦合。
对于发布用户事件的对象,我们不需要使用接口,因为核心中只会有一个实现——我们可以只使用常规类。您可以在图 6-6 中直观地看到我们的方法。当然,为了表示发送和接收事件的 API,我们需要一个名称,或者实际上是一对名称。在这里有很多选择;实际上,任何能清楚表明这些是用于发送和接收事件的 API 的东西都会做得很好。
我们选择了SenderEndPoint
作为发送事件到核心的类,以及ReceiverEndPoint
作为从核心接收事件的接口。实际上,我们可以反转发送和接收的设计,以从用户或适配器的角度工作。这种排序的优势在于我们首先考虑核心,其次考虑适配器。
图 6-6. 事件到代码
现在我们知道我们要走的路线,我们可以编写shouldBeAbleToAuthenticateUser()
测试。这个测试只需测试当我们使用有效的用户名登录系统时,用户是否成功登录即可。这里的登录意味着什么?我们希望返回一个有效的SenderEndPoint
对象,因为这是返回给 UI 以表示刚刚登录的用户的对象。然后,我们需要在我们的Twootr
类中添加一个方法来表示登录事件的发生,并允许测试通过。我们的实现签名显示在示例 6-2 中。由于 TDD 鼓励我们进行最小实现工作以使测试通过,然后演化实现,我们将仅实例化SenderEndPoint
对象并从我们的方法中返回它。
示例 6-2. 第一个 onLogon 签名
SenderEndPoint onLogon(String userId, ReceiverEndPoint receiver);
现在我们已经有了一个漂亮的绿色条,我们需要编写另一个测试——shouldNotAuthenticateUnknownUser()
。这将确保我们不允许一个我们不了解的用户登录系统。在编写此测试时,会出现一个有趣的问题。我们如何在这里建模失败情景?我们不希望在这里返回SenderEndPoint
,但我们确实需要一种方式来指示我们的 UI 登录失败了。一种方法是使用异常,我们在第三章中描述了这种方法。
异常在这里可能有用,但可以说这有点滥用概念。登录失败并不是一个异常情况——这是经常发生的事情。人们可能会拼错用户名,拼错密码,有时甚至会进入错误的网站!另一种替代且常见的方法是,如果登录成功,则返回SenderEndPoint
,如果失败,则返回null
。这种方法有几个缺点:
-
如果另一个开发人员在不检查它是否为
null
的情况下使用该值,则会获得NullPointerException
。这种错误是 Java 开发人员非常常见的错误。 -
没有编译时支持可以帮助避免这种类型的问题。它们会在运行时出现。
-
从方法的签名中无法判断它是故意返回
null
值来模拟失败,还是代码中有 bug。
这里可以帮助的更好的方法是使用Optional
数据类型。这是在 Java 8 中引入的,用于建模可能存在或不存在的值。它是一个通用类型,可以将其视为一个箱子,里面可能有值也可能没有值——一个只有一个或没有值的集合。使用Optional
作为返回类型使得当方法无法返回其值时发生什么变得明确——它返回空的Optional
。我们将在本章中讨论如何创建和使用Optional
类型。因此,我们现在重构我们的onLogon
方法,使其签名为示例 6-3 中的签名。
示例 6-3. 第二个 onLogon 签名
Optional<SenderEndPoint> onLogon(String userId, ReceiverEndPoint receiver);
我们还需要修改shouldBeAbleToAuthenticateUser()
测试,以确保它检查Optional
值是否存在。我们接下来的测试是shouldNotAuthenticateUserWithWrongPassword()
,如示例 6-4 所示。这个测试确保正在登录的用户拥有正确的密码,以使其登录工作。这意味着我们的onLogon()
方法不仅需要存储用户的名称,还需要存储他们的密码在一个Map
中。
示例 6-4. 应该不会因为密码错误而认证用户
@Test
public void shouldNotAuthenticateUserWithWrongPassword()
{
final Optional<SenderEndPoint> endPoint = twootr.onLogon(
TestData.USER_ID, "bad password", receiverEndPoint);
assertFalse(endPoint.isPresent());
}
在这种情况下,存储数据的简单方法是使用一个Map<String, String>
,其中键是用户 ID,值是密码。然而,实际上,用户的概念对我们的领域很重要。我们有涉及用户的故事,并且系统的许多功能与用户之间的交流相关。现在是向我们的实现中添加一个User
领域类的时候了。我们的数据结构将被修改为一个Map<String, User>
,其中键是用户的 ID,值是所讨论用户的User
对象。
TDD 的一个常见批评是它抑制了软件设计。它只会让你编写测试,最终导致贫血的领域模型,你最终不得不在某个时候重写你的实现。所谓贫血的领域模型指的是领域对象没有太多的业务逻辑,而是散布在不同的方法中,以过程化的方式。这确实是对 TDD 有时候可能被实践的一种公平批评。然而,识别在何时添加一个领域类或在代码中实现某个概念是一个微妙的事情。如果这个概念是你的用户故事经常提到的内容,那么你的问题域中确实应该有代表它的东西。
然而,你可以看到一些明显的反模式。例如,如果你建立了不同的查找结构,使用相同的键,同时添加但涉及不同的值,那么你可能缺少一个领域类。因此,如果我们跟踪用户的关注者集合和密码,并且我们有两个Map
对象,一个是用户 ID 对应关注者,一个是用户 ID 对应密码,那么在问题域中缺少一个概念。我们在这里引入了我们的User
类,只关注了一个我们关心的值—密码—但对问题域的理解告诉我们,用户是重要的,因此我们并没有过早行事。
注意
从本章开始,我们将使用“用户”一词来代表用户的一般概念,并使用User
来表示领域类。同样地,我们使用 Twootr 来指代整个系统,使用Twootr
来指代我们正在开发的类。
密码和安全性
到目前为止,我们完全避免讨论安全性。事实上,不谈论安全问题并希望它们会自行消失,是技术行业最喜欢的安全策略。解释如何编写安全代码不是本书的主要目标,甚至不是次要目标;然而,Twootr 确实使用和存储密码进行身份验证,因此值得稍微考虑一下这个话题。
存储密码的最简单方法是将其视为任何其他String
,称为存储它们的明文。一般来说,这是一个坏习惯,因为这意味着任何可以访问您的数据库的人都可以访问所有用户的密码。一个恶意人或组织可以,并且在许多情况下已经使用明文密码来登录您的系统并假装是用户。此外,许多人将相同的密码用于多个不同的服务。如果你不相信我们,问问你的年长亲戚!
为了避免任何人都能访问您的数据库并读取密码,您可以对密码应用加密哈希函数。这是一个函数,它接受一些任意大小的输入字符串并将其转换为一些输出,称为摘要。加密哈希函数是确定性的,因此如果您想再次对相同的输入进行哈希,您可以得到相同的结果。这对于以后检查哈希密码至关重要。另一个关键属性是,虽然从输入到摘要的转换应该很快,但反向函数应该需要很长时间或使用很多内存,以至于攻击者无法反转摘要是不切实际的。
加密哈希函数的设计是一个活跃的研究课题,政府和公司花费了大量资金在上面。它们很难正确实现,因此您永远不应该自己编写——Twootr 使用了一个名为Bouncy Castle的成熟的 Java 库。这是开源的,并经过了大量的同行评审。Twootr 使用了Scrypt哈希函数,这是一种专门用于存储密码的现代算法。示例 6-5 展示了代码示例。
示例 6-5. 密钥生成器
class KeyGenerator {
private static final int SCRYPT_COST = 16384;
private static final int SCRYPT_BLOCK_SIZE = 8;
private static final int SCRYPT_PARALLELISM = 1;
private static final int KEY_LENGTH = 20;
private static final int SALT_LENGTH = 16;
private static final SecureRandom secureRandom = new SecureRandom();
static byte[] hash(final String password, final byte[] salt) {
final byte[] passwordBytes = password.getBytes(UTF_16);
return SCrypt.generate(
passwordBytes,
salt,
SCRYPT_COST,
SCRYPT_BLOCK_SIZE,
SCRYPT_PARALLELISM,
KEY_LENGTH);
}
static byte[] newSalt() {
final byte[] salt = new byte[SALT_LENGTH];
secureRandom.nextBytes(salt);
return salt;
}
}
许多散列方案存在的一个问题是,即使它们计算起来非常昂贵,计算出散列函数的逆转可能也是可行的,通过对所有密钥进行暴力破解直到某个长度或通过彩虹表。为了防范这种可能性,我们使用盐。盐是添加到加密哈希函数中的额外随机生成的输入。通过为每个用户的密码添加一些用户不会输入但是随机生成的额外输入,我们阻止了有人能够创建散列函数的反向查找。他们需要知道哈希函数和盐。
现在我们已经在这里提到了一些围绕密码存储的基本安全概念。实际上,保持系统安全是一个持续不断的工作。你不仅需要担心静态数据的安全性,还需要担心传输中的数据。当有人从客户端连接到您的服务器时,它需要通过网络连接传输用户的密码。如果一个恶意攻击者拦截了这个连接,他们可能会复制密码并用它做 140 个字符中最邪恶的事情!
对于 Twootr 来说,我们通过 WebSockets 收到登录消息。这意味着为了保证我们的应用程序安全,WebSocket 连接需要防止中间人攻击。有几种方法可以做到这一点;最常见和最简单的方法是使用传输层安全性(TLS),这是一种旨在为其连接发送的数据提供隐私和数据完整性的加密协议。
具有成熟安全理解的组织在软件设计中建立定期审查和分析。例如,他们可能定期引入外部顾问或内部团队来尝试渗透系统的安全防御,扮演攻击者的角色。
关注者和 Twoots
我们需要解决的下一个要求是关注用户。您可以考虑以两种不同的方式设计软件。其中一种方法称为自下而上,从设计应用程序的核心开始——数据存储模型或核心领域对象之间的关系——逐步构建系统的功能。在用户之间关注的自下而上方法中,首先需要决定如何建模关注之间的关系。显然这是一种多对多的关系,因为每个用户都可以有多个关注者,一个用户可以关注多个其他用户。然后,您将继续在此数据模型上添加所需的业务功能,以保持用户满意。
另一种方法是软件开发的自上而下方法。这从用户需求或用户故事开始,尝试开发实现这些故事所需的行为或功能,逐步向存储或数据建模的关注点发展。例如,我们将从接收关注另一个用户事件的 API 开始,并设计所需的任何存储机制来实现此行为,逐步从 API 到业务逻辑再到持久化。
很难说在所有情况下哪种方法更好,以及另一种方法总是应该避免;然而,对于 Java 非常流行的企业应用程序来说,我们的经验是自上而下的方法效果最佳。这是因为当你开始进行数据建模或设计软件的核心领域时,你可能会花费不必要的时间在软件正常运行所不必要的功能上。自上而下方法的缺点是,有时随着您构建更多的需求和故事,您的初始设计可能不尽如人意。这意味着您需要对软件设计采取警惕和迭代的方法,不断改进它。
在本书的这一章中,我们将向您展示自上而下的方法。这意味着我们从一个测试开始,以验证关注用户的功能,如示例 6-6 所示。在这种情况下,我们的 UI 将向我们发送一个事件,指示用户想要关注另一个用户,因此我们的测试将调用我们端点的onFollow
方法,并将要关注的用户的唯一 ID 作为参数传递。当然,这个方法还不存在——所以我们需要在Twootr
类中声明它,以便使代码编译通过。
建模错误
在示例 6-6 中的测试仅涵盖了关注操作的黄金路径,因此我们需要确保操作已成功执行。
示例 6-6. 应该关注有效用户
@Test
public void shouldFollowValidUser()
{
logon();
final FollowStatus followStatus = endPoint.onFollow(TestData.OTHER_USER_ID);
assertEquals(SUCCESS, followStatus);
}
目前我们只有一个成功的场景,但还有其他潜在的场景需要考虑。如果作为参数传递的用户 ID 不对应于实际用户会怎样?如果用户已经在关注他们所请求关注的用户会怎样?我们需要一种方法来建模此方法可以返回的不同结果或状态。生活中有很多不同的选择可供我们选择。决策,决策,决策……
一种方法是在操作返回时抛出异常并在成功时返回void
。这可能是一个完全合理的选择。它可能不会违反我们的想法,即异常应仅用于异常控制流,因为一个设计良好的 UI 会在正常情况下避免这些场景的出现。不过,让我们考虑一些替代方案,它们将状态视为一个值,而不是根本不使用异常。
一个简单的方法是使用boolean
值——true
表示成功,false
表示失败。在操作只能成功或失败的情况下,这是一个公平的选择,而且只会因为一个原因而失败。在具有多个失败场景的情况下,boolean
方法的问题在于你不知道为什么它失败了。
或者,我们可以使用简单的int
常量值来表示每种不同的失败场景,但正如在第三章中讨论异常概念时所述,这种方法容易出错、类型不安全,并且可读性和可维护性较差。这里有一个替代方案适用于类型安全并提供更好文档的情况:枚举类型。enum
是一组预定义的常量替代品,构成一个有效的类型。因此,任何可以使用interface
或class
的地方都可以使用enum
。
但是枚举比基于int
的状态码更好。如果一个方法返回一个int
,你不一定知道int
可能包含哪些值。可以添加 javadoc 来描述它可以取哪些值,也可以定义常量(静态 final 字段),但这些只是徒劳的举动。枚举只能包含由enum
声明定义的值列表。在 Java 中,枚举还可以在其上定义实例字段和方法,以添加有用的功能,尽管在这种情况下我们不会使用该功能。您可以在 示例 6-7 中看到我们关注者状态的声明。
示例 6-7. FollowStatus
public enum FollowStatus {
SUCCESS,
INVALID_USER,
ALREADY_FOLLOWING
}
由于 TDD 驱使我们编写最简单的实现来通过测试,所以在这一点上onFollow
方法应该简单地返回SUCCESS
值。
我们有一些不同的场景需要考虑我们的following()
操作。 示例 6-8 展示了驱动我们思考重复用户的测试。为了实现它,我们需要向我们的User
类添加一组用户 ID 来表示此用户正在关注的用户集,并确保添加另一个用户不是重复的。这在 Java 集合 API 中非常容易。已经有了一个定义了唯一元素的Set
接口,如果您要添加的元素已经是Set
的成员,则add
方法将返回false
。
示例 6-8. shouldNotDuplicateFollowValidUser
@Test
public void shouldNotDuplicateFollowValidUser()
{
logon();
endPoint.onFollow(TestData.OTHER_USER_ID);
final FollowStatus followStatus = endPoint.onFollow(TestData.OTHER_USER_ID);
assertEquals(ALREADY_FOLLOWING, followStatus);
}
测试shouldNotFollowInValidUser()
断言如果用户无效,则结果状态将指示。它遵循与shouldNotDuplicateFollowValidUser()
类似的格式。
Twooting
现在我们已经奠定了基础,让我们来看产品的激动人心的部分—twooting!我们的用户故事描述了任何用户都可以发送一个 twoot,并且在那个时刻已经登录的任何关注者应该立即看到这个 twoot。现实情况下,我们不能保证用户会立即看到这个 twoot。也许他们已经登录到他们的计算机,但在喝咖啡,盯着其他社交网络,或者,天佑,做一些工作。
现在你可能已经熟悉了总体方法。我们想要为已登录用户收到来自另一用户发送的 twoot 场景编写一个测试—shouldReceiveTwootsFromFollowedUser()
。除了登录和关注外,这个测试需要一些其他概念。首先,我们需要模拟发送 twoot,并向SenderEndPoint
添加一个onSendTwoot()
方法。这个方法有两个参数,用于 twoot 的id
,这样我们以后就可以引用它,以及它的内容。
第二,我们需要一种方法通知跟随者用户已经发送了一条推文-这是我们可以检查的测试发生的事情。我们之前介绍了ReceiverEndPoint
作为向用户发布消息的一种方式,现在是时候开始使用它了。我们将添加一个onTwoot
方法,导致示例 6-9。
示例 6-9. ReceiverEndPoint
public interface ReceiverEndPoint {
void onTwoot(Twoot twoot);
}
无论我们的 UI 适配器是什么,都必须向 UI 发送消息以告知其发生了推文。但问题是如何编写一个检查此onTwoot
方法是否已被调用的测试呢?
创建模拟对象
这就是模拟对象概念派上用场的地方。模拟对象是一种假装是另一个对象的类型。它具有与被模拟对象相同的方法和公共 API,并且在 Java 类型系统中看起来像另一个对象,但实际上不是。它的目的是记录任何交互,例如方法调用,并能够验证某些方法调用是否发生。例如,这里我们希望能够验证ReceiverEndPoint
的onTwoot()
方法是否已被调用。
注意
对于具有计算机科学学位的人来说,阅读本书时听到“验证”这个词被用于这种方式可能有些混淆。数学和形式化方法的社区倾向于将其用于指所有输入的系统属性已被证明的情况。而在模拟中,“验证”一词的意思完全不同。它只是检查某个方法是否已以特定参数调用。当不同的人群使用同一个词具有多重含义时,有时会令人沮丧,但通常我们只需要意识到术语存在的不同上下文即可。
可以通过多种方式创建模拟对象。最早的模拟对象往往是手工编写的;实际上,我们可以在这里手工编写一个ReceiverEndPoint
的模拟实现,示例 6-10 就是其中一个示例。每当调用onTwoot
方法时,我们通过将Twoot
参数存储在List
中记录其调用,并且可以通过断言List
包含Twoot
对象来验证它已被调用以特定参数。
示例 6-10. MockReceiverEndPoint
public class MockReceiverEndPoint implements ReceiverEndPoint
{
private final List<Twoot> receivedTwoots = new ArrayList<>();
@Override
public void onTwoot(final Twoot twoot)
{
receivedTwoots.add(twoot);
}
public void verifyOnTwoot(final Twoot twoot)
{
assertThat(
receivedTwoots,
contains(twoot));
}
}
在实践中,手动编写模拟可能变得繁琐且容易出错。优秀的软件工程师如何应对繁琐且容易出错的事情呢?没错,他们自动化了这些过程。有许多库可以帮助我们通过提供创建模拟对象的方式来解决这个问题。我们在这个项目中将使用的库称为Mockito,它是免费的、开源的,并且被广泛使用。大多数与Mockito相关的操作可以通过Mockito
类的静态方法来调用,我们在此处使用静态导入。为了创建模拟对象,您需要使用mock
方法,如示例 6-11 所示。
示例 6-11. mockReceiverEndPoint
private final ReceiverEndPoint receiverEndPoint = mock(ReceiverEndPoint.class);
使用模拟对象进行验证
在这里创建的模拟对象可以在正常的 ReceiverEndPoint
实现被使用的任何地方使用。例如,我们可以将它作为参数传递给 onLogon()
方法,以连接 UI 适配器。一旦测试的行为——测试的“when”——发生了,我们的测试需要实际验证 onTwoot
方法是否被调用——“then”。为了做到这一点,我们使用 Mockito.verify()
方法包装模拟对象。这是一个通用方法,返回与其传入类型相同的对象;我们只需调用所期望的方法,并传入我们期望的参数,以描述与模拟对象的预期交互,如示例 6-12 所示。
示例 6-12. 验证接收端点
verify(receiverEndPoint).onTwoot(aTwootObject);
在上一节中你可能注意到的一件事是我们引入了 Twoot
类,它在 onTwoot
方法的签名中使用。这是一个值对象,用于封装值并表示 Twoot
。由于它将被发送到 UI 适配器,因此它应该只包含简单值的字段,而不是从核心领域中过度暴露。例如,为了表示 Twoot
的发送者,它包含发送者的 id
,而不是它们的 User
对象的引用。Twoot
还包含一个 content
的 String
和 Twoot
对象本身的 id
。
在这个系统中,Twoot
对象是不可变的。如前所述,这种风格减少了错误的可能性。在像传递给 UI 适配器的值对象中,这尤为重要。你确实只想让你的 UI 适配器显示 Twoot
,而不是改变另一个用户的 Twoot
的状态。值得注意的是,我们继续遵循领域语言,将类命名为 Twoot
。
模拟库
我们在本书中使用 Mockito 是因为它有良好的语法,并且符合我们的首选编写模拟的方式,但它并不是唯一的 Java 模拟框架。Powermock 和 EasyMock 也很流行。
Powermock 可以模拟 Mockito 的语法,但允许您模拟 Mockito 不支持的事物,例如最终类或静态方法。关于是否应该模拟最终类等事物存在一些争论——如果你不能在生产中提供一个不同的实现,那么在测试中确实需要这样做吗?一般来说,不鼓励使用 Powermock,但在偶尔的特殊情况下,它确实是有用的。
EasyMock 在编写模拟时采用了不同的方法。这是一种风格选择,可能会被一些开发人员所青睐。最大的概念上的差异在于,EasyMock 鼓励严格模拟。严格模拟的理念是,如果你没有明确声明一个调用应该发生,那么如果它确实发生了,那就是一个错误。这导致测试对类执行的行为更加具体,但有时可能会与无关的交互耦合在一起。
SenderEndPoint
现在像 onFollow
和 onSendTwoot
这样的方法都声明在 SenderEndPoint
类上。每个 SenderEndPoint
实例代表了一个用户将事件发送到核心领域的终点。我们的 Twoot
设计保持了 SenderEndPoint
的简单性 —— 它只是将主 Twootr
类包装起来,并委托调用这些方法,传入系统中表示的用户的 User
对象。 示例 6-13 显示了类的整体声明和一个方法对应一个事件的示例 —— onFollow
。
示例 6-13. SenderEndPoint
public class SenderEndPoint {
private final User user;
private final Twootr twootr;
SenderEndPoint(final User user, final Twootr twootr) {
Objects.requireNonNull(user, "user");
Objects.requireNonNull(twootr, "twootr");
this.user = user;
this.twootr = twootr;
}
public FollowStatus onFollow(final String userIdToFollow) {
Objects.requireNonNull(userIdToFollow, "userIdToFollow");
return twootr.onFollow(user, userIdToFollow);
}
你可能已经注意到 示例 6-13 中的 java.util.Objects
类。这是 JDK 自带的实用类,提供了用于检查 null
引用和实现 hashCode()
和 equals()
方法的便捷方法。
有一些替代设计我们可以考虑,而不是引入 SenderEndPoint
。我们可以通过直接在 Twootr
对象上公开方法来接收与用户相关的事件,并期望任何 UI 适配器直接调用这些方法。这是一个主观问题,就像软件开发的许多部分一样。有些人会认为创建 SenderEndPoint
增加了不必要的复杂性。
这里最大的动机是,如前所述,我们不想将 User
核心域对象暴露给 UI 适配器 —— 只需简单事件来与他们交流。可能可以在所有 Twootr
事件方法中将用户 ID 作为参数,但然后每个事件的第一步都需要查找该 ID 的 User
对象,而在这里我们已经在 SenderEndPoint
的上下文中有它了。那种设计会去除 SenderEndPoint
的概念,但以交换更多的工作和复杂性。
为了实际发送 Twoot
,我们需要稍微完善我们的核心领域。User
对象需要添加一组关注者,当 Twoot
到达时可以通知他们。你可以在 示例 6-14 中看到我们 onSendTwoot
方法的实现代码。在设计的这个阶段,它找到已登录的用户并告诉他们接收 Twoot
。如果你对 filter
和 forEach
方法,以及 ::
或 ->
语法不熟悉,不用担心 —— 这些内容将在 “函数式编程” 中介绍。
示例 6-14. onSendTwoot
void onSendTwoot(final String id, final User user, final String content)
{
final String userId = user.getId();
final Twoot twoot = new Twoot(id, userId, content);
user.followers()
.filter(User::isLoggedOn)
.forEach(follower -> follower.receiveTwoot(twoot));
}
User
对象还需要实现 receiveTwoot()
方法。用户如何接收 Twoot
?好吧,它应该通过发出事件通知用户界面,表明有一个 Twoot
准备好显示,即调用 receiverEndPoint.onTwoot(twoot)
。这是我们使用模拟代码验证调用的方法,并在这里调用它使测试通过。
您可以在 Example 6-15 中看到我们测试的最终迭代,如果您从 GitHub 下载示例项目,则可以看到这段代码。您可能会注意到它看起来与我们到目前为止描述的有些不同。首先,由于已编写接收 twoots 的测试,一些操作已经重构到了公共方法中。其中一个示例是logon()
,它将我们的第一个用户登录到系统中——这是许多测试给定部分的一部分。其次,该测试还创建了一个Position
对象,并将其传递给Twoot
,并验证了与twootRepository
的交互。仓库是什么鬼?这两者到目前为止我们还没有需要,但它们是系统设计演变的一部分,将在接下来的两个部分中解释。
Example 6-15. shouldReceiveTwootsFromFollowedUser
@Test
public void shouldReceiveTwootsFromFollowedUser()
{
final String id = "1";
logon();
endPoint.onFollow(TestData.OTHER_USER_ID);
final SenderEndPoint otherEndPoint = otherLogon();
otherEndPoint.onSendTwoot(id, TWOOT);
verify(twootRepository).add(id, TestData.OTHER_USER_ID, TWOOT);
verify(receiverEndPoint).onTwoot(new Twoot(id, TestData.OTHER_USER_ID, TWOOT, new Position(0)));
}
位置
你很快就会了解Position
对象,但在展示它们的定义之前,我们应该了解它们的动机。我们需要解决的下一个要求是,当用户登录时,他们应该看到自上次登录以来他们关注者的所有 twoots。这需要能够对不同的 twoots 执行某种重播,并知道用户登录时还未看到的 twoots。Example 6-16 展示了该功能的一个测试。
Example 6-16. shouldReceiveReplayOfTwootsAfterLogoff
@Test
public void shouldReceiveReplayOfTwootsAfterLogoff()
{
final String id = "1";
userFollowsOtherUser();
final SenderEndPoint otherEndPoint = otherLogon();
otherEndPoint.onSendTwoot(id, TWOOT);
logon();
verify(receiverEndPoint).onTwoot(twootAt(id, POSITION_1));
}
为了实现这个功能,我们的系统需要知道用户注销时发送了哪些 twoots。我们可以考虑设计这一功能的许多不同方法。不同的方法在实现复杂性、正确性和性能/可扩展性方面可能有不同的权衡。由于我们刚刚开始构建 Twootr,并不指望有很多用户,所以我们的重点不在可扩展性问题上:
-
我们可以追踪每个 twoot 的时间以及用户注销的时间,并在这些时间之间搜索 twoot。
-
我们可以将 twoots 视为一个连续的流,其中每个 twoot 在流中有一个位置,并在用户注销时记录该位置。
-
我们可以使用位置并记录上次查看的 twoot 的位置。
在考虑不同的设计时,我们会避免按时间排序消息。这似乎是一个好主意。假设我们以毫秒为单位存储时间单元——如果我们在同一时间间隔内接收到两个 twoot 会发生什么?我们将不知道这两个 twoot 之间的顺序。如果一个 twoot 在用户注销的同一毫秒接收到呢?
记录用户注销时间是另一个问题事件。如果用户仅通过显式点击按钮来注销,那可能还好。然而,在实际操作中,这只是他们停止使用我们用户界面的几种方式之一。也许他们会关闭网页浏览器而没有显式注销,或者他们的浏览器会崩溃。如果他们从两个网页浏览器连接,然后从其中一个注销会发生什么?如果他们的手机电量耗尽或关闭应用程序会发生什么?
我们决定最安全的方法来确定从哪里重播 twoots 是给 twoots 分配位置并存储每个用户已看到的位置。为了定义位置,我们引入了一个小的值对象称为 Position
,如示例 6-17 所示。这个 class
还有一个常量值,用于流开始前流的初始位置。由于我们所有的位置值都是正数,我们可以使用任何负整数作为初始位置:这里选择了 -1
。
示例 6-17. 位置
public class Position {
/**
* Position before any tweets have been seen
*/
public static final Position INITIAL_POSITION = new Position(-1);
private final int value;
public Position(final int value) {
this.value = value;
}
public int getValue() {
return value;
}
@Override
public String toString() {
return "Position{" +
"value=" + value +
'}';
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final Position position = (Position) o;
return value == position.value;
}
@Override
public int hashCode() {
return value;
}
public Position next() {
return new Position(value + 1);
}
}
这个类看起来有点复杂,不是吗?在你的编程过程中,你可能会问自己:为什么我在这里定义了 equals()
和 hashCode()
方法,而不是让 Java 自己处理?什么是值对象?为什么我问了这么多问题?别担心,我们刚刚介绍了一个新主题,很快会回答你的问题。引入一些代表由字段组成的值或为某些数值赋予相关领域名称的小对象通常非常方便。我们的 Position
类就是一个例子;另一个例子可能是你在示例 6-18 中看到的 Point
类。
示例 6-18. Point
class Point {
private final int x;
private final int y;
Point(final int x, final int y) {
this.x = x;
this.y = y;
}
int getX() {
return x;
}
int getY() {
return y;
}
一个 Point
有一个 x
坐标和一个 y
坐标,而 Position
只有一个值。我们已经在类上定义了字段并为这些字段定义了 getters。
equals 和 hashcode 方法
如果我们想要比较两个像这样定义的对象,具有相同的值,那么我们发现当我们希望它们相等时它们却不等。示例 6-19 展示了一个例子;默认情况下,从 java.lang.Object
继承的 equals()
和 hashCode()
方法被定义为使用引用相等的概念。这意味着,如果你在计算机内存中的不同位置有两个不同的对象,那么它们不相等——即使所有字段的值都相等。这可能导致程序中出现许多微妙的错误。
示例 6-19. 当应该相等时,Point 对象却不相等
final Point p1 = new Point(1, 2);
final Point p2 = new Point(1, 2);
System.out.println(p1 == p2); // prints false
通常有助于根据两种不同类型的对象——引用对象 和 值对象——来思考它们的相等性概念。在 Java 中,我们可以重写 equals()
方法以定义我们自己的实现,该实现使用被视为与值相等相关的字段。例如,示例 6-20 中展示了 Point
类的示例实现。我们检查给定的对象是否与此对象的类型相同,然后检查每个字段是否相等。
示例 6-20. 点相等性定义
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final Point point = (Point) o;
if (x != point.x) return false;
return y == point.y;
}
@Override
public int hashCode() {
int result = x;
result = 31 * result + y;
return result;
}
final Point p1 = new Point(1, 2);
final Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // prints true
equals 方法与 hashCode 方法之间的合同
在 示例 6-20 中,我们不仅重写了 equals()
方法,还重写了 hashCode()
方法。这是由于 Java 的 equals/hashcode 合同。这个合同规定,如果根据它们的 equals()
方法两个对象相等,则它们的 hashCode()
结果也必须相同。许多核心 Java API 使用 hashCode()
方法——特别是像 HashMap
和 HashSet
这样的集合实现。它们依赖于此合同的正确性,如果不正确,则它们的行为可能与您的期望不符。那么,如何正确地实现 hashCode()
呢?
良好的 hashCode 实现不仅遵循合同,而且生成的哈希码值在整数中均匀分布。这有助于提高 HashMap
和 HashSet
实现的效率。为了实现这两个目标,以下是一系列简单的规则,如果您遵循将会得到一个良好的 hashCode()
实现:
-
创建一个
result
变量并将其赋值为一个素数。 -
对于每个在
equals()
方法中使用的字段,取一个int
值来表示字段的哈希码。 -
将字段的哈希码与现有结果结合起来,方法是将前一个结果乘以一个素数,例如,
result = 41 * result + hashcodeOfField;
为了计算每个字段的哈希码,您需要根据字段类型的不同进行区分:
-
如果字段是原始值,使用其伴随类提供的
hashCode()
方法。例如,如果是double
类型,则使用Double.hashCode()
。 -
如果它是非空对象,只需调用其
hashCode()
方法,否则使用0
。这可以用java.lang.Objects.hashCode()
方法进行缩写。 -
如果是数组,则需要使用与我们在此处描述的相同规则来组合每个元素的
hashCode()
值。您可以使用java.util.Arrays.hashCode()
方法来执行此操作。
在大多数情况下,您不需要实际编写equals()
和hashCode()
方法。现代 Java IDE 将为您生成它们。尽管如此,了解生成的代码背后的原理和原因仍然是有帮助的。特别重要的是能够审查您在代码中看到的一对equals()
和hashCode()
方法,以及知道它们是否实现良好或糟糕。
注释
我们在这一节中稍微提到了价值对象,但是未来的 Java 版本计划包含内联类。这些正在 Valhalla 项目 中原型化。内联类的想法是提供一种非常高效的方式来实现看起来像值的数据结构。你仍然可以像对待普通类一样编写代码,但它们将生成正确的hashCode()
和equals()
方法,占用更少的内存,并且在许多使用情况下编程速度更快。
在实现此功能时,我们需要将Position
与每个Twoot
关联起来,因此我们向Twoot
类添加一个字段。我们还需要记录每个用户的上次查看的Position
,因此我们向User
添加一个lastSeenPosition
。当用户接收到Twoot
时,他们会更新他们的位置,当用户登录时,他们会发送用户尚未看到的Twoot
。因此,SenderEndPoint
或ReceiverEndPoint
不需要添加新事件。重放Twoot
还要求我们将Twoot
对象存储在某个地方——最初我们只使用 JDK 的List
。现在我们的用户不必一直登录到系统中才能享受到 Twootr,这真是太棒了。
要点
-
您了解了像通信风格这样的更大范围的架构思想。
-
您开发了将领域逻辑与库和框架选择分离的能力。
-
您通过测试驱动开发了本章中的代码,从外到内。
-
您将面向对象的领域建模技能应用于一个更大的项目中。
在您的开发中
如果您想要扩展和巩固本节的知识,可以尝试以下活动之一:
-
尝试文字包裹 Kata。
-
在没有阅读下一章之前,请写下一份需要实现的 Twootr 完整清单。
完成挑战
我们与您的客户乔进行了跟进会议,并讨论了项目取得的巨大进展。已经涵盖了许多核心领域要求,并且我们已经描述了系统的设计方式。当然,此时 Twootr 还不完整。到目前为止,您尚未听说过如何将应用程序的各个组件连接起来,以便它们可以彼此通信。您也尚未了解到我们如何将 Twootr 的状态持久化到某种存储系统中,这样当 Twootr 重新启动时不会丢失。
Joe 对所取得的进展感到非常兴奋,他非常期待看到完成的 Twootr 实现。最后一章将完成 Twootr 的设计,并涵盖剩余的主题。
第七章:扩展 Twootr
挑战
之前在 Twootr 上,Joe 想要实现一个现代化的在线通信系统。上一章介绍了 Twootr 的潜在设计和核心业务域的实现,包括通过测试驱动设计的方法来实现该设计。您了解了涉及的一些设计和数据建模决策,以及如何分解初始问题并构建解决方案。这并没有涵盖整个 Twootr 项目,因此本章需要完成这个叙述。
目标
本章通过帮助您了解以下主题,扩展并完善了前一章中取得的进展:
-
避免依赖倒置原则和依赖注入的耦合
-
使用 Repository 模式和查询对象模式进行持久化。
-
简要介绍函数式编程,将向您展示如何在 Java 特定的上下文和实际应用程序中使用这些想法。
总结
由于我们从前一章继续进行 Twootr 项目,因此现在可能值得回顾我们设计中的关键概念。如果您正在一场马拉松式的阅读中继续前一章节,那么我们很高兴您喜欢这本书,但请随意跳过本节。
-
Twootr
是实例化业务逻辑并编排系统的父类。 -
Twoot
是我们系统中用户广播的消息的单个实例。 -
ReceiverEndPoint
是一个由 UI 适配器实现的接口,用于向 UI 推送Twoot
对象。 -
SenderEndPoint
具有与用户从系统中发送的事件相对应的方法。 -
密码管理和哈希由
KeyGenerator
类执行。
持久化和 Repository 模式
现在我们有了一个能够支持大部分核心 Twootr 操作的系统。不幸的是,如果我们以任何方式重新启动 Java 进程,所有的 Twoots 和用户信息都会丢失。我们需要一种持久化信息的方法,以便在重新启动后能够存活下来。在讨论软件架构的早期,我们谈到了端口和适配器以及如何希望使我们应用程序的核心与存储后端无关。事实上,有一种常用的模式可以帮助我们做到这一点:Repository 模式。
Repository 模式定义了领域逻辑和存储后端之间的接口。除了允许我们随着应用程序的演进在不同的存储后端之间切换外,这种方法还提供了几个优点:
-
将数据从存储后端映射到领域模型的逻辑集中化。
-
使核心业务逻辑的单元测试成为可能,而不必启动数据库。这可以加快测试的执行速度。
-
通过保持每个类单一职责,提高了可维护性和可读性。
您可以将存储库视为类似于对象集合,但存储库不仅仅将对象存储在内存中,还会将它们持久化在某个地方。在演化我们应用程序设计时,我们通过测试推动了存储库的设计;但是,为了节省时间,我们只会描述最终实现。由于存储库是对象的集合,我们在 Twootr 中需要两个存储库:一个用于存储User
对象,另一个用于存储Twoot
对象。大多数存储库具有一系列常见的操作,这些操作已经实现:
add()
将对象的新实例存储到存储库中。
get()
根据标识符查找单个对象。
delete()
从持久性后端删除实例。
update()
确保为此对象保存的值等于实例字段。
有些人使用 CRUD 缩写来描述这些操作。这代表 Create,Read,Update 和 Delete。我们使用add
和get
而不是create
和read
,因为命名更符合常见的 Java 用法,例如,在集合框架中。
设计存储库
在我们的情况下,我们从顶向下设计事物,并通过测试驱动存储库的开发。这意味着,并非所有操作都在两个存储库上定义。如示例 7-1 所示的UserRepository
,没有一个操作来删除一个User
。这是因为实际上没有要求执行删除用户操作。我们问了我们的客户,乔,关于这个问题,他说“一旦你开始 Twoot,你就停不下来了!”
当独自工作时,您可能会想要添加功能,只是为了使存储库中有“正常”的操作,但我们强烈警告不要走这条路。未使用的代码,或者通常被称为死代码,是一种负担。从某种意义上说,所有的代码都是一种负担,但是如果代码实际上是有用的,那么它对您的系统有益处,而如果它没有被使用,那么它只是一种负担。随着您的需求的演变,您需要重构和改进您的代码库,而如果您有很多未使用的代码,这个任务就会变得更加困难。
这里有一个指导原则,我们在整章中一直在暗示,但直到现在才提到:YAGNI。这代表You ain’t gonna need it。这并不意味着不要引入抽象和不同的概念,比如存储库。它只是表示在实际需要时才编写代码,而不是认为将来会需要它时编写代码。
示例 7-1. UserRepository
public interface UserRepository extends AutoCloseable {
boolean add(User user);
Optional<User> get(String userId);
void update(User user);
void clear();
FollowStatus follow(User follower, User userToFollow);
}
由于它们存储的对象的性质不同,我们的两个存储库的设计也有所不同。我们的Twoot
对象是不可变的,因此示例 7-2 中显示的TwootRepository
不需要实现update()
操作。
示例 7-2. TwootRepository
public interface TwootRepository {
Twoot add(String id, String userId, String content);
Optional<Twoot> get(String id);
void delete(Twoot twoot);
void query(TwootQuery twootQuery, Consumer<Twoot> callback);
void clear();
}
通常,存储库中的 add()
方法只是将相关对象持久化到数据库中。在 TwootRepository
的情况下,我们采取了不同的方法。这个方法接受一些特定的参数,并且实际上创建了相关对象。采用这种方法的动机是数据源将负责为 Twoot
分配下一个 Position
对象。我们将确保唯一和有序对象的责任委托给将具有创建此类序列的适当工具的数据层。
另一种选择可能是接受一个没有分配 position
的 Twoot
对象,然后在添加时设置 position
字段。现在,对象构造函数的一个关键目标应该是确保所有的内部状态都被完全初始化,理想情况下应该使用 final
字段进行检查。通过在对象创建时不分配位置,我们将创建一个未完全实例化的对象,违反了我们围绕创建对象的原则之一。
Repository 模式的一些实现引入了一个通用接口,例如,类似 示例 7-3 的内容。在我们的情况下,这并不合适,因为 TwootRepository
没有 update()
方法,而 UserRepository
没有 delete()
方法。如果你想编写能够抽象不同存储库的代码,那么这可能会很有用。为了设计一个良好的抽象,避免仅仅为了这个目的而强行将不同的实现合并到同一个接口中是很重要的。
示例 7-3. 抽象存储库
public interface AbstractRepository<T>
{
void add(T value);
Optional<T> get(String id);
void update(T value);
void delete(T value);
}
查询对象
不同存储库之间的另一个关键区别是它们如何支持查询。在 Twootr 的情况下,我们的 UserRepository
不需要任何查询功能,但是当涉及到 Twoot
对象时,我们需要能够查找 twoots 以便在用户登录时重放它们。实现这个功能的最佳方式是什么?
嗯,我们在这里可以做几种不同的选择。最简单的方法是,我们可以简单地将我们的存储库视为一个纯粹的 Java
Collection
,并且有一种方法可以迭代不同的 Twoot
对象。然后,查询/过滤的逻辑可以以正常的 Java 代码编写。这很好,但是可能会相当慢,因为它要求我们从数据存储中检索所有行到我们的 Java 应用程序中以便进行查询,而实际上我们可能只想要其中的一些行。通常,像 SQL 数据库这样的数据存储后端具有高度优化和高效的数据查询和排序实现,最好将查询交给它们来处理。
决定仓库实现需要负责查询数据存储后,我们需要决定如何最好地通过TwootRepository
接口公开这一功能。一种选择是添加一个与我们的业务逻辑耦合的方法来执行查询操作。例如,我们可以从示例 7-4 编写像twootsForLogon()
方法来获取与用户关联的 twoots。这样做的缺点是,我们现在将特定的业务逻辑功能耦合到了我们的仓库实现中——这与引入仓库抽象的初衷相悖。这将使我们难以根据需求演化我们的实现,因为我们不得不修改仓库以及核心域逻辑,并且违反了单一职责原则。
示例 7-4. twootsForLogon
List<Twoot> twootsForLogon(User user);
我们想要设计的是一种能够利用数据存储的查询能力,而不将业务逻辑与所讨论的数据存储耦合在一起的东西。我们可以为给定的业务条件向仓库添加一个特定的查询方法,正如示例 7-5 所示。这种方法比前两种方法要好得多,但仍然可以稍作调整。将每个查询硬编码到特定方法中的问题在于,随着应用程序随时间的推移演变并增加更多查询功能,我们将不得不添加越来越多的方法到 Repository 接口中,这会使其变得臃肿并且难以理解。
示例 7-5. twootsFromUsersAfterPosition
List<Twoot> twootsFromUsersAfterPosition(Set<String> inUsers, Position lastSeenPosition);
这引导我们进入下一个查询迭代,显示在示例 7-6 中。在这里,我们将我们的TwootRepository
查询条件抽象成了自己的对象。现在,我们可以添加额外的属性到这个条件中进行查询,而无需将查询方法的数量变成关于不同属性的组合爆炸。我们的TwootQuery
对象的定义如示例 7-7 所示。
示例 7-6. query
List<Twoot> query(TwootQuery query);
示例 7-7. TwootQuery
public class TwootQuery {
private Set<String> inUsers;
private Position lastSeenPosition;
public Set<String> getInUsers() {
return inUsers;
}
public Position getLastSeenPosition() {
return lastSeenPosition;
}
public TwootQuery inUsers(final Set<String> inUsers) {
this.inUsers = inUsers;
return this;
}
public TwootQuery inUsers(String... inUsers) {
return inUsers(new HashSet<>(Arrays.asList(inUsers)));
}
public TwootQuery lastSeenPosition(final Position lastSeenPosition) {
this.lastSeenPosition = lastSeenPosition;
return this;
}
public boolean hasUsers() {
return inUsers != null && !inUsers.isEmpty();
}
}
这并不是查询 twoots 的最终设计方法。通过返回一个List
对象,意味着我们需要一次性将要返回的所有Twoot
对象加载到内存中。当这个List
可能变得非常大时,这并不是一个好主意。我们也许不想一次性查询所有对象。在这种情况下——我们想要将每个Twoot
对象推送到我们的 UI 中,而不需要一次性将它们全部保存在内存中。一些仓库实现会创建一个对象来模拟返回的结果集。这些对象让你可以分页或迭代访问这些值。
在这种情况下,我们将执行一些更简单的操作:只需采取一个 Consumer<Twoot>
回调。这是调用者将传递的一个函数,它接受一个参数——一个 Twoot
——并返回 void
。我们可以使用 lambda 表达式或方法引用来实现此接口。您可以在示例 7-8 中看到我们的最终方法。
示例 7-8. 查询
void query(TwootQuery twootQuery, Consumer<Twoot> callback);
参见示例 7-9 以了解如何使用此查询方法。这是我们的 onLogon()
方法调用查询的方式。它获取已登录用户,并使用该用户正在关注的用户集合作为查询的用户部分。然后,它使用该查询部分的上次查看位置。接收此查询结果的回调是 user::receiveTwoot
,这是对我们之前描述的将 Twoot
对象发布到 UI ReceiverEndPoint
的函数的方法引用。
示例 7-9. 使用查询方法的示例
twootRepository.query(
new TwootQuery()
.inUsers(user.getFollowing())
.lastSeenPosition(user.getLastSeenPosition()),
user::receiveTwoot);
就是这样——这是我们设计并在应用程序逻辑核心中可用的存储库接口。
还有另一个功能,一些存储库实现使用了,这里我们没有描述,那就是工作单元模式。我们在 Twootr 中没有使用工作单元模式,但它通常与存储库模式一起使用,因此在这里提到它是值得的。企业应用程序通常会执行一个单一操作,与数据存储交互多次。例如,您可能正在两个银行账户之间转移资金,并希望在同一操作中从一个账户中取款并将其添加到另一个账户中。您不希望这些操作中的任何一个成功,而另一个不成功——在债务人账户没有足够资金时,您不希望将资金存入债权人账户。您也不希望在确保可以向债权人账户存入资金之前减少债务人的余额。
数据库通常实现事务和 ACID 合规性,以便使人们能够执行这些类型的操作。事务本质上是一组不同的数据库操作,逻辑上作为单个原子操作执行。工作单元是一种设计模式,可以帮助您执行数据库事务。基本上,您在存储库上执行的每个操作都会在工作单元对象中注册。然后,您的工作单元对象可以委托给一个或多个存储库,将这些操作包装在一个事务中。
到目前为止我们还没有讨论如何实际实现我们设计的存储库接口。就像软件开发中的其他事物一样,通常有不同的路线可选。Java 生态系统包含许多对象关系映射(ORM)工具,试图为您自动化这些实现任务。最流行的 ORM 是 Hibernate。ORM 通常是一种简单的方法,可以为您自动化一些工作;然而,它们往往会产生不够优化的数据库查询代码,并且有时会引入更多复杂性,而不是帮助减少。
在示例项目中,我们为每个存储库提供了两种实现方式。其中一种是非常简单的内存实现,适合用于测试,不会在重新启动时保留数据。另一种方法使用了普通的 SQL 和 JDBC API。我们不会详细讨论实现细节,因为大部分并未展示出特别有趣的 Java 编程思想;然而,在“函数式编程”章节中,我们将讨论如何在实现中应用一些函数式编程的思想。
函数式编程
函数式编程是一种将方法视为数学函数运行的计算机编程风格。这意味着它避免了可变状态和数据改变。你可以在任何语言中以这种风格编程,但有些编程语言提供了功能来帮助简化和改进——我们称之为函数式编程语言。Java 不是一种函数式编程语言,但在发布 20 年后的第 8 版中,它开始添加了一些功能,帮助实现了在 Java 中进行函数式编程。这些功能包括 lambda 表达式、Streams 和 Collectors API,以及 Optional
类。在本节中,我们将简要介绍这些函数式编程特性的使用及在 Twootr 中的应用。
在 Java 8 之前,库编写者在使用抽象级别上存在限制。一个很好的例子是缺乏对大型数据集进行有效并行操作的能力。从 Java 8 开始,你可以编写复杂的集合处理算法,通过改变一个方法调用,就能在多核 CPU 上高效执行这些代码。然而,为了能够编写这类大数据并行库,Java 需要进行一次新的语言改变:lambda 表达式。
当然,这也是有成本的,因为你必须学会编写和阅读支持 Lambda 的代码,但这是一个很好的权衡。程序员学习少量新语法和几种新习惯比手写大量复杂的线程安全代码要容易得多。优秀的库和框架显著降低了开发企业业务应用程序的成本和时间,应该消除开发易于使用和高效库的任何障碍。
抽象是任何进行面向对象编程的人熟悉的概念。不同之处在于,面向对象编程主要是抽象化数据,而函数式编程主要是抽象化行为。现实世界有这两种东西,我们的程序也有,因此我们可以并且应该从两者的影响中学习。
这种新抽象还有其他好处。对于我们中的许多人来说,不是一直编写性能关键代码,这些更重要的优势更胜一筹。您可以编写更易于阅读的代码——花时间表达其业务逻辑意图而不是其实现机制的代码。易于阅读的代码比难以阅读的代码更易于维护,更可靠,更少出错。
Lambda 表达式
我们将定义一个 Lambda 表达式作为描述匿名函数的简洁方式。我们理解一次性掌握这么多内容可能有些困难,因此我们将通过实际的 Java 代码示例来解释 Lambda 表达式是什么。让我们从我们代码库中用于表示回调的接口 ReceiverEndPoint
开始,如示例 7-10 所示。
Example 7-10. ReceiverEndPoint
public interface ReceiverEndPoint {
void onTwoot(Twoot twoot);
}
在这个例子中,我们正在创建一个新对象,该对象提供了 ReceiverEndPoint
接口的实现。这个接口有一个方法 onTwoot
,当 Twootr 对象将一个 Twoot
对象发送到 UI 适配器时,将调用此方法。在 Example 7-11 中列出的类提供了此方法的实现。在这种情况下,为了保持简单,我们只是在命令行上打印它,而不是将序列化版本发送到实际的 UI。
Example 7-11. 使用类实现 ReceiverEndPoint
public class PrintingEndPoint implements ReceiverEndPoint {
@Override
public void onTwoot(final Twoot twoot) {
System.out.println(twoot.getSenderId() + ": " + twoot.getContent());
}
}
注意
这实际上是行为参数化的一个例子——我们正在对不同的行为进行参数化,以向 UI 发送消息。
在这里调用实际行为的单行代码之前,需要七行样板代码。匿名内部类旨在使 Java 程序员更容易表示和传递行为。您可以在 Example 7-12 中看到一个例子,它减少了一些样板,但如果您希望轻松传递行为,它们仍然不足够简单。
Example 7-12. 使用匿名类实现 ReceiverEndPoint
final ReceiverEndPoint anonymousClass = new ReceiverEndPoint() {
@Override
public void onTwoot(final Twoot twoot) {
System.out.println(twoot.getSenderId() + ": " + twoot.getContent());
}
};
Boilerplate 不是唯一的问题,这段代码很难阅读,因为它掩盖了程序员的意图。我们不想传递一个对象;我们真正想做的是传递一些行为。在 Java 8 或更高版本中,我们会将这段代码示例写成一个 lambda 表达式,如示例 7-13 所示。
示例 7-13. 使用 lambda 表达式实现 ReceiverEndPoint
final ReceiverEndPoint lambda =
twoot -> System.out.println(twoot.getSenderId() + ": " + twoot.getContent());
而不是传递实现接口的对象,我们传递了一块代码——一个没有名字的函数。twoot
是参数的名称,与匿名内部类示例中的参数相同。->
分隔参数和 lambda 表达式的主体,它只是一些在发布 twoot
时运行的代码。
这个例子和匿名内部类之间的另一个区别是如何声明变量 event
。以前,我们需要显式地提供它的类型:Twoot twoot
。在这个例子中,我们根本没有提供类型,但这个例子仍然可以编译。底层发生的事情是 javac 从 onTwoot
的签名中推断出变量 event
的类型。这意味着当类型显而易见时,你不需要显式地写出类型。
注意
尽管 lambda 方法参数比以前需要的样板代码少,它们仍然是静态类型的。为了可读性和熟悉性,你可以选择包含类型声明,有时编译器确实无法解析!
方法引用
你可能注意到的一个常见习语是创建一个 lambda 表达式来调用其参数上的方法。如果我们想要一个 lambda 表达式来获取一个 Twoot
的内容,我们会写出类似 7-14 的代码。
示例 7-14. 获取两推的内容
twoot -> twoot.getContent()
这是一个非常常见的习惯用法,实际上有一种简写语法可以让你重用现有的方法,称为方法引用。如果我们要使用方法引用来编写前面的 lambda 表达式,它将类似于 7-15。
示例 7-15. 方法引用
Twoot::getContent
标准形式是 类名::方法名
。请记住,尽管它是一个方法,但你不需要使用括号,因为你实际上没有调用这个方法。你提供的是一个 lambda 表达式的等价形式,可以在需要时调用方法。你可以在与 lambda 表达式相同的地方使用方法引用。
你也可以使用相同的简写语法调用构造函数。如果你要使用 lambda 表达式来创建一个 SenderEndPoint
,你可能会写出 7-16。
示例 7-16. 使用 lambda 创建一个新的 SenderEndPoint
(user, twootr) -> new SenderEndPoint(user, twootr)
你也可以使用方法引用来写,如 7-17 所示。
示例 7-17. 方法引用来创建一个新的 SenderEndPoint
SenderEndPoint::new
这段代码不仅更短,而且更易于阅读。SenderEndPoint::new
立即告诉您正在创建一个新的 SenderEndPoint
,而无需扫描整行代码。另一个需要注意的地方是,方法引用自动支持多个参数,只要您有正确的函数接口。
当我们首次探索 Java 8 的变化时,我们的一个朋友说过方法引用“感觉像是作弊”。他的意思是,通过研究我们如何使用 lambda 表达式将代码传递作为数据,直接引用方法感觉像是作弊。
实际上,方法引用确实使一等函数的概念显式化。这意味着我们可以传递行为并像处理另一个值一样对待它。例如,我们可以将函数组合在一起。
执行周围
执行周围模式是一种常见的函数设计模式。您可能会遇到这样的情况,即您有共同的初始化和清理代码,但需要对初始化和清理代码之间运行的不同业务逻辑进行参数化。通用模式示例如 图 7-1 所示。有许多可以使用执行周围的示例情况,例如:
文件
在使用文件之前打开文件,在使用完文件后关闭文件。如果出现问题,您可能还希望记录异常。参数化代码可以从文件中读取或写入数据。
锁
在关键部分之前获取锁,在关键部分之后释放锁。参数化代码是关键部分。
数据库连接
在初始化时打开数据库连接,在完成后关闭连接。如果您还希望池化数据库连接,这通常会更加有用,因为它还允许您的打开逻辑从池中检索连接。
图 7-1. 执行周围模式
由于初始化和清理逻辑在许多地方都在使用,可能会遇到这样的情况,即此逻辑被复制。这意味着如果您想修改这些通用初始化或清理代码,那么您将不得不修改应用程序的多个不同部分。这也暴露了这些不同代码片段可能变得不一致的风险,从而在您的应用程序中引入潜在的错误。
执行周围模式通过提取一个通用方法来解决这个问题,该方法定义了初始化和清理代码。此方法接受一个参数,其中包含了在同一整体模式的不同用例中行为差异的定义。该参数将使用接口来实现,以便能够由不同的代码块实现,通常使用 lambda 表达式。
示例 7-18 展示了提取方法的具体示例。在 Twootr 中,此方法用于对数据库运行 SQL 语句。它创建一个给定 SQL 语句的预处理语句对象,然后运行我们的 extractor
行为于该语句上。extractor
只是一个回调函数,用于从数据库中提取结果,使用 PreparedStatement
。
示例 7-18. 在提取方法中使用执行环绕模式
<R> R extract(final String sql, final Extractor<R> extractor) {
try (var stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.clearParameters();
return extractor.run(stmt);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
Streams
Java 中最重要的函数式编程特性集中在 Collections API 和 Streams 上。Streams 允许我们以比使用循环更高的抽象级别编写集合处理代码。Stream
接口包含一系列函数,我们将在本章中探索这些函数,每个函数对应于在 Collection
上执行的常见操作。
map()
如果你有一个将一个类型的值转换为另一个类型的值的函数,map()
允许你将此函数应用于值的流,生成另一个新值的流。
你可能已经多年使用 for 循环进行某种类型的映射操作了。在我们的 DatabaseTwootRepository
中,我们已经构建了一个用于查询的元组 String
,包含用户关注的所有不同用户的 id
值。每个 id
值都是一个带引号的 String
,而整个元组则用括号括起来。例如,如果他们关注的用户有 "richardwarburto"
和 "raoulUK"
的 ID,我们将生成一个元组 String
,内容为 "(*richardwarburto*,*raoulOK*)"
。为了生成这个元组,你可以使用映射模式,将每个 id
转换为 "*id*"
,然后将它们添加到一个 List
中。然后可以使用 String.join()
方法以逗号分隔它们。示例 7-19 就是以这种风格编写的代码。
示例 7-19. 使用 for 循环构建用户元组
private String usersTupleLoop(final Set<String> following) {
List<String> quotedIds = new ArrayList<>();
for (String id : following) {
quotedIds.add("'" + id + "'");
}
return '(' + String.join(",", quotedIds) + ')';
}
map()
是最常用的 Stream
操作之一。示例 7-20 展示了构建用户元组的同样示例,但使用了 map()
。它还利用了 joining()
收集器,允许我们将 Stream
中的元素连接成一个 String
。
示例 7-20. 使用 map 构建用户元组
private String usersTuple(final Set<String> following) {
return following
.stream()
.map(id -> "'" + id + "'")
.collect(Collectors.joining(",", "(", ")"));
}
传递给 map()
的 lambda 表达式接受一个 String
作为其唯一参数,并返回一个 String
。参数和结果不必是相同类型,但传递的 lambda 表达式必须是 Function
的实例。这是一个只有一个参数的通用函数接口。
forEach()
当你想要对 Stream
中的每个值执行副作用时,forEach()
操作很有用。例如,假设你想打印用户的名称或将流中的每个事务保存到数据库中。forEach()
接受一个参数 —— 一个 Consumer
回调函数,它会在流中的每个元素上执行。
filter()
每当您循环一些数据并使用 if 语句检查每个元素时,您可能想考虑使用Stream.filter()
方法。
例如,InMemoryTwootRepository
需要查询不同的 Twoot
对象以找到符合其 TwootQuery
的 twoots。具体来说,位置在上次查看的位置之后,且用户正在被关注。这种写法的示例在 Example 7-21 中显示为循环样式。
示例 7-21. 遍历 twoots 并使用 if 语句
public void queryLoop(final TwootQuery twootQuery, final Consumer<Twoot> callback) {
if (!twootQuery.hasUsers()) {
return;
}
var lastSeenPosition = twootQuery.getLastSeenPosition();
var inUsers = twootQuery.getInUsers();
for (Twoot twoot : twoots) {
if (inUsers.contains(twoot.getSenderId()) &&
twoot.isAfter(lastSeenPosition)) {
callback.accept(twoot);
}
}
}
您可能编写过类似于这样的代码:它被称为filter
模式。过滤器的中心思想是保留Stream
的一些元素,同时淘汰其他元素。 Example 7-22 展示了如何以函数式风格编写相同的代码。
示例 7-22. 函数式风格
@Override
public void query(final TwootQuery twootQuery, final Consumer<Twoot> callback) {
if (!twootQuery.hasUsers()) {
return;
}
var lastSeenPosition = twootQuery.getLastSeenPosition();
var inUsers = twootQuery.getInUsers();
twoots
.stream()
.filter(twoot -> inUsers.contains(twoot.getSenderId()))
.filter(twoot -> twoot.isAfter(lastSeenPosition))
.forEach(callback);
}
与map()
类似,filter()
是一个只接受一个函数作为参数的方法——在这里我们使用了 lambda 表达式。这个函数做的工作与前面的 if 语句中的表达式相同。在这里,如果String
以数字开头,则返回true
。如果您正在重构遗留代码,则在循环中间存在 if 语句的存在很可能表明您确实想要使用 filter。因为这个函数正在执行与 if 语句相同的工作,所以它必须为给定的值返回true
或false
。filter
后面的Stream
具有前面Stream
的元素,这些元素在filter
之前被求值为true
。
reduce()
reduce
是一种模式,对于使用循环操作集合的人来说也很熟悉。当你想要将整个值列表折叠成单个值时,就会写出这样的代码——例如,找到不同交易的所有值的总和。编写循环时,您将看到减少的一般模式显示在 Example 7-23 中。当您有一组值并且想要生成单个结果时,请使用reduce
操作。
示例 7-23. 减少模式
Object accumulator = initialValue;
for (Object element : collection) {
accumulator = combine(accumulator, element);
}
一个accumulator
通过循环体被推送,accumulator
的最终值是我们试图计算的值。accumulator
从initialValue
开始,然后通过调用combine
操作将列表的每个元素组合在一起。
这种模式的实现之间的差异在于initialValue
和组合函数。在原始示例中,我们使用列表中的第一个元素作为我们的initialValue
,但不一定非要这样。为了在列表中找到最短的值,我们的组合将返回当前元素和accumulator
中的较短跟踪的较短值。我们现在将看看如何通过流 API 本身的操作来将这种一般模式编码化。
让我们通过添加一个功能来展示reduce
操作,该功能将不同的两推组合成一个大的两推。操作将具有Twoot
对象列表、Twoot
的发送者以及其id
作为参数。它需要将不同的内容值组合在一起,并返回组合两推的最高位置。整体代码在示例 7-24 中展示。
我们从一个新创建的Twoot
对象开始,使用id
、空内容和最低可能的位置——INITIAL_POSITION
。然后reduce
将每个元素与累加器结合在一起,在每一步都将元素与累加器组合。当我们到达最后的Stream
元素时,我们的累加器包含了所有元素的总和。
lambda 表达式,即 reducer,执行组合并接受两个参数。acc
是累加器,保存了已组合的先前两推。同时在Stream
中传递当前的Twoot
。我们的示例中的 reducer 创建了一个新的Twoot
,其中包含两个位置的最大值、它们内容的连接,以及指定的id
和senderId
。
示例 7-24. 使用 reduce 实现求和
private final BinaryOperator<Position> maxPosition = maxBy(comparingInt(Position::getValue));
Twoot combineTwootsBy(final List<Twoot> twoots, final String senderId, final String newId) {
return twoots
.stream()
.reduce(
new Twoot(newId, senderId, "", INITIAL_POSITION),
(acc, twoot) -> new Twoot(
newId,
senderId,
twoot.getContent() + acc.getContent(),
maxPosition.apply(acc.getPosition(), twoot.getPosition())));
}
当然,这些Stream
操作单独来看并不那么有趣。它们在组合在一起形成管道时变得非常强大。示例 7-25 展示了从Twootr.onSendTwoot()
中的一些代码,我们在这里向用户的关注者发送了两推。第一步是调用followers()
方法,该方法返回一个Stream<User>
。然后我们使用filter
操作找到实际登录的用户,这些用户我们想要发送两推给他们。接着我们使用forEach
操作产生期望的副作用:向用户发送一条两推并记录结果。
示例 7-25. 在 onSendTwoot 方法中使用 Stream
user.followers()
.filter(User::isLoggedOn)
.forEach(follower ->
{
follower.receiveTwoot(twoot);
userRepository.update(follower);
});
可选
Optional
是 Java 8 引入的核心 Java 库数据类型,旨在提供比null
更好的替代方案。对于旧的 null 值存在相当多的厌恶情绪。即使是发明这个概念的人,Tony Hoare,也将其描述为“我的十亿美元错误”。这就是作为一名有影响力的计算机科学家的麻烦之处——你甚至可能在看不到十亿美元的情况下犯下十亿美元的错误!
null
通常用来表示值的缺失,而Optional
则是用来替代这种用法。使用null
表示缺失值的问题在于可怕的NullPointerException
。如果引用一个为null
的变量,你的代码就会崩溃。Optional
的目标是双重的。首先,它鼓励程序员适当地检查变量是否缺失,以避免错误。其次,它在类的 API 中文档化了预期缺失的值。这使得更容易看到哪些值是被隐藏的。
让我们看一下Optional
的 API,以便了解如何使用它。 如果你想从一个值创建一个Optional
实例,有一个名为of()
的工厂方法。 现在,Optional
是这个值的一个容器,可以用get
来取出,如 Example 7-26 所示。
Example 7-26. 从一个值创建一个 Optional
Optional<String> a = Optional.of("a");
assertEquals("a", a.get());
因为Optional
也可以表示一个不存在的值,所以还有一个名为empty()
的工厂方法,你可以使用ofNullable()
方法将可空值转换为Optional
。 你可以在 Example 7-27 中看到这两种方法,以及isPresent()
方法的使用,它指示Optional
是否持有一个值。
Example 7-27. 创建一个空的 Optional 并检查它是否包含一个值
Optional emptyOptional = Optional.empty();
Optional alsoEmpty = Optional.ofNullable(null);
assertFalse(emptyOptional.isPresent());
// a is defined above
assertTrue(a.isPresent());
使用Optional
的一种方法是在调用get()
之前通过检查isPresent()
来保护任何调用 —— 这是必需的,因为调用get()
可能会抛出一个NoSuchElementException
。 不幸的是,这种方法并不是一个很好的使用Optional
的编码模式。 如果你以这种方式使用它,你实际上只是复制了使用null
的现有模式 —— 在这种模式中,你会检查一个值是否不是null
作为守卫。
一个更简洁的方法是调用orElse()
方法,它提供了一个替代值,以防Optional
为空。 如果创建替代值的计算成本很高,应该使用orElseGet()
方法。 这允许您传入一个Supplier
函数,只有在Optional
真正为空时才调用该函数。 这两种方法都在 Example 7-28 中演示。
Example 7-28. 使用 orElse()和 orElseGet()
assertEquals("b", emptyOptional.orElse("b"));
assertEquals("c", emptyOptional.orElseGet(() -> "c"));
Optional
还定义了一系列可以像Stream
API 一样使用的方法;例如,filter()
,map()
和ifPresent()
。 您可以将这些方法想象为类似于Stream
API 的Optional
API,但在这种情况下,您的Stream
只能包含 1 个或 0 个元素。 因此,如果满足条件,Optional.filter()
将在Optional
中保留一个元素,并且如果Optional
之前为空或谓词未能应用,则返回一个空的Optional
。 同样,map()
转换Optional
中的值,但如果它为空,则根本不应用该函数。 这就是这些函数比使用null
更安全的地方 —— 它们仅在Optional
中确实有内容时才操作Optional
。 ifPresent
是forEach
的Optional
对偶 —— 如果有值存在,它将应用Consumer
回调,但否则不会。
您可以在 Example 7-29 中看到来自 Twootr.onLogon()
方法的代码片段。这是一个示例,展示了如何组合这些不同的操作以执行更复杂的操作。我们首先通过调用 UserRepository.get()
根据用户 ID 查找 User
,该方法返回一个 Optional
。然后我们使用 filter
验证用户的密码匹配。我们使用 ifPresent
通知用户他们错过的 twoots。最后,我们将 User
对象映射为一个新的 SenderEndPoint
并从方法中返回。
Example 7-29. 在 onLogon 方法中使用 Optional
var authenticatedUser = userRepository
.get(userId)
.filter(userOfSameId ->
{
var hashedPassword = KeyGenerator.hash(password, userOfSameId.getSalt());
return Arrays.equals(hashedPassword, userOfSameId.getPassword());
});
authenticatedUser.ifPresent(user ->
{
user.onLogon(receiverEndPoint);
twootRepository.query(
new TwootQuery()
.inUsers(user.getFollowing())
.lastSeenPosition(user.getLastSeenPosition()),
user::receiveTwoot);
userRepository.update(user);
});
return authenticatedUser.map(user -> new SenderEndPoint(user, this));
在这一部分,我们只是浅尝辄止了函数式编程的表面。如果你有兴趣深入学习函数式编程,我们推荐阅读Java 8 In Action 和 Java 8 Lambdas。
用户界面
在本章中,我们避免过多讨论系统的用户界面,因为我们专注于核心问题域的设计。尽管如此,了解示例项目作为其 UI 的一部分提供了什么,有助于理解事件建模如何组合在一起。在我们的示例项目中,我们提供了一个单页面网站,使用 JavaScript 实现其动态功能。为了保持简单,并且不深入探讨各种框架之争,我们只是使用 jquery
来更新原始 HTML 页面,并在代码中保持了简单的关注点分离。
当您浏览到 Twootr 网页时,它会使用 WebSockets 连接回主机。这些是我们在 “From Events to Design” 中讨论的事件通信选择之一。所有与其通信的代码都位于 chapter_06
的 web_adapter
子包中。WebSocketEndPoint
类实现了 ReceiverEndPoint
,并在 SenderEndPoint
上调用任何需要的方法。例如,当 ReceiverEndPoint
接收并解析要关注另一个用户的消息时,它调用 SenderEndPoint.onFollow()
,通过用户名传递。返回的 enum
—FollowStatus
然后被转换为一种线格式的响应并写入 WebSocket 连接。
JavaScript 前端与服务器之间的所有通信都使用 JavaScript Object Notation (JSON) standard。选择 JSON 是因为 JavaScript UI 非常容易对其进行反序列化或序列化。
在 WebSocketEndPoint
内部,我们需要在 Java 代码中进行 JSON 的映射。有许多库可以用于此目的,这里我们选择了 Jackson 库,这是一种常用且维护良好的库。JSON 在采用请求/响应方式而不是事件驱动方式的应用程序中经常被使用。在我们的情况下,我们手动从 JSON 对象中提取字段,以保持简单性,但也可以使用更高级的 JSON API,如绑定 API。
依赖反转和依赖注入
在本章中我们讨论了很多关于解耦模式的内容。我们的整体应用程序使用了端口和适配器模式以及仓储模式,将业务逻辑与实现细节分离开来。事实上,当我们看到这些模式时,我们可以想到一个大的、统一的原则——依赖反转。依赖反转原则是我们在这本书中讨论的五个 SOLID 原则中的最后一个,像其他原则一样,它也是由 Robert Martin 引入的。它指出:
-
高级模块不应依赖于低级模块。两者都应依赖于抽象。
-
抽象不应依赖于细节,细节应依赖于抽象。
这个原则之所以称为反转,是因为在传统的命令式、结构化编程中,高级模块通常组合以生成低级模块。这往往是我们在本章讨论的自顶向下设计的一个副作用。你将一个大问题分解成不同的子问题,为每个子问题编写一个模块来解决,然后主问题(高级模块)依赖于子问题(低级模块)。
在 Twootr 的设计中,我们通过引入抽象来避免了这个问题。我们有一个高级入口点类,名为 Twootr
,它不依赖于低级模块,比如我们的 DataUserRepository
。它依赖于抽象——UserRepository
接口。在 UI 端口上也是如此。Twootr
不依赖于 WebSocketEndPoint
,而是依赖于 ReceiverEndPoint
。我们编程时依赖接口,而不是具体实现。
一个相关的术语是依赖注入,或者简称DI。为了理解 DI 是什么以及为什么我们需要它,让我们对我们的设计进行一个思想实验。我们的架构已经确定,主要的 Twootr
类需要依赖于 UserRepository
和 TwootRepository
来存储 User
和 Twoot
对象。我们在 Twootr
内部定义了字段来存储这些对象的实例,如 Example 7-30 所示。问题是,我们如何实例化它们?
示例 7-30. Twootr 类内的依赖项
public class Twootr
{
private final TwootRepository twootRepository;
private final UserRepository userRepository;
我们用于填充字段的第一种策略是尝试使用new
关键字调用构造函数,如示例 7-31 所示。在这里,我们在代码库中硬编码了使用基于数据库的存储库。现在类中的大部分代码仍然以接口编程,因此我们可以相当容易地更改这里的实现,而无需替换所有代码,但这有点不太优雅。我们必须始终使用数据库存储库,这意味着我们Twootr
类的测试依赖于数据库并且运行速度较慢。
不仅如此,如果我们想要将不同版本的 Twootr 交付给不同的客户,例如为企业客户提供使用 SQL 的内部版 Twootr,以及使用 NoSQL 后端的云版本,我们将不得不从两个不同版本的代码库中构建。仅仅定义接口和分离实现是不够的,我们还必须有一种方法来正确地连接适当的实现,以确保不破坏我们的抽象和解耦方法。
示例 7-31. 硬编码字段的实例化
public Twootr()
{
this.userRepository = new DatabaseUserRepository();
this.twootRepository = new DatabaseTwootRepository();
}
// How to start Twootr
Twootr twootr = new Twootr();
用于实例化不同依赖项的常用设计模式是抽象工厂设计模式。示例 7-32 展示了这种模式,我们有一个工厂方法可以使用getInstance()
方法创建接口的实例。当我们想要设置正确的实现时,我们可以调用setInstance()
。例如,我们可以在测试中使用setInstance()
创建内存中的实现,在本地安装中使用 SQL 数据库,在云环境中使用 NoSQL 数据库。我们已经将实现与接口解耦,并且可以在任何需要的地方调用这些连接代码。
示例 7-32. 使用工厂创建实例
public Twootr()
{
this.userRepository = UserRepository.getInstance();
this.twootRepository = TwootRepository.getInstance();
}
// How to start Twootr
UserRepository.setInstance(new DatabaseUserRepository());
TwootRepository.setInstance(new DatabaseTwootRepository());
Twootr twootr = new Twootr();
不幸的是,这种工厂方法的方法也有其缺点。首先,我们现在创建了一个大的共享可变状态球。任何需要在单个 JVM 中运行具有不同依赖关系的多个Twootr
实例的情况都是不可能的。我们还将生命周期耦合在一起——也许有时我们希望在启动Twootr
时实例化一个新的TwootRepository
,或者有时我们希望重用一个现有的实例。工厂方法的方法不会直接让我们这样做。在我们的应用程序中为每个想要创建的依赖关系都创建一个工厂也可能变得相当复杂。
这就是依赖注入发挥作用的地方。DI 可以被视为好莱坞代理人方法的一个例子——不要打电话给我们,我们会打电话给你。使用 DI,你不是显式地创建依赖项或使用工厂来创建它们,而是简单地接受一个参数,任何实例化你的对象的东西都有责任传递所需的依赖项。这可能是一个测试类的设置方法传入一个模拟对象,也可能是你的应用程序的main()
方法传入一个 SQL 数据库实现。在Twootr
类中使用这个示例见示例 7-33。依赖反转是一种策略;依赖注入和仓储模式是具体的战术。
示例 7-33. 使用依赖注入创建实例
public Twootr(final UserRepository userRepository, final TwootRepository twootRepository)
{
this.userRepository = userRepository;
this.twootRepository = twootRepository;
}
// How to start Twootr
Twootr twootr = new Twootr(new DatabaseUserRepository(), new DatabaseTwootRepository());
以这种方式处理对象不仅使得为对象编写测试变得更容易,而且外部化了对象本身的创建过程。这允许你的应用程序代码或框架控制UserRepository
的创建时间以及注入到其中的依赖项。许多开发人员发现使用诸如 Spring 和 Guice 等提供许多高级特性的 DI 框架非常方便。例如,它们可以为 bean 定义生命周期,标准化对象实例化后或在需要时销毁前调用的钩子。它们还可以为对象提供作用域,例如仅在进程生命周期内实例化一次的 Singleton 对象或每个请求的对象。此外,这些 DI 框架通常能够很好地与诸如 Dropwizard 或 Spring Boot 之类的 Web 开发框架集成,并提供即开即用的高效开发体验。
包和构建系统
Java 允许你将代码库拆分成不同的包。在本书中,我们将每个章节的代码放入其自己的包中,而 Twootr 是第一个在项目本身中拆分出多个子包的项目。
这里是可以查看项目内不同组件的包:
-
com.iteratrlearning.shu_book.chapter_06
是项目的顶层包。 -
com.iteratrlearning.shu_book.chapter_06.database
包含了 SQL 数据库持久化的适配器。 -
com.iteratrlearning.shu_book.chapter_06.in_memory
包含了内存持久化的适配器。 -
com.iteratrlearning.shu_book.chapter_06.web_adapter
包含了基于 WebSockets 的 UI 适配器。
将大型项目拆分为不同的包有助于组织代码,使开发人员更容易找到所需内容。就像类将相关方法和状态分组在一起一样,包将相关类分组在一起。包应遵循与类相似的耦合和内聚规则。当可能同时更改且与同一结构相关联时,将类放在同一个包中。例如,在 Twootr 项目中,如果我们想要修改 SQL 数据库持久化代码,我们知道要进入database
子包。
包还可以实现信息隐藏。我们在 示例 4-3 中讨论了有一个包作用域构造方法的想法,以防止对象在包外实例化。我们还可以对类和方法进行包作用域。这可以防止包外的对象访问类的细节,并帮助我们实现松耦合。例如,WebSocketEndPoint
是 web_adapter
包中实现了 ReceiverEndPoint
接口的包作用域实现。项目中的其他代码不应直接与这个类交互,只能通过作为端口的 ReceiverEndPoint
接口进行交互。
我们在 Twootr 中每个适配器都有一个包的方法很好地符合我们在整个模块中使用的六边形架构模式。然而,并非每个应用程序都是六边形的,其他项目可能会遇到两种常见的包结构。
一种常见的包结构方法是按层次结构化它们,例如,将所有生成网站 HTML 视图的代码放在views
包中,并将处理网页请求相关的所有代码放在controller
包中。尽管这种方法很流行,但它可能导致耦合性和内聚性不佳。如果要修改现有网页以添加额外参数并基于该参数显示值,则需要修改controller
和view
包,以及可能还有其他几个包。
另一种代码结构的替代方法是按特性组织代码。例如,如果您编写电子商务网站,可以为购物车使用一个cart
包,为产品列表相关的代码使用一个product
包,为接受信用卡支付相关的代码使用一个payment
包,等等。这通常会更具内聚性。如果要添加支持通过 Mastercard 和 Visa 接收付款,则只需修改payment
包即可。
在“使用 Maven”中,我们讨论了如何使用 Maven 构建工具设置基本的构建结构。在本书的项目结构中,我们有一个 Maven 项目,而书中的不同章节则是该项目中的不同 Java 包。这是一个简单而清晰的项目结构,适用于各种不同的软件项目,但不是唯一的选择。Maven 和 Gradle 都提供了从单个顶级项目构建和输出多个构建产品的项目结构。
如果你想部署不同的构建产品,这是有道理的。例如,假设你有一个客户端/服务器项目,你希望有一个单一的构建同时构建客户端和服务器,但客户端和服务器是运行在不同机器上的不同二进制文件。不过,最好不要过于深思熟虑或过于模块化构建脚本。
这些是你和你的团队经常在自己的机器上运行的东西,最高优先级是它们要简单、快速和易于使用。这就是为什么我们选择在整本书中只有一个单一项目,而不是每个项目一个子模块的路线。
限制和简化
你已经看到了我们如何实现 Twootr,并了解了我们沿途做出的设计决策,但这是否意味着我们迄今为止看到的 Twootr 代码库是唯一或最佳的写法呢?当然不是!事实上,我们的方法存在一些限制和我们故意采取的简化,以便将代码库解释在单一章节中。
首先,我们把 Twootr 写成了只能在单个线程上运行的形式,并完全忽略了并发的问题。实际上,我们可能希望在我们的 Twootr 实现中有多个线程响应和发出事件。这样我们就可以利用现代多核 CPU,并在一台服务器上为更多的客户提供服务。
从更宏观的角度来看,我们也忽略了任何能够在服务器宕机时使我们的服务继续运行的故障转移。我们也忽略了可扩展性。例如,要求所有的 Twoot 都有一个单一定义的顺序,在单一服务器上实现起来既容易又高效,但会带来严重的可扩展性/争用瓶颈。同样地,当你登录时看到所有的 Twoot 也会导致瓶颈。如果你去度假一周,当你重新登录时会得到 20000 条 Twoot,这会怎么样!
对这些问题进行详细的讨论超出了本章的范围。然而,如果你希望在 Java 方面深入学习,这些是重要的话题,并且我们计划在本系列的未来书籍中更详细地讨论它们。
总结
-
现在你可以使用存储库模式将数据存储与业务逻辑解耦。
-
你已经看到了在这种方法中实现的两种不同类型的存储库。
-
你已经介绍了函数式编程的概念,包括 Java 8 的 Streams。
-
你已经看到如何结构化一个具有不同包的较大项目。
迭代你自己
如果你想扩展并巩固这一节的知识,你可以尝试以下其中一项活动。
假设我们对 Twootr 采用了拉取模型。与其通过 WebSockets 持续向基于浏览器的客户端推送消息,我们可以使用 HTTP 来定期轮询获取自某个位置以来的最新消息。
-
思考我们的设计会如何改变。尝试绘制不同类之间数据流动的图表。
-
使用 TDD 实现 Twootr 的这种替代模型。你不需要实现 HTTP 部分,只需按照这种模型实现底层类。
完成挑战
我们开发了这个产品,它运行良好。不幸的是,当 Joe 推出时,他意识到有人叫 Jack 发布了一个类似的产品,名字也很相似,获得了数十亿美元的风投资金和数亿用户。事实上,Jack 只比 Joe 早了 11 年到达这个地步;对 Joe 来说真是个倒霉的运气。
第八章:结论
如果你一直读到这里,希望你已经喜欢这本书了。我们写作的过程也很愉快。在这个结尾章节中,你将了解到如何在你的编程生涯中下一步怎么走。我们将提供一些建议,帮助你如何发展你的技能,并在作为开发者的职业生涯中推动自己进入下一个级别。
基于项目的结构
本书的项目结构旨在帮助你更轻松地理解软件开发的概念。我们在软件项目内讨论的话题旨在帮助你理解软件工程决策的背景。在软件工程中,上下文至关重要——在一个上下文中正确的决策在另一个上下文中可能并不适用。许多开发者由于误解继承是代码复用的机制而过度使用和滥用子类化。希望我们已经在第四章中让你对这个想法有所警觉。
但是你不能简单地希望通过阅读一本书就神奇地成为一名专业的软件开发者。这需要练习、经验和耐心。这本书只是帮助优化和改进这个过程的工具。这就是为什么我们在每一章节中都添加了一个“关于你的迭代”部分——它们提供了关于如何进一步利用本书内容并提高你理解的建议。
关于你的迭代
作为一名软件开发者,你可能经常以迭代的方式处理项目。也就是说,将最高优先级的一两周工作项划分出来,实施它们,然后利用反馈决定下一组工作项。我们发现,经常评估自己技能的进展是非常值得的。
定期对自己进行回顾可以帮助你在需要时获得焦点和方向。敏捷软件开发通常涉及每周的回顾,但你个人不需要那么频繁地做。每季度或每半年进行一次回顾会非常有帮助。我们发现一个有用的主题是评估哪些技能将有助于你当前或未来的工作。为了确保这些技能得到提升,设定下一个季度的目标是很有帮助的。这可以是学习或改进的事项。这个目标不需要像学习全新的编程语言那样宏大;可以是像掌握一个新的测试框架或几种设计模式这样简单的事情。
当涉及到技能时,我们听到一些开发者的反对意见。一个经常被问到的问题是“我怎么能不断地学习新技术、实践和原则呢?”这并不容易,每个人都很忙碌。关键在于不要试图在技术行业学习所有东西。那是一条通向疯狂的捷径!找到一些关键技能,这些技能会随着时间的推移为你服务,并建立在你现有的技能基础之上,这才是帮助你成为一名优秀开发者的关键。关键是不断地改进自己,对自己进行迭代。
刻意练习
尽管本书涵盖了许多成为优秀开发者所需的关键概念和技能,但实践是至关重要的。仅仅阅读是不够的——实践可以帮助你内化这些技能并自己应用它们。在日常工作中寻找适合应用不同技术的情境会有所帮助。因为本书描述的每种模式都有适用和不适用的地方,所以考虑那些不适合使用技术的情形也是很有帮助的。
我们常常认为自然天赋和智力是成功的关键因素,但大量研究已经证明,实践和工作才是真正的成功关键。例如,Geoff Colvin 的《天才是被高估的》(Portfolio, 2008)和 Malcolm Gladwell 的《异类:成功之道》(Penguin, 2009)评估了成功生活的许多关键因素,而刻意练习是其中最有效的一个。
刻意练习是一种有目的且系统化的练习形式。刻意练习的目标是试图提高表现,需要专注和注意力。通常,当人们练习他们的技能以提高它们时,他们只是在重复。一遍又一遍地做同样的事情,期望在它上面变得更好,这并不是做事情的最有效方式。
一个很好的例子是当我们在探索和学习Eclipse Collections 库时。为了系统地理解和学习该库,我们按顺序完成了附带的一套出色的代码 Kata。为了确保我们真正理解透彻,我们重复进行了三次 Kata。每次都是从头开始,然后将我们之前的解决方案与当前的进行比较,找到更清晰、更好和更快的方法。
问题在于,重复个人行为意味着它们变得自动化。所以,如果你在职业生涯中养成了坏习惯,你可能会通过在工作中实践来教给自己。经验加强习惯。刻意练习是打破这一循环的方法。刻意练习可能包括系统地从书籍中练习新方法。它可能包括多次采用不同方法解决您以前解决过的小问题。它可能包括参加旨在进行练习设计的培训课程。无论选择哪条路,刻意练习都是随着时间磨练技能并超越本书所涵盖内容的关键。
下一步和额外资源
好了,希望你现在相信这本书不是学习的终点,但接下来应该看什么呢?
参与开源是学习更多关于软件并拓展视野的好方法。许多最受欢迎的 Java 开源项目,如 JUnit 和 Spring,都托管在GitHub上。有些项目可能比其他项目更友好,但通常开源维护者工作繁忙,需要在他们的项目上获得帮助。你可以查看 Bug 追踪器,看看是否有什么可以做的。
正式的培训课程和在线学习是提升技能的另一种实用和流行方式。在线培训课程越来越受欢迎,Pluralsight和O'Reilly 学习平台都有大量优秀的 Java 培训课程可供选择。
开发者获取信息的另一个绝佳来源是博客和推特。理查德和拉乌尔都在推特上,并经常发布关于软件开发的链接。编程 Reddit经常作为一个强大的链接聚合器,就像Hacker News一样。最后,本书作者运营的培训公司(Iteratr Learning)还提供一系列免费的文章,供任何人阅读。
感谢您阅读本书。我们感谢您的想法和反馈,并祝愿您作为 Java 开发者的旅程一切顺利。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
2020-06-15 布客·ApacheCN 编程/后端/大数据/人工智能学习资源 2020.6
2020-06-15 布客·ApacheCN 编程/大数据/数据科学/人工智能学习资源 2020.1
2020-06-15 ApacheCN 编程/大数据/数据科学/人工智能学习资源 2019.11
2020-06-15 ApacheCN 公众号文章汇总 2019.9