普段自分が何気なく実行しているgem install
が実際にどのように行われれているのか気になったのでコードを読んで流れを追ってみました👀
※今回読んだのはrubygems
のstableなブランチを思われる3.0
のコードです。
GitHub - rubygems/rubygems at 3.0
gem installの流れ
実行コマンドの実行
まずgem install hoge
を実行するとbin/gem
が実行されコマンドライン引数(ARGV
)にinstall
とhoge
が渡されます。
そして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::RequestSet
とGem::Dependency
とGem::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_all
でGem::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::Source
はhttps://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_path
でhttps://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_sets
にGem::Resolver::APISet.new
の@sets
にGem::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_all
はGem::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_set
のalways_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
で何が行われているかを確認します。まずreq
はGem::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::Specification
にdownload
メソッドが無くて、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::Specification
にinstall
メソッドが無くて、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の流れを追ってみようかなと思います。