#contents *はじめに [#sf691330] Rubyパッケージの伝統的なインストーラとして[[青木さんのsetup.rb>http://i.loveruby.net/ja/projects/setup/]]があります。著名なRubyのパッケージ管理ツールであるRubyGemsでさえ、インストールする方法は ruby setup.rb です。Ruby on Railsを理解する、前にRubyGemsを理解する、前にsetup.rbを理解してみましょう。その前にRubyを理解する必要があるんじゃないかって?それはすでに[[青木さんの大著>http://i.loveruby.net/ja/rhg/book/]]があるので小生の出る幕はありません。 なお、今回対象としたバージョンは3.4.1です。 *起動部分 [#tc6d6dab] ではまず、ruby setup.rbとした時に何が起こるかを見てみましょう。実際のコードでは例外処理があるのですが見やすさのためにそこら辺のコードは省きます。 if $0 == __FILE__ ToplevelInstaller.invoke end $0は実行中のファイル名、__FILE__は現在のソースファイル名です。この2つは似ているようで違います。論より証拠です。次の2つのスクリプトがあったとします。 foo.rb puts "$0?t:#{$0}" puts "$0\t:#{$0}" puts "__FILE__:#{__FILE__}" if $0 == __FILE__ puts 'foo.rb' end bar.rb require 'foo' puts "$0?t:#{$0}" puts "$0\t:#{$0}" puts "__FILE__:#{__FILE__}" foo.rbを実行すると以下のように表示されます。 $ ruby foo.rb $0 :foo.rb __FILE__:foo.rb foo.rb bar.rbを実行すると以下のように表示されます。 $ ruby bar.rb $0 :bar.rb __FILE__:./foo.rb $0 :bar.rb __FILE__:bar.rb $0は変わらないけど__FILE__は変わります。__FILE__はRubyインタプリタが今まさに読んでいるファイルです。一方、$0はRubyインタプリタ起動時に指定されたスクリプトです。 さて、以上の説明およびfoo.rbの実行結果を見ていただくとご理解いただけると思いますが、 if $0 == __FILE__ 何とか end というのはスクリプトが直接実行されたときに実行したいコード(ライブラリとして呼ばれたときは実行して欲しくないコード)を入れておくためのイディオムです。 *ToplevelInstaller.invoke [#c798b1a5] 前置きが長くなってしまいましたが、起動時に呼ばれるToplevelInstaller.invokeに進みましょう。ToplevelInstaller.invokeは以下のようになっています。 def ToplevelInstaller.invoke config = ConfigTable.new(load_rbconfig()) config.load_standard_entries config.load_multipackage_entries if multipackage? config.fixup klass = (multipackage?() ? ToplevelInstallerMulti : ToplevelInstaller) klass.new(File.dirname($0), config).invoke end 一行ずつ読んでいきましょう。 *ToplevelInstaller.load_rbconfig [#z85024e7] まず、ToplevelInstaller.load_rbconfigです。 def ToplevelInstaller.load_rbconfig if arg = ARGV.detect {|arg| /?A--rbconfig=/ =~ arg } if arg = ARGV.detect {|arg| /\A--rbconfig=/ =~ arg } ARGV.delete(arg) load File.expand_path(arg.split(/=/, 2)[1]) $".push 'rbconfig.rb' else require 'rbconfig' end ::Config::CONFIG end まあそんな感じかなというところですが注目すべきは以下の一行です。 load File.expand_path(arg.split(/=/, 2)[1]) というか解説したいところが2つあるので二行に分けましょう。 path = arg.split(/=/, 2)[1] load File.expand_path(path) 一行目、普通ならsplit(/=/)とだけしてしまいそうですが省略可能な第2引数で分割数を2に限定しています。これにより、 --rb-config=a=b.rb としていても適切にa=b.rbが読み込まれます。そんな変なファイル指定するなというところですがこういう細かい配慮がされていると好感が持てます。 次に二行目、File.expand_pathメソッドを使って相対パスを絶対パスにしています。これにより、a.rbと指定した場合にスクリプト検索パス中にa.rbがあった場合に誤って読まれてしまうということがなくなります。広く使ってもらおうというコードはこういう配慮をすべきだなと痛感させられます。 rbconfig.rbって何?という方のために書いておくとrbconfig.rbとはRubyインストール時に生成されるファイルでライブラリのディレクトリはどこかということが書いてあります。setup.rbはこのファイルを使うことでライブラリのインストール先を決定しています。 *ConfigTable#standard_entries [#tdb1b328] さて、ToplevelInstaller.invokeに戻ると次にConfigTable#load_standard_entriesメソッドが呼ばれています。load_standard_entriesではstandard_entriesメソッドを呼んで何やらエントリーをaddしています(名前そのままですけどね。メソッドがやっていることをメソッド名にするというのは重要です。メソッド名以上のことをやり出したらメソッドを分けるべきです)。 addメソッドは以下のようになっています。 def add(item) @items.push item @table[item.name] = item end クラス名がConfigTableで、@tableというインスタンス変数名からすると@tableがこのクラスの最重要インスタンス変数のような感じですね。 さて、セクション名のstandard_entriesメソッドです。バージョンによる処理を行っています。このようなコードを見るとRubyがたどってきた歴史を見ているようでとてもおもしろいです。 というのも興味深いところですがこのメソッドではどうやらオプション解析をするための準備を行っているようです。一つ例示すると以下のものがあります。 PathItem.new('prefix', 'path', c['prefix'], 'path prefix of target environment') これはどうやら ruby setup --help としたときに表示されるオプション一覧のようです。これらがどのように使われどのような振る舞いをするかは実際に使われているところで見ることにします。 *追補ToplevelInstaller.invoke [#yd90abd9] ToplevelInstaller.invokeの次の行はマルチパッケージに関するものです。RubyGemsはマルチパッケージではないのでさっくり省きます。 次の行はConfigTable#fixupメソッドです。興味深いのは次の一行。 @options_re = /?A--(#{@table.keys.join('|')})(?:=(.*))??z/ @options_re = /\A--(#{@table.keys.join('|')})(?:=(.*))?\z/ 先ほど設定したオプションテーブルを使って正規表現を組み上げています。 さらに次の行に進んで・・・、マルチパッケージは対象としないので構わないのですが、おぉと思ったので書いておくと、 klass = (multipackage?() ? ToplevelInstallerMulti : ToplevelInstaller) 三項演算子にてmultipackage?メソッドを呼んでいます。初め、パーサ的な制約で klass = (multipackage? ? ToplevelInstallerMulti : ToplevelInstaller) と書いてないのかな?と思ったのですがそうではない(上のコードも通ります)ようです。どうやらコードを読む人が「あぁ、これはメソッド呼び出しなんだな」とすぐにわかるように()が付いているようです。引数のないメソッド呼び出しは()を付けないというポリシーより読みやすさを重視したところが素晴らしいです。((とか言っているわけですが昔のパーサだと通らなかっただけだったりして:-P)) *ToplevelInstaller#invoke [#o187d3a7] さてようやくインストール処理をしてそうなところまでたどり着きました。metaconfigを呼んだりオプション処理をしたりしているところを眺めると"ruby setup.rb"とだけでsetup.rbを起動した場合以下のコードが実行されます。 parsearg_config init_installers exec_config exec_setup exec_install parsearg_configメソッドではオプションの解析を行っています。このメソッドは後で詳しく見ます。次のinit_installersメソッドではInstallerオブジェクトを構築しています。init_installerメソッドはInstallerオブジェクトを作るだけなので別メソッドにする必要あるのかな〜?と思ったのですがどうやらマルチパッケージではいろいろ処理をしているようです。つまり、上のコードはテンプレートメソッドパターンになっているようですね。 *ToplevelInstaller#parsearg_config [#l6f62886] さて、前半で仕込んだオプションテーブルを使う部分がやってきました。一部はしょったparsearg_configメソッドは以下のようになっています。 def parsearg_config evalopt = [] set = [] while i = ARGV.shift name, value = *@config.parse_opt(i) if @config.value_config?(name) @config[name] = value else evalopt.push [name, value] end set.push name end evalopt.each do |name, value| @config.lookup(name).evaluate value, @config end # Check if configuration is valid set.each do |n| @config[n] if @config.value_config?(n) end end まず、ConfigTable#parse_optメソッドを見てみましょう。 def parse_opt(opt) m = @options_re.match(opt) or setup_rb_error "config: unknown option #{opt}" m.to_a[1,2] end 先ほど構築したオプション解析用正規表現に通し、オプション名とオプション値を取得しています。 次に、ConfigTable#value_config?ですが読み進めていくとConfigTable::Item#value?に行き着きます。興味深いのはExecItemクラスだけfalseを返す(つまり、evaloptに入れられる)ということです。 *ConfigTable::ExecItem [#qfde74e8] では、ExecItemを見てみましょう。ExecItemを構築しているところ、initializeメソッド、evaluateメソッドです。 ExecItem.new('installdirs', 'std/site/home', 'std: install under libruby; site: install under site_ruby; home: install under $HOME')? {|val, table| case val when 'std' table['rbdir'] = '$librubyver' table['sodir'] = '$librubyverarch' <<中略>> end } def initialize(name, selection, desc, &block) super name, selection, nil, desc @ok = selection.split('/') @action = block end def evaluate(val, table) v = val.strip.downcase unless @ok.include?(v) setup_rb_error "invalid option --#{@name}=#{val} (use #{@template})" end @action.call v, table end というわけで、オプションが指定されたときに設定をスクリプト的にいじりたいというときに使われる仕組みとして、Item#value?メソッドが用意されているようです。 *ConfigTable::Item#resolve [#ce14bbf4] ConfigTable#[]メソッドを見てみると以下の処理が行われました。 def [](key) lookup(key).resolve(self) end 解決しています。何を解決しているのでしょうか?見てみましょう。 def resolve(table) @value.gsub(%r<?$([^/]+)>) { table[$1] } @value.gsub(%r<\$([^/]+)>) { table[$1] } end Itemが自分の持っている値の$で始まる名前を実際の値にしているようです。 a=$b/c b=$d/e d=f の場合にaを参照しても、 +bを参照 +dを参照 +fを返す +f/eを返す +f/e/cを返す という具合に再帰的に名前が解決されています。 *Installer#traverse [#kc2f14b9] ToplevelInstaller#exec_configを見ると先ほど構築したInstallerオブジェクトのexec_configメソッドを呼び出しています。次に、Installer#exec_configメソッドを見ると次の一行が書かれています。 exec_task_traverse 'config' 次にexec_task_traveseメソッドです。オプション処理の部分をちょっと省いています。 def exec_task_traverse(task) run_hook "pre-#{task}" FILETYPES.each do |type| traverse task, type, "#{task}_dir_#{type}" end run_hook "post-#{task}" end FILETYPESってなんだろうと思ったらsetup.rbが対象としているbin/, lib/, ext/, data/, conf/, man/のようです。さらにtraverseメソッドに進みましょう。 def traverse(task, rel, mid) dive_into(rel) { run_hook "pre-#{task}" __send__ mid, rel.sub(%r[?A.*?(?:/|?z)], '') __send__ mid, rel.sub(%r[\A.*?(?:/|\z)], '') directories_of(curr_srcdir()).each do |d| traverse task, "#{rel}/#{d}", mid end run_hook "post-#{task}" } end このあたりになってくると幾分わかりづらくなりますが、どうやら引数midで渡したメソッドを呼んでいるようです。midは例えば、config_dir_binのようになります。って、config_dir_binメソッドは何もしていないのでRubyGemsには関係ないのですがconfig_dir_extを見てみましょう。 def config_dir_ext(rel) extconf if extdir?(curr_srcdir()) end configなので拡張ライブラリのための設定処理を実行しているようです。 タスクの処理ではリフレクションを使って処理を行っているようです。setup, installについても同様に各タスク各ディレクトリ用の処理メソッドが用意されて処理が実行されているというのがsetup.rbの正体のようです。 *おわりに [#wf39b730] 今回は青木さんのsetup.rbを読みました。今回学んだこととしては以下があります。 -オブジェクトテーブルを使ったオプションの処理方法 -リフレクションを使った各タスク各ディレクトリの処理方法 それではみなさんもよいコードリーディングを。