为什么我们要使用 RVM / Bundler ?
作为一名 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 横空出世,提供了这样的一个脚本分发解决方案:
- 首先用 gemspec 来描述你即将分发的脚本的元信息
- 利用 gem 提供的命令,将脚本打包成一个 .gem 文件(.gem 实质就是一个 POSIX tar archive),然后上传到服务器
- 当有其他人想要使用你的脚本时,执行
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