RubyGems 和 Bundler 是怎样诞生的
动机
因为工作需要,时不时得写一些 Ruby 代码,而程序又难免需要依赖于许多代码库。
所以想了解一下,协助 Ruby 程序加载 (load / import) 依赖库的两个工具:RubyGems 和 Bundler.
为了了解了它们,我发现了一个24分钟的视频:https://www.youtube.com/watch?v=j2V-A8vvLP0
主讲人 André Arko 是一位从2006年就开始使用 Ruby 编程的工程师
这篇文章就记录一下自己从视频里学到的东西和自己的一些想法。
工具的目的
通常啊,我们都是先有了某个动机或者目的,然后才去学习使用一个工具。
那么 Gems 和 Bundler 的 (主要) 目的是什么:创造,分享和使用 Ruby 的代码和工具库。
其实代码和工具库并不互斥,只是因为有的库含有可独立运行的工具,比如 rbtrace 它们也算作工具库。
现状
大概因为 Rails 框架的流行,大多数项目都默认使用 Bundler,所以使用代码库的过程也就拢共分三步:
1 - 往项目的 Gemfile 里添加 gem "some-library"
2 - 安装新的代码库。这步之后,就可以在程序里使用新的库了。
$ bundle install
3 - 如果是可执行的工具,那可以直接运行。
$ bundle exec some-library
这篇文章 (André 的演讲内容) 也就从这里出发,描述了:
1 - 为什么当下在开发时,我们常用这三个步骤,它们做了啥。
2 - 而为了理解它们,我们将看向历史:从 Ruby 诞生至今 (2021) 人们为了管理依赖库都做了哪些尝试。
命名
写完文章后觉得应该加一个段落讲解和区分一下几个名称。
Bundler 是工具的名称,而 bundle 是命令的名称,是命令行中可执行程序的名称。
- 就好像 Homebrew 是 Mac 系统上工具管理工具,它对应的命令是 brew.
RubyGems 是管理 Ruby 库的一整套系统,人们常通过 gem 这个命令行工具来使用它。
同时呢,Ruby 的代码库也常被称作 gem, 比如流行的 Ruby on Rails 框架就是一个 gem / 代码库。
当代码库 A 被程序 B 或者代码库 C 所使用时,我们就把 A 称作是 B 和 C 的依赖库。
历史
很多时候啊,理解现状最好的方式就是看看过去5 - 10年里发生了啥。
同时呢,事情在 5 - 10 年前的模样,又是由更早的历史所决定。
所以了解过去常常有助于解释当下(这并不是我的博客有许多古希腊语文章的原因)
这里就借助 André 演讲时使用的图片:过去这20多年里为了管理 Ruby 代码库人们都使用什么。
接下来就从 require 开始讲。
require
在讲任何工具之前,首先应该提及的是 Ruby 语言自带的 require function / 函数.
据说它诞生于1994年,在小平同志南巡讲话之后,和我出生以前。
为什么是「据说」呢,因为那时候还没啥代码版本 (version control) 工具, 即便 SVN 也是2000年才出现。
据 André 所说,目前能访问的最早的与 require 函数相关的代码是 1997 年的版本。
那么 require 是咋解决问题的呢?倒不如问问我们自己,如果要我们自己设计 / 写一个 require 会怎么做呢。
第1版
第一版大概就是这样:
def require(filename)
eval File.read(filename)
end
说白了就是通过文件地址,读取它并运行一遍。代码库里如果有类或模块 (class / module) 运行后便都声明好了。
这么写有啥不足呢?
1 - 如果 require 同一个文件多次,就得运行多次,代码库里的变 / 常量被重复初始化,效率低。
2 - 同时它假设了一个依赖库必须是可以通过文件系统访问,而且得是绝对地址 / absolute path.
第2版
为了解决问题1,咱可以用一个容器来记录加载过的文件:
LOADED_FEATURES = []
def require(filename)
if LOADED_FEATURES.include?(filename)
return true
end
eval File.read(filename)
LOADED_FEATURES << filename
end
为了解决部分问题2呢,咱可以让它从给定的文件目录来查找库。
LOAD_PATHS = []
def require(filename)
fullpath = LOAD_PATHS.first do |path|
File.exist?(File.join(path, filename))
end
eval File.read(filename)
end
当然啦,具体的 require 代码大概比这复杂许多,这只是极简的概括。
需要记住的就是:Ruby 程序运行时,从指定的一些路径加载依赖库。
接下来的 setup.rb, RubyGems 和 Bundler 都在让加载 / 使用库的过程更简单且准确。
总之呢,在工具出现之前,人们仍然要手动去下载 / 软盘拷贝别人的代码库,放到指定目录下再使用。
setup.rb
在工具出现之前,2000年左右,开始流行了一种规范:往库中添加一个 setup.rb
文件。
- 注意噢,使用
setup.rb
是一种规范 / 契约,它并不是一个工具。 - 我觉得这非常像其他编程语言的代码库所用的 Makefile.
今天咱去下载一些老旧的代码库,它们仍然保留了 setup.rb
文件。
步骤
有了 setup.rb
人们是怎么加载依赖库呢?简单来说呢,拢共分4步:
1 - 程序员手动在晚上找别人的代码库:它们可能分布在不同的网站 / 博客上,因为大家白天要工作。
2 - 手动下载代码库 - 通常是一个打包好的 .tar
文件,或者压缩过的 .tar.gz
包
3 - 在本地文件系统里,手动解压缩代码库。
4 - 运行它的 setup.rb
$ ruby setup.rb setup
$ ruby setup.rb config
$ ruby setup.rb install
这之后呢,代码库就被安装到本地系统的特定文件夹里,require
能直接找到它们。特定的文件夹的位置通常被称作库记载路径 / load path.
缺陷
当然啦,这个做法仍然有许多不足:
1 - 并没有版本管理,比如昨天安装了1.0版,今天再安装 2.0 版就把 1.0 的库给覆盖了。
- 程序的 major (版本号的第一个数字) 版本变化意味着兼容性的改变
2 - 如果要卸载一个库,还是得手动去目录里删。
3 - 代码库分布在各种网站,如果刚好用了俩名字一样的,后安装的就覆盖了先装的。
因此了,几年后诞生了 RubyGems 这一系统。
RubyGem
除了一两个命令行工具外,它还包含了分享和使用代码库的流程和规范,所以我觉得它算是一个系统。
RubyGems 诞生于 2003 左右,它现在 (2021年中) 有 74 billion / 740 亿个大大小小的代码库。
在这之后呢,所有可以通过 RubyGems 系统来分享的 Ruby 代码库也都被称作 gem.
同时呢,大伙儿开始使用 gem
这个命令行工具来查找 / 安装和卸载代码库:
$ gem install rails -v 5.0
$ gem unstall
$ gem list gemName
同时呢,它也支持了版本的选择(如果不声明版本号,它默认使用最新的版本)
同 setup.rb
不太一样的是,gem
通过改变环境变量,让 Ruby 程序从特定的目录列表里加载库。
讲到这里,André 还提及了一个小技巧,运行工具库时,可以通过 _版本号_
的方式选择运行。
some-server-lib _1.2.2_ -port 3000
大概因为 _arg_
这样格式的参数会被 RubyGem
读取识别吧,像 JVM 的参数那样。
兼容问题
André 在 2008 年时遇到一个问题,还是在生产环境 (prodcution environment) 中。
他调试了三天后发现,其中一台服务器上装的是某个库 (gem) 的1.1.4 版本,而其他服务器装的都是 1.1.3,而他们的程序与 1.1.4 不兼容。
其实在今天,当我们在同一台机器上开发不同的 Ruby 项目时,我们仍然能复现这样的问题。
比如我们需要开发一个新的服务器程序(用新版的 rails)同时又维护一个旧版的程序(用旧版 rails )
$ gem install rails # 给新程序安装了最新 rails
$ rails server
$ cd ../old-project
$ rails server # 💣 旧程序无法使用最新的 rails
当然啦,人们也曾经也尝试过解决这个问题,比如 rails 项目中的 config/application.rb
里把所有的依赖都写清了,然后 rails 运行时就会去加载正确的库。
config.gem "rack"
config.gem "rails"
config.gem "what?"
但这仍然不是一个好办法,因为还有间接依赖的问题:比如要运行 config/application.rb
需要启动 rails server,而要启动 server 需要先加载 rails gem,那么这时该用什么版本的 rails gem 呢?
运行时问题
André 觉得这个问题,在 RubyGems 的设计框架下 / 层面无法解决:
因为使用 RubyGems 之后,库与库、程序与库之间的兼容问题在运行时才能被发现。
- 还记得吗,gem 在安装库之后改变了 Ruby 程序在运行时所用的 load path / 加载路径。
在那些年里,许多使用 rails 的工程师也遇到了这个问题:某些库需要最新的 rack 但 rails 仍然用旧版的 rack
RuntimeError: can't activate rack (~> 1.1.0, runtime), already activated rack-1.0.1.
而要解决这个问题,需要的是在安装库时候就解决 / 决定所有的库的版本问题。
因此呢,Bundler 这个工具也就在 2008 年左右诞生啦。
Bundler
它引入了 Gemfile 的概念:要使用 bundler 就得在项目的根目录下写好一个 Gemfile 文件。
- 同时呢,在 Gemfile 中我们可以定义库的版本范围,比如
gem 'rails', '>=1.0'
就像 npm 项目里的 package.json
, Java 项目的 gradle.build
, Go 项目的 go.mod
当第一次在一个项目中使用 bundler 安装依赖库的时候,它会生成一个 Gemfile.lock
文件
英文 lock 表示锁定,在 Gemfile.lock 里它写明了每个库的版本号,比如这样:
gem1 (2.3.7)
gem2 (6.0.0)
工具间的关系
要说明的是 Bundler 并不是独立的用来取代 RubyGems的新系统
- 如果把 RubyGems 看作一个中央代码仓库,理论上来说 Bundler 是可以支持不同的代码仓库的。
它应该被看作是对 RubyGems 的延伸,依靠这两个途径:
1 - 它让库兼容性问题在安装时就暴露出来
2 - 它在每个项目内锁定所有依赖库的版本,免受系统上其他地方安装 gem 的影响。
同样的道理,RubyGems
也无法取代程序中的 require
函数,只是让它的使用更方便和准确。
至于往每个库中写 setup.rb
的做法,它确实不再流行了。
根据 RubyGems 定义的规范,如果想分享自己的代码库,就得写 lib-name.gemspec
文件。
bundle install
至此呢,关于 Ruby 的依赖库管理历史和现状,我们都了解得差不多了。
回到文章开头的那三个步骤,第一次运行 bundle install
时发生了什么呢?
简单来说,大概是这5个步骤事儿:
1 - 读取当前项目中的 Gemfile 获知需要哪些库
2 - 从代码仓库 RubyGems.org 获得这些库的信息和版本,并下载一部分。
3 - 枚举所有库的版本号可能性,找到它们之中可互相兼容的组合。
4 - 把这些库的名称和对应的版本号写入 Gemfile.lock 文件
5 - 把 Gemfile.lock 中的库根据它们的版本号下载和安装。
下一次运行时 bundle 就能直接通过 Gemfile.lock 从步骤 5 开始。
bundle exec
像我之前说的,许多代码库同时也是工具库,它们包含可执行文件。
所以工具 bundler 也提供了许多其他的功能,比如常用的 bundle exec
用来直接运行库中的文件。
André 也简单提到了 bundle exec 的运行流程:
1 - 读取当前项目中的 Gemfile 或者 Gemfile.lock 获知需要哪些库
2 - 优先使用 Gemfile.lock 中指定的库版本
3 - 确认所有兼容的版本 (跟 bundle install 的步骤3一样), 把它们都写进 Gemfile.lock
4 - 把 Ruby 使用的库加载路径 LOAD_PATH 清空一下。
5 - 把 Gemfile.lock 的里的库安装好,并放入库加载路径中。
6 - 运行工具库里的可执行文件。
bundle binstubs
在 André 的演讲末尾,他还提及了另一个方便的 bundler 功能:通过 bundle binstubs
命令使用。
因为开发时,人们通常需要反复运行某个 (来自依赖库的) 工具,比如单元测试。
我们可以在本地创建一个可执行文件 - 更像是一个快捷方式,比如这样:
$ bundle binstubs rspec-core
$ bin/rspec
之后呢,我们就能直接运行 bin/rspec
这个工具,而不需要重复 bundle exec 的流程。
最后
写这篇文章的起因是想记录一下 André 演讲 的内容,我觉得它能增进我们对 Gems 和 Bundler 的理解。
我们看到了这20多年里,人们对 Ruby 程序依赖管理所做出的尝试,定义的标准,制作的工具。
而同样的演化 / 发展过程也发生在其他的编程语言,操作系统,因特网协议/ 工具,和许多事情上。
对于一个问题,在不同层面有不同的解决方案,它们又难免有自己的局限。
随着简单问题的解决,更复杂的问题也逐渐出现,需要我们在不同的层面,定义新的方式 / 创造新的工具来解决。