对单体系统优缺点评判到位:拆分Shopify单体工程的经验分享


Shopify是现存最大的Ruby on Rails代码库之一。它已被超过一千名开发人员使用了十多年。它封装了来自计费商家,管理第三方开发者应用程序,更新产品,处理运输等许多不同功能。它最初是作为整体构建的,这意味着所有这些不同的功能都构建在相同的代码库中,它们之间没有边界。多年来,这种架构为我们工作,但最终,我们达到了这样一个临界点,即单体monolith的缺点超过了好处。我们必须选择如何进行分解。
微服务近年来大受欢迎,并被吹捧为解决所有单体问题的最终解决方案。然而,我们自己的集体经验告诉我们,没有一种尺寸适合所有最佳解决方案,微服务将带来他们自己的一系列挑战。我们选择将Shopify发展为模块化单体,这意味着我们将所有代码保存在一个代码库中,但确保在不同组件之间定义和遵守边界。
每个软件体系结构都有自己的优缺点,根据应用程序的增长阶段,不同的解决方案对于应用程序是有意义的。从单体到模块化是我们的下一个合乎逻辑的步骤。

单体架构
根据维基百科,monolith是一个软件系统,其中功能上可区分的方面都是交织在一起的,而不是包含架构上独立的组件。对于Shopify来说,这意味着处理计算运费的代码与处理结账的代码一起存在,并且几乎没有阻止他们互相打电话。随着时间的推移,这导致处理不同业务流程的代码之间的极高耦合。

单体系统的优点
单体架构是最容易实现的。如果没有实施架构设计,一般结果可能就是一个单体。在Ruby on Rails中尤其如此,由于应用程序级别的所有代码的全局可用性,非常适合构建单体。单体架构可以将应用程序推向极致,因为它易于构建,并允许团队在一开始就非常快速地移动,以便更早地将产品提供给客户。
将整个代码库保存在一个位置并将应用程序部署到一个位置具有许多优点。您只需要维护一个存储库,并且能够轻松搜索并查找一个文件夹中的所有功能。它还意味着只需要维护一个测试和部署管道,这取决于应用程序的复杂性,可以避免很多开销。这些管道的创建,定制和维护成本很高,因为它需要齐心协力才能确保所有管道的一致性。由于所有代码都部署在一个应用程序中,因此数据都可以存储在单个共享数据库中。每当需要一个数据时,它就是一个简单的数据库查询来检索它。 
由于单体部署在同一个地方,因此只需要管理一组基础设施。大多数Ruby应用程序都带有数据库,Web服务器,后台作业功能,然后可能还有其他基础架构组件,如Redis,Kafka,Elasticsearch等等。这些其他基础架构还会增加可能的故障点,从而降低应用程序的弹性和安全性。
 在多个独立服务上选择单体架构最显着的好处之一是,您可以直接调用不同的组件,而不需要通过Web服务API进行通信,这意味着您不必担心API版本管理和向后兼容性,以及潜在的滞后调用。
 
单体系统的缺点
但是,如果应用程序达到一定规模或者团队建设达到一定规模,它最终将超越单体架构。这发生在2016年的Shopify,由于构建和测试新功能的不断增加的挑战而显而易见。具体来说,有几件事情可以作为我们的绊脚石。
应用程序非常脆弱,新代码具有意想不到的影响。做出看似无害的变化可能会引发一系列无关的测试失败。例如,如果计算我们的运费的代码被调用到计算税率的代码中,那么对我们计算税率的方式进行更改可能会影响运费计算的结果,但这可能并不明显。这是高耦合和缺乏边界的结果,这也导致难以编写的测试,并且在CI上运行非常慢。 
在Shopify中进行开发需要大量的上下文来进行看似简单的更改。当新的Shopifolk上架并开始了解代码库时,他们在生效之前需要获取的信息量是巨大的。例如,加入运输团队的新开发人员应该只需要了解运输业务逻辑的实施,然后才能开始构建。然而,现实情况是,他们还需要了解订单的创建方式,我们如何处理付款等等,因为一切都是如此交织在一起。这对于一个人来说只是为了发布他们的第一个特征而必须坚持下去的知识太多了。复杂的整体应用导致陡峭的学习曲线。
我们遇到的所有问题都是代码中不同功能之间缺乏界限的直接结果。很明显,我们需要减少不同域之间的耦合。

微服务架构
微服务是一种非常时髦的解决方案。微服务架构是一种应用程序开发方法,其中大型应用程序构建为一套独立部署的小型服务。虽然微服务可以解决我们遇到的问题,但它们会带来另一整套问题。 
我们必须维护多个不同的测试和部署管道,并承担每项服务的基础架构开销,同时并不总是能够在需要时访问我们需要的数据。由于每个服务都是独立部署的,因此服务之间的通信意味着跨越网络,这会增加延迟并降低每次呼叫的可靠性。此外,跨多个服务的大型重构可能很繁琐,需要对所有相关服务进行更改并协调部署。

模块化单体
我们想要一种解决方案,在不增加部署单元数量的情况下增加模块化,使我们能够获得单块和微服务的优势,而没有太多的缺点。
模块化整体是一种系统,其中所有代码都为单个应用程序提供支持,并且在不同域之间存在严格的强制边界。

Shopify的Modular Monolith实现:组件化
很明显,我们已经超越了单体结构,并且它正在影响开发人员的生产力和幸福感,我们已经向在我们的核心系统中工作的所有开发人员发送了一项调查,以确定主要的难点。我们知道我们遇到了问题,但我们希望在提出解决方案时能够获得数据信息,以确保它能够真正解决我们遇到的问题,而不仅仅是传闻中的问题。
该调查的结果告知我们决定拆分我们的代码库。在2017年初,一个小而强大的团队被组合起来解决这个问题。该项目最初被命名为“Break-Core-Up-Into-Multiple-Pieces”,最终演变为“组件化”。

代码组织
他们选择解决的第一个问题是代码组织。目前,我们的代码组织得像典型的Rails应用程序:软件概念(模型,视图,控制器)。目标是通过真实世界的概念(如订单,运输,库存和计费)对其进行重新组织,以便更容易找到代码,找到理解代码的人,并了解他们的个别部分。
每个组件都将构建为自己的迷你rails应用程序,目标是最终将它们命名为ruby模块。希望这个新组织能够突出那些不必要耦合的领域。 
提出最初的组件清单涉及公司每个领域的利益相关者的大量研究和投入。我们通过在一个大型电子表格中列出每个ruby类(大约6000个)并手动标记它所属的组件来完成此操作。即使在此过程中没有更改代码,它仍然触及整个代码库,如果操作不正确可能存在风险。
我们在自动脚本构建的一个大爆炸PR中实现了这一改革举措。由于引入的更改只是文件移动,因此可能发生的故障将导致我们的代码不知道在何处查找对象定义,从而导致运行时错误。我们的代码库经过了充分的测试,因此通过在本地和CI中运行我们的测试而不会出现故障,以及在本地和分段上运行尽可能多的功能,我们能够确保没有遗漏任何东西。我们选择在一个PR中完成所有操作,因此我们只会尽可能少地破坏所有开发人员。这种变化的一个不幸的缺点是,当文件移动被错误地跟踪为删除和创建而不是重命名时,我们在Github中丢失了很多Git历史记录。我们仍然可以使用。来追踪起源git`-follow`选项跟随文件移动的历史,但是,Github不理解这一举​​动。

隔离依赖关系
下一步是通过将业务域彼此分离来隔离依赖关系。每个组件都定义了一个干净的专用接口,其域边界通过公共API表示,并对其关联数据进行独占所有权。虽然团队无法在整个Shopify代码库中实现这一点,因为它需要来自每个业务领域的专家,但他们确实定义了模式并提供了完成任务的工具。 
我们在内部开发了一个名为Wedge的工具,它跟踪每个组件朝着隔离目标的进展。它突出显示任何违反域边界的行为(当通过除公共定义的API之外的任何组件访问另一个组件时)以及跨边界的数据耦合。为实现这一目标,我们编写了一个工具,在CI期间挂钩到Ruby跟踪点以获得完整的调用图。然后,我们按组件对调用者和被调用者进行排序,仅选择跨组件边界的调用,并将它们发送到Wedge。除了这些调用之外,我们还会从代码分析中发送一些其他数据,例如ActiveRecord关联和继承。Wedge然后确定哪些跨组件事物(调用,关联,继承)是正确的,哪些是违反的。通常:

  • 跨组件关联总是违反组件化
  • 调用只适用于明确公开的内容
  • 继承将类似,但尚未完全实现

Wedge然后计算总分并列出每个组件的违规。下一步,我们将绘制随时间变化的分数趋势,并显示有意义的差异,以便人们可以看到分数变化的原因和时间。

运行边界
从长远来看,我们希望更进一步,并以编程方式强制执行这些边界。Dan Manges的这篇博客文章  提供了一个应用团队如何实现边界实施的详细示例。虽然我们仍在研究我们想要采用的方法,但高级计划是让每个组件仅加载其明确依赖的其他组件。如果它试图访问未声明依赖的组件中的代码,则会导致运行时错误。当组件通过其公共API以外的任何其他方式访问时,我们还可能触发运行时错误或测试失败。 
我们还想 通过删除意外和循环依赖关系来解开域依赖关系图。实现完全隔离是一项持续的任务,但是Shopify的所有开发人员都在投资,我们已经看到了一些预期的好处。例如,我们有一个传统的税务引擎,不再满足我们商家的需求。在本文所述的努力之前,将旧系统更换为新系统几乎是不可能完成的任务。但是,由于我们已经投入了大量精力来隔离依赖关系,我们能够将我们的税务引擎换成一个全新的税收计算系统。
总之,在系统早期,没有任何架构通常是最好的架构。这并不是说不实施良好的软件实践,而是花费数周和数月的时间来尝试构建一个您还不知道的复杂系统。
Martin Fowler的Design Stamina Hypothesis  通过解释在大多数应用程序的早期阶段,您可以实施比较少的事先设计。将设计质量与上市时间进行权衡是切合实际的。一旦您可以添加特性和功能的速度开始减慢,那就是投资良好设计的时候了。 
重构和重新构建的最佳时间是尽可能晚,因为您在构建时不断了解有关系统和业务领域的知识。在拥有领域专业知识之前设计一个复杂的微服务系统是一个冒险的举措,太多的软件项目都会陷入其中。根据Martin Fowler的说法,“几乎所有我听说过从头开始构建为微服务系统的系统,它已经结束了严重的麻烦......你不应该开始一个带微服务的新项目,即使你'确保你的应用程序足够大,以使其值得“。良好的软件架构是一项不断发展的任务,适合您应用的正确解决方案绝对取决于您的运营规模。随着应用程序复杂性的增加,Monolith,模块化整体结构和面向服务的体系结构将逐渐演化。每个架构都适用于不同规模的团队/应用程序,并将被痛苦和痛苦的时期分开。当你开始体验本文中强调的许多痛点时,那就是当你知道你已经超越当前的解决方案时,是时候进入下一个了。