支持范围要由运行结果支撑

先修:21–25、39。核心问题:怎样把 gem 的兼容声明转成可重复检查的证据。实验入口:examples/ruby/labs/E05/run.rb。验收:两个真实 Ruby 实现分别打包、临时安装、运行公开 API 与命令行,再验证本地依赖夹具升级前后的行为差异。版本边界:本机 CRuby 3.4.11 与 JRuby 10.0.5.0,宿主均为 macOS arm64;JRuby 使用 OpenJDK 21.0.10。

gemspec 里的 required_ruby_version 只是安装约束。写上大于等于某个版本,并不会自动证明库在这段范围内都能正确运行。兼容证据必须回答更具体的问题:哪个解释器实际启动过,加载的是源码目录还是安装后的包,公开方法是否保持约定,依赖升级改变了什么。

这个实验使用系列主工程 Taskbook,建立双实现、同一宿主平台的小矩阵。另有一个人工编写的本地依赖 gem,旧版本提供弃用方法,新版本删除该方法,用于演示升级前后如何保存正反差异。这个夹具不对应任何上游库的真实破坏性变更,也不向 RubyGems 发布。

矩阵的一行代表什么

矩阵每一行由真实解释器路径启动子进程,读取 RUBY_VERSION、RUBY_DESCRIPTION 与 RUBY_PLATFORM,再执行打包和回归。脚本要求存在两个可执行文件,也要求它们运行后报告不同的 RUBY_ENGINE。两个文件名不同但内部指向同一解释器,不能算两个实现的测试。

CRuby 基线报告三点四点十一,JRuby 十点零点五点零报告 Ruby 兼容版本三点四点五。JRuby 发布说明声明该分支以 Ruby 三点四为兼容目标,但包回归仍然需要实际运行。另一份 CRuby 3.4.10 源码虽然通过官方摘要核验,本机构建却在启动辅助解释器时被终止,退出码一百三十七;它明确记为未运行,不属于通过单元。

这次矩阵的两个单元都在同一台机器、同一种架构上执行。因此它覆盖 CRuby 与 JVM 实现的当前组合,不覆盖操作系统或处理器差异。Linux、Windows、x86_64 与 TruffleRuby 明确列为未测试。复制同一份 macOS 输出,再把标题换成其他平台,不能增加支持范围。

gemspec 当前允许 Ruby 三点四系列、拒绝三点五及以上;这个声明比已验证的两个运行时组合更宽。文章保留声明与证据之间的差别,不把版本范围当成逐版本测试记录。若真实库承诺更大的支持面,应建立相应 CI 单元,或缩小公开支持说明。安装约束的语法依据见 RubyGems specification reference。

打包后再测试,才能发现交付缺失

直接在仓库运行测试,会自然看到工作区里的 lib、测试辅助文件与开发依赖。用户安装的是 gem 归档,能够看到的文件集合不同。漏掉某个 require 的目标文件,可能只在安装后才失败;误把实验目录与本地依赖打进包,则会造成不必要的体积和交付内容泄漏。

每个矩阵单元都先执行 gem build,检查包文件列表包含 CLI,并拒绝 labs 或 .bundle 路径。随后安装到新建的临时 GEM_HOME。API 子进程与 CLI 子进程的工作目录位于临时目录,避免当前目录恰好提供源码文件。API 还打印 RubyGems 实际激活的 gem 路径,父进程要求它落在这次临时安装目录内。

1
2
3
4
5
6
require 'taskbook/io'
require 'stringio'
tasks = Taskbook.sample_tasks
raise unless Taskbook.summary(tasks)[:total] == 3
raise unless Taskbook::IO.read(StringIO.new(Taskbook::IO.dump(tasks))) == tasks
puts Gem.loaded_specs.fetch('taskbook-workshop').full_gem_path

回归同时覆盖合法数据往返和非法筛选选项。合法 JSON 往返证明序列化适配器确实被安装并能加载;非法优先级零必须产生 ArgumentError,避免升级后悄悄放宽校验。CLI 则通过安装后的 bin/taskbook 运行 stats,从标准输入读入空数组,并断言输出 JSON 的 total 为零。

这些检查仍然有限:没有遍历 Taskbook 所有函数和所有边界,也没有创建全新机器。CRuby 复用主工程已冻结的依赖目录,JRuby 单独安装 Java 平台依赖。安装 Taskbook 本身使用忽略依赖选项,因此这里证明的是“这组已装依赖下,打包后的程序能运行”,不证明离线空环境能够解决并安装全部依赖。

隔离环境时不能只改 GEM_HOME

在 bundle exec 启动的父进程里,Bundler 通过环境变量和启动选项影响子进程。仅设置新 GEM_HOME,不一定能摆脱原项目的依赖上下文。实验构造子进程环境时删除所有以 BUNDLE 开头的变量,同时清除 RUBYOPT 与 RUBYLIB,再显式设置安装目录和只读依赖路径:

1
2
3
4
5
6
clean = ENV.keys.grep(/\ABUNDLE/).to_h { |key| [key, nil] }
clean.merge!('RUBYOPT' => nil, 'RUBYLIB' => nil)
env = clean.merge(
'GEM_HOME' => isolated,
'GEM_PATH' => [isolated, deps].join(File::PATH_SEPARATOR)
)

Open3 接收环境 Hash 时,值为 nil 表示从子进程环境删除该变量。这里不清空整个环境,避免顺带丢失系统执行所需条件;但也不依赖继承环境碰巧正确。解释器以绝对路径指定,因此 PATH 中的系统 Ruby 不会取代矩阵指定版本。

依赖目录可能包含本地扩展,因此 JRuby 没有复用 CRuby 的安装目录。独立目录安装 json 2.13.2-java、csv 3.3.5、net-http 0.6.0 和 rack 3.2.1;其中 JSON 的 Java 平台包与 C 扩展包是不同构件。两条运行路径均通过,只能支持本次依赖组合。若要测试其他操作系统,应在对应平台安装依赖并运行同样的包回归。

用人工依赖演示弃用到删除

夹具 gem 名为 taskbook-policy-fixture。版本一提供 total 和 count,后者写出明确弃用警告;版本二只保留 total。两个版本源码都在实验目录内,可检查、可重新打包,不需要依赖外部服务:

1
2
3
4
5
6
7
8
9
10
module TaskbookPolicyFixture
def self.total(tasks)
tasks.length
end

def self.count(tasks)
warn 'DEPRECATED fixture count; use total before upgrading to 2.0'
total(tasks)
end
end

每个版本都真的执行 gem build 和本地 gem install。随后在独立 Ruby 进程中显式激活精确版本再 require,防止同名常量或已加载特性在版本之间残留。把两个版本连续 require 到同一进程,无法模拟正常的依赖切换,因为 Ruby 的加载缓存和已定义对象不会按包版本自动重置。

推荐 API 的测试在两个版本都要求返回二,且标准错误为空。旧 API 在版本一要求返回二、退出零,同时标准错误包含弃用说明;版本二则要求非零退出并出现 NoMethodError。父进程将这次预期失败记录为升级差异,而不是忽略失败状态。若版本二意外继续支持旧方法,实验同样失败,因为它没有呈现设计好的删除行为。

警告是迁移窗口中的信号,退出码是运行结果,两者需要分别断言。只保存标准输出,会丢掉弃用信息;只把所有非零状态都标成失败,又无法表达“旧调用在新版本按预期不再支持”。维护报告应把推荐调用、旧调用和预期结果列清楚,避免用一个笼统的“升级通过”掩盖行为变化。

从实验差异形成维护决策

这个夹具给出一个可执行迁移顺序:先在旧依赖下把调用方改为 total,并确认不再产生弃用警告;再切换到新依赖,运行相同公开契约。如果先升级依赖才修改调用方,旧方法调用会立即失败。顺序本身不复杂,但需要旧版本保留新 API、新版本保留相同新 API 语义,才能形成这条迁移路径。

真实依赖升级未必有这样完整的过渡。警告可能缺失,新方法可能改变默认值,或者返回结构发生变化。因此真实维护时还要阅读变更记录,再把相关公开行为加入回归样本。这里的人工 fixture 只演示如何组织和保存证据,不提供对某个真实依赖升级安全性的结论。

RubyGems 的 gem 结构与加载约定强调命名空间与加载路径组织。实验将夹具文件放在独立命名空间下,避免把通用文件名放到 lib 顶层后覆盖其他依赖的 require 目标。版本切换的问题有时表面像 API 不兼容,实际却是错误文件被先加载;安装路径与包文件列表能帮助区分这两类原因。

复现与验收材料

主工程依赖安装完成后,从 examples/ruby 运行:

1
BUNDLE_PATH=.bundle/vendor bundle exec ruby labs/E05/run.rb

CRuby 路径通过 CRUBY_BIN 指定;JRuby 使用 JAVA_BIN 指定的 Java 执行 JRUBY_JAR,独立依赖目录由 JRUBY_GEMS 指定。默认路径与完整安装命令记录在实验 README。入口不会下载或编译解释器,也不会自动联网安装依赖;缺少前置条件会明确失败。

matrix.json 记录每行版本、平台、包摘要及夹具结果,commands.json 保存实际子进程命令、标准输出、标准错误与退出码。临时包与安装目录在实验结束后清理,记录保留用于复查。包摘要用于标识本次归档,不据此宣称不同构建之间字节完全可重现。

单元 打包安装与 API/CLI 推荐夹具 API 旧夹具 API
CRuby 3.4.11 / arm64-darwin27 通过 两版本通过 一版警告,二版失败
JRuby 10.0.5.0 / Java 21 / macOS arm64 通过 两版本通过 一版警告,二版失败

练习一:在临时副本里把 gemspec 的 files 清单去掉 IO 文件,保留源码目录中的文件,然后分别运行工作区 API 与安装包 API。记录哪一步失败,解释为什么只跑工作区测试会漏掉这类交付错误。

练习二:为夹具版本二加入“空数组返回 nil”的错误修改,再增加 total 空输入必须返回零的公开契约。确认两个解释器上的新版本单元都失败,并保存修复前后的差异。这个练习检验的是返回值兼容,不能靠“方法仍然存在”代替。

兼容声明需要随实际矩阵持续更新。没有运行的单元应保持未测试状态,不把本地一次成功扩写为跨平台支持承诺。

系列导航

导读 · 上一篇:E04:C扩展与 FFI · 下一篇:E06:SQL 与事务:对象状态何时成为数据库事实 · 完整源码包