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 Gem
Modulation 是一个 Ruby gem,用于在 Ruby 应用中进行文件级依赖管理。在团队探索它时,它仍处于实验阶段。
Modulation 的工作方式是覆盖 Ruby 默认的代码加载机制。这一点令人担忧,因为这意味着需要替换 Rails 应用中的整个自动加载系统。由于依赖自省是在运行时执行的,代码本身和应用运行时行为都会因此变得更加复杂。
为了进行实验而改变整个单体应用的运行方式,显然会带来风险。如果选择 Modulation 作为解决方案,之后却又不得不改变方向,那么可能需要回滚数百个文件的修改。这对于生产级代码库而言并不现实。
此外,该 gem 的作用粒度是文件级别的。对于团队当时试图解决的问题规模来说,这个粒度过细,并不适合作为大型单体应用的边界治理方案。
直接拆成微服务可行吗?
在大型工程组织中,人们经常会提出这样一个想法:将核心单体应用中的组件提取出来,构建为微服务,从而创建代码边界。
但对于这个大型单体应用而言,试图通过创建更多服务来解耦代码,其实是在用错误方式解决代码设计问题。
将代码分布到多台机器上,是一种拓扑结构变化,而不是架构设计本身的改善。如果试图将核心代码库中的组件提取为独立服务,就会引入网络通信所带来的额外问题,并创建一个分布式系统。
单体应用中设计糟糕的 API,放到服务中依然是设计糟糕的 API,只是现在又额外增加了复杂性。这些复杂性可能包括无状态网络边界、系统间序列化,以及网络通信可靠性等问题。
当某个服务足够独立、职责足够明确,并且值得为网络边界及其复杂性付出代价时,微服务当然是一种很好的解决方案。但在此之前,单纯拆服务并不能自动解决代码设计问题。
该海外技术团队的核心代码库仍然采用模块化单体架构:所有代码被拆分成组件,并共存于同一个代码库中。接下来的目标,是通过建立清晰且严格的边界,将应用的模块化程度提升到一个新的层次。
Packwerk:Rails 应用模块化的边界治理工具
基于探索阶段获得的经验,团队创建了 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 check,Packwerk 都会显示相关违规。

错误信息会展示违规类型、违规位置,并为开发者提供可操作的后续步骤。其目的不是简单地阻止开发,而是帮助开发者理解自己所做的变更,并提醒他们注意任何可能破坏代码边界的修改。
使用 Packwerk 的注意事项
对 Ruby 进行静态分析非常复杂。如果某个常量无法通过自动加载机制识别,Packwerk 会忽略它。这样可以确保 Packwerk 的结果不会出现误报,但也可能产生漏报。
不过,如果工具能够正确识别大多数引用,就已经足以显著提升代码质量。Packwerk 团队正是基于 Ruby 静态分析存在不确定性这一现实,做出了这样的设计取舍。
Packwerk 在大型 Rails 应用中的实际使用情况
在该海外大型技术团队内部,Packwerk 并不是通过强制行政推广落地的。一些团队对这款工具很感兴趣,并在正式发布前自愿参与了 beta 测试。自发布以来,越来越多团队和开发者开始使用 Packwerk 来强化组件边界。
截至原文发布时,Packwerk 已在该团队的多个 Rails 应用中运行,其中包括核心单体应用。核心代码库中包含数十个包,这些包启用了多项边界强制规则。Packwerk 也已集成到相关应用的 CI 流水线中,并提供可在本地运行的命令,用于检查与包相关的各项指标。
自从 Packwerk 在内部投入使用以来,围绕软件架构的新讨论也随之展开。开发者在使用 Packwerk 消除技术债务、重构代码的过程中发现,目前仍然缺乏成熟的代码解耦模式,以及创建单向依赖关系的成熟实践。因此,团队正在研究控制反转,并探索在 Rails 应用中建立依赖反转模式的可能性。
如何在 Rails 应用中开始使用 Packwerk
Packwerk 已经开源,开发者可以将它应用到自己的 Rails 项目中。
要将 Packwerk 安装到 Rails 应用中,只需将其添加为 gem,然后运行:
packwerk init
该命令会生成使用 Packwerk 所需的配置文件。
Packwerk 团队将继续维护这个 gem,也期待看到更多开发者如何使用它。同时,开发者也可以根据贡献指南提交 bug 报告和 pull request。
文章包含AI辅助创作:Packwerk 实践:如何在 Rails 应用中强制执行模块化与代码边界,发布者:cai,转载请注明出处:https://worktile.com/kb/p/3979996
微信扫一扫
支付宝扫一扫