如何实现软件设计中的高凝聚和松耦合? - thevaluable


为什么我们系统的模块耦合度如此之高?是因为他们缺乏凝聚力吗?
(banq注:为什么人员在团队之间流动这么频繁?为什么团队之间开会如此频繁?是因为这些团队内部缺乏凝聚力吗?缺乏核心凝聚吗?)
 
案例:
有人说:
我们的系统是自 COBOL 和 FORTRAN 时代以来我们见过的最可怕的系统!万事俱备,太可怕了!我要求用 78 个月的时间彻底重写一切,终于拥有我梦寐以求的完美系统!
凝聚力不是这个系统的主要问题。我们只需要重写所有内容,It Will Be Fine。
整个系统是由很久以前离开的不熟练的开发人员编写的。

分析:
需要确信当时他们写这个系统时已经尽了最大的努力,让我们一起讨论内聚和耦合的概念,看看我们可以做些什么来改进我们的代码库。
 
内聚和耦合确实是软件开发中必不可少的两个概念。它们是我们在构建应用程序时应始终牢记的这些首要原则的一部分。

  • 内聚和耦合的想法来自哪里。
  • 我们可以在代码库中找到哪些不同类型的耦合。
  • 耦合的概念是如何随着时间的推移而演变的。
  • 即使在我们的代码库之外,如何到处都可以找到耦合。
  • 什么是内聚,它与耦合有何关系。
  • 我们可以在代码库中找到哪些不同类别的凝聚力。
  • 什么是最重要的概念:内聚还是耦合?
  • 一些方法和问题要问自己,以使我们的模块更具凝聚力。

 
内聚和耦合的起源
当年,软件工程师大多使用如 COBOL 和 FORTRAN旧的编程语言。敏捷方法不存在,因此大多数公司遵循瀑布类型的组织,分析师创建功能规范,将它们提供给创建一些架构的设计师,让开发人员编写带有框和箭头的可爱图表,最后将其交给编码人员将编写实际代码。

1968 年的北约会议突显了软件危机的阴暗阴影,声称计算机程序并不能真正解决它们要解决的问题,尤其是大型软件系统。主要问题:这些系统对于我们有限的人类大脑来说太复杂了。

大多数开发人员都同意模块化是解决方案,这是许多人从计算开始就想到的一个旧概念。模块化在当时受到关注,例如David Parnas (1971)的关于将系统分解为模块的标准,或Edsger Dijkstra (1974)的关于科学思想的作用的论文。

如何实现这种模块化?如何对系统进行分区?什么放在同一个模块中,什么放在外面?以什么方式?什么是好的模块?什么是坏的?

一个模块只是一组东西,这使得这个概念非常模糊:
类是可能的模块之一,还有很多。
将某些知识隔离在定义的边界内,创建“内部”和“外部”的所有东西都可以是一个模块。
具体来说,它可以是一个函数、一个类、一个命名空间、一个包、一个完整的微服务,甚至是一个单体。
 
结构化设计
为了回答这些问题, 1974 年发表了一篇名为“结构化设计”的论文。一年后,也就是 1975 年,同一作者的一本书也被称为结构化设计。
这两个资源都试图强调高层次的重要性。设计(我们现在称之为软件架构)。他们对软件世界产生了重大影响,特别是因为他们定义了两个重要概念:内聚和耦合。

从那时起,耦合和内聚被认为是优质软件的重要概念和衡量标准。这是一个范围:耦合和内聚都可以或多或少地被认为是“强”或“弱”。目标是创建指标,为学生建立一门新的“设计科学”;不多不少。

这本书本身对结构化设计的含义给出了更好的定义:
结构化设计是决定哪些组件以何种方式互连将解决一些明确指定的问题的过程

结构化设计的下一个重要里程碑是 Meil​​ir Page-Jones 在 1988 年出版的另一本书《结构化系统设计实用指南》。它试图进一步定义结构化设计运动的重要首要原则。

然后,当不可阻挡的 OOP 浪潮席卷世界时,内聚和耦合仅被认为是类、接口和围绕 OOP 范式的所有概念。这种趋势的一个很好的例子是在Steve McConnell 的名著Code Complete II中提到耦合和内聚,清楚地提到这些问题部分地由 OOP 解决。

从那时起,OOP 封装通常被认为是减少耦合和增加内聚的实用方法。但是结构设计运动的原则仍然非常实际,特别是因为我们的语言越来越多范式。

 
模块
在这篇文章中,我们将经常使用模块的一般概念。一个模块可以是一个函数,一个类,一个命名空间(包),一个微服务,一个完整的单体,或任何结构,只要它有一个边界分隔的内部和外部。这个边界允许我们将一个模块的某些部分与其他模块隔离。

换句话说,这是一种划分和封装我们系统的一部分的方法。最终目标是绕过我们大脑的限制,正如我上面提到的。

为什么不说说更具体的结构,比如类呢?只是为了说明耦合和内聚在你系统的不同抽象层的每个层面都是有用的概念,不管你用的是什么编程语言。

你应该把什么定义为 "模块",取决于你需要在抽象堆栈的哪个层次上工作,什么是对你实现目标有用的考虑。例如,如果你为一个微服务创建一个新的API,你应该把 "模块 "的概念映射到微服务。如果你修改了一个独立的类,你应该考虑把它的方法作为你选择的模块。然后,你就可以开始考虑内聚力和耦合了。
 
耦合
那么,什么是耦合?它不是指模块本身,而是指模块之间的联系。当这种联系很强时,我们就说强耦合模块;当这种联系很弱时,我们就说松散耦合模块。这不是一个二元的故事,而是像通常一样,更像是一个颜色光谱。

关于软件架构的讨论正好是关于耦合的94,82089%。如果我们发明一个喝酒的游戏,当我们每次在这些讨论中听到 "耦合 "的时候,我们就喝一杯,我们的架构就会因此而大大改善。

结构化设计运动明确指出,耦合和内聚都不是绝对的真理:在设计中,一切都要权衡。
我们应该了解、体验并记住我们提出的解决方案的好处,但也要记住缺点。这就是为什么探索和实验是如此重要。
这也是为什么软件开发是如此该死的困难的部分原因。

根据结构化设计运动,耦合的强度取决于。

  • 模块之间的连接类型。
  • 模块接口的复杂性。
  • 通过连接的信息的类型。


让我们来探讨一下这些想法:
 
模块间的连接类型
如果不同的模块有许多不同的接口,那么它们的耦合性就会更强,因为,它们可能有许多不同类型的连接。

我们在这里说的是接口的一般概念,是一个模块跨越边界向另一个模块发送信息的方式。例如,一个类的公共方法就是它的接口之一:它是一种与另一个类沟通的方式。一个函数的输入和输出也是它与其他函数的接口。

结构化设计书给出的定义很好。

任何这样的引用元素都定义了一个接口,即模块边界的一部分,数据或控制在其中流动。[你可以把它想象成一个插座,把由引用模块的连接所代表的插头插入其中。一个模块中的每一个接口都代表着一个更多的东西,这些东西被系统中的其他模块所了解、理解和正确连接。
 
接口的复杂性
尽量减少接口的数量可以使模块之间的潜在耦合强度降到最低。
现在我们来看看接口本身:理想情况下,它们应该有最小的必要输入和输出量。

例如,如果你有一个模块,像一个函数,接受19个参数,你就增加了传递这19个参数的模块和接受这些参数的模块之间的连接强度(耦合)。

我们给接口的实体数量并不总是那么明确。
例如,当你在传递对象时,可能很难知道这些对象包含什么,以及它们包含多少。正如Joe Amstrong的名言。

因为面向对象的语言的问题是它们有所有这些隐含的环境,它们随身携带。你想要一根香蕉,但你得到的却是一只抱着香蕉的大猩猩和整个丛林。

当你想从一个类的实例中得到什么时,你不仅要传递这个实例,还要传递你能从这个实例中获得的所有东西。这并不是类所特有的:
当你在像Go这样的语言中导入一个包时,你可能会比你想象的要有效地耦合得多。
 
不可见的接口
模块之间的连接是否清晰可能是一个问题。
如果每一个数据在通过接口时都有明确的说明,那么在看代码的时候就更容易理解两个模块之间的耦合。

为了使模块之间的耦合更加明显,你也可以使用注释,或者某种文档。
这些解决方案比较弱,因为它们不是代码,很容易忘记随着代码的发展而更新它们。
 
修改一个模块的控制流
在有些情况下,不可能(或不希望)将公共逻辑提取到另一个模块。在这种情况下,对控制流采取行动可能是你最后的选择。
 
共同环境上下文耦合
结构化设计将公共环境耦合定义为模块间共享的资源或变量。我们在这里看到了软件开发时的另一个首要原则:全局状态的问题,以及一般的范围界定。

全局共享的资源不一定是变量:它可以是另一个模块、公共库、文件,甚至是外部程序。

简而言之,修改这些共享实体中的一个可能会在代码库的未知部分产生影响,因为所有的东西和事物都可能依赖于它。

然而,共同的环境耦合对于某些特定的功能来说是很方便的。例如,一个记录器是一个内聚的功能,它可以是整个应用程序的全局。在这篇文章的下面有更多关于内聚的内容。
 
耦合的演变
到目前为止,我们谈到的一切都直接来自结构化设计运动。即使他们提出的观点在今天看来是完全有效的,但从70年代中期开始,他们已经对耦合的概念进行了一些补充。

本文通过对许多其他研究中的耦合概念的分析,谈到了四个高水平的类别。

  • 结构耦合
  • 动态耦合
  • 语义耦合
  • 逻辑耦合

结构耦合主要是我们上面看到的,有一些有趣的细微差别。从强到弱的耦合。

  • 内容耦合 - 模块直接访问对方的内容,不使用接口。
  • 共同耦合--模块在更大范围内突变共同的变量(如全局变量)。
  • 控制耦合 - 模块控制其他模块的逻辑(控制流)。
  • 外部耦合 - 模块使用外部手段交换信息,如文件。
  • 戳记耦合 - 模块交换元素,但接收端不对所有元素采取行动。例如,一个模块通过其接口接收一个数组,但不使用其所有元素。
  • 数据耦合 - 模块交换元素,而接收端使用所有的元素。

 
这里有一个简短的总结。

动态耦合是指在运行时发生的耦合;例如,通过使用接口结构(参数化多态性)。

逻辑耦合发生在不同模块的部分同时发生变化时,在代码库中它们之间没有可见的联系。例如,当相同的行为在不同的模块中被重复时,它就会发生;换句话说,相同的知识在两个不同的地方被编纂。当开发者在一个模块中改变了这种行为的表现形式,她就需要在所有重复的地方改变它。

语义耦合发生在一个模块使用另一个模块的知识时。例如,当一个模块假设另一个模块做一些特定的事情时。
 
 
与第三方的耦合
耦合不一定是在我们自己的代码中。与第三方的耦合可能是同样的,甚至是更危险的,因为我们对这些依赖性的控制较少(或没有)。

我有一个简单的规则:如果实现是微不足道的,就应该避免使用一个库。同样,这真的取决于你想达到什么目的:在光谱的一边,如果我需要快速建立原型,我会使用库甚至框架,如果它可以加速这个过程。如果我知道我正在开发的系统对我所从事的业务至关重要,我会避免使用框架,而且我会尽可能限制我使用的外部库。

这种危险的一个好例子是几年前发生的左键灾难。简而言之,许多Javascript项目都依赖于一个实现左键的库,这是一个实现起来非常容易的机械功能。当这个库的作者决定删除它时,无数的项目都崩溃了。

第三方的危险并不局限于库,甚至是代码。例如,我们也可以将我们的软件与云供应商耦合。这种耦合是很难避免的;但应该仔细考虑。然而,我看到许多公司因为 "其他人都在使用它 "而把自己锁定在一些供应商那里,然后在公司发展壮大时后悔不已,账单也一样。

当你把耦合想成连接时,你会发现它们无处不在。例如,当我们写代码的时候,我们把它和我们在这个特定时间内对这个领域的知识结合起来。这个领域,以及我们的知识,都会发生变化和发展;因此,代码也需要变化和发展。这就是为什么软件开发如此困难的原因之一:因为它与不断变化的现实世界相耦合,而我们需要用我们不完整的感知力来尽可能准确地表达它。

一些运动,比如领域驱动设计,教会了我们一些重要的东西:如果你试图帮助的业务在很大程度上依赖于你正在构建的应用程序,你应该尽量松散地耦合它,因为它很可能会改变。你要在隔离性和复杂性之间保持一个良好的平衡。

总而言之,和以往一样,没有什么硬性原则是我们应该一直遵循的,即使有些人似乎有不同的意见。只有单纯的指导方针。
  
DRY和耦合之间的关系
假设我们的应用程序中有两个模块:订单和发货。两者都需要一些逻辑来处理产品的概念。我们可以想象两个解决方案。

  1. 创建第三个模块来处理这个逻辑,并将我们的两个模块订单和装运耦合到这第三个模块。
  2. 在这两个不同的模块中添加相同的逻辑。

如果我们尝试遵循上面的指导方针,第一个解决方案可能是一个好的解决方案。
  1. 只使用最少的接口来连接我们的模块。
  2. 只通过接口传递所需的最小数量的参数。
  3. 只传递数据,避免改变我们模块的控制流。
  4. 不依赖另一个更全面的模块。

第二个解决方案意味着在两个不同的地方复制相同的逻辑。这意味着如果这个逻辑发生变化,我们将需要修改这两个地方;从这个意义上说,这也被认为是耦合,更确切地说,是逻辑耦合。

对我来说,问题不在于做什么,而在于何时做。如果我对我需要实现的逻辑会在某个时间点上发生变化,哪怕是最小的怀疑,我也会在两个地方复制它,看看它是如何演变的。

  1. 如果它从未改变,就没有问题。
  2. 如果逻辑经常变化,以至于在两个不同的地方维护同一段代码变得很烦人,或者最糟糕的是,如果开发人员开始忘记改变一个实现而不是另一个,我就会把它提取到自己的模块。
  3. 如果有更多的模块使用完全相同的代码,并且如果这些共同的代码似乎是在编纂相同的知识,我会将其提取到自己的模块中。

提取一段我们有充分理由归纳的代码,总比过早地归纳,创建一个被其他模块使用的模块,到时发现这个行为因使用模块的不同而变得明显不同。

我们也不要忘记,有些类型的连接比其他类型的连接成本更高:函数之间的连接比类之间的连接更容易管理,而类比微服务之间的连接更容易管理。

对于最后一种情况,网络、连接可能具有的异步性以及微服务之间的协调可能是一个严重的挑战。如果它们是强耦合的,那就是一场噩梦了。这就是为什么我认为从单体中创建微服务往往更好,因为已经对可能的模块、它们的边界和它们的接口有了一些深入的了解。
 
待续”凝聚“见下面链接