单体架构拆解实践:从单体到模块化单体,提升开发者效率

本文以某海外电商平台的架构演进实践为例,介绍大型 Ruby on Rails 代码库如何从传统单体架构逐步演进为模块化单体架构,并通过代码组织、依赖隔离和边界治理,降低系统复杂度,提升开发者效率。

某海外电商平台拥有当前规模最大的 Ruby on Rails 代码库之一。这个代码库由上千名开发者在十多年间持续维护和演进,承载了大量业务功能,包括商户计费、第三方开发者应用管理、商品更新、物流履约等。

单体架构拆解实践:从单体到模块化单体,提升开发者效率

最初,该平台采用的是单体架构。这意味着所有不同业务能力都构建在同一个代码库中,彼此之间几乎没有明确边界。多年来,这种架构一直运行良好,但随着业务和团队规模不断扩大,单体架构带来的问题逐渐超过了它的优势。团队必须做出新的架构选择。

近年来,微服务架构迅速流行,并一度被视为解决单体架构所有问题的“终极方案”。但实践经验表明,软件架构并不存在一种放之四海而皆准的最佳答案。微服务能够解决一部分问题,也会带来新的复杂性。最终,该团队选择将核心系统演进为模块化单体架构:代码仍然保留在同一个代码库中,但不同组件之间需要定义并遵守清晰的边界。

每种软件架构都有自身的优缺点,不同方案也适用于应用发展的不同阶段。对该团队而言,从单体架构过渡到模块化单体架构,是当时最自然、也最合理的下一步。

什么是单体架构

单体架构指的是一个软件系统中,功能上可以区分的不同部分彼此交织在一起,而不是由架构上相互独立的组件构成。

对于某海外电商平台而言,这意味着处理运费计算的代码与处理结账流程的代码共存在同一个系统中,并且几乎没有机制阻止它们相互调用。随着时间推移,处理不同业务流程的代码之间形成了高度耦合。

单体架构的优势

单体架构是最容易实现的架构之一。如果没有强制性的架构约束,一个应用最终很可能自然演变成单体应用。这一点在 Ruby on Rails 中尤其明显,因为 Rails 应用中的代码在应用层面通常是全局可访问的,因此非常适合构建单体应用。

单体架构可以让应用在早期快速发展。它易于构建,也便于团队快速推进功能,从而尽早将产品交付给用户。将所有代码维护在一个地方,并将应用部署到同一个位置,也带来了许多明显优势。

首先,团队只需要维护一个代码仓库,并且可以在同一个目录中搜索和定位所有功能。其次,团队只需要维护一套测试和部署流水线。根据应用复杂度不同,这可以节省大量成本。构建、定制和维护多套流水线往往需要投入大量精力,尤其是当团队还需要确保所有流水线行为一致时。

由于所有代码都部署在同一个应用中,所有数据也可以存放在一个共享数据库中。每当需要数据时,系统只需要执行一次普通的数据库查询即可获取。

单体架构还意味着团队只需要管理一套基础设施。大多数 Ruby 应用通常包含数据库、Web 服务器、后台任务处理能力,以及 Redis、Kafka、Elasticsearch 等其他基础设施组件。每增加一套基础设施,团队就需要花费更多时间进行运维,而不是专注于产品和功能开发。额外的基础设施也会增加潜在故障点,从而影响应用的稳定性和安全性。

相比多个独立服务,单体架构还有一个重要优势:不同组件之间可以直接调用,而不需要通过 Web 服务 API 通信。这意味着团队不必处理 API 版本管理和向后兼容性问题,也无需承担网络调用带来的延迟。

单体架构的缺点:复杂度、耦合与开发效率问题

然而,当应用规模或开发团队规模增长到一定程度后,单体架构最终会承受巨大压力。到 2016 年,该平台在构建和测试新功能时遇到的挑战越来越明显。最突出的问题主要有两个。

首先,应用变得极其脆弱,新代码修改很容易带来意想不到的后果。一个看似无关紧要的改动,可能会引发一连串表面上毫不相关的测试失败。例如,如果计算运费的代码调用了计算税率的代码,那么修改税率计算方式就可能影响运费计算结果,而问题原因并不容易被发现。

这种问题的根源在于高度耦合和边界缺失。它不仅让代码更难理解,也让测试更难编写,并且导致测试在持续集成环境中的运行速度越来越慢。

其次,即便是看似简单的改动,也需要开发者掌握大量上下文信息。新员工加入后,往往需要理解庞大的代码库,才能真正开始贡献代码。比如,一名新加入物流团队的开发者,理论上只需要理解物流业务逻辑即可开始工作。但在现实中,由于各个业务环节紧密交织,他还需要理解订单如何创建、支付如何处理,以及许多其他相关流程。

对于一名开发者来说,仅仅为了发布第一个功能,就必须掌握如此庞大的知识体系,这显然并不现实。复杂的单体应用会显著拉高学习曲线,最终影响开发者效率。

该团队遇到的这些问题,本质上都源于不同功能之间缺乏清晰边界。显然,团队需要降低不同业务领域之间的耦合度。真正的问题在于:应该如何做到这一点?

微服务架构是否一定适合大型系统

微服务是近年来行业中非常流行的一种解决方案。微服务架构将大型应用拆分为一组较小的独立服务,并让这些服务能够独立部署。

微服务确实可以解决单体架构中的一部分问题,但也会引入一系列新的复杂性。

采用微服务后,团队需要维护多条测试和部署流水线,也需要承担每个服务对应的基础设施成本。与此同时,系统并不总能随时直接获取所需数据。由于每个服务都是独立部署的,服务之间的通信需要跨越网络,每一次调用都会增加延迟,并降低整体可靠性。

此外,跨多个服务的大规模重构也会变得非常繁琐。团队不仅需要修改所有相关服务,还需要协调它们的发布节奏和部署顺序。

模块化单体架构:兼顾边界与交付效率

该团队希望找到一种方案:在不增加部署单元数量的前提下,提高系统的模块化程度。换句话说,他们希望同时获得单体架构和微服务架构的部分优势,同时尽量避免二者的主要缺点。

模块化单体架构正是这样一种方案。它指的是:所有代码仍然服务于同一个应用,但不同业务领域之间存在严格、清晰的边界。

模块化单体的落地:组件化

当团队发现原有单体架构已经难以支撑需求,并开始影响开发者效率和满意度时,他们向所有参与核心系统开发的工程师发放了一份调查问卷,以识别开发过程中最突出的痛点。

团队已经意识到问题存在,但希望在制定解决方案时能够以数据为依据,确保方案真正解决主要矛盾,而不是只回应少数零散反馈。对于很多正在进行大型系统治理的企业来说,这类工作不仅是代码重构问题,也涉及目标拆解、需求梳理、开发排期、测试发布和知识沉淀等研发管理流程。此时,借助 PingCode 这类智能化研发管理工具,将目标、需求、开发、测试、发布和 Wiki 知识沉淀串联起来,可以让架构治理过程中的数据和经验更容易沉淀、流转和复用。

调查结果促使团队决定对代码库进行拆分。2017 年初,他们组建了一支精干团队来负责这项工作。项目最初被称为“将核心系统拆分成多个部分”,后来逐渐演变为“组件化”。

代码组织:按业务领域重构大型代码库

团队首先要解决的是代码组织问题。

当时,代码组织方式与典型 Rails 应用类似:按照软件概念划分,例如模型、视图和控制器。新的目标是按照真实业务场景重新组织代码,例如订单、发货、库存和计费。这样做可以让开发者更容易找到代码,也更容易找到理解相关代码的人,同时让每个组件本身更容易被理解。

每个组件都会被构建成一个独立的迷你 Rails 应用,最终目标是将它们命名空间化为 Ruby 模块。团队希望新的组织方式能够暴露出那些原本隐藏在代码中的不必要耦合。

制定初始组件列表需要大量调研,也需要征求公司内不同业务领域相关方的意见。团队通过一个庞大的电子表格列出了所有 Ruby 类,总数约为 6000 个,并手动标记每个类所属的组件。

尽管这一过程没有修改业务逻辑,但它涉及整个代码库。如果处理不当,风险仍然很高。团队通过自动化脚本构建了一个大型 PR 来完成这项工作。由于引入的变更主要是文件移动,因此最可能出现的问题是代码无法找到对象定义,从而导致运行时错误。

得益于充分的测试覆盖,团队通过在本地和持续集成环境中运行测试、确保没有失败,并在本地和预发布环境中尽可能充分地验证功能,来降低遗漏风险。团队选择在一个 PR 中完成所有操作,以尽可能减少对所有开发者的影响。

这次变更也带来了一个副作用:由于部分文件移动在代码托管平台中被错误地识别为删除和新增,而不是重命名,团队丢失了大量可直接查看的 Git 历史记录。虽然仍然可以通过 git --follow 追踪文件移动历史,但代码托管平台本身无法正确识别这类移动操作。

依赖隔离:治理模块化单体中的边界问题

下一步是隔离依赖关系,也就是将不同业务领域彼此解耦。

每个组件都需要定义清晰的专用接口,通过公共 API 明确划分领域边界,并独占自身关联的数据。由于这项工作需要各个业务领域专家深入参与,负责组件化的团队无法独自完成整个代码库的改造。但他们定义了统一模式,并提供了完成这项工作的工具。

在实际推进过程中,组件边界治理往往需要架构团队、业务团队、测试团队和项目管理角色持续协同。类似 Worktile 这样的通用项目协作系统,可以承载任务分解、项目进度、文档协作、日历排期、审批和工时等信息,让跨团队的架构改造不只停留在技术方案中,而是被落实到可跟踪、可协同、可复盘的执行过程里。

团队内部开发了一套依赖治理工具,用于跟踪每个组件实现隔离目标的进度。这个工具会突出显示任何违反领域边界的行为,例如绕过公开 API 直接访问其他组件,以及跨边界的数据耦合。

为实现这一点,团队编写了一个工具,在持续集成过程中挂接 Ruby 跟踪点,以获取完整调用图。随后,系统会按照组件对调用方和被调用方进行分类,只筛选跨组件边界的调用,并将这些数据发送到依赖治理工具中。

除了调用关系之外,系统还会发送部分代码分析数据,例如 ActiveRecord 关联关系和继承关系。依赖治理工具随后会判断哪些跨组件行为是允许的,哪些属于违规行为。通常而言:

  • 跨组件关联始终违反组件化原则;
  • 只有明确公开的接口才能被调用;
  • 继承关系也需要受到类似约束,但相关机制仍在逐步完善。

该工具会计算每个组件的总体评分,并列出各个组件中的违规情况。

下一步,团队计划绘制评分随时间变化的趋势图,并展示有意义的差异,让开发者能够清楚看到评分变化的原因和发生时间。

边界维护:让架构治理成为持续过程

从长远来看,团队希望进一步通过程序化方式强制执行这些边界。已有工程实践表明,应用团队可以通过运行时检查等方式实现边界约束。虽然最终方案仍在探索中,但总体思路是让每个组件只加载它明确声明依赖的其他组件。

如果某个组件试图访问未声明依赖的组件代码,系统就会触发运行时错误。此外,当某个组件通过公共 API 以外的方式被访问时,也可以触发运行时错误,或直接导致测试失败。

团队还希望通过消除偶然依赖和循环依赖,进一步理清领域之间的依赖关系图。实现完全隔离是一项长期工作,但所有开发者都在持续投入,并且团队已经开始看到预期收益。

例如,平台此前使用的一套旧版税务引擎已经无法满足商户需求。在这些架构治理工作开展之前,用新系统替换旧税务引擎几乎是不可能完成的任务。但由于团队投入了大量精力隔离依赖关系,他们最终能够用全新的税务计算系统替换旧引擎。

总结:软件架构应当随系统规模演进

总的来说,在系统开发早期,没有复杂架构往往就是最合适的架构。这并不是说团队不需要遵循良好的软件工程实践,而是说,不应该花费数周甚至数月时间,去设计一个自己尚未真正理解的复杂系统。

某知名软件架构专家提出的“设计耐力假说”很好地阐释了这一观点:在大多数应用的早期阶段,即便设计投入不多,团队也可以快速推进。为了尽早将产品推向市场,适度牺牲一部分设计质量是可以接受的。等到添加功能的速度开始放缓时,再真正重视优秀设计,往往更加合理。

重构和重新架构的最佳时机,通常应该尽可能晚一些到来。因为在构建系统的过程中,团队会不断加深对业务领域和系统本身的理解。在缺乏领域知识的情况下,一开始就设计复杂的微服务系统,是一件风险很高的事情。许多软件项目都会犯这样的错误。

正如这位专家所说,他听说过的几乎所有从零开始构建的微服务系统,最终都遇到了严重问题。即使你确信自己的应用规模足够大、值得采用微服务,也不应该在新项目一开始就直接使用微服务。

优秀的软件架构不是一次性设计出来的,而是在系统发展过程中不断演进出来的。适合某个应用的架构方案,完全取决于它当前的规模和复杂度。

随着应用复杂度不断提升,单体架构、模块化单体架构和面向服务架构会沿着一条演进路径逐步展开。每种架构都适用于不同规模的团队和应用,也都会经历相应的阵痛期和磨合期。当团队开始遇到本文中提到的各种痛点时,往往就意味着当前架构已经无法满足需求,是时候迁移到下一种更适合的方案了。

文章包含AI辅助创作:单体架构拆解实践:从单体到模块化单体,提升开发者效率,发布者:su,转载请注明出处:https://worktile.com/kb/p/3979933

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
su的头像su

发表回复

登录后才能评论
注册PingCode 在线客服
站长微信
站长微信
电话联系

400-800-1024

工作日9:30-21:00在线

分享本页
返回顶部