Why Do We Use RVM / Bundler?

·
Cover for Why Do We Use RVM / Bundler?

TLDR

  • Use RVM to install Ruby
  • gem install rubygems-bundler && gem regenerate_binstubs can save you from the pain of having to add bundle exec before pod install every time

Where Does Our Ruby Come From?

We all know that macOS comes with Ruby built-in. This means when we get a new MacBook Pro, enter the system, open the terminal and execute whereis ruby, we’ll get a result like /usr/bin/ruby.

In the current macOS 10.14 version, the system’s built-in Ruby version is 2.3.7.

Why Do We Need RVM?

Before installing tools like RVM or rbenv, everyone must have encountered this error when executing gem install cocoapods:

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

Why does this error occur? Because gem, as Ruby’s default package manager, will install all downloaded gems in a specific directory, which we’ll call the Gem Path. For the system Ruby, this directory is /Library/Ruby/Gems/2.3.0, which is a directory that requires sudo to write to. This means we need to add sudo before every gem install command for it to execute correctly.

To solve this problem, we need to make the Gem Path point to a directory where we have write permissions. A simple and direct solution is to install a new Ruby using homebrew.

Seems perfect, but there’s one issue: how do we ensure everyone uses the same version of Ruby?

The answer is to use Ruby version management tools. Taking RVM as an example, after you install RVM, every cd command you execute in the terminal is actually replaced by RVM. RVM will check if there’s a .ruby-version file in the current directory after each directory change. If there is, it checks if the currently used Ruby version matches the one specified in the file. If not, it will give a warning like Required ruby-x.x.x is not installed.

In the early stages of our company’s projects, besides using cocoapods, we also needed to write some packaging and publishing scripts in Ruby. At that time, the system-provided Ruby version was quite low (2.0.0), making development inconvenient. Using RVM, we could not only easily install a new version of Ruby but also use .ruby-version to ensure everyone uses the same Ruby version (although it’s a relatively weak constraint).

I believe by now, everyone can understand why using RVM in our projects is necessary. Let’s look at the second question: why use Bundler?

Why Use Bundler?

To answer this question, we need to first turn our attention to gem and review what problems it was created to solve.

Problems Gem Was Meant to Solve

In Ruby, if you want to use content from another Ruby file, you need to use the require keyword to load content from that file. require will look for corresponding files in Ruby’s preset $LOAD_PATH. You can see what’s in your current Ruby’s $LOAD_PATH by executing ruby -e 'puts $LOAD_PATH'.

For example, if you wrote a simple Ruby script:

require 'foo'

When executing the line require 'foo', Ruby will search in all directories in $LOAD_PATH for a file called foo.rb. If found, it will load the content of that file. If no such file is found in all $LOAD_PATH directories, the Ruby interpreter will throw an exception. The exception usually looks like this:

LoadError - cannot load such file -- foo

Before gem existed, if you wanted to use Ruby scripts written by others, you needed to manually download these scripts and place them in a directory in $LOAD_PATH, only then could you correctly use others’ script files in your script. This code distribution process was very primitive and cumbersome.

To solve this problem, gem emerged, providing such a script distribution solution:

  1. First use gemspec to describe the metadata of the script you’re about to distribute
  2. Use commands provided by gem to package the script into a .gem file (.gem is essentially a POSIX tar archive) and upload it to the server
  3. When others want to use your script, they just execute gem install

The previous content is easy to understand, let’s focus on what happens after executing gem install.

When you execute gem install foo, gem will help you download foo.gem, decompress it, and place it in a directory. Generally, this directory is a subdirectory of the Gem Path we mentioned earlier, we’ll temporarily call it the Gems Install Path. If foo’s gemspec declares dependencies on other gems, gem install foo will also help you download the gems that foo depends on.

What gem install does is actually quite simple. But at this point, gem hasn’t completely solved our problem: the gems installed by gem install don’t exist in $LOAD_PATH, so our Ruby scripts still can’t correctly reference them.

To solve this problem, gem modified Ruby’s require implementation after being installed, making require search not only in $LOAD_PATH but also in Gems Install Path when executing (you can find where your gems are installed by executing gem env | grep -A2 'GEM PATHS', GEMS INSTALL PATH is in the gems subdirectory of this directory).

When gem finds the corresponding file in GEMS INSTALL PATH, it will add this path to $LOAD_PATH, then call Ruby’s original require. At this point, since $LOAD_PATH has a new path, require can correctly load the corresponding file of the gem you installed.

Here we can do a small experiment, find a directory without Gemfile, execute irb, then input the following content except for comments:

old_load_path = $LOAD_PATH.dup
require 'cocoapods'
new_load_path = $LOAD_PATH.dup
# Execute the code below to see the change in LOAD_PATH count
"new: #{new_load_path.count} old: #{old_load_path.count}"
# Execute the code below to see exactly what changed in LOAD_PATH. You'll see the directories where cocoapods and its dependencies are located
new_load_path - old_load_path

At this point, gem has perfectly solved the problem of distributing Ruby scripts. When you want to use any gem provided by others, you just need to simply input gem install, and your script can happily use this gem.

New Problems Brought by Gem

So far, everything seems perfect, but after Ruby was applied to various large projects, Ruby developers discovered new problems: when your project depends on dozens of gems, new team members need to input gem install dozens of times to correctly configure the environment.

Developers couldn’t tolerate this, so they started using various script files to simplify this process. These scripts might be called setup.sh, and their content generally looks like this:

gem install foo
gem install bar

Here we can temporarily call files like setup.sh a Gem List file, because it’s just a List full of all the Gems you need to install 🤓🤓🤓.

After Ruby developers solved the problem of batch installing gems, they discovered new problems: multi-version environments are not isolated.

What does this mean? Let’s use an example to explain this problem.

Suppose you’re a Ruby developer maintaining your project A, where you use version 2.0.0 of foo. After a while, you start maintaining another project B, and unfortunately, this project initially used version 3.0.0 of foo. Then the headache begins: after you configure project B’s environment, you’ll have two versions of foo gem on your machine. At the same time, you’ll find that your project A won’t run anymore, because when running project A, gem will by default find the latest version among multiple versions, so in project A you’re using version 3.0.0 of foo instead of 2.0.0.

Then interesting but helpless situations occur: your project might work fine locally but not on the server. After investigating for several days, you find it’s because another project on the server installed a higher version gem, making the server environment unable to run your project. You’re frustrated, desperate, but powerless 🤬🤬🤬.

Even if you only maintain one project, since your Gem List file doesn’t specify gem versions, it’s very possible that the gems installed using this Gem List file a week ago are completely different from those installed a week later. So much so that Ruby developers used to joke: “Hello newcomer, this is a new computer, we hope you can spend a week configuring the project’s dependencies, if all goes well”

Bundler’s Solution

To solve these problems caused by using gem, Bundler emerged, providing developers with two lifesaving commands:

  • bundle install
  • bundle exec

bundle install provides us with a convenient way to uniformly install multiple gems. After executing bundle install, Bundler will install all gems declared in its Gem List file — Gemfile, and save the final version numbers from this resolution in Gemfile.lock, ensuring that executing bundle install at different times on different machines will install the same versions of gems.

bundle exec solves the problem of multi-version environments not being isolated. When you execute bundle exec, Bundler will remove all irrelevant gem paths from $LOAD_PATH, then read the gem versions from Gemfile.lock (if there’s no Gemfile.lock it will resolve versions and create one), ensuring that $LOAD_PATH only contains paths of gems with fixed versions in Gemfile.lock. You can execute the following two lines of code to see the difference in $LOAD_PATH:

bundle exec ruby -e 'puts $LOAD_PATH'
ruby -e 'puts $LOAD_PATH'

New Problems Brought by Bundler

At this point, Bundler has well solved the problems of gem installation and environment isolation, but Bundler also brought new trouble: we need to repeatedly input bundle exec before executing Ruby-related commands 🤦🏻‍♂️🤦🏻‍♂️🤦🏻‍♂️.

Fortunately, Ruby developers are lazy, they developed a new gem — rubygems-bundler to solve this problem. After you install this gem, just execute gem regenerate_binstubs once, rubygems-bundler will help you check if there’s a Gemfile in the current directory or parent directories before executing any gem-installed command line. If exists, it will automatically add bundle exec before your command line then execute. Perfectly solving this problem.

Tip: RVM versions above 1.11.0 will install rubygems-bundler by default when installing Ruby. You can check if you have this gem installed through gem list rubygems-bundler. If you install Ruby using homebrew, you won’t enjoy this hidden benefit.

Extended Exercise: Let’s Look at Some Common Errors, Now Do You Know What Happened?

Exercise One

LoadError - cannot load such file -- macho

Answer: The macho file is not in $LOAD_PATH, so the Ruby program execution failed. If there’s a Gemfile, should bundle install first. If not, manually gem install macho

Exercise Two

Could not find proper version of cocoapods (1.1.1) in any of the sources
Run `bundle install` to install missing gems.

Answer: Gemfile.lock has specified the version of cocoapods gem as 1.1.1, but version 1.1.1 of cocoapods is not installed in current Gem Paths, should bundle install at this time

Exercise Three

Required ruby-2.3.7 is not installed.
To install do: 'rvm install "ruby-2.3.7"'

Answer: The .ruby-version in current directory specifies Ruby version should be 2.3.7, but 2.3.7 is not installed on current machine, to avoid future troubles, better execute rvm install 2.3.7

References