博客

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 年前的模样,又是由更早的历史所决定。

所以了解过去常常有助于解释当下(这并不是我的博客有许多古希腊语文章的原因)

Ruby的依赖管理历史

这里就借助 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 程序依赖管理所做出的尝试,定义的标准,制作的工具。

而同样的演化 / 发展过程也发生在其他的编程语言,操作系统,因特网协议/ 工具,和许多事情上。

对于一个问题,在不同层面有不同的解决方案,它们又难免有自己的局限。

随着简单问题的解决,更复杂的问题也逐渐出现,需要我们在不同的层面,定义新的方式 / 创造新的工具来解决。