Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby: gem istallを実行してからgemがinstallされるまでの実装の流れを途中まで追ってみた

普段自分が何気なく実行しているgem installが実際にどのように行われれているのか気になったのでコードを読んで流れを追ってみました👀

※今回読んだのはrubygemsのstableなブランチを思われる3.0のコードです。

GitHub - rubygems/rubygems at 3.0

gem installの流れ

実行コマンドの実行

まずgem install hogeを実行するとbin/gemが実行されコマンドライン引数(ARGV)にinstallhogeが渡されます。 そしてGem::GemRunner#runコマンドライン引数を引数に実行されます。

gem install hoge
# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/bin/gem#L21
# => Gem::GemRunner.new.run ["install", "hoge"]

Gem::GemRunner#runが何をしているかというと引数をもとにconfigを実行時の引数を生成し、Gem::CommandManager#runを呼び出します。

 # https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/gem_runner.rb#L41
class Gem::GemRunner
  def initialize(options={})
    # 例外処理
    @command_manager_class = options[:command_manager] || Gem::CommandManager
    @config_file_class = options[:config_file] || Gem::ConfigFile
  end

  def run(args)
    # ...
    # 色々config周りの調整後にコマンド(install hoge)を実行
    # cmd.run (["install", "hoge"], [])
    cmd.run Gem.configuration.args, build_args
  end

Gem::CommandManager#runでは、process_argsを実行し引数から実行コマンドを特定しGem::Commands::InstallCommandのオブジェクトを取得してinvoke_with_build_argsで実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/command_manager.rb#L147

class Gem::CommandManager
  def run(args, build_args=nil)
    process_args(args, build_args) # process_args(["install", "hoge"], [])となる
    # 例外処理
  end

  def process_args(args, build_args=nil)
    # 例外処理
    case args.first # install
    when '-h', '--help' then
    # オプション周りの処理
    else
      cmd_name = args.shift.downcase # install
      cmd = find_command cmd_name # buildinコマンドからinstallがあるか探してGem::Commands::InstallCommandのオブジェクトを返す
      cmd.invoke_with_build_args args, build_args # Gem::Commands::InstallCommand#invoke_with_build_args("hoge", [])が実行される。
    end
  end

invoke_with_build_argsは、Gem::Commands::InstallCommandスーパークラスGem::Commandに定義されています。 やっていることはコマンドライン引数から実行時オプションまわりを設定や、コールバックの実行を行いGem::Commands::InstallCommand#executeを実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/command.rb#L306

class Gem::Command
  def invoke_with_build_args(args, build_args)
    handle_options args # options[:args]にargsを設定
    options[:build_args] = build_args
    # 省略
    if options[:help]
      show_help
    elsif @when_invoked
      @when_invoked.call options # callbackが設定されてたら実行(今回は関係なし)
    else
      execute # ここが実行される。
    end
  ensure
    # 例外発生時の処理
  end

Gem::Commands::InstallCommand#executeでは、install先やversion周りの指定値のチェック等の前処理を行ったあと、Gem::Commands::InstallCommand#install_gemを実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/commands/install_command.rb#L150

class Gem::Commands::InstallCommand < Gem::Command
  def execute
    # 省略
    check_install_dir # instal先のオプションのチェック
    check_version # 複数のgemをinstallしていてかつversionを指定していた場合に例外を上げる
    # 省略
    exit_code = install_gems # ここがgemのinstall処理
    show_installed
    terminate_interaction exit_code
  end

Gem::Commands::InstallCommand#install_gemsではコマンドライン引数で指定したgemの数だけGem::Commands::InstallCommand#install_gemを実行します。

例) gem install hoge fugaの場合install_gem "hoge"install gem "fuga"が実行される。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/commands/install_command.rb#L255

class Gem::Commands::InstallCommand < Gem::Command
  def install_gems # :nodoc:
    exit_code = 0
    # get_all_gem_names_and_versionsがgem install時に複数のgemやversionを指定した場合を考慮している
    # 今回は`hoge`だけ
    get_all_gem_names_and_versions.each do |gem_name, gem_version|
      # 省略

      begin
        # install処理の実態
        # install_gem("hoge", nil)が実行される
        install_gem gem_name, gem_version
        # 例外処理
      end
    end

    exit_code
  end

gemのインストール処理

Gem::Commands::InstallCommand#install_gemでは、大きくわけて下記の2つが実行されます。

  • Gem::DependencyInstaller#resolve_dependenciesによる依存gemの解決
  • Gem::RequestSet#installによるgemのinstall
# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/commands/install_command.rb#L191

class Gem::Commands::InstallCommand < Gem::Command
  def install_gem(name, version) # :nodoc:
    # 省略
    # 必要なバージョンを表すオブジェクトを取得
    req = Gem::Requirement.create(version)


    if options[:ignore_dependencies]
      install_gem_without_dependencies name, req
    else # optionを指定してないのでこっちの分岐
      inst = Gem::DependencyInstaller.new options
      # inst.resolve_dependencies("hoge", <Gem::Requirement:0x00007fb5648ccc08 @requirements=[[">=", #<Gem::Version "0">]]>)が実行される。
      request_set = inst.resolve_dependencies name, req

      if options[:explain]
        # 省略
      else # optionを指定していないのでこっちの分岐
        @installed_specs.concat request_set.install options # ここでinstall処理が実施される
      end

      show_install_errors inst.errors
    end
  end

Gem::DependencyInstaller#resolve_dependenciesによる依存gemの解決

Gem::DependencyInstaller#resolve_dependenciesでの依存gemの解決では、Gem::RequestSetGem::DependencyGem::Resolver::InstallerSetのオブジェクトを生成して、Gem::Resolver::InstallerSet#always_installでinstall対象の依存gemを取得し、最終的にGem::RequestSetのオブジェクトにまとめて返却しています。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/dependency_installer.rb#L436

class Gem::Commands::InstallCommand < Gem::Command
  def resolve_dependencies(dep_or_name, version) # :nodoc:
    request_set = Gem::RequestSet.new
    # 省略
    installer_set = Gem::Resolver::InstallerSet.new @domain
    # 省略
    # 依存関係を表すオブジェクトを生成
    dependency =
      if spec = installer_set.local?(dep_or_name)
        Gem::Dependency.new spec.name, version
      elsif String === dep_or_name
        Gem::Dependency.new dep_or_name, version
      else
        dep_or_name
      end
    dependency.prerelease = @prerelease
    request_set.import [dependency] #  install対象にgemを追加
    installer_set.add_always_install dependency
    request_set.always_install = installer_set.always_install
    # 省略
    request_set
  end

Gem::Resolver::InstallerSet#always_installでは、Gem::Resolver::DependencyRequestのオブジェクトを生成して、find_allを実行、そして@remote_set.find_allGem::Resolver::BestSet#find_allが実行される。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/installer_set.rb#L53

class Gem::Resolver::InstallerSet < Gem::Resolver::Set
  def initialize(domain)
    # 省略
    @remote_set          = Gem::Resolver::BestSet.new
    @specs               = {}
  end

  def add_always_install(dependency)
    request = Gem::Resolver::DependencyRequest.new dependency, nil
    found = find_all request
    #  省略
    newest = found.max_by do |s|
      [s.version, s.platform == Gem::Platform::RUBY ? -1 : 1]
    end

    @always_install << newest.spec
  end

 def find_all(req)
    res = []
    # 省略
    # @remote_setはGem::Resolver::BestSetのオブジェクト
    res.concat @remote_set.find_all req if consider_remote?

    res
  end

Gem::Resolver::BestSet#find_allでは、Gem::Resolver::BestSet#pick_setsが実行され、@sourcesの数だけGem::Source#dependency_resolver_setが実行される。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/best_set.rb#L28

class Gem::Resolver::BestSet < Gem::Resolver::ComposedSet
  def pick_sets # :nodoc:
    @sources.each_source do |source|
      @sets << source.dependency_resolver_set
    end
  end

  def find_all(req) # :nodoc:
    pick_sets if @remote and @sets.empty?

    super
  rescue Gem::RemoteFetcher::FetchError => e
    replace_failed_api_set e

    retry
  end

Gem::Sourcehttps://rubygems.org等のgemの取得元を表す。

  irb(main):001:0> Gem.sources
  => #<Gem::SourceList:0x00007fd3e3939f40 @sources=[#<Gem::Source:0x00007fd3e3939b80 @uri=#<URI::HTTPS https://rubygems.org/>>]>
  # デフォルトでは`https://rubygems.org`が入っているっぽい。

Gem::Source#dependency_resolver_setでは、Gem::RemoteFetcher#fetch_pathhttps://rubygems.org/api/v1/dependenciesにリクエストを投げてresponseを取得し、Gem::Resolver::APISetのオブジェクトを生成します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/source.rb#L80

class Gem::Source
  def dependency_resolver_set # :nodoc:
    return Gem::Resolver::IndexSet.new self if 'file' == uri.scheme

    bundler_api_uri = uri + './api/v1/dependencies'

    begin
      fetcher = Gem::RemoteFetcher.fetcher
      response = fetcher.fetch_path bundler_api_uri, nil, true
    rescue Gem::RemoteFetcher::FetchError
      Gem::Resolver::IndexSet.new self
    else
      if response.respond_to? :uri
        Gem::Resolver::APISet.new response.uri
      else
        Gem::Resolver::APISet.new bundler_api_uri
      end
    end
  end

Gem::Resolver::BestSet#pick_setsGem::Resolver::APISet.new@setsGem::Resolver::APISetのオブジェクトを設定したあと、 superを実行してGem::Resolver::ComposedSet#find_allを実行します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/best_set.rb#L28

class Gem::Resolver::BestSet < Gem::Resolver::ComposedSet
  def find_all(req) # :nodoc:
    pick_sets if @remote and @sets.empty?

    super
  rescue Gem::RemoteFetcher::FetchError => e
    replace_failed_api_set e

    retry
  end

Gem::Resolver::ComposedSet#find_allでは@setsの各要素に対してfind_allを呼び出すので、Gem::Resolver::APISet#find_allが実行されます。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/composed_set.rb#L53

class Gem::Resolver::ComposedSet < Gem::Resolver::Set
  def find_all(req)
    @sets.map do |s|
      s.find_all req
    end.flatten
  end

Gem::Resolver::APISet#find_allGem::Resolver::APISet#versionsを呼び出して、https://rubygems.org/api/v1/dependencies?gems=hogeにリクエストを送信し、結果から依存等を管理するGem::Resolver::APISpecificationのオブジェクトを生成します。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/api_set.rb#L46

class Gem::Resolver::APISet < Gem::Resolver::Set
  def find_all(req)
    res = []
    # 省略
    versions(req.name).each do |ver|
      if req.dependency.match? req.name, ver[:number]
        res << Gem::Resolver::APISpecification.new(self, ver)
      end
    end

    res
  end

  def versions(name) # :nodoc:
    # 省略
    uri = @dep_uri + "?gems=#{name}"
    str = Gem::RemoteFetcher.fetcher.fetch_path uri

    Marshal.load(str).each do |ver|
      @data[ver[:name]] << ver
    end

    @data[name]
  end

実際にhttps://rubygems.org/api/v1/dependencies?gems=nameにアクセスしてみるとこんな感じにgemの情報を取得できます。

require 'net/http'
require 'uri'
url = URI.parse('https://rubygems.org/api/v1/dependencies?gems=makanai')
res = Net::HTTP.get(url)
Marshal.load(res)
#=> [{:name=>"makanai", :number=>"0.1.0", :platform=>"ruby", :dependencies=>[["sqlite3", "~> 1.4.1"], ["rack", "~> 2.0.7"], ["rake", "~> 10.0"]]}]

最初に戻ると最終的にはrequest_setalways_installにinstall対象のgemのGem::Resolver::APISpecificationオブジェクトが格納されます。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/dependency_installer.rb#L436

class Gem::Commands::InstallCommand < Gem::Command
  def resolve_dependencies(dep_or_name, version) # :nodoc:
    request_set = Gem::RequestSet.new
    # 省略
    installer_set = Gem::Resolver::InstallerSet.new @domain
    # 省略
    # 依存関係を表すオブジェクトを生成
    dependency =
      if spec = installer_set.local?(dep_or_name)
        Gem::Dependency.new spec.name, version
      elsif String === dep_or_name
        Gem::Dependency.new dep_or_name, version
      else
        dep_or_name
      end
    dependency.prerelease = @prerelease
    request_set.import [dependency] #  install対象にgemを追加
    installer_set.add_always_install dependency
    request_set.always_install = installer_set.always_install
    # 省略
    request_set
  end

Gem::RequestSet#installによるgemのinstall

Gem::RequestSet#installでは、マルチスレッドでの

  • Gemのダウンロード(req.spec.download)
  • Gemのインストール(req.spec.install)

が実行されます。

class Gem::RequestSet
 def install(options, &block) # :yields: request, installer
    # 省略
    # マルチスレッドでダウンロードするためのQueueに並び替え済みの中身を入れる
    sorted_requests.each do |req|
      download_queue << req
    end

    # configの設定値の数だけThreadを生成してgemをダウンロード
    threads = Gem.configuration.concurrent_downloads.times.map do
      download_queue << :stop
      Thread.new do
        while req = download_queue.pop
          break if req == :stop
          # ダウンロードを実施
          req.spec.download options unless req.installed?
        end
      end
    end

    # ダウンロードが終わるまで待つ
    threads.each(&:value)

    # Install requested gems after they have been downloaded
    sorted_requests.each do |req|
      if req.installed?
        # install済みだったらinstallしない
      end

      spec =
        begin
          # installを実施
          req.spec.install options do |installer|
            yield req, installer if block_given?
          end
        rescue Gem::RuntimeRequirementNotMetError => e
        # 省略
        end

      requests << spec
    end
    # 省略
    requests
  end

Gemのダウンロード(req.spec.download)

まずはreq.specで何が行われているかを確認します。まずreqGem::DependencyInstaller#resolve_dependenciesによる依存gemの解決で格納されたGem::Resolver::APISpecificationのオブジェクトです。

Gem::Resolver::APISpecification#specを見てみましょう。内部ではsource.fetch_specを実行してGem::Source#fetch_specを実行しています。

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/resolver/api_specification.rb#L73
class Gem::Resolver::APISpecification < Gem::Resolver::Specification
  def spec # :nodoc:
    @spec ||=
      begin
        tuple = Gem::NameTuple.new @name, @version, @platform
        source.fetch_spec tuple
      rescue Gem::RemoteFetcher::FetchError
        raise if @original_platform == @platform

        tuple = Gem::NameTuple.new @name, @version, @original_platform
        source.fetch_spec tuple
      end
  end

Gem::Source#fetch_specでは、fetcher.fetch_path source_uriを実行してhttps://api.rubygems.org/quick/Marshal.4.8/makanai-0.1.0.gemspec.rzのようなURLにリクエストを投げて、(.rz)形式で取得してパースする、

# https://github.com/rubygems/rubygems/blob/9c142f5d9d7576012a440dceb8335c3139fcc16f/lib/rubygems/source.rb#L129
class Gem::Source
  def fetch_spec(name_tuple)
    fetcher = Gem::RemoteFetcher.fetcher
    spec_file_name = name_tuple.spec_name
    # 省略
    source_uri = uri + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}"
    source_uri.path << '.rz'

    spec = fetcher.fetch_path source_uri
    spec = Gem::Util.inflate spec
    # 省略

    # TODO: Investigate setting Gem::Specification#loaded_from to a URI
    Marshal.load spec
  end

実際にMarshal.load specの箇所を再現すると、どんな値が設定されているかがわかる。

irb(main):007:0> pp Marshal.load(Gem::Util.inflate(File.read('/Users/user_name/Downloads/makanai-0.1.0.gemspec.rz')))
=> #<Gem::Specification:0x00007ff28f1f7410 @extension_dir=nil, @full_gem_path=nil, @gem_dir=nil, @ignored=nil, @bin_dir=nil, @cache_dir=nil, @cache_file=nil, @doc_dir=nil, @ri_dir=nil, @spec_dir=nil, @spec_file=nil, @gems_dir=nil, @base_dir=nil, @loaded=false, @activated=false, @loaded_from=nil, @original_platform="ruby", @installed_by_version=#<Gem::Version "0">, @autorequire=nil, @date=2019-10-22 00:00:00 UTC, @description="simple web application framework for learning.", @email=["madogiwa0124@gmail.com"], @homepage="https://github.com/Madogiwa0124/makanai", @name="makanai", @post_install_message=nil, @signing_key=nil, @summary="simple web application framework for learning.", @version=#<Gem::Version "0.1.0">, @authors=["Madogiwa"], @bindir="bin", @cert_chain=[], @dependencies=[<Gem::Dependency type=:runtime name="rake" requirements="~> 10.0">, <Gem::Dependency type=:runtime name="rack" requirements="~> 2.0.7">, <Gem::Dependency type=:runtime name="sqlite3" requirements="~> 1.4.1">, <Gem::Dependency type=:development name="rubocop" requirements="~> 0.74">, <Gem::Dependency type=:development name="bundler" requirements="~> 2.0">, <Gem::Dependency type=:development name="rspec" requirements="~> 3.0">], @executables=[], @extensions=[], @extra_rdoc_files=[], @files=[], @licenses=[], @metadata={"homepage_uri"=>"https://github.com/Madogiwa0124/makanai", "source_code_uri"=>"https://github.com/Madogiwa0124/makanai"}, @platform="ruby", @rdoc_options=[], @require_paths=["lib"], @required_ruby_version=#<Gem::Requirement:0x00007ff28f1e7e20 @requirements=[[">=", #<Gem::Version "0">]]>, @required_rubygems_version=#<Gem::Requirement:0x00007ff28f1e7448 @requirements=[[">=", #<Gem::Version "0">]]>, @requirements=[], @rubygems_version="3.0.3", @specification_version=4, @test_files=[], @new_platform="ruby", @has_rdoc=true, @license=["MIT"] makanai-0.1.0>

なので、パースしたSpecificationのオブジェクトに基づいてダウンロード処理が実行されるはず・・・。 ※このへんGem::Specificationdownloadメソッドが無くて、req.spec.downlaodが追えなくなってしまった。。。。

おそらくこの辺が呼ばれてgemの本体がダウンロードされている・・・?

rubygems/specification.rb at 9c142f5d9d7576012a440dceb8335c3139fcc16f · rubygems/rubygems · GitHub

rubygems/source.rb at 9c142f5d9d7576012a440dceb8335c3139fcc16f · rubygems/rubygems · GitHub

Gemのインストール(req.spec.install)

パースしたSpecificationのオブジェクトに基づいてインストール処理が実行されるはず・・・。 ※このへんGem::Specificationinstallメソッドが無くて、req.spec.installが追えなくなってしまった。。。。

おそらくこの辺が呼ばれてgemの本体がインストールされている・・・?

rubygems/specification.rb at 9c142f5d9d7576012a440dceb8335c3139fcc16f · rubygems/rubygems · GitHub

おわりに

今回はgem installを実行したときにrubygemsがどのようにgemをインストールしているかを実際の実装を読んで追ってみました。 ※ダウンロードやインストール周りの処理は最後まで終えませんでしたが、どうやってgemspecを取得して依存関係を探るか等は勉強になりました。

考えてみたら当たり前ですが、rubygems.orgにリクエストを投げて依存関係等を取得しているんですね👀 なのでgemspecに記載しているメタ情報を元にgemのインストールが行われるので、ちゃんとメンテナンスしておかないと使う人がgem installしたときに、依存gemがinstallされてなくて動かない、またちゃんとrubygemsにアップロードしないと修正が反映されない等が発生してしまうので注意ですね⚠

またrubygemsのコードは比較的pureなrubyで書かれていてtestを動かすのも簡単で処理は複雑でしたが読みやすかったです💎

次はbundlerのinstallの流れを追ってみようかなと思います。

参考

github.com

blog.freedom-man.com