本章介绍软件架构在软件开发中所扮演的角色,重点介绍设计C++解决方案架构时需要记住的关键方面。我们将讨论如何设计具有便利性和功能性接口的高效代码。我们还将介绍针对代码和架构的领域驱动方法。
要使用本章中的代码,需要准备:
❑Git客户端,用于签出(check out)即将给出的代码库。
❑兼容C++20的编译器,用来编译所有的代码片段。大多数编译器都是用C++11/14/17编写的,但是需要有C++20的概念支持,以便对少数涉及C++20的代码进行实验。
❑代码片段的GitHub链接,即https://github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter01。
❑GSL(Guidelines Support Library,指南支持库)的GitHub链接,即https://github. com/Microsoft/GSL。
我们首先定义软件架构的实际含义。当你创建一个应用程序、库或任意软件组件时,你需要考虑你编写的元素看起来是什么样的,以及它们之间将如何交互。换句话说,你是在设计它们以及它们与周围环境的关系。就像城市建筑一样,重要的是要考虑更大的图景,以避免陷入一种无计划的混乱状态。在小范围内,每一栋建筑看起来都不错,但它们不能组合成一个合理的、更大的图景——它们不能很好地结合在一起。这就是所谓的意外架构(accidental architecture),这是应该避免的结果之一。然而,请记住,无论你是否用心构思架构,在编写软件时,你实际就是在构造架构。
那么,如果想认真地定义解决方案的架构,到底应该构造什么呢?软件工程研究所是这样说的:
系统的软件架构是分析系统所需要的一组结构,包括软件的组成元素、元素之间的关系以及两者的属性。
这意味着,为了彻底地定义一个架构,我们应该从几个角度来考虑它,而不是直接编写代码。
我们可以从以下几个层面研究架构:
❑企业级架构考虑的是整个公司,甚至是一组公司。它采取了一种整体的方法,关注整个企业的战略。在考虑企业级架构时,应该关注公司的所有系统是如何工作和相互协作的。它关心的是业务和IT之间的协调对齐。
❑解决方案架构不如企业级架构抽象。它介于企业级架构和软件架构之间。通常,解决方案架构关注的是特定的系统及其与周围环境的交互方式。解决方案架构师需要找到一种方法来满足特定的业务需求,通常通过设计一个完整的软件系统或修改现有的软件系统来实现。
❑软件架构则比解决方案架构更加具体。它专注于特定的项目、所使用的技术以及如何与其他项目交互。软件架构师对项目组件的内部结构很感兴趣。
❑基础设施架构,顾名思义,关注的是软件使用的基础设施。它定义了部署环境和策略、应用程序的扩展方式、故障转移处理、站点可靠性,以及其他面向基础设施的各方面。
解决方案架构同时以软件架构和基础设施架构为基础,这样才能满足业务需求。接下来的两个部分将讨论这两个方面,以便让你为小规模和大规模的架构设计做好准备。在讨论它们之前,我们还要回答一个基本问题:为什么架构很重要?
实际上,一个更好的问题是:为什么关心架构很重要?正如我们前面提到的,无论你是否有意识地努力去构建它,最终你都会得到某种架构。如果经过几个月甚至几年的开发,仍然希望软件保持较高的质量,那么需要尽早采取一些措施。如果不考虑架构,那么软件很可能永远不会达到要求的质量。
因此,为了使产品满足业务需求和性能、可维护性、可伸缩性等属性,你需要设计它的架构,并且最好尽早完成。现在,我们讨论每个优秀架构师都希望他们的项目避免发生的两件事。
即使在完成了最初的工作并构思了特定的架构之后,也需要不断监控系统的发展方式,以及它是否仍然符合用户的需求,因为这些在软件的开发过程和整个生命周期中可能发生变化。软件腐朽(software decay),有时也称为软件侵蚀(software erosion),发生在软件的实现决策与之前规划的架构不对应时。所有这些差异都应被视为技术债务。
未能跟踪开发是否遵循所选择的架构或未能对架构进行有意识的规划,往往会导致所谓的意外架构(accidental architecture)。即使在其他领域应用了最佳实践(如进行了测试或遵循了特定的开发文化),意外架构也可能发生。
有几个反模式(anti-pattern)表明架构是“意外的”。类似于“大泥球”的代码是最明显的一个。拥有上帝对象(god object)
是另一个重要的标志。一般来说,如果软件耦合得比较严重——可能还有循环依赖的问题(但一开始并非这样),那么这就是一个重要的信号,它告诉你需要在架构设计上投入更多精力了。
现在,我们描述一下架构师必须掌握什么才能交付可行的解决方案。
辨别架构好坏很重要,但这不是一件容易的事。识别反模式是它的一个重要方面,但要设计一个好的架构,首要必须满足交付软件的期望,包括功能性需求、解决方案的属性,以及各方面的约束。其中许多约束可以很容易地从架构上下文中衍生出来。
架构上下文是架构师在设计可靠的解决方案时需要考虑的内容,它包括来自相关方(stakeholder)以及业务和技术环境的需求、假设和约束。它会影响相关方和环境,例如,允许公司进入新的细分市场。
相关方指所有与产品有关的人。这些人可以是客户、系统用户或者管理人员。沟通是每个架构师必须掌握的关键技能,正确地管理相关方的需求是满足他们的期望(并以他们想要的方式实现)的关键。
不同的相关方关注的事情不同,所以需要试着了解这些群体关注的内容。
客户关心的可能是编写和运行软件的成本、软件提供的功能、软件的生命周期、上市时间以及解决方案的质量。
系统用户可以分为两组:最终用户和管理员。前者通常关心软件的易用性(usability)、用户体验和性能。对于后者,更重要的方面是用户管理、系统配置、安全性、系统备份和系统恢复。
最后,对于从事管理工作的相关方来说,重要的事情是保持较低的开发成本,实现业务目标,跟上开发进度,以及保持产品质量。
架构会受到公司业务的影响,关键因素包括从产品策划到上市的时间(Time-To-Market,TTM)、产品推出时间表、组织结构、人力的使用和对现有资产的投资。
我们所说的技术环境,是指已经在公司中使用的技术以及那些不管出于何种原因需要成为解决方案的一部分的技术。我们需要集成的其他系统也是技术环境的重要组成部分。现有软件工程师的技术专长在这里也很重要:架构师做出的技术决策会影响项目的人员配备,初级和高级开发人员的比例会影响项目的管理方式。好的架构应该考虑到所有这些因素。
有了这些知识,现在我们讨论一个稍有争议的主题,架构师很可能会在日常工作中遇到这个主题。
看起来,架构和敏捷开发方法是一种对抗的关系,围绕这个主题有许多不正确的理念。为了在以敏捷的方式开发产品的同时仍然关心它的架构,你应该遵循一些简单的原则。
本质上,敏捷性是迭代式和增量式的。这意味着在敏捷的架构方法中,不能选择一个大的前期设计。相反,应该提出一个小的、但仍然合理的前期设计。最好的情况是,使用决策日志说清楚每个决策的依据。这样,如果产品愿景发生了变化,架构就可以随之演进。为了支持频繁的版本发布,前期设计方案应该逐步更新。以这种方式设计的架构被称为演进式架构。
管理架构并不意味着要保留大量的文档。事实上,文档应该只涵盖必要的内容,这样就更容易保持它的最新状态。它应该很简单,并且只涵盖与系统相关的内容。
还有一个不正确的理念,它认为架构师都是对的,是最终决策者。在敏捷环境中,是团队在做决策。不过话虽如此,相关方对决策过程的贡献也是至关重要的——毕竟,他们的观点决定了解决方案的最终样子。
架构师仍然是开发团队的一员,因为他们经常会带来强大的技术专业知识和丰富的经验。他们应该参与每次迭代之前进行的评估和计划所需的架构更改。
为了让团队保持敏捷性,应该考虑有效的工作方法,并且只考虑重要的事情。实现这些目标的一种好方法是使用领域驱动设计。
领域驱动设计(Domain-Driven Design,DDD)是Eric Evans在他的同名书中介绍的一个术语。从本质上讲,领域驱动设计关注的是如何改善业务和工程之间的沟通,让开发人员关注领域模型。基于这个模型的实现通常会使设计更容易理解,并随着模型的变化一起发展。
领域驱动设计和敏捷有什么关系?我们回顾一下《敏捷宣言》:
个体和互动 高于流程和工具
工作软件 高于详尽的文档
客户合作 高于合同谈判
响应变化 高于遵循计划
——敏捷宣言
为了做出正确的设计决策,必须首先了解该领域。要做到这一点,需要经常与开发人员交谈,并鼓励开发团队拉近与业务人员之间的距离。代码中的概念应该以通用语言中的词条命名。它应该是业务专家术语和技术专家术语的共同部分。使用双方有不同理解的术语可能会导致无数的误解,从而导致业务逻辑实现中存在缺陷和难以察觉的bug。小心地命名并使用双方约定的术语,项目可以避免很多麻烦。让业务分析师或其他业务领域专家加入团队可以提供很大的帮助。
如果要建模的是更大的系统,那么可能很难让所有的术语对不同的团队而言都具有相同的意思。这是因为每个团队都有自己的上下文。领域驱动设计建议使用有界上下文(bounded context)来处理这个问题。如果要建模的是电子商务系统,你可能想要从购物场景来理解术语,但仔细观察,你可能会发现负责库存、物流和会计的各个团队实际上都有自己的模型和术语。
这些都是电子商务领域的不同子领域。理想情况下,每个都可以映射到自己的有界上下文——系统中具有自己的词汇表的部分。当将解决方案分解成更小的模块时,明确设置这种上下文的边界是很重要的。类似地,每个模块都有明确的职责,都有自己的数据库模式和自己的代码库。为了帮助大型系统的各团队之间进行沟通,可能需要引入一个上下文映射,以显示不同上下文的术语如何相互关联,如图1.1所示。
上面讨论了一些重要的项目管理主题,接下来我们切换到更多的技术主题。
图1.1 两个具有匹配项的有界上下文(图片来自Martin Fowler关于DDD的一篇文章,见https://martinfowler.com/bliki/BoundedContext.html)
现在,我们来看即将在本书中使用最多的编程语言C++。C++是一种多范式的语言,已经存在了几十年。自诞生以来,C++发生了很大的变化。当C++11问世时,C++语言的创造者Bjarne Stroustrup说,C++11感觉像是一种全新的语言。C++20的发布标志着这头“野兽”进化的另一个里程碑,为编写代码的方式带来了类似的革命。然而,这些年来,有一件事是不变的:语言的哲学思想。
简而言之,C++的哲学思想可以概括为三条规则:
❑C++底层不应该基于任何其他语言(汇编语言除外)。
❑只为使用的东西付费(不需要为没有使用到的语言特性付费)。
❑以低成本提供高级抽象(更高的目标是零成本提供高级抽象)。
不为没有使用的东西付费意味着,如果想在栈(stack)上创建数据成员,那么就可以这么做。许多语言会在堆(heap)上分配对象,但这对于C++来说不是必要的。在堆上分配对象会有一些代价——可能分配器必须为此锁定互斥锁,这在某些类型的应用程序中可能是一个很大的负担。这么做的好处是,可以轻松地分配变量,而无须每次动态分配内存。
高级抽象是C++与C或汇编语言等低级语言的区别。高级抽象允许直接在源代码中表达思想和意图,这非常有利于保证语言的类型安全性。请考虑以下代码片段:
更好的做法是利用语言提供的类型安全特性:
上述抽象可以使我们免于犯错误,并且这样做时不会付出任何代价。它生成的汇编代码与第一个示例的相同。这就是为什么它被称为零成本抽象。有时,在C++中使用抽象实际上会比不使用抽象产生更好的代码。这种语言特性的一个例子是C++20的协程(coroutine)。
由标准库提供的另一组优秀的抽象集是算法。以下哪段代码更容易阅读,也更容易证明没有bug?哪个能更好地表达编程想法?
第二个函数有一个不同的接口,但即使保持接口不变,我们也可以从指针和字符串长度来创建std::string_view。因为它是一种轻量级的类型,所以应该由编译器对它进行优化。
使用更高级的抽象会让代码更简单、更可维护。C++语言从诞生以来就一直在努力提供零成本的抽象,所以我们应该在此基础上构建程序,而不是使用较低层次的抽象来重新设计。
下一节将介绍一些原则和启发式方法,它们在编写简单可维护的代码时非常有用。
在编写代码时,有许多原则需要记住。在编写面向对象的代码时,你应该熟悉抽象、封装、继承和多态性这四个基本概念。无论是否以面向对象的编程方式编写C++代码,你都应该记住SOLID和DRY代表的原则。
SOLID是一组实践,它可以帮助你编写更简洁、更不容易出现bug的软件。SOLID是一个首字母缩写词,由它背后的五个概念各自的第一个单词的首字母组成:
❑单一责任原则( S ingle Responsibility Principle,SRP)。
❑开放封闭原则( O pen-Closed Principle,OCP)。
❑里氏替换原则( L iskov Substitution Principle,LSP)。
❑接口隔离原则( I nterface Segregation Principle,ISP)。
❑依赖倒置原则( D ependency Inversion Principle,DIP)。
我们假设你已经知道了这些原则与面向对象编程的关系,但是由于C++并不总是面向对象的,因此我们将看看如何将它们应用于不同的领域。
有些示例使用了动态多态,但这也同样适用于静态多态。如果你正在编写面向性能的代码(如果你选择了C++,则可能是这样),那么你应该知道,就性能而言使用动态多态可能不是一个好主意,特别是在热门路径上。在本书中,你将学习如何使用 奇异递归模板模式 (Curiously Recurring Template Pattern,CRTP)编写静态多态类。
简而言之,单一责任原则(SRP)意味着每个代码单元应该只有一项职责。这意味着要编写只做一件事的函数,创建代表一种东西的类型,以及构建只关注一个方面的更高级别的组件。
这意味着,如果类管理某种类型的资源——例如文件句柄,那么它应该只做这件事,其他事情——例如文件解析,应交给其他类型去做。
通常,如果函数名称中包含“And”,那么它就违反了单一责任原则,应该进行重构。另一个特征是函数用注释来指示该函数每个部分的作用。每一部分作为一个独立的函数可能会更好。
与之相关的一个主题是最少知识原则(principle of least knowledge)。这个原则说任何对象都不需要知道其他对象的非必要信息,以便不依赖其他对象的内部结构。应用这个原则可以使代码的可维护性更高,元素之间的相互依赖性更小。
开放封闭原则(OCP)意味着,代码对扩展操作开放,对修改操作关闭。对扩展操作开放,意味着我们可以很容易地扩展代码支持的类型。对修改操作关闭,意味着现有的代码不应该改变,因为这通常会导致系统的其他地方出现bug。C++展示开放封闭原则的一个重要特性是ostream操作符<<。为了扩展<<操作符以支持自定义类,所需要做的就是编写类似于下面的代码:
请注意,operator<<的实现是一个自由(非成员)函数。如果可能的话,应该使用成员函数,因为成员函数实际上提高了封装性。有关这方面的更多细节,请参阅“进一步阅读”中Scott Meyers的文章。如果不想让输出到ostream的某些字段被外部公共访问,则可以将operator<<定义为一个friend函数,比如:
请注意,OCP的这个定义与多态的常见定义略有不同。后者指基类不能修改自己,但开放给其他类继承。说到多态,我们来继续介绍下一个原则,它与正确使用多态有关。
本质上,里氏替换原则(LSP)指出,如果函数可以使用指向基对象的指针或引用,那么它也可以使用指向其派生对象的指针或引用
。这条规则有时会被打破,因为我们在源代码中应用的技术并不总是适用于现实世界的抽象。
一个著名的例子是正方形和矩形。从数学上讲,前者是后者的特例,所以从正方形到矩形存在一种“是”的关系(正方形是矩形)。这将诱使我们创建一个继承自矩形类(Rectangle)的正方形类(Square)。所以,我们最终可能会得到像下面这样的代码:
我们应该如何实现Square类的成员呢?如果我们想遵循LSP,就会让用户对这两个类感到奇怪:如果我们调用setWidth,正方形就不再是正方形了。我们要么不使用正方形(不使用前面的代码),要么同时修改高度(调用setHeight),从而使Square类看起来与Rectangle类的行为不同。
如果代码违反了LSP,则很可能是因为使用了不正确的抽象。在我们的例子中,Square确实不应该继承自Rectangle。更好的方法可能是让Square和Rectangle都实现一个GeometricFigure接口。
既然讨论到了接口,那么我们来接着讨论与接口有关的原则。
接口隔离原则(ISP)就是像它的名字所暗示的那样。其表述如下:
不应该强迫客户端依赖它不使用的方法。
听起来它的意思很明显,但它有一些并不明显的含义。首先,应该倾向于选择更多更小的接口,而不是一个大的接口。其次,当添加派生类或扩展现有类的功能时,应该在扩展接口之前思考一下。
我们来展示一个违反此原则的示例,从以下接口开始:
我们用一个简单的类来实现它:
到目前为止没有什么问题。现在,假设我们想建模另一个更先进的食品加工器(Anoth-erFoodProcessor类),在接口中添加更多的方法:
现在,搅拌机类Blender就有问题了,因为它不支持这个新的接口——没有适当的方式来实现它。我们可以尝试绕过这个问题或抛出std::logic_error,但更好的解决方案是将接口分成两个,每个接口都有单独的职责:
现在,我们的AnotherFoodProcessor类可以同时实现这两个接口,而且我们不需要改变现有的食品加工器(Blender类)的实现。
SOLID还剩最后一个原则,现在我们来介绍它。
依赖倒置原则(DIP)可以用于解耦。本质上,这意味着高级模块不依赖于低级模块,两者都依赖于抽象。
C++允许用两种方法倒置类之间的依赖关系。第一种方法是常规的多态方法,第二种方法是使用模板。我们将看看如何在实践中应用它们。
假设你正在建模一个有前端和后端开发人员的软件开发项目。一种简单的方法是这样写:
每个开发人员(FrontEndDeveloper和BackEndDeveloper)都是由Project类构造的。然而,这种方法并不理想,因为现在高级概念(Project)依赖于低级概念——单个开发人员模块。我们来看使用多态实现的依赖倒置是如何改变这一点的。我们可以将开发人员定义为依赖如下接口:
现在,Project类就不再需要知道开发人员(Developer)的实现了。因此,Project必须接受它们作为构造函数的参数:
在这种方法中,Project与具体的实现解耦了,只依赖于名为Developer的多态接口。“较低级别的”具体类也依赖于这个接口。这可以帮助你缩短构造时间,并让单元测试更简单——现在你可以轻松地将模拟(mock)对象作为参数传递到测试代码中。
然而,用虚分派(virtual dispatch)来实现依赖倒置是有代价的,因为我们处理的是内存分配,而动态分派(dynamic dispatch)本身就有开销。有时,C++编译器可以检测到只有一个实现被用于给定的接口,并通过去虚拟化(devirtualization)来消除开销(通常需要将函数标记为final才行)。但是,这里接口使用了两种实现,因此必须付出动态分派的代价(通常是通过虚函数表跳转,虚函数表也称为vtable)。
还有另一种倒置依赖关系的方法,它没有这些缺点。我们来看如何使用可变参数模板(variadic template)、C++14的泛型lambda和C++17或第三方库(如Abseil或Boost)中的变体(variant)来实现这一点。首先是开发人员(FrontEndDeveloper和BackEndDeveloper)类:
现在,我们不再依赖接口了,所以不会进行虚分派。Project类仍然接受一个Developers(FrontEndDeveloper和BackEndDeveloper)的vector:
你可能不熟悉variant,它只是一个类,可以接受模板参数传递的任何类型。因为我们使用的是可变参数模板,所以我们可以传递任意多类型。要调用存储在variant中的对象的函数,我们可以使用std::get或std::visit和可调用对象来提取它——在本例中是泛型lambda。它展示了鸭子类型是什么样子的。由于所有的开发人员类都实现了develop函数,所以代码可以进行编译和运行。如果开发人员类有不同的方法,则可以创建一个函数对象,通过重载操作符()来处理不同类型。
因为Project现在是一个模板,所以我们必须在每次创建它时指定类型列表,或者提供一个类型别名。最后,我们可以像这样使用这个类:
这种方法保证不会为每个开发人员分配单独的内存或使用虚函数表。但是,在某些情况下,这种方法会导致可扩展性降低,因为一旦声明了variant,就不能向其添加其他类型了。
关于依赖倒置,最后想提一点,有一个名称类似的概念,即依赖注入(dependency injection),我们在示例中使用过这个概念。依赖注入指通过构造函数或设置函数(setter)注入依赖关系,这可能有利于代码的可测试性(例如,考虑注入模拟对象)。甚至有完整的框架用于在整个应用程序中注入依赖关系,比如Boost.DI。这两个概念是相关的,经常一起使用。
DRY是don’t repeat yourself(别重复你自己)的缩写,这意味着应该避免代码重复,尽可能重用代码。也就是说,当代码重复多次类似操作时,应该提取一个函数或函数模板。此外,与其创建几种类似的类型,不如考虑创建一个模板。
同样重要的是,不要重复别人的工作。现在有很多编写得很好且成熟的库,可以帮助我们更快地编写高质量的软件。特别是下面这些:
❑Boost C++库(https://www.boost.org/)。
❑Facebook的Folly(https://github.com/facebook/folly)。
❑Electronic Arts的EASTL(https://github.com/electronicarts/EASTL)。
❑Bloomberg的BDE(https://github.com/bloomberg/bde)。
❑Google的Abseil(https://abseil.io/)。
❑Awesome Cpp列表(https://github.com/fffaraz/awesome-cpp)。
然而,有时重复的代码也有其好处,比如开发微服务时。当然,在单个微服务中遵循D RY原则总是一个好主意,但是对于在多个服务中使用的代码,违反D RY原则实际上是值得的。无论我们谈论的是建模实体还是逻辑,当允许代码重复时,维护多个服务都会更容易。
假设有多个微服务重用了同一个实体的代码。突然,其中一个服务需要修改一个字段。所有其他服务现在也必须进行修改。任何对公共代码的依赖都是如此,会有几十个甚至更多的微服务因为与它们无关的更新而需要修改。因此,在多个微服务间使用重复代码通常更容易维护。
既然我们谈到了依赖关系和代码维护,那么我们继续讨论一个与此密切相关的主题。
耦合和内聚是软件中互相关联的两个术语。我们分别来看它们的含义,以及它们之间是如何相互联系的。
耦合衡量的是一个软件单元对其他单元的依赖程度。耦合度高的单元依赖许多其他单元,耦合度越低越好。
例如,如果一个类依赖于另一个类的私有成员,这就意味着它们是紧密耦合的。第二个类的改动可能意味着第一个类也需要改动,所以这不是一个理想的情况。
为了削弱前面场景中的耦合,我们可以考虑为成员函数添加参数,而不是直接访问其他类的私有成员。
紧耦合类的另一个例子是1.7.5节中的Project类和开发人员类的第一个实现。我们来看如果再添加另一种开发人员类型会发生什么:
看起来不仅仅是添加MiddlewareDeveloper类,我们还必须修改Project类的公共接口。这意味着它们是紧密耦合的,Project类的实现实际上破坏了开放封闭原则(OCP)。为了进行比较,现在我们来看如何将相同的改动应用在使用了依赖倒置的实现中:
此时,不需要对Project类进行任何更改,所以现在这些类是松耦合的。我们所需要做的就是添加MiddlewareDeveloper类。以这种方式构建代码可以实现更少的重建、更快的开发和更容易的测试,代码更少,也更容易维护。要使用新类,只需要修改调用代码:
上面展示了类级别的耦合。在更大的范围内,例如在两个服务之间,可以通过引入消息队列等技术来实现低耦合。这样,这些服务就不会直接相互依赖了,而是只依赖于消息格式。微服务架构一个常见的错误是让多个服务使用同一个数据库,这会导致这些服务之间相互耦合,因为我们无法在不影响使用它的其他微服务的情况下随意地修改数据库模式(database schema)。
下面我们讨论下内聚。
内聚衡量的是软件中单位元素的关联程度。在高内聚的系统中,同一模块中的组件所提供的功能是密切相关的,感觉就像这些组件是共生的一样。
在类级别上,方法操作的字段越多,类的内聚性就越强。这意味着常见的低内聚数据类型是那些庞大的类。当类中发生太多的事情时,它很可能没有内聚,也破坏了单一责任原则(SRP),这些类很难维护,并且容易出现bug。
较小的类也可能不是内聚的。请考虑以下示例。这个例子可能看起来很小,但真实场景的代码通常有成千上万行,都贴过来是不切实际的:
可以看到,我们的处理器类实际上做了三种工作:实际的工作、结果缓存工作和侦听器管理工作。在这种情况下,增加内聚性的一种常见方法是抽象出一个类,甚至是多个类:
现在每个部分都由一个独立、内聚的类来完成。现在也可以很容易地重用这些类。即使将它们变成模板类,也只需要很少的工作。最后,测试这些类也更容易。
内聚原则在组件或系统级别上也很简单——所设计的每个组件、服务和系统都应该很简洁,专注于做一件事并做好它。耦合与内聚的对比如图1.2所示。
图1.2 耦合与内聚的对比
低内聚高耦合的软件通常难以测试,难以复用,难以维护,甚至难以理解,因此它缺乏软件中通常需要的许多质量属性。
这些术语通常会结合在一起考虑,因为通常一个特征会影响另一个特征,不管我们谈论的是函数、类、库、服务,还是整个系统。举个例子,大的单体服务通常是低内聚高耦合的,而分布式服务往往是高内聚低耦合的。
第1章到此结束。现在,我们来总结一下所学到的内容。
在本章中,我们首先讨论了什么是软件架构,以及为什么它值得关注,展示了当架构没有随着不断变化的需求和实现而更新时会发生什么,以及如何在敏捷开发环境中处理架构问题。然后,我们介绍了C++语言的一些核心原则。
我们了解到,软件开发中的许多术语在C++中可以有不同的理解,因为C++编写的不仅仅是面向对象的代码。最后,我们讨论了耦合和内聚等术语。
作为开发人员,你现在应该能够在代码审查中指出许多设计缺陷,能够重新思考解决方案,以获得更好的可维护性,犯更少的错误。现在,你也应该可以设计更健壮、更易懂且更完整的类接口了。
在第2章中,我们将了解不同的架构风格,还将学习如何以及何时可以使用它们来获得更好的结果。
1. 为什么要关心软件架构呢?
2. 架构师应该成为敏捷团队中的最终决策者吗?
3. 单一责任原则与内聚有什么关系?
4. 在项目生命周期的哪些阶段让架构师加入更好?
5. 遵循单一责任原则有什么好处?