Shopify 是目前规模最大的 Ruby on Rails 代码库之一,超过一千名开发人员在过去十多年中一直在使用这套库。其中包含有商家计费、第三方开发者应用程序管理、产品更新以及运输处理等众多不同功能。其最初面向整体式架构所设计,意味着所有不同的功能都被整合在同一代码库当中,且各个组件间没有边界。多年以来,这类架构一直为我们提供服务;但随着时间的推移,我们迎来了需求的转折点——整体式设计的缺点开始超过收益。在接下来的道路中,我们必须做出新选择。
微服务架构近年来大受欢迎,甚至被很多支持者描述为能够克服整体式架构内所有问题的终极解决方案。然而,我们自己的整体经验告诉我们,没有哪一种解决方案能够真正做到百试百灵。事实上,微服务架构本身也会带来一系列新挑战。考虑到这一点,我们决定将 Shopify 开发为模块化整体架构——更具体地讲,所有代码仍然保存在同一代码库当中,但同时确保不同组件之间存在明确的定义与边界。
每一种软件架构都有着自己的优点与短板,根据应用程序的具体开发阶段,不同的解决方案对应用程序的意义也将有所变化。因此,从整体式架构到模块化整体架构,自然成为我们合乎逻辑的优化方向。
根据维基百科的说明,整体式架构代表一种软件系统,其中的不同功能彼此交织在一起,而非建立在架构之上的独立组件。对于 Shopify 来说,其中用于处理运费计算的代码就与处理结账的代码共存于一处,而且几乎无法阻止各组件之间相互通信。随着时间的推移,这将致使处理不同业务流程的代码之间存在极高的耦合度。
整体式架构的实现难度最低。如果不提出强制性的架构要求,那么开发人员最终拿出的基本上就是整体式架构。在 Ruby on Rails 情况也同样如此,整体式架构使得应用层级的所有代码都具有全局可用性。整体架构能够将应用程序推向极致,并允许团队在起步阶段即快速推进,从而更早将产品提供给客户。
统一维护代码库、并将应用程序部署在单一位置的作法具有诸多优势。我们只需要维护一套存储库,并能够轻松搜索以及查找单一文件夹中的所有功能。这同时意味着我们只需要维护一套测试与部署流水线——具体取决于应用程序的复杂性——从而避免大量开销。但在另一方面,这些流水线拥有高昂的创建、定制与维护成本,因为所有参与方必须齐心协力才能确保各流水线间的一致性。由于所有代码都被部署在单一应用当中,因此数据也可以存储在同一共享数据库内。每当需要数据时,我们都可以通过简单的数据库查询对其进行检索。
由于整体式架构拥有统一的部署位置,因此管理人员只需要打理一组基础设施。大多数 Ruby 应用程序都带有数据库、Web 服务器、后台作业功能,外加其它多种基础设施组件——Redis、Kafka、Elasticsearch 等等。新添加的每一组基础设施,都会增加 DevOps 的时间开销,意味着消耗掉了本可用于建立 DevOps 体系的时间。另外,新增基础设施还会增加故障点数量,同时降低应用程序的弹性与安全性水平。
在面对多种独立服务时选择整体式架构的最大优势之一,在于您可以直接调用不同组件,而无需通过 Web 服务 API 进行通信。这意味着我们不需要考虑 API 版本管理以及向下兼容性,更不必担心潜在的调用滞后问题。
但需要强调的是,如果应用程序或者团队达到一定的规模,那么业务需求最终将超出整体式架构的承受能力。2016 年 Shopify’就遇到了这样的难题,当时开发人员发现越来越难以为项目构建及测试新的功能。下面,我们来看其间出现的几个具体问题。
整体式应用程序往往非常脆弱,而新代码总会带来意想不到的影响。哪怕是一些看似无害的变化,也有可能引发一系列看似彼此无关的测试失败。例如,如果我们的税率计算代码当中调用到了运费计算代码,那么我们对税率计算方法做出的调整很有可能影响到运费的计算结果。这正是高耦合度与边界缺乏带来的结果,同时也使我们难以编写测试,并面对非常缓慢的 CI 运行速度。
在 Shopify 项目当中,看似简单的更改也同样需要涉及大量上下文信息。当新的 Shopify 开发者加入团队时,他们会发现自己面对沉重的学习负担;在搞清关于代码库的诸多细节之前,他们根本无法开始自己的工作。例如,加入运输团队的新人本来可以只学习运输业务的逻辑实现,但整体式架构迫使他们同时理解订单的创建方式、如何处理付款流程等等,因为这一切都早已紧密交织在一起。这可真的太难了,人们在发布哪怕最简单的第一项功能之前,都首先需要掌握大量相关知识。此外,复杂的整体式应用还会带来陡峭的学习曲线。
我们遇到的所有问题,都是代码当中不同功能之间缺乏边界的直接结果。很明显,我们需要减少不同域之间的耦合度,那么新的问题来了——耦合度该如何减少?
微服务已经成为一种非常流行的解决方案。微服务架构是一种应用程序开发方法,其实质在于将大型应用程序构建为一系列独立部署的小型服务。虽然微服务能够解决我们遇到的问题,但同时也会带来一整套新的挑战。
我们必须维护多个不同的测试与部署流水线,承担每一项服务带来的基础设施开销,同时忍受偶尔无法在需要时访问相关数据的问题。由于每一项服务都是独立部署的,因此服务之间的通信相当于跨网进行,这会增加延迟水平并降低每一次调用的可靠性。此外,跨多面服务的大规模重构可能非常繁琐,要求我们对所有相关服务进行更改并协调部署工作。
我们希望拥有一种解决方案,其能够在不增加部署单元数量的前提下实现模块化,使我们能够以较低的成本同时获得整体与微服务两种架构的优势。
模块整体式架构代表一类系统,其中所有的代码都为单一应用程序提供支持,并且在不同域之间存在着严格的强制性边界。
很明显,我们的业务需求已经超出整体式结构的极限,并开始影响到开发人员的生产力甚至是幸福感。为此,我们向在系统核心项目中工作的全体开发人员发出一项调查,用以确定目前的主要问题。我们知道自己遇上了问题,但希望在提出解决方案之前能够首先收集数据信息,以确保方案可以真正解决我们的问题——而不只是脑海中想到的问题。
调查的结果让我们坚定了拆分代码库的决心。2017 年年初,我们组建了一支人员不多但非常强大的团队,负责专门解决这个问题。该项目最初被命名为“核心片段拆分”,之后逐步演变为“组件化”项目。
该小组决定解决的第一个问题,就是代码组织。目前,我们的代码组织与典型的 Rails 应用程序基本一致:通过软件概念(例如模型、视图、控制器)进行组织。本次发行的目标,是通过真实世界中的概念(例如订单、运输、库存以及计费)对项目者重新组织,以便降低代码查找难度、定位能够理解代码含义的开发人员,并了解他们各自负责管理哪些代码。每个组件在结构当中都作为一款迷你 rails 应用存在,我们的目标是最终将它们命名为 ruby 模块。我们希望这种新的组织方式能够帮我们发现那些不必要的耦合区域。
为了提出最初的组件清单,我们需要对企业内各个领域的利益相关方进行研究与投入。我们通过一份大型电子表格列出了每个 ruby 类(总计约 6000 个),并手动标出其所属的组件。虽然在整个统计过程中不需要变更任何代码,但其仍然与代码库息息相关,而且错误操作很有可能引发风险。我们利用自动脚本通过一轮大规模 PR 完成了这项调整。由于变更内容只涉及文件移动,因此可能由此引发的故障也相对简单,只有代码找不到对象定义以及由此引发的运行时错误。我们对代码库进行了充分测试,确保在本地及 CI 中运行测试时不会出现任何故障。另外,我们还尽量以本地及分段方式运行功能测试,以确保没有遗漏部分。我们决定在单一 PR 当中完成全部操作,这应该可以最大程度减少对开发人员的影响。但此次变更的问题在于,由于文件移动被系统认定为删除加创建——而非重命名,我们的 GitHub 当中丢失了大量 Git 历史记录。我们仍然可以使用 git ‘-follow’选项跟踪文件的移动流程,但 Github 无法理解移动的具体过程。
下一步是通过拆分业务域实现依赖关系的隔离。各个组件都定义有一个清洁的专用接口,其域边界通过公共 API 表示,且对相关数据拥有独占所有权。虽然团队无法在整个 Shopify 代码库中实现这一点(这项工作需要来自各个业务领域的专家共同配合),但已经定义出模式并提供完成这项任务的必要工具。
我们内部开发出一款名为 Wedge 的工具,它能够跟踪每个组件在隔离方面的进展。它能够高亮显示任何违反域边界的行为(通过除公共定义的 API 之外的任意组件,访问另一组件时)以及跨边界的数据耦合。为了实现这一目标,我们还编写了一款工具,用于在 CI 当中钩入 Ruby 跟踪点以获取完整的调用图。接下来,我们按组件对调用方及被调用方进行排序,仅选择跨组件边界的调用,并将其发送至 Wedge。除了这些调用之外,我们还会从代码分析当中发送其它一些数据,例如 ActiveRecord 关联与继承。Wedge 随后可以确定哪些跨组件关系(调用、关联、继承)是正常的,而哪些不正常。一般来讲:
Wedge 随后即可计算总得分,并列出各个组件的违规情况。
Shopify Wedge - 追踪各个组件目标的进展
下一步,我们着手绘制随时间变化的得分趋势,同时展示有意义的差异,以帮助人们了解得分变化的原因与时间。
从长远角度来看,我们希望更进一步,以编程方式强制执行这些边界。虽然我们目前还在研究具体应当采用怎样的方法,但宏观层面的计划是保证各个组件仅加载其明确依赖的其它组件。如果该组件试图访问另一未声明依赖关系的组件中的代码,则会导致运行时错误。另外,当组件在通过公共 API 之外的任何其它方式受到访问,同样可以触发运行时错误或者测试失败。
我们还打算删除那些计划外以及循环依赖关系,从而解开复杂混乱的域依赖关系图。实现完全隔离是一项持续性的任务,但目前 Shopify 的所有开发人员都在为此努力,我们也已经看到一些与预期相符的好处。例如,我们拥有一套传统税务引擎,但其已经无法满足商家的需求。在进行本文提到的重构之前,我们几乎不可能将这套旧系统更换为新系统。但由于我们已经投入了大量精力来隔离依赖关系,因此现在我们可以将原有税务引擎替换为另一种全新的税收计算系统。
总之,在系统的构建早期,没有架构通常代表最好的架构。这并不是说不需要采取良好的软件实践,而是我们没必要急于投入数周甚至数个月来构建一套我们自己还没想清楚的复杂系统。根据 Martin Fowler 的经典文章,在大多数应用程序的早期阶段,我们完全可以利用较少的设计量快速推动。把设计质量与产品发布时间结合起来并加以权衡,才是更切合实际的作法。而到特性与功能的添加速度开始逐渐下降,则意味着我们是时候开始认真考虑良好设计的问题了。
重组与重构的最佳时间,永远是越晚越好,因为我们的构建的过程中能够不断积累到关于系统以及业务的知识。在真正掌握这些专业知识之前就盲目设计复杂的微服务系统,无疑是一种冒险性的行为,而目前不少软件项目都身陷这种困境。根据 Martin Fowler 的说法,“我听说过的几乎一切号称要从头开始采取微服务架构的系统,最后几乎都以失败告终……即使您可以肯定「应用程序的未来规模会非常庞大,值得使用微服务」,也同样没有这个必要。”
良好软件架构是一项不断发展变化的任务,而最适合当前需求的解决方案,则取决于您的实际运营规模。随着应用程序复杂性的增加,整体式、模块整体式以及面向服务等架构都将逐渐演化。每种架构都适用于不同规模的团队 / 应用程序,也各自拥有不同的顺利与困难阶段。如果您在实践当中体会到本文中提到的这些痛点时,可能就意味着是时候摆脱现有解决方案,转而迎接下一种架构了。
原文链接:
Deconstructing the Monolith: Designing Software that Maximizes Developer Productivity