なぜRVM / Bundlerを使用するのか?

·
Cover for なぜRVM / Bundlerを使用するのか?

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が登場し、以下のようなスクリプト配布ソリューションを提供しました:

  1. まず、gemspecを使用して配布しようとするスクリプトのメタデータを記述します
  2. gemが提供するコマンドを使用してスクリプトを.gemファイル(.gemは本質的にPOSIX tarアーカイブです)にパッケージ化し、サーバーにアップロードします
  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は$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を実行することをお勧めします。

参考