超越耦合和凝聚力:摆脱自我的策略

2021-08-10 03:31:52

松耦合和高内聚这两个术语似乎齐头并进:这两个概念是一起创造的,如果您在谈论一个,通常也会出现另一个。类似地,DRY(不要重复自己)和错误抽象的概念是齐头并进的:例如,一个人说我们应该干掉这段代码,另一个人说他们考虑过,但他们不想创建错误的抽象.我很少在同一个对话中听到这两组概念,这让我感到惊讶,因为它们实际上是在谈论同一件事。它定义了您有权更改的系统边界。你可以改变的东西放在盒子里:你不能出去的东西。盒子里面的东西需要改变的唯一原因是为了外面的东西:外部系统的变化,用户的新需求,或者你试图建模的领域的变化。例如,假设我们有一个函数 foo() 来渲染一个盒子,用户体验团队每隔一周就会决定盒子需要更改为某种新颜色。实际上,我们有一个从 foo() 指向我们的设计团队的依赖项,因为当设计团队改变(他们的想法)时, foo() 也必须改变。我们将这些输出依赖箭头涂成绿色。好的,我们有我们的作品。让我们用它们来表示松散耦合:我们通过完全没有耦合来使这一切变得容易:也就是说,我们有两个模块,每个模块都包含一些服务,而这些服务因完全不同的原因而发生变化。财务记者只是为了财务团队而改变,图片下载器是客户使用的。我们的两个服务在域中是不相关的,在我们的代码中是独立的,并且被分成不同的模块。如果将我们的两个服务移到一个模块中会怎样?这将使我们从松散耦合到低内聚 它们将继续单独发展,因为它们在域中仍然独立,在我们的代码中仍然独立,但是考虑到两个概念上不相关的模块,开发人员将更难推理该模块服务住在里面。如果该模块是我们需要独立部署的包,那么我们现在将在对图像下载器进行更改时重新部署财务服务,反之亦然,从而导致不必要的部署。

如果我们有两件事情因为同样的原因需要改变怎么办?考虑这样一种情况,我们有一个包含两个函数 foo() 和 bar() 的类,并且这两个函数共享大量重叠,需要同时更新。我们认为这个类是内聚的,因为函数紧密相关,但我们不会称它为 DRY,因为我们在重复代码。我们可以通过分解一个常见的 baz() 函数来解决这个干燥问题,我们得到了一个不会出错的干燥、高内聚的结果,就像我们最初的松耦合示例一样。所以我们从一个好的状态开始,一次调整一件事,最终进入另一个好的状态。请注意,我们每一步都改变了一些不同的东西:在第一步中,我们通过将金融服务和图像服务移到同一个模块中来改变托管的程度(我们的代码有多接近)。在第二步中,我们通过考虑一个示例,其中两段代码由于相同的原因需要更改,从而改变了域相互依赖的程度(绿色箭头) 在第三步中,我们通过提取出改变了实际相互依赖的程度(蓝色箭头)一个通用函数并在代码中向该函数添加几个依赖项。我认为这三个轴:托管、领域相互依赖和实际相互依赖,构成了一个涵盖耦合、内聚、干燥和错误抽象以及其他一些东西的基础。

鉴于我们的每个轴彼此独立,我们最终得到 8 (2^3) 个排列,其中四个我们已经在上面展示过。看看剩下的四个你有没有眼熟。我们的最后一个例子是高度域相互依赖、高度实际相互依赖和高度托管。现在让我们切换到低域相互依赖:(红色箭头也表达了实际的相互依赖,但颜色反映了它们造成的痛苦)这给了我们经典的错误抽象示例:我们有一个函数试图做所有事情,并且在其中存在用于处理两个单独用例的代码,每个用例都有不同的更改原因(由两个单独的传出依赖项表示)。解决这个问题的办法是拆除抽象,分离用例(即因为领域相互依赖程度低,所以应该是实际的相互依赖和托管)。对于下一个示例,我们将切换到低托管。这实际上是我在工作中遇到的一个问题:我们有一个 node 应用程序,它依赖于一个包,其中包含 node 应用程序实际使用的代码(标记为 A)以及一些特定于浏览器的代码(标记为 B) .我们无意中向 B 添加了一些代码,如果没有浏览器存在,无论我们是否明确将其导入到我们的节点应用程序中,都会引发该代码!解决方案是将 A 移出包并进入我们的节点应用程序(在我们的例子中没有其他依赖于它)。减少托管会使问题呈指数级复杂化,因为您现在不是直接依赖于几个代码块,而是依赖于包含这些代码块的整个模块/包。与我们的节点应用程序一样,有时该模块/包中的额外内容会使您的服务器崩溃。

这里我们有两个总是需要同时更改的微服务,这意味着与前面的示例不同,我们永远不会单独更新一个服务。这通常意味着对于每次更改,A 需要向 B 传递一些不同的参数,或者调用一个新的端点。这意味着 B 需要保持与 A 的向后兼容,以弥补它们两个部署之间的短暂差距。这个问题的解决方案就是将两个微服务合二为一!稍微少一点,但仍然提供服务。这表明将您的托管与域的相互依赖性相匹配的重要性:如果域确定两件事总是同时发生变化,则它们越接近越好。通过共享部署,不仅在词汇上更接近,而且在物理上更接近。我们现在已经研究了七种排列,这意味着我们已经到了最后一种排列。为此,我们将保持低托管和高域相互依赖,但删除实际的相互依赖(即愤怒的红色箭头)。典型的例子是在完全独立的模块中有两个重复的函数,我们希望在其中保持函数同步。鉴于编译器不知道我们想要保持函数同步,开发人员在更新方法时使用他的心灵感应本能来搜索整个代码库以查找潜在的重复函数,以防万一也应该更新。这里显而易见的解决方案是删除重复的函数并将其所有调用者重定向到原始函数。如果我们有相同的重复但在单个文件中(即更高的托管),这不会有什么大不了的,因为更容易发现相似之处,但是随着您将托管从相同文件减少到相同模块,到相同的回购,问题变得越来越有害。遍历这个 2x2x2 立方体的难题后,我们能学到什么?在每个示例中,解决方案始终是将我们实际的相互依赖和托管设置为我们的领域相互依赖。也就是说,如果两段代码因为完全不同的原因发生变化,你不仅应该将它们分开,还要尽量减少它们之间代码的依赖关系。相反,如果两段代码因为完全相同的原因发生变化,你不仅应该将它们靠近在一起,还应该通过代码中的相互依赖来表示它们在域中的相互依赖,无论是通过共享一些公共接口,相互调用,还是分解通用代码。 DRY 原则和错误抽象的思想都关心领域相互依赖和实际相互依赖,但不太关心托管。 Coupling 关心实际的相互依赖,但只在低托管时,而 Cohesion 关心领域相互依赖,但只在托管高时。这些不同的概念涵盖了很多领域,但不足以捕捉由其底层轴产生的所有情况。希望这篇文章为您提供了一个模式,当您在野外遇到这些依赖困境时,可以通过这些困境进行推理。尝试为三轴选择高/低,并将一个排列与下一个排列进行比较。

领域相互依赖性:低 高 实际相互依赖性:低 高 托管:低 高