#contents *はじめに [#g24116ef] Ruby on Railsをインストールする場合、以下のコマンドを入力することになります。 gem install rails --include-dependencies この一行によって何が起こるかを見ていきましょう。ちなみに、私はgemを使ったのは初めてだったので以下の出力が表示されました。 Bulk updating Gem source index for: http://gems.rubyforge.org Successfully installed rails-1.2.3 Successfully installed rake-0.7.3 Successfully installed activesupport-1.4.2 Successfully installed activerecord-1.15.3 Successfully installed actionpack-1.13.3 Successfully installed actionmailer-1.3.3 Successfully installed actionwebservice-1.2.3 今回対象としたバージョンは0.9.4です。 *Gem::Version::Requirement(rubygems/version.rb) [#c992ddaa] gemコマンドを見ると初めにRubyのバージョンチェックが行われています。 required_version = Gem::Version::Requirement.new(">= 1.8.0") unless required_version.satisfied_by?(Gem::Version.new(RUBY_VERSION)) puts "Expected Ruby Version #{required_version}, was #{RUBY_VERSION}" exit(1) end **オブジェクトの構築 [#lcd9e0ea] Gem::Version::Requirementを見てみましょう。早速おもしろいコードがあります。 class Requirement OPS = { "=" => lambda { |v, r| v == r }, "!=" => lambda { |v, r| v != r }, ">" => lambda { |v, r| v > r }, "<" => lambda { |v, r| v < r }, ">=" => lambda { |v, r| v >= r }, "<=" => lambda { |v, r| v <= r }, "~>" => lambda { |v, r| v >= r && v < r.bump } } OP_RE = Regexp.new(OPS.keys.collect{|k| Regexp.quote(k)}.join("|")) これらの定数はメソッドの外に書かれているため、Rubyインタプリタが読み込んだときにすぐ評価され演算子解析用正規表現が構築されます。 次に、Requirement#initializeメソッドとinitializeメソッドが呼んでいるparseメソッドです。 def initialize(reqs) @requirements = reqs.collect do |rq| op, version_string = parse(rq) [op, Version.new(version_string)] end @version = nil # Avoid warnings. end def parse(str) if md = /^\s*(#{OP_RE})\s*([0-9.]+)\s*$/.match(str) [md[1], md[2]] elsif md = /^\s*([0-9.]+)\s*$/.match(str) ["=", md[1]] elsif md = /^\s*(#{OP_RE})\s*$/.match(str) [md[1], "0"] else fail ArgumentError, "Illformed requirement [#{str}]" end end 渡された引数から演算子とバージョンを取り出しています。数字だけ指定すると等価比較になるようです。演算子だけ指定した場合は・・・意味があるのでしょうか?:-) 引数エラーでいいような。 **バージョンチェック処理 [#p6eda074] 次にバージョンチェック部分に移ります。以下のようになっています。 def satisfied_by?(version) normalize @requirements.all? { |op, rv| satisfy?(op, version, rv) } end all?メソッドって知らないなと思ったら1.8で追加されたらしいです。ということは・・・スクリプトが実行できたら調べるまでもなく1.8ってことですよね:-) それはともかくsatisfy?メソッドです。 def satisfy?(op, version, required_version OPS[op].call(version, required_version) end 初めに見たOPSハッシュを参照してProcオブジェクトを呼び出しています。このような仕掛けでRubyGemsはこれ以外のバージョン要求についても処理しているようです。 ここまで呼んできてわかったこととして、RubyGemsではpublicメソッドとprivateメソッドを分けて書いているようです。個人的にはpublicメソッドから使われるprivateメソッドは使う側のすぐ下に書くとスクロール量が少なくて済むので好きなのですが。 *Gem::GemRunner(rubygems/gem_runner.rb) [#g5fa53ac] gemコマンドに戻って、最後の一行でGem::GemRunnerオブジェクトを作成しrunメソッドを呼び出しています。[[青木さん添削本>http://i.loveruby.net/ja/rubimabook/]]によるとGem::GemRunnerというのは少し冗長ですね。 他のメソッドで出てくるので、各インスタンス変数に何が入っているかを示しておきます。 def initialize(options={}) @command_manager_class = options[:command_manager] || Gem::CommandManager @config_file_class = options[:config_file] || Gem::ConfigFile @doc_manager_class = options[:doc_manager] || Gem::DocManager end 次にrunメソッドです。 def run(args) do_configuration(args) cmd = @command_manager_class.instance cmd.command_names.each do |c| Command.add_specific_extra_args c, Array(Gem.configuration[c]) end cmd.run(Gem.configuration.args) end do_configurationは[[YAML形式>http://jp.rubyist.net/magazine/?0009-YAML]]で書かれた設定ファイルを読み込んでいます。が、設定ファイルはないので無視します。 *Gem::CommandManager(rubygems/command_manager.rb) [#t6112996] **オブジェクトの構築 [#qd5d601a] 次にGem::CommandManagerをインスタンス化してます。単純にnewしていないところを見るとシングルトンパターンっぽいです。見てみましょう。 def self.instance @command_manager ||= CommandManager.new end 何故これがシングルトンパターンを実装することになるかは||=演算子のからくりを知らないとわかりません。以下のような挙動になります。 :一回目|@command_managerがnullなのでCommandManager.newが実行され@command_managerに格納、@command_managerが返される :二回目|@command_managerはnullではないのでCommandManager.newは実行されず、@command_managerが返される さて、CommandManager#initializeメソッドに移るとRubyGemsの各コマンドが登録されているようです。登録メソッドを見てみましょう。 def register_command(command_obj) @commands[command_obj] = load_and_instantiate(command_obj) end def load_and_instantiate(command_name) command_name = command_name.to_s begin Gem::Commands.const_get("#{command_name.capitalize}Command").new rescue require "rubygems/commands/#{command_name}_command" retry end end load_and_instantiateメソッドでは各コマンドを実装するオブジェクトが作られています。Gem::Commandsの各コマンド処理クラスっていつの間に定義されたんだっけ?と一瞬思ったのですがすぐ下ですね。つまり以下のような動きをするようです。 +例えば、installコマンドを処理するGem::Commands::InstallCommandをインスタンス化しようとする +定義されていないので下のrescueブロックが実行される +rubygems/commands/install_command.rbが読み込まれる +Gem::Commands::InstallCommandが定義される +もう一度Gem::Commands::InstallCommandを作ろうとすると今度はインスタンスかできる 拡張可能なように設計するという場合のイディオムな感じですね。個人的にはinitializeメソッドで登録メソッドを呼ぶのではなくrubygems/commands以下のものを勝手に登録してしまった方が漏れがなくていいかなと思うのですが。 そういえば何でクラスを取得するのにGem::Commandsの定数を取得してるんだ?と思われた方がいるかもしれませんが、それはRubyではクラスは定数として定義されているためです。 **コマンドの検索 [#rc52d39d] GemRunner#runメソッドに戻ると、次の行は設定ファイルに書かれているコマンド別の設定を設定(紛らわしい)しているだけなので飛ばします。 次にGem::CommandManager#runメソッドに移ります。runメソッドはprocess_argsメソッドを呼んでいるだけ(例外処理はしていますが)で、install --include-dependenciesとした場合、process_argsメソッドでは以下のコードが実行されます。 cmd_name = args.shift.downcase cmd = find_command(cmd_name) cmd.invoke(*args) find_commandメソッドの中身がおもしろいので見てみましょう。 def find_command(cmd_name) possibilities = find_command_possibilities(cmd_name) if possibilities.size > 1 raise "Ambiguous command #{cmd_name} matches [#{possibilities.join(', ')}]" end if possibilities.size < 1 raise "Unknown command #{cmd_name}" end self[possibilities.first] end def find_command_possibilities(cmd_name) len = cmd_name.length self.command_names.select { |n| cmd_name == n[0,len] } end 入力された文字列からどのコマンドっぽいかを検索しています。どのコマンドっぽいかというのは例えば、 gem l と入力した場合、listだけがArray#selectメソッドのブロックでtrueになるのでこの人はlistを指定したかったんだなということです。 gem s だとsource, search, specificationの3つが該当します。この場合はどのコマンドを実行すればいいかわからないのでエラーになります。 *Gem::Command(rubygems/command.rb) [#dac53b6f] というわけで実行するコマンドが判別できたら各コマンド実行クラスのinvokeメソッドが呼ばれます。今回対象とするのはGem::Commands::InstallCommandです。 **オブジェクトの構築 [#ib69c3bb] invokeメソッドの前にinitializeメソッド周りを見てみましょう。 class InstallCommand < Command include CommandAids include VersionOption include LocalRemoteOptions include InstallUpdateOptions def initialize super( 'install', 'Install a gem into the local repository', { :domain => :both, :generate_rdoc => true, :generate_ri => true, :force => false, :test => false, :wrappers => true, :version => "> 0", :install_dir => Gem.dir, :security_policy => nil, }) add_version_option('install') add_local_remote_options add_install_update_options end 親クラスのコンストラクタを呼んでいます。第1引数はコマンド名、第2引数はコマンドの説明とすぐわかりますが第3引数はちょっとわかりません。親クラスのinitialize見てみると、 def initialize(command, summary=nil, defaults={}) @command = command @summary = summary @program_name = "gem #{command}" @defaults = defaults @options = defaults.dup @option_list = [] @parser = nil @when_invoked = nil end となっているので第3引数はオプションで変更可能な値のデフォルト値と推察されます。 次にadd_version_optionメソッドですがこれは上でincludeしているVersionOptionモジュール(rubygems/gem_commands.rb)で定義されています。 def add_version_option(taskname, *wrap) add_option('-v', '--version VERSION', "Specify version of gem to #{taskname}", *wrap) do |value, options| options[:version] = value end end add_local_remote_options, add_install_update_optionsについても同様の処理が行われています。ちなみに、--include-dependenciesオプションはadd_install_update_optionsメソッドで定義されるようです。add_optionメソッドはCommandクラスで定義されています。 def add_option(*args, &handler) @option_list << [args, handler] end **コマンドの実行 [#bc1f9749] それでは次にCommandManagerから呼ばれるinvokeメソッドを見てみましょう。InstallCommandクラスにはinvokeメソッドがなく親クラスのCommandクラスに定義されています。 def invoke(*args) handle_options(args) if options[:help] show_help elsif @when_invoked @when_invoked.call(options) else execute end end handle_optionsメソッドは次のようになっています。 def handle_options(args) args = add_extra_args(args) @options = @defaults.clone parser.parse!(args) @options[:args] = args end extra_argsは設定ファイルに書かれていた場合に追加の引数を指定するものなようなので無視します。次にparserですが、実はこれは変数ではなくメソッドです。う〜む、get_parserという名前にすべきだと思います。ともかくparserメソッドに移りましょう。 def parser create_option_parser if @parser.nil? @parser end パーサが作られていなかったら作成、作られていたら単純に返しています。先ほど||=を使ってシングルトンパターンを実装していたのの別パターンですね。create_option_parserメソッドではOptionParserオブジェクトを淡々と構築しています。 *Gem::Commands::InstallCommand(rubygems/commands/install_command.rb) [#f8a3267d] invokeメソッドに戻るとexecuteメソッドが呼ばれます。Commandクラスの子クラスはこのメソッドをオーバーライドして処理を実装するようです。 executeメソッドの前半はローカルにgemファイルがある場合の処理のようです。今回はリモートからgemファイルを拾ってくるので無視しましょう。とするとインストール処理を行っているのは次の部分のようです。 installer = Gem::RemoteInstaller.new(options) installed_gems = installer.install( gem_name, options[:version], options[:force], options[:install_dir]) if installed_gems installed_gems.compact! installed_gems.each do |spec| say "Successfully installed #{spec.full_name}" end end *Gem::RemoteInstaller(rubygems/remote_installer.rb) [#y2f0ff6b] ではGem::RemoteInstaller#installメソッドです。まず初めにRubyにあまり詳しくない方のために、 unless version_requirement.respond_to?(:satisfied_by?) version_requirement = Version::Requirement.new [version_requirement] end version_requirementはStringオブジェクトを渡すことも可能なのですが後の処理ではGem::Version::Requirementとして扱いたいのでStringオブジェクトからGem::Version::Requirementオブジェクトを構築しています。Gem::Version::Requirementオブジェクトかのチェックにはオブジェクトがsatisfied_by?メソッドを実装しているかでチェックしています。以前見たsoap4rではis_a?メソッドが使われていました。 さて本題の部分です。 spec, source = find_gem_to_install(gem_name, version_requirement) dependencies = find_dependencies_not_installed(spec.dependencies) installed_gems << install_dependencies(dependencies, force, install_dir) cache_dir = @options[:cache_dir] || File.join(install_dir, "cache") destination_file = File.join(cache_dir, spec.full_name + ".gem") download_gem(destination_file, source, spec) installer = new_installer(destination_file) installed_gems.unshift installer.install(force, install_dir, install_stub) 依存関係を見てインストールしてから指定されたパッケージを入れるというセオリー通りの処理が行われています。 依存関係を見て依存パッケージをインストールしてから指定されたパッケージを入れるというセオリー通りの処理が行われています。 **パッケージ情報の取得 [#a114a648] まずfind_gem_to_installメソッドを見てみましょう。 def find_gem_to_install(gem_name, version_requirement) specs_n_sources = specs_n_sources_matching gem_name, version_requirement top_3_versions = specs_n_sources.map{|gs| gs.first.version}.uniq[0..3] specs_n_sources.reject!{|gs| !top_3_versions.include?(gs.first.version)} binary_gems = specs_n_sources.reject { |item| item[0].platform.nil? || item[0].platform==Platform::RUBY } # only non-binary gems...return latest return specs_n_sources.first if binary_gems.empty? find_gem_to_installメソッドの残りではバイナリgemの処理が行われているようですが今回入れたパッケージは全てバイナリgemではないので無視します。ともかく最新バージョンのパッケージ情報が返されるようです。次に、specs_n_sources_matchingメソッドに進みましょう。ところで、メソッド呼び出しなのに()を付けないのは減点1ですね:-) def specs_n_sources_matching(gem_name, version_requirement) specs_n_sources = [] source_index_hash.each do |source_uri, source_index| specs = source_index.search(/^#{Regexp.escape gem_name}$/i, version_requirement) # TODO move to SourceIndex#search? ruby_version = Gem::Version.new RUBY_VERSION specs = specs.select do |spec| spec.required_ruby_version.nil? or spec.required_ruby_version.satisfied_by? ruby_version end specs.each { |spec| specs_n_sources << [spec, source_uri] } end specs_n_sources = specs_n_sources.sort_by { |gs,| gs.version }.reverse specs_n_sources end def source_index_hash return @source_index_hash if @source_index_hash @source_index_hash = {} Gem::SourceInfoCache.cache_data.each do |source_uri, sic_entry| @source_index_hash[source_uri] = sic_entry.source_index end @source_index_hash end どうやらリモートのパッケージ一覧をキャッシュしてそこから要求されたパッケージを探しているようです。次にGem::SourceInfoCache周りを見てみましょう。ところで、gsってGemSpecの略なんですね。GhostScript?と思ってしまいました。紛らわしいので減点1:-) *Gem::SourceInfoCache(rubygems/source_info_cache.rb) [#l3782b85] Gem::SourceInfoCache.cache_dataから始まる呼び出しは以下のようになっています。refreshメソッドの最後のflushメソッドでリモートパッケージ一覧がキャッシュされているようです。 def self.cache_data cache.cache_data end def self.cache return @cache if @cache @cache = new @cache.refresh @cache end def refresh Gem.sources.each do |source_uri| cache_entry = cache_data[source_uri] if cache_entry.nil? then cache_entry = Gem::SourceInfoCacheEntry.new nil, 0 cache_data[source_uri] = cache_entry end cache_entry.refresh source_uri end update flush end 今回は初めてgem installしたのでSourceInfoCache#cache_dataメソッドは単純に空ハッシュを返します(そのため、ifブロックが実行されてGem::SourceInfoCacheEntryオブジェクトが作成されます)。 Gem.sourcesってどこに定義されているんだ?と探したところ、RubyGemsをインストールしたときに同時にインストールされるsourcesパッケージで定義されていました。 *Gem::SourceIndex(rubygems/source_index.rb) [#nb885523] Gem::SourceInfoCacheEntry#refreshメソッドです。content-lengthの値で更新されてないかを判断しています。last-modifiedを見た方がいい気がします。 def refresh(source_uri) remote_size = Gem::RemoteFetcher.fetcher.fetch_size source_uri + '/yaml' return if @size == remote_size # HACK bad check, local cache not YAML @source_index.update source_uri @size = remote_size end Gem::SourceIndexクラスに移りましょう。 def update(source_uri) use_incremental = false begin gem_names = fetch_quick_index source_uri remove_extra gem_names missing_gems = find_missing gem_names use_incremental = missing_gems.size <= INCREMENTAL_THRESHHOLD rescue Gem::OperationNotSupportedError => ex use_incremental = false end if use_incremental then update_with_missing source_uri, missing_gems else new_index = fetch_bulk_index source_uri @gems.replace new_index.gems end self end 以前パッケージ情報を取得したときから増えているパッケージが一定数以下なら差分アップデートをしているようです。今回はキャッシュはないのでfetch_bulk_indexメソッドに移ります。 以前パッケージ情報を取得したときから増えているパッケージが一定数以下なら個別にパッケージ情報を取得、一定数以上ならパッケージリスト全体を取得しているようです。今回はキャッシュはないのでfetch_bulk_indexメソッドに移ります。 def fetch_bulk_index(source_uri) say "Bulk updating Gem source index for: #{source_uri}" begin yaml_spec = fetcher.fetch_path source_uri + '/yaml.Z' yaml_spec = unzip yaml_spec rescue begin yaml_spec = fetcher.fetch_path source_uri + '/yaml' end end convert_specs yaml_spec end どうやらパッケージ情報はYAMLで書かれているようです。長いのでサンプルを載せるのは止めておきます。 え〜っと・・・、ここまででパッケージ情報が取得できたので次はsearchメソッドですね。特に説明は要らないかと思います。 def search(gem_pattern, version_requirement=Version::Requirement.new(">= 0")) gem_pattern = /#{ gem_pattern }/i if String === gem_pattern version_requirement = Gem::Version::Requirement.create(version_requirement) result = [] @gems.each do |full_spec_name, spec| next unless spec.name =~ gem_pattern result << spec if version_requirement.satisfied_by?(spec.version) end result = result.sort result end *再びGem::RemoteInstaller [#rc03388a] **インストールされていない依存パッケージの検索 [#n167fb09] Gem::RemoteInstaller#installメソッドに戻ります。次はfind_dependencies_not_installedメソッドです。 def find_dependencies_not_installed(dependencies) to_install = [] dependencies.each do |dependency| srcindex = Gem::SourceIndex.from_installed_gems matches = srcindex.find_name(dependency.name, dependency.requirement_list) to_install.push dependency if matches.empty? end to_install end そんな感じかなというところです。もちろん、必要がないのに「何やってるんだ?このコードは??」というコードを書く必要はありません。 Gem::SourceIndex#from_installed_gemsメソッドおよび呼ばれているメソッド達です(例外処理省略済み)。 def from_installed_gems(*deprecated) if deprecated.empty? from_gems_in(*installed_spec_directories) else from_gems_in(*deprecated) end end def from_gems_in(*spec_dirs) self.new.load_gems_in(*spec_dirs) end def load_gems_in(*spec_dirs) @gems.clear specs = Dir.glob File.join("{#{spec_dirs.join(',')}}", "*.gemspec") specs.each do |file_name| gemspec = self.class.load_specification(file_name.untaint) add_spec(gemspec) if gemspec end self end def load_specification(file_name) spec_code = File.read(file_name).untaint gemspec = eval(spec_code) gemspec.loaded_from = file_name return gemspec end gemspecファイルを見るとわかりますがgemspecファイルはRubyスクリプトでGem::Specificationオブジェクトが定義されています。 **依存パッケージのインストール [#d129ec81] Gem::RemoteInstaller#installメソッドに戻って、次のinstall_dependenciesメソッドです。RemoteInstallerを作ってinstallメソッドを呼び出すことで依存パッケージが依存するパッケージも適切にインストールされます。 def install_dependencies(dependencies, force, install_dir) return if @options[:ignore_dependencies] installed_gems = [] dependencies.each do |dep| if @options[:include_dependencies] || ask_yes_no("Install required dependency #{dep.name}?", true) remote_installer = RemoteInstaller.new @options installed_gems << remote_installer.install(dep.name, dep.version_requirements, force, install_dir) elsif force then # ignore else raise DependencyError, "Required dependency #{dep.name} not installed" end end installed_gems end つまりこういうことです。 +aパッケージをインストール。b, cに依存(RemoteInstaller.install('a')) ++bパッケージインストール。d, eに依存(RemoteInstaller.install('b')) +++dパッケージをインストール(RemoteInstaller.install('d')) +++eパッケージをインストール(RemoteInstaller.install('e')) ++cパッケージをインストール(RemoteInstaller.install('c')) *Gem::Installer(rubygems/installer.rb) [#z88a792c] 次にリモートからファイルをダウンロードしてくると、後はローカルにファイルがある場合と同じように処理できます。というわけで次はGem::Installerクラスです。installメソッドは例外処理とかを省くと以下のようになります。 def install(force=false, install_dir=Gem.dir, ignore_this_parameter=false) format = Gem::Format.from_file_by_path @gem, security_policy # Build spec dir. @directory = File.join(install_dir, "gems", format.spec.full_name).untaint FileUtils.mkdir_p @directory extract_files(@directory, format) generate_bin(format.spec, install_dir) build_extensions(@directory, format.spec) # Build spec/cache/doc dir. build_support_directories(install_dir) # Write the spec and cache files. write_spec(format.spec, File.join(install_dir, "specifications")) unless File.exist? File.join(install_dir, "cache", @gem.split(/?//).pop) FileUtils.cp @gem, File.join(install_dir, "cache") end puts format.spec.post_install_message unless format.spec.post_install_message.nil? format.spec.loaded_from = File.join(install_dir, 'specifications', format.spec.full_name+".gemspec") return format.spec end *Gem::Package(rubygems/package.rb) [#w849937a] まず一行目のGem::Format.from_file_by_pathメソッドから。例外処理と旧フォーマットかのチェックの後from_ioメソッドが呼ばれています。 def self.from_io(io, gem_path="(io)", security_policy = nil) format = self.new(gem_path) Package.open_from_io(io, 'r', security_policy) do |pkg| format.spec = pkg.metadata format.file_entries = [] pkg.each do |entry| format.file_entries << [{"size", entry.size, "mode", entry.mode, "path", entry.full_name}, entry.read] end end format end Gem::Packageに処理が移っています。package.rbを開いて眺めるとTar何とかと書いてあるのでgemファイルの実体はtarフォーマットと推察されます。 Gem::Package.open_from_ioメソッドは第2引数modeが"r"の場合TarInput.open_from_ioメソッドを呼び出しています。TarInput.open_from_ioメソッドはTarInputオブジェクトを作成後ブロックを呼び出しています。 TarInput#initializeメソッドでは与えられたストリームからTarReaderオブジェクトを構築して、セキュリティ周りのことがごそごそされていますがそこら辺を無視するとmetadata.gzからGem::Specificationを取得しています。metadata.gzを展開したmetadataは例によってYAMLでパッケージ情報が書かれています。 次にPackage#eachメソッドを眺めてみます。 def each(&block) @tarreader.each do |entry| next unless entry.full_name == "data.tar.gz" is = zipped_stream(entry) begin TarReader.new(is) do |inner| inner.each(&block) end ensure is.close if is end end @tarreader.rewind end というわけでPackage#eachメソッドのブロックに渡されてくるのはdata.tar.gz(インストールするパッケージの各ファイルが格納されている)内の各ファイルとなっています。 *追補Gem::Installer [#q9bc9ae7] Installer#installメソッドに戻って、extract_filesメソッドはすでにファイルは展開されているので各ファイルを書き込むだけです。その後、実行ファイルがある場合は実行ファイルのスタブを作成し、拡張ライブラリがある場合は拡張ライブラリを構築し、先ほど挙げたGem::SpeficationオブジェクトのためのRubyスクリプトを書き込んでinstallメソッドは終了です。 以上でRubyGemsのパッケージインストール処理は終了です。 以上でRubyGemsのパッケージインストール処理は終了となります。 *おわりに [#k91b8adb] 今回はRubyGemsのインストール処理を読みました。これだ!というものは特にないのですがとてもRuby的なコードになっていると思いました。 それではみなさんもよいコードリーディングを。