Kotlin-软件架构-全-
Kotlin 软件架构(全)
原文:
zh.annas-archive.org/md5/8159fb6e04dbfea9431e131291a8e6bd
译者:飞龙
前言
欢迎来到使用 Kotlin 进行软件架构,这是一本全面指南,旨在为你提供构建健壮软件系统所需的知识和技能。随着对高效和可扩展应用程序的需求不断增长,理解架构原则对于工程师和架构师来说变得不可或缺。
在这本书中,我们将通过第一性原理将一系列软件架构风格分解为基本组件,这样这些组件就可以重新排列和组合,以解决现实世界的问题。每一章都专注于特定领域,介绍关键概念、最佳实践和真实世界的例子,说明如何将这些原则应用于 Kotlin。
不论你是希望深化对软件架构理解的资深工程师,还是渴望学习的初学者,这本书都提供了可以立即应用的实际见解。你将找到动手练习、代码片段和案例研究,这些将帮助你掌握复杂的概念并在你的项目中实施它们。
当你开始这段旅程时,我鼓励你尝试这里提出的概念。软件架构不仅仅是模式和风格——它关乎创造力、问题解决、灵活性和适应解决方案以应对你应用程序的独特挑战。祝您阅读愉快!
本书面向对象
这本书是为那些希望增强他们的架构知识和思维方式的软件工程师而写的,以解决日常工程问题。在工程方面的先前经验将很有用,但不是必需的。
如果你刚开始学习如何编写 Kotlin 代码,正在从 Android 开发扩展到后端技术,或者是从编写 Java 代码过渡过来,你会发现这本书很有用。
如果你是一位对讨论和探索独特架构思想感兴趣的软件架构师,这本书适合你。
本书涵盖内容
第一章,软件架构的本质,重新审视了软件架构的重要性以及软件架构师在组织中的作用。它涵盖了组织的结构如何影响架构决策。然后讨论了选择框架以及在此过程中需要考虑的因素。介绍了几个行业标准文档和图表,这些将在后续章节中用于说明。
第二章,软件架构原则,探讨了多种可视化和量化软件架构的方法,提取质量属性以进行测量和分析。然后深入探讨了三个关键概念:关注点的分离、内聚性和耦合性。还涵盖了流行的架构原则,如 SOLID、迪米特法则、YAGNI 和面向未来。这些原则为后续章节中架构风格的进一步探索奠定了基础。
第三章,多态性和替代方案,通过一个现实生活中的问题,并使用 Kotlin 代码的多种风格来解决它。它从一个多态解决方案开始,然后探讨了涉及 Kotlin 封闭类的两种解决方案。接下来,展示了使用代理的解决方案,然后是函数式方法。最后,基于系统质量属性比较了所有方法。
第四章,对等网络和客户端-服务器架构,专注于分布式系统中的网络通信。它包括使用 OpenAPI 规范和 Http4K 框架在 Kotlin 中以 API 首先的方法实现客户端-服务器解决方案的逐步指南。然后,本章实现了相同问题的对等网络解决方案,并比较了两种方法,讨论了在不同情况下哪种更适合。
第五章,探索 MVC、MVP 和 MVVM,将重点转向前端应用程序。使用一个示例 Android 应用程序,我们将 MVC、MVP 和 MVVM 应用到不同的架构风格中,以观察实现方式的演变。本章比较了这三种模式以及其他常用风格。
第六章,微服务、无服务器和微前端,将重点转向后端。本章展示了单体应用程序和面向服务的架构如何演变成微服务和纳米服务。它解释了无服务器架构如何通过云服务提供商的服务影响现代软件系统。最后,讨论了微服务的客户端对应物,即微前端。
第七章,模块化和分层架构,从三个具有相似性和差异性的分层架构——清洁架构、六边形架构和功能核心强制外壳——开始。它们使用 Kotlin 代码针对相同现实生活中的问题进行演示和比较。随后,本章探讨了连接模式,提供了一种模块化方法来与远程系统集成。
第八章,领域驱动设计(DDD),深入探讨了 DDD 设计活动。它从基本概念和术语开始,然后通过战略设计来看待领域的整体情况。为战术设计选择了一个边界上下文。本章还通过一个现实生活中的例子,逐步介绍了三个流行的领域建模活动。
第九章,事件溯源和 CQRS,将上一章中的 DDD 实践扩展到两种强大的架构模式。它首先通过一个现实生活中的例子说明了事件溯源的使用,然后解释了如何应用 CQRS。最后,将事件溯源和 CQRS 结合起来作为解决相同问题的方案,以释放这两种架构风格的可能性。
第十章,幂等性、复制和恢复模型,讨论了三个相关的架构概念。它从分布式系统中的幂等性开始,提供了如何实现它的实际示例。接下来,使用 CAP 定理探讨了和比较了几种复制模型。本章以使用 RAFT 领导选举作为案例研究的系统恢复结束。
第十一章,审计和监控模型,展示了在 Kotlin 中可以由多个服务使用但集中记录的示例审计跟踪结构。它还讨论了各种监控数据格式和用于监控目的的数据收集方法。本章涵盖了 Kotlin 代码的结构化和上下文日志,以及自动警报、事件管理和指标。
第十二章,性能和可伸缩性,专注于使用定义的指标来衡量性能。它通过基本方法和微基准测试展示了性能测试。本章在讨论使用 Kotlin 代码进行性能改进的策略的同时,指导你通过性能测试工作流程。此外,使用投票系统来说明提高性能和扩展系统的过程。
第十三章,测试,探讨了质量保证的作用。它检查了测试金字塔内的各种测试方法,并强调了每种类型的最佳实践。本章包括使用 Kotest 框架的测试驱动开发(TDD)练习的逐步旅程。
第十四章,安全,专注于保护软件系统和其数据免受恶意攻击。它从使用传输层安全性(TLS)来保护网络通信开始。然后,本章涵盖了多因素认证(MFA)用于用户身份验证。它还讨论了常见的授权和数据权限方法,隐藏和匿名化敏感数据的技术,以及各种网络安全方法。本章以 DevSecOps 和威胁建模练习的讨论结束。
第十五章,超越架构,涵盖了软件架构之外的多个工程主题。首先,它探讨了几个 Kotlin 语言特性,这些特性有助于工程师实现更好的代码质量和软件架构。接下来,它讨论了在 IDE 功能的帮助下从 Java 到 Kotlin 的过渡。本章比较了两种 CI 方法:基于功能和基于主干开发。然后,它涵盖了发布策略,简要提及开发者体验,并以软件架构当前趋势的审视作为结尾。
为了充分利用本书
以开放的心态和愿意实验的态度来学习材料至关重要。首先,回顾前两章中介绍的基础概念,因为它们是更高级主题的基石。参与动手示例和编码练习,这些练习旨在将您的理解转化为实际应用。思考每种架构风格如何满足您项目的需求。此外,考虑探索外部资源以进一步丰富您的学习体验。
本书涵盖的软件 | 操作系统要求 |
---|---|
IntelliJ IDEA(社区版或终极版) | Windows, macOS, 或 Linux |
Android Studio | Windows, macOS, Linux 或 ChromeOS |
OpenJDK 17+ | Windows, macOS, 或 Linux |
Git CLI 工具 | Windows, macOS, 或 Linux |
您需要配置 IntelliJ 和 Android Studio 以使用已安装的 JDK 和 Git* 命令行工具**。
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于避免与代码复制和粘贴相关的任何潜在错误。
阅读完本书后,我建议您开始一个新的项目,尝试解决乡村家庭交换服务的真实案例问题,或者解决您非常了解的问题,例如您日常生活中的问题或工作中的问题。同时,应用架构风格,用您对问题的了解来分析它们,并探索书中提到的框架。解决一个理解透彻的问题有助于我们专注于解决非功能性问题和动手编码。
下载示例代码文件
您可以从 GitHub(github.com/PacktPublishing/Software-Architecture-with-Kotlin
)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还提供了其他丰富的书籍和视频中的代码块,可在github.com/PacktPublishing/
找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Person
类直接访问Address
类内部的city
属性。”
代码块设置如下:
class Person(val name: String, val address: Address) {
fun getAddressCity(): String {
return address.city
}
}
class Address(val city: String)
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的词汇以粗体显示。以下是一个示例:“商业包使用持久性包来执行服务合同和家庭的实际关系型数据库操作。”
提示或重要注意事项
它看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送给我们,邮箱地址为customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将非常感谢。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 Kotlin 的软件架构》,我们非常乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、时事通讯和每天收件箱中的优质免费内容。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接
packt.link/free-ebook/978-1-83546-186-0
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:软件架构的本质
软件架构是软件系统的蓝图。它可能不包含一行代码,但它描述了不同结构如何协同工作,从而使系统从这些结构中产生系统行为,从而系统实现其预期功能。
这本书是为那些希望提高他们的架构知识和思维方式以解决日常工程问题的人准备的。在本章中,我们将讨论软件架构的基本价值和它在组织中的位置。我们将涵盖以下主题:
-
软件架构的重要性
-
架构师的角色
-
康威定律
-
选择框架
-
文档和图表
软件架构的重要性
我们为什么要关心软件架构呢?从理论上讲,一个好的工程师可以直接开始编码。给定时间和努力,可以生产出一个软件系统以开始运行。这是一个典型的跳到结果而不从过程中提取价值的例子。
软件系统是一个需要适应环境变化的活生生的实体。让我们用一个现实生活中的例子来说明这个概念。
实际应用案例 - 社区服务交换作为合同
在一个村庄社区中,每个家庭都互相提供帮助。一个家庭的成员在另一个家庭缺少某些技能。家庭 A 的成员擅长管道工但不会做衣服,而家庭 B 的裁缝需要修理管道。
因此,家庭 A 提出为家庭 B 修理管道,以换取家庭 B 为家庭 A 的新生儿制作衣服。
每个家庭使用记账软件来记录每个家庭文件中的服务交换。每个家庭中的软件副本之间不进行通信。
它在一开始工作得很好,直到一些家庭在服务交换中发生争议。双方都声称他们的记录在软件中是正确的;然而,软件每个副本中的记录略有不同。由于软件的每个副本之间不进行通信,因此争议无法轻易解决。
记账软件可能的改进之一是将记录保存在中央数据存储中,以便家庭在执行服务之前可以查看并同意服务交换的细节。
然而,记账软件是在没有架构的情况下编写的。我们只有一行又一行的代码,散布在多个文件中,并且在多个地方存在一些重复的逻辑。代码本身可能写得很好且组织有序,但原始工程师已经离开了团队,而新工程师并不理解代码背后的逻辑。
软件架构作为沟通手段
软件架构本质上是一种沟通方式。首先,它以抽象的方式定义了它解决的问题,使得来自非工程背景的利益相关者可以理解和推理软件系统。
利益相关者会使用特定的术语来描述问题。有时,不同的利益相关者可能会使用不同的术语来表示相同的意思,或者他们可能会使用相同的术语但有不同的含义。工程师也需要与工程结构中的术语和用法保持一致。软件架构作为一种共同的语言和理解,使得所有利益相关者和工程师都可以使用明确的术语进行沟通。
通常,利益相关者会利用软件架构来整合他们的操作工作流程。他们可能需要与其他系统进行交互,或者需要团队在系统的不同部分工作。软件架构成为工作流程自动化部分的可视化。
软件架构作为培训材料
其次,软件架构提供了一个不同结构如何协同工作的抽象视图,并专注于某一方面的关注点。新加入团队的工程师通常有很多东西要学,才能理解当前系统的工作方式。源代码是真理的最终来源;然而,阅读所有内容可能会很费力且耗时。源代码通常充满了语言语法和多层函数调用的混乱。从代码底部向上建立对系统的理解当然是可能的,但这需要很长时间。
在具有引导新成员直接关注他们所关心领域的架构文档的帮助下,学习效果会更好。它比源代码更不令人感到压倒,并且避免了工程师将代码中的错误视为正确行为。新工程师可以在架构文档的帮助下一次学习系统的某个方面。
软件架构来体现系统质量属性
系统质量属性,也称为系统非功能性属性,是定义软件系统整体行为、操作和性能方面的特征。它们是非功能性的,因为它们与系统解决的功能或业务问题无关。
系统质量属性,例如可用性、可伸缩性、安全性、可测试性、可扩展性和可维护性,仅凭代码难以衡量。软件架构至少提供了一个视图来体现这些属性中的每一个,以便我们可以相应地调整系统。
在给定示例中,软件可能在冗余方面存在不足,即每个软件副本都将其数据存储在其自己的本地存储中,并且不与任何其他副本通信。如果一个副本停止工作,家庭将丢失所有数据。此外,由于每个副本不与其他副本通信,没有可靠的方法来保证交换服务的两个家庭在自己的软件副本中有相同的记录。
通过软件架构来描述系统属性,工程师将能够识别问题并设计变更以改善给定的属性。此外,它使我们能够衡量和监控这些属性随时间的变化,并将它们与软件变更相关联。当我们计划对当前软件架构进行变更时,我们甚至能够预测这些属性。
软件架构作为变更管理工具
通常,问题会随着时间的推移而变化和演变。在示例中,软件副本中服务交换的分离记录是足够的,因为没有争议。软件架构为变更和增强提供了基础。在许多情况下,不同的利益相关者心中有不同的优先级。软件架构促进了关于系统如何演变以及成本的讨论,以便可以按顺序优先考虑增强。
此外,由于系统属性在软件架构中得到了描述,我们可以识别和减轻风险,因为我们了解架构的哪个部分正在被更改。
软件架构作为可重用解决方案的记录
软件架构记录了一系列提出的问题和做出的决策。在账务软件的示例中,由于原始工程师已经离开村庄,没有人真正了解思维过程以及为什么当时做出了某些设计选择。增强系统变得非常危险,因为没有人知道更改一行代码的影响。中央数据存储的想法已经计划好了,我们只是落后一步,或者它从未被设计为共享数据。我们根本不知道。
这使我们无法安全地改进软件,甚至无法修复一个错误。我们可能会犯同样的错误。我们可能会误解软件的原始意图,甚至创建一个错误。如果问题像给定示例那样发展,继续使用软件变得困难。
软件架构充当了解决问题的决策记录集。它解释了驱动决策的理由以及考虑了哪些因素来做出选择。它还记录了任何考虑过的替代方案以及为什么最终没有选择它们。
软件架构还确定了系统所受的任何约束。包括约束很重要,因为任何新的技术进步都可能消除这些约束,例如新的框架,从而为改进创造新的机会。
所有这些信息为我们提供了一个坚实的基础,如果有一天我们决定从头开始构建一个新的系统来解决问题,我们不需要从头开始。我们可以从我们所学的知识和背后的旅程开始。如果适用,我们可以重用之前架构中的许多概念。我们可以通过在之前系统上施加更少的约束,显著改善下一个系统。
软件架构师的角色
软件架构师(架构师)似乎是一个创建软件架构的人,这似乎是显而易见的。然而,软件架构是多维思维过程的结果,涉及很多人。没有哪个架构师会单独产生架构,并且不需要从其他人那里获取输入。
需要指出的是,尽管在某些组织中软件架构师可能是一个职位名称,但软件架构师的角色并不局限于只有拥有该头衔的人。
工程师与利益相关者之间的接口
软件架构师协调并翻译工程师和非技术人员(利益相关者)使用的语言。他们通过使用文档和图表来阐述软件系统中的关键主题,以促进沟通。在软件架构师的协助下,工程师和不同利益相关者之间的接口工作方式存在差异。我们现在将探讨这些差异。
工程师和产品经理
软件架构师将产品需求转化为技术设计。工程师也可以做到这一点,但软件架构师在如何某些实现可能影响系统质量属性方面具有更广阔的视角。软件架构师不会指定实现的选择;然而,他们定义非功能性需求,这些需求可以预测系统质量属性。非功能性需求为实施提供了方向和约束。
在前面给出的例子中,如果软件架构师参与了技术设计过程,他们可以要求邻居之间的服务交换记录在两个软件副本中复制,从而可以避免不一致记录的争议。
软件架构师还参与将技术约束、错误和实现转化为产品经理可以消化并参与的信息。软件架构师提供代码实现的抽象视图,以促进与产品经理的沟通。
假设有一个新的框架,它促进了两个软件副本同步邻居之间的服务交换记录,从而永久解决了争议问题。软件架构师可以记录这种新的方法,并抽象出交互,为与产品经理讨论如何改善用户体验提供基础。
工程师和交付经理
当工程师开发功能而交付经理管理这些功能发布的时间表时,常常会出现紧张关系。工程师未能及时交付完整功能是常见的情况。软件架构师可以促进讨论如何分阶段交付功能,同时保持系统的运行。在每个阶段,软件架构师确定对系统质量属性的影响以及用户在此期间如何操作。
这只是软件架构师在完整功能无法及时提供时参与的例子。
监管机构和合规性
软件系统,尤其是在受监管的行业中,必须解决合规性问题。范围很广,可能包括个人数据处理、持久数据的审计或遵守监管程序。
软件架构师不仅参与设计符合规定的架构,还参与说明其实现方式。监管机构将审查技术文件,包括架构图,作为其尽职调查过程的一部分。
安全专业人士
专注于信息安全或网络安全领域的人士与软件架构师在多个领域合作。
他们根据安全政策、程序和指南提供安全需求。这些需求可能包括身份验证、访问控制和甚至加密算法的选择。
软件架构师与安全分析师合作,进行威胁建模和风险评估。他们分析系统架构,识别漏洞和风险,并发现潜在的攻击。威胁的可能性和影响驱动着架构选择。
软件架构师还可能与渗透测试人员或道德黑客合作,以发现安全漏洞和潜在修复方案。
安全架构师与软件架构师合作,确定和选择解决已识别风险和满足安全要求的方法。
利益相关者
利益相关者通常来自组织的多个部门,他们可能对系统如何运行有不同的需求和优先级。软件架构师可以导航这些错综复杂的需求,并确保系统能够按照商定的优先级顺序满足这些需求。
软件架构师还扮演着从多个领域专家和利益相关者中提取通用术语的角色,以便这些术语可以在架构文档中以清晰和明确的方式使用。
平衡适当的架构和预算
虽然一些软件架构师可能热衷于拥有最先进的技术和最新、最快的,但现实中,他们更倾向于与组织能够承担的预算保持平衡。
技术选择上的财务限制并不一定导致糟糕的架构;相反,它们鼓励软件架构师寻找更经济有效的方法来解决问题,并可能导致更精简、更简单的架构。如果两个架构可以解决相同的一组问题,那么更简单、更便宜的那个总是更好的。
是否购买或构建的决定通常受多个因素的影响,技术因素只是其中之一。尽管软件架构师可能没有决定走哪条路的权力,但他们提供技术和运营分析,以便组织可以做出明智的选择。
当组织负担不起最技术先进的服务或系统时,软件架构师会提出折衷方案、权衡利弊和影响分析,以提供“次级解决方案”。起初这可能看起来并不理想,但软件架构师可以设计系统,使其在未来有改进和扩展的空间。
技术演变的愿景和路线图
传统系统是组织仍在使用的过时软件系统。它们之所以成为传统系统,是因为它们的技术改进空间非常有限,并且可能至少落后几年。
有些系统由于外部因素(如技术支持中断和严重限制)而成为传统系统,并且没有可行或成本效益的方法来演进。
传统系统也可能是缺乏技术愿景和路线图的结果,其中软件架构师参与度很高。一些小型初创公司可能没有软件架构师的角色,或者没有人持续倡导软件架构。这些都可能是系统成为传统系统的原因。
然而,软件架构师仍然可以在任何时刻介入,对当前的架构进行现代化改造。他们首先了解当前系统做什么以及组织真正需要什么。然后,他们将系统分解成自主的部分,分别对其进行现代化改造,并以不同的方式重新组合,以便整个技术生态系统再次更新。
通常,技术愿景包括实现具有某些系统质量属性(如高度可用和可扩展的系统)的软件架构的灵感。而技术路线图包括实现短期到中期目标的微小步骤,以及一些对长期目标的更重大变化,它需要细致的计划和思考,以了解系统的演变。此外,技术路线图必须与外部技术演变互动,以便转向和适应更好的替代方案。
技术生态系统中的横切关注点
横切关注点通常是那些需要多个软件组件协同工作以产生预期结果的关注点。
一个例子可以是标准化日志消息,以便它们可以促进跨服务的日志搜索。
工程师通常被分成团队,每个团队负责一定的业务领域。他们不一定有足够的带宽来确保其他团队的服务遵守相同的约定以实现跨领域的成果。
软件架构师以整体的方式处理这些跨领域问题。他们与多个团队进行咨询、参与和讨论,以形成共识或约定,从而使跨领域问题得以解决。
软件架构师还推动共同的基础设施、框架和工具来解决这些跨领域问题。这些问题与系统的质量属性密切相关。
假设有多个服务需要相互通信,并且选择了 REST 端点作为通信方式。然而,如果没有在团队之间建立标准,系统很快就会陷入不一致的 API 集合。URI 资源层次结构可能不一致,错误响应有效载荷也可能不一致。所有这些都会影响系统的可维护性和可重用性。
软件架构师可以参与理解每个团队的需求以及他们对使用 REST 端点的担忧。然后,可以创建 REST 端点的指南,以便工程师遵循一定的模式。一个典型的例子是为错误响应定义一个通用有效载荷结构,以包含除 HTTP 响应状态之外的信息:
{
"resource": "/users/32039/address/0",
"shortMessage": "first line of address must be present",
"longMessage": "A valid address must contain the first line",
"details": {
"addressLine1": null,
"city": "London",
"postCode": "EC12 10ED",
}
}
这个示例有效载荷代表了一个地址输入的错误;它包含诸如resource
、shortMessage
和longMessage
等通用字段,每个服务都可以遵守,同时还有一个details
部分,可以由每个服务自定义。
通过这个标准,我们可以实现这些错误的总体可观察性,并以通用格式持久化,以便审计。工程师可以重用这个结构来减少开发新的 REST 端点所需的时间。即使是由其他工程师开发的 REST 端点,工程师也会发现维护它更容易。
在某种程度上,标准化 REST 错误有效载荷解决了整个技术生态系统中可观察性、可审计性、可维护性和可重用性的跨领域问题。
康威定律
康威定律是一个观察,即组织的系统设计反映了组织结构。计算机程序员梅尔文·康威在 1967 年提出了这个想法,他的原始措辞如下:
“任何设计系统(广义上)的组织都会产生一个结构,其结构与组织的沟通结构相匹配。”
在软件系统的背景下,软件架构反映了组织结构。经典的例子可以通过以下图表说明:
图 1.1 – 按技能集组织的企业
公司有一个后端工程(BE)团队、一个前端工程(FE)团队和一个数据库工程(DE)团队。这种组织根据技能集分组人员。团队中的每个人都负责所有业务功能。这种结构可能产生一个单体系统,通常表现为单个源代码库或一个单一的逻辑过程。
图 1.2 – 按业务功能组织的公司
图 1.2 中的组织根据业务功能分组人员。因此,团队中的每个人都负责一个指定的业务功能,但每个成员可能并不具备相同的技能集。这种结构可能产生一个模块化系统,其中包含多个相互交互的逻辑过程。通常,每个团队都有自己的源代码库。
当团队规模较小时,系统扩展性更好,因为人们相互交流所需的信息通道数量是 n (n – 1) / 2,因此它是指数级扩展的。亚马逊的杰夫·贝索斯提出了他的“两个披萨规则”:
“如果你在会议中不能用两个大披萨喂饱你的团队,那你就麻烦了。”
因此,如果团队不能太大,以至于无法扩展组织和系统,那么通常会有很多团队。这与我们很快将要讨论的架构概念非常吻合。
另一方面,尽管在现代化遗留系统架构方面做出了最大努力,但如果组织结构拒绝与之对齐,那么新的架构最终很可能会回归到其旧的习惯结构。
这是由工程管理和向上解决的事情。这超出了软件架构师可以解决的问题。然而,了解这一现象是值得的,这样问题可以尽快升级。
一些大型组织发现改变他们的结构极其困难。他们甚至创建了初创公司,以现代组织结构和现代软件架构一起运行。
选择软件框架
软件框架(一个 框架),或软件开发框架,是一套标准化的工具,旨在通过一致的方法解决某些问题。
一个软件系统通常需要相当多的框架,这样它们可以专注于业务功能,而不是像日志记录、JSON 转换和配置管理这样的底层关注点。这些框架提供了一种经过验证的方式来实现目标软件架构。选择框架是架构决策的一部分。
现在组织自己构建所有框架的情况很少见。主要原因在于大多数框架都是开源的,并由社区支持。当存在可以免费使用的类似竞争框架时,组织决定开发自己的框架需要很多正当理由。
一些科技公司会在没有现成的框架满足其需求时开发自己的框架。一些公司开发自己的框架是为了与其他框架竞争,并可能从咨询服务业务或交叉销售其他产品中获利。要实现这一点,需要大量的研究努力和人才。
另一个选择是选择市场上已经存在的框架,无论是商业的还是开源的。
新框架悖论
每个月都会发布新的框架,旨在解决现有框架的陈旧问题。通常,市场上有一到两个流行的框架,新的框架会宣传它们通过一种大家都一直想要的方法解决了旧框架。
当然,也有一些真正的范式转变型新框架,使工程师更加高效,并通过创新的方法真正推动了行业的发展。例如,Ruby on Rails将 Web 开发的重复性和样板代码配置转换成了推断和约定,从而大大减少了代码行数。
但也有很多情况是新框架以创新的方法开始,但并没有取得很好的效果。这就是新框架悖论。
如果一个新框架旨在取代存在多年的框架,那么新框架将需要覆盖很多领域,并在每个领域保持“新方法”。这对贡献者来说是一项巨大的任务。
例如,Spring 框架于 2002 年创建,旨在通过使用依赖注入(DI)和控制反转(IOC)来简化代码的依赖关系。但现在,这些框架已经发展到涵盖广泛的特性,如 Web、消息传递、安全、持久性等。要取代 Spring 框架的下一代框架将需要覆盖超过 20 年的发展,对技术领域的覆盖非常全面。
最显著的风险是新框架可能解决了框架长期存在的问题之一,但它却不足以覆盖基本和必要的领域。它将采用新框架的工程师困住,使他们面临是否修复新框架或返回旧框架的困境。
另一个风险是,社区可能无法就“新方法”达成一致,因此,为了解决旧框架的陈旧问题,可能会创建多个新的框架。想要尝试新框架的工程师面临着选择过载的问题。有时,这甚至变成了选择瘫痪,因为没有单一的明确更好的选择可供选择。
假设你的团队已经选择了一个框架,并且每个人都对它非常满意。然而,无论出于什么原因,主要贡献者已经决定不再参与这个项目。那么,你的团队就面临了框架无法及时更新修复和计划改进的风险。更不用说大多数开源框架都是由普通工程师无偿投入个人时间贡献的。
如何比较和决定软件框架
然而,在实际情况下,团队仍然需要选择一些框架来继续前进。一个例子是,为 Java 应用程序记录消息的框架。我们使用随标准Java 开发工具包(JDK)一起提供的Java Logging框架,Apache Log4J,还是Logback?我们如何做出最明智的选择?不幸的是,没有保证最佳选择的黄金法则,但在做出决定之前,团队应该考虑以下几个方面的几个方面。
社区
社区是你在考虑中的最重要的因素。人们是框架被创建、使用和维护的原因。没有人们,框架将无法继续。对于框架来说,至少有三个社区领域需要关注:
-
首先,社区越大,框架就越有可能有人持续支持和改进框架。一个框架应该像是一个有生命的实体,由社区中的人们提供动力。此外,一个框架拥有大社区的原因可能是因为它具有普遍适用性,并且对于一般使用来说是可接受的。
-
其次,我们需要看看社区对框架的支持情况如何。这可能是从另一个用户那里获得如何使用框架的帮助。也可能是社区成员撰写的关于如何将框架应用于问题的技术博客的质量和数量。这可以通过社区为新功能和改进提出的建议来衡量。
-
第三,我们需要看看社区成员之间的沟通方式。他们是否有 Slack 频道、Discord 服务器、电子邮件分发列表,或者任何即时通讯平台?当人们提出问题的时候,社区成员的反应有多快?人们是否乐于并积极接受反馈?
贡献
每次提交到源代码存储库都构成了现在框架的样子。值得检查一些统计数据,以了解框架的维护活动有多积极。
最后一次提交是什么时候?它是最近更新的吗?到目前为止已经提交了多少次?此外,我们可以查看上个月、上六个月或上一年内的提交次数。此外,我们可以查看贡献者的多样性。一个好的迹象是提交是由各种贡献者完成的,而不仅仅是那些常见的贡献者。这表明来自贡献者的多样化和健康增长。
有多少分支和版本?较大的数字通常表明健康的增长,这可能意味着社区的一些成员正在努力进行更改,或者很快可能会有框架的变体。很可能代码库中已经存在一些有用的功能,人们愿意投入精力。
标签的数量表示历史版本,可能对框架的演变和增长提供一些线索。然而,要注意低于 1.0 的版本(例如,0.67),或者仅仅是构建号。在这种情况下,贡献者可能不想承诺长期运行框架,未来可能会有破坏性的更改。
低于 1.0 的版本也可能意味着贡献者可能还没有确认他们承诺长期运行框架。如果你打算在生产系统中放置0.x库依赖项,必须格外小心。如果库停止服务或引入破坏性更改,这将变得困难。
我们还应该查看源代码,并了解代码的质量和测试用例。我们应该快速查看测试覆盖率,以了解代码被测试的深度和广度。这将帮助我们预测框架的可靠性和稳定性。
工具和文档
我们还应该考虑框架是否使用成熟的工具来自我管理。这可能包括一个问题跟踪系统,社区成员可以提交错误并跟踪错误从报告到修复所需的时间。
框架也可能使用一个已建立的持续集成(CI)系统。这也是一个健康、长期运行和成熟的框架的好迹象,因为需要自动化构建来处理提交的数量、控制质量并发布框架。
文档是一个需要考虑的关键因素,因为工程师在这里学习如何使用框架。文档不一定是经过抛光的或自动生成的。内容的质量才是关键。如果图表有助于工程师理解概念,那么它们将是很好的。
与其他框架的互操作性
许多框架被设计成与其他框架协同工作,其中一些框架对其他框架有内在的依赖。这是常见的,并不是一个坏信号;然而,必须谨慎对待其影响。
采用使用或与另一个框架一起工作的框架意味着我们也在间接地采用另一个框架。这个其他框架与团队采取的工程方法兼容吗?我们是否允许团队中的工程师直接在代码中使用传递依赖?
即使我们对其他框架没有问题,我们仍然需要确保版本兼容。例如,框架 A 可能使用了 Apache Commons IO 库,版本 2.14.0,而我们的项目目前使用的是 1.4。将框架 A 导入我们的项目会将版本 2.14.0 作为一个依赖项引入项目。幸运的是,构建框架如 Gradle 和 Maven 提供了一种优雅的方式来显式指定版本并排除特定的版本从传递依赖中。在这个例子中,我们将升级我们对 Apache Commons IO 的依赖,从 1.4 升级到 2.14.0 以使用框架 A。
建立而不是选择框架
工程师可能想要构建自己的框架而不是选择现有的框架。在特定条件下,这样做可能是有益的。
如果软件有现有框架无法满足的独特需求,那么构建定制的框架就是合理的。这可能是一个非常具体的领域,或者它可能有非常严格的非功能性需求。例如,高频交易(HFT)软件的工程师可能编写自己的框架以满足超低延迟的要求。
如果组织将其视为具有尖端技术的市场竞争优势,那么构建定制的专有框架也可能是合理的。
如果之前社区中不存在这样的框架,这也可能是社区中一个新的开源框架的开始。在这种情况下,从社区中聚集工程人才并合作可能是有益的。
如果我们做出了错误的选择怎么办?
尽管我们尽了最大努力,我们可能仍然选择了错误的框架。该框架可能没有实现预期的行为。贡献者可能已经放弃了这个项目。该框架可能采取了一种不再适合我们需求的新颖方法。
错误框架的采用变成了技术债务。不幸的是,我们需要寻找替代框架并计划重构工作以消除这种依赖。
尽管重构技术超出了本书的范围,但并非总是可以避免选择错误的框架。我们所能做的就是在这个过程中进行尽职调查。如果适当的话,我们还可以创建接口,以便只有代码库中的最小类直接引用框架,而框架对其他代码库的使用是透明的。
文档和图表
软件架构作为系统的蓝图,在文档和图表中得到了体现。其中一些可能被记录在配置文件和模板中,但当软件架构师需要展示系统或与利益相关者沟通时,文档和图表仍然是使用最广泛的格式。其中一些图表将在接下来的章节中使用。
业务流程模型与符号
软件系统在高级别上可以看作是自动化的业务流程,这些流程可以通过图表进行可视化。业务流程模型与符号(BPMN)标准化了图形符号,并为建模业务流程提供了一个共同的语言。它通常被工程师和利益相关者用于沟通和文档目的。
以两个家庭就他们交换的服务合同达成协议的例子(服务合同),业务流程可以建模如下:
图 1.3 – BPMN 图例
家庭 A和家庭 B有自己的游泳道来展示各自的过程。家庭 A提交服务合同的草案,家庭 B接收它。家庭 B审查草案并提交其决定。如果家庭 B拒绝服务合同,那么家庭 A和家庭 B的流程都将结束。否则,家庭 B等待家庭 A的回应;同时,家庭 A记录服务合同,流程结束。最后,家庭 B从家庭 A那里收到服务合同并记录服务合同,流程结束。
BPMN 拥有丰富的符号集合来描述业务流程。它们可以分为四组。
流对象——活动、事件和网关
活动可以是业务流程中发生的任务和子流程。事件是已经发生的结果。网关是做出决策或流程分支的点。
连接对象——序列和关联
序列展示了控制流的流程以及流程对象之间的消息传递。关联描述了对象之间的关系,例如输入、输出或依赖关系。
游泳道
游泳道是根据参与业务流程的参与者的角色和责任对流程和连接对象进行分组。
艺术品
艺术品是图表的附加信息,它们提供了上下文,例如涉及的数据对象或简单的自由文本注释。
架构决策记录
软件架构可以看作是从问题发现到解决方案实施的过程。在这个过程中,会做出许多决策以推动系统前进。架构决策记录(ADR)是一份文档,它记录了基于当时情境所做的决策及其带来的后果。
互联网上有许多 ADR 模板,从概念上讲,涵盖了以下部分。
状态
这通常只是一个单词,用来描述过程中 ADR(抽象设计请求)的当前状态。以下是一个 ADR 过程的示例:
图 1.4 – ADR 过程的示例
基本可能的状态是提议的、接受的和拒绝的。在这个例子中,还有其他状态,例如正在审查和需要更改。这因组织而异。
背景
本节应介绍讨论开始的背景。一个好的介绍会将更改的需求带到当前情况,例如,当前运营的痛点、组织结构调整、业务扩张等)。
它还介绍了讨论中使用的术语,以便可以轻松引用而不会产生歧义。一些当前的组织结构和技术基础设施的信息也会有所帮助。
如果适用,本节可以提及当前系统的质量属性以及我们为什么要更改它们。例如,如果我们的系统只能处理 100 个并发登录,而公司希望在新的技术设计中支持 10,000 个,那么可扩展性就是本 ADR 提议要更改的系统属性。
它还应提及期望的结果。这设定了我们希望我们的更改实现的目标状态。这里的动机应参考之前提到的问题,并详细说明结果如何改善业务成果。
决策
本节详细描述了提议的更改。它应侧重于更改如何产生上一节中描述的期望结果。它还可以提及提出的问题以及决策是如何由讨论驱动的。
在某些情况下,会提到替代更改。如果提到了,应该对提议的更改和替代方案进行比较。一种比较的方法是列出每个选项的优缺点。另一种方法可能是将每个选项与一系列因素进行比较,并得出为什么提出该选项的结论。
后果
本节描述了选择提议的更改的影响。它是否改变了团队的操作方式?它会改变哪个系统属性以及如何改变?它是否优化了系统的一个方面但牺牲了另一个方面?系统的哪个部分可能会变得过时?
评论请求
评论请求(RFC)是一系列文档,其中提出了标准、协议、程序和指南,进行了讨论、达成一致并定义。互联网工程任务组(IETF),一个标准开发组织(SDO),通过 RFC 流程定义了众多对互联网具有重大意义的标准,例如互联网协议(IP)版本 4(RFC 791)和 6(RFC 2460),以及超文本传输协议(HTTP)版本 1.1(RFC 2616)。
任何人都可以提交 RFC,任何人都可以对现有的 RFC 进行评论。它们以公开和透明的方式经过迭代审查和反馈过程。它们通常由主题专家发起,但由更广泛的社区维护。RFC 的结果可能是被行业采纳的标准和协议,这对于框架扩展、进一步研究或作为下一个 RFC 的基础都是有用的。
RFC 文档的格式在不同组织之间有所不同。一般来说,文档应涵盖以下部分。
状态
存在几种可能的状态:草稿、收集反馈、接受、拒绝和废弃。
图 1.5 – RFC 流程的示例
一旦 RFC 被起草,它将经过审查和反馈的迭代过程。RFC 在以下情况下退出迭代:被接受、拒绝或废弃。
背景
本节解释提交此 RFC 的需要。可以提供一个示例来说明标准化的需要或缺乏一致协议引起的问题。
方法
本节解释在审查反馈过程之后与社区达成一致的方法。它应该尽可能详细,以捕捉方法的一致意见。
优点和缺点
应该详细说明该方法的优点和缺点,以便社区清楚是否应该进行另一轮反馈收集,或者至少让社区了解该方法的后果。
替代方案
本节提及了任何考虑过和讨论过但未采纳的替代方法。
参考文献
本节包括任何提到的先前 RFC、学术论文或任何其他提供更多讨论背景的材料。
更新日志
由于 RFC 可能引发漫长的审查反馈过程,更新日志有助于按时间顺序记录 RFC 的每个有意义的变化。
RFC 和 ADR
RFC 和 ADR 在文档格式上有很多相似之处,但在使用方面也存在差异。RFC 侧重于大型社区中的行业标准和协议,而 ADR 侧重于组织内部的惯例。RFC 倾向于接近最佳实践,而 ADR 则倾向于接近解决方案和代码。
尽管存在差异,RFC 和 ADR 可以协作工作。对于需要达成共识、预期将进行长时间讨论或具有重大影响的话题,可以先编写 RFC 来就方法达成一致。然后,可以编写 ADR 作为决策的记录和方法的详细技术规范。
UML 图
统一建模语言(UML)自 1994 年以来从不同的建模语言和符号中标准化而来。UML 1.0 于 1997 年被一个名为对象管理组(OMG)的国际标准联盟采纳为标准。
UML 拥有一系列定义良好的软件元素,可以形成各种图表,帮助工程师以结构和视觉方式建模业务问题。共有 14 种 UML 图表,分为两大类。
结构图
结构图表示系统的静态结构。它们关注诸如类、对象、组件和包等元素。它们强调这些元素在系统中的组织和相互连接方式。以下是一个类图的示例:
图 1.6 – UML 类图的示例
类图通常包含类和接口。每个类可以包含一些属性和一些函数。例如,ServiceContract
类有三个属性:agreedDate
、received
和 provided
。该类还有一个函数 isContractConcluded
,返回一个 boolean
值。ServiceContract
类的 received
和 provided
字段引用了另一个类 Service
。我们可以说 ServiceContract
类到 Service
类的多重性是一对二,如图所示。
从业务角度来看,ServiceContract
类是作为 Service
类的两个实例建模的服务交换合同:一个是“接收”服务,另一个是“提供”服务。如果合同是双方同意的,那么 agreedDate
字段应该记录合同达成的时间。
有七个 UML 结构图,每个图都有特定的用途,具体取决于关注的元素:
-
类图,如图所示,描述了类的静态结构,以及它们的属性、函数和与其他类的关联。
-
对象图可视化类在某一时间点的实例及其关系,通常从一个现实生活中的例子出发,以表示系统运行时结构的快照。
-
包图显示了类和组件如何组织到包中,以及包之间的关系。
-
组件图表示构成系统的较高层次的逻辑或物理组件及其关系。
-
部署图描述了软件组件在硬件基础设施上的物理部署以及它们与其他物理节点之间的连接。
-
组合结构图描述了一个类或组件的内部结构,重点关注内部字段和函数之间的协作。
-
配置文件图是可扩展和自定义的图表,它结合了其他 UML 图表。它们促进了 UML 语言扩展到特定领域。
行为图
行为图表示系统的动态交互。它们包括参与者、消息、活动、状态和转换等元素。这些图的关键重点是系统行为如何从控制流、交互或状态转换中产生。以下是一个状态图的示例:
,
图 1.7 – UML 状态机图的示例
此状态机图描述了两个家庭之间服务合同的生命周期。它始于一个家庭起草的服务合同的提交。然后,在服务合同处于审查期间,任一家族都可以修改服务合同,直到它被拒绝或双方同意。之后,服务合同仍然可以被撤回。否则,涉及的家族将行使合同中的服务,直到双方都行使了服务,服务合同达到生命周期的终点。
有七个 UML 行为图。技术上可以使用它们中的任何一个来描述相同系统的行为。区别在于图中展示的行为方面:
-
状态机图,如图所示,模型化系统在每个状态下的不同响应以及状态如何从一个转换到另一个。
-
通信图,也称为协作图,强调对象或组件之间交换的消息。
-
活动图表示系统组件中的业务或操作流程作为序列。
-
交互概览图表示系统组件中的业务或操作流程,重点关注系统组件之间的交互。
-
序列图按时间顺序可视化对象和组件之间交换的消息。
-
时序图在一个时间段内可视化对象和组件之间交换的消息,重点关注时间约束和事件排序。
-
用例图捕捉了参与者与系统之间的交互。参与者可以是用户或外部系统,因此参与者可以通过系统的功能来实现他们的目标。
C4 模型
C4 模型是在 2010 年代开发的一种视觉建模方法。这种方法源于观察许多软件架构图要么缺乏细节(过于高级)要么细节过多(过于低级)。这种方法旨在提供一套指南和约定,以在适当的抽象级别上记录架构。
随着时间的推移,它在希望以简单有效的方式记录其系统的软件架构师和工程师中获得了流行。Structurizr工具由 C4 模型创建者 Simon Brown 开发,允许以代码的形式创建架构模型。
C4 模型可以用地图的隐喻来描述:从街景视图,我们可以看到道路上的行人和汽车,然后放大到看到城市地图,了解城市中主要道路的连接,然后放大到看到国家地图,我们看到国家的主要城市和城镇,然后到世界地图,我们看到地球。
C4 模型有四个抽象级别。每个级别帮助不同的人与所讨论的主题进行沟通和协作,突出显示的主题。在会议或研讨会上提出正确的图示以开始对话是有帮助的。
级别 1 – 系统上下文图
系统上下文图是“整体图”,主要关注“系统”。该图应围绕系统中心,并与参与者、业务操作和外部系统交互。此图对于与非技术利益相关者和外部组织的沟通特别有用。
图 1.8 – 系统上下文图示例(C4 级别 1)
社区服务交换软件是作为每个家庭中独立副本安装的独立软件。软件的副本之间不相互通信。
级别 2 – 容器图
容器图聚焦于“系统”,关注系统内部多个容器如何协同工作。这里的每个容器都指一个可部署的过程,并在系统中拥有自己的角色、责任和边界。
容器图也可以用来说明系统使用的任何中间件或基础设施,例如消息代理、数据存储或文件系统。
此图对于与平台工程师、数据库管理员、网络工程师或安全工程师等技术利益相关者的沟通很有用。
图 1.9 – 容器图示例(C4 级别 2)
社区服务交换软件包含一个模块来组织所有静态内容,如图像和字体。有一个应用程序模块来验证数据和运行检查。应用程序模块使用关系数据库来持久化所需数据。应用程序模块还从关系数据库检索报告数据并将其导出到文件系统。家庭可以从文件系统中读取报告文件。
级别 3 – 组件图
组件图聚焦于容器,提供了不同组件如何构成容器的视图。
它描述了组件的输入(例如,REST 端点、消息消费者或调度器)和组件的输出(例如,事件、对请求的响应等)。
此图的另一个重要功能是展示容器内用于业务操作的逻辑包。它们通常在为业务目的建模的实体多个表示形式上加载、转换、组合和计算功能。
此图更接近软件工程师,因此他们可以理解他们编写代码和脚本的上下文。
图 1.10 – 组件图示例(C4 级别 3)
社区服务交换软件的应用模块有一个服务交换控制器,它操作两个家庭之间的服务合同(即“合同”)。这是从开始到结束管理合同生命周期的业务逻辑。它将合同传递到服务交换存储库,以进行持久化逻辑,例如将合同实体转换为数据库表和列。
另一方面,文件导出控制器响应家庭生成其参与合同的报告的请求。文件导出控制器验证请求并生成文件,使其可在文件系统中可用,而该文件系统位于此应用模块之外。合同部分包含统计数据,计算由服务统计计算器完成。
级别 4 – 代码图
最后,我们来到了抽象层次最低的一层——代码图。这是工程师理解所使用的设计模式和源代码如何在抽象视图中相对于其他源文件表示的微观视图。
我们可以描述组件中建模的实体及其之间的关系。这可以转换为关系数据库模式。
我们可以描述一个涉及多个类的面向对象风格的过程。我们可以展示每个类中捕获的字段以及类如何相互交互。
并非系统每个部分都必须有代码图,因为简单的逻辑可以直接在源代码中表达。通常,代码图用于捕获更复杂的交互,以便工程师在编码时能够留心。这也是您会看到 UML 图的地方。
图 1.11 – 代码图示例(C4 级别 4)
在 ServiceExchangeController
中,有一个名为 Household
的数据类,它包含一个 HouseholdMember
对象的列表。HouseholdMember
数据类模拟了一个具有执行合同技能的家庭成员。
有一个 Service
类,它捕获家庭执行合同的执行细节。它提供了一个 isExecuted
函数,如果其 executedBy
和 executedAt
字段都不为空,则返回 true
的布尔值。
ServiceContract
类模拟了两个 Service
对象之间的服务合同。它记录了双方家庭达成合同的具体日期。合同的接收者和提供者都是一个 Service
对象。它提供了一个 isContractConcluded
函数,如果两个对象的 isExecuted
函数的结果都返回 true
,则该函数返回一个布尔值 true
。
摘要
我们通过一个现实世界的例子阐述了软件架构的重要性。我们讨论了软件架构在沟通、培训、预算、定义愿景以及在技术生态系统中解决跨领域关注点中的作用。
我们已经讨论了康威定律以及组织的结构如何影响系统架构。
我们在多个场景中探讨了选择软件框架的话题,并分析了其优缺点。
我们还涵盖了在软件架构中经常使用的文档和图表,例如 ADRs、RFC、UML 图和 C4 模型。
在下一章中,我们将介绍一些推动现代软件架构的基本架构原则。我们将分解和组合多个概念,并通过相同的现实世界例子进行说明。
第二章:软件架构原则
在本章中,我们将介绍软件架构的基本原则。它们对软件架构的每个部分都至关重要。我们应该不断提醒自己这些原则,就像专业钢琴家每天练习音阶一样。
我们将首先介绍描述和查看软件架构的不同方法。然后,我们将介绍一些在后续章节中会引用的重要原则。目的是你可能随时回到这一章,再次思考这些关键概念。
-
视角、维度和品质
-
关注点分离、内聚性和耦合性
-
SOLID 原则
-
YAGNI 和未来证明的架构
-
Demeter 法则
技术需求
你可以在 GitHub 上找到本章使用的所有代码文件:github.com/Packt Publishing/Software-Architecture-with-Kotlin/tree/main/chapter-2
视角、维度和品质
软件系统不是我们可以轻易看到或触摸的物理对象。在它们的根源上,它们是由机器解释的指令。因此,我们需要以其他方式可视化软件系统。
可视化软件系统的主要目的是展示如何解决利益相关者的关注点。通常有一个长长的关注点列表。每个利益相关者通常在多个抽象级别上有多个关注点。不可能在一个视觉表示中从所有角度解决所有关注点。
视图的概念在 20 世纪 70 年代被引入,用于描述软件架构。从那时起,已经有许多努力来规范和标准化描述软件架构的方法。ISO/IEC/IEEE 42010:2022 是目前指定软件架构的标准,通过该标准定义了架构概念、结构和语言。
在一个视图中,只描绘了软件架构的选定视角。选择旨在解决某些利益相关者的关注点。此外,存在多个视图,因此每个视图可以针对特定的关注点和特定的利益相关者。
视图模型是一组视图的集合,其中每个视图都有专门的焦点、目的和可视化语言。
4+1 架构视图模型由 Philippe Kruchten 在 1995 年创建。它包括软件系统的四个视图,以及帮助不同利益相关者从其他利益相关者视角理解软件系统的选定场景。
图 2.1 – 4+1 架构视图模型
在接下来的章节中,我们将从 4+1 架构视图模型的角度逐一查看每个视图。
逻辑视图
逻辑视图关注系统的业务功能及其实现方式。它对技术问题无关紧要。它是一个抽象视图,描述了业务功能如何工作,而不涉及技术术语,使用的是技术和非技术利益相关者都能理解的语言。它适合与非技术利益相关者进行沟通。
在家庭之间服务交换合同状态转换的例子中,逻辑视图如下所示的表达在 UML 状态图中:
图 2.2 – 4+1 架构视图模型 – 逻辑视图
两个家庭之间的服务合同首先由一个家庭起草。合同处于草稿状态。一旦草案完成,家庭将合同提交给另一个家庭进行审查。
在双方家庭达成协议之前,合同可以由双方家庭进行修改。在继续之前,双方家庭必须同意条款。然而,在某些情况下,合同可能被一个家庭拒绝,从而进入拒绝的终端状态。
假设双方家庭都同意了条款,合同仍有可能因其他情况而被撤回。假设合同继续进行,双方家庭随后行使并履行合同的条款。这标志着合同的结束,并结束了其生命周期。
物理视图
物理视图关注可部署的软件组件及其之间的互连。它也被称为部署视图。它适合与系统工程师、平台工程师和基础设施工程师进行沟通。
想象一下,我们在上一章中展示的 ADR 示例被批准了。有一个中心服务来保存服务交换合同的原始记录。物理视图可能看起来如下所示:
图 2.3 – 4+1 架构视图模型 – 物理视图
该软件已被移至基于浏览器的网络应用程序。用户通过网页浏览器作为客户端访问系统,并提交服务请求。负载均衡器是第一个接收这些请求并将它们根据每个实例的负载分配到适当的交换服务实例的组件。任何静态内容,如图像,都是从网络服务器获取的。交换服务处理请求并将结果持久化到关系数据库。
过程视图
过程视图关注系统的实时行为。它通常接近系统的操作,涉及内部员工或其他系统。它在展示涉及并发、性能和可扩展性的问题时很有用。这种视图促进了技术利益相关者之间的沟通。
UML 图,如序列图、交互图、活动图、通信图和时序图,有助于表示过程视图。业务流程建模与符号(BPMN)图在描述系统在业务流程上下文中的行为时也非常有用。
如果服务合同交换系统具有电子邮件通知功能,它可能看起来像下面的图:
图 2.4 – 4+1 架构视图模型 – 流程视图
当家庭 A提交一份草稿合同时,交易所服务验证请求并更新关系数据库中相应的记录。之后,交易所服务调用电子邮件服务向家庭 B发送关于由家庭 A提交的服务合同的电子邮件通知。
在这种情况下,家庭 B通过电子邮件通知服务合同。然后家庭 B向交易所服务提交请求,同意合同条款。随后,交易所服务更新关系数据库中的记录。
开发视图
开发视图专注于软件管理。目标受众是实际在系统中编码的程序员。
该视图侧重于开发环境中软件的静态组织。这包括多个组件如何在不同级别上协作形成一个软件系统,例如源代码包、从高级业务功能到低级实用功能的调用层次结构、类的继承层次结构和软件工件之间的依赖树。该视图的详细信息可以在源代码库中找到。
UML 包和组件图可以用来表示这个视图。一个有组织的源代码库可能会发现这些图是多余的。然而,这些图擅长突出显示包和模块之间的任何依赖问题。
服务合同交换系统中的交易所服务可能用 UML 包图表示,如下面的图所示:
图 2.5 – 4+1 架构视图模型 – 开发视图
交易所服务有一个包含以下内容的业务包:
-
一个控制服务合同工作流程的模块
-
一个掌握家庭记录的模块
-
一个搜索服务合同历史的模块
业务包使用持久化包来执行服务合同和家庭的实际关系数据库操作。
业务包依赖于验证包,该包包含一些用于地址、电子邮件、日期和服务合同的独立验证器。
集成模块负责与其他服务(如电子邮件服务)进行通信。该模块由业务模块用于通知家庭。
场景
在 4+1 架构视图模型中,场景由+1表示,因为只有重要的场景会被选择进行文档记录。它侧重于用户与系统的交互以及系统如何帮助用户在他们的工作流程中。它通常用于与内部和外部用户的沟通。
UML 用例图用于场景。在服务合同交换系统的例子中,选择了服务合同的起草和执行作为用例。
图 2.6 – 4+1 架构视图模型 – 场景
家庭 A提交一份待家庭 B审查的合同。在达成一致之前,合同可以进行修改。一旦家庭就合同条款达成一致,他们就会执行服务合同直至完成。
我们已经讨论了 4+1 架构视图模型,该模型从至少五个角度可视化系统的架构。每个角度都使用不同的维度来突出显示一次的某些方面。每个视图都有其自身的受众,在沟通时非常有用。
接下来,我们将探讨系统质量属性,这些属性可以用来描述、衡量和预测系统性能的好坏。
系统质量属性
如果有两个功能上完全相同的系统,我们如何比较和评估哪个系统表现更好?我们可以使用系统质量属性来衡量和比较这两个系统。
系统质量属性独立于系统提供的业务功能存在。它们是纯粹的技术属性,决定了系统是否能够平稳运行。要求具有某些系统质量属性被称为非功能性需求(NFRs)或跨功能需求(CFRs)。
区分功能需求和 NFRs(非功能性需求)很重要。功能需求定义了系统做什么,而 NFRs 定义了如何满足这些需求。功能需求指定了系统的功能,而 NFRs 定义了其质量、行为和性能特征。
系统质量属性有许多维度。在这里,我们将突出一些将在后续章节中涵盖的系统质量属性:
-
正确性:这衡量系统是否按规格描述的行为。这可以被称为应用程序编程接口(API)文档、操作手册,或者简单地称为功能合同。
-
可用性:这衡量的是系统的正常运行时间或它能够运行并执行其目的的时间。
-
鲁棒性:这指的是系统在存在故障部分和/或面临重压时可以提供的服务水平。
-
弹性:这指的是系统从故障中恢复、继续运行并从意外中断中恢复到完全功能状态的速度。
-
性能:这衡量了系统响应请求的速度以及系统一次可以处理多少请求。
-
可伸缩性:这衡量了系统如何灵活地扩展以应对变化的数据量和延迟要求。这是系统扩展或缩小的能力。
-
可观察性:这指的是系统的外部视图如何帮助我们确定其内部状态。外部视图可以由日志消息、图表、警报、文件或系统远程调用的有效载荷表示。
-
可管理性:这衡量了在操作、监控、配置和管理方面管理和控制系统的容易程度。
-
可维护性:这衡量了维护一个运行系统有多容易。这可能包括源代码、更新基础设施、修改构建管道和调整环境。
-
可扩展性:这衡量了随着业务的增长,扩展当前系统功能以包括新功能的容易程度。它包括修改和增强当前系统所需的时间、复杂性和努力。
-
可测试性:这衡量了测试可以覆盖系统功能程度的范围。它不仅限于要测试的业务功能,还包括对其他系统质量属性的评估。
-
可重用性:这指的是系统的各个组件可以用于其他目的的程度。这不仅包括软件模块和源代码,还包括业务功能和流程。
-
可用性:这转化为与系统一起工作时用户体验有多好。它不仅限于将系统视为黑盒的最终用户,还包括其他利益相关者,如内部用户、系统操作员、管理员和程序员。
我们已经介绍了一些重要的系统质量属性,这些属性将在后面的章节中提到。我们将讨论推动现代软件架构和帮助我们改进现有系统的基本架构原则。
关注点分离、内聚性和耦合
一个系统由许多组件及其相互之间的连接组成。在系统中分组和分离元素的能力已成为确保其可维护性、可重用性和可扩展性的关键因素。我们将介绍三个基本概念,这些概念将帮助我们适当地分组和分离元素,即关注点分离、内聚性和耦合。
关注点分离
关注点分离是一个我们应该在系统每个角落应用的基本原则。这是一个主张将系统分割成独立的组件,并让每个组件处理特定关注点的原则。关注点分离旨在创建一个更容易维护、推理和更新,并且能够适应随时间变化的需求的系统。
让我们以以下场景为例:从家庭的浏览器提交草拟服务交换合同到交换服务,通过表示状态转移(REST)进行,有效载荷以JavaScript 对象表示法(JSON)格式。
首要关注点可能是确保交换服务只获取正确信息以在关系数据库中创建草拟服务。
在正确性类别中,存在多个级别:
-
请求有效载荷需要符合约定的结构;否则,应返回 HTTP
400
(错误请求)状态。 -
要交换的服务不能为空;否则,应返回 HTTP
400
(错误请求)状态。 -
合同中的家庭必须在系统记录中已存在;否则,应返回 HTTP
404
(未找到)状态。 -
提交草拟合同的该家庭必须已经登录;否则,应返回 HTTP
401
(未授权)状态。 -
提交草拟合同的该家庭必须已经经过验证;否则,应返回 HTTP
403
(禁止)状态。 -
如果请求通过所有验证检查并且合同持续存在,则应返回带有持久化记录的有效载荷的 HTTP
200
(OK)状态。 -
如果请求通过所有验证并且合同持续存在,则合同应处于草拟状态。
列表还在继续。然而,我们可以看到,例如,有效载荷在句法层面。另一方面,返回的 HTTP 状态在合同通信层面,而处于正确状态是在语义层面。
如果对系统某一部分的一个关注点的更改没有影响到其他关注点,那么这个更改与关注点分离原则是一致的。
要在修改系统时分离关注点,系统的一些部分需要分组在一起,而其他部分则需要分离。
一个组件内软件元素之间紧密相关的程度可以通过内聚性来衡量。每个组件的独立程度可以通过耦合度来衡量。
内聚性
与彼此紧密相关的软件组件具有一些属性。首先,它们承担相似的责任,并且很可能有一个共同的目标。其次,如果你更改一个组件,其余的组件可能也需要更改。最后,它们至少以一种方式协调和合作。链式函数调用是内聚组件的例子。
软件组件可以通过以下方式实现内聚。
水平内聚
水平内聚组件被分组在一起,以提供与特定实现相关的功能。例如,与特定外部系统集成相关的组件被分组在一起,以将特定供应商的实现关注点与系统提供的功能行为分开。在审查供应商选择的情况下,工程师可以用针对不同供应商系统的另一组组件替换这个组件组。这种内聚方法支持易于维护和理解的即插即用结构。
垂直内聚
垂直内聚组件根据它们共同提供的功能行为进行分组。垂直内聚通常涉及对组内组件共享的数据和行为进行封装。例如,负责为管理家庭提供创建、读取、更新、删除(CRUD)操作的组件可以分组在一起。这种分组清晰地说明了系统的行为。当必须修改行为时,工程师可以专注于一个小区域,从而降低更改的风险。
线性内聚
线性内聚组件由于执行顺序或控制流的顺序而被分组在一起。这可以通过函数调用的链或事件通信的连锁反应来体现。这种分组为较小的任务或过程如何融入更大的工作流程提供了清晰性和可见性。
交互内聚
交互内聚组件根据它们之间通信和交互模式的频率进行分组。例如,某个函数的 HTTP 服务和客户端库被分组在具有不同模块的项目下。当通信协议发生变化时,工程师可以在一个项目中找到大部分必要的更改。这减少了更改的成本和努力。同时,它也确保更改被限制在一个区域内。
以下是一个家庭验证的示例:
data class Household(
val name: String,
val members: List<Person>,
)
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
val skills: List<String>,
)
fun Household.validate(): List<String> =
listOfNotNull(
if (name.isBlank()) "name must not be empty" else null,
) + members.flatMap { it.validate() }
fun Person.validate(): List<String> = listOfNotNull(
if (firstName.isBlank()) "first name must be non-empty" else null,
if (lastName.isBlank()) "last name must be non-empty" else null,
if (age < 0) "age must be non-negative" else null,
)
一个家庭有一个名称和成员列表。每个成员都有一些基本字段,如名和姓。基本的家庭验证将涉及确保家庭名称不为空;此外,还需要确保家庭中的每个人都能通过验证。例如,年龄不能是负数。
在这个例子中,一个家庭的验证依赖于该家庭中一个人的验证。验证家庭和个人的功能是垂直内聚的,因为它们以高阶函数的形式作为验证功能。它们也是线性内聚的,因为家庭的验证函数调用了个人的验证函数。
高内聚性将相关组件集中在一个地方。这说明了组件的整体行为,使得行为更容易理解。它还促进了更好的可测试性,因为工程师可以在一个地方测试组件的整体行为。将相关组件保持在同一个地方也意味着在做出更改时移动部件更少,这降低了风险。整体行为可以被视为一个单元,系统的其他部分可以重用它。
另一方面,低内聚性导致相关组件散布在各个地方,使得它们难以理解、修改和维护。它会在组件之间创建不适当的耦合,使得在不影响系统其他无关部分的情况下进行更改变得更加危险。试图修改一个系统的质量属性可能会意外地改变其他属性。在做出更改时,也更有可能创建错误。由于它带来了不太可能适合用例的依赖,因此修改组件变得难以重用。
耦合
在内聚性的背景下,耦合是一个坏主意,但耦合的测量却一点也不坏。它提供了系统中的软件组件之间相互依赖性的视图。
耦合是不可避免的,但可以通过一种促进更好的可维护性、可重用性和理解性的方式来最小化。尽可能限制组件之间的交互是更好的选择。
功能耦合
为了限制组件之间的交互,我们必须区分需要做什么和如何做。在先前的例子中,“需要做什么”是验证包括其成员的家庭;“如何做”是验证函数通过每个字段运行,并将违反的条件添加到要返回的列表中。如果返回的列表为空,则家庭是有效的。
软件组件执行的工作的定义是提供者和调用者之间的合同。它通常包括一个输入、可选状态和一个输出。调用者关心提供输入和接收输出,但不关心软件组件内部发生的事情。另一方面,提供者关心如何根据给定的输入计算输出,并可选地更新状态。
这份合同通常被称为提供者和调用者之间的接口。它为松散耦合的软件组件提供了一个良好的基础。当双方的交互都围绕接口进行时,调用者现在可以灵活地切换到另一个能够履行合同的提供者。提供者无需为想要重用此功能的另一个调用者做任何额外的工作。
这种耦合类型被称为功能耦合。它基于软件组件之间的合同或接口。
数据耦合
软件组件可能需要与另一个组件共享的数据才能正常工作。数据所有权是一个独立的话题。然而,从简单意义上讲,如果只有一个组件创建、更新或删除一类数据,那么该组件就拥有这些数据。
几乎可以肯定,如果多个组件拥有相同的数据,那么在保持数据可靠性和一致性方面将出现重大问题。在这种情况下,两个组件真正拥有自己的数据会更好。
有几个选项可以考虑:
-
是否应该将共享相同数据的组件合并为一个组件?
-
数据的所有权是否可以分散到各个组件中,使它们不再共享相同的数据?
-
是否可以提取一个新的组件来拥有数据,而其他组件成为数据变化的监听者?
-
是否应该由一个组件承担创建、更新和删除数据的全部责任,从而成为数据的所有者?这将意味着其他组件成为数据变化的监听者。
-
是否每个组件都应该拥有数据的副本,并让每个副本在每个组件中都是多样化的?
-
在有差异处理流程的情况下,每个组件是否可以保留另一个组件拥有的数据的独立副本?
由于软件组件可以拥有数据,因此对于组件来说,决定应该对外暴露多少数据非常重要。组件应该封装数据以隐藏任何内部数据,并且只向其他组件暴露与接口相关的字段。
此外,通常很有必要将数据的内部和外部表示分开。例如,一个包含内部错误枚举值的列表可能包含无意中暴露组件内部状态的信息。
例如,假设有一个登录操作有多个内部错误枚举值,那么 Kotlin 枚举类将如下所示:
enum class InternalError {
WRONG_PASSWORD,
USERNAME_NOT_FOUND,
FAILED_CAPTCHA,
TIMED_OUT,
INVALID_REQUEST
}
如果我们公开所有这些值,组件的调用者将能够理解即使登录操作失败,用户名是否存在,或者两者都有效但验证码挑战失败。这些对于组件的外部来说是不必要的细节,应该被隐藏。这还不包括显然的安全问题。因此,我们可以创建一个外部错误枚举值列表,只显示对调用者感兴趣的内容:
enum class ExternalError {
FAILED_AUTHENTICATION,
TIMED_OUT,
INVALID_REQUEST
}
时间耦合
如果两个软件组件都需要在整个操作过程中都可用,它们就可以在时间上耦合。例如,组件 A 可能需要调用组件 B 的同步远程调用来继续处理其传入的请求。更糟糕的情况是多个组件之间的多个远程级联调用链。以下 UML 时序图中描述了时间耦合组件的例子:
图 2.7 – 同步拉取交互
每次进行同步调用时,它都会阻塞线程,直到返回答案或超时。这也被称为基于拉取的方法。如果我们有多个这样的调用,很容易导致原始请求超时错误。
同步远程调用具有阻塞线程、拉取数据和依赖于组件可用性的特性。在某些情况下,由于时间敏感性,同步远程调用可能是必要的,例如当服务必须在给定秒数或超时时间内验证用户时。
如果所有的同步远程调用只是为了提供数据,那么我们可以考虑异步和基于推送的方法。使用这种方法,组件 A 订阅了一个主题,当数据发生变化时,组件 B 会发布一个事件。然后组件 A 在本地保留数据的副本。这样,组件 A 就不再需要组件 B 可用来处理请求;组件 A 使用最后已知的值。组件之间异步推送交互的例子如图 图 2.8 所示:
图 2.8 – 异步推送交互
如果组件 A 和 B 之间的交互更程序化和顺序化,那么通过使用事件进行反应性和异步操作,组件仍然可以松散耦合。在这种情况下,组件 A 已经处理到需要组件 B 的点,因此组件 A 发送一个事件,组件 B 接收它。然后组件 B 执行工作。当组件 B 完成其部分时,它发送一个事件,组件 A 接收。然后组件 A 继续剩余的工作。组件之间异步反应事件驱动的交互可以通过以下 UML 时序图表示:
图 2.9 – 异步反应式事件驱动交互
我们已经讨论了关注点分离、内聚性和耦合性的原则。我们探讨了它们为什么重要以及这些概念的应用如何影响系统质量属性。我们还通过实时世界的实例介绍了内聚性和耦合性的子类别。它们是三个不同的概念,但常常相互关联。
接下来,我们将介绍SOLID 原则。这些原则鼓励具有分离关注点、高度内聚组件和组件之间低耦合的软件架构。
SOLID 原则
SOLID 原则是一组五个软件架构原则,主张创建可维护、可理解、灵活和模块化的软件。尽管它们最初是针对面向对象软件的,但它们背后的概念是有用的,并且可以应用于除面向对象以外的系统。
这些概念最初由 Robert J. Martin 在 2000 年的论文《设计原则和设计模式》中提出。然而,SOLID 缩写是由 Michael Feathers 后来引入的。
缩写 SOLID 代表以下内容:
-
单一职责 原则 (SRP)
-
开闭 原则 (OCP)
-
里氏替换 原则 (LSP)
-
接口隔离 原则 (ISP)
-
依赖倒置 原则 (DIP)
我们将逐一介绍它们,并使用我们已讨论的概念进行讨论。
SRP
SRP 指出,一个类应该有一个责任或关注点,只有一个。只有一个变化的原因。如果一个符合这个原则的类有一个清晰和明确的目的,那么它更容易理解、测试和维护,这可以被视为一个例外。
让我们看看一个违反 SRP 的类:
interface HouseholdService {
fun create(household: Household): Household
fun draftContract(contract: Contract)
fun notifyHouseholds(contract: Contract)
}
HouseholdService
接口负责创建家庭、起草合同以及通知参与合同的家庭。这个类可能会因为这些操作中的任何一个发生变化而改变。
我们可以将示例重构以符合 SRP:
interface HouseholdService {
fun create(household: Household): Household
}
interface ContractService {
fun draftContract(contract: Contract)
}
interface NotificationService {
fun notifyHouseholds(contract: Contract)
}
现在HouseholdService
只负责管理家庭。ContractService
接口仅负责起草合同。NotificationService
仅旨在通知参与合同的家庭。现在每个类只有一个责任和一个变化的原因。
这种变化带来了一些额外的益处。现在每个类都更容易测试、更容易理解和维护。每个类可以独立地发展和扩展其功能。单个类中的一个关注点可以解决,而不会影响其他类。其他类也可以轻松地重用这些类中的一个,而不会引入一些永远不会使用的功能。
OCP
OCP 声明,软件组件如类、模块和函数应该是可扩展的,但对修改是封闭的。
一个组件是开放的,如果其行为可以在不修改现有代码的情况下进行扩展。一个高度内聚的组件已经包含了所有紧密相关的元素,因此行为的扩展只需要使用已经提供的内容,而不需要修改内部的代码。
一个组件是封闭的,如果它可以被其他组件使用。我们应该能够修改实现而不改变行为。一个松耦合的组件提供了一个简单直接的方式,使其可以被其他组件使用,而不会引入其他可能不希望包含的依赖项。
在以下示例中,存在一个NotificationService
,它会通知涉及合同的住户合同的状态:
interface NotificationService {
fun notifyHouseholds(contract: Contract)
}
class SmsNotificationService : NotificationService {
override fun notifyHouseholds(contract: Contract) {
// send SMS messages to household's phone numbers
}
}
class EmailNotificationService : NotificationService {
override fun notifyHouseholds(contract: Contract) {
// send messages to household's email addresses
}
}
我们有一个定义了notifyHouseholds(contract: Contract)
函数的接口。SmsNotificationService
和EmailNotificationService
是这个接口的具体实现。
如果我们想要通过添加新的通信方式来扩展行为,例如电话应用通知,我们可以创建一个新的具体实现,而不需要修改任何现有代码。此接口对扩展是开放的。
我们可以在其他需要通知住户关于其合同的情况中重用此接口。我们可以更新EmailNotificationService
与简单邮件传输协议(SMTP)服务器的认证方式,而不改变接口。此接口对修改是封闭的。
LSP
LSP 声明,超类中的对象可以被其子类的对象替换,而不会改变系统的修正。
换句话说,所有子类都应该在功能上与其超类行为相同。
从前面的示例中,NotificationService
指定了notifyHouseholds
函数将通知给定合同中的住户。从行为函数的角度来看,它可以通过电子邮件、短信或任何其他通信方式来完成。然而,所有这些子类都应该通知给定合同中的住户。
相反,如果存在一个PhoneNotificationService
,它不仅通知参与合同的住户,还将合同状态更新为UNDER_REVIEW
,这将违反 LSP。这是因为如果用PhoneNotificationService
替换EmailNotificationService
,合同状态将被更新,而这种情况在替换之前是不会发生的:
class PhoneNotificationService : NotificationService {
override fun notifyHouseholds(contract: Contract) {
// ring an automated message to household's phone
// also update contract status to UNDER_REVIEW
}
}
如果我们遵守 LSP,那么我们将拥有高度内聚的类,因为它只关注类在行为上与其超类等效,而不关注其他方面。
ISP
ISP 声明,客户端不应该被迫依赖于他们不使用的接口。
这个原则提倡接口应该针对使用它们的客户端的需求进行设计。这可能导致以下几种结果:
-
小而多的接口:接口很小,因此客户端不需要依赖于他们未使用的接口中的功能。由于每个接口都较小,因此可能存在更多的接口来覆盖相同的功能范围。
-
相关性:接口是相关且针对客户端需求的。不同的客户端可能有独特的需求。因此,可能存在几个特定于不同客户端的特定接口。可能存在功能重叠,并且一些客户端可能希望有独特的行为,这些行为只与他们相关。
-
更高的内聚性:每个接口应只包含相关的功能,并且理想情况下专注于一个职责。
-
松耦合:由于接口较小,与其他组件的耦合也更松。
这是一个违反 ISP 的例子:
interface Human {
fun logOn()
fun exerciseContract()
}
data class User(val username: String) : Human {
override fun logOn() {
// user log on
}
override fun exerciseContract() {
throw UnsupportedOperationException("user cannot exercise contract")
}
}
data class HouseholdMember(val name: String) : Human {
override fun logOn() {
throw UnsupportedOperationException("household members do not log on")
}
override fun exerciseContract() {
// exercise contract
}
}
Human
接口有两个功能:logOn
和exerciseContract
。有两个具体的实现:User
和HouseholdMember
。虽然用户和家庭成员都是人类,但在功能上他们没有任何共同点。这两个子类被迫实现了他们不需要的功能。接口应该被分离,以便它们专门针对用户和家庭成员的功能。
DIP
DIP 包含两个部分:
-
高级组件不应该直接依赖于低级组件。两者都应依赖于抽象。
-
抽象不应当依赖于细节;细节应当依赖于抽象。
抽象可以指代接口,而细节可以指代具体的实现。这个原则背后的逻辑是,组件的使用者应该只关心组件的行为,而不是其实现。
例如,如果我们想在工作流中重用通知参与合同的家庭的组件,那么这个原则建议我们将该组件引用为NotificationService
类型,即使我们知道EmailNotificationService
是具体的实现。
这种方法带来了一些好处:
-
NotificationService
接口可以被模拟或替换为EmailNotificationService
。我们的单元测试关注的是如何与NotificationService
交互,处理notifyHouseholds
函数的几种类型的输出。 -
对
NotificationService
的依赖可以在运行时注入或查找。这给了我们灵活性,可以将其与不同的具体实现交换,并且功能仍然可以正确运行。一个控制反转(IoC)容器框架可以支持注入的实现。 -
与
NotificationService
的耦合被放松,只关注行为。 -
由于交换
NotificationService
的具体实现不需要代码更改,代码更容易维护。 -
通过提供不同的具体实现,更容易扩展
NotificationService
。
让我们看看接下来的例子,看看它是否遵循 DIP:
class ContractWorkflowService(
val emailNotificationService: EmailNotificationService,
) {
fun agree(contract: Contract): Contract {
return contract.copy(agreedAt = Instant.now()).also {
emailNotificationService.notifyHouseholds(contract)
}
}
}
interface NotificationService {
fun notifyHouseholds(contract: Contract)
}
class EmailNotificationService : NotificationService {
override fun notifyHouseholds(contract: Contract) {
// send messages to household's email addresses
}
}
ContractWorkflowService
有一个标记合同为agreed
的函数。合同达成一致后,它调用EmailNotificationService
通知家庭关于协议。然而,ContractWorkflowService
使用接口NotificationService
的函数,而不是子类。
没有必要让ContractWorkflowService
引用具体实现,因为该服务不关心通知是通过电子邮件还是其他渠道发送。这是违反 DIP 的。
迪米特法则
迪米特法则,或最小知识原则,指出软件组件应该对其他组件的内部细节了解有限。更具体地说,一个组件不应该了解另一个组件的内部细节。
假设有一个函数返回人的地址中的城市:
class Person(val name: String, val address: Address) {
fun getAddressCity(): String {
return address.city
}
}
class Address(val city: String)
Person
类直接访问Address
类内部的city
属性。Person
类不应该拥有这种知识,因为这违反了迪米特法则。
这在Person
和Address
之间创建了一种耦合。耦合规定,如果city
属性的数据类型发生变化,那么两个类都需要更改。这也意味着代码更改比必要的更大,因此使其更难以维护和测试。
为了符合迪米特法则,getAddressCity
函数应由Address
类提供。这减少了Person
类获取地址城市的责任,从而降低了代码库的复杂性。
YAGNI 和未来证明架构
YAGNI(你不会需要它)是一个原则,它指出功能不应该在需要之前实现。这个原则来源于极限编程(XP)作为提高软件质量和应对不断变化业务需求的方法。
这个原则也与软件开发中的极简主义思想相关,它指出我们应该避免不必要的代码和复杂性,以换取干净、易于理解且可扩展的软件。
另一种描述 YAGNI 的方式是作为执行最简单可行事情的命令。这绝对不是不完整的设计或未满足的用户需求。它仍然促进完整且功能齐全的软件,以最简单的可用设计满足用户需求。
YAGNI 旨在以下实践:
-
简单且精简的代码库:通过只实现当前必要的功能,可以避免很多复杂性。因此,代码库简单、干净且易于维护。
-
过度工程预防:过度工程发生在工程师预测未来需求并在系统中包含未使用的功能时。这不仅导致在非必要工作上浪费了时间,而且成为代码库中的累赘,使得维护变得更加困难。过度设计的代码还意味着在做出选择之前,没有足够的信息来做出那个选择,过早地锁定了一种方法。
-
自适应和灵活的实施:通过推迟功能的实施直到需要时,工程师在功能最终需要时有了更多的选择来适应变化。这也鼓励系统进行更有机的进化,工程师能够更有效地响应不断变化的需求。
-
生产力:通过关注绝对必需的需求,工程师可以更快、更有效地交付变更。任何不必要的功能都可以推迟,节省时间和资源。
虽然也有相反的观点:未来证明的架构。它旨在创建不太可能在未来过时或失败的系统。这听起来非常吸引人。如果我们能够构建一个能够满足未来需求系统,我们将节省那些本可以用来不断进化系统的宝贵时间和精力。
然而,这背后有一个假设。你需要预测新的需求,并且你需要预测正确。这相当于知道未来。这种情况很少发生。
如果你确信将来需要某个功能,那么这既不是预测也不是未来的需求。这只是一个现在的需求。
这并不意味着我们应该基于短期目标或走捷径来构建系统。相反,我们应该构建能够适应新需求的系统,但不要立即实现这些需求。
容量规划不应被误解为未来证明的架构。容量规划是与部署和物理资源相关的运营问题。例如,建造一条可以处理当前交通量两倍的道路与建造一条没有去处的道路分支不同。为扩展、额外容量和额外交通留出空间是进化准备的一部分。容量规划是一个非功能性需求(NFR),而不是未来的需求。我们不希望系统在可能因请求量波动而崩溃的边缘运行。
这种心态导致了一些结果。软件架构旨在实现模块化、可扩展和灵活的组件,这些组件在必要时可以做出改变。
这意味着每个组件具有高度的内聚性但耦合性较弱。这意味着接口小且具体。这也意味着组件之间的交互基于抽象接口而不是具体实现。进一步来说,这意味着子类符合其超类的行为,并准备好进行扩展。此外,这意味着每个组件只有一个改变的理由。这也意味着修改组件不需要重新编译整个系统。这也意味着关注点被分离,这样当我们想要调整系统质量属性时,我们可以单独处理特定的关注点。
摘要
我们介绍了 4+1 架构视图模型,以及系统质量属性、关注点分离、内聚性、耦合性、SOLID 原则和迪米特法则。我们通过代码示例演示了这些原则。这些示例表明,遵循这些原则可以使代码变得模块化、灵活、易于维护、可扩展且易于理解。
我们还讨论了 YAGNI 原则和未来兼容架构的冲突概念。我们阐明了什么是未来兼容性以及它与容量规划的差异。
在下一章中,我们将探讨多态及其替代方法。
第三章:多态性和替代方案
在本章中,我们将通过实现各种不同的解决方案来解决一个真实生活中的例子问题。我们将使用多态性作为解决方案的基础。之后,我们将使用由 Kotlin 语言支持的其它方法。最后,我们将比较它们,并尝试理解在什么情况下哪种方法更合适。
在本章中,我们将涵盖以下主题:
-
为什么选择 Kotlin?
-
生活中的例子——重访
-
多态性解决方案
-
密封类解决方案
-
委派解决方案
-
函数式解决方案
-
比较和总结
技术要求
你可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-3
为什么选择 Kotlin?
Kotlin 被选为本书所有例子的主要编程语言。选择这个语言有几个原因:
-
自从 2011 年发布以来,Kotlin 已经获得了显著的人气
-
它可以用于前端和后端应用程序,这使得可以使用相同的语言来展示广泛的架构主题
-
它具有简洁、易读和表达性强的语法,这有助于我们理解实现,而无需深入了解语言
-
它与 Java 兼容,因此如果适用,代码示例可以利用 Java 和 Kotlin 中现有的众多库
生活中的例子——重访
我们使用与第一章和第二章相同的真实生活例子。这个例子如下。在一个村庄里,家庭之间互相提供服务,并从其他家庭那里接受服务。为了使家庭之间服务交换的记录更加清晰,创建了一个软件来记录交换服务的合同(即“合同”)。
在双方家庭在合同中同意交换的服务后,每个家庭都需要提供服务。以下是一些例子:
-
修理家具
-
制作连衣裙
-
照顾x小时的小孩
-
在聚会上表演魔术
-
捐赠一件二手衣物
-
提供食物和饮料
-
清理房屋中的n个房间
我们需要能够捕捉这些服务的细节,并能够验证服务是否已经完成。一旦合同中的双方都完成了服务,合同就结束了,达到了其最终状态。让我们专注于一个场景,其中家庭 A 为家庭 B 提供服务,而家庭 B 确认家庭 A 按照合同完成了服务。交互的顺序可以简要描述如下 UML 序列图:
图 3.1 – 重访实时示例
在交换服务中,我们需要提供两个函数。一个函数是为一个家庭为另一个家庭提供服务而提供的。另一个函数是为一个家庭确认另一个家庭是否按照合同执行了服务。问题是,不同的服务类型需要不同的方式来声明和确认完成。
例如,如果服务是修理物品,那么只需要修理家庭确认物品已被修理。接收家庭会确认物品已被修理。如果服务是照看婴儿,那么保姆家庭可能会记录照看时间来确认服务已执行。有时,服务是多个子服务的组合。
至少应该有两个函数来执行服务并检查服务是否已执行:
interface Service {
fun performService(time: Instant)
fun wasServicePerformed(): Boolean
}
对于这个练习,让我们假设我们需要支持三种类型的服务:管道、保姆和房间清洁:
-
管道服务需要管道家庭报告服务开始和完成;然后另一个家庭确认这一点
-
保姆服务需要保姆家庭记录会话的开始,并跟踪它,直到另一个家庭接走婴儿并记录服务的总时长
-
房间清洁服务需要清洁家庭记录服务的开始和清洁的房间,然后另一个家庭确认按照合同所有房间都已清洁
让我们首先用多态方法解决这个问题。
多态解决方案
在面向对象编程中,多态提供了一种强大的方式来抽象多种形式的接口。多态在希腊语中字面意思是多种形式。
家庭的通用接口包含一个名为performService
的函数,用于表示服务的开始,以及一个名为wasServicePerformed
的函数,如果服务按照协议执行,则返回 true:
interface Service {
fun performService(time: Instant)
fun wasServicePerformed(): Boolean
}
这个解决方案可以用以下 UML 类图来表示:
图 3.2 – 多态解决方案
Plumbing
类相对简单。它提供了一个家庭开始服务的函数,一个报告服务完成的函数,以及一个供另一个家庭确认服务已执行的函数。同时,记录了服务执行、完成和确认的时间戳:
class Plumbing : Service {
var startedAt: Instant? = null
var completedAt: Instant? = null
var confirmedAt: Instant? = null
override fun performService(time: Instant) {
startedAt = time
}
fun completeService(time: Instant) {
completedAt = time
}
fun confirmService(time: Instant) {
confirmedAt = time
}
override fun wasServicePerformed(): Boolean {
return startedAt != null && completedAt != null && confirmedAt != null
}
}
Babysitting
类不同,因为完成标准基于服务的持续时间。类的构造函数接受一个协议中约定的小时数来确定服务是否完成。有一个函数用于保姆家庭开始工作,另一个函数用于其他家庭确认工作的结束。如果持续时间与协议小时数相同或更长,则认为服务已完成:
class Babysitting(val agreedHours: Int) : Service {
var startedAt: Instant? = null
var endedAt: Instant? = null
override fun performService(time: Instant) {
startedAt = time
}
fun endService(time: Instant) {
endedAt = time
}
override fun wasServicePerformed(): Boolean {
return if (startedAt == null || endedAt == null) {
false
} else {
Duration.between(startedAt, endedAt).toHours() >= agreedHours
}
}
}
当协议中的所有房间都被清洁时,RoomCleaning
类已执行了服务。构造函数接受一个房间名称的Set
,稍后用于检查协议中的所有房间是否都已清洁。它有一个函数用于清洁工开始工作,另一个函数用于其他家庭确认每个房间是否已被清洁:
class RoomCleaning(val agreedRooms: Set<String>) : Service {
var startedAt: Instant? = null
val roomCleaned: MutableSet<String> = mutableSetOf()
var endedAt: Instant? = null
override fun performService(time: Instant) {
startedAt = time
}
fun cleaned(
time: Instant,
room: String,
) {
roomCleaned.add(room)
if (allAgreedRoomsCleaned()) {
endedAt = time
}
}
private fun allAgreedRoomsCleaned() = roomCleaned.containsAll(agreedRooms)
override fun wasServicePerformed(): Boolean = allAgreedRoomsCleaned()
}
有一个main
函数来让所有这些家庭执行服务,并将结果打印出来:
fun main() {
val now = Instant.now()
val plumbing = Plumbing()
plumbing.performService(now)
plumbing.completeService(now.plus(2, HOURS))
plumbing.confirmService(now.plus(2, HOURS).plus(3, MINUTES))
println("Was plumbing service performed? ${plumbing.wasServicePerformed()}")
val babysitting = Babysitting(3)
babysitting.performService(now)
babysitting.endService(now.plus(3, HOURS))
println("Was babysitting service performed? ${babysitting.wasServicePerformed()}")
val roomCleaning = RoomCleaning(setOf("Kitchen", "Bathroom"))
roomCleaning.performService(now)
roomCleaning.cleaned(now.plus(3, HOURS), "Kitchen")
println("Was room cleaning service performed? ${roomCleaning.wasServicePerformed()}")
}
程序应打印出类似以下内容:
Was plumbing service performed? true
Was babysitting service performed? true
Was room cleaning service performed? false
水暖服务从时间戳开始。然后,在两小时后用时间戳完成。三分钟后,它被确认完成,并附有时间戳。由于存在所有三个时间戳,因此服务已经完成。
在协议中,保姆服务持续时间为三小时。它从时间戳开始,并在三小时后结束,附有时间戳。持续时间正好是三小时。这与协议中的小时数相匹配,因此服务已经完成。
房间清洁服务在协议中列出了厨房和浴室。只有厨房被报告为已清洁,并附有时间戳,因此服务尚未完成。
这种方法产生了一种既统一又多态的风格,并促进了三种具有某些共享行为的不同服务。
密封类解决方案
Kotlin 语言有一个名为密封类的功能,它限制了类层次结构,并要求在编译时定义所有子类。所有子类都需要在定义密封类的同一包和模块中。这也意味着不能从密封类继承第三方类。
以下是之前提到的多态解决方案的一些观察结果。首先,所有子类都有一个startedAt
字段和一个实现performService
函数来设置startedAt
字段的实现。因此,密封类解决方案可以从多态解决方案中修改。接口可以更改为具有startedAt
字段和performService
函数的密封类:
sealed class Service {
var startedAt: Instant? = null
fun performService(time: Instant) {
startedAt = time
}
abstract fun wasServicePerformed(): Boolean
}
可以通过以下方式使用密封类实现来简化子类:
class Plumbing : Service() {
var completedAt: Instant? = null
var confirmedAt: Instant? = null
fun completeService(time: Instant) {
completedAt = time
}
fun confirmService(time: Instant) {
confirmedAt = time
}
override fun wasServicePerformed(): Boolean {
return startedAt != null && completedAt != null && confirmedAt != null
}
}
performService
函数已在Service
超类中实现,因此无需在子类中实现它:
class Babysitting(val agreedHours: Int) : Service() {
var endedAt: Instant? = null
fun endService(time: Instant) {
endedAt = time
}
override fun wasServicePerformed(): Boolean {
return if (startedAt == null || endedAt == null) {
false
} else {
Duration.between(startedAt, endedAt).toHours() >= agreedHours
}
}
}
同样的简化也适用于RoomCleaning
子类:
class RoomCleaning(val agreedRooms: Set<String>) : Service() {
val roomCleaned: MutableSet<String> = mutableSetOf()
var endedAt: Instant? = null
fun cleaned(
time: Instant,
room: String,
) {
roomCleaned.add(room)
if (allAgreedRoomsCleaned()) {
endedAt = time
}
}
private fun allAgreedRoomsCleaned() = roomCleaned.containsAll(agreedRooms)
override fun wasServicePerformed(): Boolean = allAgreedRoomsCleaned()
}
封闭的 Service
类具有 startedAt
字段和 performService
函数,以方便服务的启动,而子类有自己的变体来完成它。
Kotlin 封闭类的强大之处不在于在编译时对所有子类进行限制。其强大之处在于编译器如何处理这种限制。如果我们使用 when
构造与封闭类一起,我们可以将我们的程序简化如下:
sealed class Service {
var startedAt: Instant? = null
fun performService(time: Instant) {
startedAt = time
}
fun wasServicePerformed(): Boolean {
return when (this) {
is Babysitting -> durationCoversAgreedHours()
is Plumbing -> areAllDatesPresent()
is RoomCleaning -> allAgreedRoomsCleaned()
}
}
}
由于 Service
类使用了 when
构造实现 wasServicePerformed
函数,子类根本不需要实现这个函数:
class Plumbing : Service() {
var completedAt: Instant? = null
var confirmedAt: Instant? = null
fun completeService(time: Instant) {
completedAt = time
}
fun confirmService(time: Instant) {
confirmedAt = time
}
internal fun areAllDatesPresent(): Boolean {
return startedAt != null && completedAt != null && confirmedAt != null
}
}
类似于 Plumbing
子类,其他子类现在将只包含以各种形式完成服务相关的函数体:
class Babysitting(val agreedHours: Int) : Service() {
var endedAt: Instant? = null
fun endService(time: Instant) {
endedAt = time
}
internal fun durationCoversAgreedHours(): Boolean {
return if (startedAt == null || endedAt == null) {
false
} else {
Duration.between(startedAt, endedAt).toHours() >= agreedHours
}
}
}
class RoomCleaning(val agreedRooms: Set<String>) : Service() {
val roomCleaned: MutableSet<String> = mutableSetOf()
var endedAt: Instant? = null
fun cleaned(
time: Instant,
room: String,
) {
roomCleaned.add(room)
if (allAgreedRoomsCleaned()) {
endedAt = time
}
}
internal fun allAgreedRoomsCleaned() = roomCleaned.containsAll(agreedRooms)
}
编译器强制要求所有子类都包含在 when
构造的分支中。如果没有,则无法编译。此外,this
实例在每个分支中自动转换为特定的子类(智能转换),因此我们可以直接访问在子类中定义的字段。
在这个实现中,对执行服务的检查的所有变体都被组合在 when
构造中的分支。这比 enum
实现更好,因为我们不会忽略任何子类,因为我们没有使用 else
作为分支。
此外,由于这些是封闭类的子类,所有子类都是已知的,我们可以轻松地在单个函数中比较这些变体。
然而,这种模式仅在存在少量子类且它们具有类似实现时才有用。此外,这种模式不支持在包外部扩展功能。
代理解决方案
代理通常被认为是多态解决方案的替代方案。在这种方法中,通过将部分责任委托给其他类并扩展其行为来实现函数的扩展。因此,没有创建子类的强制性要求。这有几个原因:
-
松散耦合与高度凝聚
-
关注点分离
-
易于替换
-
重构到代理解决方案
松散耦合与高度凝聚
使用代理,代码可以仅针对需要的部分进行重用和组合。这比继承一个可能给子类提供过多功能的类更灵活。这导致与重用代码的耦合更松,同时在类内部保持高度的凝聚性。
关注点分离
使用代理,可以将类分解成只有单一职责的小类(子类)。这些类仅在需要时才进行代理。因此,每个类都有一个清晰的关注点和职责。因此,类更容易维护、测试和理解。
我们还能够摆脱任何与无关实现相关的超类变更,这可能会导致行为上的意外变化。我们只需将无关的实现与真正需要的东西分开。
简单替换
如果我们需要有不同的行为,交换代理对象比交换继承的基类要容易。不仅代理对象中要实现的功能比不同的基类要少,而且还可以在运行时动态地交换代理对象。
只要子类符合Liskov 替换原则(LSP),替换子类时不会有行为变化。
重构到代理解决方案
为了重构到代理解决方案,我们将职责分解成更小的接口。我们将有一个启动服务的接口,另一个检查服务是否已执行的接口:
interface ServiceStarter {
fun start(time: Instant)
}
interface ServiceChecker {
fun wasServicePerformed(): Boolean
}
class Started : ServiceStarter {
var startedAt: Instant? = null
override fun start(time: Instant) {
startedAt = time
}
}
水暖、照看孩子和房间清洁是三种完成方式非常不同的服务。
水暖服务有三个阶段:开始、完成和确认。
图 3.3 – 三阶段服务
它可以表示为一个既是ServiceStarter
又是ServiceChecker
的ThreePhaseService
。然而,我们已经有了一个作为ServiceStarter
实现的Started
具体类,因此我们可以使用 Kotlin 的代理功能来指定ServiceStarter
的实现是通过构造函数中提供的具有Started
默认值的started
字段来实现的:
interface ThreePhaseService : ServiceStarter, ServiceChecker {
fun complete(time: Instant)
fun confirm(time: Instant)
}
class ThreePhaseServiceImpl(val started: Started = Started()) :
ThreePhaseService, ServiceStarter by started {
var completedAt: Instant? = null
var confirmedAt: Instant? = null
override fun complete(time: Instant) {
completedAt = time
}
override fun confirm(time: Instant) {
confirmedAt = time
}
override fun wasServicePerformed(): Boolean {
return started.startedAt != null && completedAt != null && confirmedAt != null
}
}
然后,Plumbing
仅仅是ThreePhaseServiceImpl
的一个特化,我们可以通过 Kotlin 的代理定义为一个单行代码:
class Plumbing : ThreePhaseService by ThreePhaseServiceImpl()
照看孩子的服务只有两个阶段:开始和结束。持续时间,即开始时间和结束时间之间的时间量,决定了服务是否已经执行。这里有一个简单的 UML 状态图来捕捉阶段转换。
图 3.4 – 按小时服务阶段转换
它可以表示为一个HourlyService
。同样,我们可以利用 Kotlin 的代理来避免代码重复:
interface HourlyService : ServiceStarter, ServiceChecker {
fun end(time: Instant)
}
class HourlyServiceImpl(val agreedHours: Int, val started: Started = Started()) :
HourlyService, ServiceStarter by started {
var endedAt: Instant? = null
override fun end(time: Instant) {
endedAt = time
}
override fun wasServicePerformed(): Boolean =
if (started.startedAt == null || endedAt == null) {
false
} else {
Duration.between(started.startedAt, endedAt).toHours() >= agreedHours
}
}
然后,Babysitting
被声明为一个单行代理类:
class Babysitting(agreedHours: Int) : HourlyService by HourlyServiceImpl(agreedHours)
最后,房间清洁服务会循环重复,直到所有约定的项目都已完成。这里有一个简单的 UML 状态图来捕捉阶段转换。
图 3.5 – 项目化服务阶段转换
我们可以将每个房间视为一个在ItemizedService
的名义下单独完成的项目。我们还使用泛型T
类型使其更灵活:
interface ItemizedService<T> : ServiceStarter, ServiceChecker {
fun complete(time: Instant, item: T)
}
class ItemizedServiceImpl<T>(val agreed: Set<T>) : ItemizedService<T>, ServiceStarter by Started() {
val completed: MutableSet<T> = mutableSetOf()
var endedAt: Instant? = null
override fun complete(time: Instant, item: T) {
completed.add(item)
if (allAgreedItemsCleaned()) {
endedAt = time
}
}
private fun allAgreedItemsCleaned() = completed.containsAll(agreed)
override fun wasServicePerformed(): Boolean = allAgreedItemsCleaned()
}
RoomCleaning
现在可以定义如下:
class RoomCleaning(agreedRooms: Set<String>) :
ItemizedService<String> by ItemizedServiceImpl(agreedRooms)
这个代理解决方案的例子可以用以下 UML 类图来表示:
图 3.6 – 代理解决方案
将它们全部放在一起,我们只需要修改一下 main
函数,因为函数名不同。程序的行为方式相同:
fun main() {
val now = Instant.now()
val plumbing = Plumbing()
plumbing.start(now)
plumbing.complete(now.plus(2, HOURS))
plumbing.confirm(now.plus(2, HOURS).plus(3, MINUTES))
println("Was plumbing service performed? ${plumbing.wasServicePerformed()}")
val babysitting = Babysitting(3)
babysitting.start(now)
babysitting.end(now.plus(3, HOURS))
println("Was babysitting service performed? ${babysitting.wasServicePerformed()}")
val roomCleaning = RoomCleaning(setOf("Kitchen", "Bathroom"))
roomCleaning.start(now)
roomCleaning.complete(now.plus(10, MINUTES), "Kitchen")
println("Was room cleaning service performed? ${roomCleaning.wasServicePerformed()}")
}
从这个例子中,我们可以看到代码重用是多么容易。例如,如果有另一个服务需要运行约定的小时数,我们可以通过委托重用 HourlyService
,而无需再次实现相同的逻辑。我们也不需要编写相同的测试。
这种模式减少了代码的重复和测试。它还促使每个类更加具体和专注于其职责。这使得对现有功能的任何扩展都变得更容易,因为它不强制继承一个具体的超类。
函数式解决方案
函数式编程在处理问题时采用了一种完全不同的思维方式。基本元素可以分为不可变数据结构和纯函数。
不可变数据结构
不可变数据结构一旦创建后就不能更改。如果需要新的值来捕捉变化,就需要创建新的数据结构实例,通常是从现有的实例转换而来。这种方法使得数据可靠且线程安全。
Kotlin 免费提供了 toString
、hashCode
和 equals
方法。结合使用 val
关键字和对其他不可变数据的独占引用,我们可以轻松创建不可变数据结构。
这里是示例中等效的数据结构:
data class Plumbing(
val startedAt: Instant? = null,
val completedAt: Instant? = null,
val confirmedAt: Instant? = null,
)
data class Babysitting(
val agreedHours: Int,
val startedAt: Instant? = null,
val endedAt: Instant? = null,
)
data class RoomCleaning(
val agreedRooms: Set<String>,
val startedAt: Instant? = null,
val completed: Set<String> = emptySet(),
val endedAt: Instant? = null,
)
值得注意的是,所有字段都是用 val
声明的,因此引用不能更改。此外,在许多字段中使用的 Instant
类也是不可变的。
Set
接口没有声明可变函数。尽管可以注入一个可变的具体实现,使得数据类不是严格不可变的,但如果只使用接口中声明的函数以及具体实现符合 LSP,则不应该有行为上的变化。
此外,可能在不同实例中具有不同值的字段,要么带有可空声明(?),要么带有默认值。如果调用时未指定,Kotlin 构造函数可以提供这些值。
Kotlin 为数据类提供了一个 copy
函数,以便作为单独的实例进行可变操作。例如,可以通过以下代码启动管道服务:
val plumbing = Plumbing()
val started = plumbing.copy(started = Instant.now())
然而,仅仅使用 copy
函数可能显得太低级,并且不能帮助传达代码的意图。可能更好的是有一个更好的名字的函数,例如 start
,它调用 copy
来使意图明显。
纯函数
当函数在给定相同输入时总是产生相同的输出并且没有副作用时,我们称这些函数为纯函数。为了实现这一点,函数不会修改任何外部数据或状态。它也不使用任何随机化或系统时钟函数。它不会调用任何创建副作用的功能,例如更新数据库或远程调用外部系统。它是确定性的、可预测的、可测试的和线程安全的。
在本章中使用的家庭实际例子中,尽管所有三种服务形式不同,但所有三种服务都需要启动服务。此外,我们希望有一个更好的函数名来传达意图。Kotlin 支持的start
函数可以与多种类型一起工作。此外,我们使用lambda 表达式,以便每种类型都可以指定其创建新值实例的自己的方式。
start
函数看起来可能如下所示:
fun <T> T.start(time: Instant, transform: T.(Instant) -> T): T = transform(time)
它声明了一个通用的T
类型作为函数接收者,因此我们可以以T.start
的风格调用函数,作为T
类型来创建一个具有startAt
时间的T
类型的新实例。这是start
函数调用的一个示例:
Plumbing().start(now) { startedAt -> copy(startedAt = startedAt) }
Plumbing()
创建一个没有startAt
时间的Plumbing
新实例。然后通过提供一个Instant
对象和一个 lambda 表达式来调用start
函数,该 lambda 表达式指定使用copy
函数设置startedAt
字段以创建一个新的Plumbing
实例。
其余的函数
可以单独声明针对服务类型特定的其他函数:
fun Plumbing.complete(time: Instant): Plumbing = copy(completedAt = time)
fun Plumbing.confirm(time: Instant): Plumbing = copy(confirmedAt = time)
fun Babysitting.end(time: Instant): Babysitting = copy(endedAt = time)
fun RoomCleaning.complete(
time: Instant,
room: String,
): RoomCleaning {
val newCleaned = completed + room
val newEnded = if (completed.containsAll(agreedRooms)) time else endedAt
return copy(completed = newCleaned, endedAt = newEnded)
}
注意,使用扩展函数对于前面的函数不是强制的。它们可以声明在其对应的数据类体内。然而,将它们声明为扩展函数确实提供了灵活性,因为它们可以位于数据类不同的包中。
确定服务是否已执行的功能对于每种类型的服务都是不同的。
fun Plumbing.wasServicePerformed(): Boolean = startedAt != null && completedAt != null && confirmedAt != null
fun Babysitting.wasServicePerformed(): Boolean =
if (startedAt == null || endedAt == null) {
false
} else {
Duration.between(startedAt, endedAt).toHours() >= agreedHours
}
fun RoomCleaning.wasServicePerformed(): Boolean = endedAt != null
最后,main
函数与其他解决方案看起来不同,主要是因为在使用main
函数时,对服务的每次更改都会导致一个新的实例:
fun main() {
val now = Instant.now()
val plumbing =
Plumbing()
.start(now) { startedAt -> copy(startedAt = startedAt) }
.complete(now.plus(2, HOURS))
.confirm(now.plus(2, HOURS).plus(3, MINUTES))
println("Was plumbing service performed? ${plumbing.wasServicePerformed()}")
val babysitting =
Babysitting(3)
.start(now) { startedAt -> copy(startedAt = startedAt) }
.end(now.plus(3, HOURS))
println("Was babysitting service performed? ${babysitting.wasServicePerformed()}")
val roomCleaning =
RoomCleaning(setOf("Kitchen", "Bathroom"))
.start(now) { startedAt -> copy(startedAt = startedAt) }
.complete(now.plus(10, MINUTES), "Kitchen")
println("Was room cleaning service performed? ${roomCleaning.wasServicePerformed()}")
}
由于每个函数都使用数据类或通用类型作为接收者,因此调用可以在当前函数的输出是下一个函数的输入的意义上链式调用。
代码最终可能像Babysitting
对象可以被重构为如下所示:
val babysitting = Babysitting()
.withAgreedHoursOf(3)
.startAt(startTime)
.endAt(endTime)
比较所有解决方案
所有这些解决方案都是有效的,尽管它们的风格差异很大。了解每种方法的优缺点非常重要,这样我们就可以在适当的地方做出明智的决定来应用解决方案:
-
可扩展性:
-
多态性:可扩展到包和模块外部
-
密封类:不可扩展到包或模块外部
-
委托:可扩展到包和模块外部
-
功能性:可扩展到包和模块外部
-
-
可读性和 代码整洁性:
-
多态:子类可能会从超类继承不必要的功能,在阅读代码时产生噪音;类可以很大
-
密封类:所有子类在编译时都是已知的;没有缺失的分支;不适合太多子类
-
委托:小接口;多个行为委托可能很复杂;促进每个接口的单个职责;仅在需要时委托行为
-
函数式:小类和函数;易于推理不可变数据和纯函数;当使用递归、单子和高阶抽象时,可读性不是那么容易
-
-
可测试性:
-
多态:每个子类都需要测试所有行为以确保其行为与其超类一致,即它符合 LSP;此外,每个子类都需要对其特定逻辑进行测试
-
密封类:在超类中实现的行为只需要测试一次;任何
when
子句和子类特定逻辑都需要测试 -
委托:每个小行为单元都可以单独测试,在其委托中不需要重复
-
函数式:所有小类和函数都可以单独测试,无需重复;每个测试只需验证给定的输入所给出的输出
-
-
线程安全:
-
多态:本质上不是线程安全的
-
密封类:本质上不是线程安全的
-
委托:本质上不是线程安全的
-
函数式:由于不可变数据类和纯函数而线程安全
-
摘要
我们以家庭之间可以互相提供的三种类型的服务(管道、看护和房间清洁)为例,重点关注服务的开始、完成、确认以及是否执行了服务的检查。
我们提出了一种使用面向对象编程中的传统多态的解决方案。定义了一个接口,并由三个子类实现,每个子类对应一种服务类型。main
函数以同质且多态的方式使用这些子类。
我们随后使用了 Kotlin 的密封类特性来限制所有子类都必须是已知的。进一步的变体是,密封类与 when
构造一起使用来处理 when
块内的所有分支。这导致了一个包含适合包中少量且固定数量的子类的服务检查行为所有变体的函数。
我们提出了一种使用 Kotlin 委托而非多态的替代解决方案。我们为每个职责定义了更小的接口,并识别了一个服务启动类。然后我们创建了三个通过委托使用服务启动类的类。管道、看护和房间清洁服务随后被声明为单行代码,使用委托。这种风格允许我们重用代码,而无需继承可能提供比子类所需更多功能的超类。
然后,我们采用函数式方法为每种服务类型创建了不可变的数据类。我们使用参数多态性和 lambda 表达式为三种服务类型之间的共享行为创建了一个start
函数。我们声明了几个以服务作为接收者的扩展函数,以在main
函数中启用调用链。
最后,我们从可扩展性、代码可读性、可测试性和线程安全性等方面比较了所有解决方案。我们还简要说明了何时使用特定的解决方案以及何时不适用。
在接下来的章节中,我们将探讨当今行业中常用的几种架构模式。我们将把相似的模式分组在一起并进行比较,这样你就可以根据实际情况进行定制,以解决实际问题。
第四章:对等网络和客户端-服务器架构
本章探讨了两种组织和结构化通信系统的基本架构模式:对等网络(P2P)和客户端-服务器架构。这些架构模式对我们设计和实现各种现代网络系统的方式产生了重大影响。
本章从原理、特性和应用等方面全面介绍了 P2P 和客户端-服务器架构。之后,我们将比较并确定这两种方法之间的关键差异,并分析各自的优缺点。
我们将涵盖 P2P 和客户端-服务器架构的权衡利弊。更重要的是,我们将讨论在决定这两种模型时需要考虑的因素,包括期望的系统质量属性,如可扩展性、容错性、安全性和控制。我们还将探讨混合模型的可能性,以实现灵活性和适应性。
本章将介绍以下主题:
-
一个网络化系统的真实示例
-
客户端-服务器架构
-
P2P 架构
-
客户端-服务器架构与 P2P 架构的比较
技术要求
您可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-4
一个网络化系统的真实示例
我们将使用我们在前几章中一直在使用的真实示例。村庄中的家庭相互交换服务。每个家庭都有一个独立的软件副本,用于保存交换服务的合同记录。
他们正在遭受一个持续的问题;那就是,有时两个交换服务的家庭在他们自己的软件副本中保留的合同记录之间存在差异。这导致了一些家庭之间的纠纷。
一个工程师希望通过同步两个软件副本之间的合同来消除这些纠纷。同步需要两个软件副本连接起来。同步的简化交互在图 4**.1中展示:
图 4.1 – 家庭间交换服务的合同同步
在图中,家庭 A将合同的详细信息发送给家庭 B。接收到的合同与家庭 B本地存储的合同进行比较。家庭 B解决发现的任何差异。然后,修改后的合同(contract’)被发送给家庭 A。家庭 A也对比合同并解决任何冲突。家庭 A将另一个修改后的合同(contract’’)发送给家庭 B。这次,家庭 B没有发现任何差异,因此用合同确认了家庭 A。家庭 A从家庭 B那里收到确认并发出最终确认,表明两个家庭已同步合同。
既然我们有一种在软件的两个副本之间同步合同的方法,我们还需要一种让它们发现彼此并通信的方法。
我们将在下一节中探讨 P2P 和客户端-服务器架构,并将它们与解决本例中问题的上下文联系起来。
客户端-服务器架构
客户端-服务器架构是一种组织分布式系统和计算机网络的模式。在这个架构中,客户端和服务器的作用被明确定义,每个组件至少扮演其中一个角色。
客户端是请求资源或服务的设备或组件,而服务器是处理请求或提供服务的设备或组件。互联网上的客户端-服务器架构的示例在图 4.2中说明:
图 4.2 – 客户端-服务器架构 (C1)
通常,客户端是面向日常用户的设备,如笔记本电脑、手机和电视。它们往往是轻量级的设备,计算能力有限,并且通常只需要在与服务器通信时可用。
服务器是专门用于处理请求的设备,通常托管在云或数据中心。它们通常具有更多的计算能力、更多的存储和更多的网络带宽,并且高度可用以服务来自客户端的请求。具有客户端-服务器架构的系统从服务器可用以服务客户端请求开始。
客户端与服务器之间的交互
客户端与服务器之间的交互属于请求-响应模型。客户端向服务器发送一个请求,该请求标识一个资源或指定所需服务的详细信息。服务器接收请求,验证它,处理它,并返回一个相应的响应,其中包含请求的资源或服务的结果。
注意,客户端总是发起交互,并知道如何定位服务器。相反,服务器只知道在相应请求的上下文中客户端的位置。
因此,提供的资源或服务集中在服务器上。客户端不直接与其他客户端通信。任何可能需要在客户端之间共享的资源都托管在服务器上,并可供客户端请求。
这种架构导致服务器相对于客户端具有更高的非功能性需求。我们现在将讨论服务器中的一些关键系统质量属性。
可用性
服务器通常需要尽可能多地保持可用状态,以便在需要时能够服务客户端的请求。通常这意味着有多个服务器实例同时运行。可能存在故障转移机制,将请求路由到可用的服务器,备份系统以恢复服务器最后已知状态,以及监控工具以主动确保服务器处于运行状态。
性能
服务器集中以同时为众多客户端提供服务。快速和高效对于维持服务器运行至关重要。延迟和吞吐量是性能的两个主要系统质量属性。
在客户端-服务器架构中,延迟是指从客户端发送请求到客户端接收到相应响应所经过的总时间。吞吐量是单位时间内到达服务器的请求数量。
服务器的性能取决于多个因素,例如处理能力、内存、网络带宽和磁盘 I/O。服务器中通常有多个组件有助于性能相关的系统质量属性,例如文件系统、数据库、消息中间件,甚至第三方系统。
可伸缩性
服务器有时需要应对客户端请求数量的增长和减少。以下是一些常见的管理方法。
可以部署负载均衡器来将客户端的请求分配到一组服务器。它跟踪每个服务器实例的健康状况和流量,以便可以将请求路由到较不繁忙的服务器,并在服务器之间实现均衡的工作负载。
服务器可以通过运行更多实例来水平扩展以分配负载,或者通过升级服务器的硬件能力来垂直扩展。
应该配置一个最小或期望运行的服务器实例数量,以便在负载不重时服务器数量可以减少。
安全性
请求被集中到服务器上提供服务,相应数据也集中存储在服务器上。安全性变得极为重要。至少有四个主要领域需要解决。
首先,服务器应仅处理来自可识别客户端的传入请求。客户端需要通过各种方式(如密码或多因素验证)进行身份验证。这些方法的细节将在第十四章中介绍。
服务器还应控制哪些请求可以被哪些客户端接受。例如,普通客户端无法访问系统设置,而管理员客户端可以。客户端通常通过服务器外部不可见的内部流程进行授权,因此客户端无法绕过检查。
请求和响应负载中的数据可能包含需要保护的个人或敏感信息。在这些情况下,客户端和服务器之间的通信可能需要在协议中实现加密。加密方法可以是个性化的,这样其他客户端就无法读取客户端的数据。
服务器还需要对常见的恶意攻击有基本的防御措施,例如,拒绝服务(DOS)、跨站脚本(XSS)和中间人攻击(MitM)。
服务器发现
客户端需要定位一个可用的服务器来发送它们的请求。存在几种常见的发现机制:
-
静态和硬编码的地址作为客户端配置。
-
域名系统(DNS),它将服务器 IP 地址转换为人类可读的域名。
-
动态 DNS 服务,动态更改服务器地址。
-
服务注册服务,允许客户端查询要连接的适当服务器。
-
负载均衡器,将客户端请求分配到一组可用的服务器。
-
服务网格,它通过专用基础设施层抽象化服务发现、负载均衡和其他网络问题。
常见的客户端-服务器架构
存在许多处理客户端和服务器之间通信的架构风格变体:
-
GET
、POST
、PATCH
和DELETE
。 -
在使用 GET 和 POST HTTP 方法时,主要使用
/place
或/update
。 -
作为异步消息:在这种架构中,客户端和服务器不直接相互联系。相反,它们通过消息基础设施作为队列和主题进行通信。
-
作为双向专用连接:客户端和服务器在传输控制协议(TCP)连接上打开专用通道进行通信。这种通信方式通常出现在需要较低延迟和频繁消息的系统。
客户端-服务器解决方案
我们将应用客户端-服务器架构来解决两个家庭之间服务合同同步的现实生活示例。建议在编码之前制定客户端和服务器之间的交互。让我们假设 HTTP 作为通信协议。
在此解决方案中,我们将使用 REST 架构。我们需要定义服务器提供的端点,以便客户端可以使用这些端点来促进必要的通信。
第 1 步 - 定义客户端-服务器通信
让我们通过一个客户端-服务器通信的示例场景来说明:
图 4.3 - 服务合同同步的示例客户端-服务器交互
以下是在客户端和服务器之间发送的消息:
-
家庭 A 向服务器提交服务合同的草稿。
-
家庭 B 从服务器获取由 家庭 A 起草的服务合同。
-
家庭 B 修订服务合同并将其提交给服务器。
-
家庭 A 从服务器获取由 家庭 B 修订的服务合同。
-
家庭 A 向服务器确认它同意服务合同。
-
家庭 B 从服务器获取由 家庭 A 同意的服务合同。
-
家庭 B 向服务器确认它同意服务合同。
-
家庭 A 从服务器获取两个家庭都同意的服务合同。
从这些消息中,我们可以定义一些 HTTP 端点供客户端调用:
-
PUT /contracts/{id}
: 提交草稿或修订后的服务合同 -
GET /contracts/{id}
: 获取服务合同 -
PATCH /contracts/{id}/agreedAt
: 确认同意服务合同
在这里,{id}
是作为服务合同的资源唯一标识符。
第 2 步 - 定义消息负载
用于 PUT
和 GET
端点的消息负载需要定义。服务合同本身是资源,因此其模型是负载。PATCH
端点不需要返回负载。负载将使用 OpenAPI 3.0 模型定义,如下所示:
Party:
type: object
properties:
householdName:
type: string
service:
type: string
agreedAt:
type: string
format: date-time
ServiceContract:
type: object
properties:
id:
type: integer
format: int32
partyA:
$ref: '#/components/schemas/Party'
partyB:
$ref: '#/components/schemas/Party'
为了简化起见,ServiceContract
类由一个整数 ID 和两个当事人定义。每个当事人都有家庭名称、提供的服务以及家庭同意服务合同的可选时间。
第 3 步 - 定义 API 规范
尽管在第 1 步中已识别了三个 HTTP 端点,但有必要深入了解每个端点并将它们定义为 API 规范。
假设只有服务合同中的两个家庭可以看到服务合同。这意味着我们需要某些方式来验证和授权 GET
端点。在这个例子中,我们强制执行 GET
请求应包含一个标题来指定哪个家庭请求。在生产系统中,它应该使用更安全的方法,例如由受信任的 身份和访问管理(IAM)系统签发的 JSON Web Token(JWT),解码的令牌包含会揭示家庭名称的声明。
因此,GET
端点有两个输入参数,如下所述的 OpenAPI 3.0 模型中:
parameters:
- in: path
name: id
required: true
schema:
type: integer
- in: header
name: household
required: true
schema:
type: string
第一个输入参数是服务合同的标识符,显示在 URI 路径中。第二个输入参数是家庭名称,位于标题中。
对于响应,我们需要考虑成功和失败的情况。可能的输出应该作为 HTTP 状态码在响应中捕获,并在以下所示的 OpenAPI 3.0 模型中记录:
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceContract'
'400':
description: Failed request validation
'403':
description: Not authorized
'404':
description: Service contract not found
请求可能是有效的,并且服务合同存在,因此返回 HTTP 状态码 200
(OK)和服务合同负载。请求可能是由服务合同中未提及的家庭发起的,因此未获授权,并返回 HTTP 状态码 403(禁止)。或者,给定的 ID 的服务合同根本不存在,并返回 HTTP 状态码 404
(未找到)。
PUT
端点使用与 GET
端点相同的 URI 路径变量和头值,此外还有一个请求体,即服务合同本身:
requestBody:
description: The service contract to be created or updated
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceContract'
required: true
请求体是 ServiceContract
组件本身,因此模式引用了组件的规范。
PUT
端点的响应与 GET
端点大不相同。所有不同的场景都通过响应中的 HTTP 状态码捕获:
responses:
'200':
description: Service contract updated
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceContract'
'201':
description: Service contract created
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceContract'
'400':
description: Failed request validation
'403':
description: Not authorized
首先,请求负载中的服务合同可能使用了双方相同的家庭名称,因此它无效,并返回 HTTP 状态码 400
(错误请求)。此外,如果头部的家庭名称在服务合同的当事人部分中未出现,则它未获授权,并返回 HTTP 状态码 403
(禁止)。如果请求有效,有两种结果。如果在服务器中创建新的服务合同,则返回 HTTP 状态码 201
(已创建),并将服务合同作为负载。如果更新现有的服务合同,则返回 HTTP 状态码 200
(OK),并附带服务合同作为负载。
最终端点,即 PATCH
端点,很简单。它使用与 GET
端点完全相同的输入参数集,因为没有必要请求体,请求只需识别现有的服务合同。所有响应都在此指定:
responses:
'204':
description: Agreed service contract
'400':
description: Failed request validation
'403':
description: Not authorized
'404':
description: Service contract not found
PATCH
端点响应中的失败结果与 GET
端点相同,在识别现有服务合同方面。但 PATCH
端点的成功结果与其他两个端点不同,因为它没有请求体,因此返回 HTTP 状态码 204
(无内容)。
第 4 步 – 服务器开发
现在我们有了 API 规范,我们准备开发服务器端点。相应的实体需要在 Kotlin 中定义:
data class ServiceContract(
val id: Int,
val partyA: Party,
val partyB: Party,
)
data class Party(
val householdName: String,
val service: String,
val agreedAt: Instant? = null,
)
注意,这与 OpenAPI 3.0 组件定义在语义上是等效的。
建议使用高度可用的存储系统来永久保存服务合同,例如,数据库。然而,在这个例子中,我们简化了存储库,将服务合同保存在内存中:
val contracts = ConcurrentHashMap<Int, ServiceContract>()
这个线程安全的内存映射使用服务合同的整数 ID 作为键,ServiceContract
对象作为值。
在这个例子中,http4k因其代码体积小而被用作服务器框架。我们需要设置读取和写入不同值的方法。这是通过声明几个http4k Lenses来验证和将有效负载转换为类型安全结构来实现的:
val serviceContractLens = Body.auto<ServiceContract>().toLens()
val householdHeader = Header.required("household")
在这里,我们将ServiceContract
对象的 Lens 作为主体,家庭名称在头部。它们将在实际的端点实现中使用。
http4k 通过简单地使用route()
函数声明一个HTTPHandler
来配置端点路由:
val app: HttpHandler =
routes(
...
)
在route()
函数内部,声明了一个端点列表,并定义了实现的细节。以下是对GET
端点的实现细节:
"/contracts/{id}" bind GET to { request ->
val household = householdHeader(request)
val id = request.path("id")?.toInt()
val contract = id?.let { contracts[it] }
if (contract == null) {
Response(NOT_FOUND).body("Service contract of ID $id not found")
} else if (contract.partyA.householdName != household && contract.partyB.householdName != household) {
Response(FORBIDDEN).body("Household $household is not allowed to see the service contract of ID $id")
} else {
Response(OK).with(serviceContractLens of contract)
}
}
此实现定义了GET /contracts/{id}
的路由 URI。然后,它使用之前定义的 http4k Lens 从头部读取家庭名称。服务合同 ID 也从路径变量中读取。
然后尝试从之前定义的内存映射中获取ServiceContract
对象。如果对象未找到,则返回 HTTP 状态404
(未找到)。如果找到服务合同,但头部中的家庭名称不是服务合同任一方的,则返回 HTTP 状态403
(禁止)。否则,一切正常,并返回 HTTP 状态200
(OK),响应体由之前定义的 http4k Lens 转换自ServiceContract
对象。
PUT
端点也是类似定义的:
"/contracts/{id}" bind PUT to { request ->
val household = householdHeader(request)
val lens = Body.auto<ServiceContract>().toLens()
val id = request.path("id")?.toInt()
val contract = lens(request)°
if (id == null || id != contract.id) {
Response(BAD_REQUEST).body("Service contract ID in the payload and the URI path do not match")
} else if (contract.partyA.householdName == contract.partyB.householdName) {
Response(BAD_REQUEST).body("Service contract must have two different household: $household")
} else if (contract.partyA.householdName != household && contract.partyB.householdName != household) {
Response(FORBIDDEN).body("Household $household is not allowed to update the service contract of ID $id")
} else {
val previous = contracts.put(contract.id, contract)
val status = if (previous == null) CREATED else OK
Response(status).with(serviceContractLens of contract)
}
}
然而,PUT
端点使用 http4k Lens 将请求体转换为ServiceContract
对象。
从 URI 路径变量中读取的服务合同 ID 与ServiceContract
对象进行核对,以确保 ID 与对象内部的id
字段相同。此外,还有一个验证确保ServiceContract
对象中每一方的家庭名称是不同的。任何验证失败都会导致返回 HTTP 状态码400
(错误请求)。
与GET
端点一样,有一个检查确保头部中的家庭名称与ServiceContract
对象中的任一方匹配;否则,返回 HTTP 状态码403
(禁止)。
之后,将ServiceContract
对象放入内存映射中。put
函数返回与键关联的先前值。如果先前值为null
,则返回 HTTP 状态201
(已创建);否则,返回 HTTP 状态200
(OK)。两种情况都带有之前定义的 http4k Lens 转换自ServiceContract
对象的响应体。
最后一个端点PATCH
与第一个半部分的GET
端点实现非常相似。读取相同的输入参数,服务器尝试从内存映射中获取ServiceContract
对象。实现的第一部分以必要的验证结束:
"/contracts/{id}/agreedAt" bind PATCH to { request ->
val household = householdHeader(request)
val id = request.path("id")?.toInt()
val contract = id?.let { contracts[id] }
if (contract == null) {
Response(NOT_FOUND).body("Service contract of ID $id not found")
} else if (contract.partyA.householdName != household && contract.partyB.householdName != household) {
Response(FORBIDDEN).body("Household $household is not allowed to see the service contract of ID $id")
在验证请求后,找到同意合同的家庭。为合同的有关方设置agreedAt
时间戳。此外,修订后的合同被放入之前声明的共享ConcurrentHashMap
中:
} else {
val now = Instant.now()
val revisedContract =
if (contract.partyA.householdName == household) {
contract.copy(partyA = contract.partyA.copy(agreedAt = now))
} else {
contract.copy(partyB = contract.partyB.copy(agreedAt = now))
}
contracts[contract.id] = revisedContract
Response(NO_CONTENT).with(serviceContractLens of contract)
}
}
实现的第二部分专注于将agreedAt
时间戳添加到ServiceContract
对象的正确方。由于验证已经通过以确保家庭是两个之一,服务器确定它是哪一个,并使用copy
函数创建原始ServiceContract
对象的一个变体,将agreedAt
时间戳设置为当前时间戳。然后更新内存中的值。随后返回 HTTP 状态码204
(无内容)。
最后,是main
函数:
fun main() {
val printingApp: HttpHandler = PrintRequest().then(app)
val server = printingApp.asServer(Undertow(9000)).start()
}
main
函数启动服务器并开始监听端口9000
上传入的请求。
第 5 步 – 客户端开发
如前所述,客户端总是首先与服务器进行交互。因此,本例中的客户端实现反映了图 4.3中的顺序图中的客户端-服务器交互。出于简化原因,我们在这个例子中使用main
函数来模拟两个家庭。我们首先创建一个使用OKHTTP的 HTTP 客户端:
val client: HttpHandler = OkHttp()
val printingClient: HttpHandler = PrintResponse().then(client)
然后,由家庭 A起草的初始服务合同被创建:
val initialContractDraftedByHouseholdA =
ServiceContract(
id = 1,
partyA = Party("A", "Plumbing"),
partyB = Party("B", "Cleaning"),
)
printingClient(
Request(PUT, "http://localhost:9000/contracts/1").with(
householdHeader of "A",
Body.json().toLens() of
initialContractDraftedByHouseholdA.asJsonObject(),
),
)
然后,通过调用PUT
端点将初始服务合同提交给服务器。随后,家庭 B通过调用GET
端点接收初始服务合同:
val contractReceivedByB =
serviceContractLens(
printingClient(
Request(GET, "http://localhost:9000/contracts/1").with(householdHeader of "B"),
),
)
val contractRevisedByB =
contractReceivedByB.copy(
partyB = contractReceivedByB.partyB.copy(service = "Babysitting"),
)
printingClient(
Request(PUT, "http://localhost:9000/contracts/1").with(
householdHeader of "B",
Body.json().toLens() of
contractRevisedByB.asJsonObject(),
),
)
家庭 B通过调用PUT
端点来修订合同,并将修订后的合同提交给服务器。然后,家庭 A通过调用GET
端点接收修订后的合同:
val contractReceivedByA =
serviceContractLens(
printingClient(
Request(GET, "http://localhost:9000/contracts/1").with(householdHeader of "A"),
),
)
printingClient(Request(PATCH, "http://localhost:9000/contracts/1/agreedAt").with(householdHeader of "A"))
家庭 A对修订后的合同感到满意。家庭 A通过调用PATCH
端点通过服务器确认其对服务合同的同意。现在轮到家庭 B接收并确认服务合同:
val revisedContractReceivedByB =
serviceContractLens(
printingClient(
Request(GET, "http://localhost:9000/contracts/1").with(householdHeader of "B"),
),
)
if (revisedContractReceivedByB.partyA.agreedAt != null) {
printingClient(Request(PATCH, "http://localhost:9000/contracts/1/agreedAt").with(householdHeader of "B"))
}
家庭 B看到家庭 A同意了服务合同。然后,家庭 B也通过调用PUT
端点通过服务器确认其对服务合同的同意。最后,它回到家庭 A接收双方同意的服务合同:
val contractAgreedByBoth =
serviceContractLens(
printingClient(
Request(GET, "http://localhost:9000/contracts/1").with(householdHeader of "A"),
),
)
本例中的客户端-服务交互已经结束。家庭 A和家庭 B之间的服务合同已经相互同意并同步。
在整个示例实现过程中,我们展示了如何使用客户端-服务器架构来解决服务合同同步问题中客户端和服务器角色的划分。
哪些系统使用客户端-服务器架构?
客户端-服务器架构在许多系统中被广泛使用。以下是一些例子:
-
B2C 系统:大多数企业对消费者(B2C)系统使用客户端-服务器架构,其中客户端要么是网页浏览器,要么是移动应用程序。客户端只保存很少的数据,只有关于用户的数据。同时,服务器保存了所有客户端的大部分数据。
-
B2B 系统:大多数企业对企业(B2B)系统使用客户端-服务器架构,其中一家公司的业务系统的一部分充当客户端,连接到另一家公司的业务系统的服务器。这些系统由于客户端和服务器之间的通信而共享一些数据。
-
在线游戏:许多在线游戏使用客户端-服务器架构来维护多个玩家之间的游戏共享状态。游戏客户端在玩家的设备上运行,并与游戏服务器通信以同步状态并与其他玩家互动。
-
金融服务系统:金融服务受到各种机构的严格监管,对数据的存储和分发有严格的规则。客户端-服务器架构可以将不必要的数据与客户端隔离开来,让服务器保存敏感数据并遵守监管和审计控制。
-
即时通讯、聊天和电子邮件系统:流行的消息平台,如 Slack、WhatsApp、Discord 和 Microsoft Outlook,都使用客户端-服务器架构。客户端连接到服务器以向其他客户端发送和接收消息,向一组客户端广播消息,共享文件,并参与实时聊天。
接下来,我们将探讨另一种选择,即对等网络架构,以及如何以不同的方式解决家庭及其服务合同的问题。
对等网络架构
对等网络架构基于没有中央权威机构进行协调的理念。一个对等网络由许多节点(“对等点”)组成,这些节点在相互通信中具有平等的角色。
每个节点可以从其他节点请求资源或服务,同时也可以向其他节点提供资源或服务。这种对等网络的分布式特性使得参与者之间能够有效地共享资源和协作。
来自对等网络架构的每个节点在计算能力、存储和网络带宽方面没有硬性非功能性要求。然而,一致性是许多对等网络系统中的一个主要非功能性关注点。
一致性
在对等网络系统中没有中央权威机构或服务器控制数据。每个节点存储和管理自己的数据,节点之间直接通信以共享和同步信息。这种对等网络的分布式特性带来了几个一致性挑战:
-
数据复制和并发控制:在 P2P 系统中,一个节点通常会在多个节点上复制其数据以提高可用性和容错性。如果数据在多个节点上同时修改,这种复制可能导致不一致。及时将更新传播到所有相关节点至关重要。
此外,如果多个节点同时更新相同的数据,将存在关于哪个更新应该发生的冲突。实施有效的并发控制机制,如锁定、版本控制或冲突解决策略,对于维护数据一致性是必要的。
-
最终一致性:在节点分布广泛的分布式网络中,可能会出现延迟和分区,这使得实现强一致性变得困难。相反,对等网络(P2P)系统侧重于最终一致性,这意味着经过一段时间后,即使在网络中断或节点故障的情况下,最终也会达到一致状态。
-
因果一致性:另一种一致性选项是因果一致性,其中相关事件以相同的顺序被所有节点接收,而不相关的事件可以以任何顺序接收。
-
共识和法定人数:在某些 P2P 系统中,在更改被接受之前,所有节点必须达到特定状态或被更新。这被称为共识方法。另一种基于法定人数的方法只需要大多数节点的同意。这两种方法都有助于维护一定水平的一致性,但引入了额外的协调和通信开销。
-
Merkle 树和哈希:Merkle 树或基于哈希的方法可以用于高效地检测和解决分布式数据中的不一致性,使节点能够快速识别并同步其数据。这些方法在区块链网络等去中心化系统中被广泛使用。
引导和节点发现
P2P 网络从第一个可用的节点开始,然后其他节点加入。这个过程称为引导。在新的节点加入现有的 P2P 网络之前,新节点必须以某种方式发现网络中的至少一个其他节点。我们现在将介绍几种节点发现机制。
静态
发现节点最基本的方式是每个节点在其静态配置中都有所有其他节点的地址。这种方法的明显局限性是可以配置的节点数量。可以通过中继服务器、网络地址转换(NAT)和打孔等技术克服 IP 地址的限制。这是关于存储和内存限制,以在每个节点中存储所有节点的地址。
具有静态节点发现的 P2P 网络可以通过尝试从静态配置连接每个节点来进行引导。
集中式目录
与去中心化的概念相反,一些 P2P 网络有一个集中的目录,维护网络中活动节点的列表。这意味着网络从集中的目录可用开始。当一个节点加入网络时,它在目录中列出自己为可用。此外,每个节点都可以从集中的目录中获取可用节点的列表。
集中的目录属于服务器类别,它需要与其他节点非常不同的系统质量属性。例如,集中的目录必须高度可用;否则,它不能接受新节点或提供可用节点列表。
多播或广播
在一个私有或局域网(LAN)中,可以通过每个节点向所有其他节点发送广播或多播消息来建立 P2P 网络。几台节点可以决定响应,原始节点能够发现它们。网络在第一次发现节点时启动。这种发现机制仅适用于节点数量有限的网络。
多播网络通常使用用户数据报协议(UDP)作为传输协议。这通常配置在指定的子网中,以避免广播洪水并限制安全风险。
Kademlia
Kademlia 是 P2P 网络中使用的网络结构和消息协议的规范。在网络的多个节点之间出现了一个分布式哈希表(DHT)。网络通常使用多播 UDP 作为传输协议。
每个节点都有一个节点 ID,这通常是一个未签名的大的随机整数。节点 ID 前缀用于使用通用哈希函数计算哈希值。哈希值对应于哈希表中的一个桶,这就是每个节点如何在本地哈希表中维护其他节点的 ID 并将其用作路由表的方式。
当一个节点加入网络时,它会向网络中的所有节点广播其节点 ID。然后,其他节点在它们的路由表中找到一个桶来保存新节点的 ID。
相邻的节点 ID 也有相邻的桶。通过异或(XOR)函数计算的两个节点 ID 之间的“距离”用于节点发现。当一个节点想要在网络中查找另一个节点时,它从最接近其节点 ID 的桶开始,通过遍历桶(从最近到最远)迭代地找到一个响应的节点。
与其他节点交换信息
节点之间也可以共享它们自己的节点。节点之间的交换有一些变化。
如果 P2P 网络有一个已知的结构,例如 DHT,节点可以使用某种协议爬取结构,以查询其他节点的信息。缺点是,新加入的节点仍然需要另一种机制来获取初始节点列表。
新加入的节点也可以联系几个引导节点,以查询有关其他节点的信息。然而,这确实依赖于知名引导节点的可用性。
节点之间也可以采用 gossip 协议,定期与几个随机邻居节点共享信息。信息应该像八卦或流行病一样集体传播,尽管它需要一些时间才能显现。该协议从小型到大型网络都具有良好的可扩展性。它也是容错的,这意味着如果邻居节点失败,其他邻居节点仍然能够提供替代信息。
节点间的通信
一旦节点了解如何联系网络中的其他节点,它就可以开始通信。
直接使用 IP 地址进行通信
两个节点之间最基本的通信形式是使用 IP 地址让一个节点直接联系另一个节点。这种方法通常用于小型网络或局域网。网络中的节点使用 TCP 等传输协议来发送更可靠和有序的消息,或者使用 UDP 来发送更快但无序的消息。
穿孔打孔
对于一个本地网络下的节点要连接到另一个本地网络下的节点,这些节点无法使用 IP 地址进行直接通信。让我们考虑以下示例网络拓扑的情况。参见图 4.4:
图 4.4 – 带有家庭网络的示例网络拓扑
节点 1没有直接通过互联网与节点 2通信的方法,因为这两个节点都位于它们自己的家庭网络后面。然而,如果家庭网络和互联网服务提供商(ISP)网络支持 NAT,那么可以使用穿孔打孔技术允许节点 1通过这种中继机制间接与节点 2通信。
NAT 是一种将本地网络地址转换为公共和全球 IP 地址的机制。因此,节点 1和节点 2都有自己的全球 IP 地址,并且它们可以相互通信。这在本图中有说明:
图 4.5 – 带有家庭网络和 NAT 的示例网络拓扑
节点 1已打开其端口443
以接收消息,并且可以通过其家庭网络下的私有 IP 地址192.168.1.10
进行定位。其家庭网络将192.168.1.10:443
(作为内部地址)映射到10.168.3.234:80006
(作为外部地址)。然后,ISP 网络也将内部地址10.168.3.234:80006
映射到全球地址108.27.39.3:24390
。节点 2具有类似的 NAT 映射路径,但其全球地址是23.1.80.0:2877
。
从这个点开始,我们可以逐步开始打孔,并假设 节点 1 已经发现了 节点 2 的全局地址。首先,节点 1 使用源本地地址 (节点 1) 和目标全局地址 (节点 2) 与其家庭网络进行联系。然后,家庭网络中继此信息,并类似地联系其 ISP 网络。
然后,节点 1 所在的 ISP 网络使用全局地址联系另一个 节点 2 所在的 ISP 网络。
节点 2 所在的 ISP 网络使用转换后的地址与目标本地家庭网络进行联系。然后,家庭网络使用转换后的地址与家庭网络中的目标 节点 2 进行联系。
NATs 在各自的表中临时打上 孔洞,以便将内部和外部地址进行转换。因此,节点 A 和 节点 B 可以与多个网络以及中间的 NATs 建立通信,以中继消息。
注意,如果防火墙是无状态的,那么防火墙后面的打孔就不会起作用。无状态防火墙不跟踪连接,也不记得地址的转换。
发布-订阅
发布-订阅 是 P2P 通信的另一种模型,其中不需要节点发现。相反,节点将消息发布到感兴趣的特定主题,订阅了这些主题的其他节点将接收到这些消息。如果消息通过代理传递,则节点仍然需要知道代理的地址来发布和接收消息。发布-订阅消除了节点发现的需求,并有效地将信息传播给相关接收者。
一个带有代理的发布-订阅架构示例显示在 图 4**.6:
图 4.6 – 发布-订阅架构
发布者不知道订阅者。它知道代理的地址以及消息应该发布的主题。订阅者将它们对某些主题的兴趣注册到代理,并接收与主题相关的消息。
代理是一种基础设施中间件,它从发布者那里接收消息。它存储消息并管理订阅者的订阅。最重要的是,它根据订阅者注册的兴趣将消息路由到适当的订阅者。
现在,我们将深入探讨 P2P 解决方案的实施。
P2P 解决方案
我们将应用 P2P 架构来解决两个家庭之间服务合同同步的实际情况。为了简化,让我们假设这两个家庭已经发现彼此,并且它们的设备运行在同一个本地网络中。
步骤 1 – 定义 P2P 通信
我们将在编码之前制定节点之间的交互。让我们假设使用 UDP 作为通信协议。节点直接使用 IP 地址和端口相互通信。参见 图 4**.7:
图 4.7 – 示例 P2P 交互服务合同同步
以下是在 家庭 A 和 家庭 B 的节点之间发送的消息,如 图 4.7 所述:
-
家庭 A 向 家庭 B 提交一份服务合同的草案。
-
家庭 B 修改服务合同并将其提交给 家庭 A。
-
家庭 A 向 家庭 B 确认它同意服务合同。
-
家庭 B 向 家庭 A 确认它同意服务合同。
在定义了通信协议之后,下一步应该定义消息的负载。
第 2 步 – 定义消息负载
从之前定义的通信中,唯一传递的消息就是服务合同本身。服务合同的模式保持不变:
data class ServiceContract(
val id: Int,
val partyA: Party,
val partyB: Party,
)
data class Party(
val householdName: String,
val service: String,
val agreedAt: Instant? = null,
)
在这个例子中,我们将 ServiceContract
类外部化为一个字节数组,以便通过网络发送。
第 3 步 – 同伴开发
让我们从为节点使用 UDP 进行通信构建一些必要的传输函数开始。由于一个节点可以产生和消费消息,因此定义一个通用的 UDP 节点类如下似乎是合理的:
class UdpNode<T>(
val address: SocketAddress,
val convertor: DtoConvertor<T>,
val transformer: (T) -> T?,
) {
private val inbound: ByteBuffer = convertor.allocate()
private val outbound: ByteBuffer = convertor.allocate()
private var channel: DatagramChannel? = null
UdpNode
类使用 非阻塞输入/输出(NIO)包来支持三个主要功能:
-
启动节点
-
向目标节点发送消息
-
接收来自另一个节点的消息
在支持这些功能的同时,UdpNode
类只有在传输机制需要更改时才应进行更改,使其仅具有处理传输层面问题的单一职责,并符合 单一职责原则。
因此,ServiceContract
类的序列化和反序列化委托给具有泛型类型 T
的 DtoConvertor
接口,这样 UdpNode
类就不与 ServiceContract
类耦合。
处理和响应 ServiceContract
对象是应用层面的关注点,并且这个关注点由构造函数委托给转换器 lambda。
UdpNode
类的 start
函数很简单。它将节点绑定到配置的地址,并使其准备好消费消息:
fun start() {
channel =
DatagramChannel.open()
.bind(address)
}
produce
函数在调用 DtoConvertor
写入之前清除输出缓冲区。然后,将缓冲区发送到通道:
fun produce(
payload: T,
target: SocketAddress,
): Int {
outbound.clear()
convertor.toBuffer(payload, outbound)
outbound.flip()
return channel!!.send(outbound, target)
}
consume
函数首先清除输入缓冲区,然后通道接收字节数组并将其写入缓冲区。然后,调用 DtoConvertor
将字节数组转换为 ServiceContract
对象:
fun consume(): Int {
return channel?.let { c ->
inbound.clear()
val address: SocketAddress = c.receive(inbound)
inbound.rewind()
val received = convertor.fromBuffer(inbound)
transformer(received)?.let { transformed ->
produce(transformed, address)
}
} ?: 0
}
}
调用转换器 lambda 函数来确定对 ServiceContract
对象的响应。如果响应是 null
,则不执行任何操作。如果响应是另一个 ServiceContract
对象,则调用 produce
函数将响应发送回发送原始消息的节点。
在这个例子中,另一个重要的类是DtoConvertor
。它被设计成通用的,用于封装泛型类型E
到字节数组的序列化和反序列化。只有三个函数:
interface DtoConvertor<E> {
fun allocate(): ByteBuffer
fun toBuffer(dto: E, buffer: ByteBuffer)
fun fromBuffer(buffer: ByteBuffer): E
}
allocate
函数创建一个足够大的ByteBuffer
以容纳指定的类型。toBuffer
函数将一个E
写入ByteBuffer
,而fromBuffer
函数从ByteBuffer
中读取类型为E
的 DTO。
声明了一个 Kotlin 单例ServiceContractConvertor
,以实现DtoConvertor
接口,并具有ServiceContract
类型:
object ServiceContractConvertor : DtoConvertor<ServiceContract> {
override fun allocate(): ByteBuffer {
return ByteBuffer.allocate(1024)
}
toBuffer
函数按照一定的顺序将ServiceContract
对象中的每个字段写入:
override fun toBuffer(dto: ServiceContract, buffer: ByteBuffer) { buffer.putInt(dto.id).putParty(dto.partyA).putParty(dto.partyB)
}
private fun ByteBuffer.putParty(dto: Party): ByteBuffer = putString(dto.householdName).putString(dto.service).putInstant(dto.agreedAt)
private fun ByteBuffer.putInstant(dto: Instant?): ByteBuffer =
if (dto == null) {
putChar(ABSENT)
} else {
putChar(PRESENT).putLong(dto.epochSecond)
}
private fun ByteBuffer.putString(dto: String): ByteBuffer = putInt(dto.length).put(dto.toByteArray())
fromBuffer
函数按照相同的顺序从ByteBuffer
中读取ServiceContract
对象的每个字段,并返回该对象:
override fun fromBuffer(buffer: ByteBuffer): ServiceContract = ServiceContract(buffer.getInt(), buffer.getParty(), buffer.getParty())
private fun ByteBuffer.getParty(): Party = Party(getString(), getString(), getInstant())
private fun ByteBuffer.getInstant(): Instant? =
if (getChar() == PRESENT) {
Instant.ofEpochSecond(getLong())
} else {
null
}
private fun ByteBuffer.getString(): String {
val bytes = ByteArray(getInt())
get(bytes)
return String(bytes)
}
最后,有两个main
函数,一个用于家庭 A,一个用于家庭 B,以表示它们如何协商服务合同。
家庭 A的行为定义在下面的代码块中,然后节点开始监听:
fun main() {
val node =
UdpNode(
InetSocketAddress(HOST_A, PORT_A),
ServiceContractConvertor,
) { it.receivedByHouseholdA() }
node.start()
家庭 A在以下情况下不会对ServiceContract
对象做出响应:
-
双方都出现了相同的家庭名称,因为它是不合法的
-
两个家庭都已经同意了合同
-
家庭 A不参与任何一方
否则,家庭 A同意ServiceContract
:
val contract =
ServiceContract(
id = 1,
partyA = Party(HOUSEHOLD_A, PLUMBING, null),
partyB = Party(HOUSEHOLD_B, CLEANING, null),
)
node.produce(contract, InetSocketAddress(HOST_B, PORT_B))
println("Submitted service contract: ${contract.id}")
loopForever(1000) { node.consume() }
}
private fun ServiceContract.receivedByHouseholdA() =
if (bothPartiesHaveDifferentNames().not() ||
partyAgreed(HOUSEHOLD_A) ||
isHouseholdInvolved(HOUSEHOLD_A).not()
) {
println("No response to service contract: $this")
null
} else {
println("Agreed to service contract: $id")
agree(HOUSEHOLD_A) { Instant.now() }
}
然后,家庭 A将草案合同发送给家庭 B。最后,家庭 A进入一个无限循环以尝试消费任何进一步的消息。
另一方面,家庭 B的行为定义在另一个main
函数中,然后它开始监听:
fun main() {
val node =
UdpNode(
InetSocketAddress(HOST_B, PORT_B),
ServiceContractConvertor,
) { it.receivedByHouseholdB() }
node.start()
loopForever(1000) { node.consume() }
}
fun ServiceContract.receivedByHouseholdB() =
if (bothPartiesHaveDifferentNames().not() ||
partyAgreed(HOUSEHOLD_B) ||
isHouseholdInvolved(HOUSEHOLD_B).not()
) {
println("No response to service contract: ${this}")
null
} else if (serviceReceivedBy(HOUSEHOLD_B) == CLEANING) {
println("Submitted revised service contract: $id")
withReceivedService(HOUSEHOLD_B, BABYSITTING)
} else if (serviceReceivedBy(HOUSEHOLD_B) == BABYSITTING) {
println("Agreed to service contract: $id")
agree(HOUSEHOLD_B) { Instant.now() }
} else {
println("No response to service contract: $id")
null
}
同样,家庭 B在以下情况下不会对ServiceContract
对象做出响应:
-
由于家庭名称在双方都出现,因此它是不合法的
-
两个家庭都已经同意了合同
-
家庭 B不参与任何一方
家庭 B会将合同中从清洁服务更改为育儿服务,并且如果收到的服务是育儿,则家庭 B会接受。
当两个main
函数运行时,我们应该看到代表家庭 A和家庭 B的两个节点协商服务合同。最终,服务合同被相互同意并同步。我们应该看到如下输出:
家庭 A:
Started on $localhost/127.0.0.1:7001
Submitted service contract: 1
Agreed to the service contract: 1
No response to service contract: ServiceContract
家庭 B:
Submitted to revised service contract: 1
Agreed to service contract: 1
在这一点上,我们已经展示了如何使用 UDP 解决服务合同同步问题。为了完全展示 P2P 网络,必须有大量的节点可供在多播 UDP 网络中发送和接收消息。此外,节点之间应该有一个数据复制和一致性机制。
使用 P2P 架构的系统有哪些?
P2P 架构在几个常见的系统中使用,例如以下:
-
Napster是人们常用以在互联网上共享文件的最早 P2P 系统之一。Napster 使用一个集中式目录服务器来维护可用文件及其位置的索引。
-
BitTorrent 是一种流行的 P2P 协议,用于在互联网上分发大文件。它将大文件分解成更小的片段,并允许每个片段独立共享。用户同时下载和上传这些片段。完成后,BitTorrent 将片段重新组合成文件供用户使用。BitTorrent 减少了文件共享对中心化的需求。
-
去中心化金融 (DeFi) 是一个较新的例子。例如,比特币和以太坊这样的加密货币在 P2P 网络上运行。网络中的节点通过共识算法进行通信和验证交易,而不依赖于中央权威机构。这种分布式和同步的共享状态使得去中心化和无需信任的数字货币系统成为可能。
我们现在将比较这两种架构,客户端-服务器和 P2P,以了解在特定情况下哪种架构更有用。
客户端-服务器和 P2P 架构之间的比较
客户端-服务器和 P2P 架构应被视为从集中化到去中心化的一系列模型,两者之间有许多可行的混合模型。参见 图 4.8。
图 4.8 – 从去中心化到中心化架构的谱系
中心化架构有更简单的方式实现强一致性,而去中心化架构有更复杂的方式实现通常较弱的致性。
客户端-服务器架构在以下情况下是有用的:
-
需要中央控制和管理的需求,通常适用于受监管的行业
-
有一些任务关键流程需要系统具有高度可用性、弹性和一致性
-
需要收集和关联大量数据,并且数据需要一致、复制、安全,并以安全的方式访问
相反,P2P 架构在以下情况下是有用的:
-
需要避免托管服务器的昂贵成本,并利用对等节点的现有资源
-
需要在没有中央控制或审查的情况下自由共享资源
-
需要避免依赖于可能导致整个系统失败的子集处理
摘要
我们已经深入探讨了两种重要架构,客户端-服务器和P2P,以解决现实生活服务合同同步问题的背景。
我们已经介绍了每种架构的启动方式,以及每种架构所需的系统质量属性。
我们还通过 Kotlin 代码演示了如何通过每种架构解决同步问题。
我们已经描述了一些采用客户端-服务器和 P2P 架构的现实生活系统,并比较了这两种架构。
你现在应该对这两种架构及其解决的问题有一个简要的了解,并且能够推断出在不同情况下可以使用哪种架构。
在接下来的章节中,我们将探讨在前端中常用的一些架构模式。
第五章:探索 MVC、MVP 和 MVVM
本章旨在全面比较 模型-视图-控制器(MVC)、模型-视图-表示者(MVP)和 模型-视图-视图模型(MVVM),展示它们的相似之处、不同之处以及它们各自的优势领域。通过了解每种模式的优缺点,开发者在架构应用程序时可以做出明智的决策。
本章首先探讨 MVC 的原则,这是一种在包括 Web 和移动应用开发在内的各种平台上广泛采用的模式。我们将深入研究其三个核心组件:模型,负责业务数据;视图,负责向用户展示信息的视觉呈现;以及控制器,模型和视图之间的中介。
接下来,我们将把重点转向 MVP,这是一种作为 MVC 进化而出现的模式。我们将研究 MVP 如何通过引入表示者(Presenter)来解决 MVC 的一些局限性,表示者负责在模型和视图之间协调数据交换和用户交互。我们将分析 MVP 中实现的 关注点分离(SoC)以及它如何提高可测试性和可维护性。
最后,我们将探索 MVVM,这是一种随着数据绑定框架的流行而变得流行的模式。我们将研究 MVVM 如何通过分离 模型、视图 和 视图模型 的关注点,以及数据绑定如何促进视图和视图模型之间数据的自动同步。我们将讨论声明性编程的好处以及 MVVM 提供的更高的 SoC(关注点分离)。
因此,本章将涵盖以下主题:
-
MVC
-
MVP
-
MVVM
-
比较 MVC、MVP 和 MVVM
-
不仅仅是 MVC、MVP 和 MVVM
技术要求
你可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-5
MVC
MVC 模式起源于 1970 年代,当时 MVC 的概念被开发出来,用于结构化代码并在桌面应用程序的 图形用户界面(GUI)中分离关注点。
在 1990 年代末和 2000 年代初,Web 开发变得流行。MVC 被作为 Web 开发框架的一部分采用;例如,JavaServer Pages(JSP)、Ruby on Rails、ASP.NET 等等。
MVC 模式将应用程序划分为三个相互关联的组件:模型(Model)、视图(View)和控制器(Controller)。每个组件都有其独特的职责,并以协调的方式与其他组件交互。此模式促进了 SoC(关注点分离)和职责的清晰划分。
你可能会在不同的框架和语言中找到三个组件之间不同版本的交互。MVC 代表了分离模型、视图和控制器的需求,而不是它们如何一起工作的规定。
模型
模型是应用程序的内部数据,独立于用户界面(UI)。数据在模型内进行验证、操作和转换成其他格式。它封装了核心行为和业务逻辑,并且可以在不同的视图中共享。
视图
视图负责向最终用户展示用户界面。它显示模型中的数据,并提供与用户交互的机制。视图是被动且对用户动作做出反应的。它只包含与渲染数据和响应用户输入相关的简单逻辑。
控制器
控制器是模型、视图和用户之间的中间协调者。它接收来自用户的请求,并请求更新模型中的数据。一旦模型接受请求,控制器就会更新视图中的展示。
控制器负责协调模型和视图之间数据流和变更序列。换句话说,控制器决定了应用程序的行为。
协作
模型、视图和控制器之间的一种协作方式如下所示:
图 5.1 – MVC 模式
用户看到视图并向控制器发出请求。控制器更新模型中的数据,然后更新视图中的展示,最后,用户看到对之前向控制器发出的请求的视觉响应。当模型发生变化时,视图也可以通过使用回调函数进行更新。
注意
值得指出的是,模型不依赖于视图或控制器。相反,视图依赖于模型。控制器依赖于视图和模型。
MVC 的好处
MVC 模式将数据管理(模型)、用户界面(视图)和用户交互(控制器)的关注点分开。因此,它促进了模块化、代码重用性和可测试性。
模型捕获应用程序数据和业务逻辑,这些可以在多个 UI 和用户交互中重用。多个平台,如网络浏览器界面和移动应用界面,都可以将模型作为模块依赖。单个更新操作和批量更新操作可以在模型内重用相同的数据结构和验证逻辑。模型中的业务逻辑可以在不依赖视图和控制器的情况下独立测试。
此外,将模型、视图和控制器模块化使得它们中的任何一个都可以在不破坏或影响其他模块的情况下被替换。如果我们现在要重写当前的 UI,就可以将下一代 UI 作为独立模块与当前 UI 并行编写,而不用担心破坏任何现有功能或降低用户体验。我们可以在另一个团队构建新 UI 的同时,仍然维护当前 UI。
当下一代 UI 启动时,我们可以安全地退役旧 UI 模块。这减少了大量的依赖关系和软件发布的风险。
另一方面,重用视图和控制器并不常见。如前一个图所示,视图依赖于模型,控制器依赖于视图和模型。重用任何一个都会将模型作为传递依赖项,锁定要使用的模型。此外,控制器通常与视图紧密耦合,几乎没有重用的空间。
MVC 的实际示例
我们将应用 MVC 架构来构建一个面向家庭的、用于与服务邻居交换服务(即“合同”)的前端应用程序。我们将使用 Android Studio 和 Android 软件开发 工具包(SDK)。
草稿合同应包含基本信息,例如每个家庭的名称和要交换的服务。家庭使用移动应用程序创建草稿合同记录。作为视图的应用程序只有两个屏幕,如下所示:
图 5.2 – 草拟合同的示例移动应用程序
第一个屏幕允许家庭输入其名称和提供的服务。在这种情况下,是史密斯家庭提供清洁服务。另一方面,家庭输入邻居的名称和交换的服务。具体来说,这里是由李家庭提供管道服务。
屏幕布局由位于 /app/src/main/res/layout
项目文件夹下的 XML 文件作为资源定义。例如,您的家庭 文本字段声明如下:
<EditText
android:id="@+id/your_household_name_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:ems="10"
android:text="Name"
app:layout_constraintTop_toBottomOf="@id/your_household_header"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/your_household_service_edit" />
XML 块定义了视图为一个具有分配 ID、对齐方式和尺寸的 EditText
组件。在 UI 中显示的副本(即显示文本)可以在单独的文件中定义,并使用标识符绑定到这里。这分离了文案编写和视觉布局的关注点。
在 XML 布局中的视图组件可以声明性地绑定到数据源,使用 Android Jetpack 库。当模型中的数据发生变化时,视图会自动更新,无需在代码中进行手动更新。这种机制创建了一个更动态和响应式的用户界面。数据绑定可以表示如下 XML:
<data>
<variable
name="household"
type="com.example.Household" />
</data>
数据定义后,视图可以绑定到模型。在以下示例中,TextView
显示 Household
的名称:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{household.name}" />
当点击 提交 按钮时,应用程序导航到确认屏幕。在屏幕上,它确认草稿合同已提交,并显示交换服务的合同给用户。
模型应该有两个数据类。HouseholdInput
捕获家庭的名字和提供的服务,而 DraftContractInput
包含两个 HouseholdInput
对象以形成一个合同。这两个数据类如下所示:
data class DraftContractInput(
val initiator: HouseholdInput,
val neighbor: HouseholdInput
)
data class HouseholdInput(
val householdName: String,
val serviceProvided: String
)
此外,还有一个用于处理草稿合同提交的示例仓库类,ContractRepository
。仓库类如下所示:
class ContractRepository {
fun submit(contract: DraftContractInput): Boolean {
return true.also {
println("Persisted contract: $contract")
}
}
}
当前仓库的实现并没有做任何事情,但可以增强以验证和持久化草稿合同。
MVC 模式中的Controller
接口是协调 View 和模型之间的接口。在这个例子中,定义了一个接口来描述 Controller 可以做什么:
interface Controller {
fun submitContract(contract: DraftContractInput)
}
MainActivity
类实现了Controller
接口,并且是 Android SDK 中的AppCompatActivity
的子类。它设置 View 的内容,控制屏幕导航,并与模型连接:
class MainActivity : AppCompatActivity(), Controller {
private val contractRepository: ContractRepository = ContractRepository()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val contractDraftFragment = ContractDraftFragment()
supportFragmentManager.beginTransaction().replace(R.id.fragment_container, contractDraftFragment).commit()
}
到MainActivity
类的这部分,它从模型中创建了一个ContractRepository
对象。它还创建了一个ContractDraftFragment
屏幕作为第一个应用程序屏幕,如图 5.2中左侧所示。Controller
接口中的submitContract
函数调用模型中的函数以提交草稿合同:
override fun submitContract(contract: DraftContractInput) {
contractRepository.submit(contract)
val bundle = Bundle()
bundle.putString("yourHouseholdName", contract.initiator.householdName)
bundle.putString("yourHouseholdService", contract.initiator.serviceProvided)
bundle.putString("yourNeighborName", contract.neighbor.householdName)
bundle.putString("yourNeighborService", contract.neighbor.serviceProvided)
val confirmationFragment = ConfirmationFragment()
confirmationFragment.arguments = bundle
supportFragmentManager.beginTransaction().replace(R.id.fragment_container, confirmationFragment).commit()
}
在 Controller 将 View 导航到确认屏幕之前,它创建一个包含提交数据的Bundle
对象。数据传递给ConfirmationFragment
,这是图 5.2中右侧显示的屏幕。ContractDraftFragment
将用户操作发送到 Controller 以提交草稿合同。这是在ContractDraftFragment
类的onCreateView
函数中实现的:
class ContractDraftFragment : Fragment() {
lateinit var controller: Controller
lateinit var inflated: View
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
inflated = inflater.inflate(R.layout.fragment_contract_draft, container, false)
controller = activity as Controller
在 View 被填充之后,可以设置一个点击监听器到EditText
组件,然后提取这些组件:
Android SDK 中的基本元素
Android SDK 提供了一套全面的工具和组件,用于构建 Android 应用程序。本章中使用了几个关键元素。Activity 是一个带有 UI 的单个屏幕,作为用户与应用程序交互的入口点。Fragment 是 Activity 的模块化部分,它们可以在多个 Activity 之间重用。View 是 UI 的基本构建块,例如按钮、文本字段和图像。Layout 是以 XML 格式声明的 UI 定义,它指定了 View 在屏幕上的排列方式。
inflated.findViewById<Button>(R.id.submit_button)
?.setOnClickListener {
controller.submitContract(
DraftContractInput(
initiator = HouseholdInput(
householdName = inflated.findViewById<EditText>(R.id.your_household_name_edit).text.toString(),
serviceProvided = inflated.findViewById<EditText>(R.id.your_household_service_edit).text.toString(),
),
neighbor = HouseholdInput(
householdName = inflated.findViewById<EditText>(R.id.your_neighbor_name_edit).text.toString(),
serviceProvided = inflated.findViewById<EditText>(R.id.your_neighbor_service_edit).text.toString(),
)
)
)
}
return inflated
}
}
从EditText
组件中提取的值创建了一个来自模型的DraftContractInput
对象。然后,将DraftContractInput
对象提交给ContractRepository
类以进行进一步处理。
最后,ContractDraftFragment
检索从上一屏幕传递过来的Bundle
对象。然后,使用Bundle
对象中的数据设置 View 组件,以显示包含草稿合同详细信息的确认屏幕:
class ConfirmationFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val inflated = inflater.inflate(R.layout.fragment_confirmation, container, false)
val yourHouseholdName = arguments?.getString("yourHouseholdName")
val yourHouseholdService = arguments?.getString("yourHouseholdService")
val yourNeighborName = arguments?.getString("yourNeighborName")
val yourNeighborService = arguments?.getString("yourNeighborService")
inflated.findViewById<TextView>(R.id.your_household_summary).text =
"Your household \"$yourHouseholdName\" providing ${yourHouseholdService}"
inflated.findViewById<TextView>(R.id.your_neighbor_summary).text =
"your neighbor \"$yourNeighborName\" providing ${yourNeighborService}"
return inflated
}
}
在这个例子中,用户首先看到 UI 并填写文本字段。然后,用户点击提交按钮。这个用户操作调用 Controller 通过导航到确认屏幕来更新 View。Controller 还通过将草稿合同对象提交到仓库来更新模型。最后,用户看到确认屏幕。
值得指出的是,模型(Model)中的代码仅关注业务数据和逻辑,而视图(View)则完全负责渲染用户界面。控制器(Controller)负责视图和模型之间的所有协调,这就是 MVC 模式中如何实现 SoC(分离关注点)。
接下来,我们将转向 MVP,这是一种可以看作是从 MVC 演变而来的架构模式。
MVP
MVP 可以被视为从 MVC 模式演变而来的一个模式。MVP 诞生于 20 世纪 90 年代,作为对将 MVC 应用于桌面和 Web 应用程序开发时遇到的局限性和挑战的回应。
MVP 建立在 MVC 的概念之上。视图和模型的概念在这两种模式之间是共享的,但视图和模型之间的交互方式发生了显著变化。
MVC 的主要局限性在于视图(View)和控制器(Controller)之间的紧密耦合。这导致独立测试视图和控制器的能力有限。此外,当视图复杂且包含大量展示逻辑时,测试和维护变得困难。
此外,对当前视图的任何变化或扩展都不可避免地需要控制器成比例的变化,因为控制器需要理解所有存在的变体和扩展。
MVP 通过引入演示者(Presenter)来促进视图和模型之间更加解耦的关系。以下图表展示了视图、模型和演示者之间新的关系:
图 5.3 – MVP 模式
如您所见,用户看到视图并与用户界面交互。然后视图将用户操作发送给演示者。演示者更新模型中的数据。当数据更新时,模型会回调演示者。然后演示者根据模型中变化的数据更新视图。最终,用户根据之前的交互看到更新后的用户界面。
与 MVC 的区别
MVP 与 MVC 之间的第一个明显区别是模型和视图不直接交互。所有消息都通过演示者(Presenter)传递,演示者依赖于视图和模型。视图是自包含的,不依赖于模型或演示者。
第二个区别是视图可以完全被动,即它只负责根据演示者请求更新视图来渲染用户界面。然而,被动视图被认为是 MVP 的一个选项。被动视图是 MVP 的一个变体,其中视图不做出任何决策,也不包含任何业务逻辑。
如果不是被动视图,视图可以包含某些不影响模型或演示者的展示逻辑。例如,简单的字段验证,如数据类型和长度。
第三个区别是 MVP 通常强制执行与视图和模型的显式通信。视图通知 Presenter 用户操作,然后 Presenter 解释并采取行动。定义显式通信有助于工程师理解和推理应用程序的行为,从而使得应用程序更容易维护。
MVP 的好处
在视图、模型和 Presenter 之间进一步实现 SoC 带来了 MVC 模式之上的几个好处。
在 MVP 中,视图可以独立测试,无需 Presenter 的参与。显式传入视图的消息可以用作测试用例的输入,UI 用于验证。另一方面,用户交互可以是测试用例的输入,视图显式传出的消息用于验证。
使用 MVP,现在可以拥有没有改变 Presenter 的视图变体。视图变体以不同的方式解释来自 Presenter 的消息。变体可能从稍微不同的用户交互中产生相同的消息给 Presenter。这样,就无需更改 Presenter 以支持视图的变体。如今,在 iOS 应用程序、Android 应用程序、Web 应用程序以及电视等定制设备等不同平台上,有如此多的 UI 变体。这些不同的视图可以共享相同的模型和相同的 Presenter,同时仍然为用户提供针对平台的独特体验。
MVP 明确标准化了发送给和接收由 Presenter 的消息。它确保没有混淆,也没有不同框架或实现偏差的空间。它为工程师带来清晰,使应用程序易于理解。
MVP 的真实生活示例
我们继续使用之前用于 MVC 模式的相同示例。我们使用 MVP 中使用的相同真实生活示例,其中一家家庭可以与另一家家庭起草一项交换服务的合同(即“合同”)。
从 MVC 模式演变而来,在 MVP 中,视图和模型可以保持不变,但通信方式将改变。
尽管声明的函数没有改变,但 Controller
接口被重命名为 Presenter
:
interface Presenter {
fun submitContract(contract: DraftContractInput)
}
Presenter 与视图和模型都有双向通信。因此,第一个变化是支持当模型发生变化时的回调:
typealias DraftContractSubmittedListener = (DraftContractInput) -> Unit
DraftContractSubmittedListener
类型别名在草案合同提交给模型时充当回调接口。ContractRepository
类被增强以保持一个 DraftContractSubmittedListener
对象,并在提交草案合同时调用回调。仓库类的实现如下所示:
class ContractRepository {
var onSubmitListener: DraftContractSubmittedListener? = null
fun submit(contract: DraftContractInput): Boolean {
return true.also {
onSubmitListener?.invoke(contract)
}.also {
println("Persisted contract: $contract")
}
}
}
当模型更新时,回调函数可用于导航到带有提交的草案合同的确认屏幕。这是 MainActivity
类中更新的 submitContract
函数:
override fun submitContract(contract: DraftContractInput) {
contractRepository.onSubmitListener = {
val confirmationFragment = ConfirmationFragment()
confirmationFragment.lastSubmittedContract = it supportFragmentManager.beginTransaction().replace(R.id.fragment_container, confirmationFragment).commit()
}
contractRepository.submit(contract)
}
另一方面,ConfirmationFragment
类被更新。屏幕直接从刚刚从 DraftContractSubmittedListener
设置到 MainActivity
的提交草稿合同中获取值:
class ConfirmationFragment : Fragment() {
lateinit var lastSubmittedContract: DraftContractInput
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val inflated = inflater.inflate(R.layout.fragment_confirmation, container, false)
lastSubmittedContract?.also {
inflated.findViewById<TextView>(R.id.your_household_summary).text =
"Your household \"${it.initiator.householdName}\" providing ${it.initiator.serviceProvided}"
inflated.findViewById<TextView>(R.id.your_neighbor_summary).text =
"your neighbor \"${it.neighbor.householdName}\" providing ${it.neighbor.serviceProvided}"
}
return inflated
}
}
与 MVC 示例相比,这个 MVP 示例的主要区别在于确认屏幕的加载是由模型的变化触发的,使用了回调函数。这样,视图和模型之间的所有通信都通过演示者(Presenter)进行。现在可以独立测试 ConfirmationFragment
类中的视图逻辑、ContractRepository
类中的模型逻辑和 MainActivity
类中的演示者逻辑。
在 MVP 之后,我们将深入探讨 MVVM 模式,该模式也旨在解决 MVC 和 MVP 相同的问题。
MVVM
MVVM 首次由微软的 John Gossman 在 2005 年引入。它是为了使用 Windows Presentation Foundation (WPF) 框架进行 UI 开发而创建的。后来它被其他框架采用,如下所示:
-
React
-
Xamarin
-
AngularJS
MVVM 基本上保留了从较老的模型 MVC 和 MVP 中继承的模型和视图的概念。然而,它使用视图模型作为视图和模型之间的中介。还有大量偏好使用声明性数据绑定而不是编码。MVVM 的数据绑定功能支持视图和视图模型之间的自动同步。
视图模型
视图模型公开视图可以绑定到其上的数据和命令。数据绑定允许视图和视图模型之间自动双向同步。换句话说,视图模型中的更新会自动反映在视图中,视图模型也可以对视图中的用户操作做出反应。这减少了手动在视图和视图模型之间同步数据以及更新视图的样板代码。以下图表将更详细地说明视图、模型和视图模型之间的交互:
图 5.4 – MVVM 模式
如您所见,视图模型从模型读取数据,将其转换为展示格式,并为视图提供属性和函数。视图接收属性和函数回调以渲染用户界面。
在 图 5.5 中演示了一个用户与视图交互并最终在视图中看到变化的用例:
图 5.5 – 用户与视图交互并看到视图中的变化
当用户与视图交互时,用户操作会被传播到视图模型。视图模型执行命令以响应用户操作,这通常涉及更新模型中的数据。一旦模型中的数据被更新,视图模型就会接收到数据更新并将其转换为用于展示的形式。最后,视图接收到视图模型的变化并渲染 UI 作为对用户操作的响应。
MVVM 的好处
MVVM 将业务逻辑的关注点进一步从视图的展示中分离出来。视图模型封装了展示逻辑,无需关心其视觉呈现方式。这意味着视图模型可以在隔离状态下进行测试,独立于 UI 的具体细节。
视图模型中的属性和命令可以在保持多个视图(如网页浏览器、桌面应用程序和移动应用程序)之间用户体验一致性的同时共享。此外,可以在不更新视图模型的情况下对视图进行更改。这促进了代码库的灵活性和可扩展性。
UI 设计师可以更好地与前端工程师协作。UI 设计师可以专注于样式、布局和视觉组件,无需干扰业务逻辑。这促进了并行工作并提高了生产力。
视图和视图模型之间的自动同步减少了手动更新视图的样板代码,反之亦然。
MVVM 的真实生活示例
从我们之前用于 MVP 模式的示例继续,代码库将演变为 MVVM 模式。我们使用与 MVC 和 MVP 相同的真实生活示例,并使用 Android SDK 从它们中演变代码库。
视图模型被引入并命名为 DraftContractViewModel
,用于在它准备好成为草稿联系人之前存储过渡数据:
class DraftContractViewModel : ViewModel() {
var yourHouseholdName: String? = null
var yourHouseholdService: String? = null
var yourNeighborName: String? = null
var yourNeighborService: String? = null
}
DraftContractViewModel
对象将在 Fragments
之间共享,以继续构建草稿合同所需的数据。这反映在可变字段(在 Kotlin 中称为 var
)。此外,视图模型充当视图和模型之间的桥梁。toModel
函数将视图模型 DraftContractViewModel
对象转换为模型 DraftContractInput
对象:
fun DraftContractViewModel.toModel(): DraftContractInput? =
if (yourHouseholdName != null
&& yourHouseholdService != null
&& yourNeighborName != null
&& yourNeighborService != null
) {
DraftContractInput(
initiator = HouseholdInput(
householdName = yourHouseholdName!!,
serviceProvided = yourHouseholdService!!
),
neighbor = HouseholdInput(
householdName = yourNeighborName!!,
serviceProvided = yourNeighborService!!
)
)
} else {
null
}
注意,已经应用了数据完整性逻辑到该函数中,以确保模型对象只能在所有字段都存在的情况下创建。这种转换逻辑也可以独立测试,如下所示:
@Test
fun `do not create model if the view model is empty`() {
assertNull(DraftContractViewModel().toModel())
}
@Test
fun `create model when all fields are present`() {
val viewModel = DraftContractViewModel().apply {
yourHouseholdName = "Smith"
yourHouseholdService = "Cleaning"
yourNeighborName = "Lee"
yourNeighborService = "Cooking"
}
val model = DraftContractInput(
HouseholdInput("Smith", "Cleaning"),
HouseholdInput("Lee", "Cooking")
)
assertEquals(model, viewModel.toModel())
}
这个单元测试由 null
运行。第二个测试创建了一个所有字段均非 null
的视图模型对象,因此它可以被转换为模型对象。
视图和视图模型之间的数据绑定和同步是自动进行的,使用以下自定义函数:
fun EditText.bind(consume: (String) -> Unit) {
consume(text.toString())
addTextChangedListener {
consume(it.toString())
}
}
初始时,当创建EditText
视图组件时,默认值设置为视图模型DraftContractViewModel
对象。随后,任何文本更改都会触发一个回调来更新视图模型。数据绑定过程在第一个屏幕的onCreate
函数中启动,由ContractDraftFragment
类表示:
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val inflated = inflater.inflate(R.layout.fragment_contract_draft, container, false)
val command = activity as Command
val viewModel = ViewModelProvider(activity as AppCompatActivity).get(DraftContractViewModel::class.java)
视图模型在这里查找,并将所有者设置为活动,以便它可以与下一个屏幕共享。然后,在点击toModel
函数时注册一个回调,用于将视图模型转换为模型对象:
inflated.findViewById<Button>(R.id.submit_button)
?.setOnClickListener {
viewModel.toModel()?.let {
command.submitContract(it)
}
}
然后使用前面提到的bind
函数,逐字段将EditText
视图组件与视图模型绑定:
inflated.findViewById<EditText>(R.id.your_household_name_edit)?.bind {
viewModel.yourHouseholdName = it
} inflated.findViewById<EditText>(R.id.your_household_service_edit)?.bind {
viewModel.yourHouseholdService = it
} inflated.findViewById<EditText>(R.id.your_neighbor_name_edit)?.bind {
viewModel.yourNeighborName = it
} inflated.findViewById<EditText>(R.id.your_neighbor_service_edit)?.bind {
viewModel.yourNeighborService = it
}
return inflated
}
确认屏幕直接从视图模型获取提交的数据:
class ConfirmationFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val inflated = inflater.inflate(R.layout.fragment_confirmation, container, false)
val viewModel = ViewModelProvider(activity as AppCompatActivity).get(DraftContractViewModel::class.java)
视图模型在这里通过活动作为所有者与第一个屏幕共享来查找。然后,视图从相同的视图模型对象获取数据。现在有两个屏幕共享相同的视图模型对象,但视图的渲染方式不同:
inflated.findViewById<TextView>(R.id.your_household_summary).text =
"Your household \"${viewModel.yourHouseholdName}\" providing ${viewModel.yourHouseholdService}"
inflated.findViewById<TextView>(R.id.your_neighbor_summary).text =
"your neighbor \"${viewModel.yourNeighborName}\" providing ${viewModel.yourNeighborService}"
return inflated
}
}
此示例中还可以添加一些其他潜在功能。当用户在文本字段中输入文本时,同步视图模型的回调函数会不断被调用。这使得向用户提供实时验证反馈成为可能。
由于视图模型在同一个活动中的多个屏幕之间共享,视图可以演变为一组屏幕,作为向导式多步骤活动。
此外,如果模型在两个应用程序的两个副本之间共享,可以使用某种协议(如对等网络(P2P))实时同步草拟合同的实时数据。当模型从网络外部更新时,模型更改的回调函数可以填充视图模型,然后实时更新视图。
比较 MVC、MVP 和 MVVM
MVC、MVP 和 MVVM 模式共享视图和模型的概念。然而,它们的关系和通信方式不同。
在 MVC 中,一个控制器可以访问多个视图,并且通常直接调用视图的功能。视图和模型紧密耦合。用户输入由控制器处理。单元测试仅限于业务逻辑的模型。修改视图或模型需要更改控制器。它仅适用于小型项目,因为代码占用空间最小。
在 MVP 中,一个演示者管理至少一个视图。视图和模型不知道演示者,但它们通过回调函数与演示者通信。此外,视图和模型是解耦的。用户输入由视图处理,然后视图调用演示者提供的回调函数。单元测试可以在模型上的业务逻辑中进行。模型和视图的回调函数的行为也可以进行测试。如果回调函数保持不变,修改视图或模型可能不需要修改演示者。它适用于从小型到更复杂的项目,这些项目需要更好的模型和视图的可测试性。
在 MVVM 中,一个 ViewModel 映射到一个或多个视图。视图和模型也是解耦的,但业务逻辑与视图的分离更加彻底,通信更加事件驱动,并得到底层框架的支持。用户输入由视图处理,然后视图调用 ViewModel 提供的回调函数。单元测试可以在模型上的业务逻辑中进行。模型、视图和 ViewModel 的回调函数的行为也可以进行测试。
关注点更容易分离,功能可以足够小,以符合 单一职责原则 (SRP)。如果数据绑定没有改变,修改视图或模型可能不需要修改 ViewModel。它适用于可能部署到多个平台和因此需要更好的视图和模型之间隔离的大型和复杂项目。
比较总结在下表中:
MVC | MVP | MVVM |
---|---|---|
视图和模型紧密耦合。 | 视图和模型解耦。 | 视图和模型解耦。 |
控制器依赖于视图和模型。 | 一个演示者通过回调函数管理至少一个视图。 | 一个 ViewModel 通过回调函数管理至少一个视图。 |
控制器处理用户输入。 | 视图处理用户输入。 | 视图处理用户输入。 |
仅在模型上进行单元测试。 | 在模型和视图上进行单元测试。 | 在模型、视图和 ViewModel 上进行单元测试。 |
表 5.1 – MVC、MVP 和 MVVM 的比较
除此之外 MVC、MVP 和 MVVM
更多的架构模式已经从我们之前讨论的三个模式中演变而来。这些模式的深入比较超出了本章的范围,但它们值得提及:
- 模型-视图-意图 (MVI): MVI 在 2010 年左右作为受 MVC、MVP 和 MVVM 影响的模式在 Android 社区中兴起。MVI 对单向数据流有独特的关注,以简化状态管理。它还采用响应式编程范式,通过 RxJava 和 RxJS 等库异步管理单向数据流。它们之间的交互在 图 5.6 中展示:
图 5.6 – MVI 交互
-
原子设计:由布拉德·弗罗斯特在 2013 年他的书《原子设计》中提出,原子设计将 UI 分解为五个级别:
-
原子:如文本字段等基本构建块
-
分子:如搜索栏等原子的功能组织
-
有机体:如导航面板等分子的部分组织
-
模板:构成页面的有机体的视觉布局和组织
-
页面:具有真实内容的业务感知和特定页面实例
-
-
基于组件:专注于将功能分离成自包含和可重用的组件。每个组件封装了行为,隐藏了其底层细节,并通过接口仅暴露高级功能。它与包含具有良好定义行为的可重用组件库的设计系统相结合。它的目的是在开发过程中提供一致的用户体验并优化生产力。每个组件都是独立开发、测试和部署的,这使得更新应用程序变得更容易。
-
服务器端渲染(SSR):SSR 主要用于网页开发,但可以扩展到移动和桌面应用程序。页面的内容是在服务器上生成的,而不是在浏览器或客户端设备上。它的目的是通过减少浏览器或客户端设备上的负载来提高性能和响应时间,这些设备具有不同的计算能力。数据获取的优化可以在服务器端进行,从而减少客户端和服务器之间的不必要流量。然而,这确实增加了服务器处理负载。
摘要
我们以 MVC 模式开始本章。我们探讨了模型、视图以及控制器如何与它们交互。我们还用一个现实生活中的例子来说明这些概念,即家庭之间服务交换合同的起草。示例中使用了起草和确认屏幕的 UI,控制器驱动导航并提交草案合同。
然后,我们通过从 MVC 演化引入了 MVP 模式。引入了演示者来替代控制器,并将从模型中添加的回调函数添加到模式中,以进一步隔离视图和模型。然后,我们回到了同一个现实生活中的例子,并将 MVC 的原始代码库修改为 MVP。
之后,我们通过从 MVP 演变而来介绍了 MVVM 模式。在视图和模型之间添加了视图模型作为中介。支持数据绑定以自动同步视图和视图模型之间的数据。我们重用了相同的真实生活示例并将其重构为 MVVM 模式。引入了一个自定义的绑定函数,用于将视图组件中的值绑定到视图模型中的字段。提交时,视图模型可以转换为模型对象。我们讨论了 MVVM 模式提供的潜在增强,例如实时验证、视图的分布式处理和视图的多种变体。
我们从多个角度比较了这三种模式。我们讨论了视图和模型之间的关系以及在每个模式中它们是如何耦合的。然后比较了三种模式之间的通信方式以及单元测试的简便性。我们考察了在每个模式中需要的修改链。最后,我们讨论了根据项目规模选择哪种模式更合适。
最后,我们介绍了前端领域中从 MVC、MVP 和 MVVM 演变而来的几种架构模式。
在下一章中,我们将探讨另一组架构模式,并进行比较:单体架构、微前端、微服务、纳米服务和无服务器架构。它们应用于前端或后端系统,但它们确实共享一些值得讨论的相似的基本概念。
第六章:微服务、无服务器和微前端
在本章中,我们将深入探讨微服务、无服务器和微前端架构风格。它们彻底改变了我们设计、开发和部署应用程序的方式。它们还使组织能够构建强大、灵活和可扩展的系统。我们将探讨每种方法的根本原则、独特特性和实际益处。到本章结束时,您将全面了解这些架构及其在现代软件工程中的应用。
我们的探索从描述传统的单体架构开始,其中整个系统被设计和发展为一个单一单元。然后,我们将讨论许多开发者面临此方法带来的挑战,从而产生将单体分解为更小、松散耦合组件的需求。
然后,我们将关注微服务架构,并检查微服务如何解决单体架构的挑战。我们将介绍如何将单体转换为微服务,然后讨论随之而来的分布式架构风格的益处、挑战和权衡。
接下来,我们将讨论无服务器计算如何帮助开发者专注于编写代码,而无需担心基础设施。我们将讨论无服务器架构的最佳用例,并解决与此范式相关的挑战。
最后,我们将探讨如何将单体应用程序转换为微服务和微前端。我们将讨论自包含组件如何使开发者受益以及如何与微服务和无服务器后端集成。
本章将涵盖以下主题:
-
单体架构
-
微服务
-
纳微服务
-
无服务器
-
微前端
技术要求
您可以在 GitHub 上找到本章中使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-6
单体架构
单体意味着“由一块石头制成”。在软件架构的背景下,单体指的是作为一个单一单元设计和开发的庞大系统。在单体架构中,通常有一个单一的代码库、统一的数据库和一个可部署的工件。
拥有一个单一的代码库意味着它通常依赖于个别开发者的努力来保持代码整洁和干净。由于所有代码都托管在同一个地方,因此很难通过设计来强制实施关注点的分离。这通常会导致应用程序的所有组件、模块和功能紧密耦合和相互依赖。
在单体应用中,通常只有关系型数据库,可能会产生一个主要的——如果不是唯一的——模式,该模式包含所有功能的所有实体。此外,每个实体表将包含所有涉及实体的业务相关列。表格之间还可能存在一个复杂的键约束网。它通常将用于事务和报告的数据库的担忧合并为一个。
虽然单体在代码质量和数据库设计方面范围很广,但它们有一个共同点:单体应用有一个可部署的工件,包含整个系统。它很大,发布需要很长时间。发布过程通常需要首先关闭所有单体应用实例,然后进行基础设施更改,新的可部署工件替换旧的工件,应用开始更新版本。每次发布通常还需要进行密集的预先规划和团队间的协调,以确定依赖关系。发布计划可能看起来像甘特图或项目计划,如图6.1所示:
图 6.1 – 发布计划作为甘特图
在这个发布计划中,开发团队首先关闭单体应用。然后,数据库团队备份数据库。之后,开发团队开始部署应用,同时基础设施团队应用更改,如网络或中间件升级。当这一切完成时,开发团队启动应用。最后,质量保证团队验证环境并签署发布。一些组织可能有一个专门的发布团队或运营团队,为每次发布制定一个操作手册,并作为一个团队执行计划。
发布计划被可视化为一个甘特图,以显示团队之间的依赖关系,并显示从左到右的发布时间线。
接下来,我们将介绍单体方法的优点和挑战,作为介绍即将到来的三种架构风格的基础。
优点
现在,很少有组织会公开倡导单体架构作为最佳风格。然而,还有一些优点使得它被认为是一种架构选择:
-
简单性:单体应用具有一个代码库、一个数据库和一个可部署工件的简单性。一旦建立构建、部署和运行的常规,开发者就可以遵循这个随着时间的推移重复的模式。正如我们在第二章中提到的,这被称为你不是真的需要它(YAGNI)原则。如果这种简单性是我们现在所需要的,那么最初采用单体方法不是一个坏主意。
-
上市时间短:这种情况通常与在初创公司工作的开发者产生共鸣,在初创公司中,上市时间是首要任务,其他一切都不重要。有时,这甚至是一个立即行动或放弃的情况。这也适用于可能不需要复杂架构的实验性应用程序。
-
先构建,后优化:单体架构的另一个好处是情境性的。如果业务领域是新的,每个人都正在寻找如何将系统构建成产品的路径,那么推迟任何重构或优化是有益的——也就是说,直到包括技术和非技术利益相关者在内的每个人都对这个主题有更多经验,并认识到需要分解单体应用程序的需要。此外,在尝试通过合理的边界将其分解并确定哪些功能自然地一起使用之前,更好地了解业务生态系统是更好的。
以单体架构为首选
这种有意识地选择单体架构的方法被称为以单体架构为首选。这个术语是由马丁·福勒在 2015 年的工程博客中推广的。
挑战
虽然单体架构已被广泛使用,但它有一些缺点。组件之间的紧密耦合使得独立更新应用程序的特定部分变得困难。换句话说,改变系统的一部分可能会无意中影响其他部分:
-
缓慢的开发过程:这使得任何变更都比实际需要的更大,因此增加了发布的风险。由于每个变更都更大,工程师之间发生代码冲突的概率显著更高。工程师将花费更多时间审查代码和解决代码冲突。需要更多的测试区域来确保系统功能正常。这些因素共同导致开发周期变慢,灵活性降低。
-
难以扩展和调整性能:此外,在单体架构中扩展资源可能效率低下,因为整个应用程序需要被复制,而不是仅需要更多资源的单个组件。由于可能存在其他进程与同一资源竞争,因此精确调整性能也更加困难。
-
耗时的测试套件:在单体应用程序中,不同的组件和模块紧密耦合,这意味着应用程序某一部分的变更可能会在其他部分产生意外的后果。测试需要验证没有对业务案例造成意外的变更,导致测试场景更加复杂,测试执行时间更长。相互依赖性也使得独立隔离和运行测试变得困难,限制了并行执行和缩短测试执行时间的潜力。即使是单体应用程序中的微小变更也需要回归测试。这需要一套全面的测试用例,这可能很耗时。
-
风险高、周期长、规模大:发布单体应用通常需要很长时间,因为整个系统作为一个单元进行部署。即使是微小的更改,也会导致整个单体需要重新部署,这意味着持续交付系统变得更加困难。更糟糕的是,由于单体无法快速和持续地部署,可能会积累更多更改以定期发布。工程师可能会花费一个漫长的夜晚来发布单体应用,而这些漫长和晚上的时间可能会因为疲劳而引入更多人为错误。
相反,在分布式系统中发布一个小型应用同样具有挑战性。然而,由于更改范围较小,可以使用现代策略,如滚动发布、蓝绿发布或金丝雀发布。这些发布策略可以在工作时间进行,此时可以获得最多的帮助,从而降低人为错误的风险。
-
技术锁定:单体应用通常具有较长的生命周期。这意味着它可能很久以前就选择了一个技术栈。开发者面临的是要么升级技术,这会导致代码库发生重大变化,要么在某些代码库的部分采用不同的技术,这会导致多个工具做相同的工作。这也意味着在技术上进行实验的空间严重受限。
-
整个系统故障:单体应用的故障很容易导致整个系统故障。即使是操作部分也可能关闭,因为它是一个单体单元的一部分。由于组件之间没有明确的分离,因此隔离和限制故障变得更加困难。
-
团队依赖:最后,多个团队共享一个可部署的工件,可能还有同一个代码库,这会创建大量的依赖。一个团队可能已经完成了一个需要尽快发布的特性,而另一个团队可能仍在开发一个尚未准备好的特性。
-
上市时间慢:由于它们共享一个可部署的工件和一个单体,第一个团队可能无法在其他团队完成他们的工作之前及时将他们的功能推向市场。这种缓慢的上市时间可能意味着竞争对手可能已经抓住了机会,并在单体发布时吸引了客户。如果系统不断追赶竞争对手,这对业务是有害的。
有了这些,我们已经设定了工程师在实施单体应用时面临的挑战的背景。接下来,我们将探讨旨在克服这些挑战的架构风格。
微服务
在微服务架构这个术语被提出之前,面向服务的架构(SOA)的概念在 21 世纪初变得流行,作为对单体架构提出的挑战的早期回应。
SOA 强调将业务功能封装到独立的服务中。每个服务都有一个明确的接口,并与其他服务通信。用于通信的标准协议如 企业服务总线(ESB)被使用。SOA 中正式化的原则和概念为分解单体提供了未来微服务发展的基础。
2011 年,随着参与者越来越意识到新架构的出现,微服务一词在软件架构研讨会上被提出。2012 年,微服务一词正式确定。詹姆斯·刘易斯 和 弗雷德·乔治 是这一风格的最初主要贡献者。
大约在同一时期,像 Netflix 和 Amazon 这样的公司也在尝试类似的架构模式。Netflix 通过采用其可扩展流媒体平台的架构来推广微服务,发挥了重要作用。他们在各种会议和博客文章中分享了他们的经验和见解,从而促进了微服务日益增长的兴趣和理解。
接下来,我们将介绍塑造微服务设计和实现的核心理念。
核心理念
首先,一个微服务应该在业务能力或功能级别符合 单一职责原则(SRP)。
让我们考虑一下本书中使用的例子,关于村庄家庭通过合同交换服务的例子。记录家庭信息、谈判合同、执行合同和通知家庭等功能都是微服务的候选者。
一个微服务应该有一个明确的责任,处理单个关注点或业务领域。对于这个例子,我们可以这样定义每个微服务:
-
家庭服务:掌握家庭记录
-
合同服务:维护从起草到完全执行的合同谈判工作流程
-
通知服务:向电子邮件服务提供商发送代理通知请求
如何将系统分解为适当的业务领域的细节将在 第八章 中深入讨论,我们将涵盖 领域驱动开发(DDD)。然而,一些架构问题表明微服务是否有明确的责任:
-
每个微服务不应该由超过一个工程师团队开发。然而,可能会有例外,例如如果责任没有分配给团队。这让我们回到了 第一章,当时提到了 康威定律。建议重新组织团队,以便每个团队都有明确的责任,并且这些责任不与其他团队重叠。重新组织团队以追求更好架构的方法被称为 逆向 康威行动。
-
微服务不应该频繁地与其他微服务通信以完成其功能。更糟糕的是,如果它迭代地调用另一个微服务的端点。这很可能是服务边界“泄漏”的迹象。也许这个微服务需要从另一个微服务中获取的部分应该被带回并归这个微服务所有。
-
微服务在发布过程中不应该依赖于另一个微服务。如果一个微服务因为其他微服务不可用而不可用,这表明可能存在技术依赖。
-
两个微服务在交换消息方面存在相互依赖性可能表明责任定义得不够充分。如果是同步通信而不是异步通信,这个问题就更大。如果服务 A 同步调用服务 B,当服务 B 处理来自服务 A 的调用时,服务 B 会同步调用服务 A。这种情况很容易耗尽请求处理程序池中的所有线程。
-
微服务不应该与其他微服务共享代码库、构建过程、数据库模式以及可部署的工件。共享它们可能会在构建和发布期间暗示微服务之间可能存在依赖关系。你最不希望的是,你的微服务直到另一个微服务部署后才能部署。
出现之前讨论的任何症状可能表明这些微服务已经变成了分布式单体,这比传统的单体应用程序更糟糕。
通信和集成
微服务通过定义良好的接口,即应用程序编程接口(APIs)相互通信。通信是同步的或异步的。
通过 API 发生的同步和异步通信分别由流行的Open API和Async API标准指定:
-
同步通信通常是通过一个微服务向另一个微服务发送请求并在继续执行之前等待响应来实现的。API 可以公开为超文本传输协议/安全(HTTP/HTTPS)、远程过程调用(RPC)、简单对象访问协议(SOAP)等。
-
异步通信通常涉及消息系统,以便微服务可以发送消息并立即继续其执行。其他微服务在消息可用时接收消息。
Webhooks是微服务之间异步通信的替代和流行方式,无需消息系统。而不是响应请求,微服务通常通过 HTTP/REST 协议向另一个微服务发送消息,但不需要响应以继续执行。Webhooks 通常需要静态配置才能与目标端点一起使用。
考虑到四个潜在的微服务示例,它们可以以下方式进行通信:
图 6.2 – 微服务通信的示例
在前面的图中,合同服务需要从家务服务获取家庭数据以验证请求并管理其工作流程。然后,合同服务向通知服务发送请求,以便向家庭发送电子邮件,通知他们工作流程中的任何变化。
可伸缩性和弹性
微服务架构由于其模块化特性,提供了固有的可伸缩性。每个微服务可以根据其特定的资源需求独立扩展。
在我们的微服务示例中,使用模式可能大相径庭。以下是一些可能适用的情况:
-
家务服务:由于家庭数量有限,交通流量较低
-
合同服务:用户最常使用的服务
-
通知服务:电子邮件请求的处理量中等,但没有严格的延迟要求
假设这确实是其使用情况的真实反映,我们可能需要比其他任何服务更多的合同服务实例。或者,其他微服务可以发送异步消息请求通知,结合接收端的排队和批处理机制。然后,合同服务将能够批量处理大量的电子邮件请求;其他微服务不需要等待从合同服务获得响应才能继续其流程。
家务服务经常被请求以从合同服务获取家庭数据。换句话说,家务服务的可用性已经比其他服务更重要。然而,我们也可以考虑在合同服务中保留家庭数据的本地缓存。当家庭数据被创建、更新和删除时,家务服务需要发送异步消息。它可以向所有感兴趣的微服务广播消息,尽管消息可以保持在最后值队列消息结构中,以便其他微服务可以将数据复制到它们的本地存储。
通过结合这些变化来解决可伸缩性、性能、可用性和弹性的问题,这些服务可能会这样通信:
图 6.3 – 更新后的微服务通信示例
注意,系统中不再有同步请求-响应消息。这意味着每个微服务可以在没有其他微服务可用的情况下独立运行,从而提高了系统的弹性。例如,如果家务服务宕机,与家庭数据相关的维护操作将不可用,但所有其他微服务仍然可以正常运行,因为它们使用保存在本地存储中的最后已知家庭数据。
然而,它更多地依赖于消息系统来提供如队列、批处理和最后值队列等功能。消息系统通常配置和部署为基础设施,因此它们比微服务更具有弹性。
即使有同步通信,也有如断路器、隔舱和优雅降级等技术可以用来优雅地应对故障。
想象一下,它只是一个由四个模块组成的单体应用。在这种情况下,这些问题无法单独解决。此外,如果单体应用出现故障,我们最终会面临整个系统故障,而不是部分系统故障。将没有任何操作可用。
可维护性和技术选择
每个微服务都应该有一个代码仓库。微服务在业务能力层面专注于单一职责,因此一个微服务的代码更改不太可能改变另一个微服务中的内容。此外,保持代码更改小可以减少工程师之间的代码冲突机会,这也减少了审查拉取请求所需的时间。随着使用微服务风格的代码的可维护性提高,工程师的生产力也会增加。
由于每个微服务都有自己的项目、构建脚本和代码仓库,因此关于使用技术和库的选择都限制在项目内部。当涉及到使用新库作为依赖项或库的新版本时,工程师可以从一个微服务开始尝试,学习和熟悉它,通过一个微服务证明其工作,然后将经过验证的方法应用到其他微服务中。为微服务专设的代码仓库在尝试新技术时减少了许多风险。它也增加了它们在系统中被使用的可能性,并保持了系统的现代化。
测试和质量保证
将微服务作为黑盒进行测试的范围比测试单体应用的范围小。端到端测试用例可能涉及来自不同模块的行为组合。
然而,在微服务架构中,这些模块已经变成了微服务。有了定义良好的 API,现在可以模拟外部接口的行为,从而使测试用例专注于测试它如何使用和响应外部 API。这简化了测试套件,每个微服务都专注于测试自己的行为。从不同微服务通信的行为的穷举组合可以推断出来,从而消除了通过端到端测试全面测试所有业务案例的需要。
话虽如此,在考虑 URL 路由、安全控制和 API 兼容性等其他因素的同时,端到端测试可能仍然需要验证微服务之间的整体通信。通常,端到端测试只包含关键业务案例,并关注整体系统正确性。
由于微服务使用 API 进行通信对于确保整体系统功能至关重要,合同测试可以引入以验证这些 API 是否符合其规范,以及微服务是否遵守这些规范。这涉及到对 API 的消费者和提供者两端的测试。
消费者根据他们的期望创建合同测试,通过 API 模拟与微服务的交互。这些测试验证消费者的需求是否得到满足。另一方面,提供者执行消费者对其实施的合同测试。这些测试确保提供者满足 API 的要求,并且不会引入任何破坏性变更。
这些改进使得工程师可以更快地验证系统的质量,从而缩短更改的上市时间。我们将在第十三章中深入探讨软件测试。
部署和基础设施
单体应用的一个关键特征是单个主要的可部署工件。相比之下,一个微服务应该有自己的可部署工件。这使我们能够实施持续集成和持续部署(CI/CD)的实践,从而在发布过程中减少或甚至消除停机时间。
在我们的实际生活例子中,以及之前展示的微服务之间的更新通信,每个微服务都可以独立发布。结合滚动部署流程,可以在发布过程中保持系统运行。
此外,每个微服务都可以根据自己的节奏进行部署。无需像在单体应用时代那样等待其他微服务部署。这鼓励工程师在准备就绪时部署微服务,从而加快软件作为产品的上市时间。
微服务架构通常使用如Docker这样的容器化技术以及如Kubernetes这样的容器编排平台。通常,构建微服务会生成其自己的 Docker 镜像,其中已经设置了依赖项和配置。这导致了一致、可重复和可预测的部署,并且每个微服务都有一个隔离的环境。
Kubernetes 提供了一种声明式方法来管理微服务的部署方式。每个微服务的期望状态是通过 Kubernetes 清单文件定义的,包括用作运行微服务的 Docker 镜像。正如我们在实际生活例子中所展示的,每个微服务的多样化需求可以通过在这些清单文件中声明副本数量和资源需求来实现。
复制设置定义了期望运行的家庭服务实例的数量。Kubernetes 的水平 Pod 自动扩展器(HPA)使用这个数字根据资源利用率进行扩展和缩减。
Kubernetes 还提供了微服务发现和相互通信的机制,例如域名系统(DNS)或环境变量。
总体而言,这些工具允许您以自动化的方式配置、扩展和管理部署和运行微服务所需的基础设施。
团队组织
微服务架构与现代团队组织紧密相连。系统被分解为微服务,团队也应如此。
金科玉律是,一个微服务应该由一个——而且只有一个——团队拥有。这个团队对指定微服务的整个开发周期负责并承担责任。
团队应在遵守关于系统中所用技术选择的更广泛指南的同时,在其范围内自主做出小的技术决策。
有更广泛的指南来保持团队之间一定程度的连贯性,例如选择商业消息传递,这样公司可以减少拥有过多技术的复杂性和成本。此外,这些指南为所有团队提供了达成一致的原则和约定,但每个团队都有权决定如何执行和遵守这些指南。
微服务的缺点
虽然微服务提供了诸如模块化、可维护性和可测试性等好处,但它们也带来了一些必须考虑的缺点:
-
增加的复杂性:微服务架构引入了一个具有更多移动部件的分布式系统。微服务之间的通信导致开发、测试和部署的额外复杂性。
工程师必须考虑 API 版本化和兼容性。在 API 中引入破坏性更改将破坏仍停留在较旧 API 版本中的其他微服务。维护向后兼容的 API 或过渡到新的主要 API 版本可能是一个挑战。
在端到端测试套件中使用了多个微服务,这意味着在运行端到端测试用例之前,这些服务必须处于运行状态。更糟糕的是,每个微服务通常由其自己的团队管理,这意味着同时有多个变更流在进行。现在有更多失败端到端测试的原因。可能是某个微服务未能启动,一个微服务的更改最终与其它微服务不兼容,等等。最终,工程团队可能花费大量精力来修复端到端测试。
微服务架构需要更复杂的基础设施,包括服务路由、负载均衡和容器编排。这种基础设施作为配置也可以作为样板,因为每个微服务可能具有相似的配置,但只在少数部分有所不同。管理和维护这种基础设施的开销可能很大,尤其是对于较小的组织或团队。
-
网络延迟和开销:将单体应用分解为微服务意味着本地函数调用变为远程调用。这可能会引入延迟和性能问题,尤其是如果服务在地理上分布的话。
网络通信的开销,包括协议、序列化和反序列化,可能会影响系统的整体性能。
-
分布式数据管理:在微服务架构中,数据通常分布在多个微服务之间,这带来了保持数据可管理和一致性的挑战。数据可能分布在两个微服务之间,也可能在多个微服务中用不同的方式表示。更糟糕的是,微服务之间可能存在不一致的数据,这使得理解整体情况变得困难。
-
监控和可观察性:与单体应用相比,追踪穿越多个微服务的业务旅程可能更具挑战性。有一些技术可以克服这个问题,但它们需要额外的工具和努力。这些技术将在第十一章中介绍。
-
细粒度、不频繁、按需或小型任务:如果你正在运行特定的流程,微服务架构的开销可能会超过其带来的好处。例如,如果需要将用户活动摘要报告导出为文件并每月上传到 SFTP 文件夹,那么很难证明建立一个每月只使用一次的长期运行微服务的合理性。类似的情况也适用于按需触发的任务。
当范围太小,无法证明开销的合理性时,微服务架构有另一种方法。我们将在下一节中讨论这一点。
纳微服务
如其名所示,纳微服务架构将微服务的原则进一步细化。微服务专注于将单体应用分解为小型、独立和松散耦合的服务,每个服务对应一个业务能力。纳微服务通过将系统分解为极其细粒度、单一用途的组件,将这一概念进一步深化。
纳微服务旨在处理高度特定、自主、独立和原子化的功能。纳微服务通常负责整体系统中的单个任务或一小块逻辑。每个纳微服务都有一个可部署的工件,并且可以独立部署。其中一些纳微服务组合起来可以被视为微服务。
使用关于微服务的先前示例,家庭服务可以被分解为几个纳微服务:
-
通过名称获取家庭记录。
-
创建或更新一个家庭记录。
这两个纳米服务共享相同的数据库模式,而只有一个专注于通过名称返回家庭记录。另一个纳米服务专注于执行创建/更新操作,并在家庭记录更新后发送异步消息。
优点
与微服务相比,纳米服务拥有更小的代码库、更少的依赖和更少的资源利用(CPU、内存和磁盘)。这种减少的足迹导致部署和配置更加简单。扩展纳米服务是高效的,因为它只涉及一个函数的需求。
由于纳米服务之间需要的协调和通信较少,纳米服务可以被看作是一个只关注输入、处理和输出的管道单元。
纳米服务的简化复杂性和开销使它们特别适合资源受限的环境、实时系统或对容错和快速扩展至关重要的场景。
缺点
即使管理纳米服务比管理微服务更容易,但将系统分解成纳米服务后,需要管理的服务数量显著增加。这个数量可能超过了管理单个纳米服务的便利性。
纳米服务数量众多可能会带来显著的网络安全和协作挑战。单个纳米服务的小资源足迹在整体资源消耗(CPU、内存和磁盘)方面可能并不理想。
此外,一些纳米服务可能为了不同的操作而共享相同的数据库模式。以我们关于家庭服务的读取和更新纳米服务的例子来说,它们共享相同的数据库模式。如果模式需要演进,那么如何在数据库模式中应用变更,以及随后在两个纳米服务中的变更,将带来额外的复杂性。正因为这些碎片化的担忧,有些人可能会认为纳米服务架构是一种反模式。
在大量高度自治的纳米服务之间维护数据一致性和连贯性可能是一个重大的挑战,需要仔细的设计和协调机制。想象一下,有一个纳米服务用于创建操作,另一个用于更新操作;保持两个纳米服务的验证逻辑一致可能是一个挑战。
在微服务和纳米服务之间做出决定
是否采用一个微服务还是多个纳米服务,是在不同开销之间进行权衡的决定。一般来说,如果功能简单且独立,纳米服务会表现得更好。任何与其他纳米服务协调的努力都应该仔细考虑。
在我们之前的例子中,通知服务与其他服务相比目标简单。它仅仅是将内部事件转换为包含地址的电子邮件,并请求电子邮件服务提供商发送电子邮件。这是一个合适的纳米服务候选者,因为其任务简单。如果我们调整通知服务,使其接受具有通用结构的电子邮件请求,那么它就可以成为一个自主且独立的纳米服务,不依赖于其他服务。
管理大量纳米服务的开销仍然是许多工程师的担忧。然而,无服务器架构的出现可能通过让云服务提供商管理纳米服务来解决了这个问题。接下来,我们将考虑无服务器架构。
无服务器
无服务器架构是一种计算风格,在这种风格中,工程师不再需要关注容量规划、配置、管理、维护、弹性、可伸缩性、物理服务器或虚拟服务器。尽管仍然有服务器在运行,但它们被抽象化了。
部署网络服务曾经是一个昂贵的过程。需要购买物理服务器机器(裸机方面)、网络电缆和其他配件,并确保有正确的存储、内存和带宽。操作工程师需要将它们安装并保存在数据中心现场。物理服务器需要正确设置并连接到网络。只有这样,网络服务才能部署到并托管在这些服务器上。
裸机服务器不仅包括购买时的初始成本,还包括持续成本,如电力、从数据中心租赁以及工程师的访问,以确保服务器始终保持在线状态。此外,由于这些物理机器可能遭到损坏或被盗,还存在安全问题。
大多数工程师不是服务器专家。公司可能需要要么培训他们的工程师成为系统管理员,要么使用硬件和网络专业承包商,或者雇佣这些专业工程师与应用程序一起工作。
每当系统需要扩展时,都需要购买新机器或升级现有机器。它们需要配置,以便与其他机器兼容并被应用程序使用。有时,购买和交付新机器需要时间,因此当系统最需要扩展时,扩展并没有发生。
今天,裸机服务器仍然是需要超低延迟和高频处理系统的首选,例如交易系统。
无服务器架构旨在解决裸机服务器时代的问题。让我们看看它是如何做到的。
无服务器架构的概念深深植根于分布式计算。其演化的历史可以追溯到网格计算,其中计算任务被分布在一个机器网络中。
无服务器架构在商业系统中并不流行,直到 2006 年亚马逊推出了亚马逊网络服务(AWS)。AWS 为商业提供了一套服务,使企业可以通过互联网(云)访问计算资源。最初,AWS 提供了弹性计算云(EC2)作为虚拟服务器来运行计算,以及简单存储服务(S3)作为分布式文件存储。
2010 年,微软推出了Azure,并提供了包括虚拟服务器和存储在内的云服务。2011 年,谷歌推出了谷歌云平台(GCP),以在云服务方面与微软和亚马逊竞争。AWS、Azure 和 GCP 仍然是当今最受欢迎的三个云服务,大公司如IBM、Oracle、阿里巴巴和腾讯也提供了云服务。随着云服务提供的多样化,无服务器架构得以实现,并且仍在不断发展。
通过使用云服务来运行应用程序,用户是订阅服务的租户。租户按需租用和使用云中的计算资源,因此成本更具灵活性。云服务取代了采购、配置计算机硬件并在数据中心托管的需求。
云服务提供商提供了四种主要的无服务器服务类别。让我们更详细地了解一下。
基础设施即服务(IaaS)
IaaS 提供基于按需的云计算资源,如虚拟服务器、存储和网络。这就像租用一个空旷的空间,租户必须配置其中的所有内容。
用户被赋予管理员账户,以便他们可以通过管理控制台图形界面、命令行界面(CLI)或如Terraform之类的声明性配置工具来设置基础设施。
以下是一些典型的基础设施提供:
-
虚拟服务器:这些是虚拟化的机器,可以运行租户设置的任何内容。租户需要指定基本需求,例如 CPU、RAM、磁盘空间和网络地址,以便访问服务器。
-
密钥管理:在许多情况下,我们需要将敏感数据作为应用程序的配置来保存。这包括加密密钥和 API 密钥,以及访问数据库的外部系统或凭证。在 IaaS 中,这些密钥可以单独管理,并注入到运行在虚拟服务器中的应用程序的运行时中。因此,这些密钥可以由更少的人查看和管理,并从代码库中抽象出来。云提供商还提供了如密钥轮换和过期等高级功能,以提供额外的安全性。
-
分布式文件存储:云服务提供商提供可扩展且持久的存储服务,这些服务可以被应用程序访问。它们允许租户存储几乎任何大小的文件,并且存储容量会根据需要扩展。它们可以将文件复制到多个位置,以实现冗余和恢复目的。它们还支持文件版本控制,因此可以检索同一文件对象的先前版本。最后,它们支持对文件的细粒度访问控制,并为特定文件提供下载目的的限时访问。
-
数据库:由于有各种各样的选择,托管数据库服务是一个大类别。大多数云服务提供商提供关系型数据库和非关系型数据库,而其中一些还提供特殊类型的数据库,如数据仓库。还有一个供应商和版本列表,为应用程序从裸机迁移到云提供了平滑的路径。它们提供托管服务,处理基础设施配置、升级、扩展、复制、故障转移和监控。其中一些提供高级功能,如数据加密,用于处理敏感信息。
-
消息传递:与数据库类似,托管消息传递服务在云服务提供商那里也是一个大类别。有四种主要的消息传递类型。第一种是一个简单的队列服务,其中消息从发送者发送到接收者。第二种是发布/订阅(Pub/Sub)模式,其中消息通过代理发布到主题,所有订阅该主题的订阅者都会收到该消息。第三种是流式传输,其中消息在发送时作为连续的流被消费。最后一种类型是专门的消息传递服务,针对特定的用例,如电子邮件和移动应用程序通知。
云服务提供商抽象化了设置和管理所需的消息基础设施的复杂性。这些托管服务根据需求进行扩展和缩减,并处理复制、安全和监控问题。
在下一节中,我们将讨论另一个大类别服务,这些服务作为平台而不是基础设施提供服务。
平台即服务(PaaS)
PaaS 为工程师提供了一个云平台,这样他们就可以在不自己设置基础设施的情况下开发、运行和管理应用程序。工程师仍然需要配置和管理他们的应用程序、运行时、数据和服务。配置的细节被抽象化并以声明性方式指定。云服务提供商负责处理底层问题,例如硬件、操作系统和网络设置。
这些服务支持特定的编程语言和框架,使工程师能够专注于应用程序本身。服务处理提供服务器、负载均衡、扩展和监控的细节。
进一步来说,如果我们不想在平台上构建系统,也许我们可以简单地使用并集成现有的软件。这是下一节的主题。
软件即服务(SaaS)
SaaS 提供的是供最终用户使用或与应用程序集成的软件。这项服务涉及租户只需使用一些软件,无需编写代码、管理环境,甚至不需要具备技术知识。这个类别的服务范围从云中的完整可使用软件解决方案和无代码应用程序构建到可以通过 API 与应用程序集成的无头系统。在后一种情况下,租户仍然需要通过其他方式运行他们的应用程序,并设置与 SaaS 服务的网络连接。
这个类别拥有最多样化的软件应用。大多数公司至少会使用一种 SaaS 服务,许多公司都希望在这个开放空间中提供 SaaS 服务。
使用 SaaS(软件即服务)为我们提供了一整套业务功能。当业务功能是必需的但不是组织核心时,这种情况尤为流行。
现在我们已经讨论了更大的单元,比如软件,接下来我们将看看更小的单元,即无服务器架构中的函数。
函数即服务(FaaS)
FaaS(函数即服务)允许工程师编写代码并将其作为函数部署,通常是在响应事件或触发器时。这些函数本身不存储状态,但它们可以利用其他资源,例如文件存储和数据库。工程师不需要管理任何基础设施。它们旨在作为可重用函数,以便在其他函数之上构建高级函数。
云服务提供商根据工作负载对正在执行函数的运行时环境进行扩展。他们还根据函数的使用情况收费,这有助于优化成本。请注意,一些服务有限制,例如最大执行时间、内存使用量和并发进程数。
使用这些服务,我们可以将我们的函数作为代码放入云环境中执行。云服务提供商将为我们完成剩余的工作。
因此,我们已经涵盖了支持无服务器架构的云计算服务的四个类别。接下来,我们将深入探讨如何使用这些服务来构建系统。我们将讨论这些服务的优势,并解释如何使用它们来创建一个现代、可扩展且易于维护的系统。
好处
无服务器架构的核心价值是基础设施问题被云提供商抽象化和实现。以下是随之而来的好处:
-
可伸缩性:无服务器架构可以根据需求自动扩展资源。当应用程序承受重负载时,云服务提供商会动态分配资源来处理增加的负载并确保最佳性能。当工作负载减少或变为空闲时,资源会缩减,从而实现优化成本和有效资源利用。
-
成本效益:云服务提供商提供按使用付费的定价模式,租户根据其实际使用情况付费。这种定价模式优化了成本,消除了购买和维护闲置资源的需求。这对寻找成本效益解决方案的组织来说很有吸引力,尤其是初创公司和中小企业。
-
上市时间:由于启动基础设施以托管应用程序的速度很快,工程师可以将时间集中在开发业务功能和特定功能上。此外,基础设施设置现在比处理每个基础设施组件的细节具有更声明性的配置。这导致开发部署周期更快,能够持续部署更改,并且上市时间更短。
-
适应性和迁移:可用的服务范围允许工程师从大型应用程序托管到小型功能。
许多公司将其系统从裸机单体应用程序迁移到云中的虚拟服务器作为第一步,即将其分解为微服务和功能。由于云服务提供商在基础设施服务方面的全面支持,这种方法比首先分解单体应用程序然后迁移到虚拟服务器更经济高效且更快。
在另一端,有许多 FaaS 支持,只需编写一个小函数来执行小任务。云服务提供商在应用程序大小方面的广泛支持使得工程师适应和迁移现有系统到无服务器架构变得相当容易。
-
支持多种业务领域:无服务器架构适用于事件驱动和高度可伸缩的系统。它通常用于构建微服务、实时处理系统、Web 和移动后端、物联网(IoT)应用程序等。
有许多现成的 SaaS 服务,工程师可以在他们的业务领域集中精力。例如,Amazon 简单电子邮件服务(SES)可用于向客户发送电子邮件,Azure 通知中心可用于向移动设备发送推送通知,而 Google Cloud IAM 可以提供多因素认证(MFA)和 reCAPTCHA 来验证用户的身份。
然而,需要注意的是,无服务器架构可能并不适合所有用例。此外,每个云服务提供商都提供了许多服务,因此您需要谨慎行事,以确保选择合适的服务来满足需求。
注意事项
虽然无服务器架构提供了许多好处,但在采用这种方法时,还有一些重要的缺点需要考虑:
-
冷启动延迟:FaaS 函数是按需启动的,这意味着当函数首次被触发或经过一段时间的空闲后,可能需要一段时间才能启动。这种延迟被称为“冷启动”,发生在云服务提供商动态分配必要资源的过程中。
如果函数被触发的频率不高,这通常会导致冷启动,那么正常请求的延迟会增加。如果应用程序需要无明显的延迟响应,那么应该使用 PaaS 或虚拟服务器。
-
供应商锁定:云服务提供商提供了一系列服务,其中许多提供了专有的 API、框架、运行时环境和甚至语言。虽然从云服务提供商在各个领域的支持是方便的,但过度依赖特定的云服务提供商是很容易发生的。这导致了供应商锁定,使得迁移到另一个提供商变得具有挑战性。
这为迁移到另一个云服务提供商或切换回裸金属基础设施设置了障碍。虽然大多数云服务提供商保持其定价具有竞争力,但许多公司发现,如果出现这种情况,拥有迁移能力至关重要。为此,一些公司选择使用多云架构,并在不同的云平台之间进行数据同步过程。
-
函数粒度:适当地将应用程序分解成更小的函数是服务器无架构的关键方面。然而,将功能分解成过于细粒度的 FaaS 函数可能会导致由于大量函数的调用和协调而增加开销,从而导致发布依赖、更高的成本、更高的延迟和更复杂的系统。
将应用程序分割并分组成适当大小的函数是可扩展和成本效益系统的关键因素。我们将详细探讨这一方面,同时考虑我们即将讨论的现实生活案例。
-
状态管理:无服务器函数通常设计为无状态,这意味着它们不会保留之前执行的记忆。当有一系列函数和触发器与数据一起协作共享时,如何在多个函数调用之间共享状态就构成了一个挑战。这种模式通常涉及其他 IaaS 服务,例如队列、数据库或内存缓存。状态管理必须谨慎处理,因为它涉及到并发、数据维护和兼容性等方面的问题。
-
监控和调试:与传统架构相比,排查和监控无服务器应用程序可能更加复杂。当业务工作流程分布在多个函数和流程中时,诊断、重现和解决问题变得具有挑战性。我们应该投资于可观察性工具,如日志聚合、监控、仪表板和警报。我们还需要设计系统,使其能够优雅地处理错误。
-
成本管理:虽然无服务器架构通常由于按使用付费的模式而优化成本,但监控和优化资源消耗至关重要。基于使用的细粒度计费可能导致意外成本,如果应用程序设计效率低下或经历意外的流量峰值。这可能是由不高效的系统设计或随着时间的推移使用模式的变化引起的。需要适当的监控、性能测试和优化策略,以有效控制成本。这也是发现系统低效的机会,以便系统可以根据新的发现进行改进。
-
长时间运行的过程:FaaS 函数通常是由云服务提供商设定的执行时间限制。换句话说,FaaS 函数旨在小型化并快速执行。如果某个操作需要大量的处理时间或必须持续运行,那么寻找 PaaS 或 IaaS 的替代方案可能更好,例如虚拟服务器。在决定适当的方法时需要仔细考虑。
-
安全和合规性:无服务器架构引入了新的安全考虑。确保安全地调用函数、管理访问控制和保护无服务器环境中的敏感数据至关重要。应彻底评估符合法规和行业标准,以确保适当的安全措施得到实施。
-
非功能性需求(NFRs):即使云服务提供商也提供了一系列服务,这些服务通常包括基础设施的担忧,这些担忧往往是 NFRs 的组成部分。我们选择的無服务器服务需要满足这些要求。有时,由于工程师只能配置所需的资源,最终是平台根据配置提供资源以满足所需资源,因此很难控制满足这些要求。有时,很难控制满足这些要求,因为工程师只能配置所需的资源,最终是平台根据配置提供资源以满足所需资源。
在极端情况下,可能有必要回到裸金属服务器,以完全控制硬件和网络,从而满足高端 NFRs。
通过理解和解决这些注意事项,组织在采用无服务器架构时可以做出明智的决定,并减轻其实施过程中可能出现的潜在风险。接下来,我们将通过利用本章提供的实际案例来练习采用无服务器架构。
在我们的实际案例中采用无服务器架构
考虑本章前面用过的相同现实生活示例,其中家庭之间相互交换服务。之前,我们确定了三个潜在的微服务。让我们回顾一下它们是什么:
-
家庭服务:掌握家庭的记录
-
合同服务:维护从起草到完全执行的合同谈判工作流程
-
通知服务:向电子邮件服务提供商发送代理通知请求
图 6**.4 展示了这四个微服务之间的通信方式:
图 6.4 – 更新后的微服务通信回顾示例
在这个练习中,我们假设我们希望系统托管在 AWS 上。我们需要决定使用哪种云服务以及期望的设置是什么。
函数粒度和选择计算服务
之前,我们提到我们需要谨慎考虑函数粒度,因为这会影响系统效率和成本。我们将回顾这些服务如何执行以及我们可能会使用哪种计算服务。
家庭服务提供经典的创建、读取、更新和删除(CRUD)操作,并连接到关系型数据库进行持久化存储。这些操作高度内聚,因为它们涵盖了家庭的整个生命周期。它们都假设相同的数据库架构。此外,为了确保架构能够可靠地演变,似乎有理由让家庭服务拥有自己的架构。这意味着可以使用增量数据库迁移工具,如Flyway,并且增量数据定义语言(DDL)文件应与将转换为执行家庭 CRUD 操作的结构化查询语言(SQL)命令的源代码一起托管。
增量数据库迁移作为服务启动的一部分运行。迁移工具将检查最新的 DDL 文件是否与迁移历史记录中注册的版本相同。如果是相同版本,迁移将以无操作(no-op)结束;如果脚本版本高于记录的版本,则工具将运行增量脚本,直到版本再次匹配。在这种设置下,数据库架构的任何演变都在一次部署操作中发布,架构更改和相应的代码更改以同步方式进行。
此外,还有三个操作(创建、更新和删除)需要发布更新后的家庭事件。这些操作将假设特定的消息格式。如果消息格式将要改变,可能会影响所有三个操作。这也表明它们应该被组合成一个可部署的组件,以确保平稳、可靠的变更。
相反,如果 CRUD 操作被分离成四个 FaaS 函数或纳米服务,任何数据结构或消息结构的更改都要求这些函数的协调发布。这意味着函数耦合、发布依赖、停机时间和部分部署失败的风险。
因此,在这个例子中,使用GET
、PUT
(创建和更新)和DELETE
动词。
本例中的消息传递技术将是 Kafka。我们打算在正常主题和压缩主题上发布事件。正常主题用于宣布创建、更新和软删除,而压缩主题用作最后值队列,以保持家庭记录的最后快照。
下一个考虑因素是家庭服务应该使用哪种无服务器计算。以下是一个使用 AWS 的示例:
图 6.5 – 使用 AWS 的家庭服务
我们可以使用 Amazon 弹性 Kubernetes 服务(EKS)来运行家庭服务。这需要我们实现以下基础设施设置:
-
指定 AWS 区域
-
创建一个虚拟私有云(VPC)和子网
-
在 AWS 身份和访问管理(IAM)中为 EKS 创建一个角色,以便 EKS 集群假定该角色
-
定义使用 VPC、子网和 IAM 角色的 EKS 集群
-
为 EKS 集群创建一个安全组
-
将 EKS 集群和 EKS 服务策略附加到 IAM 角色
-
创建一个带有 PostgreSQL 及其子网的 Amazon 关系数据库服务(RDS)
-
配置 Kubernetes 提供者
-
配置 Kubernetes 命名空间和配置映射
-
配置 Kubernetes 密钥,以便从 AWS Secrets Manager 导入,例如密码
-
配置入口(入站流量路由)和应用程序负载均衡器(ALB),以便请求可以到达 REST 端点
-
在 Kafka 主题和安全组上配置 Amazon 托管 Kafka 服务(MSK),以允许 EKS pod 发布和消费消息
由于家庭服务已设置基础设施,我们可以开始一个 Kotlin 项目。互联网上有许多现成的项目创建器可供选择,包括以下内容:
-
Spring Boot
-
Ktor
-
HTTP4K
-
Vert.x
这些工具都创建了一个可以使用各自服务器框架构建的骨架项目。在这个例子中,我们使用 Ktor 作为服务器框架和 REST 端点路由。在 Ktor 中,端点路由定义如下:
routing {
get("/households/{name}") {
...
}
put("/households/{name}") {
...
}
delete("/households/{name}") {
...
}
}
我们使用 Kotlin 代码的声明性配置,并预期定义一个有效载荷格式,并使用 Ktor 内容协商设置相应的序列化。
Kafka 主题可以使用 Terraform 定义,它为指定基础设施提供了一个标准的声明性格式。正常主题需要设置一个保留策略,以确定消息应在主题上保留多长时间。
压缩主题有不同的设置。压缩主题中的消息应尽可能保留。通过压缩日志,相同键的新消息将替换旧消息。压缩主题的清理策略应设置为"compact"
,保留期设置为–1
。以下是在 Terraform 中指定压缩主题的示例:
config {
cleanup_policy = "compact"
retention_bytes = -1
retention_ms = -1
...
}
更新的家庭记录将被发送到两个主题。以下代码使用Apache Kafka API说明了这一点:
topicProducer.send(ProducerRecord(topic, household))
compactedTopicProducer.send(ProducerRecord(topic, key, household))
发送到压缩主题的消息包含一个用于识别和删除相同键的旧消息的关键。在这种情况下,使用家庭名称作为键。
合同服务提供对合同谈判和合同执行的流程的受控操作。它使用某种形式的持久存储来保留家庭的本地副本并维护其工作流程中的合同状态。它使用与家庭服务类似的无服务器计算服务,如图6.6所示:
图 6.6 – 使用 AWS 的合同服务
合同服务从家庭服务发布的主题接收家庭记录。最初,它消费压缩主题中的所有消息以构建其本地家庭缓存,随后从正常主题接收家庭记录的更新。它还向一个将被通知服务消费的主题发送通知请求。
在这种设置下,当在household-v1
和household-snapshot-v1
主题更新家庭时,队列中的事件现在正在等待合同服务恢复。一个合同服务实例变得可用并处理此事件。
合同服务在 IAM、密钥管理、ALB、Kubernetes 和数据库服务方面使用与家庭服务相同的 AWS 组件。
另一方面,通知服务很简单,它接受通知请求并将请求代理到电子邮件服务提供商。没有立即发送电子邮件的严格要求,电子邮件晚几分钟发送是可以接受的。也不需要维护状态,因为请求消息已经包含了家庭电子邮件地址和消息内容。
这是一个适合 FaaS 服务的合适候选者。虽然我们可以使用 AWS Lambda 来满足需求,但纳米服务也是一个同样合适的选择。因此,在物理部署中,通知服务被称为电子邮件通知器,如图6.7所示:
图 6.7 – 使用 AWS 的通知服务
我们可以配置函数,使其由 Kafka 主题中的新消息触发。该函数将请求转换为电子邮件格式,并将其传递给 Amazon SES,以便发送电子邮件。
函数必须配置为使用常量 Kafka 消费者组,这样同一函数的多个实例就不会消费相同的消息,从而有效地作为队列运行。
函数是无状态的,AWS 提供了连接到 SQS 和 SES 的所有手段。由于没有严格的延迟要求,因此无需担心冷启动的发生。该函数还根据流量自动扩展,并由 AWS 控制。同样,此函数使用 IAM 和秘密管理来控制可访问的资源机密。
到目前为止,我们已经介绍了无服务器架构的基本原则,并结合了主要云提供商提供的四大类服务所提供的内容。我们还讨论了应用无服务器架构的益处和注意事项。最后,我们针对本章指定的现实生活示例进行了采用无服务器架构的练习。
接下来,我们将简要介绍微前端架构,其原理上与微服务类似。
微前端
微前端架构旨在通过将 UI 分解成更小、自包含的前端模块来增强模块化、可扩展性和自主性。
术语“微前端”首次于 2016 年在 Thoughtworks 技术雷达中出现,由“评估”推荐。它通常与后端上的微服务概念相比较。微前端架构促进将前端分解为独立部署和维护的单位,每个单位负责 UI 的特定部分。
单体应用程序时代的相同症状
传统的单体前端应用程序有一个单一的代码库来处理整个 UI。通常情况下,有多个工程师团队在开发它,这会导致代码冲突、发布依赖、构建时间慢、每个团队自主性有限,以及在大规模应用中的扩展和维护挑战。这种情况与微服务相同,只是这发生在前端。
微前端架构通过允许不同团队独立工作于 UI 的不同部分,让他们选择自己的技术和框架,以及发布周期来解决这些问题。
许多小型前端模块作为独立的应用程序
在微前端架构中,UI 由多个前端模块组成。每个模块都是一个可以独立开发、测试、部署和扩展的应用程序。像微服务一样,一个前端模块应该由一个——并且只有一个——团队拥有,但作为一个公共库。
每个模块是一组功能。考虑到本章提供的现实生活示例,应该有两个前端应用程序:
-
家庭应用,用于管理家庭账户的创建、更新和删除。每个家庭都可以通过此应用管理自己的账户详情。此应用主要与后端的家庭服务进行通信。
-
合同应用,允许两个家庭从草稿合同进展到协议。它支持起草合同,类似于我们在第五章中看到的屏幕。涉及的两个家庭可以同意合同或修改它,直到两个家庭都同意细节。它还跟踪涉及的两个家庭如何执行约定的合同。关于合同中可能提到的不同服务的多态性,如第三章中所述,可能存在多个屏幕供家庭报告基于合同的执行服务的状态。此应用主要与后端的合同服务进行通信。
所有这些应用都打包成自包含的工件,可以独立启动,允许不同的团队专注于他们的业务领域。
然而,还应该有一个与其他所有应用集成的应用。此应用不包含业务逻辑;相反,它只是一个过度委派的模块,通常为用户提供一个菜单来访问其他应用。这是在构建时间或运行时创建统一 UI 的应用,具体取决于它是 Web 平台还是移动平台。
总体而言,这些前端应用可以这样表示,连同它们与之通信的后端服务:
图 6.8 – 真实案例的前端和后端通信
在前面的图中,每个前端应用都有一个后端的主要微服务进行通信。每个前端应用与其他前端应用进行通信。微服务之间也相互通信。每个前端应用和每个微服务的角色和责任都定义得很好,也很清晰。
这种架构使得每个前端应用及其主要微服务只能由一个团队拥有。这与第一章中提到的按业务功能组织团队的想法是一致的。
前端模块之间的通信
在微前端架构中,前端模块之间的通信和协调至关重要。有各种技术和模式来促进这一点,例如异步消息传递、事件驱动架构或共享状态管理。
在我们的例子中,由后端微服务集合管理的共享状态用于为每个前端模块提供服务。
这些技术使得不同模块之间能够实现无缝集成和协作,同时保持松散耦合和封装。一个良好集成的 UI 和微前端架构带来了连接和一致的用户体验。
为一致的用户体验设计系统
可视化是任何前端应用程序的一个基本元素。尽管较小的前端应用程序由其负责的团队独立运行,但确保集成 UI 具有一致的外观和感觉至关重要。设计系统提供了常见的 UI 组件,例如按钮、复选框、文本字段和交互样式,使所有前端模块保持一致,以便它们以相同的方式表现。从这个意义上说,当用户导航到另一个前端模块时,他们可以享受到无缝的体验,几乎不需要学习。
好处
微前端架构为工程团队和组织提供了多项好处。通过允许团队独立工作,它促进了更快的开发周期、更简单的维护,以及在不影响整个应用程序的情况下采用新技术和框架的能力。这导致更高的生产率和更快的上市时间。它还通过允许独立扩展单个模块,提供了在管理流量和资源方面的灵活性。
此外,微前端架构通过重用来自设计系统的 UI 组件来倡导代码的可重用性,这些组件可以在多个应用程序之间共享。这可能导致一致性的提高、重复的减少、用户学习的减少以及前端开发生产力的提高。
挑战
尽管微前端架构提供了许多优势,但它也引入了复杂性,例如模块通信、版本控制和编排,当将所有前端模块集成到一个应用程序中时。成功的实施需要仔细规划、设计考虑以及选择适当的工具和框架。
网络和其他平台之间的差异
在应用微前端架构时,网络和其他平台存在细微的差异。网络平台可以通过使用超链接将前端模块集成来实现独立发布。
移动和桌面应用程序更为复杂,因为它们需要生成一个用于用户下载和安装的单一文件。发布一个前端模块需要重新生成单一文件并更新构建版本。一些组织可能会选择以特定的节奏发布应用程序,以避免应用程序需要过多的更新。
总体而言,微前端架构是一种强大的范式,它通过将前端分解成更小、独立的模块,使工程团队能够创建可扩展、模块化和可维护的前端应用。通过采用这种架构风格,组织可以在其前端开发过程中实现更大的灵活性、敏捷性和可扩展性。
整体视角
到目前为止,我们从历史的角度讨论了单体应用的演变。我们讨论了单体架构如何演变成 SOA,其中一个大应用被分解成更小的应用块。然后,随着它们被进一步分解成更小的应用,微服务和微前端架构的时代开始了。
最后,无服务器架构出现,它允许单个函数作为云基础设施中的一个单元执行。同时,它仍然支持更大的云应用,并允许它们运行。
将它们综合起来看,我们可以开始看到每种架构的大小差异:
图 6.9 – 覆盖的架构风格大小比较
值得注意的是,无服务器架构可以适应所有规模。它甚至可以适应单体应用,尽管通常情况下,第一步是将单体应用分解成更小的服务。这是一个典型的“先让改变变得容易,然后再让容易的改变变得容易”的例子。
注意
“先让改变变得容易,然后再让容易的改变变得容易”这句话被归功于极限编程(XP)和敏捷方法论的先驱肯特·贝克(Kent Beck)。
摘要
在本章中,我们讨论了几种情况下单体架构是合理的。我们讨论了如何将单体应用分解成微服务和纳米服务的基本原则,以及如何检测分解是否正确。我们使用一个真实案例来深入探讨设计微服务和纳米服务的思维过程。
然后,我们介绍了无服务器架构和最受欢迎的云服务提供商。我们涵盖了云计算服务的四大主要类别(IaaS、PaaS、SaaS 和 FaaS),并讨论了使用无服务器架构时的益处和注意事项。之后,我们进行了一项练习,采用无服务器架构并选择合适的云计算服务来满足需求。
最后,我们简要介绍了微前端架构,其中单体前端应用被分解成前端应用。我们使用了之前相同的真实案例来展示分解过程以及每个前端模块如何与后端组件通信。最后,我们讨论了设计系统的重要性,以确保一致的用户体验,并简要提到了使用微前端架构的益处和挑战。
在下一章中,我们将深入探讨使用精选方法来分离关注点,以帮助我们朝着高效、可扩展和可维护的应用程序迈进。
第七章:模块化和分层架构
前几章反复提到了适当模块化系统以及单独处理关注点的重要性。在本章中,我们将深入研究四种突出的架构模式,这些模式提供了在层次中分离关注点、模块化代码以及在模块之间设置清洁边界的途径。
所有这些模式都将使用相同的真实生活示例来突出这些模式的相似性和差异性。
通过在 Kotlin 中使用代码示例理解这些模式,工程师可以做出明智的选择,创建松散耦合且高度内聚的模块,这些模块是可测试的、灵活的且可维护的。
以下将介绍以下架构模式:
-
清洁架构
-
六边形架构
-
功能核心,命令 壳 (FCIS)
-
连接模式
最后,我们将简要比较这些模式。我们还将探讨从每个模式中提取元素以创建混合模式以满足需求的可能性。
技术要求
您可以在 GitHub 上找到本章使用的代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-7
清洁架构
清洁架构是一种架构模式,主张将软件系统组织成具有各自责任和依赖的独立层。
术语“清洁架构”由罗伯特·马丁(也称为“Uncle Bob”)在他的 2017 年出版的书籍《Clean Architecture: A Craftsman’s Guide to Software Structure and Design》中引入。这种方法的基础建立在几个较早的架构模式之上:
-
六边形架构 (也称为端口和适配器)由 Alistair Cockburn 提出
-
洋葱架构由 Jeffrey Palermo 提出
-
罗伯特·马丁的尖叫架构
-
数据、上下文和交互 (DCI)由 James Coplien 和 Trygve Reenskaug 提出
-
边界控制实体 (BCE)由 Ivar Jacobson 提出
清洁架构通过多个层次剖析软件系统,其中每个层次都像洋葱一样一层层包裹,依赖规则指出清洁架构的外层始终依赖于内层。
图 7.1 – 清洁架构
由于依赖规则,外层任何变化都不会影响内层。相反,内层任何变化都可能影响外层。
让我们按照从内层到外层的顺序,通过我们在前几章中使用的真实生活示例——村庄家庭之间互相提供服务——来演示这些层次。
实体
实体层是内层最深的层,不依赖于其他任何层。这一层的设计是为了封装应用程序之间共享的业务规则。
它主要包含数据结构和函数。它很少依赖于外部库。它所依赖的库很可能是那些提供专用数据结构的库。这一层也最不可能发生变化。
在我们的实际例子中,有几个候选者适合托管在实体层中。Household
、Contract
、Service
以及 Service
的子类作为 Kotlin 数据类属于实体层。某些规则和政策也可以存在于实体层中。以下是一些示例规则。
规则 1 – 家庭名称不能为空且必须至少有一个成员
此规则通过数据类中的 Kotlin 非空字段语法和在 init
函数中的验证强制执行:
data class Household(
val name: String,
val members: List<String>
) {
init {
require(members.isNotEmpty()) { "Household must have at least one member" }
}
}
规则 2 – 合同必须在指定的状态之一
此规则通过 Kotlin 枚举功能强制执行:
enum class ContractState {
DRAFTED,
UNDER_REVIEW,
AGREED,
REJECTED,
PARTIALLY_EXERCISED,
FULLY_EXERCISED,
WITHDRAWN
}
规则 3 – 合同由两个家庭对象和一个状态组成
此规则通过 Kotlin 数据类强制执行:
data class Party(
val household: Household,
val serviceProvided: String,
val agreedAt: Instant? = null,
val completedAt: Instant? = null
)
data class Contract(
val partyA: Party,
val partyB: Party,
val contractState: ContractState
)
这些类是纯 Kotlin 编写的,可以在我们之前在第六章中定义的四个服务之间共享,即 家庭服务、合同服务和通知服务。可以说,家庭服务不需要 Contract
类,这可能是在大多数服务共享实体类但不是所有服务之间的一种权衡。
用例
用例层位于实体层之上。它旨在封装应用内的业务规则。
它包含使用实体层数据结构和函数的用例。此层的任何更改都不应影响实体层。此层还应保持对框架和技术选择的中立性,例如数据库和消息传递。
在我们的实际例子中,我们有一个用例,即一个家庭使用协商服务与另一个家庭草拟合同:
fun draftContract(
householdA: Household,
householdB: Household,
serviceProvidedByHouseholdA: String,
serviceProvidedByHouseholdB: String,
): Contract {
require(householdA != householdB) { "Parties must be from different households" }
return Contract(Party(
householdA,
serviceProvidedByHouseholdA,
), Party(
householdB,
serviceProvidedByHouseholdB,
), ContractState.DRAFTED)
}
draftContract
函数验证两个家庭不是同一个。如果一切看起来都很好,它将创建一个包含两个家庭及其提供的服务合同的合同。此外,它将合同状态设置为 DRAFTED
。此用例层的 draftContract
函数使用了实体层中的 Household
、Party
、ContractState
和 Contract
类。
实体层和用例层之间可能存在一定的灵活性。如果实体层中的一个特性被认为仅与一个应用相关,则它可以移动到用例层。同样,如果不同应用中的用例层存在重复的逻辑,则可以将这些逻辑提取到实体层。
接口适配器
实体和用例层被视为内部模型,其中数据结构不会暴露在应用程序之外。接口适配器层充当内部模型和外部模型之间的翻译。外部模型的典型例子包括关系数据库表、消息负载、HTTP 请求和响应负载、文件格式以及图形用户界面(GUI)中的视觉表示。
我们已经有一些例子说明代码应该保留在接口适配器层:
-
在第四章中介绍的点对点架构展示了在
Contract
对象和用于用户数据报协议(UDP)传输的二进制负载之间的转换示例代码。 -
在同一章节中,有一个类似的例子,但转换是在一个
Contract
对象和由OpenAPI规范定义的JavaScript 对象表示法(JSON)负载之间进行的,在客户端-服务器架构下。 -
在第五章中介绍的三个前端架构风格,MVC、MVP 和 MVVM,在这个层中会有相应的代码,因为它们将内部模型转换为作为 GUI 渲染的视图。
如果应用程序使用查询语言,如 SQL,用于关系数据库,它们也应该保留在接口适配器层。
这些外部模型及其相应的转换不应泄漏到其他层。该层也不应包含业务逻辑。内部模型关注的问题与接口适配器层之外的其他层分离,外部模型关注的问题与接口适配器层之内的其他层分离。
框架和驱动器
框架和驱动器层位于接口适配器层之外的一层。这是添加外部框架以使其成为应用程序的地方。典型例子包括 HTTP 端点路由配置、数据库连接细节、Kubernetes 配置和依赖关系管理。这个层通常包含比源代码更多的配置文件。
这个层永远不应该包含业务用例。它不知道任何内部模型,因此没有从外部模型转换。这个层专注于支持将代码转换为在运行时可以执行的应用程序的可执行配置。它应该只解决非功能性需求,例如启动时间或冗余。
一个使用 Clean 架构的示例用例
让我们以一个用例为例,说明如何使用不同的层。一个家庭被要求提交一个草案合同表单,这是一个位于框架和驱动器层的网页。
然后,家庭将表单作为 JSON 值提交,并在接口适配器层中进入控制器。控制器将表单转换为几个内部对象,例如Household
对象和作为字符串提供的服务。然后,这些内部对象被传递到用例层的draftContract
函数:
fun draftContract(
householdA: Household,
householdB: Household,
serviceProvidedByHouseholdA: String,
serviceProvidedByHouseholdB: String,
): Contract
该函数创建一个来自实体层的Contract
对象。函数将Contract
对象传递给接口适配器层中的展示者。展示者将Contract
对象转换为 JSON 值,以便在框架和驱动层中的网页上渲染。整个过程在图 7.2中展示。2*:
图 7.2 – 清洁架构的示例用例
清洁架构的好处
清洁架构的核心是依赖规则,而不是四个定义的层。有合理的理由,层的结构可能会有所不同,但依赖规则仍然适用。
总体而言,在清洁架构中,每个层都关注于分离关注点。每个层都致力于解决特定的问题,而其他层不处理:
-
实体层:应用程序之间共享的功能需求
-
用例层:应用程序内的功能需求
-
接口适配器层:内部和外部模型之间的转换
-
框架和驱动层:非功能需求
通过清晰的关注点分离,我们现在有了实体层和用例层,它们独立于框架和技术的选择。它们对外部世界一无所知。它们可以在没有用户界面(UIs)、数据库、消息传递、文件或任何外部表示的情况下进行测试。在这两层执行测试的计算成本很低,因此我们可以承担运行一个全面的测试套件,而不会对构建时间产生重大影响。
由于技术和框架选择仅存在于接口适配器层和框架和驱动层,工程师可以在这些层中对框架进行更改,同时知道实体层和用例层中的功能逻辑保持完整。此外,这些层中可能有测试用例,以确保内部-外部模型转换的正确性,以及这些层中框架配置的正确性。
此外,它还使得技术变更的过渡变得顺畅。可以引入新技术,并与旧技术共存。工程师可以逐步对新技术所需的变更进行提交。可以设置开关,用于在测试目的之间切换新旧技术。一旦团队对变更感到满意,他们就可以切换到新技术,并在之后清理代码。
六边形架构是 Clean Architecture 构建在之上的几种架构模式之一。我们将在下一节中探讨六边形架构。
六边形架构
六边形架构,也称为端口和适配器架构,旨在解决核心业务逻辑与外部依赖(如数据库、UI 和外部系统)之间的耦合问题。
六边形架构是由 Alistair Cockburn 在他的论文《六边形架构》中引入的,该论文于 2005 年发表。
这种架构风格的两个基本概念是端口和适配器。端口定义了内部世界和外部世界之间的交互,适配器提供了这些交互的实现细节。
六边形架构的概念在图 7.3中被可视化为一个六边形。值得注意的是,这种架构允许尽可能多的边,不仅限于名称六边形所暗示的六个边。
图 7.3 – 六边形架构的一个示例
我们将在接下来的章节中介绍六边形架构的每个元素。
核心组件
六边形架构的核心以纯方式封装了应用程序的业务逻辑,不涉及任何技术和框架。核心通常被称为“领域”。唯一的例外可能是一个提供支持业务逻辑所需数据结构的库。核心不依赖于任何适配器实现。
核心包含表示纯业务逻辑的数据结构和函数。以我们之前提到的家庭交换服务的现实生活例子为例,我们应该在核心中包含以下元素:
-
HouseHold
、Party
和Contract
数据类;ContractState
枚举类 -
draftContract
函数
端口
端口是描述应用程序能做什么的接口。在六边形架构中,有两种类型的端口:
-
主要端口:也称为驱动端口,它决定了核心执行操作所需的输入。
-
次要端口:也称为驱动端口,它决定了核心为外部世界消费产生的输出。
端口接口应由核心需求定义,而不是外部世界。如果我们从头开始编写一个新应用程序,我们应该从核心开始,定义核心需要的端口接口,即使适配器中没有任何代码。
除了我们在核心中的代码外,我们还需要为外部世界定义端口接口,以便使用核心并消费核心产生的结果。
在现实生活中,如果一个家庭打算起草一份合同,核心会验证请求并生成一份草拟的合同。核心需要一个存储库来存储草拟的合同。
我们需要一个主端口以允许草拟合同。ContractService
接口对外公开,但其实现保持在核心中:
interface ContractService {
fun draftContract(
householdA: Household,
householdB: Household,
serviceProvidedByHouseholdA: String,
serviceProvidedByHouseholdB: String,
): Contract
}
我们还需要一个次要端口以允许草拟合同被持久化。ContractRepository
接口由适配器实现,以提供如何保存Contract
对象的详细技术信息:
interface ContractRepository {
fun save(contract: Contract)
}
主端口接口ContractService
的实现验证草拟合同,并创建一个具有DRAFTED
状态的Contract
对象。然后,将Contract
对象传递给ContractRepository
进行保存:
class ContractServiceImpl(
private val contractRepository: ContractRepository,
) : ContractService {
override fun draftContract(
householdA: Household,
householdB: Household,
serviceProvidedByHouseholdA: String,
serviceProvidedByHouseholdB: String,
): Contract =
draftContract(
householdA,
householdB,
serviceProvidedByHouseholdA,
serviceProvidedByHouseholdB,
).also { contractRepository.save(it) }
}
ContractServiceImpl
类是核心逻辑的一部分,并保持在核心中。它能够实现业务行为,而无需指定如何保存Contract
。换句话说,核心专注于业务规则,不受技术选择和框架的约束。
适配器
适配器负责将外部模型转换为在核心中定义的内部模型。
适配器使用主端口作为进入核心的入口并运行业务操作。一个适配器至少绑定到一个框架,并且它具有涉及实体的外部表示。
在实际示例中,适配器可以是创建草拟合同的POST
端点。负载是一个由DraftContractRequest
类表示的 JSON 值,它可以被转换成一个Contract
对象:
data class DraftContractRequest(
val householdA: String,
val householdB: String,
val serviceProvidedByHouseholdA: String,
val serviceProvidedByHouseholdB: String
)
合同服务在本地缓存了Household
对象,可以通过HouseholdRepository
存储库从家庭名称进行查找:
interface HouseholdRepository {
fun findByName(householdName: String): Household?
}
如果找到给定名称的家庭,则函数返回一个Household
对象。否则,它返回一个null
值。定义了 REST 控制器类ContractController
以接受草拟合同的 HTTP 请求。此控制器使用 Spring Boot 作为框架来注册 URI 映射:
@RestController
@RequestMapping("/contracts/")
class ContractController(
private val contractService: ContractService,
private val householdRepository: HouseholdRepository,
) {
控制器注入了ContractService
主端口以进入核心并草拟合同。它还注入了HouseholdRepository
次要端口以查找用于验证的Household
对象。
@PostMapping(
value = ["draft"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE],
)
控制器定义了一个映射POST /contracts/draft
,它接受 JSON 值作为输入和输出。
fun draftContract(
@RequestBody request: DraftContractRequest,
): ResponseEntity<ContractDto> {
val householdA = householdRepository.findByName(request.householdA) ?: return ResponseEntity(HttpStatus.NOT_FOUND)
val householdB = householdRepository.findByName(request.householdB) ?: return ResponseEntity(HttpStatus.NOT_FOUND)
控制器将DraftContractRequest
请求负载转换为 JSON,并验证负载中的家庭名称是否存在。如果任何家庭不存在,则向请求者返回一个404 (Not Found)
HTTP 状态码。
val contract =
contractService.draftContract(
householdA,
householdB,
request.serviceProvidedByHouseholdA,
request.serviceProvidedByHouseholdB,
)
控制器调用ContractNegotiationService
主端口中的draftContract
函数,该函数验证请求并在存储库中持久化草拟合同。
return ResponseEntity(contract.toDto(), HttpStatus.CREATED)
}
最后,操作已完成,控制器向请求者返回一个201 (Created)
HTTP 状态码,并附带草拟合同的详细信息。响应负载由ContractDto
类表示:
data class ContractDto(
val householdA: String,
val householdB: String,
val serviceProvidedByHouseholdA: String,
val serviceProvidedByHouseholdB: String,
val contractState: String,
)
对于合同从内部模型到外部模型的转换,存在一个toDto
函数:
fun Contract.toDto(): ContractDto =
ContractDto(
partyA.household.name,
partyB.household.name,
this.partyA.serviceProvided,
this.partyB.serviceProvided,
this.contractState.name,
)
在这个例子中,数据被转换并通过六角架构的层级传递,如图 7.4所示:
图 7.4 – 六角架构的示例用例
请求负载作为 JSON 值在ContractController
适配器中通过HouseholdRepository
二级端口进行验证。然后,负载被转换为核心中定义的Household
对象。核心运行业务流程,并使用ContractRepository
二级端口持久化一个有效的草稿Contract
对象。然后,结果被填充到ContractController
适配器中。最后,适配器将Contract
对象转换为响应负载。
六角架构的好处
在六角架构的层级中分离源代码确实会使代码库更加复杂。通过在核心和适配器之间分离源代码,它确实带来了一些好处。
核心封装了纯业务规则,而适配器包含所有技术细节。这种分离使得独立测试正确性和技术集成问题变得极其容易。
核心部分包含一个全面的测试套件,确保业务规则按照预期执行,而不涉及任何技术。
另一方面,适配器包含测试用例,以验证所选技术和框架的配置是否按预期工作,而不混合业务规则。
适配器被设计成可以在任何时候被拉出并替换为另一个适配器。适配器专门为选定的技术或框架实现,但端口对此一无所知。例如,如果请求和响应使用消息传递技术,如队列,那么我们可以用不同的适配器替换ContractController
,例如DraftContractRequestConsumer
。
如果需要,也可以替换作为适配器的二级端口实现。例如,我们可以有一个HouseholdRepository
的内存缓存或关系型数据库实现。它们可以共存,并且我们可以使用配置来决定在运行时使用哪一个。每个适配器都可以单独进行测试。
这种关注点的分离使得工程师能够遵循单一职责原则(SRP),其中核心只有一个改变的理由,每个适配器也只有一个改变的理由。
接下来,我们将介绍一种旨在解决类似挑战的架构模式,但以功能风格实现。
功能核心,命令式外壳
FCIS 逐渐在函数式编程社区中成为了一种架构模式。它是一种设计原则,主张将不可变的核心业务逻辑与可变方面(如持久化存储或外部系统集成)分离。它将函数式编程原则与无状态函数和不可变数据结构的使用相结合。
无状态函数有时被称为纯函数。它们可以在不引起副作用的情况下执行,换句话说,对于给定的输入,函数总是产生相同的输出。不可变数据结构永远不会改变其内容。
FCIS 原则受到了各种软件开发范式和架构模式的影响。它与六边形架构相似,也促进了将核心逻辑与基础设施关注点分离。
FCIS 有两个主要组成部分。功能核心包含代表业务逻辑和实体的无状态函数和不可变数据结构。
另一方面,命令式外壳是功能核心之外与外部世界交互的层,例如数据库操作、消息传递或用户界面。这一层负责执行系统所需的命令式和可变操作。
FCIS 架构可以在 图 7.5 中展示:
图 7.5 – FCIS
我们将在这里介绍这种架构风格的这两个组成部分。
函数核心
函数核心专注于核心业务逻辑,仅包含无状态函数和不可变数据结构。
使用我们为家庭交换服务提供的相同真实生活示例,核心中应该有这些元素:
-
HouseHold
、Party
和Contract
数据类以及ContractState
枚举类 -
draftContract
函数
在我们的示例中,所有数据类都是不可变的,所有函数都是无状态的。它们通过简单地验证给定输入的函数输出进行隔离测试。
然而,draftContract
函数需要从“清洁架构”部分示例中的实现进行微调,因为我们需要构建带有潜在错误的更高阶函数。
要开始这种方法,我们需要定义我们可能遇到的错误。我们首先定义错误的类型:
enum class ErrorType {
HOUSEHOLD_NOT_FOUND,
SAME_HOUSEHOLD_IN_CONTRACT
}
我们还需要为错误本身提供一个包装数据类:
data class Error(
val reason: String,
val type: ErrorType
)
我们需要能够传达函数可以返回预期的结果,但同时也存在发生错误的可能性。这种新的 draftContract
实现使用了 Arrow 库中的 Either
类来表示这种结果:
Arrow
使用 Either
进行错误处理的 Option
和结果类型。它还提供了各种函数式编程模式,如函子、单子和应用。Arrow 通过允许开发者以函数式风格表达计算,旨在提高代码的可读性、可维护性和健壮性,使 Kotlin 应用中的复杂数据流和副作用管理变得更加容易。
fun draftContract(
householdA: Household,
householdB: Household,
serviceProvidedByHouseholdA: String,
serviceProvidedByHouseholdB: String,
): Either<Error, Contract> =
draftContract
函数返回一个 Either
对象,其中左侧类型参数始终是潜在的错误,右侧类型参数始终是预期结果:
if (householdA.name == householdB.name) {
Either.Left(
Error("Parties must be from different households", ErrorType.SAME_HOUSEHOLD_IN_CONTRACT),
)
}
同样的验证确保合同在两侧没有相同的 Household
。然而,而不是抛出异常,该函数返回左侧值,这由之前定义的 Error
类表示。
else {
Either.Right(
Contract(
partyA = Party(householdA, serviceProvidedByHouseholdA),
partyB = Party(householdB, serviceProvidedByHouseholdB),
contractState = ContractState.DRAFTED,
),
)
}
如果一切正常,函数返回右侧值,即 Contract
对象本身。
命令式壳
命令式壳处理与外部世界的必要交互,提供必要的集成点和适配器。
在实际示例中,这包括 Contract
对象的持久化和接受 HTTP POST
请求以起草合同的 REST 端点控制器。
除了 REST 控制器之外,还有两个涉及命令式操作。第一个是通过名称查找 Household
对象,第二个是持久化 Contract
对象。我们在这个示例中使用 typealias
定义它们:
typealias HouseholdLookup = (String) -> Household?
typealias ContractPersist = (Contract) -> Either<Error, Contract>
除了这两个定义的函数之外,我们还需要一个函数来确保请求中提到的家庭存在:
fun DraftContractRequest.ensureHouseholdExist(
householdLookup: HouseholdLookup
): Either<Error, Pair<Household, Household>> {
val householdARecord = householdLookup(householdA)
val householdBRecord = householdLookup(householdB)
return if (householdARecord == null) {
Either.Left(
Error("Households not found: $householdA", ErrorType.HOUSEHOLD_NOT_FOUND),
)
} else if (householdBRecord == null) {
Either.Left(
Error("Households not found: $householdB", ErrorType.HOUSEHOLD_NOT_FOUND),
)
} else {
Either.Right(
householdARecord to householdBRecord,
)
}
}
ensureHouseholdExist
函数使用 HouseholdLookup
检查 DraftContractRequest
请求中提到的两个家庭是否存在。如果不存在,Either
的左侧将返回以报告家庭未找到错误。如果两个家庭都存在,将返回家庭记录以进行进一步处理。
定义了这个函数后,我们现在可以将它们注入到命令式控制器实现中:
@RequestMapping("/contracts/")
class ContractControllerShell(
private val householdLookup: HouseholdLookup,
private val contractPersist: ContractPersist,
) {
@PostMapping(
value = ["draft"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE],
)
draft
函数现在充分利用了 Either
类,通过 flatMap
函数将右侧类型参数折叠:
fun draft(
@RequestBody draftContractRequest: DraftContractRequest,
): ResponseEntity<ContractDto> =
draftContractRequest
.ensureHouseholdExist(householdLookup)
.flatMap { (householdA, householdB) ->
draftContract(
householdA,
householdB,
draftContractRequest.serviceProvidedByHouseholdA,
draftContractRequest.serviceProvidedByHouseholdB,
)
}.flatMap { contractPersist(it) }
.flatMap { Either.Right(it.toDto()) }
.fold(
{ error -> ResponseEntity(error.type.toHttpStatus()) },
{ contractDto -> ResponseEntity(contractDto, HttpStatus.CREATED) },
)
flatMap
函数将 Either
的结果链式连接,返回左侧的第一个错误或右侧的最终预期结果。前面的代码首先检查并确保请求中提到的两个家庭都存在。接下来的链式调用从 ensureHouseholdExist
函数获取家庭记录,并将所有参数传递给 draftContract
函数以验证业务规则并返回一个 DRAFTED
状态的 Contract
对象。
下一个链式调用接收 DRAFTED
状态的 Contract
对象,并调用 ContractPersist
函数以持久化此合同。
在 Contract
记录持久化后,它被转换为一个 数据传输对象(DTO),该对象将被公开并返回给发起请求的 HTTP 客户端。
在链式调用的最后部分,调用fold
函数来将响应区分回 HTTP 客户端。成功的结果作为一个Contract
对象,右侧,通过toDto
函数被转换为ContractDto
响应负载。返回一个包含响应负载的201 (Created)
HTTP 状态码。
所有可能的错误都被折叠到左侧,并且它们被映射到 HTTP 状态码,如下所示:
fun ErrorType.toHttpStatus(): HttpStatus = when (this) {
ErrorType.HOUSEHOLD_NOT_FOUND -> HttpStatus.NOT_FOUND
ErrorType.SAME_HOUSEHOLD_IN_CONTRACT -> HttpStatus.BAD_REQUEST
}
函数式核心和命令式壳层的优势
FCIS 将代码分为两部分。
函数式核心是一个纯函数式区域,只包含无状态函数和不可变数据结构,这些可以很容易地进行测试和推理。没有状态管理和副作用。每个测试都是关于验证给定输入值的功能输出。
函数式核心也是技术选择自由的。它使函数式核心只关注业务问题。如果我们决定使用其他技术来接收请求和生成响应,则无需更改代码。这使得核心逻辑可靠、可适应和可维护。
唯一的例外,正如我们在示例中所展示的,是使用从 Arrow 库中引入的Either
类,以表达一个操作可能失败并返回不同对象的情况。
这鼓励工程师组织代码以符合 SRP,其中每个函数只有一个改变的理由。尽管如此,我们仍然需要按照最佳实践组织我们的函数以符合 SRP。例如,每个函数需要小、可重用、无状态,并且只有一个目的。
将函数式核心与命令式壳层分离,可以更容易地进化核心逻辑,而无需与外部系统或实现细节紧密耦合。这也允许有更大的灵活性和可维护性,从而产生更健壮和灵活的软件架构。
我们接下来要讨论的下一个架构模式侧重于适应远程 API 调用。
连接模式
连接模式最初由 David Denton 在他的技术文章《Smash your Adapter Monolith with the Connect pattern》中提出,该文章于 2011 年发布在互联网上。
然而,这个模式与之前讨论的三个架构模式有不同的重点。它专门针对远程系统集成。它的目标是提供一个可测试和可扩展的方法来封装远程 API 交互。
连接模式的概念在图 7.6中得到了说明:
图 7.6 – 连接模式
集成操作被抽象为 Action。它理解如何生成请求以及如何将响应转换为一个内部对象。然而,它不知道与外部系统的连接细节。
相反,适配器是为了适应特定的系统而精心制作的。它可以在将实际请求-响应交换委托给操作的同时,与外部系统建立连接。
操作
操作负责将请求从内部格式转换为外部格式,并将响应从外部格式转换为内部格式。使用相同的现实生活例子,即村民交换服务,我们需要从家庭服务中获取一些支持函数,其中之一就是通过给定的家庭名称获取家庭,这个操作非常简单。
与家庭服务通信相关的操作定义如下:
interface HouseholdApiAction<R> {
fun toRequest(): Request
fun fromResponse(response: Response): R
}
只有两个简单的函数。toRequest
函数生成一个外部格式请求,用于与家庭服务通信。fromResponse
函数将家庭服务响应转换为内部对象。类型变量R
对于给定的操作是特定的,并且可能不同。
通过给定的家庭名称获得家庭名称的操作定义如下:
val householdLens = Body.auto<Household>().toLens()
data class GetHousehold(
val householdName: String,
) : HouseholdApiAction<Household> {
override fun toRequest(): Request = Request(Method.GET, "/households/$householdName")
override fun fromResponse(response: Response): Household = householdLens(response)
}
GetHousehold
类封装了生成发送到家庭服务的 HTTP 请求所需的详细信息。家庭服务响应通过使用Household
数据类转换为内部数据类,Household
数据类在此定义:
data class Household(
val name: String,
val emailAddress: String,
)
到目前为止,还没有与家庭服务的连接细节,但适配器将涵盖这一点。
适配器
适配器类提供了一种将连接细节和机制抽象到外部系统的方式。Household
API 在此定义:
interface HouseholdApi {
operator fun <R : Any> invoke(action: HouseholdApiAction<R>): R
companion object
}
HouseholdApi
接口充当特定操作的包装和处理程序。伴随对象被定义为扩展函数的占位符,稍后将会介绍。与本地家庭服务通信的 HTTP 实现如下:
val token = "fakeToken"
fun HouseholdApi.Companion.Http(client: HttpHandler) =
object : HouseholdApi {
private val http =
SetBaseUriFrom(Uri.of("http://localhost:9000"))
.then(
Filter { next -> { next(it.header("Authorization", "Bearer $token")) } },
).then(client)
override fun <R : Any> invoke(action: HouseholdApiAction<R>) = action.fromResponse(http(action.toRequest()))
}
这个Household
API 实现是一个匿名内部类,将家庭服务的宿主设置为 localhost,并设置授权令牌。它假定所有HouseholdAction
都会使用相同的连接设置。
此外,定义了一个扩展函数来使使用代码更整洁:
fun HouseholdApi.getHousehold(householdName: String) = invoke(GetHousehold(householdName))
代码使用示例将在下一节中介绍。
使用
在设置好操作和适配器后,使用家庭服务通过给定的名称获取家庭只需要三行代码:
val app: HttpHandler =
routes(
"/households/{name}" bind GET to { request ->
val householdName = request.path("name")!!.toString()
Response(OK).with(householdLens of Household(name = householdName, emailAddress = "same.address@domain.com"))
},
)
fun main() {
val householdApi = HouseholdApi.Http(JavaHttpClient())
val household: Household = householdApi.getHousehold("Whittington")
println(household)
}
变量 app 是HttpHandler
,它定义了 URI 路由。在这个例子中,只定义了一个GET
端点,以及一个返回给 HTTP 客户端的预定义响应。
在main
函数中,创建了一个 HTTP 客户端实现,用于创建HouseholdApi
的 HTTP 实现。然后,使用扩展函数获取名为Whittington的家庭。内部,创建了一个GetHousehold
操作并将其传递给HouseholdApi
,启动 API 通信。然而,从使用角度来看,返回的是内部Household
数据类的对象。
测试用例
Connect 模式的一个优势是更好的可测试性。每个 Action 和与 Household Service 一起的 API 都可以单独测试。这是从 Household API 获取家庭的测试用例:
@Test
fun `Get the household from Household API`() {
assertEquals(
Response(OK)
.contentType(ContentType.APPLICATION_JSON)
.body("{\"name\":\"Whittington\",\"emailAddress\":\"same.address@domain.com\"}"),
app(Request(GET, "/households/Whittington")),
)
}
当有更多 Action 时,会有更多的测试用例,但每个测试用例都是隔离的。换句话说,Action 和测试用例的扩展不会创建具有许多功能的接口。
优势
Connect 模式的重大优势在于可扩展性和可测试性。考虑到通常需要使用外部系统的多个 API,工程师们往往最终会得到一个具有许多功能的单一外部系统接口。使用 Connect 模式,每个 API 调用都由一个 Action 表示,从而将大接口分解成更小的部分。
此模式自然符合 SRP(单一职责原则),因为 Action 只代表一个 API 调用。此外,每个 Action 都有自己的专用测试用例,这比测试具有许多功能的接口要简单得多。
适配器将连接到远程系统的细节关注点抽象化,因此 Action 类可以专注于消息格式和交互。
Kotlin 扩展函数的使用使代码使用更加流畅、整洁和简洁。许多细节都被隐藏并封装在 Action 和适配器类中。
尽管示例使用了 HTTP 和 Kotlin,但 Connect 模式本身并不局限于它们。
此模式在用于与暴露单个接口中许多功能的单体应用程序通信时特别有用。
接下来,我们将简要比较这些架构风格。
架构风格比较
我们在本章中讨论的前三种架构风格有许多相似之处。它们都旨在解决相同的问题;差异在于方法。
同时,Connect 模式专注于远程系统交互的可测试性和可扩展性。
依赖框架和技术
传统架构通常将业务逻辑与特定的框架、库或技术紧密耦合。这使得在没有对代码库进行广泛更改的情况下切换或升级这些组件变得困难。
清洁架构提出实体层用于企业业务规则,用例层用于特定于应用的业务规则。两者都对框架和技术选择保持中立。
六角架构提出将核心作为业务逻辑的中心,同时不受框架和技术选择的限制。
FCIS(功能核心信息系统)提出业务逻辑的功能核心,但使用无状态函数和不可变数据结构。
Connect 模式使用 Action 作为单个操作和与远程系统交互的传输细节的集成点,并使用适配器。
可测试性
在传统架构中,由于依赖于外部系统、数据库或 UI 框架,测试核心业务逻辑可能具有挑战性。
测试一个与技术选择耦合的函数通常需要大量的设置和拆除资源,如果背后有异步过程,则更糟。这意味着需要工程师付出更多努力才能使测试工作,相同时间内可以编写的测试较少,测试套件运行时间更长,并且可能存在偶尔失败的不可靠测试,这是由于涉及的一些异步过程。任何并行执行的测试都可能使测试套件更加不可靠。
清洁架构为仅业务逻辑提供专用层(实体和用例层),可以在不涉及任何技术选择的情况下单独测试。接口适配器层专注于内部和外部模型转换,也可以独立测试。框架和驱动器层的配置可以在不涉及业务规则的情况下进行测试。
六边形架构倡导核心作为一个技术中立区域,在这里可以轻松编写单元测试,无需复杂的设置或模拟大量的外部组件。每个适配器都可以单独测试,而不涉及任何业务逻辑。
FCIS 建议将业务逻辑仅作为无状态函数和不可变数据结构放在功能核心中。这通过简单验证由输入给出的输出结果来减少对功能核心的测试。通过在功能核心中交换低阶函数,命令式外壳也可以轻松测试,因此,系统的不可变和可变部分可以单独测试。
连接模式将单体应用程序的大远程接口分解为每个 API 调用一个操作。这减少了大量的开销、过度的模拟、测试双倍或接口的存根。在隔离状态下测试 API 调用的能力提高了可测试性,并使得远程 API 的使用扩展变得容易。
可维护性
随着时间的推移,应用程序往往会发展和变化,通常需要修改核心业务逻辑或框架的选择。在传统架构中,进行更改可能存在风险,因为这些修改可能在整个系统中产生意外的后果。更新业务逻辑可能与已弃用的库交织在一起,最终两者都需要进行更改。
清洁架构、六边形架构和 FCIS 在核心业务逻辑和周围基础设施之间提供了清晰的分离,这使得在不影响核心业务规则的情况下修改或扩展应用程序变得更容易。
连接模式非常轻量级,动作之间没有重叠。连接性和消息交互关注点被分离到适配器和动作类中,这些类的维护简单,代码更改小。
灵活性和适应性
应用程序通常需要与各种外部资源集成,例如文件、数据库、第三方服务或用户界面。在传统的架构中,这些集成深深嵌入到应用程序代码中,使得切换或修改这些集成变得具有挑战性。
清洁架构促进接口适配器和框架以及驱动程序层的包含外部依赖。这允许更多的灵活性和适应性,因为业务规则与特定的技术或协议解耦。
六边形架构促进使用端口和适配器,这些作为核心逻辑和外部系统之间的接口和适配器。不同的框架选择会导致适配器的单独实现,但端口接口保持不变。通过交换相同接口的实现,改变技术或协议变得容易。
FCIS 使用命令式外壳层来处理与外部世界的所有交互。任何技术或协议的变化只需要在命令式外壳层进行更改。通过高级的小函数重用,只需进行微小的更改即可适应新技术。
连接模式允许工程师在需要与新的远程系统集成时实现新的适配器。如果需要适应新的通信协议,则需要实现新的适配器和新的动作,但无需更新当前代码。
何时使用哪种架构风格
清洁架构、六边形架构和 FCIS 在许多方面有相似之处,工程师可能难以为他们的应用程序选择。这些三种架构风格的层可以大致映射如下:
清洁架构 | 六边形架构 | FCIS |
---|---|---|
实体 | 共享/通用库 | 共享/通用库 |
用例 | 核心/领域/端口 | 核心 |
接口适配器 | 适配器 | 外壳 |
框架和驱动程序 | 适配器 | 外壳 |
表 7.1 – 三种架构风格之间的近似映射
如果我们接受这种观点,即三种风格可以松散地映射,那么选择将变成工程师之间的惯例。以下是一些供参考的意见,但它们并非严格规则:
-
拥有更多函数式编程经验的工程师会更倾向于 FCIS。
-
清洁架构为单体应用程序或拥有大型源代码库的系统提供了更好的支持。话虽如此,清洁架构绝对可以支持较小的代码库或微服务。
-
六边形架构在范围和规模方面适合微服务应用程序。
在创建混合风格方面有很多空间。例如,一个使用六边形架构的应用程序可以借鉴 FCIS 的概念,因此所有端口和适配器基本上是使用 Kotlin 操作符重载功能实现的函数:
class DummyContractPersist: ContractPersist {
override fun invoke(p1: Contract): Either<Error, Contract> {
ContractPersist
是我们用于 FCIS 的类型别名,我们可以定义一个实现类型别名接口并提供具有操作符重载的invoke
函数的类。因此,在实践中,调用者可以跳过invoke
关键字,将其视为一个函数,如下所示:
val persist = DummyContractPersist()
val result = persist(contract)
因此,这个概念可以扩展到端口接口,其中它们只是具有一个函数的唯一类型别名或接口。
Connect 模式是一种集成模式,它解决的问题不同于本章中涵盖的其他三种风格。当需要在外部系统中执行具有业务意义的相同操作,但希望将技术集成细节与业务逻辑解耦时,可以使用 Connect 模式。
与贫血领域模型相关
贫血领域模型(ADM)是一种有争议的架构风格,一些人将其归类为反模式,而另一些人发现在某些情况下它是有用的。在 ADM 中,核心或用例层主要包含数据结构,几乎没有或没有业务行为。
本章对 ADM 的深入讨论超出了范围,然而,如果一个团队选择使用 ADM,那么不建议将其与 Clean Architecture、六边形架构或 FCIS 结合使用。
主要原因是这些架构旨在将业务行为保留在核心或用例层;换句话说,它们旨在仅与丰富领域模型(RDM)一起工作。应用程序采用本章中涵盖的 Clean Architecture、六边形架构或 FCIS 的层不会获得任何好处。
ADM 的使用
虽然 AD(贫血领域模型)可能被一些人视为反模式,但缺乏业务行为可能意味着应用程序的目标仅仅是数据处理和基础设施管道。例如,一个 AD 应用程序可能负责接收一个大文件,将文件中的数据分割成块,并并行处理每个块。该应用程序专注于数据操作和可扩展性,同时,处理后的数据被包含实际与数据相关的业务行为的下游应用程序读取。另一个例子可能是一个消费外部消息头并将其转发到相应内部主题以进行进一步处理的应用程序。
按层组织源代码
按层组织源代码有两种流行的选择。在 Kotlin 中,第一种方法是将文件放入它们所属的包中作为层。
在我们使用的实际生活示例中,我们可以在每个架构风格中拥有以下包:
-
Clean Architecture:
org.example.service.negotiation.entity org.example.service.negotiation.usecase org.example.service.negotiation.interface org.example.service.negotiation.framework org.example.service.negotiation.framework.rest
-
六边形架构:
org.example.service.negotiation.core org.example.service.negotiation.core.port org.example.service.negotiation.adapter org.example.service.negotiation.adapter.rest
-
FCIS:
org.example.service.negotiation.core org.example.service.negotiation.shell org.example.service.negotiation.shell.rest
强制执行分层架构,其中只有外层可以使用内层,反之则不行,可以通过使用测试用例并作为成功构建的一部分来轻松实现。
这是一个强制执行 FCIS 层依赖的测试用例示例,使用 ArchUnit 作为测试驱动程序:
val classes = ClassFileImporter().importPackages("fcis")
@Test
fun `layer dependencies are_respected`() {
layeredArchitecture()
.consideringAllDependencies()
.layer("Imperative Shell")
.definedBy("fcis.shell..")
.layer("Functional Core")
.definedBy("fcis.core..")
.whereLayer("Imperative Shell")
.mayNotBeAccessedByAnyLayer()
.whereLayer("Functional Core")
.mayOnlyBeAccessedByLayers("Imperative Shell")
.check(classes)
}
第二种方法是使用构建框架内的源代码模块,例如 Gradle 或 Maven。我们为应用程序创建多模块项目,并且外层模块声明对内层模块的依赖。
例如,协商服务的命令行外壳可以在命令行模块的 Gradle Kotlin 脚本中声明显式依赖。
implementation("com.example:service-negotiation-core")
重要的是要注意,这种方法比包方法更重量级,因为它创建了内层的实际工件。
然而,Connect 模式是一个模块化但非分层架构,因此没有必要强制执行层依赖。
摘要
在本章中,我们讨论了三种旨在解决传统架构中相同问题的架构风格,其中业务逻辑和技术选择紧密耦合。这三个架构被深入探讨:清洁架构、六边形架构和 FCIS。我们还讨论了 Connect 模式,该模式侧重于分解大型远程接口以与远程系统集成。
我们使用我们的真实生活示例说明了每种架构风格,以及每种风格如何在代码中实现。
我们还比较了四种架构风格在解决传统架构问题方面的方法。我们简要介绍了在分层架构下如何在存储库中组织代码。
我们提到,在六边形架构中,核心有时被称为 领域。然而,"领域" 这个术语本身值得深入讨论。在下一章中,我们将探讨与领域相关的概念,其中一个共同的主题是 领域驱动 开发(DDD)。
第八章:领域驱动设计 (DDD)
工程师通常不是业务领域的专家。然而,他们负责构建代表现实世界领域的复杂应用程序。传统上,软件架构往往难以有效地表达业务领域的复杂性和微妙之处,导致系统难以理解、维护和演进。这就是领域驱动设计(DDD)发挥作用的地方。
在 第七章 中,我们介绍了围绕在应用程序中为业务逻辑设置专用层的三种架构风格。DDD 的目标是帮助工程师识别属于相应领域及其边界的业务行为,以便它们可以在应用程序的核心、领域或用例层中实现。
本章探讨了 DDD 的强大软件设计方法,该方法围绕软件设计过程中的业务领域。它侧重于捕捉和表达核心业务概念、规则和行为。
首先,我们将深入探讨 DDD 的理论原则和实际实施策略,并通过实际案例进行说明。然后,我们将探讨如何使用这种方法构建与业务需求紧密对齐的、可维护的、可扩展和灵活的软件系统。
通过应用 DDD,我们可以更好地理解领域,并在领域专家和软件开发者之间建立一个共同语言。
在本章中,我们将涵盖以下主题:
-
DDD 的基础
-
DDD 中的战略和战术设计
-
DDD 中的建模活动
技术要求
您可以在 GitHub 上找到本章使用的代码文件:github.com/Packt Publishing/Software-Architecture-with-Kotlin/tree/main/chapter-8
DDD 的基础
DDD 的目标是缩小软件技术实现与它所服务的业务领域之间的差距。DDD 侧重于构建准确模型核心概念、业务规则和行为的软件,以便软件系统与业务需求紧密对齐。这使其具有价值、可维护、灵活和可持续性。
DDD 强调了问题空间和解决方案空间之间的区别:
-
问题空间:问题空间是业务现实——即业务运营的当前状况
-
解决方案空间:解决方案空间是我们拥有的或将要构建的软件系统,用于解决问题空间中的特定业务案例
问题空间的主导部分是领域,它代表特定的业务用例和操作。解决方案空间提供了一种建模领域以解决给定业务用例的方法,因此得名领域模型。这种关系在图 8.1中得到了说明:
图 8.1 – 问题空间和解决方案空间
领域模型抽象和选择领域中的某些元素,以便在它之上构建软件系统。这种抽象和选择的结果在领域模型中永远不会是 100%正确和完整的。这正是统计学家 George Box 在 1976 年发表在 《美国统计协会杂志》 上的论文中所写的:
“所有模型都是错误的,但其中一些是有用的。”
在 DDD 中,领域模型的目标不是完整和准确;相反,它们的目标是在特定的业务环境中是有用的。
DDD 鼓励工程师深入了解他们正在为构建软件的领域。有领域专家了解他们需要什么软件来完成工作。这对于最初使用手动流程或纸质文件来运营业务的领域尤其如此。软件系统通常被视为业务运营的自动化工具。拥有运营业务的领域专家为工程师在构建相应的软件中带来了很多价值。
DDD 包含两种设计方法:
-
战略设计:这侧重于更大业务领域内多个连贯区域(称为边界上下文)之间的整体结构和组织。它通过定义它们的协作,致力于在边界上下文之间实现灵活和松散耦合的系统。
-
战术设计:这指的是使构建有用的领域模型更简单的模式、工具和实践。当我们有复杂的业务逻辑要建模或未来可能引入复杂性时,我们使用战术设计。
需要指出的是,DDD 与选择的技术和框架无关。对业务领域的建模纯粹是软件系统应该构建的方式,而不是使用的工具。
DDD – 战略设计
建议从战略设计开始 DDD,在深入到更细粒度的战术设计之前建立整体图景。第一步被称为通用语言。
通用语言
在工程师和领域专家之间使用一种共同的语言对于软件系统的成功至关重要。业务领域通常涉及许多行业术语、专业概念和微妙规则。然而,并非所有这些都可以应用于软件系统的范围。另一方面,工程涉及许多技术术语、方法和最佳实践,所有这些对于给定的软件系统都是必需的。
通用语言是由埃里克·埃文斯在他的 2004 年出版的书籍《领域驱动设计:软件核心的复杂性处理》中引入的术语。它作为工程师和领域专家之间共享的业务领域共同理解和心智模型。它旨在使用一致、明确和精确的语言来消除误解和歧义。它是一个持续、协作的努力,旨在为有效的和有意义的沟通建立一个共同的基础。通用语言是领域和领域模型之间的共同语言。
通用语言也是文档和系统知识库的一种形式。一旦建立,新团队成员可以快速理解领域和现有的代码库。它还促进了团队和利益相关者之间的知识转移,从而促进了更好的协作,并降低了信息丢失的风险。
通用语言是领域专家语言和工程语言的重叠部分,如图图 8.2所示,它暗示了一种相互理解:
图 8.2 – DDD 中的通用语言
通用语言的好处以各种形式体现:
-
术语表:术语表包含术语和概念及其定义,可供每个人阅读和学习业务领域。每个术语都经过参与开发过程的所有人的审查和同意。理想情况下,应该有一个流程,可以提交、审查、批准和跟踪更改,例如在GitHub这样的可审计仓库中。
-
文档:所有与业务领域相关的文档都应使用术语表中定义的相同术语和概念。这些文档包括用户手册、操作说明、项目计划、架构设计文档、图表、用户故事、屏幕原型、演示文稿、API 文档等。任何新的术语和概念都应添加到术语表中。
-
私有大型语言模块(LLMs):业务领域知识,包括术语、概念、规则和流程,可以用来训练 LLM,使其能够回答问题、完成文本、生成对话,甚至成为客户服务机器人的组成部分。然而,这确实需要精心设计提供给模型的输入,以生成期望的输出或响应。这个过程被称为提示工程,但超出了本书的范围。OpenAI 的文档有一个关于提示工程的章节,提供了实用的指导:https://platform.openai.com/docs/guides/prompt-engineering。
-
源代码:源文件、函数、接口、类甚至变量的命名应使用在词汇表中定义的相同术语。消息负载、数据库表和字段、日志消息和错误消息也应在使用时使用这些术语和概念。这些术语在代码库的每个元素中都被使用。对于软件系统,我们将其定义为通用语言的是在业务域中随处可见的相同语言。
子域
子域是业务域中的一个独立区域,它有自己的概念集和业务规则。子域中使用的语言是通用语言的组成部分,子域内的相应概念自然形成自己的群体。
子域有助于将大型域的复杂性分解为更小、更易于管理的部分,使团队能够专注于理解和解决每个子域的独特需求和挑战。子域属于问题空间,并且它们可能并不总是与解决方案空间中的一部分一一对应。
子域可以分为三个组:
-
核心域:核心域是业务运营的核心。没有它们,就没有问题需要解决,也就没有理由构建软件系统。核心子域是组织区分竞争对手的最关键部分,它们也拥有最复杂的业务案例。
-
支持子域:支持子域提供辅助工具和功能,这些工具和功能补充并加速核心子域,但它们不是业务的主要专业领域。它们通常是众所周知的能力,相应的解决方案可以在市场上找到或外包。它们对业务仍然相关,但并不提供显著的竞争优势。
-
通用子域:通用子域指的是不特定于业务的常见问题。它们相应的解决方案是现成的商业产品。它们对于运行业务运营至关重要,但它们并不直接贡献于业务的核心理念。
通过识别和建模子域,领域驱动设计(DDD)使得开发出凝聚、模块化和松散耦合的软件系统成为可能。这引出了解决方案空间中的一个重要概念,即有界上下文。
有界上下文
在第六章中,我们提到了识别高度凝聚的功能并将它们分组为独立的可部署工件的重要性,这样我们就不会最终构建出一个庞大且难以维护的单体应用,这种应用难以理解,几乎无法优化。系统可以被分解为有界上下文。
有界上下文是解决方案空间中的一个概念。它代表业务域的一个连贯区域。它有自己的范围、责任和规则,这些规则与其他有界上下文不重叠。一个有界上下文应该有一个明确的目的和一个清晰的边界。一个定义良好的有界上下文可以清楚地回答一个术语或规则是否属于上下文内部或外部。在有界上下文中,每个构建块都有特定的语义和目的。
它移除了一些复杂业务域的部分,并将它们转化为更小、更易于管理的单元。最终,足够数量的有界上下文的总和构成了整体域模型,从而消除了它成为一个单体应用的机会。
一个有界上下文应该只代表一个子域,但一个子域模型可能需要另一子域模型的一小部分才能正常工作。
此外,一个有界上下文应该有自己的源代码仓库。它有自己的数据模式和数据,这些数据仅通过 API 文档中定义的外部表示与其他有界上下文共享。它还应拥有专门的部署工件。它们可以独立发布,而不依赖于或影响其他有界上下文。它们应该有自己的发布周期。
有界上下文应由一个团队拥有。团队在选择框架和开发方法上拥有完全的自主权。在第一章中,我们提到了康威定律,该定律指出,一个组织通常会生产出反映组织内部结构的软件系统。团队不应受组织结构的限制,而应根据在 DDD 过程中发现的有界上下文进行重组。
上下文映射
因为有界上下文分解了一个系统,所以它们需要协作以使系统能够运行并实现整体目标。上下文映射是一种可以用来识别有界上下文之间关系和交互的技术。
这里是这个关系中的常见模式:
- 合作:两个或更多有界上下文建立合作关系。这包括建立紧密合作、共享理解和联合决策,以解决特定的业务需求。即使有界上下文有自己的目标,但它们的目标是相互连接以帮助解决特定问题。因此,它们共同成功或失败:
图 8.3 – 合作
- 共享内核:两个有界上下文共享它们模型或代码的子集。它们可能共享相同的数据模式、源代码模块或作为工件的可编译代码。然而,在两个有界上下文之间共享数据模式或原始源代码被视为反模式。然而,两个有界上下文依赖于同一被视为共享库的工件是可以接受的:
图 8.4 – 共享内核
- 客户-供应商:上游边界上下文提供数据,而下游边界上下文消费这些数据。客户和供应商合作并就数据协议达成一致:
图 8.5 – 客户-供应商
- 一致者:上游边界上下文提供数据并规定数据协议。下游边界上下文遵守该协议并消费这些数据。上游边界上下文可以是外部系统或使用行业标准协议,这使得它无法适应下游边界上下文:
图 8.6 – 一致者
- 反腐败层:上游边界上下文提供数据并主导数据协议。然而,下游边界上下文并不愿意遵守该协议。相反,下游边界上下文构建一个反腐败层来消费数据并将其转换为它所需的结构:
图 8.7 – 反腐败层
有几种情况下,下游边界上下文决定不遵守:
-
上游边界上下文使用的数据协议不方便,这使得集成和消费数据变得困难
-
上游边界上下文不可靠,经常变化,或偶尔带来破坏性的变更,下游边界上下文试图最小化这些变更,以便减少上游边界上下文带来的任何问题的影响
-
上游边界上下文中使用的数据协议将冲突或不相关的数据带到下游边界上下文
-
下游边界上下文反映了核心领域,这为不依赖外国数据协议提供了理由
-
分道扬镳:由于它们决定分道扬镳,边界上下文之间没有交互。这可能是因为集成成本过高、不可持续或不可能。以下是一些已经发生这种情况的示例场景:
-
每个边界上下文的团队在协作和达成一致方面都遇到了困难。这可能是由于涉及遗留系统或仅仅是组织政治问题。而不是让漫长的谈判过程拖延下去,边界上下文发现复制其自身空间中的逻辑更容易且更快。
-
边界上下文之间的模型差异太大,无法使用一致模式,或者与在其自身边界上下文中实现定制逻辑相比,使用反腐败层模式成本太高。上游边界上下文可能只提供下游边界上下文所需的部分数据,而下游边界上下文可以复制部分逻辑,但创建一个适合其特定目的的完整模型。
-
通常,在通用子领域中,与在各自的边界上下文中复制逻辑相比,协作的价值很小。可能某些库被用来生成数据,这就是为什么集成成本不合理的理由。
-
-
开放式主机服务:一个边界上下文定义并公开了一个公共 API,其他边界上下文可以使用它来扩展其功能。公共 API 有意与内部模型解耦,以便两者可以独立演进。边界上下文的内部模型也保持私有:
图 8.8 – 开放式主机服务
-
发布语言:发布语言侧重于在边界上下文中建立共享语言和术语表。发布语言并不旨在符合其通用语言,尽管如果通用语言中已经存在合适的术语,则无需重新发明词汇。
发布语言旨在暴露一个方便消费边界上下文的协议。它用一种通常与任何编程语言无关的集成导向语言表达。此外,每个边界上下文都应该能够将发布语言与相应的内部模型之间进行转换。
发布语言确保了对领域概念的统一理解,并促进了边界上下文之间的沟通。
发布语言可以定义在许多知名格式中,例如OpenAPI、Avro、Protobuf、固定长度值和逗号分隔值。
将战略设计应用于实际案例
到目前为止,我们已经概述了在领域驱动设计中战略设计所使用的技巧和方法。现在,我们将把它们应用到本书中一直使用的相同实际案例中。
第 1 步 – 业务问题摘要
让我们回顾一下家庭交换服务的实际案例。团队设法找到了一位开始在该村庄交换服务的资深女士。团队对她进行了采访,并记录了他们的笔记。以下是经过她作为领域专家验证和澄清的摘要:
“我们是一个紧密团结的社区,村民经常交换服务以支持彼此。村庄由各种家庭组成,每个家庭都有其独特的技能、才能和需求。家庭认识到,通过提供他们的服务,他们可以帮助彼此并创建一个更强大的社区。
在这个村庄里,有专门从事不同领域的家庭,如木工、农业、烹饪和育儿。例如,惠丁顿先生是一位技艺高超的木匠,他制作和修理家具。当巴克太太需要一张新的餐桌时,她找到惠丁顿先生请求他的服务。
作为惠丁顿先生木工工作的回报,巴克太太全年向木匠家庭提供她农场的新鲜农产品。
巴克夫人起草了一份合同,以记录交换的细节,例如桌子的材料和尺寸,提供什么和多少新鲜农产品,以及时间表。惠廷顿先生审查了合同并做了一些调整。最后,惠廷顿先生和巴克夫人都签署了合同。
这种安排帮助惠廷顿先生和他的家庭获得了稳定的营养食品供应,同时允许巴克夫人在家中享受精美制作的家具。
这些交换构成了村庄社会和经济生态系统的框架。它们在家庭之间培养了信任、合作和相互支持,创造了一个和谐且具有弹性的社区。”
通过这次访谈,团队捕捉到了村庄家庭服务交换的本质。现在,团队理解了过程中涉及到的动机、互动和利益。
第 2 步 – 通用语言
在这一点上,领域专家和团队可以通过识别以下在领域故事中提到的概念来开始开发对领域的共同理解:
-
家庭:居住在同一居住地的一群村民。
-
服务:家庭专长并能提供给另一个家庭的能力。
-
合同:两个家庭之间交换的服务细节的协议。
-
起草合同:由一个家庭发起,供另一个家庭审查的合同。
-
已达成协议的合同:合同细节已由双方家庭共同商定并签署。已达成协议的合同准备执行。
-
已执行合同:一项双方家庭执行的服务已完成的合同,按照每个服务的详细信息。
这是通用语言的基础,以术语表的形式存在。它由领域专家和团队共同开发。
第 3 步 – 子域
识别子域是 DDD 过程的下一步。在术语表的帮助下,团队已将合同识别为核心域,将家庭识别为支持子域。
-
合同是核心域,因为它是业务域的核心。没有它,就没有必要构建系统。家庭是一个必要的支持子域,以便识别每个合同中涉及的家庭。
-
家庭本身不能作为核心域,因为技术上可能仅使用家庭子域就能解决业务问题。如果家庭在合同中仅仅是名称而没有验证和识别的能力,那么可能存在一个没有家庭子域的系统简化版本。
团队也认识到在合同的几个阶段需要通知家庭,例如当合同双方达成一致并准备执行时。在这里,“通知”也被识别为一个通用子域。
核心域和已识别的子域在图 8.9 中展示:
图 8.9 – 核心域和子域
第 4 步 – 有限上下文
在问题空间中定义了核心域和子域后,现在是时候在解决方案空间中识别有限上下文了。如前所述,有限上下文由其范围、责任和目的定义。
团队理解“合同”是核心域,并且有两个涉及合同的流程。第一个流程从起草合同开始,直到达成协议。第二个流程从达成协议开始,直到执行。团队还理解这两个流程的唯一连接点是达成协议的合同。
有团队成员建议为整个合同核心域创建一个有限上下文。也有其他人建议为第一个旅程创建一个有限上下文,为第二个旅程创建另一个。
团队决定将合同的第一个旅程从起草到执行视为一个有限上下文,并将其命名为合同服务。这个有限上下文维护从起草到达成协议,再到执行的合同流程。然而,合同必须涉及两个不同的家庭以及每个家庭提供的服务。因此,合同服务覆盖了合同域的大部分以及家庭子域的一小部分以验证合同。
支持子域“家庭”也需要一个有限上下文来支持家庭的经典创建、读取、更新和删除(CRUD)操作。相应的有限上下文命名为家庭服务。
解决方案旨在在合同达成或执行时通知涉及该合同的家庭。这个通用子域“通知”由名为通知服务的有限上下文覆盖。
图 8**.10 展示了有限上下文如何覆盖核心域和子域:
图 8.10 – 有限上下文、核心域和子域
家庭服务是唯一一个专门针对其子域的有限上下文。其他有限上下文有一个主要子域或核心域,同时覆盖其他子域的小部分,以便它们能够运行。
第 5 步 – 上下文映射
需要通过上下文映射来定义有限上下文之间的关系。
家庭服务没有上游依赖,但合同服务需要接收家庭信息以执行其操作。
在“家庭服务”下的家庭包含诸如家庭名称、联系电子邮件地址、成员名单、居住地址等信息。“合同服务”只关心家庭名称以验证合同,以及联系电子邮件地址以便能够请求发送通知给指定的家庭。
由于 Contract Service 代表核心领域,因此在与 Household Service 通信时,使用反腐败层避免引入来自其他边界上下文的内容是有益的。
通知服务作为一个通用边界上下文,只需要从家庭接收通知请求并与电子邮件服务提供商集成以实现其目标。它支持由 Household Service 和 Contract Service 支持的一小部分概念,但它们溶解为一个电子邮件地址、一个标题和正文文本。这是一个客户-供应商关系,其中 通知服务 是客户,其他边界上下文是供应商。
整体 上下文映射如 图 8.11 所示:
图 8.11 – 上下文映射
团队和领域专家已经通过输出词汇表建立了通用语言。他们还确定了核心领域和子领域。最后,他们确定了边界上下文及其之间的映射。
团队现在对整个家庭交换服务业务模型有了战略设计的大图景,这是 DDD 的一部分。从现在起,团队可以专注于每个边界上下文及其内部模型。
DDD – 战术设计
战术设计关注于边界上下文内部的模型和关系。它的目标是创建一个高度内聚的领域模型,该模型表达了基本业务概念并与上下文中使用的通用语言保持一致。
战术设计由几个构建块组成,为在复杂业务域内设计和构建边界上下文提供基础:
-
Address
类是一个值对象,因为它没有标识符,但封装了构成地址的多个字段:data class Address( val line1: String, val line2: String? = null, val line3: String? = null, val postalCode: String, val city: String, val country: String )
-
Household
类是一个通过name
标识的实体:data class Household( val name: String, val emailAddress: String )
-
Party
类是一个聚合,因为它包含Household
实体类:data class Party( val household: Household, val serviceProvided: String, val agreedAt: Instant? = null, val completedAt: Instant? = null, )
Contract
类是一个聚合根,因为它是对其他聚合(如Party
)和其他实体(如Household
)的最高级别入口:data class Contract( val partyA: Party, val partyB: Party, val contractState: ContractState, )
读取和修改聚合状态入口点被称为 聚合根。聚合参与维护领域模型的完整性并确保所有相关实体都合理地链接在一起。
-
领域服务:领域服务封装了与特定实体或值对象无关的领域行为。它们强制多个对象之间的协作。领域服务有助于维护领域模型的内聚性和完整性。
-
仓储:仓储存储和检索领域对象。它定义了领域对象的可能存储和检索选项,但抽象了实际的存储实现。仓储可以是持久的,类似于数据库或文件。它也可以是瞬时的,类似于内存缓存。持久性、可用性、隔离级别和底层存储方法是唯一的实现细节。换句话说,领域服务不需要为数据存储做出技术选择。
-
领域事件:领域事件代表在边界上下文中发生的事情。它们都使用过去时态命名,且不可变。
将这些构建块组合起来,DDD 中战术设计的范围可以如下表示:
图 8.12 – DDD 中的战术设计
在这里,边界上下文是应用的核心。然后,我们有核心,这是我们在第七章中讨论分层架构时提到的相同概念——它是一个没有技术选择纯业务关注区域。这也是战术设计所有元素所在的地方。
应用将外部请求转换为领域服务处理的内部请求。请求由领域服务或相应的聚合中的业务规则进行验证和处理。然后,领域服务从根实体访问聚合以访问底层实体和其他值对象。之后,领域服务使用仓储作为处理过程的一部分来持久化和检索实体。
最后,领域服务将响应返回给请求的发起者。应用将内部响应转换为外部响应。同时,由于请求处理,产生域事件。这些事件被应用捕获,转换为外部事件,并发布。
将战术设计应用于实际案例
在之前,团队在针对家庭交换服务的实际案例的战略设计中定义了三个边界上下文。在这里,团队将选择一个边界上下文,并完成战术设计练习。
第 1 步 – 识别聚合、实体和值对象
这里选择的边界上下文是合同服务,它代表合同的核心领域。这个边界上下文的目标是维护合同的整个生命周期,从最初起草到双方共同执行。
data class Contract(
val partyA: Party,
val partyB: Party,
val contractState: ContractState,
)
data class Party(
val household: Household,
val serviceProvided: String,
val agreedAt: Instant? = null,
val completedAt: Instant? = null,
)
enum class ContractState {
DRAFTED,
UNDER_REVIEW,
AGREED,
REJECTED,
PARTIALLY_EXERCISED,
FULLY_EXERCISED,
WITHDRAWN,
}
data class Household(
val name: String,
val emailAddress: String
)
合同聚合从根合同实体开始。合同实体通过一个 ID 来识别。合同实体包含两个相同类型的值对象:当事人。当事人值对象只包含表示合同一方所需的必要字段。当事人值对象包含一个住户实体。
住户实体在合同服务中是本地的,因为它只包含与边界上下文相关的名称和电子邮件地址。在这里,住户服务从住户服务的边界上下文中提供完整的住户实体。外部的实体包含其他字段,如住宅地址;当合同服务中的反腐败层将外部实体转换为本地住户实体时,这些字段被忽略。
因此,本地的住户实体只包含一个名称和一个联系电子邮件地址。本地的住户实体通过其名称来识别。
第 2 步 – 识别领域服务、存储库和领域事件
在定义了根、其下的实体和值对象之后,团队可以确定边界上下文为实现其目标所需的操作。
在这个边界上下文中有三个主要操作:
-
合同由一个住户起草
-
所有参与的住户都同意合同
-
合同由所有参与的住户执行
当这些操作发生时,应在合同上设置相应的时间戳。服务还应通过事件通知下游的边界上下文。
合同服务验证传入的请求,例如合同和住户是否存在,以及合同是否应该更改。如果一切顺利,则在合同中设置对应服务的相应时间戳。如果合同已被起草、同意或执行,则发布相应的领域事件。
为了支持这个操作,合同服务需要知道两个住户。它们由上游边界上下文发布的领域事件提供。
这个边界上下文消费由住户服务发布的住户更新事件。住户实体从这个事件转换而来,并存储在住户存储库中。
合同服务中的本地住户实体是住户服务中原始住户实体的一个简化版。合同服务应只取住户中与其边界上下文内的操作相关的字段。同时,住户服务包含与整个业务领域相关的住户的全部字段集。
这些存储库作为本地缓存,因此如果其他边界上下文不可用,合同服务仍然存在。
边界上下文的内部结构可以在图 8.13中看到。13*:
图 8.13 – 边界上下文内部 – 合同服务
领域专家和团队已经针对现实生活中的例子进行了战略和战术设计的演练。他们涵盖了通用语言、子域、边界上下文、上下文映射、聚合、实体、值对象、领域服务、领域事件和存储库。通过这些练习,领域专家和团队建立了对业务问题的共同理解,并且现在他们有一个有用的模型,可以作为构建软件系统的基础。
有几种领域驱动设计工作坊的格式,任何组织都可以考虑引入,以改善利益相关者和工程师之间的沟通和协作。下一节将简要介绍它们及其格式。
领域驱动设计的建模活动
在我们的现实生活例子中,我们介绍了解决方案是如何从团队和领域专家的输入中演变和出现的。了解领域驱动设计中涉及的实际建模活动非常重要,这样我们才能将其付诸实践。
有几种流行的建模活动可以推动团队和领域专家设计方向的发展。
领域专家访谈
领域专家访谈的概念是由埃里克·埃文斯在他的书中提出的,这本书是《领域驱动设计:软件核心的复杂性处理》,于 2003 年出版。
进行领域专家访谈是一种可以用来深入了解业务领域并理解其复杂性的方法。它特别适用于以下场景:
-
领域专家是组织外部的
-
领域专家参与的时间有限
-
组织开始新的业务线并引入领域专家
-
团队是新的,对领域没有太多先前的知识
下面是一些在领域驱动设计中进行有效领域专家访谈的指南:
-
识别合适的专家:识别内部和外部具有特定领域深入知识和专业知识的个人。寻找主题专家、经验丰富的实践者或对涉及的业务流程和规则有深刻理解的个人。
-
准备访谈计划:明确访谈的目标。为每个主题制定大纲。准备一个问题清单,最好是开放式问题,以便提问。注意潜在的痛点和不明确区域。
-
建立一个舒适的环境:在访谈过程中与领域专家建立协作氛围。让他们感到舒适和受重视。清楚地解释访谈的目的和概述要讨论的主题。强调他们的意见对项目成功至关重要。创造一个让他们能够自由分享知识和经验的环境。
-
积极倾听:在面试期间,练习积极倾听。密切关注专家的口头回答和非语言交流。在必要时澄清他们的答案,并就特定兴趣领域提出后续问题以深入了解。表现出对他们见解的真实兴趣,并验证他们的贡献。
-
记录笔记:在面试期间做详尽的详细笔记。记录专家分享的重要概念、术语、流程和例子。记录关键细节,如业务规则、决策标准以及例外情况。使用图表或草图等视觉辅助工具来捕捉他们的解释和心智模型。
-
验证和澄清信息:在面试后,回顾所记录的笔记并验证收集到的信息。与领域专家进行澄清,或就模糊性或不确定性进行后续讨论。确保团队对领域有清晰和准确的理解非常重要。
-
分享和验证你的发现:在面试后,与领域专家和其他利益相关者分享你的发现以验证你的理解。积极寻求他们的反馈,并吸收他们可能提供的任何纠正或额外见解。通过吸收他们的意见,领域模型可以得到改进和优化,确保软件系统准确反映业务领域的复杂性和需求。
领域专家访谈是一个迭代过程。随着团队对领域了解的加深,新的问题会出现。在整个项目过程中与领域专家持续进行协作和反馈,可以显著促进你的领域驱动设计(DDD)实施的成功。
事件风暴
事件风暴是一种由 Alberto Brandolini 在 2013 年开发的领域建模技术。事件风暴要求团队、领域专家和其他利益相关者聚集在同一地点,最好是面对面,汇集关于领域的不同观点和知识。
现场事件风暴会议需要一个大的白板、多种颜色的许多记号笔和许多颜色的便签。或者,具有相同元素的在线协作绘图工具也可以满足需求。
预期参与者将积极参与过程中的每一个活动。他们需要同时移动一些便签、擦除线条并在白板上绘制线条。
会议开始于概述会议范围。它应该专注于参与者想要探索的特定业务流程或领域。这也可以是一个特定功能、用户旅程或领域的关键方面。
之后,会议可以按照以下顺序进行。如果需要,参与者可以在后续步骤中学习更多内容时,返回并纠正任何有问题的便签:
-
合同已签订
或家庭信息已更新
。 -
安排事件:一旦收集到一组域事件,就在白板上处理这些域事件。分组重复事件,删除不相关的事件,或纠正模糊的事件。将事件按时间顺序排列,从左到右创建时间线。这有助于可视化事件的流动和它们的顺序。
-
添加演员和命令:识别事件中涉及的演员或实体。这些可能是系统组件或人类用户。将演员以贴纸形式捕捉,但使用不同的颜色(例如,黄色)。将它们放置在相关事件上方或下方。
识别触发事件的任何命令或动作,并将它们与相应的事件关联起来。命令是用户想要做某些事情的意图。命令或动作应使用新的颜色(例如,蓝色)的贴纸。
识别触发事件的任何外部系统。使用专用颜色(例如,红色)的贴纸。
-
探索策略和业务规则:注意与事件相关的策略、约束和业务规则。将它们作为新颜色的单独贴纸捕捉(例如,紫色),并将它们链接到相关的事件、命令或演员。这些规则有助于塑造域内的行为和交互。
-
讨论和细化模型:在贴纸旁边促进参与者之间的讨论,包括事件、演员、命令、外部系统和策略。鼓励他们分享与事件及其关系相关的知识、洞察和问题。通过在讨论期间重新排列、添加或删除贴纸来细化模型。
-
识别聚合和边界上下文:寻找经常一起出现的事件和模式。这个事件群可能表明潜在的聚合。识别边界上下文,这是具有明确目的、边界和语言的域的凝聚区域。
-
捕捉洞察和下一步行动:捕捉任何有价值的洞察、问题或不确定区域。记录任何后续行动或进一步调查以细化域模型所需的行动。
事件风暴也是一个迭代的过程。有时,需要多次会议才能完全探索和细化域模型。鼓励参与者之间的协作、积极参与和思想共享,以获得共同的理解并推动与业务目标一致的软件系统。
域叙事
域叙事由Stefan Hofer和Henning Schwentner在 2019 年他们所著的《域叙事:域驱动设计的协作方法》一书中提出。
域叙事是一种协作和互动的方法,旨在更深入地理解复杂的企业域。团队、域专家和其他利益相关者聚集在一起,理想情况下是亲自,以开发描绘业务域各个方面的故事。
与事件风暴类似,面对面的领域叙事会议需要一个大的白板、许多记号笔和许多便利贴。同样,具有相同元素的在线白板协作工具也足够使用。
领域专家和利益相关者通过在白板上贴上便利贴并用记号笔连接它们,绘制出一个真实的业务场景。
关于起草合同的领域故事可能看起来像这样:
图 8.14 – 关于起草合同的领域故事
这些故事捕捉了领域内的上下文、挑战、交互和关系。当故事被讲述时,每个人都积极参与地倾听、提问,提供背景信息,如业务规则、动机和痛点,甚至拿起笔绘制草图。
参与者可能希望采用某种格式,例如在第二章中提到的业务流程建模符号(BPMN),但这并非强制要求。练习的本质是沟通和协作,以确保大家对共同的理解达成一致。此外,领域专家可能不熟悉标准的视觉概念。为了避免他们为了获得正确的概念而挣扎,参与者应专注于获取正确的信息。
参与者应利用这个机会澄清任何含糊不清、不明确和误用的语言,以实现通用语言。
视觉表示作为共享工件,可以在整个开发过程中被参考和改进。
领域叙事通过共同努力实现共享语言和领域理解,帮助弥合团队和领域专家之间的差距。它有助于揭示隐藏的需求、边缘情况和异常场景,这些通常在开发的中后期被发现。因此,它减少了设计修改的成本,并降低了范围蔓延的风险。
此外,领域叙事通过强调故事中用户的需求、目标和经验,为软件系统提供了一个以用户为中心的视角。它帮助团队和领域专家识别边界上下文、聚合根和领域实体,从而促进构建一个强大且准确的领域模型。
总体而言,领域叙事作为领域探索、分析和沟通的有力工具,有助于建立对领域的共同理解,促进利益相关者和团队之间的协作,并支持构建准确反映业务领域复杂性和需求的软件系统。
模型活动比较
在领域驱动设计中,所有的模型活动都是迭代的。随着参与者对主题内容的理解不断深入,以及业务的不断发展,这些活动持续地精炼和塑造软件系统。
然而,这三个活动的动态是不同的。在领域专家访谈中,团队通过准备主题和问题来驱动访谈。领域专家主要对这些材料做出反应,团队则积极倾听并做出回应。在事件风暴中,每个人都根据领域专家和利益相关者之间共享的知识在板上的所有便签上工作。最后,在领域故事讲述中,领域专家和利益相关者的叙述是在白板上进行的,团队则积极倾听、反应和提问。
这些都是很好的工具。如果难以让领域专家可用,领域专家访谈尤其有用。如果每个人都对领域有一些知识和经验,事件风暴就很有用。它侧重于从左到右可视化事件流的时间线。领域故事讲述侧重于用叙述故事捕捉领域知识。它不需要创建从左到右的时间线,也不需要使用事件风暴所用的所有标签。
摘要
在这一章中,我们深入探讨了 DDD 中的两种主要设计方法。我们涵盖了战略设计的基本概念,以获得领域的大图景,同时提供了一个现实生活中的例子。在这里,我们涵盖了通用语言、子域、边界上下文和上下文映射等概念。
之后,我们通过使用战略设计示例中的边界上下文来探索战术设计。我们展示了如何识别聚合、实体、值对象、领域服务、存储库和领域事件。
我们还涵盖了 DDD 中的三个流行建模活动,并讨论了它们的议程:领域专家访谈、事件风暴和领域故事讲述。
到目前为止,你应该能够使用至少一种概述的建模活动来规划和设计使用 DDD 方法架构。
在下一章中,我们将深入探讨基于 DDD 的某些架构模式,即命令查询责任分离(CQRS)和事件溯源。
第九章:事件源和 CQRS
上一章关于领域驱动设计(DDD)为我们奠定了基础,让我们深入探讨两种强大的架构模式,这些模式满足了可扩展、响应和可维护应用程序的需求:事件源和命令-查询责任分离(CQRS)。
首先,我们将探讨事件源的基础。我们将讨论如何使用事件源来建模我们的领域,如何持久化领域状态,以及如何从持久化的事件中重建当前状态。我们将探讨这种方法的优点。
接下来,我们将把注意力转向 CQRS,探讨它如何分离命令(写)和查询(读)的责任。我们将讨论 CQRS 架构的关键组件,包括命令和查询处理器、领域模型和事件存储。我们将深入探讨这种分离的优点。
随着我们进一步深入,我们将检查实现 CQRS 和事件源相结合的实际考虑因素,包括数据建模、事件架构设计和处理最终一致性。我们还将讨论将这些模式集成到现有软件生态系统中的策略,确保无缝且可扩展的过渡。
通过实际示例和最佳实践,您将全面了解如何将 CQRS 和事件源转化为您对软件设计和开发的方法。到本章结束时,您将具备利用这些模式的力量并释放应用程序全部潜能的知识和工具。
我们将按照以下顺序介绍以下主要内容:
-
事件源
-
命令-查询责任分离(CQRS)
-
结合 CQRS 和事件源
技术要求
您可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-9
事件源
事件源是一种数据管理模式,其起源可以追溯到 20 世纪 90 年代,当时工程师认识到传统数据存储创建、读取、更新和删除(CRUD)的局限性,尤其是在构建复杂和事件驱动系统的背景下。
事件源起源于领域驱动设计(DDD)的原则,如第八章所述。DDD 引入了聚合作为领域模型的基本构建块的概念,并且聚合通常需要保存在数据存储中。
经典的 CRUD 方法及其局限性
经典的 CRUD 方法通过 CRUD 操作捕获聚合的最新快照是足够的,通常使用关系数据库。然而,这种方法存在局限性:
-
历史、可审计性和可追溯性:虽然 CRUD 方法可以捕捉聚合体的当前快照,但其保留所有随时间对聚合体所做的变更的审计轨迹的能力有限。
这通常通过自定义数据持久化代码来保持历史记录,或者通过数据库更新触发器的辅助来实现。这可能会使得跟踪变更历史、理解系统如何达到特定状态以及遵守监管要求变得具有挑战性。
-
建模复杂领域:基于 CRUD 的系统与简单直接的数据模型配合良好,但它们在有效表示和管理复杂领域模型随时间演进方面可能会遇到困难。
传统上,使用关系型数据库时,复杂的聚合对象会导致复杂的数据库模式、复杂的数据持久化操作,以及维护和演进系统的困难。
-
事件驱动能力:CRUD 方法不支持事件驱动架构,在这种架构中,系统需要以解耦和可扩展的方式对变化做出反应并传播。
-
并发和一致性:基于 CRUD 的系统通常依赖于传统的锁定机制来确保数据一致性,这往往会导致在分布式、并发和高负载环境中的性能瓶颈。
在并发更新的情况下维护强一致性在 CRUD 系统中可能是一个重大的挑战。
-
版本控制和演进:更新和演进基于 CRUD 的系统可能会出现问题,因为数据模型或业务逻辑的更改可能需要复杂的迁移和数据转换。
在以 CRUD 为中心的方法中,版本控制和处理历史数据也可能更加复杂。
-
分析和报告:CRUD 系统关注聚合体的当前快照,这可能会使得分析、生成报告或从聚合体的历史数据中提取洞察变得具有挑战性。
面对这些挑战,捕捉聚合体变更全历史的想法开始受到关注。
事件作为一等公民
事件溯源旨在通过将事件作为一等公民来解决这些挑战。这里的“事件”一词与在第八章中提到的 DDD 中的“事件”具有相同的概念。一个事件捕捉了聚合体的变化,使其成为该框架中的关键元素。
事件溯源将聚合体的所有事件持久化存储在事件存储中。事件没有更新或删除操作,因为事件代表了已经发生变化的聚合体。换句话说,事件是不可变的,并且按时间顺序存储为日志。事件存储通常不是关系型数据库;它们可以是 NoSQL 数据库或持久队列。
与 CRUD 不同,在 CRUD 中,一个聚合的最新快照是一个一等公民,事件源通过从第一个事件到最新事件重放聚合中的事件来推导出聚合的最新快照。因此,聚合的完整历史被保留,并且不需要自定义代码来提供聚合的审计跟踪。
此外,聚合的历史被捕获为一个线性时间线,并自然地消除了保持并发更新强一致性的挑战。然而,在接收对聚合进行更改的请求之前,应该进行版本验证,并最终生成事件。这是为了防止丢失更新问题,即并发更新相同聚合时,不知情地覆盖彼此。
事件源的功能表示
将系统的状态表示为不可变事件的序列的想法与函数式编程范式很好地一致。聚合和事件都是不可变的。每次更改都是通过通过无状态函数从事件创建聚合的新版本来执行的。这可以通过两个基本函数表达,这两个函数被写成 Kotlin lambda 表达式:
(CreatedEvent) -> Aggregate
(UpdatedEvent, Aggregate) -> Aggregate
第一个函数创建一个初始聚合。随后,更新函数获取聚合的当前版本并创建一个新版本。
使用事件源处理请求的一个示例
假设有一个请求要更新现有的聚合。接收请求的服务需要获取聚合的最新版本以验证请求。因此,服务从事件存储中获取聚合的所有事件。
所有事件都被重放以重新创建聚合的最新快照。假设请求一切正常,服务创建一个新事件。然后服务将此事件播放到当前聚合上,并创建聚合的更新版本。
通过在事件存储中附加新事件来提交事务。更新的聚合版本可以用作对原始请求者的响应。
整个交互被表示为一个序列图,如图图 9.1所示:
图 9.1 – 事件源的一个示例
需要指出的是,聚合不是由服务直接更新的。这是通过处理新事件来实现的。此外,事件存储负责将新事件分发给对这些事件感兴趣的所有订阅者。
事件源的好处
事件源的好处来自于聚合完整审计跟踪的持久性:
-
带有意图的完整审计跟踪:不仅保留了聚合的完整审计跟踪,而且每个更改的意图也被捕获。聚合的每个事件的名称理想情况下应来自通用语言,使其成为业务感知和用户友好的历史。
-
时间旅行:由于完整的历史被捕获为一系列线性的事件,因此可以回到过去构建聚合体的历史表示。这有助于工程师重现过去发生的场景,用于调查和故障排除。它还使用户能够将历史聚合体作为一个功能来查看。
-
创建读取模型:一个聚合体的事件有多个消费者时,就会开启多个读取模型的大门。每个读取模型都消费相同的事件,但将其转换为满足其特定需求。这种方法提供了针对特定商业目的的定制化视图。
决定是否使用事件溯源
选择将事件溯源作为存储聚合体及其审计跟踪的方式不应轻率行事。这在我们对数据的推理方式上是一个根本性的转变,并且需要显著的努力才能使其工作。
从我们在第一章中提到的YAGNI原则来看,工程师应该构建最简单可行的事物。当有多种解决方案时,应该选择最简单的解决方案。
简单的解决方案与容易的解决方案不同
简单的解决方案不复杂,或者容易推理。容易的解决方案需要更少的努力来实现。以捕获一个新字段为例。如果我们认为该字段应该属于一个新实体,那么创建一个具有该字段的新实体是最直观和直接的方法。然而,新实体可能意味着添加新的数据库表、新的验证和新的公开 API。另一方面,如果我们将新字段附加到现有实体上,我们只需要增强现有实体、数据库表和 API。在编码和测试中涉及的精力更少,即使该字段不属于现有实体。这是一个容易的解决方案,因为需要的努力较少,但它并不简单,因为它不直观,而且看到不属于实体的字段会让人困惑。
图 9.2展示了确定聚合体是否应该使用事件溯源的决策树:
图 9.2 – 是否使用事件溯源的决策树
最决定性的因素是考虑使用事件溯源的聚合体是否属于通用子域。在第八章中,我们确定了核心域、支持子域和通用子域。通用子域有很大可能性被现成的软件产品完全取代,这使得使用事件溯源的好处并不显著。
如果涉及的聚合属于核心域或支持子域,下一步考虑的是是否需要保留聚合的完整审计跟踪。完整审计跟踪可用于监管报告、回放事件以获取聚合的特定历史状态,或进行时间序列数据分析。这是事件源的一个强大功能,但并非所有聚合都需要这种功能。
另一个有助于考虑事件源的提示是,如果聚合有多个读取模型。这里的读取模型定义与在第八章(Chapter 8)中提到的事件风暴期间可以发现的读取模型相同。
需要多个读取模型的聚合可以从事件源中受益。每个读取模型都可以消费聚合的相同事件,但将其转换为聚合的物化视图的独特表示。有时,读取模型甚至可能结合来自其他聚合或实体的数据。
事件源广泛使用事件来记录聚合的每个变化,事件通常异步处理。如果聚合的操作主要是同步的,那么在聚合中实现事件源会带来挑战。有技术可以同步处理事件以更新聚合,但实现会有成本。
重要的是要重申,这只是一个示例决策树。每个组织在决策中可能有其他因素。有时,即使面对相同的问题,也可能做出不同的决定。
使用事件源的实际示例
让我们回顾一下村民交换服务的实际示例,这是在第八章(Chapter 8)中确定的三个边界上下文:
-
核心域:合同服务
-
支持子域:家庭服务
-
通用子域:通知服务
按照前一小节中提到的决策树(见图 9**.2),作为通用子域的通知服务可以安全地排除使用事件源。
在家庭服务中作为聚合的家庭户不需要保留完整审计跟踪,因为仅需要家庭户的最新状态来处理业务案例。CRUD 方法就足够了。
作为合同服务中聚合的合同可能需要保留完整审计跟踪,因为家庭之间在合同协议上可能发生争议。
还涉及多个涉及合同的读取模型。合同的主体读取模型是规定两个家庭之间合同细节的那个。
每个家庭也可以有一个单方面的读取模型。它包含一个家庭应提供的服务列表以及应提供服务的家庭。还有一个列表,列出了家庭期望接收的服务以及期望提供服务的家庭。
此外,还有一个潜在的读取模型,旨在突出村庄中最受欢迎的服务和最活跃的服务交换家庭。
合同的谈判过程涉及多轮修改,直到双方家庭达成一致。当一个家庭起草合同时,会异步地向另一个家庭发送电子邮件。在谈判过程中,一个家庭所做的任何更改都会导致涉及的家庭收到电子邮件通知。在根据合同提供服务的过程中,两个家庭之间也有多个消息。这种通信的异步性质表明,合同是合约服务中使用事件溯源的合适候选者。
在这个例子中,我们将专注于使用事件溯源来捕获合约服务中聚合合约的完整历史。
让我们重新审视聚合合约作为数据类:
data class Contract(
val id: UUID,
val draftedAt: Instant,
val updatedAt: Instant? = null,
val version: Int,
val partyA: Party,
val partyB: Party,
)
data class Party(
val householdName: String,
val serviceProvided: String,
val agreedAt: Instant? = null
)
Contract
数据类包含一个id
字段,该字段唯一标识此聚合。还有一个名为version
的字段,它是一个单调递增的整数,显示为该聚合播放了多少事件。
ContractEvent
的基本结构应包含聚合的唯一标识符和事件发生的时间:
interface ContractEvent {
val contractId: UUID
val targetVersion: Int
val time: Instant
}
它还有一个目标版本,这是事件应用后的聚合版本。
在这个例子中,我们使用一个简单的内存事件存储。它有两个基本功能。append
函数通过聚合 ID 在序列末尾添加新事件,而get
函数根据聚合 ID 返回事件的按时间顺序的序列:
class EventStore<KEY, AGGREGATE> {
private val aggregatesByKey = mutableMapOf<KEY, List<AGGREGATE>>()
fun append(id: KEY, payload: AGGREGATE) {
aggregatesByKey.merge(id, listOf(payload)) { t1, t2 -> t1 + t2 }
}
fun get(id: KEY): List<AGGREGATE>? = aggregatesByKey[id]
}
如果这是一个真实系统,应使用信誉良好的事件存储中间件来确保其持久性、高可用性和弹性。
聚合合约的创建始于起草了合同的家庭,并且它应包含创建聚合第一个版本所需的所有信息:
data class ContractDraftedEvent(
override val contractId: UUID,
override val targetVersion: Int = 0,
override val time: Instant,
val draftedByHousehold: String,
val counterpartyHousehold: String,
val serviceProvided: String,
val serviceReceived: String,
) : ContractEvent
Contract Drafted Event
类应提供一个创建聚合的函数。这是一个简单的函数,将值放入适当的结构中:
fun ContractDraftedEvent.play(): Contract = Contract(
id = contractId,
draftedAt = time,
version = targetVersion,
partyA = Party(
householdName = draftedByHousehold,
serviceProvided = serviceProvided
),
partyB = Party(
householdName = counterpartyHousehold,
serviceProvided = serviceReceived
)
)
任何后续事件都必须使用聚合的当前版本作为参数来生成新版本。例如,一个捕捉家庭修改并同意起草合同的事件的示例可能如下所示:
data class ContractAmendedEvent(
override val contractId: UUID,
override val targetVersion: Int,
override val time: Instant,
val amendedByHousehold: String,
val serviceProvidedUpdate: String?,
val serviceReceivedUpdate: String?,
) : ContractEvent
data class ContractAgreedEvent(
override val contractId: UUID,
override val targetVersion: Int,
override val time: Instant,
val agreedByHousehold: String,
) : ContractEvent
注意,此事件不一定遵循聚合的数据结构。关键点是保持事件简洁简单。因此,此事件仅提及一个家庭,并依赖于相应的play
函数来正确应用更改。注意,play
函数接受当前聚合的参数:
fun ContractAmendedEvent.play(current: Contract): Contract {
validate(current, amendedByHousehold)
return if (amendedByHousehold == current.partyA.householdName) {
current.copy(
version = targetVersion,
updatedAt = time,
partyA = current.partyA.copy(
serviceProvided = serviceProvidedUpdate ?: current.partyA.serviceProvided
),
partyB = current.partyB.copy(
serviceProvided = serviceReceivedUpdate ?: current.partyB.serviceProvided
)
)
} else {
current.copy(
version = targetVersion,
updatedAt = time,
partyA = current.partyA.copy(
serviceProvided = serviceReceivedUpdate ?: current.partyA.serviceProvided
),
partyB = current.partyB.copy(
serviceProvided = serviceProvidedUpdate ?: current.partyB.serviceProvided
)
)
}
}
fun ContractAgreedEvent.play(current: Contract): Contract {
validate(current, agreedByHousehold)
return if (agreedByHousehold == current.partyA.householdName) {
current.copy(
version = targetVersion,
updatedAt = time,
partyA = current.partyA.copy(agreedAt = time),
)
} else {
current.copy(
version = targetVersion,
updatedAt = time,
partyB = current.partyB.copy(agreedAt = time)
)
}
}
你会注意到有一个validate
函数,这对于确保数据完整性非常重要:
fun <T : ContractEvent> T.validate(current: Contract, expectedHouseholdName: String): T {
require(contractId == current.id) {
"Aggregate ID mismatch - expected: $contractId, was ${current.id}"
}
require(targetVersion == current.version + 1) {
"Unexpected version - expected: ${targetVersion - 1}, was ${current.version}"
}
require(
expectedHouseholdName == current.partyA.householdName ||
expectedHouseholdName == current.partyB.householdName
) {
"Unexpected household - expected: ${expectedHouseholdName}, was ${
listOf(current.partyA.householdName, current.partyB.householdName)
}"
}
return this
}
此validate
函数断言事件引用的是参数中的聚合。然后,它断言当前聚合比事件的目标版本低一个版本。最后,它断言涉及的户主在聚合合同中被提及。
应该有一个迭代函数,它接受一个Contract Events
列表,并最终返回一个Contract
对象:
fun List<ContractEvent>.play(): Contract? {
if (isEmpty()) return null
var current: Contract = (first() as ContractDraftedEvent).play()
var index = 1
while (index < size) {
val event = get(index++)
current = when (event) {
is ContractAmendedEvent -> event.play(current)
is ContractAgreedEvent -> event.play((current))
else -> throw IllegalArgumentException("Unsupported event")
}
}
return current
}
该函数使用合同事件的List
作为接收者。在空列表的情况下,返回类型是可空的。它假设第一个事件是ContractCreatedEvent
,它设置了Contract
的初始快照。它从第二个事件循环到最后一个事件,生成一个新的Contract
版本,将其设置为current
以传递给下一个事件,并在最后返回Contract
对象。其用法示例如下。同一聚合的事件列表是有序的,并且是顺序播放的:
val contractId = UUID.randomUUID()
val eventStore = EventStore<UUID, ContractEvent>()
val createdEvent = ContractDraftedEvent(
contractId = contractId,
time = Instant.now(),
draftedByHousehold = "HouseholdA",
counterpartyHousehold = "HouseholdB",
serviceProvided = "Cleaning",
serviceReceived = "Babysitting"
)
val amendedEvent = ContractAmendedEvent(
contractId = contractId,
targetVersion = 1,
time = Instant.now(),
amendedByHousehold = "HouseholdB",
serviceReceivedUpdate = "Dish washing",
serviceProvidedUpdate = null
)
val agreedEventByHouseholdA = ContractAgreedEvent(
contractId = contractId,
targetVersion = 2,
time = Instant.now(),
agreedByHousehold = "HouseholdA"
)
val agreedEventByHouseholdB = ContractAgreedEvent(
contractId = contractId,
targetVersion = 3,
time = Instant.now(),
agreedByHousehold = "HouseholdB"
)
listOf(
createdEvent,
amendedEvent,
agreedEventByHouseholdA,
agreedEventByHouseholdB
).forEach { eventStore.append(contractId, it) }
val aggregate = eventStore.get(contractId)?.play()
println("Aggregate is of version: ${aggregate?.version}")
代码不会直接更新聚合。相反,它创建了一些事件,并让它们通过。最终版本应该是3
,因为第一个版本是0
。当执行前面的代码时,以下内容应打印到控制台:
Aggregate is of version: 3
此示例说明了事件溯源的一种简单形式,其中每个事件都会生成聚合的新版本。这些事件应持久化到事件存储中作为永久存储,并由订阅者接收,以便可以构建其他读取模型。
在复杂系统中,处理一个事件可能会产生一系列作为反应的事件,这需要递归函数来遍历处理。它还可能需要将相关事件分组为一个事务,因为连锁反应。
虽然这里的例子很简单,但事件溯源可能会出错的方式有很多。我们将讨论一些在实现中应考虑的最佳实践。
事件溯源最佳实践
事件溯源是一种从经典 CRUD 方法中不同的方式来推理领域中的聚合。它只有在我们将系统和架构设计为将事件视为一等公民的心态时才有效。否则,它可能成为一种反模式,并抵消它带来的所有好处。以下是一些基本最佳实践。
随机化和幂等性
对于一个聚合,重新播放相同的事件序列每次都应该生成相同的聚合快照。换句话说,事件的处理必须是幂等的。有两个主要因素可能会违反这种行为:时间和随机化。
如果事件处理包含利用事件处理时间的逻辑,那么它将根据处理时间生成不同的结果。例如,以下expire
变量将根据系统时钟具有不同的布尔值:
val expire = If (event.time < System.currentMillis()) true else false
与系统时钟相关的任何信息都应该在事件上盖章。这样,结果就已经确定,并且不会随时间改变。任何基于时间的触发器或计划作业都应该获取系统时间,并将值捕获在事件中。
事件处理过程中的任何随机化都会在每次迭代中产生不同的结果。从随机化生成的值应该被捕获在事件有效负载中,事件处理过程中不涉及任何随机化。如果在处理过程中必须生成标识符,它们可以是事件范围内的唯一值。在外部,它们与事件标识符一起用作复合键。以下是一个例子:
val externalValueId = "${event.id}-${event.value.id}"
事件内部的价值可以通过事件 ID 和事件内部的价值 ID 的连接来外部识别,这两个 ID 由连字符分隔。
事件设计
一个事件应该只有一个聚合。混合多个聚合,无论是同一类型还是不同类型,都会导致聚合之间不必要的耦合。在一个事件中由混合聚合产生的耦合使得扩展事件及其主题变得困难。
可能存在多个聚合受到影响的情况。在这种情况下,应该创建多个事件作为结果,并且每个事件描述了每个聚合发生了什么。
每个事件都应该捕捉到聚合变化的目的。例如,ContractCreatedEvent
是一个不好的名称,因为它没有描述聚合合同创建的原因。更好的名称应该符合通用语言,例如ContractDraftedEvent
。
事件拓扑
事件被发布供订阅者接收,并且可以逻辑上分组为主题。这里的主题不要与传统 pub-sub 消息中的主题混淆,在所有订阅者确认收到消息后,消息不再属于任何主题。在事件源中,事件旨在永久保留,作为事件的只追加和顺序日志。例如,一个具有无限保留期的 Kafka 主题可以用来保存事件,每个主题代表事件的逻辑分组。
一个聚合的所有事件应该只发送到一个主题。这是为了简化创建和读取聚合的线性历史。
将聚合事件分散到多个主题会给重建聚合完整历史带来困难。这也使得性能扩展和吞吐量增加变得更加困难,这些都是与事件设计无关的独立问题。
事件模式兼容性
由于事件源旨在保留所有历史事件,因此所有事件都应该是向后兼容的;换句话说,当事件模式演变时,旧事件仍然可以被读取和处理。
维护向后兼容性本身就是一个大话题。有许多因素可以保持或破坏向后兼容性。以下是一些例子:
-
保持:
-
添加可选字段
-
向类型中添加更多枚举值
-
减少字段的约束
-
-
中断:
-
添加必填字段
-
重命名字段
-
改变字段的类型
-
删除字段
-
增加字段的约束
-
在事件源的场景中,一个向后兼容的事件架构确保系统总能读取聚合的完整历史来重新创建聚合的最新快照。
前向和完全兼容性
前向兼容性意味着旧消费者可以读取和处理新架构的事件。完全兼容的架构意味着它既向后兼容又向前兼容。
性能和备忘录
虽然当前版本的聚合可以从该聚合从时间开始的所有事件中推导出来,但如果请求当前快照,则播放这些事件并不总是理想的。
一种性能优化是将聚合的最新版本持久化为派生记录。这种模式称为备忘录。如果频繁请求当前快照,则使用此模式是有道理的。
在使用事件恢复聚合的最新状态的情况下,使用备忘录模式也可能是合理的。原因在于事件的数量会持续增长,因此重放所有事件的总时间会越来越长。应用备忘录模式将恢复所需的总时间与事件数量从线性变为对特定聚合的常数。
从 CRUD 迁移
将聚合从 CRUD 迁移到事件源是有趣的,因为通常 CRUD 没有完整的审计跟踪来允许完全重建事件的历史。相反,聚合的最新快照被视为第一版本,然后后续事件被持久化。
在这种情况下,类似于ContractMigratedEvent
的事件将是第一个事件。
此外,聚合的变更将通过事件的播放来完成,而不是直接更新聚合。因此,任何直接更新聚合的代码都需要被弃用。
我们已经通过一个真实世界的示例和源代码介绍了事件源的基本知识。还有一个与事件源一起工作且基于领域驱动设计(DDD)的架构模式。我们现在将介绍这个模式。
命令-查询责任分离(CQRS)
CQRS 的起源可以追溯到另一个称为命令查询分离(CQS)的设计模式。CQS 是定义系统中处理两种类型操作的核心概念:执行任务的命令和返回信息的查询,并且不应该有一个函数同时执行这两项工作。
术语 CQS 是由伯特兰·梅耶在 1988 年他的书《面向对象软件构造》中提出的。他将其作为他在 Eiffel 编程语言上的工作的一部分。
CQRS 将 CQS 的定义原则扩展到系统中的特定对象,一个用于检索数据,一个用于修改数据。CQRS 是一个更广泛的架构模式,而 CQS 是行为的一般原则。
术语 CQRS 由 Greg Young 在 2010 年提出。从那时起,CQRS 获得了关注,并开发了各种框架和库来支持在 Java 和 .NET 等流行语言中实现该模式的实现。
CQRS 有四个基本元素:聚合体、查询、命令和事件。
聚合
在 CQRS 中,聚合体的含义与事件源和 DDD 中相同。它是一个表示领域模型当前状态的聚合实体。聚合体包含一系列其他实体和值对象,以表示通用语言中定义的领域概念。
查询
查询是客户端请求检索领域模型状态的表示。处理查询是只读操作,不会改变任何聚合体的状态。然而,查询可能针对与聚合体相关的特定读取模型。
命令
命令是客户端请求意图改变领域模型中聚合体的状态。处理意图以确定是否应该更改状态以及如何更改。命令可能只包含更改所需的信息,而不是请求中的整个聚合体。
事件
事件是聚合体状态的确认和不可变更改。事件可以由命令创建,也可以由处理另一个事件创建。这与 DDD 和事件源中的事件概念相同。
CQRS 如何分解 CRUD
CQRS 将经典的 CRUD 分解成许多小的查询、命令和事件。每个都精确地表达了正在发生的事情,以至于它与通用语言相匹配。
以两个家庭之间服务合同的谈判过程为例。两个家庭都可以修改合同,并最终达成一致。
图 9.3 – CRUD 与 CQRS – 更新与命令
在 CRUD 风格中,合同的修改和同意都导致更新合同的请求,区别在于合同的内容。在 CQRS 风格中,修改和同意有专门的命令来捕捉不仅需要更新的内容,还包括更新的意图和业务背景。
CQRS 风格导致修改和同意操作被分离。这导致设计更加清晰和模块化。分离还允许独立扩展和命令的优化。
在查询方面,如图所示,家庭 A 和 B 可以通过使用 CRUD 读取请求来获取它们之间的合同,并且对两个家庭来说,响应将是相同的。然而,CQRS 查询允许多个读取模型,在这种情况下,它可以返回一个根据哪个家庭发起查询而定的自定义读取模型。
图 9.4 – CRUD 与 CQRS – 读与查询
CQRS 风格可以通过消费命令接受时产生的事件为每个家庭构建一个物化视图作为读取模型。在 CRUD 风格中,这些自定义视图通常使用 SQL 命令实现,并且这些自定义视图不会物化。
物化读取模型可以在不担心命令的情况下独立扩展。例如,如果聚合体合同的读写比严重偏向于读取,那么在单独的数据存储基础设施中物化相应的读取模型是合理的,这样即使在查询操作的重压下,写入也不会受到影响。
由于读取模型是通过异步消息消费事件而物化的,因此聚合体的变化可能不会立即反映在读取模型中,但最终会同步。
需要指出的是,处理命令确实需要一些现有信息进行验证、完整性检查和并发控制。这些读取操作对于处理命令是必要的,但不是用于服务请求。
何时应考虑使用 CQRS?
与事件溯源一样,当满足一些先决条件并且存在可以通过 CQRS 解决的合法问题时,应考虑使用 CQRS。CQRS 是我们思考系统的一种范式转变,正确实现它需要付出巨大的努力。将 CQRS 应用于错误系统会增加复杂性而没有带来任何好处。
CQRS 是建立在 DDD(领域驱动设计)基础上的架构模式。如果当前系统没有 DDD、边界上下文或聚合的概念,那么它就不是一个起点。即使系统包括边界上下文,由于它们的复杂性有限,对于通用子域来说,使用 CQRS 可能并不必要。CQRS 最有可能对核心域有益,因为域本身足够复杂,足以证明其使用价值。
然而,有几个迹象表明可以在领域内考虑使用 CQRS:
-
多个角色在同一个聚合体上工作。这通常意味着并非所有角色都关心聚合体中的每一件事。一些角色可能只处理聚合体的一部分,而不是全部。
-
更新聚合体的多种用例。存在一些特定的用例,其中只需要更新聚合体的一部分。
-
同一个聚合体的多个视图。存在同一聚合体的替代视图,有时甚至可能有一个结合多个实体的视图,这些实体与聚合体有所偏离。
-
读写比例不平衡。如果读取或写入操作比另一个操作显著频繁,那么读取和写入需要以不同的方式扩展,因为它们的需求不同。
CQRS 的益处和成本
CQRS 将读取(查询)和写入(命令)操作的关注点分开,以便它们可以独立满足需求。这导致每个函数或类都有更小的代码量,但由于分离,会有更多的函数或类。
这种分离推动了代码向单一职责原则(SRP)发展,正如在第二章中提到的,即应该只有一个理由来更改一个类。每个演员的每个用例都有自己的类,无论是作为查询还是作为命令。
查询和命令的分离使得独立性能优化成为可能,从而在整体上提高了系统性能和可扩展性。例如,由于有专门的读取模型,查询可以优化以实现更快的执行,而命令可以优化以实现高吞吐量和一致性。然而,这也导致系统中移动部件增多,从而增加了其复杂性。
查询和命令被分解为其自己的函数或类。这意味着扩展功能不太可能需要更改现有的查询和命令,因此比 CRUD 更容易,在 CRUD 中有一个包含所有 CRUD 操作的大仓库类。
为每个业务案例提供专门的查询和命令消除了客户端处理与聚合无关的字段和细节,或创建 CRUD 风格的更新或读取请求的需要。这与在第二章中提到的接口隔离原则(ISP)相一致,即客户端不应被迫依赖于它不使用的字段和函数。
使用 CRUD 支持多个读取模型具有挑战性。这通常需要复杂的 SQL 语句来联合相关数据。此外,由于不同的读取模型有不同的需求,优化性能也变得困难。很多时候,需要做出妥协,以便不同的读取模型具有合理可接受的性能。
使用 CQRS,读取模型通过消费聚合的事件来实现。它们有自己的存储,因此可以扩展并优化针对非功能性需求的独特性能。这以在多种形式复制数据为代价,并且需要更多的存储来保持这些读取模型。此外,每个读取模型都需要自己的代码来转换事件并持久化与其数据结构相关的数据。
你可能已经注意到了 CQRS 和事件溯源之间的协同作用。我们将通过一个具体的例子来说明它们是如何一起工作的。
结合 CQRS 和事件溯源
CQRS 和事件溯源是互补的模式,在构建健壮、可扩展和可维护的分布式系统时协同工作得很好。
在 CQRS 架构中,命令处理器负责验证写请求。如果命令有效,则将事件持久化到事件存储中,这是事件溯源模式的核心。CQRS 命令和事件溯源如何集成的示例显示在图 9.5中:
图 9.5 – CQRS 命令和事件溯源
服务从请求者接收命令。服务需要聚合的当前状态,该状态通过重新播放从事件存储检索的事件来重建。命令通过验证,因此生成新的事件。新事件在聚合上播放以生成新状态。新事件附加到事件存储,并将更新的聚合返回给请求者。
事件溯源回答了 CQRS 如何更新聚合并通知订阅者聚合变化的问题。CQRS 回答了事件溯源如何创建事件的问题。
查询反过来通过重新播放存储在事件存储中的事件来重建应用程序的当前状态。此外,通过将事件有效负载转换为构建它们独特的数据结构,重建多个读取模型。CQRS 查询和事件溯源如何集成的示例显示在图 9.6中:
图 9.6 – CQRS 查询和事件溯源
事件溯源提供了一种方法,使 CQRS 查询能够重建给定聚合的快照。它使查询能够根据请求构建任何给定的读取模型。它还允许从给定的时间戳构建聚合的历史视图。
命令和查询模型之间的关注点分离,加上事件溯源的事件驱动特性,允许高度可扩展、灵活和可维护的系统,这些系统可以轻松适应不断变化的企业需求。
使用 CQRS 和事件溯源
从事件溯源的先例扩展,添加 CQRS 需要创建几个命令和查询类。我们需要一个类来捕获家庭之间合同当前状态的查询,以及一个类来捕获起草合同的命令。相应的代码如下:
data class CurrentContractQuery(
val contractId: UUID
)
data class DraftContractCommand(
val draftedByHousehold: String,
val counterpartyHousehold: String,
val serviceProvided: String,
val serviceReceived: String,
)
data class AgreeContractCommand(
val contractId: UUID,
val agreedByHousehold: String,
)
你会发现这些命令类看起来很像事件类。区别如下:
-
创建聚合的命令中不包含聚合 ID
-
命令中不包含聚合版本或时间戳
这是因为在处理命令时填充了聚合 ID、版本和时间戳。在这个例子中,命令处理不是幂等的。它使用随机化聚合 ID 和系统时钟来标记时间戳。
可能会有各种实现来提供随机值和系统时间戳,以使命令处理具有幂等性。如果它们是一致的并且被充分理解,这两种方法都可以被证明是合理的。
在此示例中,每个命令的处理都有两种潜在的结果。成功的结果会创建一个事件,并且这个事件需要被持久化。失败的结果会通知调用者原因,并且不会创建事件。需要有一个类来封装失败结果的信息:
data class Failure<T>(
val request: T,
val message: String? = null,
val error: Throwable? = null
)
Failure
类包含原始请求、可选消息和可选的Throwable
对象。
每个查询和命令都需要一个处理程序。利用事件源示例中的EventStore
类,查询处理程序使用 Kotlin 扩展和事件存储作为参数,操作简单:
fun CurrentContractQuery.handle(
eventStore: EventStore<UUID, ContractEvent>
): Contract? = eventStore.get(contractId)?.play()
查询处理程序简单地获取给定contractId
的所有事件,然后播放所有事件以重新创建Contract
的最新版本作为返回值。
命令处理程序有两种主要风格:创建和更新。创建命令的处理程序生成一个随机的通用唯一标识符(UUID)和时间戳。这些字段被捕获在创建事件中:
fun DraftContractCommand.handle(
eventStore: EventStore<UUID, ContractEvent>,
onSuccess: (ContractDraftedEvent) -> Unit,
onFailure: (Failure<DraftContractCommand>) -> Unit
) {
if (draftedByHousehold == counterpartyHousehold) {
onFailure(Failure(this, "Same household is not allowed: $draftedByHousehold"))
} else {
ContractDraftedEvent(
contractId = UUID.randomUUID(),
time = Instant.now(),
draftedByHousehold = draftedByHousehold,
counterpartyHousehold = counterpartyHousehold,
serviceReceived = serviceReceived,
serviceProvided = serviceProvided
).also{
eventStore.append(it.contractId, it)
}.also(onSuccess)
}
}
命令处理程序需要两个回调函数,一个用于成功,一个用于失败。在执行过程中只会调用其中一个回调函数。如果命令验证失败(在这种情况下,当使用相同的家庭用于草案合同时),则不会创建事件,并调用失败回调函数。否则,将创建一个事件来捕获随机合同 ID、事件时间以及其他字段。该事件被持久化到事件存储中。然后,该事件传递给成功回调函数。
更新命令的处理程序需要验证聚合是否存在,以及是否保留了相同的聚合 ID。其余的实现是创建命令的处理程序:
fun AgreeContractCommand.handle(
eventStore: EventStore<UUID, ContractEvent>,
onSuccess: (ContractAgreedEvent) -> Unit,
onFailure: (Failure<AgreeContractCommand>) -> Unit) {
validate(
eventStore = eventStore,
contractId = contractId,
householdName = agreedByHousehold,
onSuccess = { contract ->
ContractAgreedEvent(
contractId = contractId,
targetVersion = contract.version + 1,
time = Instant.now(),
agreedByHousehold
).also { eventStore.append(contractId, it)
}.also(onSuccess)
},
onFailure = { onFailure(it)}
)
}
有一个名为validate
的函数,旨在与其他更新命令处理程序共享:
fun <T> T.validate(
eventStore: EventStore<UUID, ContractEvent>,
contractId: UUID,
householdName: String,
onSuccess: (Contract) -> Unit,
onFailure: (Failure<T>) -> Unit) {
val events = eventStore.get(contractId)
if (events == null) {
onFailure(Failure(this, "Contract not found: $contractId"))
} else {
val contract = events.play()
if (contract == null) {
onFailure(Failure(this, "Failed to reconstruct Contract: $contractId"))
} else if (contractId != contract.id) {
onFailure(Failure(this, "Contract ID mismatched. Expected: $contractId, was: ${contract.id}"))
} else if (householdName != contract.partyA.householdName
&& householdName != contract.partyB.householdName) {
onFailure(Failure(this, "Household not found in contract: $householdName"))
} else {
onSuccess(contract)
}
}
}
成功回调函数将传入Contract
,因为已经找到了并重新创建了聚合的最新版本。失败回调函数将传入Failure
对象以进行委托。
最后,当使用此 CQRS 和事件源示例时,客户端只需创建一个命令并将其传递到事件存储中即可开始。然后,调用扩展handle
函数:
var contractId: UUID? = null
val eventStore = EventStore<UUID, ContractEvent>()
DraftContractCommand(
draftedByHousehold = "HouseholdA",
counterpartyHousehold = "HouseholdB",
serviceProvided = "Cleaning",
serviceReceived = "Babysitting"
).handle(
eventStore = eventStore,
onSuccess = { contractId = it.contractId
println("Contract drafted: $contractId") },
onFailure = { "Failed to draft contract: $it"}
)
AmendContractCommand(
contractId = contractId!!,
amendedByHousehold = "HouseholdB",
serviceReceivedUpdate = "Dish washing",
serviceProvidedUpdate = null
).handle(eventStore = eventStore,
onSuccess = { println("Contract amended: $contractId") },
onFailure = { println("Failed to amend contract: $contractId")}
)
成功回调函数捕获contractId
以供未来的更新使用。要更新聚合,需要创建一个更新命令并指定合同 ID。之后,调用handle
扩展函数:
AgreeContractCommand(
contractId = contractId!!,
agreedByHousehold = "HouseholdA"
).handle(eventStore = eventStore,
onSuccess = { println("Contract agreed: $contractId") },
onFailure = { println("Failed to amend contract: $contractId")}
)
AgreeContractCommand(
contractId = contractId!!,
agreedByHousehold = "HouseholdB"
).handle(eventStore = eventStore,
onSuccess = { println("Contract agreed: $contractId") },
onFailure = { println("Failed to amend contract: $contractId")}
)
在所有这些更新之后,我们可以查询最新的 Contract
并查看所有这些更新是否已累积。通过捕获的合同 ID 创建了一个查询。调用 handle
扩展函数,并将事件存储传递进去:
val aggregate = CurrentContractQuery(contractId!!).handle(eventStore)
println("Aggregate is of version: ${aggregate?.version}")
因为事件存储在处理命令时持续捕获事件,它已经拥有了聚合的完整历史。这是执行所有命令和查询后得到的控制台输出:
Contract drafted: 3a25642c-fc9b-4024-b862-daf10fc645a6
Contract amended: 3a25642c-fc9b-4024-b862-daf10fc645a6
Contract agreed: 3a25642c-fc9b-4024-b862-daf10fc645a6
Contract agreed: 3a25642c-fc9b-4024-b862-daf10fc645a6
Aggregate is of version: 3
这个示例展示了 CQRS 和事件溯源在工作中的强大组合。它们相互补充,无缝协作。它还演示了每个命令和查询都有自己的类和函数。这打破了传统的 CRUD 方法,其中通常有一个包含所有四种操作的大文件的仓库类。
Outbox 模式
值得指出的是,在实际系统中,也存在一种趋势,即也将 Outbox 模式应用于以可靠和容错的方式管理事件交付。这是通过在持久存储中(如关系数据库表)拥有消息的 Outbox 来实现的。
有一个独立的过程读取未发送的 Outbox 消息并将它们发送到目标目的地。如果消息已发送,则相应的记录被视为已发送并将被删除。
如果事件存储不可用,此交付过程将自动重试交付,直到事件存储再次可用。交付过程还可以独立扩展,并可能并行地将消息发送到不同的目标。
与 Outbox 模式类似的是 变更数据捕获(CDC)模式。CDC 通过数据库触发器、事务日志或变更跟踪器检测记录的变化,并创建一个事件。创建的事件最终进入事件流或主题。虽然事件在 Outbox 过程之前创建,但 CDC 中的事件是事后创建的。这意味着 CDC 在捕获事件意图方面不太直观。
传统的关联数据库提供强一致性事务保证。这意味着我们可以有一个事务用于常规数据库操作和事件交付,作为 Outbox 或 CDC 的数据库记录,实现全有或全无的事务行为。
通过在关系数据库中存储 Outbox 消息,事件发送的可靠性、容错性、一致性和可扩展性也得到了提高。
CQRS 和事件溯源的流行框架和基础设施
CQRS 和事件溯源是架构概念,不依赖于特定的技术或框架。它们对编程语言也是中立的。然而,有一些框架和基础设施旨在支持 CQRS 或事件溯源。
-
CQRS / 事件溯源框架:
-
Axon 框架 (
www.axoniq.io/products/axon-framework
) -
Akka (
akka.io/
)
-
-
事件存储:
-
EventStore (
www.eventstore.com/
) -
Apache Cassandra (
cassandra.apache.org/
) -
MongoDB (
www.mongodb.com/
)
-
-
消息基础设施:
-
RabbitMQ 流 (
www.rabbitmq.com/docs/streams
) -
Apache Kafka (
kafka.apache.org/
)
-
需要强调的是,使用这些工具并不能自动使 CQRS 或事件溯源在你的系统中生效。只要团队使用 CQRS 和事件溯源的语义实现系统,你的当前框架和基础设施可能已经为这些架构风格做好了准备。
摘要
我们首先介绍了经典的 CRUD 架构及其局限性。然后,我们介绍了事件溯源作为管理数据的替代方法,并探讨了其历史。我们深入探讨了团队如何决定是否应该在他们的系统中考虑事件溯源。
我们通过村民交换服务的现实生活例子来展示事件溯源的实施方法。我们还简要概述了 CRUD 系统迁移到事件溯源的计划。
之后,我们转向了 CQRS 架构的主题。我们讨论了使用命令作为写操作和查询作为读操作。我们提到了 CQRS 的基本结构以及它们与 DDD 和事件溯源架构的关系。我们看到了 CRUD 和 CQRS 在分解多个更新操作时的对比。
我们接着讨论了使用 CQRS 和事件溯源。我们描述了如何通过扩展现实生活中的例子来使这两种架构相互补充。
最后,我们简要介绍了使用 Outbox 模式与 CQRS 和事件溯源的结合使用。
在下一章中,我们将讨论分布式系统的幂等性、复制和恢复方面。
第十章:幂等性、复制和恢复模型
分布式系统在现代软件架构中非常普遍。确保数据一致性、容错性和可用性的挑战变得至关重要。本章将涵盖三个关键概念,有助于解决这些挑战:
-
幂等性
-
复制
-
恢复模型
幂等性是一个基本的非功能性系统属性,它确保操作可以安全且重复地执行,而不会产生意外的副作用。在分布式系统中,网络故障和系统崩溃是常见的。幂等性对于维护数据完整性和一致性至关重要。通过设计幂等操作,工程师可以构建更具有弹性和容错性的系统,能够在部分故障的情况下恢复,而不会损害整体系统状态。
另一方面,复制是一种用于提高分布式系统中数据可用性和持久性的技术。通过在不同节点上维护数据的多个副本,复制提供了冗余,并有助于确保即使在某个或多个节点失败的情况下,系统仍能继续运行。然而,复制引入了自己的挑战,例如确保副本之间的一致性以及高效地管理复制过程。
最后,恢复模型定义了在故障或中断后用于恢复分布式系统状态的策略和机制。这些模型可以从简单的备份和恢复方法到更复杂的技术。选择正确的恢复模型对于构建能够应对意外事件并保持高可用性和响应性的弹性分布式系统至关重要。
在本章中,我们将更深入地探讨这些主题,讨论其基本原理、权衡以及在实际分布式应用中应用的最佳实践。在本章之后,你应该能够以适合你系统的水平实现幂等性、复制和恢复模型。
技术要求
你可以在 GitHub 上找到本章使用的代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-10
幂等性
幂等性是软件工程中的一个概念,它指的是操作的非功能性属性,可以在执行多次的情况下仍然保持与只执行一次相同的效果。换句话说,幂等操作可以安全地重复执行而不会产生副作用。让我们来看一个需要幂等性的简短场景。
一个需要幂等性的用例
想象我们正在构建一个在线银行应用程序。一个关键功能是转账,其中用户将资金从一个账户转移到另一个账户。这个功能是系统的基础且至关重要的部分,需要以确保用户财务交易完整性和可靠性的方式实现。
如果转账操作不具有幂等性,那么用户可能会不小心多次点击转账按钮,系统会多次执行转账操作,从而导致源账户不预期的扣款和目标账户相应的贷记。
大多数成熟的用户界面可以通过在收到响应前阻止按钮被按下,来避免这种情况。然而,也有一些 API 集成需要幂等性。
这种结果并不是用户所期望的,它有多重后果。首先,如果用户在第二次及以后的转账中资金不足,用户将会有透支资金,并可能被收取利息。其次,这些事件会触发用户投诉,并可能导致金融监管机构的潜在介入。这不仅会影响用户体验,还会导致银行声誉受损。
为了防止这些问题,转账操作应该被设计成具有幂等性。这意味着无论用户点击转账按钮多少次,系统都只会执行一次转账,确保账户的最终状态是正确的,并且与用户的意图相匹配。
幂等性的关键方面
幂等性是软件开发中的一个重要概念,尤其是在分布式系统、API 和数据处理管道的背景下。以下是幂等性的几个关键方面:
-
恒定结果:幂等性操作总是产生相同的结果,无论执行多少次。如果一个操作不具有幂等性,每次后续执行可能会产生不同的结果。
-
错误处理和重试:幂等性有助于优雅地处理错误和重试。如果一个操作失败,系统可以安全地重试操作,而不会造成不预期的副作用。
-
数据一致性:幂等性操作通过防止在重试非幂等性操作时意外修改或重复数据,确保数据一致性。
-
可扩展性和可靠性:在分布式系统中,幂等性至关重要,因为可能存在多个应用程序实例同时处理相同的请求。幂等性操作允许系统进行扩展并处理故障,而不会损害数据完整性。
让我们探讨一些可以应用幂等性的实际场景。
场景 1 – 数据库迁移脚本
进化数据库旨在创建可以随着时间的推移而演变和适应变化的数据库系统。它们不是由静态和僵化的模型定义的。数据库模式是通过增量更改构建目标模式的。
考虑 Flyway,这是一个开源的数据库迁移工具。增量更改由 SQL 脚本指定:
V1_create_new_tables.sql
V2_add_new_columns.sql
为了简化起见,让我们假设 V1
脚本只包含以下创建表的语句:
CREATE TABLE HOUSEHOLD (
id UUID primary key,
name text not null
);
如果 CREATE
SQL 语句中不存在名为 HOUSEHOLD
的新表,它将创建该表。否则,将报告错误,并且 V1
脚本将失败。换句话说,它不是幂等的,重复执行不会产生相同的结果。以下是脚本的幂等版本:
CREATE TABLE IF NOT EXISTS HOUSEHOLD (
id UUID primary key,
name text not null
);
IF NOT EXISTS
语法确保如果表不存在,则创建该表,如果表已存在,则不执行任何操作。在两种情况下结果相同,即数据库中存在 HOUSEHOLD
表。
执行 V2
脚本将向该表添加一个新列作为非空列。一些数据库供应商支持创建非空列并在同一语句中填充值的巧妙 SQL 语句。为了这个论点,让我们假设这不被支持。我们求助于经典的添加可空列、填充值然后设置列为非空的方法。就像修改后的 V1
脚本一样,我们可以使其幂等:
ALTER TABLE IF EXISTS HOUSEHOLD ADD COLUMN deleted boolean;
UPDATE HOUSEHOLD SET deleted = false;
ALTER TABLE IF EXISTS HOUSEHOLD ALTER COLUMN IF EXISTS deleted SET NOT NULL;
COMMIT;
IF EXISTS
语法确保如果表或列存在,则将对其进行修改,如果不存在,则不执行任何操作。结果相同,因此它是幂等的。经典的指导方针会建议 ALTER TABLE
是 DDL(数据定义语言),而 UPDATE
是 DML(数据操作语言)。这是建议这样做的原因是 DDL 会立即提交,而 DML 需要显式提交。然而,由于幂等性,这不再是问题,因为每个语句都可以重复执行以产生相同的结果。
场景 2 – 创建/更新操作
以村民交换服务的现实生活为例,有一个业务案例来确保家庭记录保持在系统记录中。然而,家庭用户不知道家庭记录是否已经被持久化。
基于 CRUD(创建、读取、更新、删除)的系统可能将创建和更新定义为两个独立的操作。这些操作非常适合用户,因为用户希望无论记录是否存在,家庭记录都应持久化。可能发生了网络中断,因此用户可能不知道他们的先前请求是否成功。
换句话说,用户希望一个可以重复执行且产生相同结果的操作。他们需要一个幂等操作来确保即使记录已存在,家庭记录也已存储。
这种操作通常被称为 upsert,即 更新或插入。upsert 操作的关键特征如下:
-
幂等性: 它可以重复执行而结果相同。如果记录已存在,则更新记录;如果记录不存在,则创建记录。
-
原子性: 操作在序列化隔离的事务中执行。这意味着操作要么完成,要么未发生。
-
选项 1 – 悲观锁: 悲观的方法会检查记录是否存在,以确定是更新操作还是创建操作。
-
选项 2 – 乐观锁: 乐观的方法会假设记录要么存在,要么不存在,并分别执行更新或创建操作。如果更新操作未找到记录,则切换到创建操作。或者,如果创建操作由于违反唯一约束而失败,则切换到更新操作。
这里是一个 SQL 语句中家庭上插操作(upsert operation)的示例。它实现了乐观方法:
INSERT INTO HOUSEHOLD (id, name, email) VALUES ('d0275532-1a0a-4787-a079-b1292ad4aadf', 'Whittington', 'info@ whittington'.com') ON DUPLICATE KEY UPDATE name = 'Whittington', email = 'info@ whittington'.com';
此 SQL 语句尝试插入一个新的家庭记录。如果记录不存在,则插入新行。如果执行过程中遇到重复键违反,则变为更新 name
和 email
的操作。
如果此操作公开为外部 API(即作为 REST 端点),则合同可以用以下方式表达:
-
GET
: 在系统状态保持不变的情况下,GET
端点的多次调用应返回相同的结果。 -
PUT
:PUT
端点意味着创建新资源或用请求有效负载替换家庭表示。 -
DELETE
:DELETE
端点旨在删除资源,无论该资源是否存在。如果未找到资源,则应返回成功的 超文本传输协议 (HTTP) 状态码。
HTTP 方法本身并不保证幂等性。例如,如果 GET
端点的响应有效负载包含当前时间或随机值,那么多次调用不会返回相同的结果,因此它不是幂等的。
POST
和 PATCH
端点未定义为幂等。REST 架构中的 POST
端点意味着创建资源的请求,并假设资源不存在。PATCH
端点假设资源已存在,以便可以部分更新资源。
HTTP 方法
HTTP 定义了几种方法来对请求进行分类,以便在资源上执行操作。GET
方法是一种只读操作,从服务器返回数据。POST
方法在服务器上创建资源。PUT
方法替换或创建资源。PATCH
方法部分更新现有资源。DELETE
方法从服务器删除资源。HEAD
方法返回资源的头部信息,但不包含正文内容。OPTIONS
方法描述了与特定资源通信的选项。最后,TRACE
方法是一种诊断操作,通过回显请求的最终接收情况来提供故障排除信息。
场景 3 – 按顺序处理事件
通常,从流或主题中消费事件的东西一次处理一个事件,并按顺序处理它们。如果处理的事件序列很重要,那么就需要优雅地处理重复和顺序错误的事件。
事件序列可能受到损害的两个级别是传输级别和应用级别。第一个级别是传输级别,由于网络问题、分区更改或消费者组更改,最后消费事件的偏移量被重置为较旧的事件。第二个级别是应用级别,这是发布者发送较旧事件的地方。
在消费者级别进行应用级去重可以处理在传输或应用级别受损的事件序列。然而,这要求发布者为每个事件提供顺序信息。这可以是事件上的序列号,或者事件发生的时间戳。
消费者可以维护每个发布者的最后处理序列号或时间戳。如果消费者收到一个序列号低于最后处理序列号的事件,或者时间戳早于最后处理时间戳的事件,那么消费者将跳过此事件,直到收到新的事件。
这里是一个事件监听器的示例实现,该监听器防止处理较旧的事件:
class HouseholdEventListener {
var lastProcessedTime: Instant? = null
@KafkaListener(
topics = ["\${household-v1-topic}"],
clientIdPrefix = "\${client-id}",
groupId = "\${group-id}",
containerFactory = "kafkaListenerContainerFactory",
properties = ["auto.offset.reset=earliest"]
)
fun onMessage(
@Payload(required = false) event: HouseholdEvent?,
@Header(name = "kafka_eventTime", required = true) key: String,
) {
if (lastProcessedTime != null && event?.time?.isBefore(lastProcessedTime) == true) {
log.warn { "Skipping event with time ${event.time} because it is before the last processed time $lastProcessedTime" }
return
}
// some processing logic here
lastProcessedTime = event?.time
}
}
在这里,HouseholdEventListener
保存了最后处理事件的戳记。来自 Kafka 的传入事件有一个头部字段,kafka_eventTime
,由发布者提供。该值是事件发生的时间,而不是事件发布的时间。
第一个事件处理不会执行任何时间戳检查。随后,如果事件头部的时间戳早于最后处理的时间戳,监听器将跳过处理。这表明传入的事件是旧的,可以跳过。
如果事件没有被跳过并且已经完成处理,则最后处理的时间戳将被更新,并且事件将通过 Kafka 代理进行确认。现在,监听器已准备好消费另一个事件。
在生产系统中,最后处理的时间应该持久化到数据库中,并且应该在业务处理发生的同一事务中。当监听器启动时,应该恢复最后处理的时间。这将允许监听器在重启后继续消费事件。
这个实现说明了消费者如何在发布者的帮助下检测到较旧的事件。较旧的事件不会被处理,消费者可以将最后处理的时间戳作为偏移量来验证下一个事件。
为了扩展到这个例子,最后处理事件的戳记可以持久化到数据库中,以便在重启后恢复值。
场景 4 – 多个边界上下文的故事
故事是领域驱动设计(DDD)中的一种模式,它涉及分布式事务。挑战是在多个边界上下文中保持数据一致性。
让我们以我们的银行转账为例,其中我们需要幂等操作来确保资金只被转账一次,并且只被转账一次。银行手机应用程序打算向后端服务发送请求。
然而,这个操作涉及多个后端服务。首先,有转账服务,它验证请求。
一旦验证,它需要保留提款账户中的金额,直到转账完成。这是通过另一个名为账户服务的服务完成的。
账户服务通过将资金从客户账户转移到企业账户来协调预留资金。稍后,它通过与企业账户到客户账户的资金转移来协调增加资金。这是通过与遗留的核心 银行系统通信来完成的。
一旦资金被预留,转账服务可以通过将资金从企业账户转移到客户账户来请求转账的第二部分。请求由账户服务处理,它与遗留的核心银行系统通信以转账资金。一旦核心银行系统确认并完成转账,账户服务将结果返回给转账服务,从而完成转账。
这种交互在图 10.1中得到了演示:
图 10.1 – 银行转账示例序列
使整个转账操作具有幂等性是复杂的,因为事务分布在多个服务中。此外,我们需要一种方法来识别用户只想转账一次,尽管银行应用程序有多次尝试。
通常情况下,系统的一些部分可能是遗留系统,可能无法轻易增强。在这种情况下,让我们假设核心银行系统无法在请求中接受幂等键。
让我们探讨每个参与此过程的组件如何努力实现幂等性。
银行应用程序
第一步应该是银行应用程序生成一个幂等键,它可以识别属于同一用户意图的多个尝试。理想情况下,幂等键应该携带到所有相关的服务中。
转账服务
转账服务可以缓存这些幂等键一段时间。在这段时间内,相同的幂等键被视为重复请求。
为了避免并发请求下的不一致性问题,许多系统使用显式锁来确保相同幂等键的请求在多个实例中一次只处理。
服务可以选择跳过与其他服务的剩余交互,并返回之前发送给银行服务的响应。如果我们确信剩余的服务已经确认了请求的完成,这种方法是可以接受的。
例如,如果转账服务与账户服务通信时出现超时,那么重复与账户服务的交互可能是合理的。这允许操作得到修复并继续完成。这种方法还假设账户服务可以以幂等的方式处理重复请求。
账户服务
在这个操作中,账户服务提供两个功能:储备资金和增加资金。为了能够识别重复请求,幂等键应该与持有和移动资金的记录一起持久化。
当账户服务处理储备或增加资金的请求时,它必须通过使用幂等键来检查是否存在重复请求。如果存在,账户服务将返回记录中的响应,就像这次已经处理过一样。
如果由于资金不足,核心银行系统拒绝储备资金请求,账户服务需要通过将资金反向退回到提款账户来回滚操作。
与转账服务类似,应该有一种形式的显式锁定,以确保在多个实例中一次只处理给定幂等键的一个请求。
核心银行系统
核心银行系统是一个不支持幂等的遗留系统。它无法接受或处理幂等键。由于账户服务是与核心银行系统通信的服务,账户服务应该将核心银行系统的响应与相应的幂等键一起持久化。
如果响应记录已经存在且带有幂等键,账户服务将跳过与核心银行系统的通信,并使用之前持久化的核心银行系统的响应来完成流程。
这变得复杂了,因为可能会有对核心银行系统的请求超时。账户服务不知道核心银行系统是否已处理了转账。账户服务需要查询最近的交易历史以识别之前对核心银行系统的请求,无论是成功还是失败,以便恢复并继续转账操作。否则,重试可能仍然会导致不一致的状态。
有时,这种恢复甚至可能涉及手动纠正,这容易出错。你可以看到当过程变得复杂得多、效率低下且成本高昂时,它就无法保持幂等性。
有了这些,我们已经探讨了需要幂等性的四个场景,并探索了这些场景的多种方法。现在,让我们深入研究与幂等性相关的一个概念——复制。
复制
复制作为对潜在故障的防护措施,即使在个别组件出现故障或不可用的情况下,系统也能保持服务的连续性。
复制的这一方面与恢复密切相关,将在本章后面讨论。简而言之,一些复制技术可以防止系统停机,这需要恢复。还有一些复制技术可以启用并增强恢复过程。
复制的另一个方面是,它可以通过将负载分配到多个节点以及允许系统根据流量进行扩展来提高系统性能。
数据或运行实例的副本通常被称为副本。有许多领域可以应用复制。让我们看看。
数据冗余
多个副本分布在不同的节点或服务器上。如果一个节点失败,数据仍然可以从其他节点上的复制副本中访问。它还防止了某些节点永久不可用时的数据丢失。
这种冗余确保了即使某些节点或组件不可用,整体系统也能继续运行。
这可以应用于关系数据库、NoSQL 数据库、耐用的消息代理、分布式对象缓存以及对等网络(P2P)中的节点。
服务冗余
系统的运行服务实例分布和复制带来了一些关键的好处。
首先,请求可以被路由到最可用和响应最快的副本,从而降低单个节点过载的风险,并提高整体系统性能。这种负载均衡有助于通过防止瓶颈并确保系统可以处理增加的流量或工作量来维持可用性。
第二,它使得系统可以通过增加更多副本或实例来扩展,以满足需求增加。这种水平扩展性使得系统可以处理更高的负载,并在请求或所需资源数量增加时保持可用性。
此外,如果主节点变得不可用,系统可以自动故障转移到次要或备份副本,确保无缝过渡。
次要副本可以接管工作负载,保持服务连续性和高可用性。
复制还促进了更快的恢复,因为系统可以通过提升一个健康的副本成为新的主副本来恢复服务。
数据和服务在多个地理位置和数据中心之间进行复制也很常见。这种做法可以在区域故障或灾难发生时提高可用性。如果一个数据中心或区域发生故障,系统可以使用其他位置的副本继续运行,确保服务对用户始终可用。
CAP 定理
让我们看看我们应该讨论的几个复制和恢复模型。它们满足各种一致性、可用性和可扩展性非功能性要求的不同级别。
根据 CAP 定理,也称为 Brewer 定理,分布式系统无法同时提供以下三个非功能性属性:
-
一致性 (C): 系统中的所有节点在相同时间拥有相同的数据。一致性确保数据始终处于有效状态
-
可用性 (A): 每个请求都会收到一个非错误响应,但无法保证它包含最新的数据
-
分区容错性 (P): 即使发生任意消息丢失或系统部分故障,系统仍能继续运行
该定理指出,当节点间的通信失败时,分布式系统只能在三个属性(C、A 或 P)中同时满足两个。这被称为 CAP 权衡。
CAP 定理的历史
CAP 定理由 Eric Brewer 在 2000 年的 分布式计算原理研讨会(PODC)上提出。该定理后来由麻省理工学院的 Seth Gilbert 和 Nancy Lynch 在 2002 年通过他们的论文 Brewer 的猜想与一致、可用、分区容错 Web 服务可行性 得到证明。
可能的选择有以下三种:
-
一致性和分区容错性 (CP): 在面对网络分区的情况下,系统牺牲可用性以保持强一致性。这在传统数据库系统中很常见,例如关系数据库。
-
可用性和分区容错性 (AP): 在网络故障期间,系统保持可用但放弃维护一致性。这在 NoSQL 数据库中很常见。
-
一致性和可用性 (CA): 系统提供一致性和可用性,但这仅在无网络分区的完全连接系统中才可能。在实践中,这种情况很少发生,系统必须在一致性和可用性之间做出选择。
虽然有三种组合,但选择更为灵活和情境化。例如,一个系统最初可能是 AP,但随着更多节点的故障,它可能回退到单个节点运行 CA。
CAP 定理是一个帮助开发者理解在设计分布式系统时需要做出的权衡的概念。当您为特定应用程序选择适当的数据存储和处理解决方案时,这是一个重要的考虑因素。
在探索这些模型时,理解并发现系统应追求的非功能性属性非常重要。并非所有模型都适用于所有系统。这关乎根据您的需求和预期场景找到最合适的模型。
模型 1 – 主从
主从(也称为单领导者)复制有一个主节点(“领导者”),它处理所有写操作并将数据更改复制到从节点(“追随者”)。单领导者复制在图 10.2中展示:
图 10.2 – 主从复制
读和写操作
主节点负责所有写操作。主节点或从节点是否应该处理读操作对系统质量属性,如一致性、吞吐量、可用性和弹性,有深远的影响。
如果主节点处理所有读操作,那么从节点可以是冷备份或热备用。冷备份意味着从节点没有运行,但数据文件正在复制。热备用意味着从节点已启动,但未处理任何请求。
这种设置提供了强一致性,但服务读和写操作意味着主节点承担所有负载。这增加了资源消耗,并使得实现高性能更具挑战性。此外,如果主节点失败,冷备份启动可能需要一些时间,并可能导致中断。热备用由于从节点已经运行,因此具有更好的可用性,但所有对失败主节点的读请求仍然受到影响。这将导致“波动”,直到其中一个从节点成为主节点。
如果从节点处理读请求,读操作的吞吐量会增加。有更多节点可用于处理读请求。如果某些从节点失败,其他节点可以继续运行。这种方法带来的权衡是可能的不一致性问题。想象一下,如果其中一个从节点未能连接到主节点;这个从节点将拥有过时的数据,但仍然执行读操作并提供过时数据,这与其他节点不一致。
复制
当您从主节点复制数据变更到辅助节点时,您有两个选项:同步或异步复制。同步过程的示例序列图显示在图 10.3中:
图 10.3 – 主-辅助同步 – 同步(左侧)/异步(右侧)
此图垂直分为两种方法。在左侧,我们有同步复制。在这里,写请求被发送到主节点。主节点更新其本地存储中的数据,但不提交事务。然后,它将数据变更发送到所有辅助节点。
这是一个阻塞和同步的过程,其中主节点等待所有辅助节点的响应。如果所有响应都成功,则主节点提交事务并将更改刷新到本地存储。最后,将响应返回给原始请求者。同步方法通过主节点和辅助节点之间的同步通信,以更高的延迟为代价,在所有节点上维护强数据一致性。
在右侧,主节点完成写请求后,数据变更被提交到本地存储,并将响应返回给请求者。数据变更在后台同步,不会阻塞。这可以通过计划的后台进程完成,或者作为发布给辅助节点的事件。这种方法减少了延迟,因为不需要复制来返回响应。然而,它引入了可能出现数据不一致的场景。
如果主节点与某些辅助节点之间的通信失败,一些辅助节点将拥有最新数据,而另一些则不会。同时,所有辅助节点都执行返回相同数据不同版本的读操作。
通过在数据上标注版本号或时间戳可以减轻不一致的风险。任何过时的数据都可以被发现并跳过。
请求者也可以与处理读请求的辅助节点保持粘性连接。返回给请求者的数据将与辅助节点同步变化。这提供了一定程度的可靠性,即请求不会得到一个版本的数据,然后得到一个更早的版本。
备用
如果主节点失败,其中一个辅助节点需要成为主节点。新的主节点可以通过轮询规则确定,或者通过可能更复杂的领导者选举算法。
如果数据是异步复制的,丢失主节点可能会导致丢失最新数据。这种情况发生在主节点已更新其本地存储并返回结果,但在通知辅助节点之前失败。
如果失败的主节点被备份但失去了与一些从节点的连接,情况会更糟。在这种情况下,可能已经分配了一个新的主节点。我们现在有一个分裂脑的情况,有两个主节点,从节点是碎片化的。这通常需要手动干预来关闭一个主节点,并将所有从节点重新连接到单一的主节点。
主从复制在高度可用的数据库和消息代理中常用。
模型 2 – 分区和分布式
分区和分布式(也称为多主)复制将数据管理分布到分区中。它允许多个节点同时处理请求。这些节点将更改复制到其他节点,从而实现更高的写入吞吐量和可用性。
通常在数据和服务跨多个地理位置复制时使用,通常在不同的数据中心或云区域。这提供了对区域故障或灾难的可用性和弹性。这在图 10.4中有所说明。4*:
图 10.4 – 分区和分布式复制
请求在地理上分区,以便给定区域的用户可以访问该区域对应的服务。在这个区域内,这种分区和分布式复制可以表现得像主从复制一样,其中主节点处理写请求,从节点处理读请求。
在区域之间,发生额外的同步过程,以便一个区域的数据被复制到另一个区域。一些数据是完全分区和区域化的,这意味着在正常情况下,所有对数据的请求都在指定的区域内得到服务。一些数据是共享的,可能需要完全复制。这引入了在两个区域都更新时解决冲突的需求。
与主从复制相比,这种设置更为复杂。然而,如果存在如下非功能性需求,则可以证明其合理性:
-
服务来自多个地理区域的请求
-
面对数据中心完全故障时进行恢复
-
在架构和操作上从特定的云服务提供商解耦
-
支持离线操作
-
支持协作更新操作
另一方面,如果多个区域中的相同数据不能同时更新,将难以保持强一致性。
如果数据中心已经失败,对应分区的请求应该被路由到运行中的数据中心。在运行中的数据中心尚未复制的数据库将会丢失。在这种情况下,客户端可能需要回滚到最后一次复制的状态。
解决写冲突和避免丢失更新
分区式和分布式复制需要一些机制来解决同时更新同一份数据并可能不同的情况。让我们用一个现实生活中的例子来说明写冲突的解决。
想象一下,一个村庄中的每一户都有一个其名称和联系电子邮件地址的记录。Whittington 家庭在存储库中有一个记录,其电子邮件地址为 info@whittington.com。
此记录被暴露给两个不同的客户端。每个客户端都读取了电子邮件地址,info@whittington.com。一个客户端已将电子邮件地址更新为 query@whittington.com,而另一个客户端已将其更新为 contact@whittington.com。两个客户端试图通过提供它们的更新值来更新存储库中的值。存储库将接收这两个客户端的写请求。
两个客户都根据他们收到的当前值确定新值:
-
客户 A:将当前电子邮件地址从 info@whittington.com 更新为 query@whittington.com
-
客户 B:将当前电子邮件地址从 info@whittington.com 更新为 contact@whittington.com
如果客户 A 请求更新早于客户 B,那么将电子邮件地址更新为 query@whittington.com 的过程将会丢失。这是因为客户 B 几乎立即用 contact@whittington.com 覆盖了该值,而不知道客户 A 也请求了更新。这个问题被称为 丢失 更新 问题。
通常,通过在数据上添加版本号或时间戳来解决此问题。如果传入的请求更新被识别为比系统记录中的更新更旧,那么可以安全地跳过更新。与时间戳相比,单调递增的版本号是一个更受欢迎的方法,因为每个机器的系统时钟可能不同。
我们可以用以下数据类来模拟这种情况:
data class Household(
val version: Int,
val name: String,
val email: String,
)
在这里,Household
类有一个整型的 version
字段。这将在更新操作期间用于比较。还有一个用于处理更新请求的 Household
存储库类。以下是代码中模拟的场景:
fun main() {
val repo = HouseholdRepository()
val name = "Whittington"
val email1 = "info@whittington.com"
val email2a = "query@whittington.com"
val email2b = "contact@whittington.com"
val household1 = Household(0, name, email1)
首先,创建一个作为版本的 household
记录,之后基于它有两个更新:
repo.create(name) { household1 }
repo.update(name) { household1.copy(version = 1, email = email2a)}
repo.update(name) { household1.copy(version = 1, email = email2b)}
repo.get(name)?.also {
println("${it.version}, ${it.email}")
}
}
在这种情况下,我们预计第二次更新将被跳过,因为它基于版本零。第二次更新将需要刷新 household
记录到版本一,并计算潜在的更新。
存储库中应实施版本检查,以防止丢失更新问题。以下是一个示例实现:
class HouseholdRepository {
private val values: ConcurrentMap<String, Household> = ConcurrentHashMap()
HouseholdRepository
类持有 ConcurrentMap
接口,使用家庭名称作为键。create
函数利用原子的 putIfAbsent
函数来确保值不会被错误地覆盖:
fun create(
key: String,
callback: () -> Household
): Household {
val household = callback()
val result = values.putIfAbsent(key, household)
return result ?: household
}
update
函数通过使用原子的computeIfPresent
函数检查更新的值必须比现有值高一个版本:
fun update(
key: String,
callback: (Household) -> Household
): Household? = values.computeIfPresent(key) { _, existing ->
callback(existing).let { updated ->
if (updated.version == existing.version + 1) {
updated
} else {
existing
}
}
}
为了完整性,还有一个get
函数,这样我们可以在运行后获取存储在映射中的内容:
fun get(key: String): Household? = values[key]
}
程序的输出如下:
1, query@whittington.com
这意味着第二次更新被跳过了。
模型 3 – 基于多数派复制
基于多数派(也称为无领导者)复制要求节点在提交写入操作之前就数据的状态达成一致。这确保了即使某些节点失败,也能保持一致性和可用性。
基于多数派复制的关键区别在于没有主节点、领导者或中央协调者。相反,数据是去中心化并在集群中的节点之间分布式存储:
图 10.5 – 基于多数派复制
只有当写入操作被系统中参与节点的多数(多数派)确认时,才被认为是成功的。这个多数派要求确保只有当写入操作被复制到足够的节点时,才会提交,这使得系统对单个节点故障具有弹性。
多数派的大小通常设置为至少超过总节点数的一半,确保即使某些节点失败,系统仍可以继续前进并保持一致状态。同步到节点之间的数据出于以下几个原因进行了版本化:
-
数据同步过程需要识别旧版本的数据,以及增加版本号以指示更新
-
客户端可以读取版本以了解接收到的数据是否过时
例如,在五个节点的集群中,写入操作要成功,需要三个节点的多数派。这样,系统可以容忍多达两个节点的故障,而不会损害数据一致性。
由于所有节点具有相同的状态,实际上没有实际的故障转移机制。相反,每个请求都需要能够删除重复或旧响应。如果数据是分版本的,则可以这样做。
基于多数派复制在分布式数据库、键值存储、P2P 网络、区块链和协调服务中常用,在这些服务中,在节点故障的情况下保持强一致性和可用性至关重要。
比较三种复制模型
在数据库或数据系统中选择适当的复制模式取决于几个因素,包括与一致性、可用性、性能和容错性相关的非功能性需求。以下是每种模型的摘要和用例:
主从 | 分区和分布式 | 基于多数派 |
---|---|---|
强一致性 | 最终一致性 | 可配置一致性 |
简单且易于维护 | 增加的复杂性 | 复杂的多数派维护 |
对数据丢失的低容忍度 | 冲突解决挑战 | 容错性 |
性能受限于领导者容量和复制延迟 | 性能受限于领导者容量和复制延迟 | 为每个变更实现共识的额外延迟 |
可用性较低 | 高可用性;提供负载均衡器选项 | 取决于可用节点的数量 |
存在单点故障 | 没有单点故障 | 没有单点故障 |
适用于传统数据库和读操作多于写操作的系统(例如,内容管理系统) | 适用于跨不同地区分布的系统以及协作应用 | 适用于分布式数据存储和不需要低延迟的关键系统 |
故障转移机制是恢复过程的一部分,但它的重点是将工作负载转移到其他正在运行的节点。恢复还包括启动未运行的节点。这些方法将在下一节中介绍。
恢复
系统的恢复过程高度依赖于可访问的数据副本,除了无状态系统。这意味着恢复方法高度依赖于复制方法。
快照和检查点
最常见的恢复方法是保存最后已知系统状态的快照。定期保存分布式系统状态的过程称为 检查点。
在发生故障的情况下,系统可以回滚到最后一个已知的好检查点,以将系统恢复到一致状态。未在快照中持久化的数据将会丢失。数据丢失的数量将取决于快照的频率。
变更日志
通过重放分布式系统内所有操作和事务的变更日志,也可以恢复系统状态。
使用检查点和变更日志的组合恢复分布式系统是常见的做法。这与在 第九章 中提到的基于事件源恢复方法类似,其中通过重放所有相关事件来存储聚合。
这种方法通过重放遗漏或丢失的操作来帮助从故障中恢复。
重新路由和重新平衡
启动节点后,它需要创建或加入一个节点网络。可能需要重新路由请求并重新平衡分区。
这也可能触发新主节点的选举。可以使用如 Raft (raft.github.io/
) 和 Paxos (www.microsoft.com/en-us/research/publication/part-time-parliament/
) 这样的共识协议来协调其他节点的操作,确保系统在个别节点失败的情况下仍能保持运行。
案例研究 – Raft 领导者选举
为了展示恢复的细节,我们将通过一个简化的 Raft 领导者选举过程进行说明,如图 10.5 所示:
图 10.6 – Raft 领导者选举中的节点状态转换
Raft 使用主从复制,其中主要节点将数据更改复制到所有次要节点。主要节点保持一个名为Terms的整数;这个数字在每次选举时增加。每个被主要节点接收到的请求都会被Terms标记。
主要节点向所有次要节点广播心跳消息。它们就像脉冲一样,不断宣布主要节点已经启动。
当一个次要节点在配置的时间内没有收到心跳消息时,它将成为一个候选人并要求其他次要节点为自己投票。
其他次要节点可以接受或拒绝他们的投票。当一个候选人在获得最多投票后得到接受,他们将成为主要节点,其他人则恢复为跟随者。
这种机制是并发发生的,意味着将会有冲突需要解决:
-
冲突选举:如果所有次要节点的心跳消息超时配置相同,则可能会发生冲突选举。可以通过在每个节点中随机化超时配置来避免这种情况。此外,如果有冲突选举的平局,则取消所有选举,之后可以再次进行选举。
-
多个领导者:如果网络的一部分与另一部分断开连接,我们可能会遇到脑裂情况,其中每个相互连接的部分开始自己的选举。由于大多数应该超过节点总数的半数,只有一个部分可以达到多数票并选举出一个领导者。
如果原始领导者位于网络分裂的小部分中,当整个网络恢复时,将会有多个领导者。在这种情况下,可以使用Terms的值让原始领导者下台,因为新领导者的 term 值将高于原始领导者。
-
过时候选人:一些次要节点可能在复制上落后于其他节点,但仍然会要求进行选举并将自己作为候选人。如果其中之一成为主要节点,其过时的数据将成为真相的来源,并且一些更新可能会丢失。
为了避免这种情况,次要节点将拒绝那些 Terms 值低于其他候选人且数据未更新的候选人。可以通过变更日志中的项目数量来识别具有过时数据的候选人。
摘要
在本章中,我们讨论了三个主题:幂等性、复制和恢复。首先,我们讨论了幂等性有用的四种场景以及如何通过参考实现来实现。
然后,我们简要介绍了如何复制数据和服务的步骤。我们提到了 CAP 定理,其中每个系统都需要考虑权衡。我们还深入探讨了三种复制模型,即主从复制、分区和分布式复制以及基于法定人数的复制。
最后,我们讨论了一些常见的恢复机制,概述了在分布式系统中一个新启动的节点如何成为可操作的。
在下一章中,我们将讨论分布式系统的审计和监控方面。
第十一章:审计和监控模型
本章致力于涵盖软件系统的审计和监控方面。实施强大的审计和监控策略对于组织提高其系统的整体可靠性、安全性和性能至关重要,同时还能获得有价值的见解,以支持数据驱动的决策和持续改进。
这对于分布式系统尤为重要,因为与传统的单体应用相比,分布式系统的增加复杂性和相互依赖性引入了额外的挑战。
本章将探讨以下主题:
-
审计和监控的重要性
-
分布式系统审计和监控的挑战
-
审计和监控的关键方面
-
有意义审计跟踪的基本要素
到本章结束时,你将牢固地理解如何为你的系统建立强大的审计和监控能力,使你能够主动识别和解决问题,确保合规性,并维护整体系统健康。
技术要求
你可以在 GitHub 上找到本章使用的代码文件:github.com/Packt Publishing/Software-Architecture-with-Kotlin/tree/main/chapter-11
审计和监控的重要性
审计和监控是两个不同但又紧密相关的概念,对于系统的有效管理和监督至关重要。
审计
审计是一种系统性的方法,用于审查、检查和验证系统的各个方面,以确保其合规性、安全性和整体完整性。审计涵盖以下关键领域:
-
合规性:检查系统是否符合相关法律、权威机构的规定、行业标准和企业政策。
-
安全:评估系统的安全基础设施、政策和程序。这包括漏洞评估、渗透测试、数据保护机制和访问控制。
-
变更管理:审查与系统更改和更新相关的流程和文档。
-
事件管理:检查对系统事件和灾难恢复程序的响应的有效性。
-
性能:评估系统操作的效率、资源利用和整体性能。
审计过程通常涉及收集和分析各种系统日志、配置文件、用户活动、文档和其他相关数据,以识别潜在问题、漏洞和改进领域。
审计过程通常定期进行。组织通常会有季度、半年和年度审计。周期通常由监管要求、系统的复杂性、关键性、风险概况以及组织的整体风险管理策略等因素决定。
高风险系统可能需要更频繁的审计周期,某些组织甚至可能实施持续的审计过程。
在面对某些事件时,例如重大的系统变更、安全事件、主要行业变化或监管更新,可能需要临时审计过程。
监控
监控,另一方面,涉及持续观察和跟踪系统的运行状态、性能和行为。以下是一些监控活动:
-
实时监控:持续收集和分析系统指标,如系统可用性、资源利用率、网络流量和错误率,以便及时检测和响应问题
-
异常检测:识别不寻常或意外的系统行为,这可能会表明潜在的问题或安全威胁
-
趋势分析:通过检查历史数据来识别系统性能和使用的模式、趋势和变化
-
警报和通知:当达到预定义的阈值或条件时触发警报和通知,使问题能够得到主动解决
-
仪表板和报告:提供系统健康、性能和关键指标的可视化表示,以支持数据驱动的决策
监控通常涉及部署各种监控中间件组件、代理和框架,它们从系统的不同组件收集、汇总和分析数据。
为什么审计和监控很重要?
审计和监控对于任何系统的有效管理和运营至关重要。以下是审计和监控之所以如此重要的几个关键原因:
-
确保可靠性和可用性:主动监控有助于在问题升级为系统故障或停机之前识别和解决问题。实时警报和事件管理能够快速响应和解决问题,最小化对最终用户的影响。全面的审计跟踪提供了必要的有关系统故障根本原因的信息,从而提高了系统的可靠性和可用性。
-
维护安全和合规性:审计日志和数据监控可以用来检测和调查安全漏洞、未经授权的访问尝试和其他恶意活动。合规法规通常要求实施强大的审计和监控能力,以确保敏感数据和系统的完整性和机密性。审计报告和监控仪表板可以证明组织遵守合规要求,降低处罚和声誉损害的风险。
-
优化性能和效率:监控系统指标和资源利用率可以帮助识别瓶颈、优化资源分配并提高整体系统性能。审计数据可以提供对使用模式、工作负载趋势和潜在优化领域的见解。
-
启用数据驱动决策:审计和监控数据可以用来识别模式并生成有价值的商业智能,支持战略规划和决策。详细的报告和可视化可以给利益相关者提供一个对系统健康状况、性能和整体状况的全面理解。历史数据和趋势分析有助于预测未来的资源需求,规划容量扩展,并识别流程改进的机会。
-
促进故障排除和根本原因分析:全面的审计轨迹和监控数据可以帮助工程师和支持团队快速识别问题的根本原因,减少问题解决所需的时间。详细的事件日志和上下文信息有助于重建系统行为和重现问题场景。审计和监控数据可以用来验证实施的修复措施的有效性,并确保问题不会再次发生。
通过投资于强大的审计和监控能力,组织可以确保其分布式系统的可靠性、安全性和优化,最终为最终用户和利益相关者提供更好的体验。
审计、监控和测量系统
“你不能改进你没有测量的东西”是管理顾问和作家彼得·德鲁克的一句话。
没有测量,组织可能会陷入以下情况:
-
基于意见的决策制定:没有定量证据,人们只能表达基于很少依据的意见。这会导致利益相关者和工程师之间沟通无效,以及对问题的理解碎片化。
-
试错改进:任何试图改进系统功能或质量属性的努力都可能成为试错。由于对问题的理解不足,其中一些可能有效,而另一些可能无效。更糟糕的是,几乎没有客观反映改进效果的方法。因此,组织可能会陷入基于意见的决策的恶性循环。
相反,通过监控来衡量系统对组织有以下好处:
-
建立基线:衡量系统的当前状态,无论是性能指标、安全完整性还是合规性,为未来改进提供了必要的基线,以便评估和比较未来的改进。
-
识别机会:测量和监控数据可以揭示系统内部可能没有量化证据就难以显现的问题、瓶颈或低效。
-
跟踪进度:一旦实施改进或变更,持续的测量和监控使组织能够跟踪这些变更的影响和有效性,确保它们产生积极影响,并实现预期结果。如果没有,组织可以决定从原始变更中转向,以避免进一步恶化。
-
基于信息的决策:可靠的数据和指标能够支持数据驱动的决策,使组织能够更有效地优先排序和分配资源,以实现最大的改进。这也有助于对抗基于意见的决策。定量证据是使人们理解一致并有效推动对所需改进达成共识的最好方式之一。
-
持续优化:通过建立一个测量和监控的文化,组织可以持续识别新的改进机会,创造一个持续优化和精炼的循环。
何时审计和监控不是必需的?
有一些例外情况,在这些情况下,审计和监控可能不是必需的。
如果系统极其简单,组件非常少,依赖性最小,那么对全面审计和监控的需求可能会减少。
在实验性、低保真度或概念验证系统中,当主要关注验证特定概念、假设或功能时,对审计和监控的投资可能不是首要任务。然而,如果该系统后来证明是一个可行的持续业务,那么在审计和监控上投入更多是值得的。
用于测试、个人实验或其他低影响用例的系统可能不需要与关键任务生产系统相同级别的审计和监控。
注意,某些系统或组织可能不受严格的监管或合规要求约束,这些要求强制实施全面的审计和监控功能。
此外,一些系统是隔离的,甚至与互联网断开连接。如果它们有严格的安全控制,那么广泛的审计和监控可能就不那么关键了。
如果系统设计为临时或短期使用,具有明确的生命周期,那么在全面的审计和监控上的投资可能是不合理的。这适用于一次性数据处理任务或具有预定停用日期的系统。
审计和监控的结合为管理系统的完整性、安全性和性能提供了一个全面的方法,尤其是在复杂的分布式环境中。审计发现可以帮助指导和增强监控策略,而监控数据可以为审计过程提供宝贵的输入。对于任何组织来说,审计和监控相辅相成是最有益的。
在现代系统中实施审计和监控并不简单,因为它们通常被分布到多个组件中。我们将在下一节中探讨这些挑战。
分布式系统审计和监控的挑战
分布式系统提出了几个独特的挑战,使得它们的审计和监控比传统的单体架构更为复杂:
-
分布式数据源:在分布式系统中,相关的数据和日志分散在多个节点、服务和通信渠道中。收集、汇总和关联这些信息是一项关键但具有挑战性的任务。
-
动态基础设施:分布式系统通常涉及高度动态的基础设施,节点和服务可以根据需求添加、删除或扩展。跟踪不断演变的拓扑结构和资源利用率对于有效的监控至关重要。
-
相互依赖和级联故障:分布式系统中组件之间的复杂相互依赖可能导致级联故障,即系统某一部分的故障会触发其他区域的故障。识别和追踪这些复杂关系对于根本原因分析和恢复至关重要。
-
各种技术的混合:分布式系统通常包含各种技术,包括各种编程语言、数据存储和中间件组件。开发一种可以处理这种异质性的统一审计和监控方法是一个重大挑战。
-
实时响应性:分布式系统通常需要提供实时响应,这要求审计和监控解决方案能够以高速处理和分析数据,同时不引入显著的延迟或性能开销。
-
合规性和监管要求:许多行业和组织都有严格的合规性规定,要求有全面的审计轨迹和监控能力。确保分布式系统满足这些要求是一项关键责任。
为了克服这些挑战,我们将通过具体示例探讨审计和监控的关键方面。
捕获适当的数据
为了解决这些挑战并为分布式系统建立有效的审计和监控实践,我们需要捕获最合适的、基本的构建块。
审计跟踪
以下是在审计跟踪中通常捕获的基本字段:
-
时间戳:事件发生时的日期和时间。对于所有审计跟踪来说,拥有一个通用时区非常重要。协调世界时(UTC)是一个明智的选择,因为它是一致的,不依赖于任何时区。没有夏令时或时钟变化的复杂性。它可以轻松转换为任何本地时区。它也是全球时间标准。这对于关联在相同时间发生的不同操作以反映模式非常有价值。
-
用户 ID:执行或受该操作影响用户的标识符。用户的身份必须被标记化,且不得包含任何 PII(个人身份信息)。这通常受到当地法律和法规的规范,尤其是在数据保护和隐私方面。因此,使用标记化用户 ID 可以减少暴露用户详细信息的大部分法律麻烦。通过用户 ID 访问用户信息仅限于授权个人和当地当局。
-
事件或操作类型:已执行的事件或操作类型(例如,登录、注销、数据访问或数据修改)。
-
执行操作的详细信息:操作的特定细节,通常是操作的输入参数。不同的操作通常具有不同的数据结构。请注意,细节可能包含需要保护敏感信息。敏感信息的保护技术将在第十四章中介绍。
-
访问的资源:涉及已执行操作的资源。它通常与聚合、实体或值对象相关联。通常涉及多个对象。
-
结果:操作的结果或后果。值得注意的是,成功和失败的结果在捕获审计跟踪方面同等重要。对于成功,重要的是要捕获接下来会发生什么。对于失败,应包括任何错误消息或调用堆栈跟踪。此外,捕获任何副作用也很重要,以便可以进一步调查它们。
-
会话 ID:发生事件时的会话标识符。拥有会话 ID 有助于任何关联调查确定在相同会话中可能执行的其他操作。
-
应用程序 ID:发生事件的程序的标识符。此信息有助于工程师确定问题可能发生的位置,以便改进情况。
监控数据
用于监控捕获的数据可能看起来与审计跟踪非常相似。然而,监控的独特焦点在于指标、可用性和系统的非功能性属性。以下是基本字段:
-
时间戳:事件的日期和时间。大多数现代系统使用协调世界时(UTC)而不是其他时区。
-
系统指标:CPU 使用率、内存使用率、磁盘 I/O、网络流量、消息基础设施、数据库和缓存。
-
应用程序指标:API 调用次数、后台作业执行次数、响应时间、请求速率和错误率。
-
服务健康:服务状态(例如,运行中、已关闭或降级)。
-
性能指标:操作的延迟和吞吐量。
-
日志:应用程序日志和系统日志。
-
警报:符合预定义标准的通知。
-
用户活动或业务指标:一般用户活动模式,而不是具体动作。这通常涵盖与业务相关的模式,例如“在过去 2 小时内有多少新用户注册”或“在过去 30 分钟内创建了多少交易。”
应用程序日志消息
应用程序级别的日志消息是由工程师编写并使用日志框架辅助生成的代码生成的。因此,日志消息的质量取决于工程师。
每个组织都应该为其日志消息定义其惯例和最佳实践。有几个方面需要标准化。
日志级别
通过分层级别组织日志消息提供了系统在多个抽象层次上的视角。它就像一个可以放大和缩小的系统地图。此外,它还定义了系统发生事件所需的响应级别。通常,有六个级别:
-
TRACE:信息最详细和粒度最细的级别。消息非常冗长,充满了可以参考源代码的技术数据。TRACE 日志通常仅在本地开发环境中开启,或者在更高环境中解决关键问题时例外。
-
DEBUG:比 trace 级别更简洁,DEBUG 日志消息提供可能需要用于诊断和解决问题的信息。DEBUG 级别通常在生产环境中关闭,但在较低环境中开启以进行测试目的。
-
INFO:标准级别的日志消息,宣布应用程序状态的变化或发生了某事。在之前用于村民的实际情况中,INFO 日志消息可能是一个新家庭记录创建的公告,以及一些基本信息,如家庭名称。INFO 日志消息旨在仅捕获成功案例的结果,并且不需要采取纠正措施。这也是在生产环境中显示的最低级别的日志消息。
-
WARN:应用程序中发生了意外情况。在此实例的流程中可能存在问题,但应用程序可以继续工作。例如,可能有一个请求删除不存在的家庭。这可能表明该家庭记录存在数据一致性问题的迹象,但应用程序可以继续处理其他请求。WARN 日志消息可能需要工程师进行调查,但不是紧急情况。
-
ERROR:一个或多个功能无法完成。这不是失败的单一实例,而是系统一部分的持续失败。系统已经降级,可能需要采取纠正措施来恢复失败的功能。
-
FATAL:关键功能中的基本错误不再工作。一个例子是失去与数据库的连接,以至于无法完成任何持久性功能。需要采取紧急纠正措施甚至人工干预来恢复情况。
日志消息格式
在日志消息中保持一致的格式有助于工程师快速分类和识别问题。一个好的日志消息应包含时间戳、记录器的名称、日志级别、线程名称、记录消息的类名以及消息本身。
幸运的是,大部分这些信息都是由日志框架提供的。然而,工程师仍然需要编写日志消息的内容。
一个好的日志消息应具备以下特点:
-
简洁:消息应简短,最好是一句话。
-
注意时态的使用:应使用两种主要时态。过去时用于描述发生的事情。进行时用于记录仍在运行的流程,并且应以宣布流程完成的日志消息结束。
-
关键信息:消息应包含必要的 ID,以便阅读消息的工程师可以排除相关故障。编写日志消息的工程师可以从控制台读取内容并运行故障排除会话。
-
激发行动:工程师可以针对日志消息采取行动,无论是作为调查还是确认流程的结果,因为这将是宝贵的。
-
一致的样式:一致的样式有助于更容易地理解日志消息,并更快地响应。
日志框架
尽管不同的组件可能使用不同的技术和语言,但在可能的情况下,应尽可能使用相同的日志框架。这将减少系统中日志消息的不一致性,从而减少工程师在故障排除目的下使用日志消息时的认知负荷。
结构化日志与非结构化日志
一个非结构化日志消息是一个带有一些格式的普通字符串,如下所示:
09:50:22.261 [main] INFO o.e.household.HouseholdRepository - Created a new household 'Whittington'
由于格式一致,它并不太糟糕,并且可以被人阅读。然而,当涉及到日志聚合、警报触发和分析时,很难准确和一致地提取确切信息。
然而,结构化日志提倡定义良好的字段和结构,以便数据可以轻松提取。之前的普通非结构化文本日志消息可以表示为一个 JSON 对象:
{
"@timestamp": "2024-08-20T09:50:22.261878+01:00",
"@version": "1",
"message": "Created a new household 'Whittington'",
"logger_name":
"org.example.household.HouseholdRepository",
"thread_name": "main",
"level": "INFO",
"level_value": 20000,
"householdName": "Whittington"
}
结构化日志允许自定义字段,这为日志消息提供了更多的价值,以便进行进一步的监控和分析。这个特性由大多数日志框架提供支持。
上述日志消息由 build.gradle.kts
支持:
implementation("io.github.oshai:kotlin-logging-jvm:7.0.0")
implementation("org.slf4j:slf4j-api:2.0.16")
implementation("ch.qos.logback:logback-classic:1.5.7")
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
在 Logback 配置文件 logback.xml
中,使用 Logstash 编码器将日志消息格式化为 JSON 字符串:
<appender name="structuredAppender" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
</encoder>
</appender>
<root level="debug">
<appender-ref ref="structuredAppender" />
</root>
然后,structuredAppender
被附加到根节点作为日志追加器。记录结构化消息的代码如下:
log.atInfo {
message = "Created a new household '$householdName'"
payload = mapOf(
"householdName" to householdName
)
}
除了主要消息外,payload
字段支持键值对格式的自定义字段。
应该强调的是,日志消息内容是在 Lambda 表达式中创建的,而不是作为参数。这是最优的,因为日志框架可以选择不执行 Lambda 表达式,如果此消息的日志级别低于配置。
相反,作为日志函数参数传递的值在日志框架决定使用它们之前会被评估。如果我们要在低日志级别(如 TRACE)中记录非常详细的信息,这可能会对性能产生影响。
上下文日志,或称为映射诊断上下文(MDC)
上下文日志,也称为 映射诊断上下文(MDC),旨在通过使用 ID(例如用户 ID、请求 ID、会话 ID 等)来分组或关联日志消息。它强调了这些日志消息属于更广泛的业务上下文或过程的事实。这有助于工程师通过查看同一上下文下的一小部分日志消息来识别和诊断问题。
这些上下文数据也可以用于监控和警报。例如,可以通过会话 ID 监控用户活动,以了解在会话中一起执行的操作。
上下文日志也可以穿透日志消息中的抽象层。服务层可能有日志记录,而存储层可以根据上下文数据分组。
上下文日志也与结构化日志兼容。上下文数据作为自定义字段添加到作用域内的日志消息中,以便这些日志消息可以分组和分析。
从提供的结构化日志示例扩展,Kotlin Logging 提供了一个 withLoggingContext
函数来简化 MDC 的使用:
withLoggingContext("session" to sessionId) {
log.atInfo {
message = "Created a new household '$householdName'"
payload = mapOf(
"householdName" to householdName
)
}
}
withLoggingContext
函数接受多个键值对作为上下文数据。在这个例子中,session
被添加为上下文数据。随后的 Lambda 表达式定义了上下文的范围,因此 Lambda 表达式中的所有函数调用都将自动将上下文数据添加为自定义字段到结构化日志消息中。
可选地,可以通过在日志格式中添加上下文字段来在日志消息的内容中展示上下文数据:
<appender name="plainTextWithMdc" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} MDC=%X{session} - %msg%n</pattern>
</encoder>
</appender>
因此,JSON 字符串日志消息通过添加上下文字段得到了增强:
{
"@timestamp": "2024-08-20T09:50:22.261878+01:00",
"@version": "1",
"message": "Created a new household 'Whittington'",
"logger_name": "org.example.household.HouseholdRepository",
"thread_name": "main",
"level": "INFO",
"level_value": 20000,
"session": "57fa4035-0390-406c-9f2b-7dfcfc131d5a",
"householdName": "Whittington"
}
session
字段由withLoggingContext
函数自动添加到所有在范围内记录的消息中。这种方法也将记录上下文数据的问题与主要应用程序逻辑分离。这些上下文数据不需要传递到任何在范围内调用的函数中。
在如何集中和汇总数据以进行监控和审计方面有更广泛的范围。我们将在下一部分讨论这些内容。
集中和汇总数据
之前,我们讨论了审计和监控分布式系统所面临的挑战,其中之一是分散在多个地方的数据。在分布式系统中,一个业务流程通常被视为一个单元,但在多个服务和设备上执行。
在这种情况下,审计和监控数据只有在我们可以将其汇总到一个集中的地方进行合并和分析时才有意义。
集中审计日志汇总
让我们重新审视村民交换服务的真实生活例子,并想象我们需要从众多服务中汇总审计和监控数据。这里有三种服务:家庭服务、合同服务和通知服务。汇总审计日志的需求将需要一个新的通用子域服务,该服务收集在其他服务中发生的所有事件。新的服务,审计服务,以及它与其他服务的交互,在图 11.1中展示:
图 11.1 – 审计服务交互的示例
审计服务消费由其他四个服务生成的所有事件。它负责理解这些事件,将它们转换为标准数据结构,并将它们持久化到永久存储中,以便将来查询。
这种交互模式不需要其他服务知道审计服务的存在,减少了耦合或依赖。其他服务可能只需要更改代码来通知消费者在其领域内发生了什么,但无需承担审计要求的重担。
替代方法
另一种方法是让其他服务强制通知审计服务,通常是通过调用REST 端点。这将使其他服务依赖于审计服务。与异步事件相比,同步 REST 通信提供相同的可靠性保证也更加复杂。现在,所有其他服务都意识到了审计要求,这在审计的边界上下文中被认为是一个泄露。因此,这不是一个推荐的方法。
审计数据结构统一
这种方法的缺点是审计服务必须知道所有服务中所有可审计事件的模式。在审计服务中需要管理很多依赖关系。
对于这个问题有一个解决方案。如果系统能够调整以采用所有事件的标准化信封,那么审计字段在信封级别定义,而特定领域的字段在内容级别定义。
话虽如此,仍然有必要将特定领域的字段作为审计跟踪的一部分进行捕获。这些字段可以以它们的原生格式存储,无需转换。这种转换仅在检索数据时才需要。
使用 ID 链接相关的审计跟踪
在报告完整的业务旅程时,能够链接相关的审计跟踪至关重要。这通常是通过为入口服务或分布式系统需要关联的任何其他组件生成一个关联 ID来实现的。这些事件不一定属于同一个请求或事务。
关联 ID 对于故障排除和理解业务旅程中不同组件之间的关系非常有用,即使它们不是同一个请求流的一部分。
事件主题的可发现性
在一个大型分布式系统中,需要跟踪审计服务将要消费的新事件主题可能会是一项耗时的任务。理想情况下,审计服务应该能够发现并动态消费事件主题。有几种方法可以实现这一点:
-
使用服务发现机制,如服务注册表或服务网格,来发现可用的事件主题或事件流。
-
使用集中式事件目录,它可以是一个独立的服务,或者只是一个可以被审计服务访问的资源,例如最后一个值队列。
-
使用消息代理。一些(例如,RabbitMQ 和 Kafka)提供 API 来发现事件主题。
-
使用配置管理工具,如 Spring Cloud Config 或 Kubernetes,因为它们提供 API,可以用来查找事件主题。
一旦动态查找了事件主题以及标准信封,审计服务就可以自动消费并将事件持久化到用于审计报告的专用数据库中。
审计跟踪事件示例
结合我们之前讨论的审计跟踪的关键方面,我们可以给出一个审计跟踪作为 Kotlin 数据类的示例。审计跟踪最重要的元素是参与事件的参与者:
data class Actor(
val id: UUID,
val type: String,
val involvement: String,
)
Actor
类使用 UUID 作为标记化标识符。虽然它通常代表人类用户,但有时可能是一个计划触发器或启动业务旅程的外部系统。参与者的类型(例如,用户、外部系统或调度器)由 type
字段捕获。参与者的参与由 involvement
字段捕获——例如,“由...执行”、“代表...”等等。
他们使用 String
类型而不是枚举有两个原因:
-
添加新的枚举值不具有向后兼容性
-
移除现有的枚举值不具有向前兼容性
将其作为普通字符串可以确保它始终可以被解析,从而从时间的开始构建完整的审计跟踪,考虑到未来可能会引入值的变化。
另一个重要元素是涉及的资源:
data class Resource (
val id: UUID,
val type: String,
val applicationId: String,
val version: Int? = null
)
每个资源都由一个 UUID 标识,但它也可以是一个普通字符串。资源还作为一个 type
字段出现,这可以是聚合、实体或值对象(例如,“家庭”、“合同”等)的名称。捕获资源所属的应用程序也很有用。此信息作为 application ID
(例如,“家庭服务”)捕获。如果资源是版本化的,那么这也被捕获。
从这两个数据类中,可以定义事件信封数据类如下:
data class EventEnvelope<E>(
val id: UUID,
val sessionId: UUID? = null,
val correlationId: UUID? = null,
val happenedAt: Instant,
val action: String,
val outcome: String,
val actor: Actor,
val otherActors: Set<Actor>? = null,
val resource: Resource,
val otherResources: Set<Resource>? = null,
val content: E,
val diffs: List<Difference>? = null,
)
它以一个作为 UUID 类型的事件 ID 作为唯一标识符开始。会话 ID 被捕获以关联同一登录会话中发生的活动。有一个关联 ID 将多个业务活动链接在一起。事件的时间戳作为 happenedAt
字段捕获。action
字段捕获启动业务旅程的内容,而 outcome
字段捕获事件发生时的结果。
信封以两种方式使用 Actor
类:它启动业务旅程并设置参与此事件的其它参与者。空集合被视为与空集合相同。Resource
类遵循相同的模式,即有一个主要资源和其他资源。
事件的内容使用了通用的 E
类型,因为信封下将会有许多形式的事件。
最后,有一个事件前后主要资源差异的通用列表。Kotlin 数据类可以表示为 JSON 对象,并且有开源库可以根据两个 JSON 对象生成 JSON Patch 格式的列表。然后,差异列表可以通过数据类表示——即 Difference
:
data class Difference(
val op: String,
val path: String,
val fromValue: Any? = null,
val toValue: Any? = null
)
此类有四个字段。op
字段表示数据操作类型,如“添加”、“替换”或“删除”。
path
字段是字段路径,就像它是一个 JSON 对象一样——例如,/party/0/householdName。事件前后更改的值分别捕获为fromValue
和toValue
。
这个审计跟踪信封只是一个例子,每个组织都应该有一个适合其需求的信封。接下来,我们将关注监控数据收集和聚合。
监控数据收集和聚合
监控工具使用多种不同的方法来收集其数据。它们使用多种方法从各种来源收集数据,如下所示:
-
代理或守护进程:在要监控的系统中安装了称为代理的小型软件组件。这些代理收集数据并将其发送到中央监控服务器。
-
系统级指标:这些代理可以收集各种指标,例如 CPU 使用率、内存使用率、磁盘 I/O、网络流量等等。
-
应用级指标:应用可以以这种格式记录消息,以便它们可以作为指标进行记录,或者应用可以直接将指标数值提交给监控工具。例如,一个 Kotlin/JVM 应用可以使用Java 管理扩展(JMX)来公开资源使用情况、应用数据、配置和性能指标。JMX 可以通过管理 Bean(MBeans)访问,也可以与第三方监控工具集成,用于可视化和警报目的。
-
日志文件收集:这些代理可以监听系统标准输出和系统错误输出。这些代理还可以跟踪日志文件并将它们发送到监控数据源。日志消息也可以直接提交到数据源,例如 Elastic Store,用于聚合目的。
-
无代理:通过使用标准网络协议,可以收集监控数据,尤其是网络监控数据,而无需安装代理。例如,Windows 管理规范(WMI)提供了一个操作系统接口,其中启用了来自节点的通知和设备相关信息。另一个例子是在多播 UDP 网络中的一个额外节点,它捕获要发送到监控工具的网络指标。
-
API 集成:一些监控中间件软件使用与服务、应用和云平台的直接 API 集成。它可以双向进行:要么被监控的节点提供一个 API 来公开监控数据,例如 Spring Actuator,要么监控工具提供一个 API 供节点提交监控数据。
-
measureTimeMillis
)和一个用于纳秒精度(measureNanoTime
)的另一个:val elapsedInMillis = measureTimeMillis { someProcess() } val elapsedInNanos = measureNanoTime { someProcess() }
-
跟踪 ID 和跨度 ID:一个跟踪 ID是一个唯一的标识符,它代表一个端到端业务流程或请求,在它通过分布式系统流动时。它用于将所有属于分布式事务或请求的各个单独的跨度(见下一段)分组在一起。跟踪 ID 使我们能够理解请求在跨越多个服务、组件和系统时的完整旅程。
跨度 ID是一个用于分布式事务或请求中单个操作或工作单元的唯一标识符。跨度代表作为更大跟踪(如 HTTP 请求、数据库查询或函数调用)一部分执行的单个步骤或操作。跨度是分层的,可以在跟踪内部嵌套以表示处理单个请求涉及的不同组件、服务或进程。跟踪 ID 和跨度 ID 之间的关系如图11.2所示:
图 11.2 – 实际示例中的跟踪 ID 和跨度 ID
在这里,一个请求从移动应用程序通过Contract Service来修改合同,同时为这个请求分配了跟踪 ID 215和跨度 ID 12。Contract Service在处理请求时请求家庭信息从Household Service。这意味着Household Service参与了处理这个请求,如跟踪 ID 215所示,但具有不同的跨度值——即跨度 ID 13。Contract Service通知Notification Service向受影响的家庭发送电子邮件,因此Notification Service也参与了此请求,使用相同的跟踪——即跟踪 ID 215——但一个新的跨度——即跨度 ID 14。
由于市场上存在许多第三方监控工具(例如,Elastic Slack(ELK)、Splunk、Datadog、Kibana、Prometheus、Grafana、New Relic、Jaeger 等)以及各种收集数据的方法,建议避免供应商锁定问题。如果系统使用专有方法与监控工具集成,考虑其他监控工具可能会变得过于昂贵。
OpenTelemetry (OTel)
OpenTelemetry(OTel)是一个由社区驱动的开源框架,旨在标准化我们从应用程序收集、处理和导出可观察数据的方式。它提供了一套 API、库、代理和仪器工具,可以支持各种编程语言和框架。这种互操作性使得能够跟踪和监控使用不同技术的应用程序,我们可以全面地监控系统。
此外,由于它们都使用 OTel 作为标准,因此从一种监控工具迁移到另一种监控工具的成本更低,因此我们不会局限于只使用一个供应商。
设置 OTel 从使用其库开始。以下代码展示了这一点,它使用了 Gradle Kotlin DSL:
implementation("io.opentelemetry:opentelemetry-api:1.43.0")
implementation("io.opentelemetry:opentelemetry-sdk:1.43.0")
implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.43.0")
implementation("io.opentelemetry:opentelemetry-extension-annotations:1.18.0")
下一步是配置tracer
和span
处理器:
val tracer: Tracer = run {
val oltpEndpont = "http://localhost:8123"
val otlpExporter = OtlpGrpcSpanExporter.builder()
.setEndpoint(oltpEndpont)
.build()
val spanProcessor = SimpleSpanProcessor.create(otlpExporter)
val tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(spanProcessor)
.build()
OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.buildAndRegisterGlobal()
GlobalOpenTelemetry.getTracer("example-tracer")
}
上述代码定义了一个端点,用于将遥测数据导出到Tracer
对象,该对象被创建用于在可追踪过程中使用。让我们看看这个对象是如何在开始和结束 span 时使用的:
fun main() {
val span: Span = tracer
.spanBuilder("process data")
.startSpan()
.apply { setAttribute("data.source", "memory") }
try {
println("process finished")
} catch (e: Exception) {
span.recordException(e)
} finally {
span.end()
}
}
这个示例main()
函数通过添加自定义属性来启动一个 span,以提供上下文信息。如果过程成功,span 将被确认并结束。否则,span 将记录已捕获的异常。
需要注意的是,监控 API 的设计是为了不抛出会干扰实际过程的错误。在这个例子中,即使无法连接到 OTLP 服务器,代码也会运行。
这些数据将被导出到监控工具以供进一步使用,我们将在下一节中介绍这一点。
指标、可视化和仪表板
由于监控数据是集中和汇总的,监控工具可以开始将此数据用于许多目的。例如,心跳消息和应用程序的常规健康检查提供每个应用程序的运行时间比作为指标。这个指标可以在仪表板上可视化,供操作员和工程师观察。
我们必须创建直观的可视化和仪表板,以便提供对分布式系统健康、性能和整体状态的清晰、一目了然的理解。
在仪表板上测量和可视化的指标可以分为两类。
-
服务级指标:被称为服务级指标(SLIs),这些指标衡量系统在服务级别的可靠性、可用性、延迟和性能。它侧重于向用户和外部系统提供的服务质量(QoS)。
除了常见的指标,如 CPU 利用率、网络延迟、内存使用和磁盘空间利用率之外,还应突出显示作为服务级目标(SLO)和服务级协议(SLA)一部分的指标。这些是可能影响客户满意度、与外部实体的关系以及组织声誉的敏感指标。
一个典型的服务级指标是对常用功能的响应时间。应用程序的响应时间很可能是 SLO 或 SLA 的一部分,应该持续进行测量和可视化。
SLA 与 SLO 与 SLI 的比较
SLA 是服务提供商和客户之间的一项正式合同,它定义了预期的服务水平,包括保证的具体性能指标以及未达到协议的处罚。
SLO 是一个具体且可衡量的目标,它定义了服务的目标水平。它设定了服务提供商旨在实现的服务性能标准。
SLI 是用于衡量服务性能的指标,特别是针对 SLO。它提供了确定 SLO 是否得到满足所需的数据。
-
业务级指标:业务级指标关注的是应该引起业务关注的模式和用法。例如,电子商务系统可能会对监控系统中注册了多少新用户感兴趣。
业务级指标通常与定义的关键绩效指标(KPIs)进行比较,这些指标用于展示组织如何有效地实现其目标和关键结果(OKRs)。
OKR 的目标部分是一个定性和具有前瞻性的目标,可能不可衡量。然而,关键结果是可衡量的,并且通常定期设定。
在这个用户注册电子商务系统的例子中,目标可以是“尽可能多地注册活跃的购买用户”。这个目标可以转化为以下关键结果:
-
在第三季度(Q3)注册 30%的新用户
-
确保在第三季度(Q3)有 80%的新用户保持活跃
-
确保在第三季度(Q3)有 50%的新用户在过去 30 天内至少购买了一件系统内的商品
这些关键结果是在时间边界内的任务。它们是可衡量的目标,旨在激发大胆的目标。
另一方面,关键绩效指标(KPIs)持续衡量绩效。为了支持上述 OKR,需要衡量以下 KPIs:
-
在过去 3 个月内注册的用户数量(即新用户)
-
在过去 30 天内登录系统的新的用户数量
-
在过去 30 天内至少购买了一件商品的新的用户数量
这些数据可以从应用程序日志消息或直接从持久数据库中收集和汇总。
-
全面指标的示例 – DORA
如果有操纵数字的方法但无法改善任何东西,衡量指标可能成为一种反模式。例如,如果指标仅关于服务正常运行时间,那么对于只能执行健康检查之外的操作的服务,可能实现 100%的正常运行时间。
拥有一个全面的指标套件来填补这个漏洞是很重要的。如果多个指标衡量主题的各个方面,操纵一个指标就会扭曲其他指标,因此不可能隐藏。在本节中,我们将通过一个全面指标示例以及它们如何避免作弊来进行分析。
DevOps 研究和评估(DORA)指标是一组 KPIs,以确保软件开发和交付过程的有效性和效率。这些指标帮助组织了解其 DevOps 绩效并确定改进领域。四个主要的 DORA 指标如下:
-
部署频率:生产发布成功发生的频率。更高的频率表明更高的速度和响应式开发过程。
-
变更的领先时间:将代码提交到生产所需的时间。较短的领先时间代表更高效的开发流程。
-
变更失败率:导致生产中失败的部署的百分比。较低的比率表明发布更稳定、更可靠。
-
平均恢复时间(MTTR):从生产中的故障恢复的平均时间。更快的恢复时间表明更好的事件响应和弹性。
任何试图操纵这些指标的行为都会被其他指标检测到。例如,如果开发跳过运行测试,变更的领先时间会减少,但由于缺乏测试,变更失败率会增加。
DORA 团队还开发了SPACE指标,除了软件交付效率外,还提供了对工程生产力的全面视角。让我们看看 SPACE 代表什么:
-
满意度:这是对工程师对其工作、工作与生活平衡和工具满意度的定量和定性测量
-
性能:这指定了已交付软件的质量、有效性和影响
-
活动量:对工作完成有贡献的活动量——例如,提交和拉取请求的数量
-
沟通与协作:这涉及到评估团队会议、跨团队合作和协作工具的有效性
-
效率:这衡量了时间、努力和工具如何有效利用以达到预期结果、交付和减少浪费
SPACE 指标旨在涵盖团队生产力的多个角度,以及避免任何指标被其他指标操纵。例如,过多的会议可能会增加活动量,但缺乏有效性将通过评估沟通和协作而被捕捉到。
DORA 和 SPACE 是互补的,可以同时测量,以提供对团队健康及其软件产品交付的全面洞察。
良好的指标应该作为一个综合套件提供,以提供对组织绩效的更全面视角。
自动警报和事件管理
拥有一个显示指标的视觉仪表板是好的,但当涉及到检测系统故障和发出警报时,组织不能依赖于人类的眼睛。应该有自动警报和定义良好的事件管理流程,以便组织能够尽快恢复系统。
我们必须建立实时警报和事件管理的机制,以便我们能够快速响应并解决分布式系统中出现的问题。
监控工具允许组织在多种条件下触发警报:
-
资源利用率过高(例如,10 分钟内 CPU 使用率超过 90%)
-
给定的指标已超过阈值(例如,在过去的 5 分钟内有超过 20 个状态码为
400
的 HTTP 响应) -
当检测到安全威胁或异常,例如未经授权的访问尝试
-
检测到定制的错误日志模式(例如,检测到错误日志消息“无法授权支付”)
事件管理流程在每个组织中都不同。一些组织有专门的 24 小时支持团队来响应事件,而其他组织则有工程师轮流担任支持人员。此外,一些组织在重大事件无法立即解决的情况下设有三个级别的升级。
没有黄金事件管理程序,但有一些事件管理原则应该考虑。
-
建立并记录事件管理流程:该流程需要被定义和记录,以便参与事件管理的人员有一个遵循的过程。
-
实施沟通渠道:建议为每个事件设立一个专门的即时通讯渠道,以便在人员之间进行相关且专注的协作。电子邮件和电话应用于通知目的,因为它们没有良好的结构化对话格式。后来加入调查过程的其他人员会发现很难跟进。
此外,即时通讯渠道内置了时间戳和参与者识别,这对于编制事件报告的沟通部分很有用。
-
记录每个事件的过程:记录每个事件很重要,以捕捉问题的发现、故障排除过程、问题的解决以及事件期间的沟通。这对于内部改进、审计和监管要求很有用。事件报告也应标准化,以便进行比较和进一步分析。
-
进行后续会议:这种类型的会议还有三个其他已知名称:事后审查(AAR)、尸检和尸检会议。这次会议的目的是回顾和重放事件,确定从这次事件中学到了什么,并讨论预防措施和任何潜在的改进(流程、沟通、工具等)。
这次会议的行动项目被优先考虑并纳入负责团队的待办事项列表中,并与事件相关联,以激励变革。
事件管理的示例工作流程展示在图 11.3中:
图 11.3 – 事件管理示例工作流程
在这个图中,多个来源可以报告发生的事件。支持人员通过电子邮件、电话、即时消息或电话应用程序由事件管理工具通知。支持人员通过回答以下问题对事件进行分类:
-
这是不是一个实际的事件,还是只是一个误报?
-
哪个业务区域受到影响?
-
哪些人受到这次事件的影响?
-
业务区域受到的影响有多严重?
-
哪个团队负责受影响的业务领域?
-
是否需要立即修复?
如果事件最终证明是误报,则事件得到解决。如果它是实际事件但不需要立即修复,则事件将在下一个工作日由负责团队处理。
如果事件重大或严重,那么负责团队的第二级支持人员将介入,立即开始调查。在此阶段,调查可能会根据调查进展而升级。有时,需要另一支团队介入,或者需要上级管理层做出重大决策。一旦修复方案被应用并验证,事件即得到解决。
如果系统是关键任务系统,那么考虑一个支持升级策略、轮换支持管理、通信渠道、自动报告和待办事项票证集成的企业级事件管理系统是值得的。
事件管理改进通常是在事件发生后进行的。对于组织来说,持续学习和改进其事件管理流程非常重要。
摘要
在本章中,我们探讨了审计和监控的重要性。我们强调了测量系统的重要性,以避免组织陷入基于意见的决策的恶性循环。提到了一些不需要审计和监控的例外情况。
我们还确定了在审计和监控分布式系统时遇到的几个挑战。在突出我们面临的挑战后,我们开始涵盖审计和监控的关键方面。
用于审计和监控的数据是不同的。我们通过 Kotlin 代码演示了审计跟踪的样本,以便我们能够涵盖可用于监控的各种类型的数据。
接着,我们通过示例代码和一些日志框架展示了应用级日志的最佳实践。我们涵盖了结构化日志和上下文日志的技术。
之后,我们转向集中和汇总审计和监控数据方面。我们提出了标准封套和可发现主题的审计跟踪方法。我们还提到了收集监控数据的多种方法。
然后,我们确定了两个级别的指标:服务级别和业务级别。我们介绍了如何将定性目标转化为可量化的指标,并以DORA指标作为一个综合指标套件的例子,以防止指标游戏化。
最后,我们讨论了自动化警报和事件管理的方面。我们展示了事件管理的一个示例工作流程,以说明适当工具、有效沟通和文档的重要性。
下一章将涵盖软件系统的性能和可扩展性方面。
第十二章:性能和可扩展性
软件系统随着业务和不断变化的环境而增长,表现为更高的复杂性、更多样化的用户需求和更重的负载。在增长过程中保持高性能和可扩展性变得至关重要。性能指的是系统处理和响应请求的速度,而可扩展性描述的是系统随着时间的推移处理更高流量和使用的容量。
低性能可能导致令人沮丧的用户体验、生产力下降,甚至完全的系统故障。而无法扩展以满足日益增长需求的系统将很快变得不堪重负,无法使用。因此,确保最佳性能和可扩展性是任何软件工程项目的关键挑战。
在本章中,我们将探讨性能工程和可扩展系统设计的核心概念和原则。我们将讨论常见的性能瓶颈和缓解策略,回顾负载测试和基准测试的技术,并涵盖支持水平和垂直扩展的架构模式和设计选择。
到本章结束时,您将深入了解如何构建高性能、可扩展的系统,以承受现实世界需求的压力。
在本章中,我们将涵盖以下主题:
-
性能和可扩展性的维度
-
现在还是稍后优化性能?
-
性能测试规划
-
执行性能测试
-
微基准测试
-
性能提升策略
-
极低延迟系统
技术要求
您可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-12
性能和可扩展性的维度
性能是系统执行任务和响应请求的效率。它通过各种指标来衡量:
-
延迟:系统响应请求所需的时间。
-
吞吐量:在给定时间框架内处理的请求数量。
-
资源利用率:在操作期间使用的资源(例如 CPU、内存、网络、文件等)的百分比。
-
并发用户:系统同时有效服务而不会降低性能的用户数量。
-
页面加载时间:屏幕完全加载所需的总时间,包括所有资源(图像、视频、脚本等)
-
队列大小:等待服务器处理的请求数量
-
首次字节时间(TTFB):从客户端发起请求到客户端从服务器接收到第一个字节所经过的时间。
-
缓存命中率:从缓存中服务请求的百分比与从较慢的二级数据源中服务请求的百分比。更高的比率表明缓存效率更高。
-
错误率:导致错误的请求百分比(例如,HTTP 错误状态)。高错误率表明应用程序或基础设施存在问题。
可扩展性是指系统在不降低性能的情况下处理增加负载的能力。它表明系统可以增长并适应用户流量和数据量的增加。可扩展性可以分为两种类型:
-
垂直扩展:向单个节点添加更多资源(例如,CPU 或 RAM)以增加其容量。这也被称为向上扩展。
-
水平扩展:向分布式系统中添加更多节点以分散负载并提高容量。这也被称为向外扩展。
可扩展性也是系统在负载减少时缩小规模的能力。缩小规模通常涉及资源的灵活使用和成本节约。这仍然是可扩展性的一个重要方面,但重点通常放在向上扩展和向外扩展上。
可扩展性可以通过以下指标来衡量:
-
可扩展性比率:性能增加与资源增加(如服务器数量)之间的比率
-
扩展时间:向系统中添加资源到额外资源变为可操作之间的时间
这些指标有助于衡量系统中的变化可能如何影响性能和可扩展性。没有它们,很难决定是否应该优化性能。我们将在下一节深入讨论这个决定。
现在优化性能还是以后再优化?
工程师和架构师经常面临是否应该现在优化性能还是以后再优化的问题。这发生在系统设计的早期阶段,以及已经建立的生产系统中。
我们都知道优化性能至关重要,但是否从第一天开始就优先考虑它并不是一个简单的是非问题。
你可能听说过有人说过“过早优化是万恶之源”。这个说法本身很戏剧化,但其中确实有一些优点。
你可能也听说过一句名言,“先让它工作,再让它正确,最后让它变快。” 这是由软件工程师Kent Beck提出的。
那么,如果系统过早优化,或者如果我们过早地“让它变快”,会有什么后果呢?
在了解用户行为和需求之前花费太多时间进行性能改进可能导致努力白费。此外,它创建了一个不必要的复杂架构,阻碍了团队的效率。团队可能不得不简化过度设计的系统,这也需要付出努力。从这个意义上说,团队因为过早提高性能而受到了两次惩罚。
优化性能和可扩展性的考虑因素
在考虑系统是否应该为了性能和可扩展性进行优化时,有几个因素需要考虑:
-
核心功能完整性:如果一个系统的核心功能仍在开发中,那么最初专注于交付核心功能和功能通常更为重要。这是第一步:“让它工作。”
此外,我们必须确保系统按照功能要求的行为符合预期。正确性始终应该优于性能。这是第二步:“让它正确。”
-
性能指标:在优化性能或提高可扩展性之前,拥有当前的性能指标作为基准至关重要。性能基准可以提供关于当前系统瓶颈的见解,帮助团队确定哪个区域应该首先改进。
性能基准测试可以经验性地和客观地比较一个变更是否导致了更好的或更差的表现,或者是否尝试改进性能已经达到了其目标。
-
非功能性需求:非功能性需求是关于系统是否需要现在进行优化的有用指导。性能的非功能性需求可能由监管约束、外部系统集成合规性或用户体验原则驱动。
-
关键用例、用户体验和竞争对手:如果一个应用从一开始就预计要处理大量流量(例如,产品发布活动、培训或营销活动),那么早期优化是必不可少的。如果一个应用的性能直接影响用户满意度,那么早期解决性能问题以避免负面反馈很重要。竞争对手的当前性能指标也表明了应用性能应该优化多少。
-
可扩展性需求:如果一个应用预计会有快速增长或扩展需求,那么从一开始就实施良好的性能实践可以在以后节省时间和精力。
性能最佳实践
即使现在可能不是优化性能的最佳时机,也有一些最佳实践至少可以避免性能变得更差:
-
先测量:测量当前的性能指标,理想情况下是所有操作,但底线是测量核心功能和最频繁的操作。
-
实现基本优化:在开发早期阶段,实现基本性能最佳实践,例如高效的数据库查询。
-
可扩展性规划:在设计系统架构时,考虑到可扩展性,以便在以后更容易优化而无需进行大规模重构。有时,这关乎不要设置限制,这些限制会限制可扩展性。
虽然不一定需要在第一天就优化性能,但将基本性能考虑纳入您的开发流程可以导致更好的长期结果。首先关注交付价值,然后随着应用的演变对性能进行迭代。让我们通过一个性能测量的例子来更好地理解这一点。
基本性能测量的示例
这是一个要测量的操作的基本示例:
fun sampleOperation() {
Thread.sleep(1)
}
此示例的目标是找出以下方面:
-
吞吐量:每秒可以执行多少操作
-
延迟:平均完成一个操作所需的时间
必须定义一个小的函数measureTotalTimeElapsed
来测量操作所有迭代的总时间消耗:
fun measureTotalTimeElapsed(
iterations: Int,
operation: (Int) -> Unit,
): Long =
measureTimeMillis {
repeat(iterations, operation)
}
此函数使用标准库中的measureTimeMillis
Kotlin 函数来捕获重复操作所花费的时间。
最后,这是启动测试的main
函数:
fun main() {
val iterations = 1_000
val operationTime = measureTotalTimeElapsed(iterations) { sampleOperation() }
println("Total time elapsed: ${operationTime / 1000.0} second")
println("Throughput: ${iterations / (operationTime / 1000.0)} operations per second")
println("Latency (average): ${operationTime / iterations} ms")
}
此函数定义了要执行 1,000 次的操作。通过使用 Lambda 表达式调用measure
TotalTimeElapsed函数,该表达式运行sampleOperation
函数,返回总的毫秒时间消耗。然后,吞吐量计算为迭代次数除以总消耗时间(秒)。平均延迟计算为吞吐量的倒数倒数——总时间消耗除以迭代次数。
这是运行测试的样本输出:
Total time elapsed: 1.264 second
Throughput: 791.1392405063291 operations per second
Latency (average): 1 ms
由于示例函数sampleOperation
仅使线程休眠 1 毫秒,因此平均延迟如预期为 1 毫秒。在此运行中,吞吐量接近 800,但每次运行都会有所不同。
Kotlin 标准库提供了一些时间测量的函数:
-
返回时间消耗的毫秒数(在本例中使用):
measureTimeMillis
-
返回时间消耗的纳秒数:
measureNanoTime
-
返回时间消耗作为
Duration
:measureTime
-
返回时间消耗和 Lambda 表达式返回的值:
measureTimedValue
对于现实生活中的性能关键系统,这当然是不够的。因此,在下一节中,我们将介绍性能测试的主要类型。
性能测试
性能测试是一类测试,它评估系统在给定负载下的速度、响应性和稳定性。在本节中,我们将探讨性能测试的主要类型。
负载测试
负载测试旨在评估系统在预期负载条件下的行为,例如配置的并发请求数量。目标是识别在负载下性能可能下降的应用程序或基础设施中的瓶颈。它确保系统可以处理预期的流量而不会出现性能下降。
压力测试
压力测试旨在评估系统在超出其正常操作容量极限的极端负载条件下的性能。它们还帮助我们确定系统的断裂点以及它在压力下的失败方式,因此可以部署主动监控和警报以采取预防措施。
耐久性测试(浸泡测试)
耐久性测试,也称为浸泡测试,专注于系统在长时间内的稳定性和性能。这个长时间是用来识别随着时间的推移积累或出现的问题,例如内存泄漏、资源耗尽或性能下降。
峰值测试
峰值测试引入突然增加的负载(即“峰值”),以便我们可以观察系统在这种情况下的反应。结果说明了系统如何处理突然的交通变化而不会失败。
体积和延迟测试
体积测试评估系统在大量数据下的性能。延迟测试测量请求和相应响应之间的时间延迟。它们通常测量吞吐量和延迟等指标,以确保应用程序可以满足服务级别协议(SLAs)或服务级别****目标(SLOs)。
可伸缩性测试
可伸缩性测试旨在确定系统在响应增加或减少的负载时如何扩展或缩小。它测量在添加或移除资源时系统的性能。
配置测试
配置测试旨在确定性能的最佳配置。这包括在不同的配置下运行性能测试,包括硬件、软件和网络。
性能测试规划
虽然有不同类型的性能测试,但规划和执行性能测试的过程是相似的。区别在于每个步骤的细节。在本节中,我们将探讨规划和执行性能测试的旅程。
规划
在规划阶段,首先,应该定义测试的目标。这意味着我们必须定义我们希望从测试中获得的信息——例如,能否在 50 毫秒内创建一个家庭记录?系统能否在不降级的情况下处理 5,000 个并发请求?这些目标是规划执行性能测试的主要驱动力。它们还决定了可以使用哪种类型的性能测试。
然后,应该定义性能测试的业务场景。通常,目标会给出关于将使用哪些场景的很大提示,但值得探索每个场景中涉及的步骤的细节,并将它们正式化为测试脚本的蓝图。
规划的最后部分是指定要运行的负载级别,包括用户数量和测试持续时间。有时,并不清楚要运行哪个级别,特别是如果我们想找到系统的断点。这最初是可以接受的,因为性能测试旨在迭代运行。
准备和开发
一旦有了初步计划,性能测试就可以准备和开发。这些活动可以并行进行。
测试脚本是测试执行的核心。测试需要自动化以实现一致的结果。这涉及到一个重大的决定,即使用哪个工具。以下是一份常用工具的列表:
-
Apache JMeter (
jmeter.apache.org/
): 开源,免费,支持图形用户界面,分布式测试,插件支持,基于 Java -
OpenText 的 LoadRunner (
www.opentext.com/
): 商业许可证,图形用户界面,与 CI/CD 工具集成,支持分析和报告,以及 Java 支持 -
Gatling (
docs.gatling.io/
): 开源,带有额外功能的商业许可证,脚本可以用 Kotlin 编写 -
K6 (
k6.io/
): 开源,基于订阅的云功能,可以与用JavaScript(JS)编写的 CI/CD 脚本集成 -
Locust (
locust.io/
): 开源,支持图形用户界面,分布式测试,脚本用 Python 编写 -
BlazeMeter (
www.blazemeter.com/
): 免费版功能有限,商业许可证,基于云,支持图形用户界面,实时报告和分析,与 CI/CD 集成,支持 JMeter 脚本
这些工具提供全面的功能,例如组织测试脚本、管理多个测试配置、度量测量、分析和报告。您还可以选择构建自己的性能测试驱动程序。如果测试简单且没有外部工具的度量测量足够,则适用。
需要根据测试脚本的要求设置适当的度量测量。这些度量可以通过测试工具或如前所述已嵌入系统的监控工具进行测量。任何缺失的度量在执行测试之前都需要设置。
同时,需要设置一个测试环境以进行执行。理想情况下,环境应该与系统实际运行的实际生产环境相当。如果成本太高,可以使用较小规模的环境来预测预期性能,同时考虑到一定的误差。
测试环境应该是一个隔离的沙盒,只进行性能测试。对于某些组织来说,复制类似生产环境的性能测试环境可能是一个挑战。仅用数据复制环境可能对某些组织来说已经是一个挑战。此外,环境需要具备运行测试场景所需的数据。
有时,系统会与第三方系统集成。在这种情况下,外部集成需要使用模拟器进行模拟。
执行和迭代
一旦我们设置了测试脚本、测试环境和相应的指标,我们就可以执行性能测试。允许迭代反馈循环至关重要,其中测试可以多次运行,并且每次测试之间可能会有变化。在每次迭代中,应执行相同的操作多次,以便我们有足够的数据点进行分析。
至少应运行两次测试,初始运行识别瓶颈,然后进行更改以消除瓶颈,最后再次运行以证明瓶颈不再存在,如指标所示。
实际上,在消除最大的瓶颈之后,另一个瓶颈将会出现。性能景观将随着为提高性能所做的每一次改变而改变。当目标完成时,迭代可以结束,或者在这个过程中可能会发现新的问题。
性能测试的迭代执行可以在图 12.1中看到:
图 12.1 – 性能测试的示例工作流程
在每次运行中,测试通过执行测试脚本进行。测试脚本通常以预热操作开始。例如,如果我们打算发送 10,000 个请求 100 次,前 10 次可以被视为预热,因此这些指标不考虑。
预热允许系统在实际性能测量之前达到稳定状态。处理初始请求触发缓存,使其填充频繁访问的数据。它还允许系统有效地分配资源,如线程、内存和数据库连接。通过预热系统,可以减少其他瞬态因素,如即时编译、垃圾收集和资源竞争。
在运行测试后,应从收集到的指标数据生成报告。报告应构建成一个允许迭代进行比较的格式。然后分析原始数据以生成如下统计图表:
-
响应时间的平均值和中位数百分位数
-
平均和峰值吞吐量;吞吐量的增加除以资源的增加,这表明了扩展效率
-
总体错误率和按类型划分的错误率
-
平均和最大延迟
-
能够处理而不降级的并发用户数量
-
维持负载的时间
从这些数字中,可以识别出一些瓶颈。一些数字可能低于非功能性需求、SLA 或 SLO。一些数字可能与其他数字相比特别慢。这些瓶颈推动了为提高整体性能所需的变化。
尤其是在早期迭代中,可能会发现测试脚本中的缺陷。测试脚本本身效率低下并导致系统变慢的情况并不少见。测试脚本可能包含不必要的循环或复杂的逻辑,这会减慢脚本的执行时间。测试脚本可能在请求之间有虚假的等待时间,这结果限制了吞吐量。其他因素,如错误处理、同步操作、资源竞争和网络性能,也可能扭曲性能测试的结果。这些发现导致测试脚本被审查和更新以供未来运行。
在进行修改后,应再次执行性能测试,以检查目标性能统计指标是否有所提升,同时确保这些更改不会在其他区域降低系统性能。
这种重复的练习会持续进行,直到我们对结果感到满意。有几个可能的情况,迭代应该停止:
-
测试目标已经完成——例如,我们已经检测到系统在不降低性能的情况下可以处理的最大请求数量。
-
性能指标已满足非功能性需求、SLA 或 SLO
-
性能测试所花费的时间已经超过了最初的时间盒限制
性能测试应被视为一种反复进行的练习。一次成功且令人满意的表现测试只能支持假设系统能够在测试脚本中的配置和参数下处理请求。由于业务增长和新功能的引入,系统使用模式会不断变化。
性能测试的好处
性能测试提供了在预配置负载下系统性能的见解。它表明我们如何优化系统以提供快速且可靠的用户体验,即使在重负载下也是如此。它帮助利益相关者了解系统限制并就扩展和基础设施做出明智的决定。它还在这些问题影响用户之前识别潜在问题,从而降低停机或服务退化的风险。
性能测试对于确保应用程序满足用户期望并在不同条件下保持稳定性至关重要。通过进行不同类型的性能测试,组织可以识别和解决潜在问题,优化性能,并提高整体用户满意度。
接下来,我们将考虑一种迭代使用的技术来衡量函数的性能。这种技术被称为微基准测试。
微基准测试
虽然性能测试关注的是系统级性能,但微基准测试是在函数级别对一小块独立代码的性能测量。微基准测试通常适用于以下领域:
-
整个系统核心处的算法——例如,互联网搜索引擎的搜索算法
-
用户最频繁使用的功能
-
作为 API 暴露给外部系统的功能
-
至关重要的代码路径和性能敏感
-
当比较一个函数、算法或代码更改的实现时
Kotlin 基准测试 (github.com/Kotlin/kotlinx-benchmark
) 是运行 Kotlin 代码基准测试最流行的工具。它封装了经典的Java 微基准工具(JMH)框架,并通过Java 虚拟机(JVM)、JS、本地和甚至Web Assembly(WASM)支持 Kotlin。
使用 Gradle Kotlin DSL 脚本设置微基准测试
使用 Gradle Kotlin DSL 脚本设置基准测试很简单。例如,对于 JVM,我们需要以下插件:
plugins {
id("org.jetbrains.kotlinx.benchmark") version "0.4.11"
kotlin("plugin.allopen") version "2.0.20"
}
第一个插件用于 Kotlin 微基准测试,而第二个插件用于打开最终的 Kotlin 类以进行测试。现在,我们需要确保可以从存储库中查找插件和依赖项:
repositories {
mavenCentral()
gradlePluginPortal()
}
接下来,需要声明对 Kotlin 微基准测试的代码依赖项:
implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:0.4.11")
然后,我们需要配置allOpen
插件,使其仅打开带有State
注解的 Kotlin 类:
allOpen {
annotation("org.openjdk.jmh.annotations.State")
}
设置的最后部分是设置微基准测试本身:
benchmark {
targets {
register("main")
}
configurations {
named("main") {
}
}
}
配置被命名为main
并已被选中运行。可以配置预热迭代次数、要测量的迭代次数以及每次迭代应持续多长时间。然而,此示例中使用了基于注解的配置。
微基准测试
实际的基准测试运行器代码被注释,以便运行器可以按照特定配置执行它。请注意,此测试应放置在main
源文件夹(而不是test
源文件夹)中,以便插件可以捕获:
@State(Scope.Benchmark)
@Fork(1)
@Warmup(iterations = 10)
@Measurement(iterations = 20, time = 1, timeUnit = TimeUnit.MILLISECONDS)
class MicrobenchmarkingTest {
private var data = emptyList<UUID>()
@Setup
fun setUp() {
data = (1..2).map { UUID.randomUUID() }
}
@Benchmark
fun combineUUIDBenchmark(): UUID = data.reduce { one, two -> one + two }
private operator fun UUID.plus(another: UUID): UUID {
val mostSignificant = mostSignificantBits xor another.mostSignificantBits
val leastSignficant = leastSignificantBits xor another.leastSignificantBits
return UUID(mostSignificant, leastSignficant)
}
}
此微基准测试评估了结合两个State
注解触发allOpen
插件打开此类进行测试的功能的性能。然后,Fork
注解定义了用于执行的线程数。其他注解指定了预热、执行迭代的次数以及每次迭代的持续时间。
例如,setup
注解函数用于创建运行测试所需的数据,而具有Benchmark
注解的combineUUIDBenchmark
函数是主要要测量的功能。
微基准测试运行器
要运行微基准测试,我们可以使用以下 Gradle 命令:
./gradlew benchmark
结果摘要打印到控制台,而详细报告生成在/``build/reports/benchmarks/main
文件夹下:
Success: 109349297.194 ±(99.9%) 15493649.408 ops/s [Average]
(min, avg, max) = (55205844.260, 109349297.194, 132224154.121), stdev = 17842509.699
CI (99.9%): [93855647.787, 124842946.602] (assumes normal distribution)
微基准测试的格式旨在比较运行情况。可以在运行之间进行改进,并且下一次运行应该展示这些更改是否产生了影响。
微基准测试是性能测试的一个有价值的子集,它专注于代码实现。通过了解独立函数的性能特征,工程师可以进行有针对性的优化。相比之下,性能测试采用整体方法来评估整个系统在各种条件下的性能。这两种实践对于交付高性能系统都是必不可少的。
另有一个工具可以测量和分析应用程序的性能,但通过图形用户界面进行可视化。这个工具被称为应用程序性能分析器,我们将在下一节中介绍它。
应用程序性能分析
性能分析通过监控和分析应用程序在运行时的性能来工作。性能分析器对代码进行仪器化并拦截调用以收集性能度量,例如已用时间和调用次数。它可以生成应用程序的堆栈跟踪,以可视化函数之间的关系。
性能分析工具还监控内存分配和释放,分析堆转储,并识别潜在的内存泄漏。
同时,性能分析工具测量代码各个部分消耗的 CPU 周期,并识别计算密集型函数。性能分析工具还监控其他资源的使用情况,例如文件操作、网络活动以及线程之间的交互,以提供资源利用的全面视图。
性能分析工具附带详细的报告,这些报告在用户界面中以可视化形式呈现,以帮助工程师定位需要优化的区域。
然而,由于侵入性的仪器化和测量,使用性能分析器运行应用程序会显著降低性能。捕获的度量数据应被视为实际运行时间的放大,并用于查找运行缓慢、效率低下或资源消耗大的区域。
对于 Kotlin 工程师,有几种流行的性能分析工具可供选择:
-
YourKit Java 性能分析器 (
www.yourkit.com/java/profiler/
) -
VisualVM (
visualvm.github.io/startupprofiler.html
) -
IntelliJ IDEA 性能分析器 (
www.jetbrains.com/pages/intellij-idea-profiler/
) -
JProfiler (
www.ej-technologies.com/jprofiler
) -
Async Profiler (
github.com/async-profiler/async-profiler
) -
Java Mission Control (
www.oracle.com/java/technologies/jdk-mission-control.html
)
应用程序剖析器应用于分析性能关键操作。由于仪器显著减慢,它们通常不会在生产环境中运行。在较低的环境中运行剖析器,输入模拟生产环境是很常见的。
接下来,我们将介绍一些性能提升策略。
性能提升策略
提升系统的性能通常需要一种多样化的方法,以解决各个方面的问题。没有银弹可以神奇地提升性能。然而,一些常见的策略有助于工程师解决问题,以满足非功能性需求。
测试,测试,测试
性能测试应持续和重复进行。当出现感知的性能问题时,如果不运行性能测试,很难知道根本原因。工程师不应盲目应用“性能修复”,而应首先执行性能测试以了解问题。
性能测试应被视为故障排除和发现工具。系统中总是存在让工程师感到意外的瓶颈。
避免昂贵的操作
更多的时候,性能问题是由操作的性质与实际实现之间的不匹配引起的。换句话说,资源被用于不必要的区域,这会导致过度使用资源和计算能力。如果过度消耗资源在昂贵的操作上,那么就会出现性能问题。
让我们考虑一个示例场景,该场景通过避免昂贵的操作来展示性能优化。
场景 – 对昂贵操作的迭代
想象一下有一个执行成本很高的函数。这种成本高的原因有几个:
-
这是一个远程同步调用到另一个应用程序
-
它计算成本高且/或资源密集
-
它涉及文件、数据库、消息传递、网络或其他资源
-
它可能被阻塞,直到结果返回
我们知道以下函数并不昂贵,但为了讨论的目的,让我们假装它是昂贵的:
fun someExpensiveOp(n: Int): Int = n
在这个函数之上,我们希望运行一些过滤、映射和选择:
val result = listOf(1, 7, 3, 23, 63).filter {
println("filter:$it"); it > 3
}.map {
println("expensive:$it"); someExpensiveOp(it)
}.take(2)
println(result)
首先,这段代码通过只过滤出大于 3 的数字来筛选。然后,它调用expensive
函数并得到一个新的数字。最后,只选择昂贵的操作中的前两个数字。调用println
函数以显示在filter
、map
或take
函数中评估的值。
执行此段代码会产生以下控制台输出:
filter:1
filter:7
filter:3
filter:23
filter:63
expensive:7
expensive:23
expensive:63
[7, 23]
如果这些数字大于 3,则评估所有五个数字。数字7
、23
和63
大于 3,因此它们被传递给expensive
操作。最后,只返回昂贵的操作中的前两个数字。
对于第三个数字的昂贵操作是不必要的,因为最终只选择了前两个数字。此外,它可以在过滤过程中找到前两个数字并停止检查其他值。
使用 Kotlin 标准库中的asSequence
函数优化后,代码如下所示:
val result = listOf(1, 7, 3, 23, 63)
.asSequence().filter {
println("filter:$it"); it > 3
}.map {
println("expensive:$it"); someExpensiveOp(it)
}.take(2)
println(result)
然而,执行前面的代码会在控制台打印以下内容:
kotlin.sequences.TakeSequence@246b179d
没有过滤,没有昂贵的操作或选择被运行。这是因为asSequence
函数直到有终端函数才会构建列表。让我们更新代码:
println(result.toList())
现在,执行会在控制台打印以下内容:
filter:1
filter:7
expensive:7
filter:3
filter:23
expensive:23
[7, 23]
序列操作只理解只取前两个数字,因此它寻找大于 3 的第一个两个数字并停止。数字63
甚至没有被处理。第一个大于 3 的数字是7
,所以7
被传递到昂贵的
操作。第二个大于 3 的数字是23
,所以23
也被传递到昂贵的
操作。与之前的实现相比,这种实现节省了一个昂贵的
操作。
性能改进之旅的例子
村庄中的住户已经决定运行一项调查来评估每个住户的服务。投票包括从 1 到 3 的评分:
-
1: 好
-
2: 一般
-
3: 差
一个住户可以为所有其他住户投票,但每个住户只能投一票。住户有 1 天时间提交所有投票。让我们也假设一个住户只提供一项服务。
每个住户都有一个“得分”,这是所有投票的排名数字之和。得分最高的住户成为村庄中提供最佳服务的住户。
因此,如果村庄中有n个住户,最大投票数将是n x (n- 1)
。我们需要一个系统来计算所有被投票住户的得分,并记录所有投票作为审计记录。系统还需要在投票进行时显示每个住户的非最终得分。
这个投票系统的简单架构可能如下所示:
图 12.2 – 模拟调查架构 v0.1
所有住户将他们的投票提交给投票服务进行验证。服务验证以下方面:
-
所有参与的住户都是有效的
-
一个住户不能为自己投票
-
一个住户只能对另一个住户投一次票
-
投票具有有效排名
由于投票在村庄的一天内进行,系统需要快速响应(延迟),以便在特定时间段内处理大量投票并支持大量住户(可扩展性)。
系统预期在某一时刻会有许多并发请求到投票服务,这可能会导致峰值。
可以通过添加更多资源(CPU、内存等)来垂直扩展服务。然而,在 CPU 插座数量或最大支持的 RAM 方面存在物理限制。添加更多资源也会导致收益递减,即由于其他瓶颈,性能不会成比例提高。唯一的运行实例也是单点故障,如果这个实例失败,整个系统将不可用。
或者,如果我们添加更多服务实例,系统可以水平扩展。可以部署一个负载均衡器来在服务的多个实例之间分配负载,防止任何单个实例成为瓶颈。这通过启用并行处理显著提高了吞吐量。
负载均衡器对每个实例的负载有一些了解,因此它可以路由下一个请求到负载最少的实例。这使我们能够添加更多实例来处理增加的负载。因此,架构已经改变,如图 12.3所示:
图 12.3 – 模拟调查架构 v0.2
现在,投票服务有两个状态验证规则。第一个是参与的家庭必须是有效的。第二个是每个家庭只能对其他家庭投票一次。
家庭记录经常被访问,并且可以在数据库中远程查询。在每个服务实例中缓存所有家庭记录是一种合理的策略,可以加快验证速度。
执行一条规则,即一个家庭只能为另一个家庭投票,将受益于缓存。如果我们缓存一个给定家庭已投票的家庭列表(即x-voted-by-y 列表),那么我们可以执行这项业务规则。然而,如果任何实例可以处理任何家庭,那么它意味着共享这个列表,这会带来复杂性。
我们可以考虑两种选择。第一种选择是我们可以使用如 Redis 这样的分布式内存数据库,这样x-voted-by-y 列表就可以共享,但代价是拥有一个分布式内存数据库和潜在的资源竞争。
第二种选择是配置负载均衡器,使其支持粘性路由。来自一个家庭的请求总是路由到负责的实例。每个实例都知道自己的分配,并在启动时可以本地缓存数据库中的x-voted-by-y 列表。本地缓存也会在处理传入请求时更新。
到目前为止,瓶颈已经转移到数据库,因为所有流量最终都会流入其中,每个请求只有在数据库操作完成后才能得到响应。这影响了每个投票请求的响应延迟。
需要为每个被投票的家庭计算分数。这是一个累积数,几乎没有并行处理的空间。每个经过验证的投票也需要作为审计记录保留在数据库中。
然而,由投票服务验证的投票可以进一步异步处理。每个投票可以根据被投票的家庭进行分区,因此如果家庭 1为家庭 2投票,则该投票将进入家庭 2的“桶”。
将一个家庭解析到桶中可以像模函数一样简单,即一个哈希数除以桶数的余数:
Bucket number = (hash number of household name) mod (number of buckets)
每个桶都是一个事件流。投票服务可以在将事件发布到代表该桶的事件主题后响应投票请求。投票计数、评分计算和投票持久化指标将在下游组件消费事件时进行处理。这种变化将显著降低每个投票请求的延迟。
更新的架构看起来像这样:
图 12.4 – 模拟调查架构 v0.3
这种方法有一个限制:运行时固定的桶的数量。事件已经路由到桶中,我们需要维护桶分配以正确计算分数。
下游操作最终需要在数据库中持久化结果,而我们希望避免数据库过载。让我们检查需要持久化的数据:
-
每个家庭的分数:每个家庭一个累积数;历史数字无关紧要
-
投票审计记录:每条记录都需要保留,并且每条记录之间相互独立
分数数字和投票审计记录在性质上不同,因此它们以不同的方式处理是有意义的。最好将这种临时数据保存在瞬态本地缓存中,以减少数据库负载,但定期持久化这些值。
在这里,我们可以介绍两个组件:
-
第一个组件,投票计数器,消费其分配的桶的事件流,并为它负责的家庭计算分数。它不会立即更新数据库中的分数记录。相反,它会按照固定的时间表刷新最新的分数到数据库中——例如,每 10 分钟一次。这种机制“吸收”了投票的峰值,并将其转化为常规更新。
有多个投票计数器实例,并且至少应该有两个实例消费一个桶以提供可用性。每个分数记录应包括家庭名称、投票数、分数和时间戳。应该有去重规则,只持久化较新的记录并跳过旧的记录。
-
第二个组件,投票记者,一次消费一批事件,并将更新在一个事务中刷新到数据库。如果事务失败,批次中的事件不会被确认,并将稍后再次处理。投票记者实例应配置为只有一个实例接收事件批次。批量处理显著增加了投票审计记录持久化的吞吐量。然而,它需要性能测试来发现可以随着投票数量扩展且仍在进程内存限制内处理的最佳批次大小。
考虑到所有这些性能问题,我们得到了最终的 1.0 架构,如图图 12.5所示:
图 12.5 – 模拟调查架构 v1.0
在这个架构中,我们优化了将传入请求负载均衡到多个投票服务实例以进行验证的过程。这增加了吞吐量和可伸缩性。然后,我们在每个服务实例中引入了住户和x-voted-by-y-lists的本地缓存,以加快验证过程。它还通过添加更多实例支持水平扩展。
然后,我们为每个只负责几个住户的事件流创建了一些桶。在投票服务验证请求有效后,它响应原始请求并向相应的桶发布事件流。这减少了投票请求响应的延迟:
-
投票计数器被引入以计算分配给给定桶的住户的分数。它定期将最新分数发送到数据库,并吸收峰值。
-
投票记者被引入以一次接收一批事件,并将它们在一个事务中持久化到数据库。批量处理增加了投票审计记录持久化的吞吐量。
在这个例子中,我们学习了如何优化系统的吞吐量、延迟和可伸缩性。性能改进是非常情境化的。我们绝对不应该将一个模式复制到另一个系统中并相信它将表现良好。性能需要被衡量和测试。只有当指标证明时,一个变化才被认为是性能改进。然而,一些已知的性能最佳实践可以改进,我们将在下一节中介绍。
Kotlin 的性能最佳实践
Kotlin 有一些旨在减少开销并因此提高性能的功能。然而,这些功能都有合理的依据,以便工程师可以在使用它们时做出有意识的决策。它们并不期望在没有理由的情况下被到处使用。
内联函数
内联 Kotlin 函数简单地复制到调用者以减少调用函数本身的必要性。这在存在函数调用栈较深或存在高阶函数的情况下特别有用。
内联函数可以通过在函数级别添加修饰符来声明,如下所示:
inline fun <T> measureTime(block: () -> T): T {
val start = System.nanoTime()
val result = block()
val timeTaken = System.nanoTime() - start
return result.also { println("taken: $timeTaken") }
}
使用不可变和可变数据结构
不可变数据消除了多线程环境中的锁定需求。在 Kotlin 中,List
、Set
和Map
集合使用不可变数据。
然而,如果我们正在构建一个更大的对象,例如字符串,建议使用可变集合或StringBuilder
类来避免不必要的对象创建,这可能会在 Kotlin/JVM 中触发垃圾回收。
使用协程进行异步操作
Kotlin 的协程库使程序能够调用异步操作,这样线程就不会被阻塞,可以在等待异步结果返回的同时执行其他操作。它使应用程序的资源管理更好,响应更快。
例如,想象有两个耗时的函数:
suspend fun task1(): Int {
delay(1000)
println("Task 1 completed")
return 42
}
suspend fun task2(): Int {
delay(1500) // Simulate a 1.5-second delay
println("Task 2 completed")
return 58
}
这两个函数具有suspend
修饰符,表示它们可以在不阻塞线程的情况下暂停和恢复。使用这两个挂起函数的main
函数如下所示:
fun main() =
runBlocking {
val result1 = async { task1() }
val result2 = async { task2() }
val combinedResult = result1.await() + result2.await()
println("Combined Result: $combinedResult")
}
runBlocking
函数启动一个协程,直到其执行完成才释放当前线程。在这个块中,有两个async
函数来调用两个耗时的suspend
函数。async
函数返回一个Deferred
对象,我们在这个对象上调用await
函数以阻塞,直到结果返回。两个耗时函数返回的数字相加,并将总和打印到控制台。
注意,即使考虑到性能的最佳实践,用 Kotlin 编写的代码仍需进行测量。经验结果才是唯一的证明。
接下来,我们将简要介绍超低延迟系统以及它们如何将性能和可扩展性推向极致。
超低延迟系统
超低延迟系统在微秒或纳秒量级运行。它们在一个低响应时间至关重要的环境中运行,甚至可以说是必不可少的。这些系统可以在金融交易、电信、游戏和工业自动化中看到。
这些系统旨在实现尽可能低的延迟、最高的效率、高可预测性和高并发性,以处理。它们涉及系统的各个方面以减少响应时间,例如网络优化、硬件加速、负载均衡和高效算法。
这些系统通常用系统级编程语言如 C++和 Rust 编写。然而,也有一些超低延迟系统是用 Kotlin 或 Java 编写的,它们在微秒量级运行。
Kotlin 或 Java 中的低延迟系统采用了一些不太常见的技术设计:
-
重复使用对象,避免创建对象,并避免垃圾回收。
-
使用特定的 JVM 供应商以获得更好的性能。
-
避免使用第三方库以减少开销并确保您对性能有完全的控制。
-
使用 Disruptor 模式,因为它提供了一个大环形缓冲区,用于线程间的无锁通信,并在线程中提供内存屏障以实现数据可见性。
-
为每个 JVM 进程使用单线程模型以减少上下文切换、锁竞争以及同步和并发处理的需求。
-
编写或设计对底层硬件和网络基础设施有意识且针对其优化的代码或系统。这也被称为 机械同理心。
超低延迟系统有理由打破一些设计原则(例如,不可变对象),以换取更高的性能。由于对低延迟、高吞吐量和快速响应时间的严格要求,它们是特殊情况。在开发这些系统时,性能测试至关重要,应成为常规开发活动的一部分。
开发超低延迟系统是一个专业话题,其内容超出了本章的范围。然而,有一些阅读材料可能对您有所帮助:
-
机械同理心,由马丁·汤普森撰写 (
mechanical-sympathy.blogspot.com/
) -
LMAX Disruptor (
lmax-exchange.github.io/disruptor/
) -
Aeron 消息传递 (
github.com/real-logic/aeron
)
摘要
在本章中,我们涵盖了性能和可扩展性的不同维度,并提到了一些衡量系统性能和可扩展性的基本指标。我们强调了性能测试的重要性,几种类型的性能测试以及如何规划它们。我们还提供了一个 Kotlin 中的微基准测试示例,然后讨论了使用分析器以实现更好性能的方法。
然后,我们深入探讨了性能提升的一些策略。我们考虑了一个只执行必要昂贵操作的场景。我们还观察了一个在现实情况下系统性能提升的例子。这使我们能够通过代码示例考虑一些关于 Kotlin 性能的最佳实践。
最后,我们简要介绍了超低延迟系统及其应用领域。
在下一章中,我们将讨论软件测试这一主题。
第十三章:测试
软件测试是软件开发生命周期中的一个关键部分,作为缺陷的防护措施,并提高软件产品的整体质量。质量保证(QA)的认证通常用作软件产品是否准备发布的指标。
本章深入探讨了软件测试的基本原则,探讨了其重要性、方法和最佳实践。
我们将讨论 QA 和软件测试人员在行业中的作用。我们将总结对这个角色的理解以及它可能对不同的人意味着不同的事情。
我们将探讨几种类型的软件测试和测试金字塔。此外,我们还将讨论自动化测试实践,这些实践因其提高效率和确保一致测试覆盖率的特性而受到欢迎。
我们还将通过使用 Kotest 进行严格的测试驱动开发(TDD)练习,深入了解这一方法。
本章旨在提供软件测试的全面概述,为您提供实施有效测试策略所需的知识和工具。本章将使您能够为创建满足用户期望并经得起时间考验的高质量软件做出贡献。本章将涵盖以下主题:
-
QA 的作用及其在软件开发中的参与
-
测试金字塔
-
带练习的 TDD
-
BDD
-
现场测试、A/B 测试和细分
技术要求
您可以在 GitHub 上找到本章中使用的所有代码文件:
github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-13
QA 和软件测试人员的作用
软件测试的主要目标如下:
-
在产品达到最终用户之前识别和纠正缺陷
-
确保软件产品的行为符合功能规范或业务期望
即使对于初创公司或新公司首次发布的产品,这也是至关重要的。
QA 或软件测试人员的作用可能令人困惑,并且经常被误解。就像软件架构师作为一个角色一样,QA 不一定是一个职位名称,尽管您可能在就业市场上看到过这些标题:
-
QA
-
QA 测试人员
-
QA 工程师
-
质量工程师
-
软件测试人员
-
测试工程师
-
自动化测试人员
-
测试开发工程师(SDET)
不同的组织可能对每个标题有不同的解释或期望。在本章中,我们使用 QA 一词来代表负责软件质量的工程师。
QA 的作用如图 13.1 所示:
图 13.1 – QA 的作用
强调 QA 应该是一个全职的、嵌入到由业务功能组织的团队中的参与,正如在第一章中所述。QA 与其他工程师一样,参与理解业务优先级、需求分析、测试计划创建和验收标准定义。
然而,从这一点开始,QA 的关注点与为业务开发软件的工程师不同。QA 专注于整体测试策略、测试脚本创建、测试流程和工具、用户验收测试(UAT)规划和探索性测试。
QA 的目标与开发软件的工程师的目标相似。他们都希望软件具有完整的功能,足以满足业务期望。尽管如此,QA 在实现目标时确实有不同的关注点和方法——通过确保软件按照所需的高标准开发。
编码还是不编码?
关于 QA 是否应该编码的问题往往缺乏清晰性。QA 应利用所有可用的工具和资源来满足软件质量标准。编写代码对于创建特定的测试脚本或增强工具可能是必不可少的。最终,关于 QA 是否应该编码的争论有些误导;在许多情况下,编写代码是他们角色中必要的一部分。
行业中 QA 的职位通常包括术语工程师(例如,QA 工程师),当组织期望 QA 人员编写代码时。
软件质量是每个人的责任
说软件质量是每个人的责任似乎很显然,但对于某些组织来说可能并不那么清楚。软件质量最好是从软件开发过程开始到结束都嵌入其中。
这包括软件开发生命周期中的所有活动,从明确的企业优先级到编写良好的代码,最终到业务用户签字和软件发布。这涉及到团队中的每个成员,而不仅仅是 QA。
QA 是确保软件质量在每个步骤都得到关注的角色,因此结果是高质量且经过良好测试的软件产品。
随着软件系统复杂性的增加和对稳健应用需求的增长,有效的测试策略是必不可少的。通过采用系统化的测试方法,组织可以降低风险,减少与发布后缺陷相关的成本,并培养用户信任。
通过营造优先考虑 QA 的环境,组织不仅可以提高产品结果,还可以增强团队合作和沟通。
QA 在软件开发生命周期中的参与
团队,包括质量保证人员,共同理解业务优先级。然后团队一起分析需求,并创建几个用户故事。每个用户故事代表一个工作单元,是更大业务功能的一部分,但每个故事也给业务带来一些价值。
用户故事需要细化,以便有一套验收标准,这些标准决定故事是否满足了利益相关者的期望。每个验收标准都应该是简洁且可测试的。
验收标准的惯例
验收标准可以遵循流行的给定-当-然后结构。给定提供了在执行操作之前系统状态的初始上下文。当是在给定上下文的情况下执行的操作。然后是执行操作后的预期结果。一个给定-当-然后结构中的验收标准示例如下:“给定系统中不存在家庭,当在系统中创建家庭账户时,然后相应的家庭记录 被创建。”
从验收标准开始,工程师开始进行技术设计,以确定如何进行更改以满足条件。同时,质量保证人员开始创建测试计划,以验证更改是否满足条件。
测试计划应分解为实际的测试脚本。测试脚本是一系列详细的可执行脚本,描述了如何对软件进行测试。它包括设置数据(即给定),执行操作(即当),以及验证结果(即然后)。测试脚本可以是任何格式,例如步骤文档、自动化脚本,甚至是一个独立执行程序。测试内容的重要性高于格式。
除了脚本测试之外,质量保证人员还执行探索性测试,这强调测试人员的自主性和创造力。质量保证人员可以自由地探索应用程序,在积极测试的同时了解它。通常,质量保证人员会发现系统行为不一致、漏洞或隐藏缺陷,这些缺陷无法通过固定脚本发现。探索性测试通常有时间限制。还将有一份关于发现、发现的错误、异常行为和需要进一步调查的领域的文档。这些文档通常托管在问题跟踪系统中,如 JIRA、Asana、Trello、GitHub Issues 等。
质量保证人员也参与规划用户验收测试(UAT),其中涉及业务测试人员(利益相关者和可能的实际用户)。质量保证人员帮助塑造测试过程,并负责回应业务测试人员的查询。这也是质量保证人员确认需求是否完全捕获并识别任何遗漏在范围之外的功能的机会。
除了以业务交付为重点的测试活动之外,质量保证(QA)人员还负责制定整体测试策略,以与其他团队保持一致并分享最佳实践。QA 人员还负责维护测试流程和工具。通常情况下,QA 人员会增强现有的测试框架并维护端到端测试套件。
接下来,我们将专注于测试方法,从测试金字塔开始。
测试金字塔
测试金字塔是一个概念框架,其中软件开发中的各种测试层级以层次结构的形式出现。这个概念由马丁·福勒在 2009 年的《测试金字塔》文章中推广。测试金字塔在图 13.2中展示。2*:
图 13.2 – 测试金字塔
在本节中,我们将探讨测试金字塔的所有层级。
单元测试
金字塔的底层是单元测试。单元测试是测试金字塔的基础。它们关注可以独立测试的最小构建块。它们通常测试函数的行为,并且作为本地项目构建的一部分执行。
由于规模小和范围有限,单元测试相对容易编写和执行。单元测试可以在集成开发环境(IDE)中运行,这提供了最快的反馈循环。单元测试可以在几分钟内(如果不是几秒钟内)找到并报告错误。
如果任何单元测试失败,本地项目构建通常会失败。将自动化单元测试集成到构建过程中有助于在开发早期识别错误。在单元测试中测试和修复错误是最具成本效益的,因为错误规模较小,需要较少的努力来解决,并且与其他测试阶段相比提供更快的反馈。此外,系统通常比任何其他类型的测试都有更多的单元测试,因为单元测试针对的是最小的组件,与较大的测试相比,数量更多。
单元测试应该是具有意义的
虽然单元测试是可以测试的最小构建块,但也有一些情况下,一个函数太小而无法进行测试。如果工程师难以解释测试旨在验证的内容,那么这个函数可能太小而无法进行测试。通常,私有函数不需要单元测试,但被其他包(即公共函数)调用的函数应该有单元测试。仅仅为了避免代码重复而提取的函数不太可能形成需要测试的意义。总之,单元测试应该是具有意义的。
这里是一个由 Kotest 框架驱动的 Kotlin 单元测试的例子:
class FindBiggestNumberKtTest : FunSpec({
test("Find the biggest out of positive numbers") {
findBiggestNumber(listOf(17, 18, 6)) shouldBe 18
}
})
Kotest 框架提供了许多作为规范的测试模板。FunSpec
是示例中使用的。测试用例作为 lambda 表达式传入。test
函数接受测试名称作为参数。在 TestScope
范围内的 lambda 表达式被传入以进行实际测试。这个单元测试针对 findBiggestNumber
函数,它接受一个整数列表:17
、18
和 6
。shouldBe
内联函数模仿自然英语语言,并验证预期结果是否为 18
。
参数化测试
你可能会质疑是否一个测试用例不足以彻底测试这个函数。Kotest 框架支持以下参数化测试:
class FindBiggestNumberParameterizedTest : FunSpec({
context("Find the biggest out of positive numbers") {
withData(
emptyList<Int>() to null,
listOf(8) to 8,
listOf(99, 8) to 99,
listOf(17, 18, 6) to 18,
listOf(944, 0, 633) to 944,
listOf(0, -32, 76) to 76,
listOf(-11, -32, -102) to -11,
listOf(-25, -57, 0) to 0,
listOf(
Integer.MAX_VALUE + 1,
Integer.MAX_VALUE,
0,
Int.MIN_VALUE,
-Int.MIN_VALUE - 1,
-Int.MAX_VALUE,
Int.MIN_VALUE - 1
) to Integer.MAX_VALUE,
) { (allNumbers, expectedMax) ->
findBiggestNumber(allNumbers) shouldBe expectedMax
}
}
})
对于一个接受整数列表并返回最大数字的函数,我们可以想到许多情况:
-
空列表
-
一个整数的列表
-
两个整数的列表
-
所有正整数
-
所有负整数
-
零、正数和负数的混合
-
最大值、最小值、最大值加一、最小值减一,以及这些整数的否定
使用参数化测试,可以以比我们必须将它们复制到单独的测试用例中更小的代码足迹测试它们所有。
在这一点上,你可能想查看正在测试的函数的源代码,以确保你已经覆盖了所有情况,但你需要吗?这里没有正确或错误的答案,因为它代表了两种软件测试方法:黑盒测试和白盒测试。请注意,这两种测试风格适用于金字塔中所有级别的测试。
在我们详细讨论这两种测试风格之前,让我们揭示实现:
fun findBiggestNumber(numbers: List<Int>): Int? = numbers.maxOrNull()
这是一个非常简单的实现,并使用内置的 maxOrNull
Kotlin 函数在列表中找到最大数字或空列表的 null。
黑盒测试
黑盒测试评估被测试的功能,而不了解任何内部代码或结构。测试人员仅关注输入、预期输出和声称的功能(称为合同)。
白盒测试
白盒测试朝相反的方向进行。它涉及检查正在测试的功能的内部实现。测试人员了解代码和内部逻辑,使他们能够根据实现细节设计测试用例。
比较黑盒测试和白盒测试
黑盒测试关注会影响用户体验的结果和功能。不依赖于实现也使测试人员能够发现实际行为和预期行为之间的任何差异,揭示可能没有充分定义的需求。然而,它可能会在测试套件中错过一些代码分支,这可能会阻碍完整的代码覆盖率。通常,拥有独立 QA 团队且与开发团队分离的组织会使用黑盒测试作为其默认方法。
白盒测试能够全面测试内部逻辑,导致在特定情况下发现隐藏的漏洞或缺陷。了解代码也有助于测试人员识别安全漏洞和优化机会,这些可以帮助满足非功能性需求。了解代码也可能在测试用例中引入偏见,无意中省略了可以全面覆盖外部行为和用户体验的测试用例。
在这两种风格之间也存在人为因素。一旦测试人员看到了内部实现,就很难假装之前没有看到过,并编写无偏见的黑盒测试。
两种测试风格都有其优点和缺点。由于提到的人为因素,建议首先在不了解实现的情况下编写黑盒测试,并专注于测试外部行为。之后,检查实现来编写白盒测试用例,并专注于代码分支和非功能性需求。
这将导致一个称为 TDD 的话题,将在本章后面进行介绍。
组件测试
被称为模块测试的组件测试在金字塔中位于单元测试之上。它专注于测试自包含模块的高级行为。组件测试关注于由几个代码单元的交互产生的行为。
组件测试也被包括在本地项目构建的一部分。因此,如果组件测试失败,本地项目构建也会失败。它通常从 IDE 执行,以提供快速反馈循环。
然而,组件测试更大,需要更多的努力来编写。每个测试通常涉及在测试之前设置一组状态。测试本身通常涉及多个步骤,并且通常有多个地方需要验证结果。如果发现问题,问题所在并不立即明显,需要一些时间来调试和解决问题。因此,测试和修复错误的成本高于单元测试。
组件测试的一个例子可以在提到模块化和分层架构的应用程序中找到,如第七章所述。例如,如果我们使用六边形架构,可以在核心层进行组件测试,以验证纯业务逻辑,而不受技术选择的影响。如果应用程序的边界上下文属于核心域,这如第八章所述,这尤其有用。
核心域的核心层通常被视为整个系统的“皇冠上的宝石”。它作为一切围绕其旋转的心脏。它主张使用组件测试来确保在系统中的每一次变更中,核心的纯业务行为都保持完整。
使用黑盒测试首先对核心域的核心层进行组件测试将成为行为驱动开发(BDD)方法,这将在本章后面讨论。
模拟外部资源
在编写组件测试时,几乎不可避免地会遇到代码试图与外部资源(如队列、文件、数据库或其他应用程序)集成的场景。这些集成点给测试人员带来了准备测试环境和增加编写测试工作量的负担。
模拟使测试人员能够将正在测试的组件从外部依赖项中隔离出来。有一些常见的模拟场景:
-
验证组件是否按预期与外部依赖项交互,例如检查是否调用了具有预期参数的正确 API
-
使组件测试能够在不需要外部依赖项(例如数据库)可用的情况下运行
-
验证组件是否能够按预期处理外部依赖项的失败
-
维护允许测试不同条件的状态,例如根据测试的上下文返回不同的值
这里是一个使用 Kotest 进行组件测试的模拟示例:
class ExerciseExecutorTest : BehaviorSpec({
Given("Today is sunny") {
val exerciseLog = mockk<ExerciseLog>()
val executor = ExerciseExecutor(exerciseLog)
every { exerciseLog.record(any(), any()) } returns Unit
val weather = Weather.SUNNY
When("doing an exercise") {
val now = Instant.now()
Then("running in the park") {
executor.doExercise(weather, now) shouldBe Exercise.RunInThePark
}
And("the exercise is logged") {
verify { exerciseLog.record(Exercise.RunInThePark, now) }
}
}
}
})
首先,这个组件测试使用了遵循给定-当-然后(given-when-then)格式的 Kotest 的BehaviorSpec
。它还匹配安排(Arrange)、执行(Act)、断言(Assert)(3A)的测试模式。
3A 测试模式
3A 测试模式可以在单元测试中使用。它帮助工程师和测试人员通过将测试分为三个不同的部分来组织测试。因此,测试脚本更容易阅读、理解、推理和维护。"安排(Arrange)"是测试的先决条件和输入数据的初始化。"执行(Act)"是执行被测试的行为。"断言(Assert)"是验证实际结果与预期结果是否一致。
其次,存在一个外部的ExerciseLog
依赖项,可能涉及在文件或数据库中持久化数据:
interface ExerciseLog {
fun record(time: Instant, exercise: Exercise)
}
函数记录接受一个Exercise
对象和练习完成时的相应时间:
enum class Weather {
SUNNY,
RAINY,
CLOUDY,
STORMY,
}
由于测试的重点是ExerciseExecutor
的逻辑,而不是ExerciseLog
,我们使用了ExerciseLog
接口的mockk
函数。我们设置模拟对象以接受任何参数的record
函数调用,并返回一个Unit
。
主要验证是当天气晴朗时,该函数返回由这个密封类定义的RunInThePark
:
sealed class Exercise {
data object RunInThePark: Exercise()
data object GoToGym: Exercise()
}
第二个验证是ExerciseExecutor
已将正确的参数传递给ExerciseLog
以记录此练习。以下是ExerciseExecutor
的完整实现:
class ExerciseExecutor(
private val log: ExerciseLog
) {
fun doExercise(
weather: Weather,
time: Instant
): Exercise {
val exercise = when (weather) {
Weather.SUNNY, Weather.CLOUDY -> Exercise.RunInThePark
Weather.STORMY, Weather.RAINY -> Exercise.GoToGym
}
log.record(time, exercise)
return exercise
}
}
模拟是软件测试中使用的五种测试替身(test doubles)类型之一。以下是完整的列表:
-
模拟(Mocks):这些预先编程了它们应该如何被使用的期望。它们被用来验证特定的函数是否以预期的参数被调用
-
存根:这些为函数提供预定义的响应,但不验证交互。
-
间谍:间谍记录使用的参数并计算函数调用次数。实际的函数仍然被调用。
-
模拟:这些允许在测试目的下简化外部依赖的实现。
-
模拟对象:模拟对象是一个简单的对象,仅用于满足参数要求,而不需要实现任何行为。
合同测试
合同测试:这主要关注 API 生产者和消费者之间的交互。它仅针对通信协议和消息内容。不应将其用于业务案例测试,因为我们已经有组件测试在测试金字塔的较低层次覆盖了这一点。
合同测试有两种类型:
-
消费者测试:这主要关注向其他服务发出请求的那个服务。它定义了它与生产者交互的期望,通常通过合同。它还验证消费者服务可以处理所有已记录的请求响应。消费者合同测试使用存根或模拟来设置目标服务以进行通信。
-
生产者测试:这主要关注提供其他服务所需功能或数据的那个服务。其目的是断言生产者已经履行了 API 合同并满足了消费者的期望。生产者测试可能涉及运行实际服务,这使得它似乎应该在测试金字塔的更高层次。也有可能生产者测试会模拟业务逻辑以产生合同中定义的消息和响应。生产者测试通常用于确保合同的更新和更改具有向后兼容性。
然而,重要的是让合同测试仅关注通信和消息内容。例如,openapi.yaml
文件。这导致更可靠和可维护的系统,尤其是在微服务架构中。
集成测试
集成测试:这主要关注应用程序不同组件或模块之间的交互。在金字塔中,集成测试位于合同测试之上,因为集成测试不使用存根或模拟。它们在集成系统的各个部分时识别问题,并验证这些部分是否按预期协同工作。集成测试也是本地项目构建的一部分。
集成测试通常涉及数据库、文件系统、外部服务或 API。以下是一些常见的集成测试类型:
-
API 集成测试:使用公开的 API 与应用程序进行交互,以验证给定用例的结果和响应。
-
数据库集成测试:确认数据在数据库中正确处理。这通常与创建、读取、更新、删除(CRUD)操作相关。
-
文件系统集成测试:验证应用程序能否正确地从文件中读取或写入,并验证文件反映了测试中操作的结果。
-
中间件或外部服务集成测试:验证中间件或外部服务连接的集成配置是否正确,以及应用程序和中间件或外部服务能否按预期进行通信。
集成测试比组件和单元测试更大,因为需要配置和准备。集成测试编写和推理也更复杂。集成测试可能涉及各种配置组合,例如,支持多个可插拔数据库或消息提供者,而业务功能保持不变。
一些测试可能由于外部资源或外部服务的表现而变得不确定,尤其是如果存在应用程序之外的非同步处理。
参考组件测试,如果组件测试关注六边形架构应用的 Core 层,那么集成测试则关注适配器层。
在扩展练习代码示例的基础上,我们将编写一个针对ExerciseLog
接口实现的集成测试,该接口为每次调用向文件追加一行。每行以 UTC 使用的本地日期时间开始,由冒号分隔,以锻炼的名称结束,如下所示:
2024-09-30T18:39:03.353250: GoToGym
集成测试可以编写如下:
class ExerciseExecutorIntegrationTest : StringSpec({
"Gym when cloudy and run in the park when rainy as recorded in file log" {
val file = File.createTempFile("Exer", "cise")
.apply { deleteOnExit() }
val exec = ExerciseExecutor(ExerciseFileLog(file))
val now = Instant.now()
val fourHoursLater = now.plus(4, HOURS)
val utc = ZoneId.of("UTC")
exec.doExercise(RAINY, now)
exec.doExercise(CLOUDY, fourHoursLater)
FileReader(file).readLines() shouldBe listOf(
"${now.atZone(utc).toLocalDateTime()}: GoToGym",
"${fourHoursLater.atZone(utc).toLocalDateTime()}: RunInThePark",
)
}
})
测试开始时创建一个临时文件,该文件将在退出时被删除。然后,将两个锻炼条目列表传递给ExerciseFileLog
对象。验证开始时逐行读取文件,并断言每行包含预期的内容。
ExerciseFileLog
类本身很简单:
class ExerciseFileLog(
private val file: File,
) : ExerciseLog {
val utc = ZoneId.of("UTC")
override fun record(
time: Instant,
exercise: Exercise,
) {
try {
val utcDateTime = time.atZone(utc).toLocalDateTime()
val text = "$utcDateTime: $exercise\n"
file.appendText(text)
} catch (e: IOException) {
println("error writing to the file: $file")
}
}
}
测试脚本应主要是在支持性和通用子域应用程序中作为集成测试,如第八章中所述。这是因为这些子域通常不包含很多业务逻辑,或者业务用例的组合足够简单,可以被集成测试覆盖。
端到端和自动化 GUI 测试
到目前为止,我们讨论的所有测试都集中在单个后端服务或特定的软件组件组上。下一个层次是端到端自动化测试,这包括图形用户界面(GUI)测试和契约测试。此类测试评估系统在多个服务横向和多个层级纵向上的行为。此外,它对业务利益相关者来说更加透明。
端到端和自动化 GUI 测试专注于覆盖系统中的多个服务或组件的用户旅程。例如,端到端测试可能涉及创建两个家庭记录,然后一个家庭与另一个家庭签订合同。然后,两个家庭将协商以达成一致合同,最后,他们各自将执行合同中描述的服务合同。
端到端测试使用 API 与系统的各个部分进行通信,而自动化 GUI 测试模拟人类与系统的交互。
一些系统有一套公共 API,用于与外部软件即服务(SaaS)平台集成(如第六章所述)。在这种情况下,端到端测试应确保用户旅程可以通过调用公开的公共 API 来完成。这种公共 API 集成的测试,称为无头集成,与视觉 GUI 测试一样重要。
一个用户旅程的测试脚本复杂且脆弱。它需要在环境中运行多个服务,这意味着需要稳定的基础设施。测试所有用户旅程的变体并不实际,因为测试套件需要很长时间才能完成。
此层级的测试通常仅涵盖最关键和面向用户的功能。它们通常也仅涵盖成功案例。测试是定期运行或按需进行的。如果在测试过程中发现错误,则排查错误需要更长的时间,有时可能是由于环境中的稳定性问题而不是实际的错误。
手动和探索性测试
手动和探索性测试是金字塔中的最高层级。它不是自动化的,因此需要质量保证人员手动运行测试用例。这一层级的测试是最耗时和费力的。
如果可以自动化手册,质量保证人员将尽快自动化以降低成本。有一些情况下,手动测试是必要的:
-
可用性测试:评估用户体验需要主观分析,包括视觉布局、设计和整体满意度等元素。
-
短期功能:对短期功能的测试自动化投资可能是不合理的。
-
上下文密集型测试:一些测试高度依赖于复杂的流程、交互或上下文理解。自动化这些测试以使其可靠可能超过了手动测试的努力。
-
安全性测试:许多安全评估,如渗透测试,依赖于人类的安全专业知识来识别自动化测试可能无法捕捉到的漏洞。一些测试需要安全专家快速决定下一步;这些难以自动化。
手动和探索性测试通常是基于临时执行的;然而,一些组织允许质量保证人员将探索性测试时间框定以发现隐藏的缺陷和可用性问题。
测试金字塔的好处
测试金字塔作为软件开发生成测试策略的指导原则。随着测试和错误修复在每一级变得越来越昂贵,优先考虑单元测试,然后是组件测试,一直到最后是手动测试,这是很自然的,这样团队可以实现更高效和成本效益的 QA 流程。
通过将测试用例放在金字塔的适当层级,团队不仅提高了软件的整体质量,还允许快速迭代反馈循环,从而逐步改进软件开发实践。
到目前为止,本章中所有的测试用例示例都只使用了 Kotest。然而,还有几个其他框架也可以考虑:
-
Atrium:
github.com/robstoll/atrium
-
Kluent:
markusamshove.github.io/Kluent/
接下来,我们将讨论 TDD 方法。
TDD
TDD 的历史可以追溯到 20 世纪 70 年代,当时讨论了“先测试”编程的想法。直到 TDD 成为 20 世纪 90 年代由 Kent Beck 引入的极限编程(XP)的一部分,它才变得流行。
XP
XP 是一种敏捷软件开发方法,旨在交付高质量的软件,满足不断变化的需求,并减少由于过程的不确定性带来的风险。它有五个核心价值观——沟通、简洁、反馈、勇气和尊重。它强调短迭代开发周期和开发人员与利益相关者之间的紧密合作,鼓励频繁的反馈以适应变化的需求。XP 的关键实践包括结对编程、TDD、持续集成和频繁发布小型和增量更改。
2002 年,Beck 出版了《Test-Driven-Development: By Example》一书,提供了关于 TDD 流程的详细指导,并自那时以来显著影响了大量的工程实践,甚至至今。在某些组织中,TDD 甚至已成为面试编码实践必备的技能。
TDD 使用一个简单的流程,即编写测试和生产代码,如图 图 13.3 所示:
图 13.3 – TDD 工作流程
TDD 的第一步是编写测试场景列表。测试场景是用业务语言编写的,不涉及技术实现。它描述了在特定条件下应用程序的预期行为,而不了解应用程序是如何实现它的。
从列表中选择一个测试场景,然后我们开始编写测试。这部分很有趣,因为测试用例代码通常无法编译,因为需要增强当前的 API 或创建一个新的 API。这是正常的,因为 API 合同应该从用户的需求中得出,而不是提供者。从用户的角度设计 API 自然符合我们之前讨论的接口隔离原则(ISP)。测试用例应该设置先决条件,尝试执行步骤,并验证结果。
此时,你面前的是一个要么不通过(红色)要么甚至无法编译的测试用例。下一步是更改代码,使测试通过(绿色)。确保所有其他测试也通过非常重要。这正是 TDD 的重要倡导者 Kent Beck 在他说“假装直到你做到”时提出的建议。
测试现在会通过,但你可能不会感到满意,因为代码可以被优化或组织得更好。这是你通过重构代码来提高质量的机会,同时确保所有测试继续通过。这就是为什么 TDD 另有一个名字:红-绿-重构。
可能还有更多需要编写测试用例的测试场景,或者我们可能会发现遗漏的测试场景。无论如何,这个循环会一直重复,直到没有更多的测试用例需要编写。
TDD 练习
TDD 的精髓最好在实践中体验。所以,我们将进行一个小型的 TDD 练习。
团队被要求开发一个功能,允许用户在系统中创建家庭记录。这个功能还没有编写代码。
第 1 步 - 编写测试场景列表
TDD 的第一步是编写测试场景列表。QAs 和工程师应该向利益相关者提出很多问题,并将这些答案转化为测试场景。以下是一些例子:
-
家庭记录有哪些属性?答案是一个姓氏和电子邮件地址。
-
两个不同的家庭可以有相同的姓氏吗?不可以,这是一个小村庄,所有家庭都有不同的姓氏。
-
一个家庭可以没有姓氏吗?不可以。
这是测试场景的第一个草案列表:
-
尝试创建一个姓氏为空的家庭记录失败
-
成功创建家庭
-
如果姓氏已存在,则无法创建家庭
现在我们可以选择第一个测试场景并编写一个测试用例。
第 2 步 - 编写测试用例
我们想要断言创建一个姓氏为空的家庭记录会导致失败。再次,我们使用 Kotest 框架的 StringSpec
作为测试风格:
class HouseholdServiceTest : StringSpec({
"fail to create household of empty surname" {
val service = HouseholdService()
service.createHousehold(Household(surname = "")) shouldBe Failure(
"Surname must be non-empty"
)
}
})
这个测试用例创建了一个 HouseholdService
对象,然后使用一个姓氏为空的 Household
对象调用 createHousehold
函数。测试预期该函数返回一个 Failure
对象,提供适当的失败原因。
显然,代码无法编译。HouseholdService
和 Failure
类是由测试用例虚构的。它们不存在。然而,编写这个测试用例要求我们考虑 API 应该如何构建以及用户的期望是什么。
第 3 步 - 使测试通过
您的 IDE 应该会显示非现有类和函数的编译错误。希望您的 IDE 有一个“快速修复”功能,可以为您创建类。建议首先让 IDE 创建所有类,然后创建函数,这样 IDE 就有上下文来生成带有新类的函数。这些类是空的,可能看起来像这样:
class Failure(reason: String) { }
class Household(surname: String) { }
class HouseholdService {
fun createHousehold(household: Household): Failure {
TODO("Not yet implemented")
}
}
我们已经准备好运行测试。它失败了,并显示以下信息:
kotlin.NotImplementedError: An operation is not implemented: Not yet implemented.
这是一个好的开始。测试运行了,它是红色的(即,它失败了)。现在尝试用最简单的可能实现将其变为绿色。
将函数硬编码为返回预期的 Failure
对象可能是最简单的方法,不是吗?大多数工程师都会有修复一切并使类合理的冲动。然而,这里的目的是编写尽可能少的代码来使测试通过。以下是更改内容:
data class Failure(val reason: String)
class Household(surname: String) { }
class HouseholdService {
fun createHousehold(household: Household) = Failure("Surname must be non-empty")
}
Failure
类已更改为 data
类,以便测试用例能够获取验证的原因。现在 createHousehold
函数仅返回一个硬编码的 Failure
对象以通过测试。Household
类没有变化。
目前,这只是一个临时解决方案。然而,随着更多测试用例的出现,它将不断发展。让我们选择下一个测试场景。
第 2 步再次 - 新的测试用例
下一个测试场景是成功创建一个家庭记录。家庭的姓氏不再为空,测试期望 HouseholdService
返回一个包含已创建 Household
记录的成功结果。请在此查看测试用例的代码:
"successfully create a household" {
val service = HouseholdService()
val household = Household(surname = Arb.string(minSize = 3).next())
service.createHousehold(household) shouldBe Success(household)
}
测试用例使用 Kotest 属性模块(io.kotest:kotest-property
)中的 Arb
类为 Household
对象生成一个随机姓氏。测试用例使用最小长度为三个的随机字符串作为姓氏。
再次,测试用例无法编译。Success
类不存在,createHousehold
函数也没有返回这种类型。
第 3 步再次 - 使所有测试通过
旨在让两个测试用例通过将推动代码的开发。这可以说是由测试驱动的开发。我们可以使用 IDE 功能来生成 Success
类。自己编写一个也是足够简单的:
data class Success(val household: Household)
编译错误已解决。在 TDD 循环中,我们需要运行 所有 测试。这是为了确保我们没有破坏现有的测试。测试结果显示第一个测试仍然通过,但第二个测试失败,并显示以下信息:
Expected :Success(household=example.tdd.step2_2.Household@1f958876)
Actual :Failure(reason=Surname must be non-empty)
我们需要 createHousehold
函数返回一个 Success
对象以表示成功创建,或者返回一个 Failure
对象以表示失败。最简单的方法是使用 Kotlin 的 sealed
类。工程师们可能会使用其他结构,例如以下内容:
-
Result4k<Household, String>
来自String
-
Either<String, Household>
来自String
,右边的参数是成功响应的类型
sealed
类的方法看起来是这样的:
sealed class Result {
data class Success(val household: Household): Result()
data class Failure(val reason: String): Result()
}
createHousehold
函数需要进化以处理这两个测试用例。这样做将需要移除之前的黑客实现并实现实际的验证逻辑。Household
类被改为 data
类,以便函数可以访问姓氏以执行验证:
data class Household(val surname: String)
函数已将返回类型更改为 Result
。还添加了一个简单的验证,以确保只接受非空姓氏:
fun createHousehold(household: Household): Result =
if (household.surname.isNotBlank()) {
Success(household)
} else {
Failure("Surname must be non-empty")
}
现在所有测试都通过了。然而,使用 IsNotBlank
函数可能会让你想到,应该导致失败的空字符串或只包含空格或制表符的字符串,但这些情况并未进行测试。
第 4 步 - 代码重构
我们希望通过参数化几个空字符串以及混合空格、制表符和换行符来增强第一个测试用例。Kotest 中的 DescribeSpec
更好地支持这种参数化,因此测试类被修改为继承自 DescribeSpec
。这个更改也影响了第二个测试用例,并且所有测试名称都相应地进行了更新:
class HouseholdServiceTest : DescribeSpec({
val blankStrings = listOf("", " ", "\t", "\n", " ", " \t", " \t \n ")
describe("household creation") {
blankStrings.forEach { blankString ->
it("ensures surname is not blank") {
val service = HouseholdService()
service.createHousehold(Household(surname = blankString)) shouldBe Failure("Surname must be non-empty")
}
}
it("succeeds with non-blank surname") {
val service = HouseholdService()
val household = Household(surname = Arb.string(minSize = 3).next())
service.createHousehold(household) shouldBe Success(household)
}
}
})
现在我们为第一个测试设置了一个参数化配置,覆盖了多个空字符串组合。我们现在对重复这个过程感到足够满意。
额外步骤
如果这个练习进一步扩展并选择了下一个测试场景,“如果姓氏已存在,则无法创建家庭”,那么它涉及到在某个地方保留已创建的家庭记录以提供状态验证,如果家庭已存在。为了驱动持久化实现,例如保存家庭记录,我们需要添加测试场景,如“检索由另一个家庭服务实例创建的家庭记录”。从某种意义上说,这鼓励我们编写更好和更多的测试场景。
此外,正如我们建议的,通过使用最简单的实现来使所有测试通过,我们最终得到了最简单但完整的实现,它满足了简单至上,傻瓜也能理解(KISS)和你不会需要它(YAGNI)的原则,正如在第二章中讨论的那样。
简单至上,傻瓜也能理解(KISS)原则
简单至上,傻瓜也能理解(KISS)原则是一种强调设计和实现中简单性的设计哲学。它首次出现在 1938 年的美国报纸《明尼苏达星报》上。KISS 的首字母缩略词是由首席军事工程师凯利·约翰逊提出的。KISS 原则主张系统应尽可能简单,避免不必要的复杂性。简单性提高了可维护性,减少了错误的可能性,并改善了用户体验。
TDD 旨在作为一种短周期和迭代的实践,如前面解释的 TDD 练习步骤所示。结合过程中最简单的实现,TDD 可以产生简单且自然地被测试覆盖 100%的实现。
如果 QA 和工程师在学习过程中学习功能,TDD 特别有用,因为 TDD 鼓励通过短周期迭代学习和改进测试用例和实现。
然而,对于已经建立良好的系统,严格的 TDD 方法可能并不那么有效。API 可能已经存在,并且可能只有一行代码需要更新以适应行为变化。可能直接更新现有测试以断言新行为并使它们失败,比更新实现以使所有测试通过要简单得多。可能没有必要从头开始。
接下来,我们将讨论 TDD 的兄弟——BDD。
BDD
BDD 是从 TDD 演变而来的,旨在解决 TDD 的一些局限性,例如测试用例类中充满了非技术利益相关者难以阅读的技术语法。
BDD 的概念是由 Dan North 在 2003 年提出的,当时是在讨论技术团队和非技术团队成员之间的改进协作。这也是他开始开发JBehave框架作为JUnit框架替代品的年份,强调行为而不是测试。
Gherkin语言是在那一年创建的,作为一种接近自然英语的领域特定语言。该语言旨在使非技术利益相关者更接近技术团队成员。
我们在 TDD 练习中工作的测试场景可以用以下 Gherkin 语言表达:
Feature: Household creation
Scenario: Creation of households with non-empty surnames
Given the household surname is non-empty
When the user requests to create the household
Then the household is created
Gherkin 使用简单的结构化语法来定义测试场景。主要关键字包括以下内容:
-
功能:应用程序的功能
-
场景:特定的情境或示例
-
给定:测试开始前的条件
-
当:触发行为的动作或事件
-
然后:预期的结果
-
和/但:添加额外的步骤、条件或预期结果
用 Gherkin 语言编写的测试场景需要被翻译成编程语言才能执行。Cucumber (github.com/cucumber
)是 BDD 的第一个主要工具,它大约在 2005 年开发。它可以将 Gherkin 语言中的测试场景翻译成多种编程语言的测试脚本,例如 Ruby、Rust、Java、Go、JavaScript 和 Kotlin。
规范化示例(SBE)
BDD 与规范化示例(SBE)有密切的关系。SBE 这个术语是由Gojko Adzic在他的书规范化示例中推广的,这本书于 2011 年出版。
SBE 倡导在现实场景中使用具体示例来阐明规范,并与非技术利益相关者进行沟通。这影响了用户故事的常规格式如下:“作为一个[用户],我想[功能],以便[业务价值]”。这确保了基于真实示例的清晰和可测试的规范。
用户故事进一步扩展,以包含验收标准,以确定功能是否满足用户的满意度。这些验收标准反映在测试场景中,可能是作为 BDD 实践在 Gherkin 语言中。
采用 SBE 和 BDD 有几个影响。测试场景是用 Gherkin 语言编写的,所使用的词汇应与通用语言一致,正如在第八章中讨论的那样。其次,可读性强的测试场景强烈暗示黑盒测试是主要方法。
最后,许多使用敏捷方法的团队甚至使用 SBE 和 BDD 来改进他们的需求收集和测试流程。从某种意义上说,SBE 的具体示例和 BDD 的测试场景成为非技术利益相关者对功能理解的事实上的协议。
Kotlin 中的 BDD 采用
BDD 至今仍被许多团队积极采用。许多 Kotlin 工程师仍在使用 Cucumber 作为他们的 BDD 工具。然而,有些团队有意识地决定不使用 Gherkin 语言来定义测试场景。
Kotlin 语言比其前身 Java 更加简洁和简洁。Kotlin 提供了大量的语法支持和语法糖,以简化代码,提高可读性。
使用现代测试框架,如 Kotest、Spek (www.spekframework.org
) 和 Kluent (github.com/MarkusAmshove/Kluent
),可以编写出可读性强的基于 Kotlin 的测试脚本,这些脚本在很大程度模仿了 Gherkin 格式的测试场景。
它减少了引入翻译层的需要,这有时会在测试期间引入错误。它也是阅读测试场景的好处和将 Gherkin 测试脚本转换为 Kotlin 的成本之间的平衡。
然而,在敏捷开发过程中始终考虑 BDD 和 SBE 是有益的,因为它在理解用户需求的过程中与非技术利益相关者进行有意义的对话。
在生产环境中进行了一些类型的测试。他们需要在面向客户的环境中运行的理由,我们将探讨这些理由背后的原因。
现场测试、A/B 测试和细分
现场测试不能替代在较低环境中进行的其他类型的测试。每种现场测试都有其独特的作用,因为它只能在现场环境中执行。
发布后测试
一些系统与不提供测试环境的第三方系统集成。工程师通常会通过在较低环境中运行模拟器来减轻这种风险。模拟器是一个假组件,它运行简化的逻辑,仅为了模仿目标外部系统。工程师依赖于文档或第三方公司的信息来实现模拟器。
这种方法并不理想,但比在较低环境中没有东西来检测缺陷要好。这种方法伴随着一些风险:
-
模拟器逻辑需要紧密遵循外部系统更改的步骤。否则,它会产生不一致的时间差距。
-
外部系统可能在不通知团队的情况下发布其更改,导致系统故障并需要热修复。
-
工程师必须确保模拟器永远不会在生产环境中运行以创建虚假数据。数据损坏和修复的成本非常高。
-
尽管采取了所有安全措施,外部系统在发布后可能仍然不可用。因此,系统只能部分运行。
无论是否存在外部系统集成测试环境,一些关键任务系统,如金融交易系统,都会进行“测试交易”,以最小金额确保关键功能正常运作且对应数据正确。
A/B 测试和细分
一些测试由于 QA 以外的其他原因在生产环境中运行了更长的时间。A/B 测试和细分执行是为了发现市场和机会。
一些组织会将用户至少分成两组。细分可以通过以下方式进行:
-
无状态算法
-
用户数据,例如人口统计信息或偏好
-
用户自愿注册
-
随机和粘性分配
-
手动分配到小群体
每个组都有不同的用户体验,并设置了指标来衡量业务指标,如页面着陆次数、购买统计和客户满意度。这是一个典型的细分设置:
-
对照组:原始体验;比较的基线
-
变体组:修改后的体验
通过进行 A/B 测试,组织可以收集有关用户和市场的有用信息。收集的数据提供了对哪些用户体验导致更好结果的定量视角。这通过经验证据提供了对真实用户行为的洞察,并促进了假设检验和数据驱动决策的文化。
一些 A/B 测试可能只能运行有限的时间,只是为了收集足够的数据进行分析,而另一些则可能运行很长时间以进行持续改进。一些组织甚至会同时运行多个 A/B 测试,但这在执行统计分析时会导致指数级复杂性的增加。
摘要
在本章中,我们讨论了质量保证(QA)在软件开发周期中的作用和参与。我们深入探讨了测试金字塔,通过代码示例探讨了每一层,并提到了测试脚本中使用的某些技术,例如黑盒和白盒测试、模拟和参数化测试。
我们探讨了测试驱动开发(TDD)的概念。我们通过使用真实生活例子,进行小而频繁迭代的 TDD 练习。
我们讨论了行为驱动开发(BDD),它是 TDD 的近亲。我们详细介绍了它的历史以及它是如何从 TDD 演变而来的。我们还介绍了 SBE,它与 BDD 实践紧密合作。最后,我们简要讨论了 Kotlin 中 BDD 的现代采用情况。
我们还简要介绍了在实时环境中执行的一些测试类型和示例及其背后的原因。
下一章将涵盖软件系统的一个重要方面——安全性。
第十四章:安全
本章涵盖了与系统安全相关的根本原则和实践,重点关注影响软件架构和工程师日常生活的方面。
它首先定义了关键概念,如机密性、完整性和可用性(CIA三要素),这些构成了安全策略的基石。本章概述了几种威胁类型,包括恶意软件、钓鱼和内部攻击,强调了进行全面风险评估和管理的重要性。
接下来,它探讨了各种认证方面及其如何影响软件功能的工程设计,例如多因素认证(MFA)。然后,我们将讨论如何使用访问控制确保只有授权用户可以访问特定资源。
此外,本章还讨论了遵守法律和监管要求,如通用数据保护条例(GDPR)和健康保险可携带性和问责法案(HIPAA),并塑造安全实践。此外,我们将探讨一些处理系统中机密数据的方法。
我们将深入探讨网络安全如何塑造软件架构,例如安全层、加密和安全的 API 设计,这些设计可以保护系统免受恶意攻击。
最后,我们将通过一个真实案例进行威胁建模练习。
我们将涵盖以下主题:
-
认证
-
授权
-
处理敏感数据
-
网络安全
-
DevSecOps 和威胁建模
技术要求
您可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-14
软件架构中安全的重要性
系统安全指的是实施的过程、措施和实践,旨在保护信息系统免受未经授权的访问、滥用、损害或中断。它包括一系列旨在保护系统内数据和资源机密性、完整性和可用性的机制。
系统安全包括多个维度,包括硬件、软件、政策和人为因素,以对抗恶意威胁和漏洞。
软件系统通常处理敏感信息。敏感数据可以分成几个类别:
-
个人身份信息(PII):全名、出生日期、地址、电话号码和个人电子邮件地址
-
个人信息:健康保险政策、医疗检测结果、治疗记录、处方、教育证书、成绩单、大学成绩和学号
-
认证凭证:密码、PIN 码、安全问题和答案以及指纹
-
财务信息:银行账户详情、信用卡号码、税务申报、财务报表和收入信息
-
机密商业信息:客户名单、商业计划、商业机密和内部通讯
-
法律文件:合同、诉讼文件和和解协议
-
知识产权:版权、专利、商标、源代码、用户活动历史、系统数据和专有算法
-
政府和国家安全信息:政府合同、情报报告和机密文件
确保软件架构的安全性可以保护敏感数据免受未经授权的访问、泄露和破坏,维护机密性和隐私。
安全措施在整个数据生命周期中保护数据的完整性,防止未经授权的修改。这可以保证用户接收到的信息准确可靠,这对于决策和信任至关重要。
一个安全的软件架构旨在抵御恶意攻击,并确保合法用户能够获得服务。这对于维持业务连续性、建立积极的声誉、培养用户满意度和信任以及提供竞争优势至关重要。
许多行业都受到关于数据保护和隐私的法规的约束,例如欧盟的 GDPR 和美国 HIPAA。符合这些法规的软件架构可以保护组织免受法律处罚和声誉损害。
安全应成为软件架构设计早期阶段的一部分。设计过程包括识别潜在威胁和漏洞。积极应对安全风险可以减少从第一天开始的安全事件发生的可能性。在发生漏洞之前实施安全措施也比之后更经济有效。与法律费用、补救措施和业务损失相关的财务损失可能非常巨大。
安全威胁不断演变,一个精心设计的软件架构能够灵活地融入新的安全技术和实践。这种适应性和可扩展性对于保持对潜在漏洞的领先地位和维持强大的安全态势至关重要。
以安全为重点的架构鼓励将安全编码实践整合到软件开发周期中,例如DevSecOps。这种文化转变增强了开发团队的整体安全意识。
一个安全系统依赖于日志记录、监控和警报机制,这些机制有助于快速检测和响应安全事件,正如在第十一章中讨论的那样。这对于减少损害和有效恢复漏洞至关重要。
通过从一开始就优先考虑安全性,组织可以构建具有弹性的系统,这些系统能够不仅防御当前威胁,还能适应未来挑战,从而培养信任并增强其整体商业成功。安全性的最被讨论的基础之一是 CIA 三要素,我们将在下一部分介绍。
CIA 三要素
CIA 三要素是信息安全的基础模型,概述了信息安全三个核心原则:机密性、完整性和可用性。它最初在 1972 年的《计算机安全技术规划研究》中提及,也称为安德森报告,由威廉·安德森领导的团队撰写。该概念随后在 20 世纪 80 年代由弗雷德里克·科恩在其著作《计算机安全实践者的方法》中讨论。术语CIA 三要素后来由史蒂夫·利普纳在 1986 年左右提出。CIA 三要素在 1993 年由威利斯·沃尔从其在美国智库研究与开发公司(RAND)分发的研究中推广。从那时起,它在网络安全领域获得了普及。
CIA 三要素如图14.1所示:
图 14.1 – CIA 三要素
我们将在以下部分简要介绍每个原则。
机密性
机密性确保敏感信息仅对授权的个人或系统可访问。这包括将数据远离具有恶意意图的恶意行为者。组织内的个人也受到数据访问的限制。
维护机密性的常见方法包括以下内容:
-
身份验证:确认用户、设备或系统的身份。主要目标是确保试图访问系统的实体是其声明的身份。
-
授权:授予或拒绝已验证身份对系统内特定资源、操作或数据的访问权限。
-
加密:将可读数据转换为编码数据以防止未经授权的访问。即使未经授权的个人获得了加密数据,他们也需要拥有密钥并知道解密算法才能解锁数据。
-
编辑:在不暴露敏感信息的情况下保留功能性和可用性。
其中一些方法将在接下来的部分中使用和讨论。
完整性
完整性指的是数据在其整个生命周期中的准确性和一致性。这一原则确保信息不会被未经授权的用户更改或篡改,并且保持准确、可靠和可信。
确保完整性的常见技术包括以下内容:
-
校验和与哈希函数:通过算法从数据计算出的固定长度字符串。校验和或哈希值通常与数据本身一起提供。任何对数据的篡改都会产生与原始校验和不同的校验和(或哈希值),因此系统会将其检测为损坏的数据。
-
数字签名:文档由发送者使用其私钥生成的哈希值进行签名。接收者收到文档及其数字签名。接收者使用相同的算法计算文档的哈希值。接收者使用发送者的公钥解密数字签名并检索原始哈希值。两个哈希值相同确认文档未被修改。发送者的私钥和公钥形成一对,其中私钥仅发送者知道,公钥对任何人都是可用的。
-
版本控制:版本控制系统维护对文档所做的更改的完整审计跟踪和历史记录。如果检测到错误或损坏,可以将文档回滚到先前的版本。
可用性
可用性确保在需要时信息资源对授权用户是可访问的。这一原则侧重于保持系统功能并最小化由于攻击、故障或其他中断造成的停机时间。
提高可用性的策略包括以下内容:
-
冗余:通过拥有额外的组件和替代路径来避免单点故障,以确保在发生故障时持续运行和保持数据完整性。
-
负载均衡:将传入流量分配到多个服务器,以确保处理传入请求。
-
定期备份:在多个服务器或位置维护数据库、文件存储和消息存储的副本,以确保数据可用性并从数据问题中恢复。
-
灾难恢复计划和演练:概述在灾难期间采取的具体步骤以恢复系统并使其运行。定期进行灾难恢复演练以验证计划并识别差距。在灾难期间建立清晰的沟通渠道,以保持工程师和利益相关者的信息。
重要性:CIA 三角模型
CIA 三边模型作为组织发展和实施有效安全政策和实践的指导框架。这三个原则帮助组织创建一个全面的信息安全方法,并保护业务免受各种威胁和漏洞的侵害。
我们将深入探讨基于 CIA 三边模型的选择性主题。
认证
认证是在授予资源访问权限之前验证用户或设备身份的过程。这是建立双方之间信任的第一步。一个简化的认证过程看起来像 图 14.2:
图 14.2 – 认证交互的简化表示
客户端(用户、设备或系统)与目标系统启动认证,并提供凭证以声明其身份。系统接收凭证并开始验证过程。如果系统能够识别客户端,则确认结果为阳性,否则将向实体发送拒绝。
这种交互仅仅是概念性的,因为实际的认证需要考虑很多方面。首先,传输层需要被保护以确保没有窃听,也称为中间人攻击(MitM)。
MitM 攻击
中间人攻击是一种网络攻击,攻击者秘密地拦截并转发两个当事人之间的通信。攻击者窃听并捕获可能包含敏感信息的通信。攻击可能会更改交换的消息或记录它们以进行进一步的恶意目的,例如身份盗窃和金融欺诈。中间人攻击可以以各种形式发生,例如拦截未加密的 Wi-Fi 流量、利用安全通信中的漏洞或诱骗用户连接到恶意网络。这种攻击威胁到个人和组织之间通信的机密性和完整性。
传输层安全(TLS)
传输层安全(TLS)在网络通信安全中发挥着重要作用,作为对如我们刚才提到的中间人攻击等威胁的防御。
TLS 是从其前身安全套接字层(SSL)演变而来的。SSL 最初由 Netscape 于 1994 年开发,旨在提供互联网上的安全通信协议。由于安全漏洞,它从未发布。随后,发布了 SSL 2.0 和 SSL 3.0,但它们仍然面临着需要进一步发展的漏洞。
TLS 首次于 1999 年发布。它基于 SSL 3.0,但解决了 SSL 的弱点,并提供了对现代加密算法的更好支持。TLS 的后续版本在安全性和性能方面带来了重大改进。TLS 广泛用于各种方式,以在互联网上确保通信安全,例如安全网页浏览、电子邮件内容加密、安全消息、虚拟私人网络(VPNs)和安全的系统间通信。
TLS 要求客户端和服务器交换几轮消息以建立安全的传输通信。TLS 建立在传输控制协议(TCP)之上。最初的几个步骤是交换消息以建立 TCP 连接。之后,有几轮消息交换以建立 TLS 通信:
-
客户端问候:客户端通过向服务器发送一条消息来启动 TLS,该消息指定了支持的 TLS 版本、密码套件(称为加密算法)以及客户端生成的随机数。
-
服务器问候:服务器通过选择 TLS 版本和密码套件以及服务器生成的随机数来响应。
-
服务器证书:服务器发送其包含服务器公钥并由受信任的证书机构(CA)签名的 TLS 证书。某些加密套件要求服务器包含额外的密钥交换参数。
CA
CA 是一个受信任的实体,它向个人、组织或设备颁发数字证书,以验证在安全通信框架内的身份。CA 通过将公钥绑定到其所有者的身份,在公钥基础设施(PKI)中扮演着至关重要的角色,使 TLS/SSL 等安全协议能够进行加密通信。它们在颁发证书之前进行身份验证,通过根证书和中间证书维护信任链,并管理证书吊销以确保持续的安全性。通过提供这个信任基础,CA 使用户能够自信地在互联网上进行通信和交易。
-
服务器握手完成:服务器确认其握手部分的完成。
-
客户端密钥交换:客户端生成一个预主密钥,这是一个没有有意义数据的随机字符串,使用从服务器证书中接收到的服务器公钥对其进行加密,并将其发送到服务器。
-
会话密钥生成:客户端和服务器使用预主密钥以及之前交换的随机数来生成会话密钥。这些密钥将用于会话期间的数据加密。
-
Finished
消息,表示握手已完成,他们现在将开始使用会话密钥进行安全通信。
交互过程如图14**.3所示:
图 14.3 – TLS 握手
在 TLS 握手过程中,客户端和服务器独立生成仅适用于该会话的会话密钥。任何会话外的消息重放都是无效的,并且将被检测到。会话密钥永远不会存在于交换的消息中。
此外,如果中间人攻击者想要解密应用程序数据的内容,它需要对称的会话密钥。会话密钥是由双方生成的随机数和预主密钥形成的。预主密钥只能由服务器的私钥解密,该私钥永远不会在任何消息中暴露。换句话说,中间人攻击者无法读取加密的应用程序数据。
此外,每个交换的消息都包含一个序列号。任何顺序错误的消息都将被检测到,并且不会进一步处理。每个消息还包含一个消息认证码(MAC),它就像一个校验和,用于确认数据在传输过程中是否被更改。
TLS 使用受信任的 CA 颁发的数字证书来验证服务器的身份。这防止了中间人攻击,其中攻击者冒充服务器。在双向 TLS 的情况下,客户端也可以出示证书以证明其身份,从而进一步增强安全性。
在这一点上,我们相信我们已经在客户端和服务器之间建立了安全的传输。然而,我们仍然需要验证客户端的身份以授权资源和允许的操作。我们将在下一节进一步讨论如何验证客户端。
多因素认证(MFA)
简单的身份验证只需要一个这样的证据(称为因素),通常是密码。这种方法有几个弱点,使其不足以保护敏感信息:
-
弱密码:容易被攻击者猜到
-
钓鱼攻击:攻击者诱骗用户泄露他们的密码(例如,伪造的登录页面)
-
密码重用:如果一个密码被泄露,攻击者可以使用相同的密码访问其他服务
-
暴力破解:自动化工具可以不断猜测一个密码
-
社会工程学:操纵用户共享密码
-
无用户验证:密码验证仅检查提供的凭据是否与记录中的凭据相同,而不是验证试图获取访问权限的人是否是实际用户
多因素认证旨在通过验证多个因素来强化身份验证过程,进一步验证用户是他们所声称的人。这些因素分为三个类别:
-
知识因素:密码、安全问题和答案、PIN 码或 ID
-
持有因素:智能手机、硬件令牌、身份验证应用程序和一次性密码
-
生物识别因素:指纹、面部和声音
至少,多因素认证需要来自不同类别的至少两个因素。攻击者从目标用户那里获取多个因素的可能性较小,因此它降低了未经授权访问的风险。此外,一些行业,如银行,有监管要求强制使用多因素认证来保护敏感数据。
值得指出的是,多因素认证不必要地需要 TLS,但强烈建议使用 TLS 进行多因素认证,以确保在多因素认证过程中没有窃听和拦截,尤其是在传输敏感信息(如生物识别数据和密码)时。
多因素认证对软件架构的影响
多因素认证(MFA)在日常生活中的普及,例如在线银行和电子商务,已经影响了现代软件架构。迫切需要专门的服务来专注于身份验证并从业务逻辑中分离出来。这是由于身份验证过程的高度复杂性和处理数据的敏感性。最好有一个专门从事身份验证的服务,将敏感数据保持在其边界范围内以降低风险。
与第六章中讨论的无服务器云计算的兴起一起,每个在云中运行其服务的组织都必须选择以下之一:
-
云提供商原生的 身份提供者(IdP)(例如,Azure 的 Active Directory,GCP 的 Google Identity,AWS 的 Cognito)
-
平台独立的 IdP,例如 Okta,Auth0 和 Duo Security
-
编写自己的 身份和访问管理(IAM)服务,可选地作为其他 IdP 的代理
-
无需认证
现在,很少看到系统跳过认证。有一些情况,例如公共网站、匿名调查以及低风险功能,如计算器,可能不需要认证。
根据组织的需要构建一个定制的 IAM 服务可能是合理的。除非组织本身专门从事安全解决方案,否则 IAM 服务属于通用的子域,如第 第八章 中所述。大多数第三方 IdP 提供了组织所需的足够认证能力。如果有任何特定于组织的理由,建议构建一个 IAM 服务作为第三方 IdP 的代理。如果第三方 IdP 提供了目前定制的全新功能,这种方法可以最小化功能迁移的影响。
MFA 的示例交互
假设用户想要登录系统以从智能设备上的应用程序请求操作,消息交换可能看起来像 图 14。4:
图 14.4 – 带有 MFA 的示例业务请求
这只是一个示例交互,因为通信因不同的 IdP 和认证中涉及的不同因素而异。在这个示例中,IdP 为组织提供了一个定制的白色标签页面。
白色标签页面
白色标签页面是一个可定制的网页或应用程序,可以被重新品牌化并由不同的组织使用。屏幕针对目标组织进行了主题设计,并隐藏了原始组织的品牌。这对于 SaaS 产品来说是一个常见功能,允许与任何组织无缝集成,同时保持一致的用户体验。
用户登录并使用 HTTPS over TLS 提供用户名和密码。用户名和密码由冒号连接成字符串:
username:password
字符串随后被编码为 Base64 并作为 HTTP 基本认证的一部分传递到 HTTP 标头。由于它是 HTTPS,因此标头也是加密的。登录请求等同于以下命令:
curl -i http://api.example.com/api/sign-in \
-H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ="
这些数据构成了认证的知识因素。
IdP 确认用户名和密码确实存在且匹配,然后向客户端发出一个 一次性密码(OTP)的挑战。OTP 然后通过 短信服务(SMS)发送到用户的手机号码。拥有手机的用户会收到 OTP 并输入密码。密码从客户端发送到 IdP 以解决挑战。这是认证的占有因素。
身份提供者(IdP)验证 OTP 是否正确。如果错误,IdP 会向客户端响应并透露身份验证失败。如果正确,IdP 会生成一个JSON Web Token(JWT)作为访问令牌并发送给客户端作为响应。随后,IdP 还会通过 webhook 通知服务器用户已成功通过身份验证。
JWT是一个加密的 JSON 字符串,用于携带关于用户角色和权限的声明。它由三部分组成:
-
标题: 令牌的类型为“JWT”以及用于加密令牌的算法。
-
有效载荷: 注册的声明,如发行者、受众、过期时间和 ID。还包含公共声明,如应用程序用户角色,以及仅用于使用令牌的各方之间共享的私有声明。
-
签名: 发送者身份的证明以及整个 JSON 字符串未被修改的证据。
身份验证成功响应还会触发屏幕重定向到用户登录页面,以便用户开始进行业务请求。
经身份验证的用户发起业务请求。客户端将带有附加到 HTTP 头部的访问令牌作为Bearer
令牌发送请求到服务器:
curl -i http://api.example.com/api/business-request \
-H "Authorization: Bearer mytoken123"
服务器从客户端接收请求以及Bearer
令牌。服务器与身份提供者(IdP)验证令牌是否有效。身份提供者(IdP)确认令牌有效,因此服务器处理请求并向客户端响应。
客户端可以继续使用Bearer
令牌进行其他操作,无需进一步身份验证,直到会话结束。在整个会话期间,身份提供者(IdP)控制令牌的有效性。根据请求,令牌也可以由身份提供者(IdP)刷新。
令牌交换和刷新流程是行业标准授权协议的一部分,OAuth (oauth.net/2/
)。
OAuth
OAuth是一个授权框架,它允许第三方应用程序在不暴露凭证的情况下获取对资源的有限访问。用户通过各种授权类型(如授权代码、隐式和客户端凭证)从一种服务访问另一种服务的数据。用户向授权服务器提交身份验证请求,授权服务器随后为访问资源服务器上受保护的资源颁发访问令牌。OAuth 强调灵活性,并被广泛采用以实现现代应用程序中的安全委托访问。
此示例交互还允许使用Bearer
令牌对使用相同身份提供者(IdP)的其他系统进行身份验证。由于用户已经通过身份提供者(IdP)进行了身份验证,因此使用相同身份提供者(IdP)的其他系统可以以相同的方式验证用户的身份。这种机制实现了单点登录(SSO)体验,用户只需进行一次身份验证即可访问共享相同身份提供者(IdP)的应用程序和系统集合。
步进式身份验证
有时,经过认证的用户需要执行超出其初始认证的额外验证。这一额外步骤被称为提升认证。
提升认证通常适用于故意选择的情况,当涉及访问敏感信息或高风险操作时,例如更新密码、进行银行转账或将账户数据移动到不熟悉的设备。
提升认证也可以根据访问请求的上下文动态应用,例如新的地理位置、新设备、新银行账户等。
最简单的提升认证方法是重复在 TLS 通信下相同的 MFA 过程。一些公司可能会故意增加更多的挑战,例如 CAPTCHA 或生物识别验证。
通过实施提升认证,组织可以显著降低未经授权访问的风险,同时保持低风险活动的无缝用户体验。
我们竭尽全力确保系统已验证进入系统的实体的身份。现在实体已登录系统,下一个问题是:实体可以在系统中访问和执行什么?我们将在下一节中探讨这个问题。
授权
授权决定了用户或实体可以访问哪些资源以及可以执行哪些操作。用户经过认证后,只能与明确授予权限的数据和功能进行交互。这限制了敏感信息只对系统已知并维护其完整性的个人。
授权符合最小权限原则(PoLP),也称为最小权限原则(PoMP)或最小权限原则(PoLA)。PoLP 指出,用户、实体或系统应仅具有操作其功能所必需的权限。这最小化了风险并限制了可能来自意外或恶意行为(我们可能没有预料或了解)的潜在损害。
此外,有意识地授予特定资源和操作的权限可以提高问责制并符合安全策略。坚持已知信息可以促进更安全的感知。
正如我们简要提到的,授权有两个要素:数据和操作。它们在授权中是不同的概念,可以不同地管理:
-
数据权限:用户是否有权访问特定的数据或资源
-
权限:用户在资源或数据上允许执行的操作
例如,用户有权查看自己的用户账户。管理员角色的用户有权查看其他用户的账户。然而,用户必须首先被授予读取用户账户的权限。用户被授予写入权限以更新自己的用户账户。然而,管理员角色用户可能没有更新其他用户账户的写入权限。
实施授权有四种典型方法:
-
基于角色的访问 控制 (RBAC)
-
基于属性的访问 控制 (ABAC)
-
访问控制 列表 (ACLs)
-
基于策略的访问 控制 (PBAC)
我们将深入探讨这些方法。
基于角色的访问控制 (RBAC)
RBAC 方法将用户分配到角色,每个角色都分配了特定的权限。用户根据组织内分配的角色获得权限。这如图 图 14**.5 所示:
图 14.5 – RBAC 的一个示例
在前面的 RBAC 配置中,Alex 和 Taylor 被分配了 客户服务支持 角色,该角色被授予执行基本级别操作(如更新客户的订单)的权限。更高级的操作,如退款订单,只能由具有 客户服务 主管 角色的人如 Sam 执行。
RBAC 在需要不同访问级别的不同工作职能的公司中普遍使用。它比逐个分配权限更有效。在员工休假而另一位员工临时接管职责,或新员工需要与队友相同级别的访问权限的情况下,它也很有效。
访问控制列表 (ACLs)
ACL 授予一组用户或用户组访问特定资源的权限,并定义允许哪些操作。这种方法适用于授予可以精确指定的资源权限,例如以下内容:
-
文件系统: 文件夹、读写操作和文件名
-
HTTP 路径: 统一资源标识符 (URIs) 和 HTTP 方法
-
网络: IP 地址、端口和传输层协议
-
数据库: 模式、表和操作
一些 ACLs 也支持通配符匹配,以实现更灵活的配置。这种方法适用于在细粒度级别管理资源权限。
基于策略的访问控制 (PBAC)
PBAC 基于预定义的策略授予权限,这些策略考虑了各种标准,例如用户角色、属性和上下文。
这些策略是预先定义的,但它们支持参数。它们通常由 GUI 工具管理或设置为声明性配置。授权工具本身可能是定制的,但策略的维护不需要代码更改。
策略可以是细粒度和高度可配置的。它们也可以在运行时更改,而无需部署或重启。在需要根据实时条件或合规性要求调整访问需求的环境中,它们非常有用。
PBAC 对于符合监管要求非常有效,因为定义良好的策略可以被视为合规政策的技术实现。
基于属性的访问控制(ABAC)
ABAC 授予与用户、资源和环境相关的属性权限。它提供了最灵活的方式来控制权限,这些权限可能对上下文敏感,例如时间和地理位置。
例如,只有客户服务主管可以更新完成的客户订单,但开放订单可以由客户服务支持和主管共同更新。
这种方法适用于复杂的环境,其中访问决策取决于多个、通常是动态的因素,例如用户属性(例如,部门、清查级别)、资源属性(例如,敏感性)、访问请求的时间、环境配置等。控制可以细粒度到所需的程度。
由于其高度灵活性,ABAC 通常被编写为代码而不是配置。其中一些 ABAC 策略甚至可能涉及权限继承的概念,无论是在用户级别还是资源级别。实现可能会变得复杂。
将 ABAC 编写为代码也带来了测试和维护的挑战。ABAC 代码中的错误可能导致资源泄露给未经授权的人员。
授权对软件架构的影响
授权对软件架构的影响有几个维度。微服务、纳米服务和 FaaS(如第六章所述*)本身需要访问基础设施资源,如数据库和文件系统。
如果它们经常在云提供商下运行,则 RBAC 和 ALCs 可以用于定义和限制组件可以访问哪些资源以及允许哪些操作。更常见的是,组织会使用云原生 IdP 来管理应用程序使用的基础设施权限和数据权益。
正如我们在讨论 PoLP 时提到的,应用程序应授予恰好足够的权限和数据权益来执行其操作,但不应更多。
如果需要将特定资源的写访问权限(例如,数据库模式)授予多个边界上下文,那么这可能是不明确或泄漏的边界上下文的迹象。对涉及的边界上下文进行架构审查将有助于了解需求并检查应用程序边界的改进是否可行。
最终用户、外部系统和其他设备需要访问基础设施和应用数据。有一些用例需要它们访问基础设施资源。
用例 – 提交报销请求和批准
在一个组织中,所有用户都有权提交报销请求以进行业务授权:“报销请求和批准”。财务操作团队负责审查这些请求并决定是否批准或拒绝请求。
在这个设置中,所有用户都有用户角色。该角色被授予访问以下端点的权限:
-
POST /expense
:这是为了提交新的报销请求 -
GET /expense/{id}
:这是为了查看给定 ID 的报销请求
一些用户被分配到财务操作角色。该角色被授予访问以下端点的权限:
-
GET /expense/{id}
:这是为了查看给定 ID 的报销请求 -
PATCH /expense/{id}/result
:这是为了批准或拒绝给定 ID 的报销请求
相应的权限控制可以使用 RBAC 和 ACL 进行配置。大多数身份提供者(IdP),包括云原生中间件,都可以支持此设置。
集中式或分布式授权逻辑?
工程师们经常问,当与应用程序数据相关时,授权逻辑应该是集中式还是分布式。
这不是一个简单的“是”或“否”的问题。它取决于授权逻辑的性质。在基本层面上,请求应用程序数据记录的用户应该被授予读取给定类型数据的权限。如果是 RBAC 或 ACL,有一个集中的服务来确认用户 X 可以读取类型为 Y 的应用程序数据是合理的。
或者,权限或角色可以作为 JWT 自定义声明的一部分进行编码。这种方法减少了处理权限所需的时间。然而,这也意味着授予的权限可能会持续到 JWT 过期,使得动态授权变得更加困难。
然而,当决定用户是否有权查看特定应用程序的数据时,事情变得复杂,这是关于用户 X 是否可以读取类型为 Y 和 ID 为 123 的应用程序数据的权益问题。回答这个问题通常需要特定领域的知识,这些知识应该保持在有限范围内,换句话说,是分布式的。
此外,对应用程序数据的其他操作意味着用户最初可以读取数据。权限、数据权益和业务逻辑的交织逻辑显著增加了操作复杂性。在与第三方 API 集成的情况下,数据授权分布在多个服务中,这更加复杂。
使用上一节中提到的报销请求和批准的相同示例,权限和数据权益业务流程的示例交互看起来像图 14.6中的图表:
图 14.6 – 权限和数据权益业务流程的示例交互
担任财务操作角色的用户需要查看费用报销请求以决定是否批准或拒绝它。因此,客户端的第一个请求是按照给定的 ID 获取费用报销请求。
相应的服务,即费用服务,会对专门处理用户权限的授权服务进行同步调用,以检查给定用户是否有权限读取费用报销请求。
授权服务通过其角色解决用户的权限,并返回用户被授予的操作列表。费用服务接收被授予的操作列表,并且在此基础上,检查用户是否可以看到这个特定的费用报销请求。
可能存在特定领域的业务规则,例如,费用报销请求只能由同一地理管辖区的用户批准。费用服务还会进一步筛选可以对这笔费用报销请求执行哪些操作。例如,如果它已经被批准,那么批准或拒绝的操作将不再可用,这是由于业务逻辑,而不是由于权限。
结合权限和数据权益的结果,如果用户不允许查看费用报销请求,则向客户端返回 HTTP 状态码 403(禁止)。否则,返回 HTTP 状态码 200(OK),并附带用户在此费用报销请求上可用的操作列表的有效负载。
如果用户能否读取费用报销请求,这两个服务本可以只返回一个二进制答案。然而,返回一个授权操作列表允许该列表被填充到前端应用程序中。前端应用程序可以启用和禁用按钮,以给用户明确的预期。
费用报销请求在屏幕上展示给用户,用户已经决定批准。客户端发送一个PATCH
请求来更新费用报销请求的结果。
费用服务会对授权服务进行同步调用,以检查给定用户是否有权限批准或拒绝费用报销请求。授权服务返回用户被授予的操作列表。费用服务进一步检查是否可以批准或拒绝这笔特定的费用报销请求。
如果费用报销请求已经被批准或拒绝,则向客户端返回 HTTP 状态码 403(禁止)。否则,费用服务更新费用报销请求的结果,并返回 HTTP 状态码 200(OK),同时附带更新后的费用报销请求的有效负载。
授权的扩展性问题
从这个示例交互中,我们可以预见授权服务会被频繁地同步调用。这引发了关于可扩展性的担忧。所有业务请求都需要授权,因此授权服务将承受所有面向业务的其他服务的总负载。
同时,本地缓存权限可能不可取,甚至是有风险的。建议通过设计高效且非冗余的 API 来保持对授权服务的请求简洁。考虑使用更小的有效负载大小,如二进制格式,也是值得考虑的。
扩展使用授权服务的任何面向业务的服务意味着也需要显著扩展授权服务。建议设置指标和监控,以了解每个 API 客户端对授权服务的使用情况,这样我们才能理解如果服务扩展以处理更多的请求,授权服务也需要相应地扩展。
在本章的这一部分,我们可以对用户进行身份验证,并使用允许的操作和可访问的资源列表对他们进行授权。在它们之上进行的业务操作会导致系统保留敏感数据。我们将在下一节探讨处理敏感数据的话题。
处理敏感数据
处理敏感数据需要仔细考虑和实施最佳实践,以确保其机密性、完整性、可用性和符合监管要求。在本节中,我们将探讨管理敏感数据的关键策略。
数据分类
我们在本章的开头讨论了哪些数据可以被识别为敏感数据。对于组织来说,明确分类和记录哪些字段是敏感的非常重要。至少有三种数据敏感度类别:
-
机密:仅指定人员或角色可以访问
-
内部使用:仅组织成员可以访问
-
公开:对所有可访问
文档应作为指南,供组织中的每个人,包括工程师,小心谨慎地处理。
传输中的数据
在 TLS 通信中传输的数据使用会话密钥加密。这确保了即使数据被拦截或未经授权访问,它仍然是不可读的。
如何防止意外记录敏感信息
然而,目前还不安全也不安全。无意中通过日志泄露敏感信息并不罕见:
-
请求、响应或消息的完整解密有效负载
-
递归包含敏感字段的聚合 Kotlin 对象
-
包含敏感字段的生成类的对象
-
包含敏感信息的故障排除或调试信息
-
敏感字段本身
这非常危险,因为相关的代码看起来无害,泄露通常是在事后发现的。有一些技术可以防止这种情况发生。
工程师采用的一种常见技术是重写 Kotlin 数据类的toString
函数:
data class UserAccount(
val username: String,
val password: String,
val createdAt: Instant
) {
override fun toString(): String {
return "UserAccount(createdAt=$createdAt)"
}
}
这种方法有效,但因为它需要编写大量的重写函数,所以不可扩展。作为替代,值包装器可以有效地工作,如下所示:
data class Secret<T> (val value: T) {
override fun toString(): String = "*"
}
Secret
包装类作用于类或字段。toString
重写函数也很简单。
此外,还有一些开源库旨在用最少的代码解决这个问题。例如,Redacted 编译器插件(github.com/ZacSweers/redacted-compiler-plugin
)允许工程师注解字段或类以进行红字,因此当调用toString
时,值会被屏蔽。设置也很简单:
-
设置的第一步是其 Gradle 插件:
plugins { id("dev.zacsweers.redacted") version "1.10.0" }
-
其次,定义一个自定义的
Redacted
注解类:@Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS) annotation class Redacted
-
然后,配置插件以使用此注解,并在
build.gradle.kts
中配置屏蔽字符:redacted { redactedAnnotation = "redacted/Redacted" replacementString = "*" }
我们有以下用Redacted
注解的数据类:
@Redacted
data class BankAccount(
val iban: String,
val bic: String,
val holderName: String
)
data class UserAccount(
@Redacted val username: String,
@Redacted val password: String,
val createdAt: Instant
)
我们为每个类创建一个对象,并在主函数中将它们打印到控制台:
fun main() {
println("${BankAccount("Iban", "bic", "holderName")}")
println("${UserAccount("username", "password", LocalDate.now())}")
println(
"Secret wrapper: ${Secret("email@address.com")}"
)
}
我们有以下结果:
BankAccount(*)
UserAccount(username=*, password=*, createdAt=2024-10-09)
Secret wrapper: *
因此,敏感信息作为toString
函数结果的一部分被屏蔽。然而,如果程序使用带有此编译器插件的 IDE 运行,则不会屏蔽值。如果从命令行或 Gradle 任务执行程序,则显示屏蔽的值。
有一些替代方法使用反射作为红字或隐藏敏感字段的手段。由于使用反射的开销,它们不建议使用。编译时红字是最高效的方法。
对于生成的类的负载和对象,一个防止敏感信息泄露的合理方式是在生产环境中避免记录它们。
静态数据
静态数据指的是存储在设备、文件系统、数据库和云环境中的数据。在基础设施中坐着的通常由 MFA、RBAC 和 ALCs 保护,如前几节所述。然而,对于敏感信息,需要采取额外的步骤。
加密
敏感数据需要以加密状态存储;差异在于如何。以下是常见的技巧:
-
加密数据库:数据库中存储的所有数据都以加密和不可读的格式存储。数据在检索时自动解密,无需应用代码。这些数据库通常支持授权,以确保只有授权用户才能与加密数据交互。它们还天生支持加密密钥的生成、存储和轮换,以提供额外的安全性。一些数据库还支持在数据进出数据库时进行加密。然而,对所有数据进行加密和解密,无论敏感与否,都会增加延迟。
-
加密字段:只有被标识为敏感数据的字段才会被加密。这种方法适用于所有类型的存储,如数据库列、消息基础设施中的事件和文件。与加密数据库相比,这种方法具有更小的性能开销。然而,在字段级别执行意味着应用程序需要处理加密数据库自动处理的方面,如下所示:
-
加密和解密算法
-
对称或非对称加密密钥
-
密钥生成和密钥管理
-
密钥轮换和重新加密
-
-
混合:由于加密数据库的性能开销以及处理应用程序代码中加密的额外工作,可能有必要采用混合方法,其中仅将敏感数据存储在加密数据库中,其余数据则保存在任何类型的存储中。
-
加密备份:敏感数据应定期以加密格式备份,以确保在数据丢失或泄露的情况下可以安全地恢复数据。
数据保留和匿名化
地方当局和法规对数据应保留多长时间有明确的指导方针,包括敏感数据。在保留期内,组织有责任确保它们的安全和保密。保留期过后,敏感数据可以被删除。
此外,还有一些法规(例如,GDPR)规定个人有权要求从系统中删除隐私数据。
然而,单独删除敏感数据可能会出现一些复杂情况。其中一些数据具有引用约束,并包含有用的业务洞察,这些洞察不需要识别个人。
有几种技术可以使敏感数据匿名化,并使其不再敏感:
-
anonymized@data.com
) -
泛化:降低敏感值的精度,以便无法识别特定个人或记录(例如,将出生日期缩减到出生月份,地址裁剪到城市等)。
-
聚合:将数据汇总成统计数据,以便没有针对个人或记录的引用。
-
丢失解密密钥:删除解密密钥,以便无法追踪数据到个人。
-
定期匿名化:积极扫描已过保留期的数据,并将其匿名化。
-
断链:一个预先设计的数据库模式,将敏感数据和非敏感数据分别存储在不同的表中,且没有任何表引用敏感数据表。这些敏感数据记录可以随时删除而不会出现问题(参见图 14**.7)。
图 14.7 – 敏感和非敏感数据的分离以实现匿名化
用户记录表有一个主键用户 ID,该字段可以被其他表用作参考。敏感个人信息记录表仅是一个补充表,仅保留 PII 字段。
积极使用这些技术有助于组织管理敏感数据,并保护其免受未经授权的访问和潜在的安全漏洞。它们有助于与客户建立信任并符合监管要求。
接下来,我们将简要介绍一些确保网络安全的实践。
网络安全
在本章前面,我们讨论了 TLS 的工作原理以及它是如何防止网络通信窃听的。然而,还有其他不需要渗透认证和授权过程的恶意攻击。
例如,分布式拒绝服务(DDoS)是一种网络攻击类型,其中系统被多个源系统(通常为机器人或自动化脚本)所淹没,以至于合法用户无法访问系统。攻击会生成大量流量以饱和和耗尽系统的资源,如 CPU、内存和网络。
以下是一些关键策略,用于保护系统免受这些网络级攻击。
网络应用防火墙(WAF)
网络应用防火墙(WAF)是一种专门用于保护互联网上系统的安全解决方案。WAF 可以在云中、数据中心中运行,或两者兼而有之,作为互联网流量到达系统内部更深层次之前的前端。它提供了一些关键功能:
-
地理封锁和 IP 黑名单:阻止来自和前往与恶意活动相关的 IP 地址列表的流量,并阻止来自某些地理区域的流量。
-
速率限制:防止服务器请求的数量达到配置的持续时间阈值。
-
防止外部脚本执行:防止攻击者执行不属于系统的脚本,例如未经授权的数据库命令、来自网页的跨站脚本(XSS)和特洛伊木马。
-
基于策略或基于规则的访问策略:高度可配置和可定制的规则,以在细粒度级别设置每个应用程序的需求。
-
频繁更新:WAF 频繁更新以适应不断变化的网络安全威胁环境。WAF 解决了开放网络应用安全项目(OWASP)Top 10 中概述的漏洞,该漏洞定期更新。
开放网络应用安全项目(OWASP)
OWASP 是一个开源项目,其使命是使软件安全对工程师和组织可见和可访问。它是一个全球性的工程师、安全专家和组织社区,致力于提供免费工具和知识,以增强数字环境的安全性。OWASP Top 10 是全球公认的十大最关键安全风险列表。该列表定期更新,以反映安全威胁的发展。
WAF 是全面网络安全策略的关键部分,为 Web 应用提供对各种攻击的基本保护。对持续存在担忧的组织应该拥有 WAF。基于云的系统可以选择来自本地云提供商的 WAF,或者选择可以在云中运行的 WAF。WAF 帮助组织保护其应用,保护敏感数据,并保持符合安全标准。
流量路由和网络分段
与流量黑名单和阻止相反,应该有明确的流量路由,作为 API 网关或代理到应用的接口。明确的配置充当了外部世界允许的流量路由的白名单,有助于符合 PoLP。
明确配置的流量路由允许应用以下功能:
-
分布式跟踪:将作为整体流通过多个组件传递的请求链接起来
-
指标:收集如数据包丢失、响应时间、连接时间和错误率等指标
-
重试:允许应用程序在面临间歇性故障时重试操作
-
断路器:不允许应用程序重复执行失败的操作
-
速率限制:与 WAF 的功能重叠,限制一定时间内的请求数量
到目前为止,我们在这里讨论的交通路由都是针对入站流量,即从外部世界到内部应用的流量。出站流量,即从内部应用到外部世界的流量,同样重要。例如,攻击者可能已经安装了木马,悄无声息地将敏感数据传输到未知的目的地。这可以通过路由器、网络交换机、网络 ACL 和 WAF 配置来实现。云服务提供商也提供通过规则和政策管理入站和出站路由的服务。
服务网格是另一种配置和控制网络通信的方法,但它专注于服务间的通信。它提供了一个专门的底层基础设施,定义了服务之间如何通信,确保服务之间的所有路由都是明确定义的。除了 HTTP 请求路由外,服务网格还可以扩展到其他类型的传输,如消息传递、文件和数据库。它自动检测并注册服务的新实例,因此工程师可以专注于业务逻辑而不是网络通信。服务网格通常使用边车模式,其中在后台服务旁边部署了一个代理,代理拦截服务的所有入站和出站流量。
边车模式
边车模式是一种架构设计方法,其中部署了一个称为边车的辅助服务,以扩展主要服务的功能,但与其代码库解耦。这个边车通常在同一个容器或虚拟机中运行。边车处理跨切面关注点,如日志记录、监控、安全和通信。这种模式增强了服务的弹性和可维护性,但不会与其耦合,从而便于更有效地管理复杂的应用程序。
网络分段是另一种技术,它将网络划分为更小、更易于管理的部分,以限制访问并降低被攻击的风险。这有助于限制违规行为并提高整体安全性。可以通过设置虚拟局域网(VLANs)或子网络来实现。
防病毒和反恶意软件解决方案
最后,在基础设施中应运行防病毒和反恶意软件解决方案,以扫描、检测和移除恶意软件。它们监控运行中的进程,扫描存储的文件,并发现可疑行为,而无需人工干预。这些解决方案也会自动更新以保持最新的威胁定义。
大多数网络安全策略都涉及涉及特定应用程序配置的基础设施解决方案。在下一节中,我们将介绍 DevSecOps,其中应用程序工程师会积极参与。
DevSecOps
DevSecOps是一种软件开发方法,它将安全最佳实践整合到开发过程中。它强调软件安全责任应由团队中的所有成员共同承担,安全是开发过程中的一个不可分割的元素。
在组织内仍将存在安全专家和潜在的安全团队。他们为开发提供专业知识、知识、工具和指导。DevSecOps 拥抱以下原则:
-
左移:安全考虑因素在开发过程的早期就整合为需求的一部分。这使在应用程序达到生产之前就能发现、识别和修复漏洞。
-
合规性作为代码:将监管要求纳入代码中,并使用自动化测试以持续验证合规性。
-
协作与沟通:这鼓励开发、安全和运维团队之间的协作,培养一种对软件安全共享责任的文化。
-
自动化:将安全流程自动化,例如代码漏洞扫描,作为持续集成/持续部署(CI/CD)管道的一部分,以提供对安全问题的快速反馈,并允许团队在开发早期阶段解决这些问题。例如,OWASP Dependency-Check是一个静态分析工具,用于识别项目依赖中的已知漏洞。此工具与构建工具(如 Maven、Gradle 和 Jenkins)集成。
-
持续监控:在所有环境中运行时,持续监控应用程序和基础设施中的安全威胁、漏洞和异常行为。
-
威胁建模:在开发的早期阶段发现和识别潜在威胁和漏洞,使团队能够主动解决安全问题。
DevSecOps 的好处
通过在整个软件开发周期中集成安全,组织可以早期识别和缓解漏洞。它降低了安全漏洞的风险、修复安全问题的成本和补救成本。
在 CI/CD 管道内集成的自动化和协作有助于在流程早期发现漏洞。它节省了交付安全应用的时间,并缩短了上市时间。
持续监控和自动合规性检查有助于组织积极有效地满足监管要求。
DevSecOps 是一种文化和技术转变,它改变了组织对待软件开发和安全的做法。DevSecOps 流程中的实践培养了一种提供弹性应用和有效应对威胁的文化,从而导致了更安全的生态系统。
威胁建模练习
我们将进行一次威胁建模练习([owasp.org/www-project-threat-model/
](https://owasp.org/www-project-threat-model/)),以展示团队中的每个人如何参与这项活动。产品经理、安全团队和开发团队应参与所有步骤。
威胁建模从业务场景开始。我们将在整个书中使用相同的真实生活示例。这是一个允许家庭通过约定的合同相互交换服务的软件系统。这次威胁建模练习的目标是识别系统中的潜在安全威胁和漏洞。
第 1 步 – 可视化架构并识别资产
威胁建模的第一步是定义威胁建模发生的范围。这包括架构图来展示以下内容:
-
系统内部组件
-
系统边界、入口点和出口点
-
需要保护的资产
对于此目的,有几个图表格式可供选择,例如结构分析中的数据流图(DFD)或C4 模型中的容器图(第 2 级),如第一章所述。系统的容器图显示在图 14.8中:
图 14.8 – 来自实际威胁建模示例的系统容器图
虚线矩形定义了系统和其内部网络的边界。我们可以假设系统运行在云环境中,并打算使用云原生服务来实现跨功能。此图中有几个组件:
-
移动应用程序:家庭在移动设备上的用户界面。
-
WAF:一个基于云的防火墙中间件,阻止恶意 IP 地址,限制传入请求的数量,并防止XSS。
-
API 网关/路由器:一个基于云的中间件,将来自移动应用程序的请求路由到适当的服务。
-
家庭服务:一个后端服务,负责家庭记录的 CRUD 操作。
-
合同服务:一个后端服务,负责管理从起草到执行合同的生命周期。
-
通知服务:一个后端服务,负责通过电子邮件向外部用户发送通知。
-
电子邮件服务:一个基于云的中间件,将电子邮件内容发送到指定的地址。
-
IdP:一个基于云的中间件,使用来自移动应用程序的多因素认证(MFA)验证用户的身份,并确认携带令牌是否有效。由家庭服务和合同服务使用。
-
数据库:一个基于云的数据库,允许应用程序从中读取数据并向其写入数据。由家庭服务和合同服务使用。
-
消息代理:一个基于云的中间件,接收家庭更新、合同更新和通知请求。它允许感兴趣的服务消费消息以进行进一步处理。例如,通知服务消费通知消息并将它们转换为电子邮件请求发送给电子邮件服务。
从架构图中,我们确定了系统中的入口点。请求从移动应用程序进入,通过 WAF,然后被路由到相应的服务。
我们还在系统中确定了两个出口点:
-
后端服务请求 IdP 验证访问令牌
-
通知服务请求电子邮件服务发送电子邮件
此外,我们还确定了以下资产:
-
家庭数据:包含地址和姓名等 PIIs
-
合同数据:仅限于两个相关家庭之间的私人信息
-
通知请求:包含电子邮件内容和电子邮件地址(PIIs)
-
应用程序代码:所有后端服务和移动应用程序的源代码
-
基础设施:服务器、数据库、网络组件、基础设施配置代码
在架构图、入口点、出口点和已识别的资产的基础上,团队现在可以检查信息并发现系统中的威胁和漏洞。
第 2 步 – 识别威胁
团队应用STRIDE框架根据已识别的资产对潜在威胁进行分类。STRIDE 是以下六个方面的首字母缩写组合:
-
欺骗:未经授权的用户可能冒充合法用户
-
篡改:在传输过程中恶意修改家庭或合同数据
-
否认:家庭用户可能否认与另一家庭签订合同
-
信息泄露:PII(个人身份信息),如姓名、地址和电子邮件地址,通过漏洞暴露
-
拒绝服务:攻击者可能通过流量使系统过载和耗尽
-
特权提升:用户访问未经授权的功能
在确定威胁后,团队需要评估风险并确定如何应对它们。
第 3 步 – 评估风险并确定反应
风险可以通过两个因素进行评估:可能性和影响。这两个因素相乘得到风险等级,从而确定反应的优先级。
一种简单的方法是为每个因素设置三个级别,每个级别都与一个整数相关联。整数的乘积是风险等级。组织可以根据需要自定义级别和整数。在这个例子中,整数分配给每个级别如下:
-
可能性:1 – 不太可能,2 – 可能,3 – 很可能
-
影响:1 – 低,2 – 中等,3 – 高
这两个因素的数值构成了风险评估表,如表 14.1所示:
威胁 | 可能性 | 影响 | 风险等级 |
---|---|---|---|
欺骗:用户冒充 | 2 | 3 | 6 |
篡改:恶意数据修改 | 2 | 3 | 6 |
否认:家庭否认行为 | 1 | 2 | 2 |
信息:PII 泄露 | 2 | 3 | 6 |
拒绝服务 | 2 | 2 | 4 |
特权提升:未经授权的用户行为 | 1 | 3 | 3 |
表 14.1 – 风险评估表示例
风险等级实际上是团队需要响应和解决的安全威胁的优先级,最高数字代表最高优先级,最低数字代表最低优先级。
面对风险有四种一般性反应,团队必须为每个安全风险选择一种反应:
-
缓解:减少威胁的负面影响
-
转移:将风险转移到另一方,通常是通过使用第三方软件
-
规避:通过开发新的策略或解决方案消除风险
-
接受:接受并承认风险,但不再采取进一步行动
根据 STRIDE 框架确定的每个威胁,应概述以下反应:
-
欺骗 – 转移:使用基于云的 IdP 实现多因素认证并保护密码
-
篡改 – 避免措施:使用 TLS 之上的 HTTPS
-
否认 – 缓解:确保有足够的审计跟踪和记录所有与家庭和合同记录相关的用户操作
-
信息泄露 – 避免措施:使用加密数据库,在消息有效负载中加密敏感数据,将代码漏洞扫描集成到 CI/CD 管道中,并定期进行安全审计
-
拒绝服务 – 转移:配置 WAF 以保护网络免受 DDoS 攻击
-
提升权限 – 缓解:定期审查访问控制设置,并与内部用户进行安全培训
到目前为止,我们已经从安全的角度讨论了架构。已经确定了威胁及其风险等级。每个风险都已得到响应。完成一轮威胁建模只需要几个步骤,这些步骤将在下一步介绍。
第 4 步 – 记录、审查和更新
在完成前一步的所有活动后,所有活动都应得到记录,最好是作为单页文档,并标注威胁建模的大致日期。该文档作为团队学习记录和后续行动清单。
安全团队应审查文档,并建议是否有任何其他主题需要讨论。否则,安全团队应作为审阅者签署威胁建模文档。
团队应将风险响应转换为待办事项中的工作项,以确保决定采取的行动得到执行。
团队还应定期审查此文档,并决定是否需要进行另一轮威胁建模。或者,如果发生重要功能变更或架构变更,则团队应考虑是否需要更新威胁模型。
威胁建模是一种工具和流程,用于帮助团队在开发初期交付安全的软件。它通过早期解决安全问题,使工程师、安全团队、产品经理和客户受益,开发出高效的软件产品。
摘要
我们讨论了在软件架构中安全的重要性以及敏感信息的分类。之后,我们介绍了保密性、完整性和可用性的安全原则——CIA 三要素。
我们讨论了身份验证,重点关注 TLS 的工作原理及其如何防止中间人监听等网络攻击。我们深入探讨了多因素认证的工作原理及其对软件架构的影响。
然后,我们转向授权主题,并介绍了四种主要的访问控制方法(基于角色的、基于策略的、基于属性的以及 ACLs)。我们讨论了授权如何影响软件架构。
我们提到了处理敏感数据的基本实践,包括数据分类,以及在传输和静止状态下保护数据。本章突出了一些 Kotlin 工程师可以避免意外记录敏感数据的方法。还包括了一些匿名化数据的策略。
我们简要介绍了 DevSecOps 的原则及其通过将安全集成到开发过程中对软件开发周期的益处。
最后,我们使用实际示例系统进行了威胁建模练习。我们详细探讨了每个步骤的细节,强调了如何结束一个循环,以及何时应该重新审视威胁模型。
仍然有一些工程方面的内容超出了仅仅架构的范畴。在下一章和最后一章中,我们将讨论一些特定于 Kotlin 工程师的相关杂项主题。我们希望您会发现它们在支持更好的软件架构方面很有用。
第十五章:超越架构
本章我们将探讨一些有助于工程师构建更好软件的主题。其中一些主题可能不直接与软件架构相关,但考虑它们的用法将支持和增强更好的架构。
阅读本章后,工程师应该掌握一些技巧,以提高他们的生产力,并在考虑特定架构风格实现软件时消除障碍。这些工具如果以代码形式实现,则与 Kotlin 相关;否则,其中一些是通用的工程工具。
希望在前几章中所有建筑主题的概念理解能够通过本章的工具箱转化为实际和实用的解决方案。
本章我们将涵盖以下主题:
-
由 Kotlin 驱动
-
从 Java 过渡
-
持续集成和交付
-
开发者体验很重要
-
关于软件架构的最终思考
技术要求
您可以在 GitHub 上找到本章使用的所有代码文件:github.com/PacktPublishing/Software-Architecture-with-Kotlin/tree/main/chapter-15
由 Kotlin 驱动
作为一种编程语言,Kotlin 为工程师提供了大量的语法支持,使他们能够简洁地表达代码的意图。此外,一些特性允许工程师分离关注点并组织代码,使其更易于管理。
扩展函数
Kotlin 扩展函数允许在不修改其源代码的情况下向现有类添加额外功能。这个特性对于以下用例非常有用,甚至可以说是必需的:
-
向来自外部库或最终类的类添加更多函数。例如,我们想要提取每个单词的首字母并用点连接起来,所以
Sam Payne
将变成S.P
。Kotlin 字符串不提供这样的函数,因此我们可以编写一个扩展函数来代替:fun String.getFirstLetters(): String = split(" ").joinToString(".") { it.first().toString() }
-
增强一个类以适应某些 Kotlin 语言特性,例如操作符重载(
+
、-
、in
等)。操作符重载的使用将在后续章节中详细讨论。 -
向处理诸如尝试连接一个可空字符串列表等场景的函数添加空安全功能。在扩展函数中将接收者作为
List<String>?
确保,无论列表是否为空,都会创建一个字符串。实现方式如下:fun List<String>?.concat(): String = this?.joinToString(",")?: ""
然而,也存在一种用例可以支持更好的架构。扩展函数可以通过将类的函数隔离到具有非公开可见性的不同包中来分离类的关注点。
例如,我们有一个来自上一个示例的Name
数据类。这个数据类是一个域实体,需要根据操作上下文转换为不同的格式。
由于 Name
类的对象需要转换为 JSON 字符串,因此存在几种常见的函数签名样式:
-
Name
类已将对象的 JSON 表示暴露给所有使用场景:data class Name(val value: String) { fun toJson(): String = "{\"name\":\"$value\"}" }
然而,并非所有使用场景都需要这个功能。业务逻辑不太可能需要对象的 JSON 表示。这种方法将业务逻辑和外部表示的关注点混合在一起,而且更糟糕的是,这种外部表示也不适用于所有情况。
-
toJson
现在对所有可以访问Name
类的项目都是公开的:fun toJson(name: Name): String = "{\"name\":\"${name.value}\"}"
它在功能上等同于非局部扩展函数实现;区别在于扩展函数将参数移动到函数接收者:
fun Name.toJson(): String = "{\"name\":\"$value\"}"
当工程师搜索以 to
开头的函数名称时,原始函数实现会创建噪声,尤其是在 IDE 中,如果所有数据类都有单独的 toJson
函数。这种现象被称为作用域污染,因为我们暴露了比必要的更多函数。一个快速的解决方案是有一个类或 Kotlin 单例对象,其中包含一个成员函数用于此目的:
object NameJsonConverter {
fun toJson(name: Name): String = "{\"name\":\"${name.value}\"}"
}
然而,如果 JSON 转换仅适用于外部集成,那么将转换函数与外部集成代码一起定位可能是有可能的,并且函数可以作为局部****扩展函数是私有的:
private fun Name.toJson(): String = "{\"name\":\"$value\"}"
这种方法允许工程师在仅限于同一文件上下文的情况下扩展类的功能。换句话说,可以通过将源库中的本地扩展函数按文件分组来分离实体周围的不同关注点。
这并不是什么新东西,因为私有可见性修饰符对函数确实如此。尽管如此,将数据类作为函数的接收者并扩展其行为的能力带来了几个好处:
-
列表中的参数少一个
-
专注于实体,因为它成为接收者
-
调用函数就像它是类的一个成员一样
-
无需继承
-
与成员函数或扩展函数的流畅调用链
Kotlin 扩展函数通过允许工程师以模块化方式向现有类添加新功能,增强了代码的灵活性和可读性。同时,有一种方法可以限制使用以避免作用域污染并分离关注点。毕竟,Kotlin 扩展函数促进了更好的编码实践,并使代码更容易理解和维护。
Infix 修饰符
Kotlin 的 infix 修饰符是创建更易读和更具表现力的代码的另一种方式。我们在这里讨论了 When
:
object When
让我们定义一个与整数(Int
)相关的 PreCondition
类和一个 Action
类,如下所示:
typealias PreCondition = () -> Int
typealias Action = (Int) -> Int
类型别名在 Kotlin 中允许工程师为现有类型创建新名称。它们还允许工程师快速将名称映射到函数类型。对于函数类型,类型别名在声明单个函数的接口时特别有益,这有助于工程师实现符合单一职责原则(SRP)的代码,如在第 2 章 中讨论的。
到目前为止,它们看起来像 BDD 测试场景的 Gherkin 语言可能仍然令人困惑。当我们添加infix
函数时,代码将开始支持自然语言:
infix fun When.number(n: Int): PreCondition = { n }
infix fun PreCondition.then(action: Action): Int = action(this())
在这里,PreCondition
用作返回类型和另一个函数的接收者。我们需要实现Action
并有一个函数来验证结果,以完成一个简单的测试场景:
object Square: Action {
override fun invoke(p1: Int): Int = p1 * p1
}
infix fun Int.shouldBe(expected: Int) {
require(this == expected) {
"Expected: $expected but was $this"
}
}
将它们全部放在一起,我们可以生成如下测试场景:
((When.number(2)).then(Square)).shouldBe(5)
当运行此行时,它应该抛出一个包含以下消息的异常:
Expected: 5 but was 4
这个示例令人兴奋的部分是,Kotlin 的中缀特性让我们可以省略函数调用的点和中缀函数单个参数的括号。因此,代码变得非常接近自然语言和 Gherkin 语言语法:
When number 2 then Square shouldBe 5
当然,从这个点开始,要有一个完整的 Gherkin 风格的 BDD 测试场景代码将需要很长时间。然而,这个例子已经展示了 Kotlin 中缀函数如何使代码可读和表达。
在 Kotlin 中拥有中缀函数有一些基本规则需要遵循:
-
它是类的一个成员函数或一个带有接收者的扩展函数
-
只能有一个参数
中缀函数通常用于构建直观和可读的领域特定语言(DSL)。它们在链式操作中用得很多,例如我们刚刚演示的例子。
操作符重载
操作符重载是使您的代码可读性和直观性的另一种方法。它允许工程师为+
、-
等运算符定义自定义行为。当我们在讨论扩展函数时,已经展示了语法:
data class Name(val value: String)
operator fun Name.plus(other: Name): Name =
Name("$value ${other.value}")
fun main() { println(Name("Sam") + Name("Payne")) }
在fun
关键字之前使用operator
修饰符表示要重载内置运算符。返回类型需要与接收者或所属类相同。所有可以重载的运算符都列在表 15.1中:
运算符 | 函数名 | 示例 |
---|
|
+
|
plus
|
a + b
|
|
+
|
unaryPlus
|
+a
|
|
-
|
minus
|
a - b
|
|
-
|
unaryMinus
|
-a
|
|
*
|
times
|
a * b
|
|
/
|
div
|
a / b
|
|
%
|
rem
|
a % b
|
|
==
|
equals
|
a == b
|
|
!=
|
notEquals
|
a != b
|
|
>
|
compareTo
|
a > b
|
|
[]
|
get
|
val value = a[key]
|
|
[]
|
set
|
a[key] = value
|
|
+
|
unaryPlus
|
+a
|
|
()
|
invoke
|
a()
|
表 15.1 – Kotlin 可以重载的运算符
操作符的重载需要具有兼容的语义。例如,+
运算符应该创建一个新实例,该实例由相同类型的两个对象组合而成。如果plus
函数有副作用,例如更新现有对象的值,那么重载运算符是不合适的。
作用域函数
Kotlin 中的作用域函数在对象的上下文中执行代码块。作用域从开括号 {
开始,以闭括号 }
结束,这对编程语言来说已经很自然了。我们已经有类作用域、函数作用域和 lambda 作用域,所有这些作用域都使用括号来表示边界。此外,内部作用域可以访问外部作用域中声明的值和函数。例如,成员函数可以访问其包含类中的其他函数。
内置作用域函数
Kotlin 中的作用域函数提供了另一种拥有有限作用域的方法,该作用域专注于上下文对象。提供了五个作用域函数,如图 图 15.1 所示:
图 15.1 – Kotlin 内置作用域函数
五个作用域函数(let
、apply
、run
、with
和 also
)各有其用途和行为。它们之间有两个主要区别。第一个区别是上下文对象是 it
还是 this
。
以下三个语句返回相同的 "35"
结果:
"3".let { it + "5" }
"3".run { this + "5" }
with("3") { this + "5" }
let
函数使用 it
作为上下文对象,而 run
和 with
使用 this
作为上下文对象。
还值得注意的是,run
和 with
在功能上是等价的,但语法不同。run
函数是一个扩展函数,而 with
是一个顶层函数。工程师可以利用这种差异来传达使用意图。通常,当使用 run
函数时,上下文对象是操作的重点。如果另一个对象是操作的重点,则可以使用 with
函数。
第二个区别是 lambda 或接收者的结果是否返回。这就像是一个窥视函数,工程师想要插入一个额外的操作,但不想改变结果。以下两个语句返回相同的 "3"
结果:
"3".also { println(it) }
"3".apply { println(this) }
在 lambda 表达式中评估的任何输出都不用作返回值。
自定义作用域函数
编写自己的作用域函数可以为您的系统带来强大的功能。这在构建预定义作用域内的结果时尤其有用。通常情况下,系统需要在一个传入请求上执行全面验证,并在响应中报告所有验证失败。
我们需要一个可以累积验证错误的构建器类,如下所示:
class ValidationBuilder {
private val failures = mutableListOf<String>()
fun evaluate(
result: Boolean,
failureMessage: () -> String
) {
if (!result) failures.add(failureMessage())
}
fun getErrors() = failures.toList()
}
ValidationBuilder
类使用可变字符串列表来收集在过程中找到的所有验证错误。然后,我们可以定义一个自定义作用域函数,该函数定义验证的开始和结束,并在作用域内执行验证:
fun <T> T.validate(
build: ValidationBuilder.(T) -> Unit
): List<String> =
ValidationBuilder()
.also { builder -> builder.build(this) }
.getErrors()
此作用域函数是一个使用泛型类型作为接收器的扩展函数,因此可以在任何对象上调用validate
函数。它接受一个 lambda 表达式作为参数,其中将ValidationBuilder
实例作为通过this
标识的上下文对象传递。在validate
函数的末尾,所有收集的错误都作为不可变的List
返回。
一个示例用法可能如下所示:
fun main() {
val failures = "Some very%long nickname".validate {
evaluate(it.length < 20) { "Must be under 20 characters: \"$it\"" }
evaluate(it.contains("%").not()) { "Must not contains % character"}
}
println("failures: $failures")
}
这是一个对String
对象进行的简单验证,包含两个规则:
-
必须少于 20 个字符
-
不能包含百分号,
%
验证从具有validate
扩展函数的String
对象开始。在 lambda 作用域内,evaluate
函数被调用两次,用于评估和错误消息。如果评估失败,ValidationBuilder
会收集错误消息。返回一个验证错误列表并打印到控制台。控制台应该有如下输出:
failures: [Must be under 20 characters: "Some very%long nickname", Must not contains % character]
输出显示了如何使用自定义作用域函数对字符串执行完整验证。
自定义作用域函数在构建复杂对象,如大型域对象时也很受欢迎。实际上,它在流行的框架Ktor中用于构建服务器端路由配置:
routing {
route("/hello", HttpMethod.Get) {
handle {
call.respondText("Hello")
}
}
}
我们已经展示了使用自定义作用域函数来执行完整验证并收集所有验证错误的方法。自定义作用域函数在收集作用域内的元素时特别有用,其中传递给构建器的对象作为上下文对象。
我们将在下一节中介绍从 Java 过渡到 Kotlin 的主题。
从 Java 过渡
作为一种编程语言,Kotlin 由 JetBrains 开发,这是一家以 IntelliJ IDEA 等软件开发工具而闻名的软件公司。该项目始于 2010 年,旨在创建一种与 Java 兼容但改进了 Java 一些缺点的新语言。名称Kotlin来自俄罗斯圣彼得堡附近波罗的海的科特林岛。
Kotlin 1.0 于 2011 年 7 月发布,具有 null 安全、静态类型和类型推断等功能。它在 2016 年引入了 100% Java 互操作性、扩展函数、lambda 表达式和高级函数等特性后开始流行。
2017 年,谷歌宣布正式支持 Kotlin 在 Android 上的使用。谷歌与 JetBrains 合作支持 Kotlin 在 Android 上的使用,使 Kotlin 成为 Android 开发者的热门选择。2018 年,JetBrains 推出了 Kotlin Multiplatform,它使得 Kotlin 代码可以翻译和编译为在 Android、iOS 和 Web 应用程序中运行。大约在同一时间,Kotlin 后端服务开始受到越来越多的后端工程师的关注,尤其是那些有 Java 背景的工程师。
100%的 Java 互操作性使得许多 Java 工程师能够顺利过渡到为商业应用编写 Kotlin 代码。我们将与您分享一些工具和技巧。
JetBrains 的 IntelliJ IDEs 提供了一个工具,可以将 Java 文件代码转换为 Kotlin 文件代码。这听起来可能很神奇,但现实是,仍然需要做一些调整才能使 Kotlin 代码真正符合规范。
在 Java 项目中启用 Kotlin
由于已经有一个 Java 项目,我们需要设置项目以编译 Kotlin 源代码。
如果项目使用 Gradle,添加 Kotlin 插件就足够了,例如以下使用 Gradle Kotlin DSL 的代码:
plugins {
kotlin("jvm") version "2.0.20"
}
这与 Gradle Groovy 的设置等效:
plugins {
id 'org.jetbrains.kotlin.jvm' version '2.0.20'
}
使用 Maven 的项目需要在pom.xml
中进行以下更改:
<properties>
<kotlin.version>2.0.20</kotlin.version>
</properties>
<plugins>
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>2.0.20</version>
</plugin>
</plugins>
前面的配置将 Kotlin 版本定义为2.0.20
。它还导入了一个插件,该插件启用了对 Kotlin 源代码的编译。
转换 Java 文件并将它们移动到 Kotlin 文件夹
Kotlin 文件可以位于src/main/java
和src/test/java
文件夹下,但建议分别存储在src/main/kotlin
和src/test/kotlin
。
让我们使用 IntelliJ 的转换工具将以下 Java 类转换为 Kotlin:
public class Household {
private final String name;
private final List<String> members = new ArrayList<>();
public Household(String name, List<String> members) {
this.name = name;
this.members.addAll(members);
}
public String getName() {
return name;
}
public List<String> getMembers() {
return new ArrayList(members);
}
}
这个 Java 类是不可变的,所以所有字段都是私有的,列表在 getter 中也没有暴露以保证不可变性。当你使用转换工具转换 Java 文件时,IntelliJ 会提醒你还需要进行一些修正。
这是工具转换后的 Kotlin 类(在 IntelliJ 中,右键单击 Java 文件并选择Convert Java File to Kotlin File):
class Household(val name: String, members: List<String?>) {
private val members: MutableList<String?> = ArrayList()
init {
this.members.addAll(members)
}
fun getMembers(): List<String?> {
return ArrayList<Any?>(members)
}
}
为了成为一个符合规范的 Kotlin 类,有一些立即需要做出的更改:
-
更新为 Kotlin 数据类,因为这个类打算作为一个实体类。
-
尽可能地将
init
块替换为构造函数。只有真正的初始化逻辑应该保留在init
块中。 -
使用 Kotlin 的不可变
List
接口作为成员字段。同样的规则也应该适用于其他Collection
接口。使用 Kotlin 的集合接口可以消除集合被修改的风险。 -
由于不可变的
List
接口,不需要返回列表的新副本,因此可以移除getMembers
getter 函数。 -
除非有理由预期可能为 null 的值,否则请移除可空符号(
?
)。这是工程师们消除在转换为 Kotlin 后变得不必要的 null 检查的机会。
这是转换的结果,它从工具开始,以一些手动修正结束:
data class Household(val name: String, val members: List<String>)
如果 Java 实体类使用了像 Lombok(projectlombok.org/
)这样的外部库来自动生成 setter 和 getter 函数,那么我们也需要移除 Lombok 注解。
Java 14 引入了一个名为record classes的新特性,它的工作方式类似于 Kotlin 数据类。这里展示了一个 Java 记录类的示例:
public record Account(String number, String holderName) {}
转换为 Kotlin 相对简单,但有一个JvmRecord
注解保留了下来:
@JvmRecord
data class Account(val number: String, val holderName: String)
注释仅用于保留一些函数名称,例如 account.getNumber()
以便向后兼容。如果这不是一个关注点,我们可以删除这个注释,并让这个类的用户使用 account.number
代替。
惯用表达式、代码风格和约定
将 Java 文件转换为 Kotlin 文件是工程师开始采用 Kotlin 的惯用表达式和约定的绝佳机会。强烈建议工程师从开始就选择一个 lint 工具来统一 Kotlin 风格:
-
Ktlint (
github.com/pinterest/ktlint
) -
KtFmtFormat (
github.com/facebook/ktfmt
) -
Detekt (
github.com/detekt/detekt
) – 同样也是一个静态代码分析工具 -
Spotless (
github.com/diffplug/spotless
) – 也支持其他语言
选择“最佳”代码风格对工程质量的关注最少,但拥有统一风格对于团队集中精力在更重要方面(如正确性和响应性)非常重要。
转换顺序
对于现有的 Java 项目,也建议按以下顺序转换 Java 类:
-
测试类,因为这些代表最低风险。这是一个工程师学习 Kotlin 和犯错误影响较低的安全空间。
-
没有其他类依赖的顶层类。
-
如果应用程序使用分层架构,如第 第七章 中所述,则应首先转换外层(即适配器、命令壳、框架和驱动器)中的类,然后向内转换,直到达到代码。当 Kotlin 代码调用 Java 代码时,与 Java 的 Kotlin 兼容性比反过来更平滑。
在转换过程中,工程师通常会开始寻找用 Kotlin 中的等效库替换 Java 中的框架;我们将在下一节讨论这些内容。
框架替换
在转换之旅中,不可避免地会有人提出讨论是否应该用支持原生 Kotlin 的库替换 Java 库。我们在 第一章 中讨论了新框架的悖论,在从 Java 到 Kotlin 的转换具体话题上,我们应该注意以下几点:
-
一切仍然正常工作!这似乎很明显,但团队可以选择不替换任何现有框架,因为 Java 兼容性达到 100%。
-
这可能是一次单程之旅。新的 Kotlin 库可能并不旨在支持 Java 项目,除非这些库在 Kotlin 流行之前就已经存在。如果项目仍然使用 Java 但需要使用新的 Kotlin 库与 Java 一起使用,使用可能会显得尴尬。
-
开源社区已经贡献了多个 Kotlin 库,以提供以 Kotlin 为主的库体验。其中一些在方法、活动和贡献者数量方面看起来很相似。团队可能会陷入分析瘫痪,不知道该使用哪一个。这更多的是一个普遍的开源框架采用问题,但它影响了从 Java 到 Kotlin 的过渡。
-
对于贡献者来说,停止一些 Kotlin 为主的较新框架的努力并不罕见。这是一个自然的演变,其中一些想法最终证明并不可行或可行。团队可以始终等待 Kotlin 为主的库成熟。再次强调,这是一个普遍的开源项目关注的问题。
-
一些现有的 Java 框架将 Kotlin 支持作为一个额外依赖项,以帮助工程师进行过渡。Kotlin 模块可能就足够了,没有必要完全淘汰该框架。
-
由受欢迎的社区或值得信赖的组织开发的以 Kotlin 为主的框架可能具有更强的支持和连续性。
尽管有众多框架替换的因素,但有一些以 Kotlin 为主的框架值得关注:
-
客户端和 服务器框架:
-
Spring:
spring.io/
-
Ktor:
ktor.io/
-
Http4K:
www.http4k.org/
-
Micronaut:
micronaut.io/
-
Retrofit:
square.github.io/retrofit/
-
-
语言增强框架:
-
Arrow:
arrow-kt.io/
-
Result4K:
github.com/npryce/result4k
-
Coroutines:
github.com/Kotlin/kotlinx.coroutines
-
-
依赖注入框架:
- Koin:
insert-koin.io/
- Koin:
-
持久化框架:
- Exposed:
github.com/JetBrains/Exposed
- Exposed:
-
测试框架:
-
Kotest:
kotest.io/
-
Spek:
www.spekframework.org/
-
Mockk:
mockk.io/
-
-
UI 框架:
-
Jetpack:
developer.android.com/jetpack
-
Compose Multiplatform:
github.com/JetBrains/compose-multiplatform
-
持续过渡
转向 Kotlin 的一个挑战是与其他变化的结合,这些变化是由业务或技术利益相关者推动的。
重要的是,转向 Kotlin 的过程应该是渐进的和持续的。例如,一个新业务功能可以完全用 Kotlin 编写,同时使用一些现有的 Java 类。
当有足够的时间和空间时,工程师们也可以逐步引入 Kotlin 转换后的代码,甚至在开发其他更改的过程中也是如此。团队可以采取一项政策,即如果需要更新 Java 类,它也将被转换为 Kotlin。这项政策为每次更改添加了少量的开销,但可以保持过渡的连续性,而无需停止。
保持风险可控是将 Java 项目迁移到 Kotlin 的关键。
Kotlin 的未来
Kotlin 已经从仅仅是一个更好的 Java 语言发展而来。特别是随着 Kotlin Multiplatform 的推出,Kotlin 已经成为市场上最灵活的编程语言之一,因为它可以用来编写 Android、iOS、桌面、Web、数据科学和后端应用程序。随着 Kotlin V2 的最新发布,Kotlin 的流行度和使用率仍在持续增长。工程师们应该关注 Kotlin 的新兴趋势(例如 Kotlin Multiplatform、Kotlin Native 和云集成),并拥抱其令人兴奋的即将到来的进步。
接下来,我们将讨论持续集成和交付。
持续集成和交付
持续集成(CI)和持续交付(CD)在软件开发生产力中发挥着至关重要的作用。由于它们之间紧密的关系,在许多讨论中它们被统称为 CI/CD。
当我们在第十三章中讨论了发现和修复应用程序问题的成本时,我们提到,如果在开发过程中早期发现问题,修复问题的成本会更低。在一个典型的环境中,工程师团队协作在源代码库上工作时,由于代码冲突而引起的问题修复成本也较低。
CI 是一种软件开发实践,工程师们频繁地将他们的更改集成到一个共享的源代码库中。CI 的主要目标是早期发现集成问题,并减少发布新功能或修复所需的时间。CI 的实践包括以下内容:
-
频繁提交:团队成员频繁提交更改。每个工程师也频繁更新他们的本地源项目以接收其他团队成员的更改。提交的频率可能高达每天多次。
-
自动项目构建:每次提交都会与源代码库集成,并触发自动构建以编译所有源代码。
-
自动化测试和反馈:每次提交还会触发一个自动化测试套件,包括各种测试和质量保证指标,如在第 12、13 和 14 章中所述:
-
单元测试
-
组件测试
-
集成测试
-
自动化 GUI 测试
-
端到端测试
-
性能测试
-
代码漏洞扫描
-
代码风格检查
-
静态代码分析
-
测试覆盖率
这些测试检查是代码编译后项目构建的一部分。如果任何前面的检查失败,项目构建将失败,并且工程师将被通知。工程师将根据反馈进行故障排除并修复问题。
-
-
版本控制:CI 使用版本控制系统来管理代码库。它支持保留提交的完整历史记录和审计记录。它允许工程师从主代码仓库分支,并在稍后将其合并回主分支。
-
与部署的集成:CI 是质量的第一道门,它认证应用程序是否足够好以进行部署。一旦所有检查和测试都通过,构建过程就可以继续为部署做准备。
通过缩短代码集成-测试-修复的反馈循环,团队可以更频繁地交付新特性和修复,缩短上市时间,并快速响应用户反馈。它还通过为每次集成进行自动化测试来提高团队内的协作,并提高整体软件质量。
然而,还有一个重要的因素会影响软件开发的生产力。大多数在团队中工作的工程师都会讨论在版本控制系统下的分支策略问题。在这个永无止境的辩论中,有两种流行的策略不断出现:基于特性的开发和主干开发。
基于特性的开发
基于特性的开发可以以其较高的分支数量和较长的生命周期为特征。工程师可以在自己的分支上独立工作。每个分支包含一大块连贯的工作,如特性或发布,因此它持续较长时间以收集所有必需的更改。同时,还有其他长期存在的分支代表其他特性。基于特性的开发的一个示例在图 15**.2中展示:
图 15.2 – 基于特性的开发示例
在这个例子中,主干是吸收来自其他分支所有更改的主要分支。特性 1从主干分支出来,并在该特性分支中继续开发。同时,团队需要准备一个新的发布,因此从主干创建了发布 1分支。发布分支作为发布候选部署到 UAT 环境进行验收测试。
在特性 1分支中进行几次提交后,特性 2需要开始开发,并且它需要从特性 1分支中进行一些更改,因此特性 2从特性 1分支分支出来,并在新分支中继续特性 2的开发。特性 1开发完成后,因此,创建了一个拉取请求进行审查,随后该分支被合并到主干。
在 功能 2 上工作的工程师希望保持其分支是最新的,所以他们从最新的 主干 分支重新合并 功能 2 分支。发布 1 分支不需要重新合并,因为 功能 1 不包含在即将发布的版本中。
发布 1 分支已经部署到生产环境中。在生产发布后发现了几个错误,不幸的是,其中之一是一个关键错误。因此,发布 1 分支还不能合并到 主干 分支。当一些工程师在 发布 1 分支上处理低优先级的修复时,几位工程师需要立即进行热修复并立即在生产环境中修复关键错误。因此,为关键错误修复创建了一个热修复分支。
关键错误修复已完成,已部署并在用户验收测试(UAT)中进行了验证。因此,它被发布到生产环境中。然后,热修复分支被合并到 发布 1 分支。之后,所有生产错误修复都已完成,并在用户验收测试(UAT)中进行了部署和验证。因此,还有另一个生产发布来总结这个版本。然后,发布 1 分支被合并到 主干 分支。
功能 2 分支尚未完成,因此需要从 主干 分支进行重新合并。同时,工程师们开始准备新的发布版本,通过从 主干 分支创建一个新的发布分支来开始。
基于功能的开发将分支隔离开来,并使它们专注于其目的。示例中的 发布 1 分支自然防止了 功能 1 影响发布。此外,热修复分支为工程师提供了一个稳定和安全的空间,专注于修复关键生产错误,知道关键修复可以作为优先级进行修补,而无需考虑其他无关的更改。基于功能的开发使用拉取请求,鼓励工程师之间的代码审查和协作。
然而,许多长期存在的分支带来了分支管理的开销。它在合并或重新合并分支时引入了高度复杂性的冲突。当在一个分支中将文件移动到另一个文件夹,而另一个分支中文件被更新时,这种类型的树冲突通常会导致复杂、耗时且容易出错的代码合并。缓解这种问题的方法是频繁地重新合并长期存在的分支。
基于主干的开发
基于主干的开发鼓励工程师在一个单一的分支上工作,这个分支是 主干(也称为 main)。然而,对 主干 分支的每次提交都是通过合并来自短期分支的拉取请求来完成的。
它提倡频繁提交小、频繁和增量更改到 主干。每个分支都是短期存在的,通常不会持续超过几天。每个分支都有频繁的重新合并或合并操作,以从 主干 获取最新更改。基于主干开发的示例在 图 15**.3 中展示:
图 15.3 – 基于主干的开发示例
在这个例子中,特性 1分支是从主干创建用于开发的。特性 2分支也是从主干创建用于开发的。
特性 1分支已经将代码开发到里程碑阶段,此时代码经过测试、验证和可发布,但特性本身尚未完成。因此,为特性 1创建了一个拉取请求以供审查,随后该分支被合并到主干。
特性 1的开发需要继续。因此,为特性 1的继续开发从主干创建了一个新分支。
同时,特性 2分支检测到在主干中有一个提交,因此相应的工程师将分支从主干重新基座以获取最新代码。
之后,特性 2的开发在其分支中完成、测试和验证。因此,为特性 2创建了一个拉取请求以供审查。然后该分支被批准并合并到主干。
特性 1分支检测到在主干中有一个提交,因此相应的工程师将分支从主干重新基座以获取最新代码。
基于主干的开发强调,主干分支在任何时候也应该足够好以供发布。没有发布分支的概念。这就是为什么所有分支都应该在合并前由同行进行测试、验证和审查。
为了扩展这个概念,每个提交都可以触发软件向环境发布,甚至到生产环境。这种方法非常适合 CD 实践,其中软件频繁地交付给客户,大大缩短了上市时间。这也意味着在开发的早期阶段,自动测试应该更加严格和彻底,以确保软件质量。
基于主干的开发也与敏捷开发方法中提倡的快速开发和短迭代周期很好地结合。快速的迭代和短的反馈循环使团队能够更快地演进软件产品。
频繁的提交和重新基座也减少了合并冲突解决的复杂性。任何集成问题都可以在早期识别和修复,因此工程师的反馈循环更短。分支管理简化了,因为主干分支是唯一关注的焦点。
在主干基于开发中,长期存在的分支是一个反模式。对于较大的特性开发,变更需要分割成多个分支,因此需要对主干分支进行多次提交。
工程师需要在合理地分割变更时做出判断。这要求工程师有更多的思考和纪律,以确保每个提交到主干的变更都是安全的,并且可以被发布。特性标志是在开发中隐藏特性同时仍然允许代码发布到生产环境的常用技术。特性标志将在下一节中介绍。
工程师不小心添加了尚未准备好生产的更改,尽管这些更改已经合并到 Trunk 中,这种情况并不少见。更糟糕的是,之后还有其他更改被提交。此时,Trunk 还未准备好发布。这种情况让工程师面临一些艰难的选择:
-
Roll back:撤销所有提交,使 Trunk 仍然安全发布。这将生成一个额外的提交用于回滚。在回滚后,重新应用来自新分支的其他所需更改。
-
** cherry-picking**:从最新的 Trunk 创建一个新分支,并仔细撤销不希望的变化。将此分支合并回 Trunk。
-
Roll forward:从最新的 Trunk 创建一个新分支,并继续对变化进行工作,重点是使 Trunk 再次准备好发布。将此分支合并回 Trunk。
所有上述选择都需要工程师谨慎执行,并且都不是容易的。许多工程师宁愿前进以保持开发流程的连续性,而牺牲在 Trunk 中留下一些不适合发布的提交。
基于功能和基于 Trunk 的开发比较
在基于功能和基于 Trunk 之间的选择高度相关于团队和组织的工作方式。这是一个康威定律适用的例子,如在第第一章中所述,组织结构影响软件开发过程。
基于功能的开发 | 基于 Trunk 的开发 |
---|---|
长期分支 | 短期分支 |
专门的发布分支 | Trunk 是发布分支 |
在长期分支中开发大型功能 | 将大型功能拆分为多个短期分支 |
合并和变基操作中的高复杂性 | 合并和变基操作中的低复杂性 |
集成问题的反馈较慢 | 集成问题的反馈较快 |
独立开发 | 协作开发 |
回滚更常见 | 前进更常见 |
用于紧急问题的热修复分支 | 没有热修复分支的概念 |
发布分支是稳定的 | 在主分支中修复不可发布提交的复杂操作 |
发布频率较低 | 发布可以更频繁 |
表 15.2 – 基于功能和基于 Trunk 的开发比较
这两种开发方法都有其优点和权衡。最终的选择取决于团队规模、项目复杂度和开发实践。一些团队可能会采用混合方法,以最大化每种方法的优势并减少其缺点。
例如,一个团队可能会决定采用基于 Trunk 的开发,但创建一个发布分支以针对每个发布。这减少了撤销不希望的变化以进行发布的需要,并将发布准备与其他正在开发的功能的开发隔离开来。
上述例子可以被视为一种发布策略;然而,我们还想介绍更多发布策略,并将它们放在下一节中介绍。
发布与部署与推出
一旦应用程序准备发布,就会面临如何将其交付给客户和最终用户的一系列新的关注点和策略。在历史上某些时期,这只是一个是否发布的二元条件。现代发布更加复杂和精细。
首先,我们需要区分三个概念:发布、部署和推出。这些概念可能在某些组织中可以互换使用;然而,它们之间存在细微的差别,应该进行讨论。
部署是最不容易误解的概念。部署意味着从源代码和配置构建的可执行软件工件已加载到目标环境中。本节重点介绍生产环境。这是一个操作和技术任务,通常通过脚本自动化,可选地涉及人工干预的审批流程。
发布意味着应用程序现在在目标环境中可供用户使用。有时,我们可能还会说发布一个功能,这意味着使该功能可供用户使用的意图。部署是发布的先决条件。即使应用程序已经部署,应用程序中的某些功能可能仍然不可用给某些用户。
推出是这三个概念中最不涉及技术的一个。术语推出并不意味着启动应用程序。相反,它意味着软件产品被营销、广告,并且可选地举办新闻发布会或展览等推出活动。推出软件产品不是 CI/CD 生命周期的组成部分。发布是推出的先决条件,因为用户需要访问应用程序。推出将涉及市场推广计划,涉及各种非技术利益相关者(例如,市场营销、销售、客户服务等)。
通过这些区分,软件产品的发布策略与 CD 相关的复杂性在于。接下来,我们将介绍一些常见的发布策略。
蓝色-绿色发布
蓝色-绿色发布保留应用程序在生产环境中的当前版本(蓝色),并将新版本(绿色)部署到生产环境的副本中。这种策略允许在保持当前版本对用户可用的情况下部署新版本的应用程序。
这种策略通常适用于服务器端基于 Web 的应用程序以及域名系统(DNS)将 Web 请求路由到应用程序的新版本或当前版本。蓝色-绿色发布策略可以在图 15.4中看到:
图 15.4 – 蓝色-绿色发布的示例
默认情况下,DNS 将 Web 请求路由到Zone 1(蓝),其中运行着当前版本的应用程序。蓝区的应用程序只与彼此通信,通过将它们保持在相同的本地****网络(LAN)中。
与通用的api.contract.system
相比,api.zone2.contract.system
)。工程师甚至可以排查和修复在新版本中发现的问题。
一旦新版本经过验证并准备好供用户使用,可以在 DNS 中切换流量,因此 Web 请求被路由到Zone 2。Zone 2现在是有效的新的蓝区。没有更多的绿区,如图 15.5所示。5*:
图 15.5 – 蓝绿发布(切换后)
Zone 1现在运行着可以关闭到下一次部署的旧版本应用程序,这使得该区域成为新的绿区。重要的是要注意,蓝/绿是区域将随着时间的推移相互翻转的角色。
蓝绿发布不同于滚动部署。滚动部署旨在在部署进行时保持服务可用。滚动部署通常采取以下步骤:
-
将当前应用程序实例的数量缩减到一。
-
将应用程序的新版本部署到目标数量的新实例。
-
启动新版本的所有实例。
-
通知负载均衡器新运行实例的存在,或者让它们被发现。
-
一旦探测端点确认新版本的实例正在运行,关闭旧的应用程序实例。
与蓝绿发布不同,工程师和 QA 没有时间窗口可以在用户无法访问的情况下测试应用程序的新版本。此外,这是一种部署技术。它不关心应用程序是否对用户可用。它只关心应用程序是否已过渡到新版本,而没有中断或停机。
暗色发布/功能开关
有方法可以在不向用户开放的情况下验证新功能。新功能可以正常部署到生产环境,但仍然对用户隐藏,使用功能开关(也称为暗色发布)。
特性开关可以通过几种方式管理:
-
集中式:有一个中心服务或资源决定某个功能是否对特定用户可用。
-
个人:每个组件或服务管理自己的功能开关。
-
按请求:使用一个非公开的请求参数来启用功能以服务此请求。
无论如何管理特性标志,这种方法都允许工程师和 QA 在特性对用户可用之前验证预发布特性。只有当团队对验证结果满意时,特性才会打开。此外,即使在特性对用户可用之后,也可以将其关闭,从而最大限度地减少相关问题的影响。
特性标志与** trunk-based development**实践相结合效果良好,如前文所述。当特性标志关闭时,工程师可以继续开发特性并将他们的更改合并到主干。然而,所有自动化测试都必须通过,以确保现有功能在代码库中未完成和关闭的特性下仍然按预期工作。
特性标志主要关注发布和将特性提供给用户。一些组织可能会将特性标志扩展到支持用户细分和 A/B 测试,如第十三章中所述。这并不是反模式,而只是对特性标志系统的一种增强。
金丝雀发布
金丝雀发布是一种分阶段发布策略,在向所有目标用户推出之前,先将新部署的软件提供给一小部分用户。它是在应用程序层面操作,而不是在应用程序中的某个特性。它旨在逐步向用户提供可用性,并且在用户初始选择和可用性战略增加方面有很多变化。有几个因素会影响这一策略:
-
团队可能希望邀请主题专家或领域专家首先使用该应用程序,以收集可能在公众使用之前塑造产品的反馈。
-
组织可能希望最初将软件产品发布到选定的地理区域用户。这可能是由于应用程序的地理背景、法律限制或相关的营销活动。组织可能希望逐步扩大其地理领土。
-
团队可能希望最初将应用程序发布到某些类型的设备或设备的操作系统。这可能是由于兼容性考虑,特别是在 Android 应用程序领域,或者由于市场上某些设备的流行。
金丝雀发布有时会被误认为是 beta 测试的概念。在软件产品被内部 QA 签批后,一些组织可能希望邀请专家用户进行内部测试。通常使用白盒测试技术进行内部测试,如第十三章中所述,这被称为alpha 测试。在公开发布之前由外部用户进行的测试称为beta 测试。
然而,由于正在测试的软件产品版本通常不是最终版本,因此 beta 测试不被视为生产发布。beta 测试仅限于有限的时间。
从选定的用户那里收集反馈和建议,以验证产品概念、可用性和功能。它们也用于产品的进一步改进。发布软件产品的非最终版本进行 beta 测试不需要使用金丝雀发布策略。
选择发布策略
选择发布策略并不容易或直接。然而,我们绝对不推荐像我们在第六章中讨论的那样进行大爆炸发布并计划停机,因为决策中涉及其他因素,如基础设施准备情况、营销策略、服务正常运行时间目标等。从纯粹的技术角度来看,我们可以使用决策树来推荐发布策略,如图15.6所示:
图 15.6 – 发布策略的技术决策树示例
在应用程序中发布一个新功能时,应首先考虑使用功能标志或暗色发布,因为这与其他选择相比成本最低。如果涉及到发布整个应用程序,并且是一个服务器端应用程序,可以考虑使用蓝绿发布或金丝雀发布。如果应用程序涉及桌面、移动或 Web 应用程序,那么应考虑使用金丝雀发布。
请注意,这更多是关于哪些发布策略可以使用的技术限制,而不是考虑由于涉及的其他非技术因素而提供的最佳结果。
我们已经讨论了 CI/CD 的讨论。一个高度自动化的 CI/CD 管道可以为工程师节省大量时间,以便与其他人的工作集成并将应用程序交付到多个环境。从某种意义上说,它改善了软件产品工程师的工作体验。在下一节中,我们将进一步探讨开发者体验的主题。
开发者体验
开发者体验(DX)是工程师在开发软件时的体验。它包括从工具、流程、环境、团队、组织到开发文化的一切。在接下来的几节中,我们将提出一些改进 DX 的建议。
为什么 DX 很重要?
从表面上看,DX 可能看起来是任何参与项目的人的一般满意度。然而,优秀的 DX 不仅给工程师本人带来很多好处,也给软件产品和组织带来很多好处:
-
生产力和效率:高效的工具、自动化的工作流程、简洁的文档和流畅的开发流程减轻了工程师的负担,使他们能够专注于编码和解决问题。
-
产品质量:稳定的环境和直观的工具减少了工程师日常工作的摩擦,使他们能够专注于以关注细节的方式交付高质量的代码。
-
运营成本:自动化的工具和流程降低了工程师支持系统的劳动力成本。更好的工具导致错误更少,从而减少了修复问题的时间和资源。
-
学习曲线:简化的入职流程、良好的文档和直观的过程使新工程师更快地熟悉并变得高效。
-
协作:良好的 DX 鼓励团队内部的沟通和协作,提高团队士气。它还改善了工程师与利益相关者之间的互动,从而带来更好的结果。
-
创新:一个安全和支持的环境培养了创新和实验的文化。工程师可以在不担心失败的情况下发挥创造力。高质量的工具有助于共同尝试新想法并集体学习。
-
工作满意度和留存率:令人满意的工作环境使工程师更加投入和有动力。当他们享受自己的工作时,员工流失率可以降低。反过来,这可以节省招聘和培训的时间和资源。
-
人才招聘:如果一个组织在技术社区中享有卓越的 DX 声誉,它将吸引顶尖人才加入团队并提升组织的品牌形象。
DX 在交付高质量软件产品中发挥着有影响力的作用,最终导致更健康的企业和更好的商业成果。我们将介绍一些可以改善 DX 的领域。
入职
除了一般的入职流程外,还应该为工程主题提供全面的文档,例如以下内容:
-
开发流程(看板、敏捷等)。
-
组织使用的技术栈。
-
内部库和框架。
-
如第一章中提到的架构决策记录(ADRs)。
-
工程实践和约定。
-
设置专门的工程环境(例如,后端、前端、数据、平台等)。
这些文档最好由加入组织的每位新工程师更新,因为这可以保持信息的时效性。
指派一名技术同伴作为入职伙伴是一个有益的欢迎举动。入职伙伴可以帮助新工程师设置工作环境、请求适当的权限、将新工程师添加到相应的沟通渠道,并回答问题。
工具
工具在工程师的生产力中扮演着重要角色。投资于良好的工程工具不仅带来卓越的开发体验(DX),还为工程团队带来了实际的生产力。这些工具包括但不限于以下内容:
-
一个用于访问所有其他第三方工具的单点登录(SSO)门户。通过中央门户登录这些工具,而不是让每个人记住每个第三方工具的密码,可以节省大量时间。这适用于所有超出工程工具的工具。
-
一种简化的登录方法,在不影响安全和审计的情况下访问任何环境或基础设施。历史上,工程师需要使用终端工具等手段,如安全外壳协议(SSH)来访问服务器。在当今时代,有更好的方法在任何环境中访问服务器。这些工具(如 Teleport、JumpCloud、CyberArk 等)支持简单的请求-审批工作流程,围绕终端工具建立隧道,限制某些访问,并在访问过程中记录所有活动,以确保环境安全。
-
每位工程师可能都有自己的工具包,例如某些任务的脚本。建议有一个源代码库来托管所有这些脚本和小型工具包,使所有工程师都能访问。这不仅可以让组织吸收工程师的秘密强大脚本,还可以建立一个标准工具包,帮助工程师完成日常任务。
-
在某些情况下,工程师需要调用 API 端点来执行某些任务,例如通过请求发布者再次发布所有消息来重新激活事件主题。建议组织拥有一个共享的操作端点集合,以减少工程师自己构建这些步骤所需的时间。此外,这些 API 端点可能需要获取一个作为令牌的授权令牌来调用端点(参见第十四章以获取详细流程);获取这些令牌的编排应被脚本化并在工程师之间共享。此类工具的例子是Postman Collections。
-
Kotlin 项目的依赖项随着时间的推移会默默过时。使用自动化工具如Dependabot自动创建更新依赖项的拉取请求。
-
在软件开发的多领域使用人工智能(AI)应用。除了 IDE 提供的正常语法代码补全功能外,还有通过理解现有代码库来提示语义代码补全的人工智能助手。还有嵌入的大型语言模型(LLM)聊天机器人,它们提供带有示例代码片段的实时技术建议。还有理解拉取请求意图并提供有用反馈的拉取请求机器人。
开发流程
组织应该投资于支持软件开发流程的工具。每个团队可能采用了略微不同的方法,如 Scrum 或 Kanban,并且团队需要一个仪表板来运行每日站立会议。
随着远程办公成为一种趋势,组织应该投资于诸如问题跟踪系统、数字看板或 Scrum 看板以及在线回顾工具等工具,以使团队能够运行任何软件开发流程。
沟通与协作
现代组织已经投资了多个即时通讯、视频会议、屏幕共享和配对以及电子邮件的通信工具。建议仅将电子邮件用于外部沟通。即时通讯应该是主要的沟通方式。
此外,建议每个团队都有一个渠道,工程师可以在其中大声思考他们的问题解决方法。渠道的成员应养成使用线程发布特定问题消息的习惯。通过集中讨论问题,可以引入 AI 机器人来总结讨论,以便其他人跟上进度。
即时通讯系统应与其他工具集成,以便工程师能够一站式接收通知并反应性地工作。这可以包括从设计协作工具接收视觉设计变更的通知、问题管理工具中问题变更的通知、监控工具的警报、拉取请求的批准,或者简单的每日会议日程摘要。
工程师需要拥有能够立即加入远程配对编程小组的工具,这些工具能够共享屏幕甚至接管输入设备的控制。
一些 IDE,如 IntelliJ Ultimate,具有运行配对编程会议的能力。这些工具增强了工程师之间的协作并鼓励集体学习。
有其他工具可以增强工程师与其他团队学科的协作。例如,可以使用 Figma 或 Miro 等在线协作设计工具作为工程师与设计师和产品经理一起工作的草图板,以了解需求。
同一专业领域的工程师(后端、前端、数据、测试、平台等)可以拥有自己的论坛,作为行会,工程师可以在其中分享他们的学习和讨论与工作密切相关的话题。
反馈和持续改进
定期从工程师那里获得定性和定量反馈以发现任何改进空间非常重要。建议定期进行问卷调查、反馈会议或回顾会议,以了解工程师的满意度并确定改进领域。
如在第第十一章中讨论的,SPACE指标提供了一个全面和全面的工程团队生产力和福祉评估。
最重要的是,工程管理需要认可反馈并实施相应的变化以改善开发体验(DX)。被倾听并看到反馈被采纳将极大地提高工程团队的满意度和士气。
认可和奖励
工程团队和管理层应该认可和庆祝团队或个人的成就。这可以通过明确指出团队及其成员的成就,或者为成功的发布、创新想法和实施的改进提供实际奖励来实现。认可和奖励可以提升士气和动力。
关于软件架构的最终思考
随着本书的结束,我想分享我对软件架构的最终思考。我将涵盖当前趋势以及我们如何装备自己以驾驭不断变化的浪潮。
小型、响应式和独立的服务
虽然正如在第六章中讨论的那样,有一些理由可以写出一个单体应用,但软件组件正变得越来越小。如今,很少看到有新项目是以成为单体为目标而编写的。相反,工程师会积极地将小型服务分离出来,这些服务通过 API 或事件进行通信。
小型服务提高可扩展性、灵活性和部署的简便性。团队可以独立地开发、部署和扩展服务。微服务和纳米服务足够小,可以由一个团队拥有,因此它们鼓励自主性,并赋予团队做出自己决策的能力。即使做出了错误的决定(例如,选择了错误的框架),相对而言,在不影响其他软件组件的情况下重新定位和重构服务也相对容易。
应用逻辑也从命令式转变为响应式,从同步处理转变为异步处理。许多新系统采用事件驱动架构和响应式处理。许多业务用例不需要完成所有操作才能收到响应。许多处理可以通过实时异步地响应事件来完成。我们在第九章中探讨了 CQRS 和事件溯源的结合,它提供了一个在早期响应同步请求并异步执行其余过程的示例。
将系统拆分为小型服务带来了保持整体系统行为一致性的挑战。这导致了对幂等性、复制和恢复的讨论,正如在第十章中所述。
这种思维方式的改变提高了响应性和可扩展性,使得与众多服务集成以及处理高吞吐量场景变得更加容易。我们在第十二章中深入探讨了性能和可扩展性。我们还在
左移范式——API 优先和安全性优先的方法
继续进行软件组件精简的过程中,API 变得更为重要。事实上,API 优先的设计方法在当今越来越受欢迎。API 优先的方法倡导在功能实际实现之前先开发 API。
这种方法促进了团队之间的沟通和协作。它提前解决了消费者端(例如,另一个服务或前端应用程序)的开发问题,并允许并行开发。首先开发 API 还让工程师在实施之前发现边缘用例的视角。我们在第四章中提供了一个使用 OpenAPI 规范示例。
如第十四章中讨论的,DevSecOps 的日益普及将安全问题带到了工程师的注意中。通过将安全实践集成到软件开发中(例如,威胁建模),团队可以解决安全问题并将它们纳入早期技术设计。这种方法提高了应用程序的安全态势并减少了漏洞。
云和服务器端架构的影响
云计算和无服务器架构已经永远改变了软件开发的面貌。云提供商管理基础设施并自动扩展资源。工程师可以专注于用代码满足业务需求,而不是解决基础设施问题。此外,无服务器架构具有成本效益并降低运营成本。
云环境中的众多服务和工具,如 Kubernetes,允许应用程序利用云提供商的功能来提高可扩展性和弹性。
此外,云上运行着广泛的 PaaS 和 SaaS 服务,这使得将这些服务与任何应用程序集成变得更加容易。这些服务包括关系数据库、消息服务、身份提供者、电子邮件服务,甚至完整的企业级软件系统。我们在第六章中讨论了无服务器架构。
可组合架构
小服务的理念扩展到将软件分解成独立的模块或组件,这些模块或组件可以轻松组装或替换。这种构建块方法导致了可组合架构,其中每个块旨在解决特定问题。这些模块被设计成内聚的,以便与任何应用程序集成。
这种方法增加了整体架构的敏捷性和灵活性。它使应用程序能够快速适应技术,并以较低的成本进行多种选项的实验。
例如,在许多章节中提到的家庭服务可以用于除家庭交换服务以外的商业问题。由于其模块化和高度内聚性,该服务可以轻松地与其他系统(例如,投票、回收等)集成。我们在第八章中详细讨论了如何定义边界上下文,从而产生定义良好的服务。我们还在第七章中讨论了分层和模块化架构。
架构模式持续增长和演变。拥有可组合架构使得每个模式可以独立地进步和改进。
可观察性和监控
可观察性和监控已成为当今软件架构的一个基本组成部分。工程师不仅希望通过日志、跟踪和监控工具了解系统行为,还希望更快地识别问题。
可观察性和监控工具在云中运行,易于集成,通常使用如第十四章中提到的 Sidecar 模式,这也是一种可组合架构的方法。
审计也已成为软件产品的一个重要方面。能够理解系统中执行的操作,可以深入了解技术和业务流程。这对于需要遵守规定的系统尤其有帮助。
我们在第十一章中讨论了审计和监控模型。
AI 和机器学习
AI 和机器学习(ML)正在显著塑造软件架构的未来。ML 是 AI 的一个子集,专注于从数据中学习以生成预测并帮助决策。
AI/ML 需要消耗大量数据来训练其模型,这使得软件架构越来越以数据为中心。使用 AI/ML 的系统需要有效地收集、存储和处理大量数据。
云服务提供商提供现成的 AI/ML 服务,工程师可以无需管理基础设施即可运行它们,并可以根据需求自动扩展它们。这些服务(例如 AWS Bedrock、Azure OpenAI 和 GCP Vertex AI)包括自然语言处理、行为分析、预测分析、生成推荐、数据摘要和模式识别,而且这个列表还在增长。
更好的是,这些 AI/ML API 使用工程师已经熟悉的格式和传输方式,例如 JSON 有效载荷和 HTTP 请求。工程师可以利用高级 AI 功能,而无需深入了解 AI/ML。这种易于集成的特性加速了开发周期。
除了在商业应用中使用 AI/ML 之外,它们还用于监控、事件响应、威胁检测、语义代码补全和工程师的聊天框支持。由 AI/ML 驱动的无处不在的功能正在推动软件架构的转型性变革,促进以数据为中心的设计、模块化和将高级分析集成到应用程序中。
AI 和 ML 不仅将塑造应用程序的构建方式,还将塑造它们与实时数据的交互方式。我们仍在见证 AI 和 ML 的演变,我们还没有看到它们的全部规模。
摘要
我们讨论了一些可以帮助工程师实现更好架构的 Kotlin 语言特性,包括扩展函数、中缀函数、操作符重载和作用域函数。
然后,我们介绍了将 Java 项目过渡到 Kotlin 项目的必要步骤。我们详细介绍了将 Java 类转换为惯用 Kotlin 类所需的工具和手动修正。我们还提到了在转换过程中提高代码质量的一些机会。我们深入探讨了转换策略,包括转换顺序和框架转换。我们强调了持续过渡到 Kotlin 的重要性,以及过渡如何可以逐步在日常业务功能编码工作中进行。
我们转向了 CI/CD 的话题,并介绍了两种主要的集成方法:基于功能的开发和基于主干线的开发。我们比较了它们在组织结构背景下的优缺点和适用性。
我们区分了三个概念:软件产品的部署、发布和上线。然后,我们介绍了三种发布策略:蓝绿部署、功能标志和金丝雀发布。我们展示了一个示例决策树,以帮助工程师从技术角度选择合适的发布策略。
我们还涵盖了 DX 的话题,解释了其重要性以及它如何使软件产品、团队和组织受益。
我们提出了许多改进 DX 的建议,包括入职、工具、开发流程、沟通、协作、反馈、持续改进、认可和奖励。
最后,我们回顾了整本书中讨论的软件架构,重点关注当前趋势和未来发展方向。