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