2018-05-10

RubyGemsのinstallは何をやっているか

RubyGemsのコードリーディングをしました。バージョンは2.7.3です。今回は gem installのパターンで追ってみます。

コマンドの実行ファイルはbin/gemです。

#!/usr/bin/env ruby

# ...

begin
  Gem::GemRunner.new.run args
rescue Gem::SystemExitException => e
  exit e.exit_code
end

bin/gemはGem::GemRunner#runを呼び出します。

class Gem::GemRunner
  def run args
    build_args = extract_build_args args

    do_configuration args

    cmd = @command_manager_class.instance

# ...
    cmd.run Gem.configuration.args, build_args
  end

Gem::CommandManager#run経由でGem::Commands::InstallCommand#executeが実行されます。

class Gem::Commands::InstallCommand < Gem::Command
  def execute
# ...
    exit_code = install_gems
# ...

#install_gemsが根幹の処理になります。get_all_gem_names_and_versionsは引数に指定したgem名の配列です。

  def install_gems # :nodoc:
    exit_code = 0

    get_all_gem_names_and_versions.each do |gem_name, gem_version|
      gem_version ||= options[:version]

      begin
        install_gem gem_name, gem_version
      rescue Gem::InstallError => e
# ...
      end
    end

    exit_code
  end

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

  def install_gem name, version # :nodoc:
    return if options[:conservative] and
      not Gem::Dependency.new(name, version).matching_specs.empty?

    req = Gem::Requirement.create(version)

    if options[:ignore_dependencies] then
      install_gem_without_dependencies name, req
    else
      inst = Gem::DependencyInstaller.new options
      request_set = inst.resolve_dependencies name, req

      if options[:explain]
# ...
      else
        @installed_specs.concat request_set.install options
      end

      show_install_errors inst.errors
    end
  end

Gem::DependencyInstaller#resolve_dependenciesで取得したGem::RequestSetに対してinstallメソッドを実行しています。

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

class Gem::DependencyInstaller
  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) then
        Gem::Dependency.new spec.name, version
      elsif String === dep_or_name then
        Gem::Dependency.new dep_or_name, version
      else
        dep_or_name
      end

    dependency.prerelease = @prerelease

    request_set.import [dependency]

    installer_set.add_always_install dependency

# ... (依存関係をTSortとか使って良い感じにソート)

    request_set
  end

 全部追えなかったので全部追ううと複雑で本質から外れそうなので、Gem::Resolver::InstallerSet#add_always_installのみ解説します。

class Gem::Resolver::InstallerSet < Gem::Resolver::Set
  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

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

  def find_all req
    res = []

# ...

    res.concat @remote_set.find_all req if consider_remote?

    res
  end

@remote_setはGem::Resolver::BestSetのインスタンスです。Gem::Resolver::BestSet#find_allは以下のとおりです。

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

  def pick_sets # :nodoc:
    @sources.each_source do |source|
      @sets << source.dependency_resolver_set
    end
  end

pick_setsはGem.sourcesの要素であるGem::Sourceのインスタンスの#dependency_resolver_setを呼び出して@setsに追加します。

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

    bundler_api_uri = api_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 then
        Gem::Resolver::APISet.new response.uri
      else
        Gem::Resolver::APISet.new bundler_api_uri
      end
    end
  end

#api_uriはGem::RemoteFetcher#api_endpointを呼び出しています。

  def api_endpoint(uri)
    host = uri.host

    begin
      res = @dns.getresource "_rubygems._tcp.#{host}",
                             Resolv::DNS::Resource::IN::SRV
    rescue Resolv::ResolvError => e
# ...
    end
  end

_rubygems._tcp.rubygems.orgのSRVレコード(DNS)を取得します。rubygems.orgの場合はapi.rubygems.orgが返ります。

最終的に#dependency_resolver_setはGem::Resolver::APISetのインスタンスを返します(https://api.rubygems.org/api/v1/dependencies のGETリクエストのレスポンスはresponse.uriでしか利用されないっぽい)

@setsへの設定が終わると、Gem::Resolver::BestSet#find_allはsuperを呼び出し、Gem::Resolver::ComposedSet#find_allが呼ばれます。各々の要素であるGem::Resolver::APISetに対してfind_allが呼ばれます。

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_allは以下のように定義されています。

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

#versionsはGem::RemoteFetcher#fetch_pathでhttps://api.rubygems.org/api/v1/dependencies?gems={name}からGETリクエストでgemのデータを取得します。

  def versions name # :nodoc:
    if @data.key?(name)
      return @data[name]
    end

    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

取得したデータは以下のような構造になっています。各要素にgemの名前、バージョン、プラットフォーム、依存gemが入っています。

Marshal.load(str)
=> 
[{:name=>"json_refs",
 :number=>"0.1.2",
 :platform=>"ruby",
 :dependencies=>[["hana", ">= 0"]]},
 {:name=>"json_refs",
 :number=>"0.1.0",
 :platform=>"ruby",
 :dependencies=>[["hana", ">= 0"]]},
 {:name=>"json_refs",
 :number=>"0.1.1",
 :platform=>"ruby",
 :dependencies=>[["hana", ">= 0"]]}]

これらの情報を元にGem::Resolver::APISpecificationのインスタンスがセットされます。

Gem::InstallerSet#add_always_installに戻ると、foundにはGem::Resolver::APISpecificationのインスタンスの配列が入り、max_byによって最新のバージョンが選択されます。この最新バージョンのGem::Resolver::APISpecification#specが@always_installにセットされます。

    newest = found.max_by do |s|
      [s.version, s.platform == Gem::Platform::RUBY ? -1 : 1]
    end

    @always_install << newest.spec
  end

Gem::Resolver::APISpecification#specはGem::Source#fetch_specを呼び出します。

class Gem::Source
  def fetch_spec name_tuple
    fetcher = Gem::RemoteFetcher.fetcher

    spec_file_name = name_tuple.spec_name

    uri = api_uri + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}"

    cache_dir = cache_dir uri

# ...

    uri.path << '.rz'

    spec = fetcher.fetch_path uri
    spec = Gem.inflate spec

    if update_cache? then
      FileUtils.mkdir_p cache_dir

      open local_spec, 'wb' do |io|
        io.write spec
      end
    end

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

https://api.rubygems.org/quick/Marshal.4.8/{name}-{version}.gemspec.rz からGETでデータを取得してrzファイルを解凍します。解凍したgemspecはキャッシュとして${HOME}/.gem/specs/api.rubygems.org%443/quick/Marshal.4.8/ ディレクトリ内に保存されます。このgemspecはMarshal.dumpされているので、最終的にMarshal.loadした結果を返します。

こうして対象のgemと依存gemがgemspecから取得できるので、それらを良い感じにソートした要素をGem::RequestSetのインスタンスに入れます。Gem::RequestSet#installは以下のように定義されています。

  def install options, &block # :yields: request, installer
# ...
    sorted_requests.each do |req|
# ...
      spec =
        begin
          req.spec.install options do |installer|
            yield req, installer if block_given?
          end
        rescue Gem::RuntimeRequirementNotMetError => e
# ...
        end

      requests << spec
    end
# ...

sorted_requestsはGem::Resolver::ActivationRequestの配列でGem::Resolver::ActivationRequest#specはGem::Resolver::APISpecificationのインスタンスです。

Gem::Resolver::ApiSpacification#installはGem::Resolver::Specification#installで以下のように定義されています。

class Gem::Resolver::Specification
  def install options = {}
# ...

    gem = source.download spec, destination

    installer = Gem::Installer.at gem, options

    yield installer if block_given?

    @spec = installer.install
  end

sourceはGem::SourceのインスタンスなのでGem::Source#downloadが呼ばれます。Gem::Source#downloadはさらにGem::RemoteFetcher#downloadを呼び出します。

  def download(spec, source_uri, install_dir = Gem.dir)
    cache_dir =
      if Dir.pwd == install_dir then # see fetch_command
        install_dir
      elsif File.writable? install_dir then
        File.join install_dir, "cache"
      else
        File.join Gem.user_dir, "cache"
      end

    gem_file_name = File.basename spec.cache_file
    local_gem_path = File.join cache_dir, gem_file_name

    FileUtils.mkdir_p cache_dir rescue nil unless File.exist? cache_dir

# ...
    scheme = source_uri.scheme

    # URI.parse gets confused by MS Windows paths with forward slashes.
    scheme = nil if scheme =~ /^[a-z]$/i

    # REFACTOR: split this up and dispatch on scheme (eg download_http)
    # REFACTOR: be sure to clean up fake fetcher when you do this... cleaner
    case scheme
    when 'http', 'https', 's3' then
      unless File.exist? local_gem_path then
        begin
          verbose "Downloading gem #{gem_file_name}"

          remote_gem_path = source_uri + "gems/#{gem_file_name}"

          self.cache_update_path remote_gem_path, local_gem_path
# ...
    end

    local_gem_path
  end

色々やっていますが、ざっくり言うとhttps://api.rubygems.org/gems/{name}-{version}.gemからgemをダウンロードしてきて、cacheディレクトリに保存しています。

ダウンロード後は、ダウンロードしたローカルのファイルパスをGem::Installerのインスタンスに設定して、Gem::Installer#installを呼び出します。

  def install
# ...
    FileUtils.mkdir_p gem_dir

    if @options[:install_as_default] then
      extract_bin
      write_default_spec
    else
      extract_files

      build_extensions
      write_build_info_file
      run_post_build_hooks

      generate_bin
      write_spec
      write_cache_file
    end

    say spec.post_install_message if options[:post_install_message] && !spec.post_install_message.nil?

    Gem::Installer.install_lock.synchronize { Gem::Specification.reset }

    run_post_install_hooks

    spec

  # TODO This rescue is in the wrong place. What is raising this exception?
  # move this rescue to around the code that actually might raise it.
  rescue Zlib::GzipFile::Error
    raise Gem::InstallError, "gzip error installing #{gem}"
  end

Gem::Installer#extrace_filesはGem::Package#extract_filesを呼び出します。

class Gem::Package
  def extract_files destination_dir, pattern = "*"
    verify unless @spec

    FileUtils.mkdir_p destination_dir

    @gem.with_read_io do |io|
      reader = Gem::Package::TarReader.new io

      reader.each do |entry|
        next unless entry.full_name == 'data.tar.gz'

        extract_tar_gz entry, destination_dir, pattern

        return # ignore further entries
      end
    end
  end

gemファイルはtarアーカイブなのでtarで解凍してdata.tar.gzを取得し、gems/{name}-{version}のディレクトリに展開します。

Gem::Installer#generate_binでは#generate_bin_scriptでspec.executablesからgems/binディレクトリに実行ファイルを展開します。

  def generate_bin # :nodoc:
    return if spec.executables.nil? or spec.executables.empty?
# ...
    spec.executables.each do |filename|
# ...
      if @wrappers then
        generate_bin_script filename, @bin_dir
      else
        generate_bin_symlink filename, @bin_dir
      end

    end
  end

Gem::Installer#write_specはspec.to_ruby_for_cacheでgemspecファイルをspecificationsディレクトリに書き出します。

  def write_spec
    open spec_file, 'w' do |file|
      spec.installed_by_version = Gem.rubygems_version

      file.puts spec.to_ruby_for_cache

      file.fsync rescue nil # for filesystems without fsync(2)
    end
  end

Gem::Specification#to_ruby_for_cacheはGem::Specification#to_rubyを呼び出しており、こんな感じでごりごりとファイルを生成しています。

  def to_ruby
    mark_version
    result = []
    result << "# -*- encoding: utf-8 -*-"
    result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{platform} #{raw_require_paths.join("\0")}"
    result << "#{Gem::StubSpecification::PREFIX}#{extensions.join "\0"}" unless
      extensions.empty?
    result << nil
    result << "Gem::Specification.new do |s|"

    result << "  s.name = #{ruby_code name}"
    result << "  s.version = #{ruby_code version}"
# ...

Gem::StubSpecification::PREFIXを付けて生成することに注意してください。これはrequireする際にRubyを評価することなくgem名、バージョン、プラットフォーム、require_pathsを抽出するための仕組みになっています。

まとめ

  1. _rubygems._tcp.rubygems.orgのSRVレコード(DNS)を取得。api.rubygems.orgが返る。
  2. https://#{1のホスト名}/api/v1/dependencies?gems={name}からgemの情報(名前、バージョン、依存gemなど)を取得
  3. https://#{1のホスト名}/quick/Marshal.4.8/{name}-{version}.gemspec.rz からデータを取得してrzファイルを解凍。解凍したgemspecはキャッシュとして${HOME}/.gem/specs/api.rubygems.org%443/quick/Marshal.4.8/ ディレクトリ内に保存。
  4. 依存関係等を考慮してインストールすべきgemを決定&良い感じにソート
  5. https://api.rubygems.org/gems/{name}-{version}.gemからダウンロード
  6. gemファイルはtarアーカイブなのでtarで解凍してdata.tar.gzを取得して、gems/{name}-{version}のディレクトリにgemの中身を展開。
  7. 実行ファイル群をbinディレクトリに展開
  8. gemspecファイルをspecificationsディレクトリに書き出し。requireのstubのPREFIXを付ける。
  9. (その他インストールの色んな処理)

このエントリーをはてなブックマークに追加