
文以某海外电商平台的架构演进实践为例介绍大型 Ruby on Rails 代码库如何从传统单体架构逐步演进为模块化单体架构并通过代码组织、依赖隔离和边界治理降低系统复杂度提升开发者效率。某海外电商平台拥有当前规模最大的 Ruby on Rails 代码库之一。这个代码库由上千名开发者在十多年间持续维护和演进承载了大量业务功能包括商户计费、第三方开发者应用管理、商品更新、物流履约等。点击添加图片描述最多60个字 编辑最初该平台采用的是单体架构。这意味着所有不同业务能力都构建在同一个代码库中彼此之间几乎没有明确边界。多年来这种架构一直运行良好但随着业务和团队规模不断扩大单体架构带来的问题逐渐超过了它的优势。团队必须做出新的架构选择。近年来微服务架构迅速流行并一度被视为解决单体架构所有问题的“终极方案”。但实践经验表明软件架构并不存在一种放之四海而皆准的最佳答案。微服务能够解决一部分问题也会带来新的复杂性。最终该团队选择将核心系统演进为模块化单体架构代码仍然保留在同一个代码库中但不同组件之间需要定义并遵守清晰的边界。每种软件架构都有自身的优缺点不同方案也适用于应用发展的不同阶段。对该团队而言从单体架构过渡到模块化单体架构是当时最自然、也最合理的下一步。什么是单体架构单体架构指的是一个软件系统中功能上可以区分的不同部分彼此交织在一起而不是由架构上相互独立的组件构成。对于某海外电商平台而言这意味着处理运费计算的代码与处理结账流程的代码共存在同一个系统中并且几乎没有机制阻止它们相互调用。随着时间推移处理不同业务流程的代码之间形成了高度耦合。单体架构的优势单体架构是最容易实现的架构之一。如果没有强制性的架构约束一个应用最终很可能自然演变成单体应用。这一点在 Ruby on Rails 中尤其明显因为 Rails 应用中的代码在应用层面通常是全局可访问的因此非常适合构建单体应用。单体架构可以让应用在早期快速发展。它易于构建也便于团队快速推进功能从而尽早将产品交付给用户。将所有代码维护在一个地方并将应用部署到同一个位置也带来了许多明显优势。首先团队只需要维护一个代码仓库并且可以在同一个目录中搜索和定位所有功能。其次团队只需要维护一套测试和部署流水线。根据应用复杂度不同这可以节省大量成本。构建、定制和维护多套流水线往往需要投入大量精力尤其是当团队还需要确保所有流水线行为一致时。由于所有代码都部署在同一个应用中所有数据也可以存放在一个共享数据库中。每当需要数据时系统只需要执行一次普通的数据库查询即可获取。单体架构还意味着团队只需要管理一套基础设施。大多数 Ruby 应用通常包含数据库、Web 服务器、后台任务处理能力以及 Redis、Kafka、Elasticsearch 等其他基础设施组件。每增加一套基础设施团队就需要花费更多时间进行运维而不是专注于产品和功能开发。额外的基础设施也会增加潜在故障点从而影响应用的稳定性和安全性。相比多个独立服务单体架构还有一个重要优势不同组件之间可以直接调用而不需要通过 Web 服务 API 通信。这意味着团队不必处理 API 版本管理和向后兼容性问题也无需承担网络调用带来的延迟。单体架构的缺点复杂度、耦合与开发效率问题然而当应用规模或开发团队规模增长到一定程度后单体架构最终会承受巨大压力。到 2016 年该平台在构建和测试新功能时遇到的挑战越来越明显。最突出的问题主要有两个。首先应用变得极其脆弱新代码修改很容易带来意想不到的后果。一个看似无关紧要的改动可能会引发一连串表面上毫不相关的测试失败。例如如果计算运费的代码调用了计算税率的代码那么修改税率计算方式就可能影响运费计算结果而问题原因并不容易被发现。这种问题的根源在于高度耦合和边界缺失。它不仅让代码更难理解也让测试更难编写并且导致测试在持续集成环境中的运行速度越来越慢。其次即便是看似简单的改动也需要开发者掌握大量上下文信息。新员工加入后往往需要理解庞大的代码库才能真正开始贡献代码。比如一名新加入物流团队的开发者理论上只需要理解物流业务逻辑即可开始工作。但在现实中由于各个业务环节紧密交织他还需要理解订单如何创建、支付如何处理以及许多其他相关流程。对于一名开发者来说仅仅为了发布第一个功能就必须掌握如此庞大的知识体系这显然并不现实。复杂的单体应用会显著拉高学习曲线最终影响开发者效率。该团队遇到的这些问题本质上都源于不同功能之间缺乏清晰边界。显然团队需要降低不同业务领域之间的耦合度。真正的问题在于应该如何做到这一点微服务架构是否一定适合大型系统微服务是近年来行业中非常流行的一种解决方案。微服务架构将大型应用拆分为一组较小的独立服务并让这些服务能够独立部署。微服务确实可以解决单体架构中的一部分问题但也会引入一系列新的复杂性。采用微服务后团队需要维护多条测试和部署流水线也需要承担每个服务对应的基础设施成本。与此同时系统并不总能随时直接获取所需数据。由于每个服务都是独立部署的服务之间的通信需要跨越网络每一次调用都会增加延迟并降低整体可靠性。此外跨多个服务的大规模重构也会变得非常繁琐。团队不仅需要修改所有相关服务还需要协调它们的发布节奏和部署顺序。模块化单体架构兼顾边界与交付效率该团队希望找到一种方案在不增加部署单元数量的前提下提高系统的模块化程度。换句话说他们希望同时获得单体架构和微服务架构的部分优势同时尽量避免二者的主要缺点。模块化单体架构正是这样一种方案。它指的是所有代码仍然服务于同一个应用但不同业务领域之间存在严格、清晰的边界。模块化单体的落地组件化当团队发现原有单体架构已经难以支撑需求并开始影响开发者效率和满意度时他们向所有参与核心系统开发的工程师发放了一份调查问卷以识别开发过程中最突出的痛点。团队已经意识到问题存在但希望在制定解决方案时能够以数据为依据确保方案真正解决主要矛盾而不是只回应少数零散反馈。对于很多正在进行大型系统治理的企业来说这类工作不仅是代码重构问题也涉及目标拆解、需求梳理、开发排期、测试发布和知识沉淀等研发管理流程。此时借助智能化研发管理工具将目标、需求、开发、测试、发布和 Wiki 知识沉淀串联起来可以让架构治理过程中的数据和经验更容易沉淀、流转和复用。调查结果促使团队决定对代码库进行拆分。2017 年初他们组建了一支精干团队来负责这项工作。项目最初被称为“将核心系统拆分成多个部分”后来逐渐演变为“组件化”。代码组织按业务领域重构大型代码库团队首先要解决的是代码组织问题。当时代码组织方式与典型 Rails 应用类似按照软件概念划分例如模型、视图和控制器。新的目标是按照真实业务场景重新组织代码例如订单、发货、库存和计费。这样做可以让开发者更容易找到代码也更容易找到理解相关代码的人同时让每个组件本身更容易被理解。每个组件都会被构建成一个独立的迷你 Rails 应用最终目标是将它们命名空间化为 Ruby 模块。团队希望新的组织方式能够暴露出那些原本隐藏在代码中的不必要耦合。制定初始组件列表需要大量调研也需要征求公司内不同业务领域相关方的意见。团队通过一个庞大的电子表格列出了所有 Ruby 类总数约为 6000 个并手动标记每个类所属的组件。尽管这一过程没有修改业务逻辑但它涉及整个代码库。如果处理不当风险仍然很高。团队通过自动化脚本构建了一个大型 PR 来完成这项工作。由于引入的变更主要是文件移动因此最可能出现的问题是代码无法找到对象定义从而导致运行时错误。得益于充分的测试覆盖团队通过在本地和持续集成环境中运行测试、确保没有失败并在本地和预发布环境中尽可能充分地验证功能来降低遗漏风险。团队选择在一个 PR 中完成所有操作以尽可能减少对所有开发者的影响。这次变更也带来了一个副作用由于部分文件移动在代码托管平台中被错误地识别为删除和新增而不是重命名团队丢失了大量可直接查看的 Git 历史记录。虽然仍然可以通过git --follow追踪文件移动历史但代码托管平台本身无法正确识别这类移动操作。依赖隔离治理模块化单体中的边界问题下一步是隔离依赖关系也就是将不同业务领域彼此解耦。每个组件都需要定义清晰的专用接口通过公共 API 明确划分领域边界并独占自身关联的数据。由于这项工作需要各个业务领域专家深入参与负责组件化的团队无法独自完成整个代码库的改造。但他们定义了统一模式并提供了完成这项工作的工具。在实际推进过程中组件边界治理往往需要架构团队、业务团队、测试团队和项目管理角色持续协同。类似 Worktile 这样的通用项目协作系统可以承载任务分解、项目进度、文档协作、日历排期、审批和工时等信息让跨团队的架构改造不只停留在技术方案中而是被落实到可跟踪、可协同、可复盘的执行过程里。团队内部开发了一套依赖治理工具用于跟踪每个组件实现隔离目标的进度。这个工具会突出显示任何违反领域边界的行为例如绕过公开 API 直接访问其他组件以及跨边界的数据耦合。为实现这一点团队编写了一个工具在持续集成过程中挂接 Ruby 跟踪点以获取完整调用图。随后系统会按照组件对调用方和被调用方进行分类只筛选跨组件边界的调用并将这些数据发送到依赖治理工具中。除了调用关系之外系统还会发送部分代码分析数据例如 ActiveRecord 关联关系和继承关系。依赖治理工具随后会判断哪些跨组件行为是允许的哪些属于违规行为。通常而言跨组件关联始终违反组件化原则只有明确公开的接口才能被调用继承关系也需要受到类似约束但相关机制仍在逐步完善。该工具会计算每个组件的总体评分并列出各个组件中的违规情况。下一步团队计划绘制评分随时间变化的趋势图并展示有意义的差异让开发者能够清楚看到评分变化的原因和发生时间。边界维护让架构治理成为持续过程从长远来看团队希望进一步通过程序化方式强制执行这些边界。已有工程实践表明应用团队可以通过运行时检查等方式实现边界约束。虽然最终方案仍在探索中但总体思路是让每个组件只加载它明确声明依赖的其他组件。如果某个组件试图访问未声明依赖的组件代码系统就会触发运行时错误。此外当某个组件通过公共 API 以外的方式被访问时也可以触发运行时错误或直接导致测试失败。团队还希望通过消除偶然依赖和循环依赖进一步理清领域之间的依赖关系图。实现完全隔离是一项长期工作但所有开发者都在持续投入并且团队已经开始看到预期收益。例如平台此前使用的一套旧版税务引擎已经无法满足商户需求。在这些架构治理工作开展之前用新系统替换旧税务引擎几乎是不可能完成的任务。但由于团队投入了大量精力隔离依赖关系他们最终能够用全新的税务计算系统替换旧引擎。总结软件架构应当随系统规模演进总的来说在系统开发早期没有复杂架构往往就是最合适的架构。这并不是说团队不需要遵循良好的软件工程实践而是说不应该花费数周甚至数月时间去设计一个自己尚未真正理解的复杂系统。某知名软件架构专家提出的“设计耐力假说”很好地阐释了这一观点在大多数应用的早期阶段即便设计投入不多团队也可以快速推进。为了尽早将产品推向市场适度牺牲一部分设计质量是可以接受的。等到添加功能的速度开始放缓时再真正重视优秀设计往往更加合理。重构和重新架构的最佳时机通常应该尽可能晚一些到来。因为在构建系统的过程中团队会不断加深对业务领域和系统本身的理解。在缺乏领域知识的情况下一开始就设计复杂的微服务系统是一件风险很高的事情。许多软件项目都会犯这样的错误。正如这位专家所说他听说过的几乎所有从零开始构建的微服务系统最终都遇到了严重问题。即使你确信自己的应用规模足够大、值得采用微服务也不应该在新项目一开始就直接使用微服务。优秀的软件架构不是一次性设计出来的而是在系统发展过程中不断演进出来的。适合某个应用的架构方案完全取决于它当前的规模和复杂度。随着应用复杂度不断提升单体架构、模块化单体架构和面向服务架构会沿着一条演进路径逐步展开。每种架构都适用于不同规模的团队和应用也都会经历相应的阵痛期和磨合期。当团队开始遇到本文中提到的各种痛点时往往就意味着当前架构已经无法满足需求是时候迁移到下一种更适合的方案了