为什么我们要使用 RVM / Bundler ?

YetsingZ 2019-06-30

作为一名 iOS 工程师,cocoapods 是我们所不会陌生的。然而在我们的日常开发中,编写 cocoapods 的 Ruby 语言我们可能不甚了解,更不要说 Bundler 以及 RVM 了。因此,当我们遇到一些 Ruby 环境相关的问题时,可能完全不知道发生了什么。如果恰好你对这两个工具做了什么感到好奇,那么,在这篇文章中,我会尽量由浅入深的去说明 RVM / Bundler 的原理和作用,帮助大家对 Ruby 的环境管理有一个更加深入的理解。

TLDR

  • 使用 RVM 来安装 Ruby
  • gem install rubygems-bundler && gem regenerate_binstubs 可以让你免去每次都要在 pod install 之前添加 bundle exec 的痛苦

我们所使用的 Ruby 从哪里来?

我们都知道,macOS 是自带 Ruby 的。也就是说,当我们拿到一台新的 MacBook Pro,进入系统,打开终端执行 whereis ruby,我们会得到 /usr/bin/ruby 这样的结果。

在目前的 macOS 10.14 版本中,系统自带的 Ruby 版本为 2.3.7。

为什么需要使用 RVM?

在没有安装 RVM 或者 rbenv 这样的工具以前,大家在执行 gem install cococapods 这一行命令的时候一定会遇到这样的报错:

You don't have write permissions for the /Library/Ruby/Gems/2.3.0 directory

为什么会出现这样的错误?因为 gem 作为 Ruby 默认的包管理器,会将所有下载的 gem 安装在某个特定的目录下,我们暂且称呼这个目录为 Gem Path ,对于系统的 Ruby 来说,这个目录就是 /Library/Ruby/Gems/2.3.0,这是一个需要启用 sudo 才能写入的目录。这也就导致我们在每次 gem install 的时候都需要在命令之前增加 sudo 才能让命令正确执行。

为了解决这个问题,我们需要让 Gem Path 指向一个我们拥有写权限的目录。比较简单直接的办法就是我们利用 homebrew 去安装一个新的 Ruby。

似乎很完美,但有个问题:我们如何约束大家所有人都使用同样版本的 Ruby 呢?

答案是使用 Ruby 的版本管理工具。以 RVM 为例,当你安装 RVM 以后,你在命令行中执行的每一个 cd 命令其实都被 RVM 所替换了。RVM 会在每一次切换目录后检查当前目录中是否有 .ruby-version 文件,如果有,就检查当前使用的 Ruby 是否是文件中指定的版本。如果不是,他会给出类似 Required ruby-x.x.x is not installed 这样的警告。

在我司工程的早期阶段,我们除了使用 cocoapods,还需要使用 Ruby 编写一些打包和发布的脚本,而当时系统提供的 Ruby 版本还比较低(2.0.0),开发起来不太方便,而利用 RVM ,我们不仅可以方便的安装一个新版本的 Ruby,还可以利用 .ruby-version 来保证大家可以使用相同版本的 Ruby(尽管只是一个比较弱的约束)。

相信到这里,大家已经能够理解,在我们的项目中使用 RVM 是很有必要的。我们接下来看第二个问题:为什么要用 Bundler?

为什么要使用 Bundler?

为了回答这个问题,我们需要先把目光转向 gem,回顾一下 gem 诞生时要解决的问题。

gem 所要解决的问题

在 Ruby 中,如果你想使用另外一个 Ruby 文件中的内容,你需要使用 require 关键字来加载另外一个 Ruby 文件中的内容。require 会在 Ruby 预设的 $LOAD_PATH 中去查找对应的文件。你可以通过执行 ruby -e 'puts $LOAD_PATH' 来看看当前 Ruby 中的 $LOAD_PATH 都有什么内容。

例如如果你写了一个简单的 Ruby 脚本:

require 'foo'

当执行到 require 'foo' 这一行时, Ruby 就会在 $LOAD_PATH 中出现的所有目录下去查找是否有一个叫做 foo.rb 的文件。如果有,就去加载这个文件的内容。如果在所有的 $LOAD_PATH 中都没有找到这样的一个文件,Ruby 解释器就会抛出异常。异常通常长这个样子:

LoadError - cannot load such file -- foo

在没有 gem 以前,如果你想用别人已经写好的 Ruby 脚本,就需要手动把这些脚本下载下来,放到 $LOAD_PATH 中的某个目录下,然后你才能在你的脚本中正确的使用别人的脚本文件。这样的代码分发过程是非常原始而繁琐的。

为了解决这个问题,gem 横空出世,提供了这样的一个脚本分发解决方案:

  1. 首先用 gemspec 来描述你即将分发的脚本的元信息
  2. 利用 gem 提供的命令,将脚本打包成一个 .gem 文件(.gem 实质就是一个 POSIX tar archive),然后上传到服务器
  3. 当有其他人想要使用你的脚本时,执行 gem install 即可

前面的内容很好理解,我们来着重看一下执行 gem install 之后发生了什么。

当你执行 gem install foo 的时候,gem 会帮你把 foo.gem 下载下来,解压缩,放到一个目录下。一般这个目录都是我们前面提到 Gem Path 的子目录,我们这里暂时称其为 Gems Install Path。如果 foo 的 gemspec 中声明了对其他 gem 的依赖,gem install foo 还会帮你把 foo 所依赖的 gem 下载下来。

gem install 所做的事情其实很简单。但到此时 gem 还没有完全解决我们的问题:gem install 所安装的那些 gem 并不存在于 $LAOD_PATH 中,我们的 Ruby 脚本还是无法正确的引用到他们。

为了解决这个问题,gem 在自己被安装后,就去修改了 Ruby 中 require 的实现,使得 require 在执行的时候,除了 $LOAD_PATH,还会在 Gems Install Path 中查找文件(你可以通过执行 gem env | grep -A2 'GEM PATHS' 找到你的 gem 所安装的路径,GEMS INSTALL PATH 就在这个目录的 gems 子目录下)。

当 gem 在 GEMS INSTALL PATH 中找到对应文件后,就会把这个路径加入到 $LOAD_PATH 中,然后调用 Ruby 本来的 require。此时由于 $LOAD_PATH 中增加了新的路径,require 就可以正确的加载到你所安装的 gem 的对应文件了。

这里我们可以做一个小实验,找一个没有 Gemfile 的目录执行 irb,然后依次输入注释以外的内容:

old_load_path = $LOAD_PATH.dup
require 'cocoapods'
new_load_path = $LOAD_PATH.dup
# 执行下面的代码可以看看 LOAD_PATH 数量的变化
"new: #{new_load_path.count} old: #{old_load_path.count}"
# 执行下面的代码可以看看 LOAD_PATH 到底变了什么。你会看到 cocoapods 以及他的依赖库所在的目录
new_load_path - old_load_path

至此,gem 已经完美解决了分发 Ruby 脚本的问题。当你想要使用任何一个别人已经提供好的 gem 的时候,只需要简单输入 gem install,你的脚本就可以快乐的使用这个 gem 了。

gem 所带来的新的问题

到目前为止,一切似乎很美好,但是随着 Ruby 应用于各种大型项目以后,Ruby 的开发者们发现了新的问题:当你的项目依赖了十几个 gem 后,新接手的人的配置环境时需要输入十几次 gem install 才能正确的配置好环境。

这样的事情开发者们当然不能忍,于是他们开始使用各种脚本文件将这个过程简化,这些脚本可能叫做 setup.sh ,他们的内容一般是这样的:

gem install foo
gem install bar

在这里我们暂时可以称呼类似这种 setup.sh 文件为 Gem List 文件,因为他就是一个装满了所有你需要安装的 Gem 的 List

相关推荐