はじめに

Rubyパッケージの伝統的なインストーラとして青木さんのsetup.rbがあります。著名なRubyのパッケージ管理ツールであるRubyGemsでさえ、インストールする方法は

ruby setup.rb

です。Ruby on Railsを理解する、前にRubyGemsを理解する、前にsetup.rbを理解してみましょう。その前にRubyを理解する必要があるんじゃないかって?それはすでに青木さんの大著があるので小生の出る幕はありません。

なお、今回対象としたバージョンは3.4.1です。

起動部分

ではまず、ruby setup.rbとした時に何が起こるかを見てみましょう。実際のコードでは例外処理があるのですが見やすさのためにそこら辺のコードは省きます。

if $0 == __FILE__
  ToplevelInstaller.invoke
end

$0は実行中のファイル名、__FILE__は現在のソースファイル名です。この2つは似ているようで違います。論より証拠です。次の2つのスクリプトがあったとします。

foo.rb

puts "$0\t:#{$0}"
puts "__FILE__:#{__FILE__}"

if $0 == __FILE__
  puts 'foo.rb'
end

bar.rb

require 'foo'

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

前置きが長くなってしまいましたが、起動時に呼ばれる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

まず、ToplevelInstaller.load_rbconfigです。

def ToplevelInstaller.load_rbconfig
  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

さて、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

ToplevelInstaller.invokeの次の行はマルチパッケージに関するものです。RubyGemsはマルチパッケージではないのでさっくり省きます。

次の行はConfigTable#fixupメソッドです。興味深いのは次の一行。

@options_re = /\A--(#{@table.keys.join('|')})(?:=(.*))?\z/

先ほど設定したオプションテーブルを使って正規表現を組み上げています。

さらに次の行に進んで・・・、マルチパッケージは対象としないので構わないのですが、おぉと思ったので書いておくと、

klass = (multipackage?() ? ToplevelInstallerMulti : ToplevelInstaller)

三項演算子にてmultipackage?メソッドを呼んでいます。初め、パーサ的な制約で

klass = (multipackage? ? ToplevelInstallerMulti : ToplevelInstaller)

と書いてないのかな?と思ったのですがそうではない(上のコードも通ります)ようです。どうやらコードを読む人が「あぁ、これはメソッド呼び出しなんだな」とすぐにわかるように()が付いているようです。引数のないメソッド呼び出しは()を付けないというポリシーより読みやすさを重視したところが素晴らしいです。*1

ToplevelInstaller#invoke

さてようやくインストール処理をしてそうなところまでたどり着きました。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

さて、前半で仕込んだオプションテーブルを使う部分がやってきました。一部はしょった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

では、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

ConfigTable#[]メソッドを見てみると以下の処理が行われました。

def [](key)
  lookup(key).resolve(self)
end

解決しています。何を解決しているのでしょうか?見てみましょう。

def resolve(table)
  @value.gsub(%r<\$([^/]+)>) { table[$1] }
end

Itemが自分の持っている値の$で始まる名前を実際の値にしているようです。

a=$b/c
b=$d/e
d=f

の場合にaを参照しても、

  1. bを参照
  2. dを参照
  3. fを返す
  4. f/eを返す
  5. f/e/cを返す

という具合に再帰的に名前が解決されています。

Installer#traverse

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)], '')
    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の正体のようです。

おわりに

今回は青木さんのsetup.rbを読みました。今回学んだこととしては以下があります。

  • オブジェクトテーブルを使ったオプションの処理方法
  • リフレクションを使った各タスク各ディレクトリの処理方法

それではみなさんもよいコードリーディングを。


*1 とか言っているわけですが昔のパーサだと通らなかっただけだったりして:-P

トップ   編集 凍結解除 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2007-08-12 (日) 04:51:18 (6095d)