全球大型电商Shopify如何使用DDD实现单体架构的模块化? – Shopify Engineering


高内聚低关联和SOLID原则是面向对象的设计原则,也是DDD用来划分有界上下文和聚合的原则,DDD聚合是一种高内聚低关联的对象,单一职责是划分不同上下文的主要原则,Shopify谈论他们如何使用这些原则将Rails单体切分为模块组件的过程,虽然他们文中只是简单提及了DDD领域驱动设计,但是他们这种整体划分成模块化(也可以是微服务)的方式实际就是一种DDD设计过程:
 
Ruby on Rails是一个不错的框架,用于快速构建用户和开发人员喜欢的精美Web应用程序。但是,如果应用程序成功,通常会持续进行投资,从而导致附加功能并增加整体/单体系统复杂性。
Shopify的核心整体拥有超过280万行Ruby代码和50万次提交。Rails不提供用于管理固有复杂性和以结构化,界限分明的方式添加功能的模式或工具。
因此,三年前,Shopify成立了一个团队来研究如何使我们的Rails整体组件更具模块化。目的是通过创建称为组件的较小的独立代码单元,帮助我们朝着不断增加的系统功能和复杂性扩展。愿景是这样的:

  • 我们可以更轻松地在新开发人员中加入与他们直接相关的部分,而不是整个整体。
  • 不必在整个应用程序上运行测试套件,而是可以在受更改影响的较小组件子集上运行它,从而使测试套件更快,更稳定。
  • 不必担心我们不太了解的系统部分的影响,我们可以自由地更改组件,只要我们保持其现有合同完整无缺即可,从而减少了功能实现时间。

总之,开发人员应该感觉自己正在开发比实际小得多的应用程序。
自上次我们共同努力使Rails整体组件更加模块化以来已经18个月了。在过去的两年半中,我一直在进行这种模块化工作,目前在一个名为“架构模式”的团队中。我将列出团队工作的当前状态,如果现在就重新开始,我们会做一些不同的事情。
...
 
整体/单体架构
软件的某些属性紧密相关,因此需要成对使用。通过处理一个资产并忽略其“伙伴资产”,您最终可能会导致系统性能下降
  • 带有简单依赖图的平衡封装(接口依赖或面向接口编程的问题)

首先,我们将工作重点放在围绕每个组件构建干净的公共界面以隐藏内部。期望这将允许独立地推理和理解组件的行为。只要接口保持稳定,更改组件的内部结构不会破坏其他组件。
虽然不是那么简单。公共接口是其他组件所依赖的;如果很多组件都依赖于它,则很难进行更改。在设计接口时,必须牢记这些依赖关系,并且依赖于该接口的组件越多,它就需要越抽象。很难更改,因为它无处不在,而且如果它包含有关业务逻辑具体部分的知识,就必须经常更改。(banq注:接口涉及依赖太多,导致一改牵动全身)
当我们开始分析组件之间的依赖关系图时,它非常密集,以至于每个组件都依赖于所有其他组件的一半以上。我们也有很多循环依赖。

循环依赖关系是例如组件A依赖于组件B但组件B也依赖于组件A的情况。但是循环依赖关系不必是直接的,周期可以大于两个。例如,A依赖于B依赖于C依赖于A。
依赖关系图的这些属性意味着无法分别推理或演化组件。周期中任何组件的更改都可能破坏周期中的所有其他组件。对几乎所有其他组件都依赖的组件所做的更改可能会破坏几乎所有其他组件。因此,这些更改需要很多上下文。密集的周期性依存关系图破坏了组件化的整个思想,它阻止了我们使系统变得更小。
当我们忽略依赖关系图时,在代码库的大部分中,公共接口原来只是现有控制流中的一个附加的间接层。这使得重构这些控制流变得更加困难,因为它添加了需要更改的其他部分。这也使得隔离系统各部分的推理变得容易得多。

该图表明,引入公共接口的最简单方法可能只是意味着将以前有问题的设计泄漏到一个单独的接口类中,从而通过将基础设计问题传播到更多文件中而使其更难解决。
关于依赖关系的理想方向的讨论经常会出现这些潜在的设计问题。我们通常会以这种方式发现职责过多且缺少抽象的对象。
也许并不奇怪,Shopify系统的核心实体之一就是Shop,因此几乎所有东西都取决于Shop类。这意味着如果我们要避免循环依赖关系,Shop类几乎可以不依赖任何东西。 
幸运的是,有一些可靠的工具可以用来理顺依赖关系图。我们可以使箭头指向不同的方向,方法是将职责移到依赖于它们的组件中,或者应用控制反转。控制反转意味着以一种相反的方式反转依赖关系,即控制流和源代码依赖关系是相反的。例如,这可以通过类似的发布/订阅机制来完成ActiveSupport::Notifications。
这种消除循环依赖的策略自然会引导我们从诸如Shop之类的类中删除具体的实现,将其移向仅包含商店标识和一些抽象概念的空容器。
如果我们在构建公共接口时应用上述技术,那么结果将更加有用。简化的图形使我们能够对系统中的各个部分进行推理,甚至为隔离地测试系统的各个部分奠定了一条道路。
平台,支持和前端组件之间的依赖关系图
如果确定某个组件上所有依赖项的期望方向感到不知所措,则可以考虑将组件分组。这使我们能够确定优先级,并集中精力首先清理跨层的依赖关系。上图概述了一个示例。在这里,我们有平台组件PlatformShop Identity,它们纯粹为其他组件提供功能。支持组件(例如,商品销售库存)不仅依赖于平台组件,而且还可以为其他组件提供功能,并且通常服务于自己的外部API。前端组件,例如online shop在线商店死主要面向外部。在查看图层中的相关性(例如,商品销售和库存之间的相关性)之前,可以先确定和清除穿过虚线的相关性。
 
(banq注:以上组件之间和层之间的划分类似DDD的有界上下文划分和上下文映射,只不过DDD使用了这两个专业名词使得初学者可能比较迷茫,有界上下文划分以后,下面是DDD聚合的设计了)
 
具有高内聚力的平衡松耦合
上图是具有低内聚力的紧耦合和具有高内聚力的松耦合的对比
 像我们想要围绕组件建立有意义的边界需要松散的耦合和高度的内聚性。一个很好的近似值是Change Locality:一起更改的代码是可以在一起生活的程度。
首先,我们只专注于组件之间的去耦。之所以感觉良好是因为这是一个简单、可见的更改,但仍然给我们留下了跨越组件边界的代码库的紧密结合部分。在某些情况下,我们加强了破碎状态。结果是,对系统功能的微小更改通常仍意味着跨多个组件的代码更改,因此,所涉及的开发人员需要了解和理解所有这些组件。
更改局部性Change Locality是低耦合和高内聚性的标志,并且使代码的开发更加容易。代码库感觉更小,这是我们声明的目标之一。更改地点也可以显示出来。例如,我们正在致力于自动化分析代码库中所有涉及组件的拉取请求。随着时间的流逝,接触的组件数量应该会下降。
有趣的旁注是,存在各种不同的内聚力。我们发现,我们的遗留代码在考虑内聚性的地方,主要是信息内聚性:对相同数据进行分组的代码。这源于从数据库表开始的设计过程(在Rails社区中很常见)。更改本地性可能会受到阻碍。为了生产易于维护的软件,将重点放在功能凝聚力上更为有意义,因为功能凝聚力将执行任务的代码分组在一起。这也与我们通常对系统的看法非常接近。 
通过使业务逻辑(软件的核心)更易于理解,我们对功能凝聚力的关注已经显示出了好处。
 
(banq注:DDD聚合是逻辑一致性的聚合,体现在静态的结构上凝聚和动态功能的凝聚,功能凝聚更能体现逻辑的一致性,因为逻辑大多数是通过功能动态实现的,当然,结构凝聚可以参考数据表结构的一对多等关系结构。)
(文章中还谈及了SOLID原则)
 
使用工具帮助实现目标
以上是shopify的主要部分,其他部分讲解了如何使用一些工具帮助实现这些目标重构:

  • 使用Rails引擎

当我们开始使用大量自定义代码时,我们的组件逐渐演变为看起来更像Rails Engines。我们正在加倍努力发展引擎。它们是Rails开箱即用的一种模块化机制。它们具有Rails应用程序的熟悉外观和功能,但是除了应用程序以外,我们可以在同一过程中运行多个引擎。而且,如果我们决定从整体中提取组件,那么引擎很容易转换为独立的应用程序。
  • 定义和执行合同

建立牢固的边界界限需要明确的合同。代码和文档中的合同允许开发人员使用组件而无需阅读组件的实现,从而使系统更小。
最初,我们基于dry-schema构建了一个名为Component :: Schema的哈希模式验证库。它为我们服务了一段时间,但是在检查更复杂的合同时遇到了与变更和运行时性能保持一致的问题。
在2019年,Stripe发布了他们的静态Ruby类型检查器Sorbet。Shopify在该版本发布之前就参与了其开发,并且有一个团队为Sorbet做出了贡献,因为我们正在大量使用它。现在,这是我们在组件边界上表达输入和输出合同的首选工具。正确配置后,它几乎不会对运行时性能产生任何影响,它更加稳定,并且提供了接口等高级功能。
  • 执行静态依赖性分析

正如Kirsten 在Shopify上有关组件化的原始博客文章中所述,我们最初构建了一个称为Wedge的调用图分析工具。它在CI上执行测试套件期间记录了所有方法调用,以检测组件之间的调用。
我们开发了一种名为Packwerk的新工具来分析静态常量引用。例如,line Shop.first包含对Shop的静态引用和对该类上称为的方法的方法调用first。Packwerk仅分析对的静态常量引用Shop。静态引用的含糊性较小,并且由于开发人员总是明确引入它们,因此突出显示它们更具可行性。Packwerk在几分钟内就对我们最大的代码库进行了全面的分析,因此我们能够将其与Pull Request工作流集成在一起。这使我们可以拒绝在依赖关系图或组件封装合并到我们的主分支之前破坏它们的更改。
我们计划很快将Packwerk开源。敬请关注!
 
优先考虑所有权或边界的决策
有两种主要方法来划分现有的整体结构,并从一个大泥球中创建组件。以我的经验,所有大型体系结构更改最终都处于不完整状态。也许这是一种悲观的看法,但是我的经验告诉我,暂时的不完整状态至少会持续比您预期的更长的时间。因此,应根据哪种中间状态对您的具体情况最有用来选择一种方法。
  1. 一种选择是根据对未来的某些了解在整体中划定界线,并随着时间的推移将这些线加强为完整的边界。
  2. 另一种选择是将其部分分割成具有强边界的微小单元,然后逐步转移职责,随着时间的推移增加组件。

对于我们的主要整体,我们采用第一种方法。我们的愿景以领域驱动设计的思想为指导(banq注:谈到了DDD)。我们将组件定义为业务领域的子域的实现,并将文件移动到相应的文件夹中。
主要优点是,即使我们还没有完成边界的划分,职责也可以粗略地分组在一起,并且每个文件都分配有一个管理团队。
缺点是几乎没有一个组件具有完整而强大的边界,因为组件包含大量的遗留代码,要建立这些遗留代码需要大量的工作。
如果定义良好的所有权和清晰可见的应用程序分区分包对您来说最重要,那么对未来方法的这种愿景将是很好的,因为对我们而言,这是因为我们有大量的代码库工作人员。
在Shopify中的其他大型应用程序上,我们尝试了第二种方法。优点是代码库的大部分都位于隔离的干净组件中。这为人们努力工作创造了很好的榜样。这种方法的缺点是应用程序内仍然有相当大的泥球,没有任何结构。如果明确边界是您的首要任务,那么这种分拆方法很好。
 
(banq注:重构也是从动态和静态两个方向入手,第一种是一种静态方式,可以通过事件建模将所有相关人员集中在一起白板会议,取得共识划分有界上下文,第二章是程序员从下而上的垂直行为,可能没有宏观战略视野)
  
成就
尽管整体上的功能开发如以往一样快,但许多开发人员正在同时使事情变得更加模块化。我们看到有更多的人可以做到这一点,并且围绕代码库的优秀示例也在不断增加。
目前,我们的主要整体中有37个组件,每个组件都有公共入口,涵盖了其大部分职责。Packwerk用于大约三分之一的组件,以限制其依赖关系并保护其内部实现的私密性。我们正在努力使Packwerk具有足够的吸引力,以使所有组件都能采用它。
通过增加采用率,我们逐渐增强了依赖图的属性。总的非周期性是长期目标,但短期内我们可以从图形中删除的边越多,系统就越容易推理。
现在,我们还有其他一些整体式应用程序正在经历相似的组件化过程。一些目标是长期拆分成单独的服务,有些目标是模块化整体。我们非常谨慎地考虑何时将功能拆分到单独的服务中,并且我们这样做的理由充分。这是因为将单个整体应用程序拆分为分布式服务系统会大大增加总体复杂性。
例如,我们拆分店面渲染,因为这是一个具有很高吞吐量的只读用例,这对我们来说很有意义,可以与商家用来管理商店的界面分开扩展和分发它。信用卡保管库是一项单独的服务,因为它处理的敏感数据不应流经系统的其他部分。
此外,我们准备在Shopify上默认将所有新的Rails应用程序组件化。这个想法是在创建Rails应用程序,删除顶层应用程序文件夹并从一开始就为模块化的未来设置开发人员时,开箱即用地生成多个单独测试的引擎。
同时,我们正在研究一些必要的模式,以阻止进一步采用Packwerk。首先,这意味着使依赖图易于清理。我们想鼓励控件的倒置,更普遍的是依赖倒置,这可能会导致我们在许多情况下使用发布/订阅机制,而不是直接的方法调用。
第二个大障碍是有效地查询组件之间的数据,而不会过于紧密地耦合它们。这方面最有趣的问题是
  • 我们的GraphQL API向外部使用者展示了部分圆形的图,而我们希望组件中的实现是非循环的。
  • 当前,我们的GraphQL查询执行和ElasticSearch重新索引严重依赖Active Record功能,这打败了“公共接口,私有实现”的想法。

长期的愿景是为我们的主要整体组件的大多数组件提供独立的隔离测试套件。