TLDR
- RubyのインストールにはRVMを使用
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 cocoapods
を実行する際に以下のようなエラーに遭遇したはずです:
You don't have write permissions for the /Library/Ruby/Gems/2.3.0 directory
なぜこのエラーが発生するのでしょうか?それは、Rubyのデフォルトパッケージマネージャーであるgemが、ダウンロードしたすべての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
キーワードを使用してそのファイルの内容を読み込む必要があります。require
は、Rubyのプリセットされた$LOAD_PATH
内で対応するファイルを探します。現在のRubyの$LOAD_PATH
の内容は、ruby -e 'puts $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アーカイブです)にパッケージ化し、サーバーにアップロードします
- 他の人があなたのスクリプトを使用したい場合は、
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は$LOAD_PATH
に存在しないため、私たちのRubyスクリプトはまだそれらを正しく参照できません。
この問題を解決するために、gemはインストール後にRubyのrequireの実装を修正し、requireが実行時に$LOAD_PATH
だけでなくGems Install Pathも検索するようにしました(gemがインストールされている場所はgem env | grep -A2 'GEM PATHS'
を実行することで確認できます。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のリストだからです🤓🤓🤓。
Ruby開発者たちがgemの一括インストールの問題を解決した後、新しい問題を発見しました:マルチバージョン環境が分離されていません。
これはどういう意味でしょうか?例を使って説明しましょう。
あなたがRuby開発者で、プロジェクトAを維持しているとします。このプロジェクトではfooのバージョン2.0.0を使用しています。しばらくして、別のプロジェクトBの保守を始めることになりましたが、残念なことに、このプロジェクトは最初からfooのバージョン3.0.0を使用していました。そして頭痛の種が始まります:プロジェクトBの環境を設定した後、マシンにはfoo gemの2つのバージョンが存在することになります。同時に、プロジェクトAが動作しなくなることに気付きます。なぜなら、プロジェクトAを実行する際、gemはデフォルトで複数のバージョンの中から最新のバージョンを探すため、プロジェクトAでは2.0.0の代わりにfooのバージョン3.0.0を使用することになるからです。
そして面白いけれど無力な状況が発生します:プロジェクトはローカルでは問題なく動作するのに、サーバーでは動作しません。数日間調査した後、サーバー上の別のプロジェクトが高いバージョンのgemをインストールしたため、サーバー環境でプロジェクトを実行できなくなっていることが分かります。あなたは苦しみ、絶望しますが、どうすることもできません🤬🤬🤬。
たとえ1つのプロジェクトしか維持していなくても、Gem Listファイルにgemのバージョンが指定されていないため、1週間前にこのGem Listファイルを使用してインストールしたgemと1週間後にインストールしたgemが完全に異なる可能性があります。そのため、Ruby開発者たちは冗談でこう言っていました:「こんにちは新人さん、これは新しいコンピュータです。うまくいけば1週間でプロジェクトの依存関係を設定できると良いですね」
Bundlerのソリューション
gemを使用することで発生したこれらの問題を解決するために、Bundlerが登場し、開発者に2つの救命コマンドを提供しました:
bundle install
bundle exec
bundle install
は、複数のgemを統一的にインストールする便利な方法を提供します。bundle install
を実行すると、BundlerはそのGem Listファイル — Gemfileで宣言されているすべてのgemをインストールし、この決定の最終バージョン番号をGemfile.lockに保存します。これにより、異なる時期に異なるマシンでbundle install
を実行しても、同じバージョンのgemがインストールされることが保証されます。
bundle exec
は、マルチバージョン環境が分離されていない問題を解決します。bundle exec
を実行すると、Bundlerは$LOAD_PATH
から無関係なgemのパスをすべて削除し、Gemfile.lockからgemのバージョンを読み取ります(Gemfile.lockがない場合はバージョンを決定して新しいGemfile.lockを作成します)。これにより、$LOAD_PATH
にはGemfile.lockで固定されたバージョンのgemのパスのみが含まれることが保証されます。以下の2行のコードを実行して、$LOAD_PATH
の違いを確認できます:
bundle exec ruby -e 'puts $LOAD_PATH'
ruby -e 'puts $LOAD_PATH'
Bundlerがもたらした新しい問題
この時点で、Bundlerはgemのインストールと環境分離の問題をうまく解決しましたが、Bundlerも新しい問題をもたらしました:Ruby関連のコマンドを実行する前に、毎回bundle exec
を入力する必要があります🤦🏻♂️🤦🏻♂️🤦🏻♂️。
幸いなことに、Ruby開発者たちは怠け者で、この問題を解決するために新しいgem — rubygems-bundlerを開発しました。このgemをインストールした後、gem regenerate_binstubs
を一度実行するだけで、rubygems-bundlerは任意のgemがインストールしたコマンドラインを実行する前に、現在のディレクトリまたは親ディレクトリにGemfileが存在するかどうかをチェックします。存在する場合は、自動的にコマンドラインの前にbundle exec
を追加して実行します。完璧にこの問題を解決しました。
ヒント:バージョン1.11.0以上のRVMは、Rubyをインストールする際にデフォルトでrubygems-bundlerをインストールします。gem list rubygems-bundlerを通じて、このgemがインストールされているかどうかを確認できます。homebrewを使用してRubyをインストールする場合は、この隠れた特典を享受できません。
拡張演習:いくつかの一般的なエラーを見てみましょう。今、何が起きているか分かりますか?
演習1
LoadError - cannot load such file -- macho
答え:machoファイルが$LOAD_PATHに存在しないため、Rubyプログラムの実行に失敗しました。Gemfileがある場合は、まずbundle installを実行する必要があります。ない場合は、手動でgem install machoを実行します。
演習2
Could not find proper version of cocoapods (1.1.1) in any of the sources
Run `bundle install` to install missing gems.
答え:Gemfile.lockはcocoapods gemのバージョンを1.1.1と指定していますが、現在のGem Pathsにcocoapodsのバージョン1.1.1がインストールされていません。この時点でbundle installを実行する必要があります。
演習3
Required ruby-2.3.7 is not installed.
To install do: 'rvm install "ruby-2.3.7"'
答え:現在のディレクトリの.ruby-versionはRubyのバージョンを2.3.7と指定していますが、2.3.7は現在のマシンにインストールされていません。将来のトラブルを避けるため、rvm install 2.3.7を実行することをお勧めします。
参考
- Rbenv — How it works
- rbenv vs rvm
- A Ruby workflow with RVM and Bundler
- Bundler: The best way to manage a Ruby application’s gems
- How Bundler Works: A History of Ruby Dependency Management
- A History of Bundles: 2010 to 2017
- Understanding ruby load, require, gems, bundler and rails autoloading from the bottom up
- How does Bundler work, anyway?