RubyGemsのrequireのコードリーディングをしました。バージョンは2.7.3です。

RubyGemsのデバッグ方法

デフォルトで自動的にrubygemsがrequireされるので、RUBYOPTにrequireを無効化するオプションを指定します。また、今回はデバッガを仕込みたいのでgit cloneしてきたものをロードしています。そのため、RUBYLIBに${RubyGemsのリポジトリのパス}/libを設定しました。

また、byebugを入れようとするとbyebugをロードするためのrequireでさらにbyebugを呼び出そうとしてエラーになるので、byebugを起動する条件をうまく絞り込む必要があります。こんな感じ↓

このようにデバッガを仕込んだ上で

を呼び出すことで、rakeのrequireにのみbyebugが効きます。あとはステップ実行でコードを追っていきます。

コードリーディング

今回は3rd partyのgemをrequireしたときの挙動を追っていきます。

requireはlib/rubygems/core_ext/kernel_require.rbに定義されています(オリジナルのrequireを上書きして再定義しています)。まずはRuby本体のrequireに対してgem_original_requireの名前でaliasを貼ります。

オーバーライドしているKernel.#requireの定義は以下の通りです。

最初はGem::Specification.unresolved_deps.empty? = trueなのでgem_original_requireをそのまま呼び出します。ロードパスに対象のファイルがなければLoadErrorがraiseされます。

LoadErrorはrescueによって補足されます。rescue内の処理は以下のように定義されています。

load_error.messageはcannot load such file -- {gem名}というようなメッセージになります。if文の2つ目の条件式が評価され、Gem.try_activateが呼ばれます。

Gem.try_activateは以下のように定義されています。

Gem::Specification.find_by_pathは以下のように定義されています。stubs=gemspecのスタブの中で対象のファイルが存在すれば、それを返します。

stubsはspecificationsディレクトリ内にある拡張子がgemspecのもの(=インストール済みのrubygemのgemspec)を検索してGem::StubSpecificationのインスタンスを返します。

default_stubsはspecifications/defaultのディレクトリ内にあるgemspecを拾ってStubSpecificationのインスタンスを生成します。defaultディレクトリには csv やjsonなどの標準ライブラリが入っています。

installed_stubsはインストール済みの3rd partyのgemのgemspecを”#{Gem.path}/gems/specifications”から取得してインスタンスを生成します。

stubs内の#contains_requirable_file?がtrueであるGem::StubSpecificationを返しています。#contains_requirable_file?はGem::BasicSpecificationに定義されており#have_file?を呼びます。

#have_file?で呼び出しているraw_require_pathsはdata.require_pathsを返します。

#dataは以下のように定義されています。

specificationsディレクトリ内のgemspecファイルは以下のようなコメントが付いています。

stubのprefixが付いている場合は、そのコメントを引数にStubLineをインスタンス化します。

rake 12.3.0 ruby libの場合、nameがruby、versionが12.3.0(Gem::Versionのインスタンスが入る)、platformがruby、require_pathsがlibに設定されます。

よってrakeの場合、raw_require_pathsには[“lib”]が入ります。このraw_require_pathsに対してループを回して”gems/#{name}-#{version}/lib/#{file}” のファイルを検索して存在すればtrueを返すことになります。このようにしてファイルの存在チェックを行っています。

最終的にfind_by_pathではGem::StubSpecification#to_specが呼ばれます。

Gem::Specification.loadでgemspecファイルを読み込んでいます。stubは# stubのコメント部分だけを読んでSpecificationを作成する一方で、loadメソッドはGem::Specificationのインスタンスを生成します。

loadメソッドでやっていることはgemspecのファイルを読んでevalしてGem::Specificationのインスタンスであればそのまま返しています。

ここでインスタンス化したGem::Specificationに対して#activateが呼ばれます。

add_self_to_load_pathは以下のような定義になっています。full_require_pathsはその名の通りrequire_pathsのフルパスです。$LOAD_PATH.insertでfull_require_pathsが$LOAD_PATHに追加されます。

ちなみにGem.load_path_insert_indexはRbConfig::CONFIG[‘sitelibdir’]のインデックスで、$LOAD_PATHはlib/ruby/site_ruby/#{version}の直前に差し込まれることになります。

依存関係にあるgemもGem::Specification#activate_dependenciesによって$LOAD_PATHに追加されます。

#runtime_dependenciesはgemspec内でadd_runtime_dependencyメソッドで追加したGem::Dependencyのインスタンスが入っています。

Gem::Dependency#to_specsは以下のように定義されています。

#matching_specsによって対象のGem::StubSpecificationを取得しています。

#matching_specsは以下のように定義されています。

Gem::Specification.stubs_forで対象のgemspecを検索して、requirementsに合ったバージョンのgemを抽出し、#to_specしたものをmatches変数に格納しています。

matchesの配列が1つであれば、その要素のGem::Specification#activateを実行して$LOAD_PATHにrequire_pathを追加します。

matchesの配列が2つ以上の場合はGem.unresolved_depsにGem::Dependencyが追加されます。

この場合は、各gemでrequireしたときにkernel_require.rbの以下の処理で$LOAD_PATHに追加されます。

Gem::Specification.find_in_unresolvedはDependencyに対して#to_specsを呼び出して#contains_requirable_file?でフィルタした結果を返します。

#to_specsは#stubs_forを使っており、#stubs_forはバージョンが高いgemが先頭に来るようにソートされています。

found_specs.findで#has_conflicts?ではないgemで一番バージョンが高いgemに対して#activateしています。

まとめ

  1. Kernel.requireを再定義。再定義したrequire内でオリジナルのrequireを呼ぶ
  2. $LOAD_PATHから対象のファイルが見つからなければLoadErrorがraiseされる
  3. 再定義したKernel.require内でLoadErrorをrescue
  4. stubsからマッチするgemを検索
    (stubsはspecificationsディレクトリ内のgemspecファイルから生成。この時点ではgemspecを全ロードせずstubコメントから読み取り)
  5. gemspecに設定されたrequire_pathを使ってrequireの引数になっているパスの解決を試みる
  6. パスが解決できれば対象のgemspecをロードしてspecをactivate
  7. activateすると対象のgemのrequire_pathのフルパスが$LOAD_PATHに載る。依存gemも$LOAD_PATHに載る(ただし依存gemが複数バージョンある場合は依存gemがrequireされたタイミングで$LOAD_PATHに追加される)
  8. 再度オリジナルのrequireを実行してロード