こんにちは、wai-doi です。 本記事では、Rails Console で Gemfile に書かれていない gem を使いたくなったときの困りごとの共有と、その解決方法をご紹介します。
環境
- Ruby 4.0.5
- RubyGems 4.0.10
- Bundler 4.0.14
- Rails 8.1.3
困りごと
Rails Console で Active Record のコードを書くことがよくあります。 そのとき、チームで共有する Gemfile に書くほどではないけれど、自分だけは使いたい gem が出てきました。
pp_sql はログに出力される SQL を整形して見やすく表示してくれる gem です。
GitHub - kvokka/pp_sql: Rails ActiveRecord SQL queries log beautifier · GitHub
この gem を Rails Console で使いたいと考えました。しかし、Rails Console は Bundler を使用しているため通常は Gemfile 内にある gem しか利用できません。
本記事では簡単な例として、tapp という gem を使えるようにすることを目指します。
GitHub - esminc/tapp: tap { pp self } · GitHub
実際に require すると以下のような LoadError が発生します。
$ bin/rails c Loading development environment (Rails 8.1.3) >> require 'tapp' (rails-sample-app):1:in '<main>': cannot load such file -- tapp (LoadError)
そのため、Gemfile に書かれていない gem を Rails Console で使えるようにしてみました。
Bundler の有無での require の違いを調査
Rails 内で呼び出す require は以下のような順番で再定義されています。
CRuby の Kernel#require ↓ RubyGems の Kernel#require ↓ Bootsnap の Kernel#require ↓ Zeitwerk の Kernel#require
Zeitwerk は autoload のため、Bootsnap はライブラリのパスをハッシュに持つことで最適化するために、それぞれ require を再定義しています。このうち、Gemfile にない gem を require できるかに関わってくるのは RubyGems の require です。
ただし Rails では、この RubyGems による gem の探索が Bundler によって無効化されています。詳しくは後述します。
例えば、gem install tapp している状態で、通常の Ruby で require をすると成功します。
$ ruby -e 'p require "tapp"' true
通常の Ruby では RubyGems の Kernel#require が呼び出されます。以下のような実装になっています。
rubygems/lib/rubygems/core_ext/kernel_require.rb at v4.0.10 · ruby/rubygems · GitHub
def require(path) return gem_original_require(path) unless Gem.discover_gems_on_require # 省略 begin gem_original_require(path) # まず普通に $LOAD_PATH から探す rescue LoadError => load_error # 見つからなかったら if load_error.path == path && RUBYGEMS_ACTIVATION_MONITOR.synchronize { Gem.try_activate(path) } return gem_original_require(path) # activate 後にリトライ end raise load_error end end
まず最初に discover_gems_on_require を判定します。false であれば元の require を呼び出して activate は行われません。
true の場合は、まず元の require を試し、$LOAD_PATH から見つからず LoadError になったときに、インストール済みの gem の中から探して activate ($LOAD_PATH に追加) し、あらためて require します。そのため require が成功するようになっています。
こちらが元の require のリファレンス。
Kernel?.require (Ruby 4.0 リファレンスマニュアル)
こちらが RubyGems で拡張された require のリファレンス。
Kernel#require (Ruby 4.0 リファレンスマニュアル)
Rails では config/boot.rb 中で require "bundler/setup" が呼ばれると、Bundler を使う準備が行われます。
その準備の中で、discover_gems_on_require が false に切り替わり、利用する gem が Gemfile の gem だけに固定されるようになるため、上記の $LOAD_PATH の追加が行われなくなっています。
これは Rails に限った話ではなく、bundle exec 経由で Ruby を実行した場合も同様です。
以上のことから、Rails でも自分で $LOAD_PATH へ gem のパスを追加すれば、Gemfile にない gem を require できるということがわかります。
解決方法
方針として、自分のローカル環境だけに影響するよう、~/.irbrc にやりたいことを実現するコードを書くことにしました。~/.irbrc に書くことで、どの Rails プロジェクトでも共通して利用できます。
以下が今回 ~/.irbrc に書いたコードです。
# ~/.irbrc module GlobalGemLoader module_function def require!(gem_name, require_path = gem_name) add_load_paths(gem_name) require require_path end def add_load_paths(gem_name) spec = find_spec(gem_name) raise LoadError, "#{gem_name} gem is not installed" unless spec lib_path = File.join(spec.full_gem_path, "lib") return if $LOAD_PATH.include?(lib_path) $LOAD_PATH.unshift(lib_path) spec.runtime_dependencies.each { |dep| add_load_paths(dep.name) } end def find_spec(gem_name) spec = Gem::Specification.find_all_by_name(gem_name).max_by(&:version) return spec if spec Gem::Specification.dirs .flat_map { |dir| Dir.glob(File.join(dir, "#{gem_name}-*.gemspec")) } .map { |path| Gem::Specification.load(path) } .select { |s| s.name == gem_name } .max_by(&:version) end private_class_method :add_load_paths, :find_spec end
処理の流れは以下のようになっています。
require!を呼び出す- 該当する gem の gemspec オブジェクトを取得
- gemspec オブジェクトにある
libディレクトリのパスを$LOAD_PATHに追加 - 依存 gem も同様に
libディレクトリのパスを$LOAD_PATHに追加 - require を実行
find_spec メソッドは、require したい gem の gemspec オブジェクトを返すメソッドです。Gem::Specification.find_all_by_name で探しますが、Bundler は利用できる gem を Gemfile の中だけに固定するため、Gemfile にない gem はここでは見つかりません。その場合は、インストールした gem の gemspec ファイルが集められている specifications ディレクトリを探します。
add_load_paths メソッドは、find_spec で見つかった gem の lib ディレクトリを $LOAD_PATH へ追加していきます。
依存 gem がある場合はそれらも再帰的に find_spec して $LOAD_PATH へ追加しています。
使い方は GlobalGemLoader.require! 'tapp' を実行するだけです。
$ bin/rails c Loading development environment (Rails 8.1.3) >> GlobalGemLoader.require! 'tapp' => true
これを ~/.irbrc に書いておくと Rails Console を起動するだけで自動的に使えるようになるので便利です。
まとめ
Gemfile に書かれていない gem を Rails Console で使えるようにするため、GlobalGemLoader.require! を実装して使ってみました。
今回の調査で何気なく使っていた require が再定義されていたり、require がどういうときに成功し、どういうときに失敗するのかなど、深いところまで知ることができたのが良かったです。
株式会社永和システムマネジメントでは、Ruby とアジャイルソフトウェア開発を通じてコミュニティと成長したいエンジニアを絶賛募集しています。