书名:软件开发中的决策:权衡与取舍
ISBN:978-7-115-63516-7
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
著 [美]托马斯·莱莱克(Tomasz Lelek)
[英]乔恩·斯基特(Jon Skeet)
译 陆明刚 胡世杰
责任编辑 李 瑾
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
本书详细阐述如何在设计、规划和实现软件时做出更好的决策;通过真实的案例,以抽丝剥茧的方式分析那些失误的决策;探讨还有哪些可能的解决方案,并对比各种方案的优缺点,摸索软件设计的常青模式。本书通过实例来说明某些决策的后果,例如代码重复如何影响系统的耦合与演进速度,以及如何在日期和时间信息方面隐藏细微差别。本书还介绍如何根据帕累托法则有效地缩小优化范围,确保分布式系统的一致性。
通过阅读本书,读者很快就可以将作者来之不易的经验应用到自己的项目中,以预防错误并采取更合适的编程决策。
本书适合负责软件设计与实现决策的中高级开发人员和架构师阅读。
托马斯将本书献给开源社区的所有贡献者。本书介绍的大多数工具及架构都源于你们的无私奉献。正是因为你们的不懈努力,软件的世界才得以不断进步,进而满足当今世界的需求。
乔恩将本书献给所有曾为解决时区或菱形依赖问题而彻夜难眠的软件工程师(他们占据了开发者群体的相当大比例……)。
软件交付工作充满了各种各样的取舍,这是参与其中的每个人都无法回避的挑战。我们经常面对有限的项目周期、“捉襟见肘”的预算、陌生的领域。因此,我们当下做出的任何一个软件设计决策都会对软件的未来产生影响,譬如系统维护的过大开销、软件需要变更时才暴露的僵化设计、软件需要扩展时才发现的性能瓶颈,诸如此类林林总总的问题。我们必须认识到一点,即任何一个取舍结果都是特定条件的产物。我们很容易评判之前的软件设计在设计之初缺乏对上下文的充分了解。显然,设计时我们了解得越多,分析得越深入,我们对所做决策的利弊得失就越了然于心。
我们亲自主持和参与了许多软件设计决策,对这些决策背后所隐含的权衡与取舍逐渐熟稔于心。在此过程中,托马斯开始撰写个人对软件设计决策日志,记录下我们当时做出某个决策时的来龙去脉,如软件设计决策的背景是什么,有哪些替代方案,我们是如何评估某个特殊方案的,我们最终采用了什么样的解决方案,对某个解决方案我们是否成功预测了所有可能的利弊,我们对某些情况是否也感到惊讶,等等。事实证明,这份个人经验教训清单所涵盖的内容包含许多软件工程师需要解决的问题、需要面临的取舍。托马斯认为现在是一个与世界分享这些知识的绝佳时机。这就是本书的写作初衷。
通过本书,我们想分享我们从各种软件设计中收获的经验与教训,如单体系统、微服务、大数据处理、库等的设计经验与教训。本书深入分析现实软件设计中的决策、权衡和教训。通过分享这些设计模式、错误决策以及惨痛教训,我们希望能帮助你拓宽视野,升级你的工具箱,帮助你在日常工作中做出更好的决策。提前发现软件设计中潜在的问题以及局限,可以在未来帮你节省大量的时间和金钱。我们不会试图给你明确的答案。对于很复杂的问题,解决方案往往不是唯一的。我们将分享一些具有挑战性的问题,并提出一些没有明确答案的问题。关于这些问题的每种解决方案都有其优缺点,我们会做针对性的分析。每选择一种解决方案都意味着需要做出取舍,你需要判断到底哪一种最适用于实际情况。
写书是一件极其劳神费力的“苦差事”,感谢Manning出版社,让它成为一段愉悦的经历。
首先,我要感谢我的妻子Malgorzata。你一直支持我,倾听我的想法和问题。因为有了你的支持,我才能专心致志地写书。
接下来,我要感谢Manning出版社的编辑Doug Rudder。谢谢你和我一起工作。你的意见和反馈非常宝贵。因为你的参与,我的写作技巧才得以更上一层楼。我要感谢和我一起为本书的制作和推广辛勤工作的Manning出版社的同事们。本书真的是团队协作的结晶。我还要特别感谢Manning出版社的其他工作人员:制作编辑Deirdre Hiam、文案编辑Christian Berk、审稿编辑Mihaela Batinic,还有校对编辑Jason Everett。
我也要感谢在本书编写的不同阶段花时间阅读它的书评人,他们提供了宝贵的反馈,让本书变得更好,他们是Alex Saez、Alexander Weiher、Andres Sacco、Andrew Eleneski、Andy Kirsch、Conor Redmond、Cosimo Atanasi、Dave Corun、George Thomas、Gilles Iachelini、Gregory Varghese、Hugo Cruz、Johannes Verwijnen、John Guthrie、John Henry Galino、Johnny Slos、Maksym Prokhorenko、Marc-Oliver Scheele、Nelson González、Oliver Korten、Paolo Brunasti、Rafael Avila Martinez、Rajesh Mohanan、Robert Trausmuth、Roberto Casadei、Sau Fai Fong、Shawn Lam、Spencer Marks、Vasile Boris、Vincent Delcoigne、Vitosh Doynov、Walter Stoneburner以及Will Price。
特别感谢开发编辑Jeanne Boyarsky,她从技术角度仔细审查了本书的内容。
本书是目前我职业生涯的所有决定以及遇到的所有人共同作用的结果。感谢成长道路上给予我支持和帮助的人们,他们将我塑造成了一位合格的软件工程师,并对我的职业生涯产生了积极的影响。我很幸运,在我职业生涯的初期,就遇到了这些良师益友,并与他们一起工作。我要感谢Schibsted、Allegro、DataStax和Dremio的所有同事。除此之外,我还要特别感谢一些人,他们是:
■ Paweł Wołoszyn—— 一名大学讲师,他带我走进软件设计的世界并让我意识到编程对世界的巨大影响;
■ Andrzej Grzesik——鼓励我设立远大的目标并砥砺前行;
■ Mateusz Kwaśniewski——激发了我对学习的无限渴望;
■ Łukasz Bancerowski——为我指明了最初的方向,为我的JVM职业生涯打下了坚实的基础;
■ Jarosław Pałka——给予了我足够的信任和空间,让我有机会在试错中学习和成长;
■ Alexandre Dutra——以身作则,展示了高标准的职业道德。
——托马斯·莱莱克
我要感谢这些年来默默忍受我在时区话题上持续絮叨的读者,以及我的家人。我在谷歌公司工作时一起共事的同事和在Noda时间库及其他开源项目的合作者,都对我在本书中介绍的内容的形成做出了贡献。
——乔恩·斯基特
本书展示了一系列设计软件时可能遇到的现实问题,试图对软件设计中可能出现的各种情况进行分析,并逐一解说每种决策的权衡与取舍。本书也会讲解一些并不常见的软件设计缺陷和错误,这些缺陷和错误可能会对你的软件系统产生方方面面的影响而不仅是影响程序的正确性。
本书适合希望理解软件系统设计的取舍与常见设计模式的软件工程师阅读。同时,本书开篇从基础性的话题切入,介绍如何避免常见的设计缺陷,对刚刚开启职业生涯的软件工程师而言,也是大有裨益的。接着,本书转入相对深入的话题,即便是有经验的程序开发者也能从中获益。本书使用的主开发语言是Java,案例、模式以及代码片段都基于Java,不过对软件设计的决策并不局限于Java语言。
本书共13章。第1章概述本书使用的决策分析方法。其余各章相对独立,分别专注于软件工程的不同方面。为了从本书中获得最大的收益,建议你按顺序阅读各章的内容。但是,如果你对软件工程的某个方面感兴趣,可以直接跳转到对应章节。
■ 第1章介绍软件决策分析时所采用的方法。我们会从软件架构、代码以及质量保证等方面,举例说明如何做出权衡与取舍。
■ 第2章阐释代码重复不一定是坏事。本章从不同的架构角度出发,分析代码重复如何对系统的松耦合或紧耦合造成影响;并且使用阿姆达尔定律计算团队内部协调与跨团队协同的开销。
■ 第3章描述代码出现异常情况时的处理模式。我们会按照已检测异常和未检测异常两类用例分别展开介绍。本章还介绍如何为公共API(库)设计异常处理策略。最后,我们探讨面向对象的程序设计与函数式编程方法在错误处理时的权衡与取舍。
■ 第4章介绍如何平衡代码以及API设计中的灵活性和复杂性。通常情况下,代码在一个方向上的演进会对其在另一个方向上的发展造成影响。
■ 第5章告诉我们,在项目的早期开展优化并不是坏事。使用适当的工具和定义恰当的SLA,我们可以发现代码路径中的热路径并对其进行优化。此外,本章还演示如何利用帕累托法则帮助定位系统中适合进行优化的部分,从而将优化工作聚焦于此。
■ 第6章介绍如何设计对用户体验友好的API。通过本章的介绍,我们会了解设计对用户体验友好的API不仅是UI的事,也需要编程接口的支持,譬如REST API、命令行工具以及其他接口。然而,这也表明,为了获得良好的用户体验,我们需要付出更高的维护成本。
■ 第7章讨论如何处理日期和时间数据,这是一个极其棘手的问题。想想我们的数据里有多少日期和时间元素,譬如出生日期或者日志的时间戳,这些地方都极有可能出现错误。这并不是一个复杂的领域,但确实需要我们特别注意。
■ 第8章介绍为什么数据本地性在大数据处理中至关重要,还介绍分配数据和流量的分区算法要满足哪些要求。
■ 第9章介绍将你使用的库变成你的代码。本章会讨论将第三方库导入代码库时需要考虑的因素、可能引发的问题及权衡与取舍。最后,本章试图回答一个问题:我们应该导入一个库还是重新实现它的一小部分。
■ 第10章重点讨论设计分布式系统时一致性与原子性之间的权衡。本章分析分布式系统中可能发生的竞争条件,并展示幂等性是如何影响我们设计系统的方式的。
■ 第11章介绍如何处理分布式系统中的传输语义。本章可以帮助读者理解分布式系统中“至少一次”、“至多一次”和“最终恰好一次”的含义。
■ 第12章介绍软件、API以及存储数据是如何随着时间的推移发展、演进的,并介绍它们如何能在保持与其他系统的兼容性的同时做到这些。
■ 第13章讨论紧跟IT行业的最新技术趋势可能并不总是明智的选择。本章分析一些广泛使用的模式和框架,譬如响应式编程,也对这些技术在某些特定场景的适用性进行讨论。
本书包含大量的代码示例,有的是以数字序号标注的代码清单的方式呈现的,有的则是以普通文本的方式呈现的。有些时候代码会被加粗从而区别于之前的代码,譬如,对一行现存代码进行修改,增加了新的功能。
很多时候,源码会被重新格式化,我们会添加换行符或者对代码进行重构,引入代码缩进以适配页面的可用空间。即便如此,还是有一些极端的情况,代码清单中会包含行连续标记(➥)。此外,如果代码在正文中有介绍,源码中不再添加注释。
本书示例的源码根据谷歌代码指南,使用自动化插件进行了格式化。许多代码清单都附有代码注释,对重要的概念进行强调。为了保证代码质量,本书使用的所有代码都有大量的单元测试和集成测试。但并不是所有的测试都在本书的代码清单中进行了展示。你可以阅读并运行这些测试从而更深入地理解某部分的逻辑。通过阅读代码库中的README.md文件,你可以了解如何导入并运行这些示例代码。本书示例的完整代码可以从https://github.com/tomekl007/ manning_software_mistakes_and_tradeoffs下载,也可按“资源与支持”页指引,在异步社区下载。
托马斯在他的软件开发职业生涯里,设计并开发过各种各样的生产服务、软件架构,他精通多种编程语言(大多数是基于JVM的)。他既实现过单体系统,也做过与微服务架构相关的工作。他设计的一些系统可服务数千万用户,每秒处理数十万的操作量。他的工作方向如下。
■ 设计采用CQRS架构的微服务(基于Apache Kafka)。
■ 市场自动化及事件流处理。
■ 基于Apache Spark和Scala的大数据处理。
托马斯现在就职于Dremio,负责创建现代大数据处理的数据湖解决方案。在此之前,他在DataStax负责与Cassandra数据库相关的一些产品。他设计的工具帮助成千上万的开发者设计出性能优异、用户友好的API,发挥了重要的作用。他为Java-Driver、Cassandra Quarkus、Cassandra-Kafka Connector以及Stargate都贡献过代码。
乔恩是谷歌公司的资深开发工程师,目前的工作方向是谷歌云的.NET客户端库。他向开源社区贡献了.NET版本的Noda时间库,然而最让人称道的是他在Stack Overflow开发者社区的贡献。乔恩是Manning出版社出版的C# in Depth一书的作者,此外,他对Groovy in Action以及Real-World Functional Programming两本书也有所贡献。乔恩对日期时间API以及API版本非常感兴趣,这些通常是无人问津的冷门话题。
本书封面上的人物是“Groenlandaisse”,或称“来自格陵兰岛的女人”,摘自Jacques Grasset de Saint-Sauveur于1797年出版的作品集,其中的每幅插图都经由手工绘制和上色。在那个年代,仅凭一个人的穿着,就可以很容易地推断出他的居住地及社会地位。Manning出版社以几个世纪前丰富多样的地方文化作品作为图书的封面,来颂扬计算机行业的创造力和开创精神。
本书提供如下资源:
■ 本书示例代码;
■ 本书彩图文件;
■ 本书思维导图;
■ 异步社区7天VIP会员。
要获得以上资源,你可以扫描下方二维码,根据指引领取。
作者、译者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎你将发现的问题反馈给我们,帮助我们提升图书的质量。
当你发现错误时,请登录异步社区(https://www.epubit.com),按书名搜索,进入本书页面,点击“发表勘误”,输入错误信息,点击“提交勘误”按钮即可(见下页图)。本书的作者、译者和编辑会对你提交的错误信息进行审核,确认并接受后,你将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。
我们的联系邮箱是contact@epubit.com.cn。
如果你对本书有任何疑问或建议,请发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。
如果你有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们。
如果你所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。
如果你在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请你将怀疑有侵权行为的链接发邮件给我们。你的这一举动是对作者权益的保护,也是我们持续为你提供有价值的内容的动力之源。
异步社区是由人民邮电出版社创办的IT专业图书社区,于2015年8月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作译者与读者在线交流互动,以及传统出版与数字出版的融合发展。
异步图书是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域30余年的发展与积淀。异步图书面向IT行业以及各行业使用IT的用户。
本章内容
■ 设计软件时的重要权衡
■ 选择单元测试或集成测试的结果
■ 不存在“一招鲜,吃遍天”的代码与架构设计模式
无论是设计应用程序,还是应用程序接口(application program interface,API),抑或是系统架构,我们都需要做各种各样的决策。这些决策会影响程序的可维护性、性能、可扩展性,产生无数潜在的后果。选定某个技术方向,另一个技术方向的发展就会受到一定限制,这种事在软件开发中屡见不鲜。系统存续的时间越长,修正之前的决策、改变系统的设计越困难。本书会用大量篇幅讨论设计软件时,如何在两个或者多个技术方案间进行选择。无论你的选择结果是什么,选择技术方案时清晰地了解其“优缺利弊”,做到“了然于胸”很重要。
通常情况下,开发团队需要结合项目背景、上市时间、服务等级协定(service level agreement,SLA)以及其他相关因素综合考量,才能做出一个艰难的决策。我们将毫无保留地向你展示设计软件系统时所做的那些权衡与取舍,并将其与其他可选方案进行比较。希望读完本书,你会开始关注每天都在做的那些设计决策。关注这些,尤其是熟稔每种决策的优缺点,可以帮助你做出清晰的选择。
本书先着重讨论每位软件工程师在做API设计和编码时都应考虑的基础设计决策,接着讨论软件设计中更宏观的部分——架构及各组件间的数据流,还将讨论采用分布式系统架构时需注意的取舍。
接下来介绍进行取舍分析时所采用的方法。首先,我们着重讨论每位软件工程师都得做的判断:如何在单元测试、集成测试、端到端测试,抑或是其他类型的测试之间实现平衡。在实际项目中,通常开发团队要在有限的开发周期内发布软件以创造价值。因此,我们要决定在哪类测试中投入更多的时间,是单元测试、集成测试、端到端测试,还是其他类型的测试,我们会逐一分析每种测试类型的利与弊。
然后介绍久经考验的单例模式,剖析为什么上下文差异会导致该设计模式的可用性发生变化。我们会结合单线程和多线程上下文实例进行阐述。最后,我们会从宏观角度分析采用微服务与单例模式的利与弊。
注意,描述软件架构时,简单地用纯微服务(only micro-service)或者纯单例模式(only monolithic)都是不确切的。我们经常在实际软件项目中看到混合模式的架构:一些功能以服务的方式实现,另一些功能则以单体系统的方式实现。譬如某个遗留系统,它可能整体而言是单体形态,少部分功能是由微服务架构实现的。此外,一个全新项目初始时只是一个应用,如果花费极高的代价将其微服务化,往往是得不偿失的。即便你采用混合架构,也需要根据项目实际情况,选择性地运用。
对于每一章内容的介绍,我们均采用这样的方式:首先介绍某个特定背景下的难题是如何解决的,接着分析其他备选的解决方案,最后补充介绍当时做决策的上下文及最终的选择。我们会分析每种解决方案在特定上下文中的利与弊。接下来的各章会围绕设计软件系统时的决策做更深入的探讨。
编写本书的初衷是帮助读者了解设计软件时应考虑的取舍以及分享经验和教训。谈到设计选型取舍时,我们有一个假设前提,即你所编写的代码已经足够健壮。高质量的代码是软件“大厦”的基础,打牢此基础后你才需要考虑架构演进的方向。
为了帮助你了解本书各章通用的内容组织形式,我们先以两个大家都熟悉的取舍为例,分别是单元测试和集成测试,它们可能是比较立竿见影的软件质量保障实践,你在编程时很可能已经用到了它们。最终的目标是单元测试和集成测试可以覆盖所有的代码路径。然而,这很难在实践中达成。因为项目周期是有限的,你没有那么多的时间来完成编码并进行充分的测试。因此,投入多少资源与时间到单元测试与集成测试上就变成了我们需要权衡的问题。
编写测试时,你需要决定测试哪部分代码。譬如,你需要对一个简单的组件SystemComponent
进行单元测试,它只提供了一个声明为public类型的接口,其他所有方法的声明都是private类型的,客户端无法直接访问。该场景的代码片段如代码清单1.1所示。
代码清单1.1 组件单元测试
public class SystemComponent {
public int publicApiMethod() {
return privateApiMethod();
}
private int privateApiMethod() {
return complexCalculations();
}
private int complexCalculations(){
// 复杂的代码逻辑
return 0;
}
}
这时你需要判断,要不要为complexCalculations()
添加单元测试,是否继续保持该方法的私有成员属性。这类单元测试属于黑盒测试,只能覆盖public类型的API。通常,单元测试做到这种程度就已经足够了。然而,极端的情况下,譬如私有方法的逻辑特别复杂时,为其添加单元测试也是物有所值的。为了做到这一点,你得放开complexCalculations()
的访问权限。代码清单1.2展示了对应的修改。
代码清单1.2 通过公有访问方式进行单元测试
@VisibleForTesting
public int complexCalculations() {
// 复杂的代码逻辑
return 0;
}
修改方法的可见性,将其由private类型变为public类型之后,你可以为这部分之前访问级别为private的API编写单元测试。由于公有方法对所有API客户端都可见,你不得不面对客户端可以直接调用该方法的窘境。你可能会说,上述代码清单中不是还有@VisibleForTesting注解吗?实际情况是,这个注解只能起到“提示信息”的作用,无法强制限定调用方不使用你的API中的公有方法。如果调用方没有留意这个注解,他们可能会忽略这一点。
本节提到的两种单元测试方法并无高低优劣之分。后一种方法提供了更高的灵活性,然而,随之而来的是维护代价的增加。你可以在这二者间取一个折中方案。譬如,将代码的包标记为private类型。这样一来,由于测试代码与产品代码在同一个包内,你可以直接在测试代码中调用上述方法,而不再需要将方法修改成public类型的。
计划测试任务时,你需要思考对你的系统而言,应该按什么比例分配单元测试与集成测试。通常情况下,选定一个方向会限制向另一个方向发展的可能性。而且,这种限制也许从该开发项目开始时便产生了。
大多数情况下,我们开发功能的时间都是“捉襟见肘”的,需要慎重考虑是否要投入更多的时间在单元测试或者集成测试上。现实场景中,我们应该充分结合单元测试和集成测试的优势,最大限度地发挥其效能,这也是为什么我们需要思考该按怎样的比例分配单元测试和集成测试。
这两种测试方法都是双刃剑,各有其利弊,你在编码时不得不做利弊权衡。单元测试的优点是速度更快,反馈时间更短,因此调试流程通常也更短。图1.1展示了这两种测试的优缺点。
图1.1 单元测试、集成测试及其执行与反馈时间(速度)
图1.1所示为一个金字塔,这是因为通常情况下,软件系统中的单元测试比集成测试多得多。单元测试可以为开发者提供几乎即时的反馈,从而帮助提升开发效率。单元测试的执行速度更快,可以帮助减少代码调试的时间。如果单元测试100%覆盖了你的代码库,当一个新的缺陷被引入时,很可能某个单元测试能发现这个缺陷。你可以在单元测试覆盖的方法级别上精确定位该缺陷。
另外,如果系统缺少集成测试,你将无法判断组件之间的连接是否正常以及它们之间的集成是否成功。你的算法虽然经过充分的测试,但没有对更大场景进行覆盖。最终你的系统可以在较低的层级正确完成所有任务,但由于系统中的组件配合没有经过测试,无法在更高层级上确保系统的正确性。在实际项目中,你的代码应该同时包含单元测试与集成测试。
需要注意的是,图1.1仅关注了测试的执行与反馈时间。但实际生产系统中还会有其他层级的测试,如我们可能会做完整验证业务场景的端到端测试。在更复杂的体系结构中,我们可能需要启动N个相互连接的服务以提供对应的业务功能。由于搭建测试基础架构所需的开销较大,这类测试的反馈时间可能比较长。然而,它们能从更高的层级保障系统端到端流程的正确性。如果要用这些测试与单元测试或者集成测试做比较,我们需要从不同的维度进行分析。如图1.2所示,它们从整体角度而言对系统验证的效果如何?
单元测试仅在单一组件中隔离运行,无法提供系统中其他组件的信息,也无法验证单一组件如何与其他组件进行交互。集成测试的重要性此时就凸显了,它可以同时测试多个组件,验证组件之间的交互效果。不过,集成测试通常不会跨多个服务或者微服务验证某个业务功能。最后我们要介绍的是端到端测试,这类测试可以对系统进行完整的验证,由于我们需要串联起整个系统,该系统可能包含若干个微服务、多个数据库、多个消息队列等,测试涉及的组件数目是极其庞大的。
图1.2 单元测试vs集成测试vs端到端测试
我们还要考虑创建测试所需的时间。创建单元测试比较容易,只需花费比较少的时间就能创建大量的单元测试用例。创建集成测试往往需要更多的时间。最消耗资源的是端到端测试,创建端到端测试的基础设施需要大量的投入。
在实际项目中,我们的资金和时间往往是有限的,虽然我们秉持尽最大可能提升软件质量的原则,但也要考虑方方面面的限制。通过测试覆盖代码变更可以帮助我们发布更高质量的软件,减少缺陷数量,从长远来看,还可以提升软件的可维护性。在资源有限的情况下,我们不得不思考,该选择做什么类型的测试,以及做到什么程度。我们需要在单元测试、集成测试、端到端测试之间寻找平衡点,可以多维度分析特定类型测试的优势与劣势,帮助我们做出更合理的判断。
有一点特别重要,也需要特别强调,那就是添加测试会延长开发的时间。我们做的测试越全面,花费的时间就越多。如果不为这些测试任务分配时间,只是死板地限定项目交付日期,很难开展有效的端到端测试工作。因此,计划为产品添加新功能时,我们也需要考虑添加对应的测试任务,而不要奢望以事后弥补的方式解决问题。
我们熟知的建造者(builder)模式、装饰器(decorator)模式、原型(prototype)模式以及许多其他设计模式诞生的时间都不短。这些设计模式为解决典型的软件设计问题提供了久经考验的生产级解决方案。我们强烈建议读者熟练掌握这些设计模式并在代码中使用它们。使用设计模式,代码的可维护性、可扩展性都会更好,也更优雅[关于设计模式的更多内容可以参考Erich Gamma等人合著的经典图书Design Patterns: Elements of Reusable Object-Oriented Software(《设计模式:可复用面向对象软件的基础》)]。另外,使用设计模式时,你应该谨慎,因为设计模式的实现对上下文有非常强的依赖。正如本书开篇所述,我们希望帮助你了解软件设计中的取舍及其影响。
我们会以单例模式为例进行介绍,帮助读者从代码层面理解这些取舍。引入单例模式的目的是提供一种所有组件共享通用状态的方式。单例是贯穿你的应用整个生命周期的单一实例,被其他的类所引用。创建单例非常简单,你可以通过创建一个私有构造器来避免创建新的实例,代码实现如代码清单1.3所示。
代码清单1.3 创建一个单例
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这个例子里获取单例的唯一方法是通过getInstance()
方法,该方法返回由所有组件共享的唯一实例。这里有一个假设,即每次调用方代码访问单例时,都通过调用getInstance()
方法。后续我们会讨论其他的用例,不一定每次都必须通过该方法。使用单例模式看起来是条捷径,通过全局的单例,你可以安心地共享代码。看到这里,你可能会疑惑:“这哪有什么取舍?”
我们换个上下文,看看使用这种模式是否也恰当。如果我们在一个多线程的环境中使用单例模式会出现什么情况呢?如果多个线程同时调用getInstance()
方法,就可能产生竞争。这时,你的代码猝不及防地创建出了单例的两个实例。单例模式存在两个实例会破坏该模式的不变性,最终可能导致系统性的故障。为了避免出现这种情况,你需要在初始化逻辑之前,添加同步机制,如代码清单1.4所示。
代码清单1.4 使用同步机制创建线程安全的单例模式
public class SystemComponentSingletonSynchronized {
private static SystemComponent instance;
private SystemComponentSingletonSynchronized() {}
public static synchronized SystemComponent getInstance() { ◁--- 同步代码块开始
if (instance == null) {
instance = new SystemComponent();
}
return instance;
}
}
同步代码块避免了两个线程同时访问该逻辑。初始化完成之前,仅有一个线程能进入该逻辑,所有其他的线程都会被阻塞。乍一看,这不就是我们所期望的吗?然而,如果你的代码有比较高的性能要求,采用单例模式的同时使用了多线程,程序的性能可能会受到比较严重的影响。
初始化是多线程因锁竞争而等待的第一个地方。一旦完成单例的创建,接下来每次对该对象的访问还需要进行同步。单例会引起线程争用,进而严重影响程序性能。多线程并发访问同一个共享的对象实例时经常出现这种问题。
同步的getInstance()
方法一次只允许一个线程进入临界(同步)区,所有其他的线程都需要等待锁的释放。前一个线程退出临界区后,队列中的第二个线程才能进入。这种方式的问题在于它引入的同步会严重拖慢程序的执行。简而言之,每次执行同步调用,都可能引入额外的开销。
通过这个例子,我们可以得出以下结论:采用单例模式时,单线程与多线程存在性能差异,你需要在二者间做权衡。判断最基本的出发点是应用程序的运行环境。如果你的程序不需要并发运行,或者单例不会在多个线程间共享,那就完全不需要考虑这个问题。一旦你创建的单例需要在多个线程间共享,就需要确保它是线程安全的,从而避免潜在的性能问题。熟稔这些取舍,可以帮助你在做设计、代码实现、方案选择时理性从容。
如果你发现某个设计最初的选择弊大于利,最终可能要变更方案。以上文的单例而言,我们可有两种方式变更方案。
第一种方式是采用双检锁(double-checked locking)技术。采用这种方式,每次进入临界区之前,都要检查实例是否为空。如果实例为空,我们可以继续进入临界区,否则就不进入,直接返回已经存在的单例对象。代码清单1.5展示了双检锁的使用。
代码清单1.5 采用双检锁的单例
private volatile static SystemComponent instance;
public static SystemComponent getInstance() {
if (instance == null) { ◁--- 如果实例不为空,则不需要进入临界区
synchronized (ThreadSafeSingleton.class) {
if (instance == null) {
instance = new SystemComponent();
}
}
}
return instance;
}
采用这种方式,我们可以显著缓解同步以及线程竞争资源的情况。我们只会在应用启动的时刻观察到发生了同步,该时刻每个线程都试图初始化单例。
我们可以采用的第二种方式是线程限定(thread confinement)。线程限定可以将状态访问限定在特定的线程内。不过,你需要注意,这种方式从应用全局的角度而言就不再是单例模式了。每个线程都会持有一个单例对象的实例。如果你有N个线程,就会有N个实例。
在这种方式下,每个线程独享一个对象实例,且这个对象仅对相应线程可见。基于这样的设计,多线程之间就不再存在访问对象引起的竞争。每个对象由单一线程独享,而非多线程共享。Java语言提供了ThreadLocal
类来实现这一效果。凭借ThreadLocal
类,我们可以封装系统组件,并将其绑定到特定的线程。从代码实现的角度而言,对象存在于ThreadLocal
实例之内,如代码清单1.6所示。
代码清单1.6 使用ThreadLocal
类进行线程限定
private static ThreadLocal<SystemComponent> threadLocalValue = new ThreadLocal<>();
public static void set() {
threadLocalValue.set(new SystemComponent());
}
public static void executeAction() {
SystemComponent systemComponent = threadLocalValue.get();
}
public static SystemComponent get() {
return threadLocalValue.get();
}
将SystemComponent
与某个线程绑定的逻辑封装在ThreadLocal
实例中。例如,线程A调用set()
方法时,ThreadLocal
中便创建了一个新的SystemComponent
实例。我们需要注意,此时该实例只能被线程A访问,这一点非常重要。如果另外一个线程,譬如B线程,之前没有调用过set()
方法,试图执行executeAction()
,它得到的就是一个空的SystemComponent
实例,因为没有任何人为该线程执行组件的set()
方法。只有B线程调用set()
方法后,该线程独享的新实例才会被创建。
通过为withInitial()
方法传递一个提供方(supplier),我们可以简化这段逻辑。如果线程本地对象没有值,该方法就会被调用,这样我们就避免了遇到空对象的风险。代码清单1.7展示了这一实现。
代码清单1.7 为线程限定添加初始值
static ThreadLocal<SystemComponent> threadLocalValue = ThreadLocal.withInitial (SystemComponent::new);
采用这一方式,你可以消除竞争,提升程序的性能。不过这种方式也有其弊端,它会使程序的复杂性增加。
注意 在这种方式下,调用方代码访问单例时,不再需要通过getInstance()
方法。你可以在第一次访问单例时,将其赋给某个变量(引用)。一旦将单例赋给变量,后续所有的调用都可以通过该变量获得单例对象,不再需要调用getInstance()
方法。如此一来,就可减少竞争。
单例对象的实例也可以被注入需要使用它的其他组件中。理想情况下,你的应用在一个地方创建了所有组件,并将它们注入服务中(利用像依赖注入这样的技术)。这种情况下,你甚至根本不需要使用单例模式。你只需要创建一个需要分享的对象实例,并将它注入所有依赖的服务中。当然,你也可以采用其他方式,譬如使用枚举类型,它的底层实现也基于单例模式。接下来让我们通过代码来验证我们的猜测。
到目前为止,我们已经通过3种方式实现了线程安全的单例模式,如下所示。
■ 为所有的操作添加同步机制。
■ 使用双检锁创建单例。
■ 采用线程限定方式(通过ThreadLocal
类)创建单例。
在我们的猜测中,第一种方式的性能应该最差,然而目前我们还没有任何证明数据。现在,我们将创建一个性能基准测试来验证这3种实现方式的性能差异。我们会使用性能测试工具JMH进行性能对比测试,本书后续内容也会多次使用该工具对代码的性能进行测试。
我们会创建一个执行50,000次获取SystemComponent
(单例)对象操作的基准测试(代码请参考代码清单1.8)。我们会创建3个基准测试,每个基准测试使用不同的单例实现方式。为了验证竞争是如何影响程序性能的,我们会创建100个并发线程执行这段代码逻辑。结果报告中以毫秒为单位呈现测试结果。
代码清单1.8 创建单例的基准测试
@Fork(1)
@Warmup(iterations = 1)
@Measurement(iterations = 1)
@BenchmarkMode(Mode.AverageTime)
@Threads(100) ◁--- 启动100个并发线程执行这段代码逻辑
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class BenchmarkSingletonVsThreadLocal {
private static final int NUMBER_OF_ITERATIONS = 50_000;
@Benchmark
public void singletonWithSynchronization(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(
➥ SystemComponentSingletonSynchronized.getInstance()); ◁--- 第一个基准测试采用SystemComponentSingletonSynchronized
}
}
@Benchmark
public void singletonWithDoubleCheckedLocking(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(
➥ SystemComponentSingletonDoubleCheckedLocking.getInstance()); ◁--- 对SystemComponentSingletonDoubleCheckedLocking的基准测试
}
}
@Benchmark
public void singletonWithThreadLocal(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(SystemComponentThreadLocal.get()); ◁--- 获取SystemComponentThreadLocal的基准测试结果
}
}
}
执行这个测试,我们可以得到100个并发线程完成50,000次调用的平均耗时。注意,你的实际耗时可能因环境不同有所差异,不过总体的趋势应该保持一致,如代码清单1.9所示。
代码清单1.9 执行不同单例获取的基准测试结果
Benchmark Mode
Cnt Score Error Units
CH01.BenchmarkSingletonVsThreadLocal.singletonWithDoubleCheckedLocking avgt
2.629 ms/op
CH01.BenchmarkSingletonVsThreadLocal.singletonWithSynchronization avgt
316.619 ms/op
CH01.BenchmarkSingletonVsThreadLocal.singletonWithThreadLocal avgt
5.622 ms/op
查看测试结果,singletonWithSynchronization
方式的执行的确是最慢的。完成基准测试逻辑执行的平均时间超过300 ms。其他两个方式对这一行为进行了改进。singletonWithDoubleCheckedLockin
g的性能最优,只花费了大约2.6 ms,而singletonWithThreadLocal
耗时大约为5.6 ms。据此,我们可以得出如下结论:采用线程限定方式可以带来约50倍的性能提升,采用双检锁方式可以带来约120倍的性能提升。
验证我们的猜测后,我们为多线程上下文选择了合适的方式。如果需要在多个方式间做选择,当它们的性能不相上下时,我们建议选择更直观的解决方式。然而,所有这一切的前提都是测试数据,如果没有实际的测试数据,我们很难做出客观和理性的决策。
接下来,我们将讨论涉及架构选型的设计取舍。1.3节中,我们会对比微服务架构与单体系统,了解它们在设计上的权衡。
到目前为止,我们已经了解了影响代码设计的底层编程模式以及各种选择的利弊和取舍,但如果应用程序的上下文发生变化,你可能依旧能接受对这些底层设计做对应的修改。下文将着重讨论架构设计模式:这些模式由于贯穿组成你的系统的多个服务,因此很难做变更。我们先要讨论的是微服务架构,这是当今创建软件系统最通用的模式之一。
微服务架构与单体系统(单体系统在创建时,所有的业务逻辑都需要在单一系统中实现)相比,有诸多的优势。不过,微服务架构也带来了不可忽略的维护开销以及日益增加的复杂性。我们先从微服务架构与单体系统的根本优势入手,了解二者的区别。
我们创建的系统需要有能力处理海量的数据,同时,它们还要能按需伸缩。如果系统的一个节点每秒可以处理N个请求,流量暴增时,微服务架构允许你快速横向扩展(水平扩展)来满足业务需求(见图1.3)。当然,系统需要以支持容易扩展的方式编码,也需要使用底层的组件。
图1.3 横向扩展意味着可以通过向资源池中添加更多的机器来满足增长的业务需求
举个例子,为了让你的系统有能力每秒处理2N个请求(这里的2表示服务的数量,N表示单个服务可以处理的请求数),你可以增加一个原微服务的新实例。不过,要想达到期望的效果,底层的数据访问层需要具备向上扩展的能力。
当然,扩展性会有上限,当它达到上限之后,增加新节点也不一定能带来太大吞吐量的提升。性能瓶颈可能出现于数据库、消息队列、网络带宽等达到了扩展的上限时。
不过,就整体而言,与单体系统相比,微服务架构的扩展要容易得多。在单体系统中,一旦某些资源达到上限,几乎不可能做快速扩展。
你可以通过为计算实例添加更多处理器、更大的内存或者磁盘容量来垂直扩展(通常称为纵向扩展)你的应用,同样地,这种方式也存在一定限制,达到上限之后很难继续提升性能。举个例子,有个单体应用部署到云端,部署时选择更强劲的云计算实例类型(更多的处理器或者更大的内存)就可以纵向扩展,提升其性能。如果能够增加更多的资源,这种方式显然是有效的。然而,云计算实例也存在资源上限,某些时候,云计算提供商可能也无法提供更强劲的机器。这时,横向扩展的灵活性优势就体现出来了。如果你的应用在设计和实现时考虑了支持部署到N个实例,就可以通过部署更多的实例,为服务提供更高的总吞吐量。
在微服务架构中,工作可以比较容易地拆分到多个团队。举例而言,团队A可以创建一个独立的微服务,专注于业务功能的开发。与此同时,团队B在业务领域的另一个部分开展工作。这两个团队能互不干扰地独立工作,并可由此进行快速的迭代开发。
采用微服务架构,团队之间不需要在代码库层面进行协作。各团队可以按照自己的需求选择技术栈,快速演进开发。加入团队的新成员也不需要掌握整个业务领域的内容,只需要了解其团队负责的部分领域,这样可以更容易地理解系统,快速开展工作。
由于每个团队都能独立地部署自己的代码库,部署流程会更健壮。所有这一切的结果是部署的频度会越来越高,风险也越来越小。即使团队偶然引入了某些缺陷,对部署的影响也比较小。因为变更的内容少,调试定位潜在问题也更快。调试过程中可能遇到困难,譬如多个细粒度的微服务集成时报错。在这种情况下,我们需要进行请求跟踪,收集多个微服务之间的请求调用关系。
与此相反,在单体系统中,多个团队的成员经常要共享同一个代码库。如果某应用的代码存放在某个代码库中,由于应用比较复杂,多个团队同时在这个代码库上开展工作。这种情况下,你很可能遇到代码冲突的情况。一旦出现这种情况,大量的开发时间都会消耗在解决这些冲突上。当然,如果产品代码能够以模块化的方式组织,在一定程度上可以降低冲突的概率。然而,随着越来越多开发者的加入,产品主分支上的变更会越来越频繁,你也不得不隔三岔五地做代码同步。对比单体系统和微服务架构,我们很容易发现固定业务领域的代码通常少得多。因此,采用微服务架构时出现代码冲突的概率小得多。
单体应用的部署一般不太频繁,主要原因是每次部署都需要向主分支合并大量的功能代码(因为有更多的人在其上开展工作)。功能越多,完成相关测试所需的时间越长。同一个版本包含的功能越多,引入缺陷的概率越大。值得一提的是,通过创建稳定的持续集成(或者持续部署)流水线,这些痛点在一定程度上可以得到缓解。我们可以通过更频繁地运行这些流水线,更快速地构建新应用,从而让每个新版本包含更少的新功能。如果新版本代码引入了缺陷,这样也将更易于分析和调试。版本中包含的新功能越少,定位潜在问题的速度越快。如果我们做一个对比,即在同样的时间内,在一个发布周期中将频繁构建新应用与不频繁构建新应用的方式做比较,会发现前一种情况下发布最终部署到生产的特性数比后一种情况要多得多。一个版本包含的功能越多,潜在的问题越多,也越难调试。
微服务架构是一种复杂的设计,它包含多个组成部分。如果你有合适的负载均衡器,可以很容易地实现扩展;负载均衡器维护多个运行服务的列表,并为流量进行路由。底层服务可以纵向扩展,也可以收缩,这意味着服务可以按需创建和销毁。变化的跟踪是一个非常关键的问题。为了解决这一问题,我们引入了一个新的服务注册组件,如图1.4所示。
图1.4 微服务架构中的服务注册组件
每个微服务都需要一个运行的注册客户端,该注册客户端负责向服务注册表注册该服务。一旦完成注册,负载均衡器就开始向新的实例转发流量。服务注册表会对服务实例进行健康检测,若发现服务实例出现问题则执行解绑操作。这是微服务架构所面临的诸多技术挑战之一,也导致部署变得越来越复杂和困难。
了解了方案的优势及劣势,你还需要了解项目的实际情况,这对一个好的设计决策来说至关重要。如果你的项目对可扩展性没有很高的要求,同时团队规模也不大,采用单体系统可能是一个明智的决策。本书的每一章都会遵循相似的流程来评估设计决策:找到每种设计的优势及劣势,结合项目的上下文,解决困惑,即什么情况下哪种设计是更优的决策。
本章中,我们向你介绍了一个设计取舍的例子,在什么环境下如何做取舍是本书试图回答的核心问题。通过本章,你应该了解了如何为你的应用选择恰当的单元测试与集成测试比例,及其底层的利弊取舍。我们也讨论了像单例模式这样久经考验的解决方案不一定是最合适的选择,我们需要结合实际的使用场景进行判断。譬如,如果你的系统采用多线程的环境,采用单例模式时可能会由于线程竞争导致一定的性能问题。最后通过高层设计选择的例子,我们对微服务架构与单体系统设计模式进行了对比。
第2章中,我们会讨论代码重复与复用之间的取舍。我们认为代码重复不一定都是反模式的或者是坏事,同样,做判断时我们需要充分结合上下文。
■ 开发周期有限时,你需要特别留意设计选择的后果,譬如,采用单元测试还是集成测试来保障你的代码质量。
■ 久经考验的底层代码设计模式,譬如单例模式,不一定是“放之四海而皆准”的“设计良药”。以单例模式为例,涉及线程安全时它会引入性能问题。所以我们做决策的时候需要结合项目的上下文,综合判断。
■ 微服务架构不一定适合解决每个问题;架构设计选型时,我们需要系统地评估方案的优缺点。