系列导航

导读 · 上一篇:20:require、load 与 autoload · 下一篇:22:gemspec 与可安装的交付边界 · 完整源码包

安装过一个版本,不代表进程使用它

同一台机器可以保存同一个 gem 的多个版本。文件存在于磁盘,当前进程允许选择哪些版本,以及 require 最后执行哪个入口,是三个不同状态。排查依赖问题时,如果只提供 gem list,只能证明可见安装集合,不能证明故障进程的选择结果。

Taskbook 的版本实验直接读取当前进程已经激活的 specification:

1
2
3
4
5
6
7
8
9
10
11
12
13
require 'bundler/setup'
require 'json'
require 'rack'

raise unless Gem.loaded_specs.fetch('json').version.to_s == '2.13.2'
raise unless Gem.loaded_specs.fetch('rack').version.to_s == '3.2.1'

begin
gem 'json', '= 0.0.0'
rescue Gem::LoadError
else
raise 'conflicting activation unexpectedly succeeded'
end

最后一个请求故意与已激活版本冲突。它验证同一进程不能随意把已经加载的库替换成另一个版本。异常被测试显式捕获,普通验证入口仍应成功结束。完整实验在 examples/ruby/labs/21/run.rb,前置是第 20 篇的加载路径。

依赖文件分别表达什么

累计工程的 Gemfile 以公共 RubyGems 源为来源,并通过 gemspec 引入库的运行依赖。测试与类型工具写在这个工作区的 Gemfile 中:

1
2
3
4
5
6
7
source 'https://rubygems.org'
gemspec

gem 'minitest', '5.25.5'
gem 'debug', '1.11.0'
gem 'rbs', '3.9.5'
gem 'steep', '1.10.0'

这里固定的工具版本属于本系列可重跑实验的环境。它们不应因为作者在开发阶段使用,就全部变成最终 gem 使用者的运行依赖。消费者只调用任务筛选时,没有理由被迫安装调试器与静态分析器。

gemspec 中的依赖则随包的公开功能一起交付。本例的 JSON/CSV 导入需要 json 与 csv,HTTP 示例需要 net-http 与 rack;版本由工程明确固定。生产库常用经过兼容性验证的版本范围以便与其他库共同解算,本例精确固定的目的在于缩小教学实验变量,不能由此得出“所有 gem 都应该只允许一个补丁版本”。

Gemfile.lock 记录解算后的依赖图,包括间接依赖、平台与 Bundler 版本等。Gemfile 是约束输入,锁文件是本次解算结果。只提交前者,未来同样的约束可能选中不同版本;只提交后者却改变约束,运行时又可能发现二者不一致。Gemfile 手册 描述了依赖声明形式。

bundle exec 影响当前命令的依赖环境

bundle exec ruby ... 让命令在工程的 bundle 环境下运行。关键不是多敲两个单词,而是把“可见版本集合”从个人全局环境约束到当前依赖图。它不会把某个 gem 的所有文件提前执行,真正使用库时仍要 require 对应入口。

没有 bundle 环境的 Ruby 命令可能仍然成功,因为系统或用户 gem 目录刚好有可用版本。这种成功最容易隐藏依赖遗漏:作者电脑正常,干净环境报 LoadError。把工具全部装进全局目录虽然方便,却让每个工程都能碰巧借用其他工程安装的组件。

相反,bundle 环境中的找不到依赖通常是有价值的反馈。它指出当前工程没有声明这个能力,或者依赖没有按锁文件安装。正确修复是核对声明与安装步骤,不能先删掉锁文件、关掉 bundle 限制,再把偶然跑通当成解决。bundle exec 手册 说明命令环境的调整。

bundle 命令本身也属于版本记录。不同 Bundler 版本在锁文件格式和平台解算上可能有变化,因此系列记录实际 bundle --version,而不是根据安装教程猜测。Ruby 版本与 Bundler 版本互不替代:换了解释器后,可能需要重新确认与该解释器关联的 gem 路径。

隔离路径应该怎样检查

本系列把安装目录放在工程的 .bundle 下,目录内容不作为源代码提交。读者可在 examples/ruby 中建立本地配置并安装锁定依赖:

1
2
3
bundle config set --local path .bundle/vendor
bundle install
bundle exec ruby labs/21/run.rb

运行输出给出 Ruby、Bundler、json、rack 和平台信息。需要继续诊断时,可以在同一执行环境打印:

1
2
3
puts Gem.loaded_specs.fetch('json').full_gem_path
puts Gem.loaded_specs.fetch('rack').full_gem_path
puts Gem::Platform.local

版本号相同仍可能存在不同来源路径,因此证据最好同时包含版本与实际路径。某些工具会启动子进程;子进程继承哪些环境变量也会改变依赖选择。打包实验既要清除指向源码 bundle 的关键环境影响,又要清楚声明依赖究竟从哪里获得。

隔离安装不等于每次都从网络重装。已经存在的锁定缓存可以加快重跑,但缓存命中与版本选择是两件事。缓存目录里没有需要的原生平台包时,安装器可能编译扩展;同一个源版本由不同编译器、Ruby 头文件或系统库构建,也可能具有不同运行条件。

锁文件没有冻结整个计算机

一个完整复现实验至少包含解释器补丁版本、操作系统架构、依赖图和运行命令。本系列选择 CRuby 3.4.11,其他实现或更早 Ruby 不在这次验证范围。锁文件写有当前平台,并不代表所有平台都已经验证。

原生扩展尤其需要区分源码版本和构建结果。debug、rbs 等组件可能涉及编译;编译完成只证明扩展能够在当前环境加载或构建,仍需要执行相应功能测试。某个平台预编译包的可用性也不等于另一个平台的可用性。

网络、系统证书、时区、区域设置以及外部动态库都可能影响应用。Taskbook 的实验尽量使用本地合成数据,HTTP 也限定在回环地址,正是为了减少这些变量。对于实际依赖外部服务的程序,应额外记录服务协议和响应样本,不能要求 Gemfile.lock 承担它不描述的部分。

锁定也不意味着永不升级。固定版本让当前结果有可追溯基线;升级时应变更锁文件、运行行为与包装验证,并记录差异。把版本长期冻结却不维护,和可以重现实验没有逻辑等价关系。本篇没有对整套依赖做全面安全审计,也不把版本固定称为安全保证。

冲突报告如何缩小问题

依赖冲突可能出现在解算阶段,也可能出现在运行激活阶段。前者是依赖约束没有共同解,后者往往涉及进程已经加载某个版本,又收到不兼容要求。两种报错都包含版本名称,却需要不同处理。

实验的 gem 'json', '= 0.0.0' 属于刻意制造的激活冲突,不需要远端真的存在这个版本。它检查的是“当前已激活的 json 不能满足这个要求”。真实问题应检查是谁首先激活了库,以及启动命令是否已经进入 bundle 环境,不能盲目安装更多版本。

如果冲突来自两个库的约束,增加一个顶层版本声明不一定能解决,甚至会进一步缩小可行解。应读取冲突链,找出真正不兼容的边,再根据相应库的兼容性说明决定升级哪个组件。本系列用精确固定的小型依赖图,避免在语言入门章节扩成依赖升级教程。

默认 gem 与捆绑 gem 也进入依赖讨论

随 Ruby 安装出现的库并不都属于核心语言。某些库以默认或捆绑 gem 形式分发,版本可以独立变化,未来 Ruby 发行版也可能调整它们的分发方式。看到一台机器能直接 require csv,不能证明所有目标环境都能在未声明依赖的情况下加载。

本系列在真正使用 JSON、CSV、Net::HTTP 时记录并固定对应版本,正是为了避免这种环境偶然性。BigDecimal 用于数字章节,属于教学开发依赖;它没有被 Taskbook 的公开业务 API 需要,因此不塞入运行依赖。这种选择根据实际加载路径而来,既避免遗漏,也避免把整套教程工具强加给包消费者。

依赖安装失败还可能来自网络、证书或扩展编译。解算成功之后下载失败,不代表版本约束有冲突;下载完成后编译失败,也不该通过改成一个任意旧版本来掩盖缺少头文件的问题。应该保留安装日志中首次失败的阶段,再对该阶段修复。只有真正的兼容问题才需要改变依赖约束。

重跑时应先确认命令使用的 ruby、gem 和 bundle 属于同一工具链。在 PATH 中只替换 ruby 而保留另一个解释器生成的 bundle 包装脚本,可能得到难以解释的混合环境。实际输出中的解释器版本、Gem 路径与 Bundler 版本,比目录里的配置文件更能证明当前命令使用了什么。

锁文件差异也应该像代码差异一样阅读。直接依赖没有变化,间接依赖仍可能因重新解算而升级;平台项改变可能带来不同二进制包。一次本来只改文章的提交不应顺手更新整个依赖图,除非升级确实是本次实验所需并已重新验证。

依赖记录还应说明哪些组件没有进入实验。没有加载的可选 gem 不需要为了目录完整而提前安装;只有真正调用时才冻结并验证。这样版本清单能够对应运行行为,避免把安装成功的长列表误读为每项功能都已通过测试。

运行与练习

1
2
cd examples/ruby
bundle exec ruby labs/21/run.rb

成功行以 PASS 21 开头,随后报告平台。原始记录位于 evidence/21/run.txt。它证明这次进程激活了指定版本并拒绝冲突请求,不证明任意机器上都已经安装完成。

练习一:在受控临时环境里不使用 bundle 启动同一只读版本查询,比较激活路径;若环境中只有一个版本,也明确记录“没有构成多版本差异”。练习二:复制 Gemfile 与锁文件到临时目录,把一个约束改成不兼容值,观察安装或冻结检查如何失败,再恢复原约束验证通过。不要用删除锁文件掩盖冲突。