设计习惯比较:高凝聚/松耦合、DRY/错误抽象 - Jesse


本文将面向对象分析设计的单一职责等SOLID原则应用于微服务划分,以及DDD领域划分/上下文分界/DDD聚合等设计概念中,是一种实际中每天重复的设计习惯:
松耦合高内聚这两个术语似乎同时存在的:这两个概念是一起创造的,如果您谈论其中一个,通常也会出现另一个。
类似地,DRY(不要重复自己)和错误抽象的概念也是同时存在的:例如,一个人说我们应该干掉这段代码,另一个人说他们考虑过,但他们不想创建错误的抽象.
但是,我很少在同一个对话中听到这两组概念,这让我感到惊讶,因为它们实际上是在谈论同一件事。
请允许我解释一下。这是一个盒子:

它定义了您有权更改的系统边界。你可以改变的东西放在盒子里。盒子里面的东西需要改变的唯一原因是为了外面的东西:因为外部系统发生了变化,用户的新需求,或者你试图建模的领域的变化。
例如,假设我们有一个foo()渲染frame的函数,用户体验团队每隔一周就会决定这个frame需要更改为某种新颜色。实际上,我们有一个指向foo(),这是依赖我们设计团队的依赖项,因为当设计团队对foo()改变时,这里也必须改变。我们将这些输出依赖箭头涂成绿色。

好的,我们有我们的作品。让我们用它们来表示松耦合
 
松耦合

通过完全没有耦合,我们让这一切变得容易:也就是说,我们有两个模块,每个模块都包含一些服务,而这些服务因完全不同的原因而发生变化。财务模块只是为了财务团队而改变,图片下载器是客户使用的。我们的两个服务在域中是不相关的,在我们的代码中是独立的,并且被分成不同的模块。
如果将我们的两个服务移到一个模块中会怎样?这将使我们从松耦合低内聚了

低内聚

它们将继续单独发展,因为它们在域中和在我们的代码中仍然是独立的,但是由于其中存在两个概念上不相关的服务,开发人员将更难从逻辑上推理这个模块。如果该模块是我们需要独立部署的包,那么我们现在将在对图像下载器进行更改时重新部署财务服务,反之亦然,从而导致不必要的部署。
 
内聚 + 非DRY
如果我们两件事情就是出于同样的原因需要改变?考虑我们有两个函数的类的情况,foo()和bar(),这两个函数共用一个需要前后进行更新的重叠片。我们认为这个类是内聚的,因为函数紧密相关,但我们不会称它为DRY,因为我们在重复代码。

我们可以通过分解出一个通用baz()函数来解决这个DRY问题,我们得到了一个DRY高内聚的结果,不会出错,就像我们最初的松耦合示例一样。
 
内聚 + DRY

(蓝色箭头表示系统的依赖项)
 
共同点是什么?
我们从一个好的状态开始,一次调整一件事,最终进入另一个好的状态。请注意,我们改变的每一步都不同:

  1. 在第一步中,我们通过将金融服务和图像服务移到同一个模块中来改变托管程度(我们的代码有多接近,集中托管高表示代码很接近)。
  2. 在第二步中,我们通过考虑两个代码段出于相同原因需要更改的示例来更改域相互依赖的程度(绿色箭头)
  3. 在第三步中,我们通过提取一个公共函数并在代码中为该函数添加几个依赖项来改变实际相互依赖的程度(蓝色箭头)。

我认为这三步:托管领域相互依存实际相互依存,构成了涵盖耦合内聚DRY错误抽象以及其他一些事情的基础。
鉴于我们的每步骤相互独立,我们最终得到 8 (2^3) 个排列,其中四个我们已经在上面展示过。看看剩下的四个你眼熟否?
我们上面最后一个例子是高度域相互依赖、高度实际相互依赖和高度托管。现在让我们切换到低域相互依赖:
 
错误的抽象

(红色箭头也表达了实际的相互依赖,但颜色反映了它们引起的痛苦)
这为我们提供了经典的错误抽象示例:我们有一个函数尝试做所有事情,其中​​包含处理两个单独用例的代码,每个用例都有不同的更改原因(由两个单独的输出依赖项表示)。解决这个问题的办法是拆除抽象,分离用例(即因为领域相互依赖程度低,所以应该是实际的相互依赖和托管)。
 
紧耦合
对于下一个示例,我们将切换到低集中托管。这实际上是我在工作中遇到的一个问题:我们有一个node app,它依赖于一个包,其中包含 node app实际使用的代码(标记为 A)以及一些特定于浏览器的代码(标记为 B) . 我们无意中向 B 添加了一些代码,如果没有浏览器存在,无论我们是否明确将其导入到我们的Node应用程序中,都会包含该代码!

解决方案是将 A 移出包并进入我们的node app(在我们的例子中没有其他代码依赖于它)。
减少集中托管会使问题呈指数级复杂化,因为您现在不是直接依赖于几个代码块,而是依赖于包含这些代码块的整个模块/包。与我们的node app一样,有时该模块/包中的额外内容会使您的服务器崩溃。
 
注定要在一起的微服务
对于这个例子,我们将域相互依赖设置为高:

这里我们有两个总是需要同时更改的微服务,这意味着与前面的示例不同,我们永远不会单独更新一个服务。这通常意味着对于每次更改,A 需要向 B 传递一些不同的参数,或者调用一个新的端点。这个问题的解决方案就是将两个微服务合二为一!稍微少一点,但仍然提供服务。
这证明了将您的集中托管与域的相互依赖性相匹配的重要性:如果领域中确定两件事总是同时发生变化,则它们越接近越好。通过共享部署,不仅在词汇上更接近,而且在物理上更接近。
 

危险的重复代码
我们现在已经研究了七种排列,这意味着我们已经到了最后一种排列。为此,我们将保持低集中托管和高域相互依赖,但删除实际的相互依赖(即愤怒的红色箭头)。典型的例子是在完全独立的模块中有两个重复的函数,我们希望在其中保持函数同步。

鉴于编译器不知道我们想要保持函数同步,开发人员在更新方法时使用他的心灵感应本能来搜索整个代码库以查找潜在的重复函数,以防万一也应该更新。这里显而易见的解决方案是删除重复的函数并将其所有调用者重定向到原始函数。如果我们有相同的重复但在单个文件中(即更高的托管),这不会有什么大不了的,因为更容易发现相似之处,但是随着您将集中托管从相同文件分离到相同模块,问题变得越来越有害。
 
结论
遍历这个 2x2x2 组合难题后,我们能学到什么?在每个示例中,解决方案始终是将我们实际的相互依赖和集中托管设置为我们的领域相互依赖。也就是说,如果两段代码因为完全不同的原因发生变化,你不仅应该将它们分开,还要尽量减少它们之间代码的依赖关系。相反,如果两段代码因为完全相同的原因发生变化,你不仅应该将它们靠近在一起,还应该通过代码中的相互依赖来表示它们在域中的相互依赖,无论是通过共享一些公共接口,相互调用,还是分解通用代码。
DRY原则和错误抽象都很在乎领域的相互依存和相互依存的现实,但不是很关注是否集中托管。
紧耦合关心实际的相互依赖,但只在低集中托管时。
而内聚关心领域相互依赖,但只在集中托管很高时。
这些不同的概念涵盖了很多领域,但不足以捕捉由其底层轴产生的所有情况。希望这篇文章为您提供了一个模式,当您在野外遇到这些依赖困境时,可以通过这些困境进行推理。