深入 Ruby E05:gem 维护与版本升级的兼容证据
支持范围要由运行结果支撑
先修: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 | |
回归同时覆盖合法数据往返和非法筛选选项。合法 JSON 往返证明序列化适配器确实被安装并能加载;非法优先级零必须产生 ArgumentError,避免升级后悄悄放宽校验。CLI 则通过安装后的 bin/taskbook 运行 stats,从标准输入读入空数组,并断言输出 JSON 的 total 为零。
这些检查仍然有限:没有遍历 Taskbook 所有函数和所有边界,也没有创建全新机器。CRuby 复用主工程已冻结的依赖目录,JRuby 单独安装 Java 平台依赖。安装 Taskbook 本身使用忽略依赖选项,因此这里证明的是“这组已装依赖下,打包后的程序能运行”,不证明离线空环境能够解决并安装全部依赖。
隔离环境时不能只改 GEM_HOME
在 bundle exec 启动的父进程里,Bundler 通过环境变量和启动选项影响子进程。仅设置新 GEM_HOME,不一定能摆脱原项目的依赖上下文。实验构造子进程环境时删除所有以 BUNDLE 开头的变量,同时清除 RUBYOPT 与 RUBYLIB,再显式设置安装目录和只读依赖路径:
1 | |
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 | |
每个版本都真的执行 gem build 和本地 gem install。随后在独立 Ruby 进程中显式激活精确版本再 require,防止同名常量或已加载特性在版本之间残留。把两个版本连续 require 到同一进程,无法模拟正常的依赖切换,因为 Ruby 的加载缓存和已定义对象不会按包版本自动重置。
推荐 API 的测试在两个版本都要求返回二,且标准错误为空。旧 API 在版本一要求返回二、退出零,同时标准错误包含弃用说明;版本二则要求非零退出并出现 NoMethodError。父进程将这次预期失败记录为升级差异,而不是忽略失败状态。若版本二意外继续支持旧方法,实验同样失败,因为它没有呈现设计好的删除行为。
警告是迁移窗口中的信号,退出码是运行结果,两者需要分别断言。只保存标准输出,会丢掉弃用信息;只把所有非零状态都标成失败,又无法表达“旧调用在新版本按预期不再支持”。维护报告应把推荐调用、旧调用和预期结果列清楚,避免用一个笼统的“升级通过”掩盖行为变化。
从实验差异形成维护决策
这个夹具给出一个可执行迁移顺序:先在旧依赖下把调用方改为 total,并确认不再产生弃用警告;再切换到新依赖,运行相同公开契约。如果先升级依赖才修改调用方,旧方法调用会立即失败。顺序本身不复杂,但需要旧版本保留新 API、新版本保留相同新 API 语义,才能形成这条迁移路径。
真实依赖升级未必有这样完整的过渡。警告可能缺失,新方法可能改变默认值,或者返回结构发生变化。因此真实维护时还要阅读变更记录,再把相关公开行为加入回归样本。这里的人工 fixture 只演示如何组织和保存证据,不提供对某个真实依赖升级安全性的结论。
RubyGems 的 gem 结构与加载约定强调命名空间与加载路径组织。实验将夹具文件放在独立命名空间下,避免把通用文件名放到 lib 顶层后覆盖其他依赖的 require 目标。版本切换的问题有时表面像 API 不兼容,实际却是错误文件被先加载;安装路径与包文件列表能帮助区分这两类原因。
复现与验收材料
主工程依赖安装完成后,从 examples/ruby 运行:
1 | |
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 与事务:对象状态何时成为数据库事实 · 完整源码包
