真实世界的软件开发-一-

93 阅读1小时+

真实世界的软件开发(一)

原文:zh.annas-archive.org/md5/9fe6488c1d46ccf6de3ab02ce7d234fc

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

掌握软件开发需要学习一系列不同的概念。如果你是初级软件开发者,或者即使你已经有经验了,这看起来也像是一个无法逾越的障碍。你是否应该花时间学习面向对象世界中已被广泛接受的主题,如 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 呢?很明显,主类具有多个可以单独分解的责任:

  1. 读取输入

  2. 根据给定格式解析输入

  3. 处理结果

  4. 报告结果的摘要

本章节将专注于解析部分。在下一章节中,您将学习如何扩展银行对账单分析器,使其完全模块化。

第一个自然的步骤是将 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 类很有用,因此我们应用程序的不同部分可以共享对银行对账单的相同理解。您会注意到该类为 equalshashcode 方法提供了实现。这些方法的目的以及如何正确实现它们在 第六章 中有所介绍。

示例 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())。

  • 不要改变参数的状态,因为代码的其他部分可能依赖于它。

最少惊讶原则可能是一个主观的概念。当有疑问时,请与你的同事和团队成员沟通,以确保大家达成一致。

内聚性

到目前为止,你已经学习了三个原则:KISSDRYSRP。但你还没有学习到关于代码质量的评估特征。在软件工程中,你经常会听到内聚性作为你编写的代码不同部分的重要特征。听起来很花哨,但这是一个非常有用的概念,可以帮助你评估代码的可维护性。

内聚性关注相关性。更准确地说,内聚性衡量了一个类或方法责任的强相关程度。换句话说,这些事情多么相关?这是一种帮助你理解软件复杂性的方式。你想要实现的是高内聚性,这意味着代码更容易被他人找到、理解和使用。在你之前重构的代码中,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());
}

那么这里发生了什么?有三个部分:

  1. 你为你的测试设置上下文。在这种情况下,是一行要解析的内容。

  2. 你执行一个操作。在这种情况下,你解析输入行。

  3. 您指定了预期输出的断言。在这里,您检查日期、金额和描述是否被正确解析。

设置单元测试的这种三阶段模式通常被称为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 中流行的代码覆盖工具包括JaCoCoEmmaCobertura。在实践中,您会看到人们谈论行覆盖率,它告诉您代码覆盖了多少语句。这种技术会给您一种错误的感觉,认为代码覆盖良好,因为条件(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 提出了两个新功能需求:

  1. 他还希望能够搜索特定的交易。例如,你应该能够返回在特定日期范围内或特定类别中的所有银行交易。

  2. 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 中的这种层次结构。 ErrorRuntimeException 类是未经检查的异常,并且是 Throwable 的子类。您不应该期望捕获并从中恢复。类 Exception 通常表示程序应该能够从中恢复的错误。

Java 中的异常层次结构

图 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 中展示。您已经考虑了每一个边缘情况来验证输入,并将每个边缘情况转换为一个已检查的异常。异常DescriptionTooLongExceptionInvalidDateFormatDateInTheFutureExceptionInvalidAmountException都是用户定义的已检查异常(即它们扩展了类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 项目布局。

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 中的每个命令都是一个任务。你可以定义自己的任务并执行它们,或者使用诸如 testbuildclean 等内置任务:

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.Pathjava.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 的来源缩小到特定创建该DocumentImporter。你还可以从不可变性推断出,可以对与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的,因为我们希望能够从不同的导入者中使用它,尽管您可能更喜欢使用privatepackage作用域的常量。使用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。在测试命名方面的关键驱动原则是可读性、可维护性和作为可执行文档的功能。当您看到测试类运行的报告时,这些名称应该作为说明文档,记录哪些功能可用,哪些不可用。这允许开发人员轻松地从应用程序行为映射到断言该行为被实现的测试。通过减少行为和代码之间的阻抗不匹配,我们使其他开发人员更容易理解未来发生的情况。这是一个确认文档管理系统导入文件的测试。

然而,还有很多命名反模式。最糟糕的反模式是给一个测试命名为完全不明确的东西——例如,test1test1在测试什么?读者的耐心?对待阅读你代码的人,就像你希望他们对待你一样。

另一个常见的测试命名反模式是仅以概念或名词命名,例如,filedocument。测试名称应描述测试的行为,而不是一个概念。另一个测试命名反模式是仅将测试命名为在测试期间调用的方法,而不是行为。在这种情况下,测试可能被命名为importFile

你可能会问,通过将我们的测试命名为shouldImportFile,我们是否在这里犯了这个罪?这种指责有一定的道理,但在这里我们只是描述了正在测试的行为。事实上,importFile方法由各种测试进行测试;例如,shouldImportLetterAttributesshouldImportReportAttributesshouldImportImageAttributes。这些测试都没有被称为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);

assertTrue 示例

图 4-1. assertTrue失败的截图

最常用的断言是assertEquals,它接受两个值并检查它们是否相等,并重载以支持原始值。因此,我们可以断言documents列表的大小为0,如示例 4-23 所示。这产生了稍微好一点的诊断,如图 4-2 所示,您知道期望的值是0,实际值是1,但它仍然没有给出任何有意义的上下文。

示例 4-23. assertEquals示例
assertEquals(0, documents.size());

assertEquals 示例

图 4-2. assertEquals示例失败的截图

关于大小本身进行断言的最佳方法是使用matcher来断言集合的大小,因为这提供了最具描述性的诊断。示例 4-24 采用了这种样式编写的示例,并演示了输出。正如图 4-3 所示,这样更清晰地说明了出了什么问题,而无需再编写任何代码。

示例 4-24. assertThat示例
assertThat(documents, hasSize(0));

assertThat 示例

图 4-3. assertThat示例失败的截图

这里使用了 JUnit 的assertThat()方法。方法assertThat()的第一个参数是一个值,第二个参数是一个MatcherMatcher封装了一个值是否符合某些属性以及相关诊断的概念。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 时,你将会回顾这一主题。