Packwerk 实践:如何在 Rails 应用中强制执行模块化与代码边界 2020 年 9 月 30 日海外某大型电商平台举办了一场关于Packwerk 的技术分享活动。活动视频现已上线感兴趣的读者可以通过视频进一步了解这款开源工具。Packwerk 的核心作用是在 Rails 应用中创建带有强制边界的代码包从而帮助大型代码库实现更清晰的模块化结构。该平台的核心代码库规模庞大、结构复杂并且仍在持续增长。为了更好地理解和维护这样的复杂系统工程团队需要借助软件架构来建立清晰的结构边界。然而Ruby 本身并没有提供太多用于强制边界的机制Ruby on Rails 也只提供了非常基础的分层结构。因此如果缺少可靠的边界约束模式Rails 应用在规模持续扩大后就很容易变得难以维护。相比之下其他语言或框架往往内置了更明确的垂直边界机制例如 Elixir 的伞形项目umbrella projects。随着业务规模不断扩大建立一种新的架构模式变得至关重要。工程团队希望让单体应用中的大型业务域能够通过明确定义的边界进行交互从而提升开发效率也让开发者在庞大代码库中工作时更有掌控感。基于这样的需求团队创建了 Packwerk 这款开源工具用于在大型 Rails 应用中构建包系统并通过包系统引导和强制执行代码边界。Packwerk 是一个静态分析工具用于约束 Ruby 文件组之间的边界。这里的 Ruby 文件组被称为“包”。代码中的高内聚与低耦合模块化的基础理想情况下我们希望使用的是一个“感觉很小”的代码库。让大型代码库变得更容易理解的一种方式就是提高代码的内聚性并降低代码之间的耦合度。内聚性指的是模块或类中各个元素彼此关联的程度。例如功能内聚指的是某个模块中的代码都服务于同一个任务因此被组织在一起。相关代码往往会一起发生变化因此也应当被放在一起。与之相对耦合度指的是模块或类之间相互依赖的程度。彼此独立的元素在实现位置上也应当保持独立。当某个代码领域依赖大量无关领域的代码时就说明这些领域之间缺少有效的边界隔离。边界是代码之间的屏障。例如将代码拆分到不同代码仓库或不同服务中就是一种代码边界。在这种情况下不同代码之间若要协同工作往往需要通过网络调用完成通信。而在本文讨论的场景中代码边界指的是同一代码库中不同关注领域之间的边界。因此我们希望在应用中强制执行两类边界依赖边界和私有边界。依赖边界关注的是一个类或一个包可以依赖哪些常量。理想情况下相关代码的依赖列表应当经过审慎设计并且尽可能保持精简。某个类不应依赖那些并非其真正依赖项的类。私有边界关注的是包内部的私有常量不应被外部直接使用。当某个包之外的代码引用了该包内部的私有常量时就会破坏私有边界。外部代码应当只引用公共常量并通过公共 API 与包进行交互。大型 Rails 应用为什么需要代码边界如果单体应用缺少代码边界开发者就会发现在自己负责的领域内进行修改会越来越困难。很多开发者都经历过类似情况只是做了一个看似简单的改动却意外导致代码库中其他无关部分的测试失败或者为了理解一个超过 2000 行的类或模块不得不在代码库中反复跳转、艰难追踪。如果没有清晰的代码边界代码库最终很容易出现各种反模式例如意大利面条式代码以及承担过多职责、包含过多信息的巨型类。随着低内聚、高耦合的代码库不断增长开发、维护和理解它都会变得越来越困难。最终实现新功能、扩展系统和演进架构都会变得异常艰难。这也会直接影响开发者体验。对于一个大型工程组织而言开发者在代码库中的工作效率和满意度非常重要。代码边界并不只是架构洁癖而是影响工程效率、协作质量和长期可维护性的关键因素。在实际研发管理中像PingCode 这样的智能化研发管理工具也可以把团队目标、需求、开发、测试、发布和 Wiki 知识沉淀连接起来让架构治理不只停留在代码检查层面而是融入完整研发流程。Rails 应用就像一个开放式生活空间我们可以把大型 Rails 应用想象成一栋没有墙的房子。开放式生活空间就像一个没有结构边界的代码库。为了区分不同生活区域的功能你可以通过摆放家具来划分空间。2017 年该海外技术团队推进组件化工作时采用的就是类似思路他们将逻辑相关的代码移动到被称为“组件”的文件夹中。每个组件文件夹代表一个不同的业务领域例如订单、结账等。在这个开放式空间的类比中可以想象一个没有墙的浴室浴室的位置虽然明确但我们仍然希望用墙把它与其他生活空间隔开。组件化是大型单体应用迈向模块化的重要一步但距离真正的模块化代码库仍有很长距离。换句话说组件化只是摆好了家具但还没有真正建起墙。当时跨组件调用依然存在Active Record 模型也仍然在不同业务领域之间共享。组件之间没有真正的“墙”来划定边界只有一种很容易被打破的约定也就是所谓的“社会契约”。Rails 边界强制方案曾经研究过的几种路径团队的目标是找到一种能够强制执行边界的解决方案。就 Ruby 本身而言它并不提供完善的边界强制机制。Ruby 只允许在类级别指定可见性并且会将所有依赖项加载到全局命名空间中。直接依赖和间接依赖之间也没有明确区别。在 Ruby 生态中确实有一些方法可以在一定程度上实现边界约束。团队曾探索过多种方案的组合包括使用private_constant关键字设置私有常量通过 gem 建立边界使用测试阻止跨边界关联试验外部 Ruby gem例如 Modulation。使用private_constant设置私有常量private_constant是 Ruby 内置方法可用于将常量设为私有使其无法在所属命名空间之外被访问。常量的命名空间指的是它所嵌套和定义的模块或类。换句话说private_constant为命名空间级别的常量提供了可见性语义而这正是大型代码库所需要的能力之一我们希望能够为类或类组区分公共常量和私有常量。然而使用private_constant来强制私有边界也存在一些缺点。如果某个常量是在定义之后才被设为私有那么对它的首次引用可能不会被检查。因此这种方法并不完全可靠。此外当通过private_constant声明私有常量时很难判断是否存在边界违规也很难确认某个常量到底是被绕过了还是被正确使用了。更重要的是这种方式只解决了私有性问题并不能解决依赖关系问题。因此仅依靠 Ruby 现有的private_constant机制并不足以在大规模代码库中强制执行边界。工程团队需要一款更灵活、也更容易集成到现有工作流程中的工具。通过 Gem 建立边界创建模块化 Rails 应用的另一种方式是使用 gem。Ruby gem 通常用于在 Rails 应用之间分发和共享 Ruby 库。开发者可以将相关代码放入内部 gem 中从而将特定关注点与主应用分离。此外这些 gem 也可以较为方便地从应用中提取出来。Gem 可以通过 gemspec 提供依赖项列表这正是模块化治理所需要的能力之一。但问题在于工程团队不仅需要声明依赖项列表还希望能够强制执行这份列表。他们主要担心的是gem 缺乏可见性语义。Gem 会以与应用直接依赖相同的方式暴露传递依赖。主应用可以像使用自身依赖一样使用内部 gem 中的任何依赖。这并不利于实现严格的边界控制。理想的方案应当既能将相关代码组织在一起又能只将其中一部分代码作为公共 API 暴露出去。换句话说工程团队希望能够控制并强制执行一组代码的私有边界和依赖边界而 Ruby gem 本身无法完整满足这一需求。使用测试防止跨组件关联团队还曾添加过一个测试用例用来拒绝任何在组件之间引入 Active Record 关联的 PR。跨组件关联正是他们试图避免的一类模式。不过这个方案存在不少不足。首先这个测试没有考虑依赖关系的方向。其次它并不是一个完整的测试。它没有覆盖非关联形式的 Active Record 对象使用场景通常也无法覆盖任何非 Active Record 的内容。这个测试的执行效果不错但缺少一些关键能力。团队真正想要的是一个能够判断依赖方向并且能够覆盖不同类型 Active Record 关联的解决方案。尽管如此他们仍然保留了这个测试用例因为它有助于促使开发者思考并讨论组件之间是否真的需要建立关联。使用 Modulation Ruby GemModulation 是一个 Ruby gem用于在 Ruby 应用中进行文件级依赖管理。在团队探索它时它仍处于实验阶段。Modulation 的工作方式是覆盖 Ruby 默认的代码加载机制。这一点令人担忧因为这意味着需要替换 Rails 应用中的整个自动加载系统。由于依赖自省是在运行时执行的代码本身和应用运行时行为都会因此变得更加复杂。为了进行实验而改变整个单体应用的运行方式显然会带来风险。如果选择 Modulation 作为解决方案之后却又不得不改变方向那么可能需要回滚数百个文件的修改。这对于生产级代码库而言并不现实。此外该 gem 的作用粒度是文件级别的。对于团队当时试图解决的问题规模来说这个粒度过细并不适合作为大型单体应用的边界治理方案。直接拆成微服务可行吗在大型工程组织中人们经常会提出这样一个想法将核心单体应用中的组件提取出来构建为微服务从而创建代码边界。但对于这个大型单体应用而言试图通过创建更多服务来解耦代码其实是在用错误方式解决代码设计问题。将代码分布到多台机器上是一种拓扑结构变化而不是架构设计本身的改善。如果试图将核心代码库中的组件提取为独立服务就会引入网络通信所带来的额外问题并创建一个分布式系统。单体应用中设计糟糕的 API放到服务中依然是设计糟糕的 API只是现在又额外增加了复杂性。这些复杂性可能包括无状态网络边界、系统间序列化以及网络通信可靠性等问题。当某个服务足够独立、职责足够明确并且值得为网络边界及其复杂性付出代价时微服务当然是一种很好的解决方案。但在此之前单纯拆服务并不能自动解决代码设计问题。该海外技术团队的核心代码库仍然采用模块化单体架构所有代码被拆分成组件并共存于同一个代码库中。接下来的目标是通过建立清晰且严格的边界将应用的模块化程度提升到一个新的层次。PackwerkRails 应用模块化的边界治理工具基于探索阶段获得的经验团队创建了 Packwerk。Packwerk 会强制检查两类违规依赖违规和私有性违规。依赖违规指的是某个包引用了另一个包中的常量但没有将该包声明为依赖项。私有性违规指的是某个包之外的代码引用了该包内部的私有常量。不过位于app/public公共目录中的常量可以被访问因此不会构成违规。Packwerk 的工作原理Packwerk 借助名为ConstantResolver的开源 Ruby gem对应用中的常量进行静态解析。ConstantResolver使用与 Rails 代码加载器 Zeitwerk 相同的假设来推断常量所在的文件位置。例如常量Some::Nested::Model会被解析为文件路径path/models/some/nested/model.rb中定义的常量。随后Packwerk 会使用该文件路径判断这个常量属于哪个包。接下来Packwerk 会根据解析出的常量检查相关包的配置。如果依赖检查和私有性检查都已启用那么从包 A 到包 B 的引用只有在以下两个条件同时满足时才是有效的包 A 声明了对包 B 的依赖被引用的常量是包 B 中的公共常量。使用 Packwerk 前确保 Rails 应用处于有效状态在深入使用 Packwerk 之前必须先确保应用处于有效状态。只有这样Packwerk 才能正常工作。要被视为有效应用必须拥有有效的自动加载路径缓存、包定义文件以及正确的应用目录结构。Packwerk 提供了packwerk validate命令。该命令通常会在持续集成CI流水线中运行以确保应用始终处于有效状态。Packwerk 还会检查应用中是否存在循环依赖。根据无环依赖原则组件依赖图中不应存在任何环。如果包之间存在循环依赖那么对其中一个包的修改就可能产生多米诺骨牌效应迫使循环中的所有包都发生变化。这样的依赖环会非常难以管理。举例来说想象一下你和另外 100 位开发者正在同时修改代码库中的某个领域。如果代码库存在循环依赖那么你的修改就可能影响依赖你所在组件的其他组件。当你完成工作并希望将自己的修改与其他开发者的修改一起合并到主分支时代码集成会变得极其复杂因为每次应用迭代都可能牵动所有相关依赖。拥有无环依赖图的应用可以被独立测试和发布而不需要在每次变更时同时修改整个应用。在 Rails 应用中创建包包由位于包目录根路径下的package.yml文件定义。该文件用于设置具体配置。Packwerk 允许包声明自己希望遵循哪种类型的边界强制规则。此外package.yml还可以指定其他有用的包级元数据例如负责团队和联系信息。实践中细粒度的包级所有权往往比笼统的领域所有权更有利于跨团队协作。因为每个包都有清晰的负责人和沟通渠道相关变更也更容易被正确评审和维护。如果团队还需要在包负责人、评审人和跨团队协作事项之间建立更轻量的协作机制也可以借助Worktile 这样的通用项目协作系统将任务、文档、日历和审批等协作信息集中管理。使用 Packwerk 强制执行包之间的边界Packwerk 通过检查命令来强制执行包之间的边界。该检查既可以在本地运行也可以在 CI 流水线中运行。要执行检查只需运行packwerk check该海外技术团队也将这个命令加入了 CI 流水线以防止任何新的违规代码被合并到代码库主分支中。在现有 Rails 代码库中强制执行边界由于 Rails 应用通常缺乏严格的代码结构遗留的大型 Rails 应用往往已经存在包间依赖和私有性泄露问题。在这种情况下首要目标不是一次性解决所有历史问题而是先止损防止新的违规继续被加入代码库。即使应用中已经存在违规用户仍然可以启用边界强制能力从而确保违规列表不会继续增长。这是通过为包生成“弃用引用列表”实现的。工程团队希望开发者能够继续正常工作同时阻止进一步的违规。弃用引用列表可以帮助代码库逐步过渡到更清晰的架构。随着开发者逐步减少列表中的引用数量它会以迭代方式为现有代码建立边界。components/online_store 的已弃用引用列表弃用引用列表包含有关包中违规行为的有用信息。例如它可以指出哪些文件存在私有性违规以及这些文件引用了某个包中定义的哪些常量。通过找出包边界被突破的确切引用团队实际上得到了一份可以逐项处理的待办事项清单。从传统意义上说弃用引用列表的目标是帮助开发者立即开始强制执行应用边界。即使代码中已经存在违规也可以借助这份列表逐步消除技术债务。不过在实践中也有团队利用这份列表将某个业务领域从主应用中提取出来使其成为一个独立服务。如果某个包未来要被提取为 gem这份列表同样可以发挥作用。归根结底弃用引用列表应当用于重构代码并逐步减少列表中的违规行为。如果团队只是把违规简单添加到列表中Packwerk 的意义就无法真正实现。当然在少数情况下也可以对这条规则做出例外。当团队因为暂时找不到合适模式而无法按正确方向添加依赖时可以先将该违规加入弃用引用列表。这样可以确保当合适模式出现后团队最终会重构代码并将该违规从列表中移除。这比沿着错误方向创建依赖要好得多。防止新的代码边界违规在应用中创建包并强制执行包边界之后Packwerk 就可以开始发挥作用了。无论是在本地还是在 CI 流水线中运行packwerk checkPackwerk 都会显示相关违规。错误信息会展示违规类型、违规位置并为开发者提供可操作的后续步骤。其目的不是简单地阻止开发而是帮助开发者理解自己所做的变更并提醒他们注意任何可能破坏代码边界的修改。使用 Packwerk 的注意事项对 Ruby 进行静态分析非常复杂。如果某个常量无法通过自动加载机制识别Packwerk 会忽略它。这样可以确保 Packwerk 的结果不会出现误报但也可能产生漏报。不过如果工具能够正确识别大多数引用就已经足以显著提升代码质量。Packwerk 团队正是基于 Ruby 静态分析存在不确定性这一现实做出了这样的设计取舍。Packwerk 在大型 Rails 应用中的实际使用情况在该海外大型技术团队内部Packwerk 并不是通过强制行政推广落地的。一些团队对这款工具很感兴趣并在正式发布前自愿参与了 beta 测试。自发布以来越来越多团队和开发者开始使用 Packwerk 来强化组件边界。截至原文发布时Packwerk 已在该团队的多个 Rails 应用中运行其中包括核心单体应用。核心代码库中包含数十个包这些包启用了多项边界强制规则。Packwerk 也已集成到相关应用的 CI 流水线中并提供可在本地运行的命令用于检查与包相关的各项指标。自从 Packwerk 在内部投入使用以来围绕软件架构的新讨论也随之展开。开发者在使用 Packwerk 消除技术债务、重构代码的过程中发现目前仍然缺乏成熟的代码解耦模式以及创建单向依赖关系的成熟实践。因此团队正在研究控制反转并探索在 Rails 应用中建立依赖反转模式的可能性。如何在 Rails 应用中开始使用 PackwerkPackwerk 已经开源开发者可以将它应用到自己的 Rails 项目中。要将 Packwerk 安装到 Rails 应用中只需将其添加为 gem然后运行packwerk init该命令会生成使用 Packwerk 所需的配置文件。Packwerk 团队将继续维护这个 gem也期待看到更多开发者如何使用它。同时开发者也可以根据贡献指南提交 bug 报告和 pull request。