软件架构基础——模块化

145 阅读29分钟

架构师和开发人员在模块化的概念上已经挣扎了相当长一段时间,这一点从《组合/结构设计》一书中的这句话可以看出(Van Nostrand Reinhold,1978年):

“关于软件架构的95%的讨论都在赞扬‘模块化’的好处,而很少有人提到如何实现模块化。”

——Glenford J. Myers

不同的平台提供不同的代码重用机制,但都支持将相关代码组织成模块的方式。尽管这一概念在软件架构中是普遍的,但它的定义却一直难以捉摸。通过随便在网上搜索,你会发现数十种不同的定义,没有一致性(甚至有些相互矛盾)。这并不是一个新问题。然而,由于没有公认的定义,我们必须在书中为了一致性而提供我们自己的定义。

理解模块化及其在选择的开发平台中各种表现形式对架构师至关重要。我们用来分析架构的许多工具(如度量、适应度函数和可视化工具)依赖于模块化和相关概念。模块化是一种组织原则。如果架构师设计一个系统时没有注意到各个部分是如何连接在一起的,那么这个系统就会出现各种各样的问题。用物理学的类比来说,软件系统模拟的是复杂的系统,这些系统倾向于走向熵(或无序)。在物理系统中,必须不断地加入能量以保持秩序。软件系统也是如此:架构师必须不断地付出精力以确保结构的健全,这不是偶然能够实现的。

保持良好的模块化体现了我们对隐式架构特征的定义:几乎没有项目要求明确要求架构师确保良好的模块区分和沟通,但可持续的代码库确实需要这种秩序和一致性。

模块化与粒度

开发人员和架构师经常将“模块化”和“粒度”这两个术语交替使用,但它们的含义却截然不同。模块化是将系统拆分成更小的部分,例如从单体架构(如传统的n层架构)迁移到高度分布式的架构风格,如微服务。而粒度则是关于这些部分的大小——系统(或服务)中的某个特定部分应该有多大。然而,正如其中一位作者所说,正是粒度让架构师和开发人员陷入困境:

“拥抱模块化,但要小心粒度。”

——Mark Richards

粒度导致服务或组件彼此耦合,从而产生复杂且难以维护的架构反模式,如意大利面架构、分布式单体架构和著名的“分布式泥球”。避免这些架构反模式的窍门是关注粒度以及服务和组件之间的整体耦合度。

定义模块化

Merriam-Webster 将模块定义为“用于构建更复杂结构的一组标准化部分或独立单元。”然而,在本书中,我们用模块化来描述相关代码的逻辑分组,这些代码可以是面向对象语言中的一组类,或者是结构化或函数式语言中的一组函数。开发人员通常使用模块作为将相关代码组合在一起的一种方式。例如,Java 中的 com.mycompany.customer 包应包含与客户相关的内容。大多数语言提供模块化机制(例如,Java 中的包,.NET 中的命名空间,等等)。

现代编程语言具有各种各样的打包机制,许多开发人员发现很难在它们之间做出选择。例如,在许多现代语言中,开发人员可以在函数/方法、类或包/命名空间中定义行为,每种方式都有不同的可见性和作用域规则。一些语言通过添加编程构造(例如元对象协议)进一步复杂化了这一点,从而提供更多的扩展机制。

架构师必须了解开发人员如何打包内容,因为打包在架构中有重要的影响。例如,如果多个包紧密耦合,那么重新利用其中一个包来进行相关工作就变得更加困难。

在类之前的模块化重用

那些在面向对象语言诞生之前接受培训的开发人员可能会感到困惑,为什么有这么多不同的分离方案。很多原因与向后兼容性有关——不是代码的兼容性,而是开发人员如何看待问题。

1968年3月,计算机科学家Edsger Dijkstra在《ACM通讯》期刊上发表了一篇名为《Go To 语句被认为有害》的论文。他批评了当时编程语言中常见的 GOTO 语句的使用,因为它允许在代码中进行非线性的跳跃,使推理和调试变得困难。

Dijkstra的论文帮助开启了20世纪70年代中期结构化编程语言的时代,这些语言以Pascal和C为代表,鼓励开发人员深入思考事物如何组合在一起。开发人员很快意识到,大多数编程语言没有提供一个良好的方式来将相似的内容进行逻辑分组。因此,1980年代中期,模块化语言的短暂时代诞生了,例如 Modula(Pascal 创建者 Niklaus Wirth 的下一语言)和 Ada。这些语言拥抱了模块这一编程构造,就像我们今天谈论包或命名空间一样(但没有类)。

然而,1980年代中期的模块化编程时代是短暂的,因为面向对象的语言变得流行,并提供了封装和重用代码的新方式。尽管如此,语言设计者意识到模块的实用性,并将其以包和命名空间的形式保留了下来。许多语言仍然包含一些看起来奇怪的兼容性特性,但这些特性是在支持不同范式的同时引入的。例如,Java 支持模块化范式(通过包和使用静态初始化器的包级初始化),以及面向对象和函数式范式,每种范式都有自己的作用域规则和怪癖。

在本书关于架构的讨论中,我们使用模块化作为一个通用术语,表示相关代码的分组:类、函数或其他任何分组。这并不意味着物理分离,而仅仅是逻辑上的分离。(有时这种区别是很重要的。)例如,将大量的类放在一个单体应用中可能很方便;然而,当需要重新构建架构时,松散分区所鼓励的耦合可能会妨碍拆分单体应用的努力。因此,将模块化作为一个概念来讨论是有用的,而不是特定平台强制或暗示的物理分离。

值得讨论的是命名空间的普遍概念,这与 .NET 平台中的命名空间技术实现是不同的。开发人员通常需要精确的、完全限定的名称来区分不同的软件资产(组件、类等)。最明显的例子是互联网,它依赖于与IP地址绑定的唯一全局标识符。

大多数语言都有某种模块化机制,兼作命名空间,用来组织变量、函数或方法等内容。有时模块结构是物理上反映的:例如,Java 的包结构必须反映物理类文件的目录结构。

没有命名冲突的语言:Java 1.0

Java 的原始设计者在处理命名冲突和冲突方面有着丰富的经验,这是当时编程平台的常见问题。Java 1.0 使用了一个巧妙的hack来避免当两个类具有相同名称时的歧义——例如,如果问题领域中有一个目录订单和一个安装订单,两个都叫“order”,但含义(和类)完全不同。Java 设计者的解决方案是创建包命名空间机制,并要求物理目录结构必须与包名匹配。因为文件系统不允许两个文件具有相同的名称并驻留在同一目录中,这就利用了操作系统固有的特性来避免歧义和命名冲突。因此,Java 的原始类路径仅包含目录。

然而,正如语言设计者所发现的那样,强制每个项目都拥有一个完全形成的目录结构是繁琐的,特别是随着项目规模的增大。此外,构建可重用资产变得困难:框架和库必须“解压”到目录结构中。在 Java 的第二个主要版本(1.2,但称为 Java 2)中,设计者添加了 JAR 机制,允许存档文件作为类路径上的目录结构。接下来的十年,Java 开发人员一直在努力正确设置类路径,因为它是目录和 JAR 文件的组合。它们最初的设计意图被打破了:现在两个 JAR 文件可能会在类路径上创建冲突的名称。这就是为什么那个时代的 Java 开发人员往往有许多关于调试类加载器的“战争故事”。

测量模块化

由于模块化非常重要,架构师需要一些工具来帮助他们更好地理解它。幸运的是,研究人员为此目的创建了多种语言无关的度量标准。在这里,我们将重点讨论三个关键概念:内聚性、耦合性和共生性。

内聚性

内聚性指的是模块内部各部分之间的关联程度。换句话说,它衡量模块内部各部分的相关性。一个理想的内聚模块是其中的所有部分都被打包在一起;如果将它们拆分成更小的部分,则需要通过模块间的调用来将它们联系在一起,从而实现有用的结果。与内聚性相关的模块化警示故事,可以通过《结构化设计》一书中的以下引述来体现:

尝试将一个内聚性强的模块拆分只会导致耦合性增加和可读性下降。

——拉里·康斯坦丁 (Larry Constantine)

计算机科学家已经定义了一系列从最好到最差的内聚性:

  • 功能内聚:模块的每个部分都与其他部分相关,且模块包含了完成任务所需的所有关键部分。
  • 顺序内聚:两个模块相互作用,一个输出数据,另一个将其作为输入。
  • 通讯内聚:两个模块形成一个通信链,每个模块都处理信息和/或贡献某些输出。例如,一个模块向数据库添加记录,另一个基于该信息生成电子邮件。
  • 过程内聚:两个模块必须按照特定顺序执行代码。
  • 时间内聚:模块之间基于时间依赖性相关。例如,许多系统在启动时需要初始化看似无关的任务;这些任务是时间内聚的。
  • 逻辑内聚:模块中的数据在逻辑上相关,但功能上不相关。例如,考虑一个将文本、序列化对象或流转换成其他格式的模块。它的操作是相关的,但功能差异很大。一个常见的例子是在几乎每个Java项目中都有的StringUtils包,它包含一些静态方法,这些方法操作String但彼此没有其他关联。
  • 偶然内聚:模块中的元素除了在同一个源文件中外没有其他关联。这代表了内聚性最差的形式。

尽管内聚性有许多不同的变体,但它作为度量标准的精确度不如耦合性。通常,一个特定模块的内聚性程度由架构师根据需要决定。考虑以下模块定义:

客户维护

  • 添加客户
  • 更新客户
  • 获取客户
  • 通知客户
  • 获取客户订单
  • 取消客户订单

这最后两个条目是否应该包含在该模块中?或者开发人员是否应该创建两个独立的模块?以下是这两种情况的展示:

客户维护

  • 添加客户
  • 更新客户
  • 获取客户
  • 通知客户

订单维护

  • 获取客户订单
  • 取消客户订单

哪种结构是正确的?通常,这取决于以下几个问题:

  • 订单维护是否只有这两个操作?如果是的话,将它们重新合并回客户维护模块可能是合理的。
  • 客户维护是否预计会增长得更大?如果是的话,也许开发人员应该寻找机会将行为提取到另一个(或新的)模块中。
  • 订单维护是否需要如此多的客户信息,以至于将两个模块分开会导致需要高耦合性才能使其正常工作?(这与前面的拉里·康斯坦丁引述相关。)

这些问题代表了软件架构师工作中的权衡分析。

计算机科学家开发了一种良好的结构化度量来确定内聚性——特别是缺乏内聚性(Lack of Cohesion),这点有点令人惊讶,因为这一特性通常是主观的。著名的Chidamber和Kemerer面向对象度量套件(Object-Oriented Metrics Suite)衡量了面向对象软件系统的特定方面。它包括许多常见的代码度量标准,如环路复杂度(Cyclomatic Complexity)(参见“环路复杂度”)以及几个重要的耦合度量,后面将在“耦合”章节讨论。

Chidamber和Kemerer还开发了方法的缺乏内聚性(LCOM)度量,用于衡量模块的结构性内聚性。最初版本的公式出现在公式3-1中:

公式3-1:LCOM,版本1

LCOM=∣P∣−∣Q∣\text{LCOM} = |P| - |Q|LCOM=∣P∣−∣Q∣

如果 ∣P∣>∣Q∣|P| > |Q|∣P∣>∣Q∣,则公式成立;否则为0。
在这个公式中,P表示任何没有访问特定共享字段的方法的数量;Q表示那些共享特定字段的方法的数量。如果你觉得这个公式有点困惑,我们表示理解——它的版本逐渐变得更加复杂。第二个变体是在1996年引入的(因此被称为LCOM96B),它出现在公式3-2中:

公式3-2:LCOM96B

LCOM96B=1−1a∑j=1am−μ(Aj)\text{LCOM96B} = 1 - \frac{1}{a} \sum_{j=1}^{a} m - \mu(A_j)LCOM96B=1−a1​j=1∑a​m−μ(Aj​)

我们不打算解开公式3-2中的变量和运算符,因为以下的书面解释会更清楚。基本上,LCOM度量揭示了类内部的偶然耦合性。LCOM的更好定义是“通过共享字段不共享的方法集合的总和”。

考虑一个类,其中有私有字段a和b。许多方法仅访问a,其他许多方法仅访问b。通过共享字段(a和b)不共享的方法集合的总和较大,因此该类的LCOM得分较高,表明方法内聚性较差。

考虑图3-1中显示的三个类。这里,字段作为单个字母出现在八边形中,而方法作为块出现。在类X中,LCOM得分低,表示良好的结构内聚性。然而,类Y缺乏内聚性;类Y中的每对字段/方法可以各自出现在不同的类中,而不会影响系统的行为。类Z显示了混合内聚性;最后一个字段/方法组合可以重构为自己的类。

image.png

LCOM(方法的内聚性缺失)度量对于架构师在分析代码库时非常有用,特别是在帮助重构、迁移或理解代码库时。共享工具类是迁移架构时常见的难题。使用LCOM度量可以帮助架构师发现那些偶然耦合的类,这些类本不应当是一个类。

然而,许多软件度量存在严重的不足,LCOM也不例外。这个度量只能发现结构上的内聚性缺失;它无法判断特定的部分是否逻辑上适配。这也反映了我们软件架构第二定律的理念:为什么比如何更重要。

耦合

幸运的是,我们有更好的工具来分析代码库中的耦合性。这些工具部分基于图论:由于方法调用和返回形成了调用图,因此可以通过数学方式进行分析。Edward Yourdon和Larry Constantine的书籍《结构化设计:计算机程序和系统设计学科的基础》(Prentice-Hall, 1979)定义了许多核心概念,包括度量“传入耦合”和“传出耦合”。传入耦合度量的是代码构件(如组件、类、函数等)接收的连接数量;而传出耦合度量的是代码构件发出的连接数量。几乎所有平台都有工具,允许架构师分析代码的耦合特征。

为什么耦合度量的名字如此相似

为什么在架构领域代表相反概念的两个关键度量,名字几乎完全相同,只有发音上相近的元音有所不同?这些术语来自于《结构化设计》一书。Yourdon和Constantine借用了数学中的概念,创造了现在常用的“传入耦合”和“传出耦合”术语。它们本该叫做“输入耦合”和“输出耦合”,但作者偏向数学对称性而非清晰表达。开发人员为此提出了一些记忆法。例如,字母a在字母表中排在字母e之前,就像输入排在输出之前一样。字母e在“传出耦合”一词中的位置与“exit”(退出)一词的首字母一致,这有助于记住它代表的是输出连接。

核心度量

尽管组件耦合对架构师有一定的实际价值,但其他一些衍生度量可以进行更深入的评估。本节讨论的度量由软件工程师Robert C. Martin创建,并广泛适用于大多数面向对象语言。

抽象性(Abstractness)是抽象构件(如抽象类、接口等)与具体构件(如实现类)的比例。抽象性度量衡量代码库在抽象与实现之间的平衡。例如,度量的一端是一个没有抽象的代码库,它只是一个巨大的单一代码函数(例如单一的 main() 方法)。度量的另一端是一个过度抽象的代码库,使得开发人员难以理解各个部分是如何连接的。(例如,由于其多层次的抽象和模糊的名称,开发人员可能需要一段时间才能弄清楚如何使用一个 AbstractSingletonProxyFactoryBean 抽象类。)

抽象性公式如下所示(方程3-3):

方程3-3. 抽象性

A=∑ma∑mc+∑maA = \frac{\sum m_a}{\sum m_c + \sum m_a}A=∑mc​+∑ma​∑ma​​

在这个公式中,m_a 代表模块中的抽象元素(接口或抽象类),m_c 代表具体元素(非抽象类)。架构师通过计算抽象构件的总和与具体和抽象构件总和的比例来计算抽象性。最简单的方式来可视化这个度量是考虑一个包含5,000行代码的应用程序,这些代码全部都在一个 main() 方法中。其抽象性分子为1,而分母为5,000,从而得出接近0的抽象性得分。这就是该度量如何衡量代码中抽象的比例。

另一个衍生度量是不稳定性(Instability),其定义为传出耦合(efférent coupling)与传出耦合和传入耦合之和的比例,如方程3-4所示。

方程3-4. 不稳定性

I=CeCe+CaI = \frac{C_e}{C_e + C_a}I=Ce​+Ca​Ce​​

在这个公式中,C_e 代表传出耦合(或外向耦合),C_a 代表传入耦合(或内向耦合)。

不稳定性度量用来衡量代码库的易变性。如果一个代码库表现出高度的不稳定性,它在发生变更时更容易出现故障,因为它的耦合性较高。例如,如果一个类调用了太多其他类以委托工作,那么如果其中一个或多个被调用的方法发生变化,调用类将更容易出错。

距离主序列

架构师为架构结构所拥有的少数整体性度量之一是距离主序列(Distance from the Main Sequence),它是一个基于不稳定性和抽象性的衍生度量,如方程3-5所示。

方程3-5. 距离主序列

D=∣A+I−1∣D = |A + I - 1|D=∣A+I−1∣

在该公式中,A 代表抽象性,I 代表不稳定性。

需要注意的是,抽象性和不稳定性都是位于0和1之间的分数(除非在一些极端情况下)。因此,将这两个度量的关系绘制成图,会生成如图3-2所示的图表。

image.png

距离度量(Distance metric)假设抽象性和不稳定性之间存在理想的关系;那些接近这一理想线的类表现出这两种相互竞争的关切的健康混合。例如,将某个特定类绘制到图表中,开发人员可以计算出距离主序列度量,如图3-3所示。

image.png

图3-3中的度量图绘制了候选类,然后测量其与理想化线的距离。越靠近这条线,类的平衡性就越好。那些过于接近右上角的类进入了架构师所称的无用区(Zone of Uselessness):过于抽象的代码变得难以使用。相反,如图3-4所示,落入左下角的代码进入了痛苦区(Zone of Pain):代码实现过多,抽象性不足,变得脆弱且难以维护。

image.png

许多平台提供工具来计算这些度量,它们可以帮助架构师在分析代码库时熟悉它们,准备迁移或评估技术债务。

度量的局限性

尽管行业中有一些代码级度量能够提供有价值的洞察,但与其他工程学科中的分析工具相比,我们的工具仍然非常粗糙。即便是直接源自代码结构的度量也需要解释。例如,环状复杂度(见“环状复杂度”)衡量代码库的复杂性,但该度量无法区分本质复杂性(代码复杂是因为底层问题复杂)和偶然复杂性(代码比实际需要的复杂)。几乎所有代码级度量都需要解释,但建立关键度量的基准(如环状复杂度)仍然是有用的,这样架构师可以评估代码库展示的是哪种类型。我们在《治理与适应函数》中讨论了如何设置这样的测试。

Yourdon 和 Constantine 于1979年发布的《结构化设计》比面向对象语言的流行要早。它更侧重于结构化编程构造,如函数(而不是方法)。它还定义了其他类型的耦合,这些耦合由于现代编程语言设计的变化已经过时。面向对象编程引入了额外的概念,这些概念覆盖了传入和传出耦合,包括描述耦合的更精细的词汇,称为“共生性”(connascence)。

共生性 (Connascence)

Meilir Page-Jones 的《What Every Programmer Should Know about Object-Oriented Design》(1996年出版)一书,创造了一个更精确的语言来描述面向对象语言中不同类型的耦合。共生性(Connascence)并不是像传入和传出耦合那样的耦合度量,而是一个语言,帮助架构师更精确地描述不同类型的耦合,并理解这些耦合类型的常见后果。

如果一个组件的变化需要另一个组件也进行修改,以保持系统整体的正确性,那么这两个组件就是共生的。Page-Jones 区分了两种类型的共生性:静态共生性和动态共生性。

静态共生性 (Static Connascence)

静态共生性指的是源代码级别的耦合(与执行时耦合相对,后者在“动态共生性”中讨论)。架构师将静态共生性视为通过传入或传出耦合的方式进行耦合的程度。静态共生性有几种类型:

名称共生性 (Connascence of Name)
多个组件必须就实体的名称达成一致。

方法名称和方法参数是代码库中最常见的耦合方式,也是最理想的耦合方式,尤其是在现代重构工具的帮助下,系统范围内的名称更改变得非常容易实现。例如,开发人员不再在活跃的代码库中手动更改方法名称,而是使用现代工具对方法名进行重构,从而在整个代码库中实现更改。

类型共生性 (Connascence of Type)
多个组件必须就实体的类型达成一致。

这种类型的共生性指的是许多静态类型语言中常见的做法,即将变量和参数限制为特定类型。然而,这种能力并非仅限于静态类型语言,一些动态类型语言也提供了选择性类型的功能,特别是 Clojure 和 Clojure Spec。

意义共生性 (Connascence of Meaning)
多个组件必须就特定值的含义达成一致,也称为约定共生性(Connascence of Convention)。

代码库中最常见的显式例子是硬编码的数字,而不是常量。例如,在某些语言中,定义类似 int TRUE = 1; int FALSE = 0 是常见的做法。假设有人把这些值反转,可能会引发很多问题。

位置共生性 (Connascence of Position)
多个组件必须就值的顺序达成一致。

这是一个方法和函数调用中参数值的问题,即便在支持静态类型的语言中也会出现。例如,如果开发人员创建了一个方法 void updateSeat(String name, String seatLocation) 并用 updateSeat("14D", "Ford, N") 调用它,那么语义上就是不正确的,尽管类型是正确的。

算法共生性 (Connascence of Algorithm)
多个组件必须就某个特定算法达成一致。

算法共生性常见的例子是,开发人员定义了一个安全哈希算法,必须在服务器和客户端上都运行并生成相同的结果,以便进行用户身份验证。显然,这代表着高度的耦合——如果任何算法的细节发生变化,握手操作将无法继续。

动态共生性 (Dynamic Connascence)

Page-Jones 定义的另一种共生性是动态共生性,它分析运行时的调用。动态共生性包括以下类型:

执行共生性 (Connascence of Execution)
多个组件的执行顺序非常重要。

考虑以下代码:

email = new Email();
email.setRecipient("foo@example.com");
email.setSender("me@me.com");
email.send();
email.setSubject("whoops");

这段代码不会正确工作,因为某些属性必须按特定顺序设置。

时序共生性 (Connascence of Timing)
多个组件的执行时序非常重要。

这种共生性的常见情况是由两个线程同时执行而引发的竞争条件,影响联合操作的结果。

值共生性 (Connascence of Values)
多个值相互依赖,必须一起更改。

考虑一个开发人员定义了一个矩形,通过定义四个点来表示其四个角。如果要保持数据结构的完整性,开发人员不能随意更改其中一个点,而不考虑对其他点的影响,以保持矩形的形状。

一个更常见且问题更为严重的情况涉及事务,尤其是在分布式系统中。在一个设计为使用独立数据库的系统中,当有人需要在所有数据库中更新一个单一值时,这些值要么一起更改,要么根本不更改。

身份共生性 (Connascence of Identity)
多个组件必须引用同一个实体。

身份共生性的常见例子是两个独立的组件必须共享并更新一个共同的数据结构,比如一个分布式队列。

共生性属性

共生性是架构师和开发人员用于分析的框架,其中一些属性有助于确保我们明智地使用它。这些共生性属性包括:

强度 (Strength)

架构师通过开发人员重构耦合的难易程度来确定系统共生性的强度。某些类型的共生性显然比其他类型更可取,如图 3-5 所示。朝着更好的共生性类型进行重构,可以改善代码库的耦合特征。

架构师应更偏好静态共生性而非动态共生性,因为静态共生性可以通过简单的源代码分析来确定,并且现代工具使得改善静态共生性变得非常简单。例如,意义共生性(Connascence of Meaning)可以通过重构为名称共生性(Connascence of Name)来改善,即创建一个命名常量,而不是使用魔法值。

image.png

局部性 (Locality)

系统共生性的局部性衡量的是其模块在代码库中的相对位置(接近程度)。相邻的代码(同一模块中的代码)通常比分离的代码(不同模块或代码库中的代码)具有更多且更高层次的共生性。换句话说,当组件距离较远时,表现为差耦合的共生性形式在组件接近时是可以接受的。例如,如果同一模块中的两个类具有意义共生性(Connascence of Meaning),与它们位于不同模块时的影响相比,对代码库的损害较小。

在作者首次发布这一观察结果时,架构师们大多没有意识到这一点的重要性。用现代术语来说,他建议架构师应尽可能将实现细节(高耦合)的范围限制在实际可行的最小范围内,这与领域驱动设计(DDD)的边界上下文(bounded context)思想相同。这一架构观察与DDD的原则一致——限制实现耦合。Meilir-Page 描述了一个好的设计原则,后来在DDD中得到了更全面的重新引入(参见第七章“领域驱动设计的边界上下文”)。

考虑强度和局部性一起使用是一个好主意。同一模块内较强的共生性形式比分散在不同模块中的相同共生性更少“代码味道”。

程度 (Degree)

共生性的程度与在特定模块中更改一个类的影响大小相关——这种更改会影响少数类还是许多类?较低程度的共生性只需对其他类和模块进行较少的更改,因此对代码库的损害较小。换句话说,如果架构师的模块较少,具有高动态共生性也不算太糟。然而,随着代码库的增长,一个小问题会随着更改而变得更大。

在《What Every Programmer Should Know about Object-Oriented Design》一书中,Page-Jones 提出了三条使用共生性来改善系统模块化的指南:

  1. 通过将系统拆分为封装元素来最小化整体共生性。
  2. 最小化穿越封装边界的共生性。
  3. 在封装边界内最大化共生性。

传奇的软件架构创新者 Jim Weirich 在2012年《企业新兴技术》大会上的“共生性检视”演讲中提出了两条很好的规则:

  • 程度规则 (Rule of Degree) :将强共生性转化为弱共生性。
  • 局部性规则 (Rule of Locality) :随着软件元素之间的距离增大,使用较弱形式的共生性。

架构师学习共生性有好处,原因与学习设计模式一样:共生性为描述不同类型的耦合提供了更精确的语言。例如,架构师可以告诉某人:“我们需要一个服务,并且只能有一个实例”,或者可以告诉某人:“我们需要一个单例服务”。单例设计模式通过一个简单的名称很好地封装了一个常见问题的上下文和解决方案。

类似地,在进行代码审查时,架构师可以指示开发人员:“不要在方法声明中间添加魔法字符串常量,而是将其提取为常量。” 或者可以说:“你有意义共生性(Connascence of Meaning);将其重构为名称共生性(Connascence of Name)。”

从模块到组件

在本书中,我们使用“模块”这一术语作为相关代码集合的通用名称。然而,大多数架构师将模块称为组件,组件是软件架构的关键构建块。组件的这一概念以及相应的逻辑或物理分离分析,自计算机科学的早期阶段以来就已存在,但开发人员和架构师仍然在实现良好的结果方面面临挑战。

我们将在第8章中讨论如何从问题域中推导出组件,但在此之前,我们必须先讨论软件架构的另一个基本方面:架构特性及其范围。