RubyGemsはrequireの裏で何をやっているのか?


ライブラリやツールをコマンド一発でインストール出来るRubyGemsはとっても便利です。自作ソフトをRubyGems.orgに登録すれば世界中でインストールして使ってもらえます。便利なRubyGemsですが内部ではどのような仕組みで動いているのでしょうか?

インストールしたgemはどこへいくのか?

試しに適当なgemをインストールしてみましょう。

$ gem install rubywho
Successfully installed rubywho-0.4.0
1 gem installed
Installing ri documentation for rubywho-0.4.0...
Installing RDoc documentation for rubywho-0.4.0...

OSXのMacPorts経由でインストールした場合は、以下にインストールされます。
/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/

古いバージョンもインストールしてみます。

$ gem install rubywho -v 0.3.0
Fetching: rubywho-0.3.0.gem (100%)
Successfully installed rubywho-0.3.0
1 gem installed
Installing ri documentation for rubywho-0.3.0...
Installing RDoc documentation for rubywho-0.3.0...

gemは複数のバージョンを同時に管理することが出来ます。rubywho-0.3.0は0.4.0の横に置かれます。

  • /opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/
  • /opt/local/lib/ruby/gems/1.8/gems/rubywho-0.3.0/

rubywho-0.4.0/の下には、コードがテストを含めた状態で入っています。ongaeshi/rubywho - GitHubは完全なrubywhoのソースコード一式ですが、gemの中身とほとんど変わりません。

gemをフォルダごと適当な場所にコピーすればテストも実行出来ますし改造することも可能です(unix環境ではパーミッションを直す必要があるかもしれません)。

# コピーして・・・
$ cp -rpv /opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/ ~/tmp/rubywho
/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/ -> /Users/ongaeshi/tmp/rubywho
/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0//.document -> /Users/ongaeshi/tmp/rubywho/.document
.
.
# テストも実行出来る
$ cd ~/tmp/rubywho/
$ rake test
(in /Users/ongaeshi/tmp/rubywho)
/opt/local/bin/ruby -I"lib:lib:test" "/opt/local/lib/ruby/gems/1.8/gems/rake-0.8.7/lib/rake/rake_test_loader.rb" "test/test_rubywho.rb" 
Loaded suite /opt/local/lib/ruby/gems/1.8/gems/rake-0.8.7/lib/rake/rake_test_loader
Started
......
Finished in 0.016145 seconds.

6 tests, 48 assertions, 0 failures, 0 errors

何かRubyで作りたいものがある時は、似たようなことを実現しているgemを見つけ、そのソースコードを読むとかなり役立ちます。

$LOAD_PATHって何者?

まずは以下のプログラムを実行してみましょう。

# requireやloadはLOAD_PATHに登録されているディレクトリの中を順番に探して最初に見つかったものを採用する
p $LOAD_PATH                    #=> ["/opt/local/lib/ruby/site_ruby/1.8", ...]
# p $:                            # $LOAD_PATHの省略形

# ファイルが見つからない場合は、LoadError例外
begin
  require 'test_script'
rescue LoadError
  puts "'test_script' not found."
end

# LOAD_PATHに適切なディレクトリを設定すると読み込めるようになる
$LOAD_PATH.unshift "test"
require 'test_script'
puts test_script()

# LOAD_PATHに含まれたディレクトリからの相対パスでもOK
require 'a/test_script2'
puts a_test_script()

実行結果

$ ruby ./load_path_test.rb
["/opt/local/lib/ruby/site_ruby/1.8", "/opt/local/lib/ruby/site_ruby/1.8/i686-darwin10", "/opt/local/lib/ruby/site_ruby", "/opt/local/lib/ruby/vendor_ruby/1.8", "/opt/local/lib/ruby/vendor_ruby/1.8/i686-darwin10", "/opt/local/lib/ruby/vendor_ruby", "/opt/local/lib/ruby/1.8", "/opt/local/lib/ruby/1.8/i686-darwin10", "."]
'test_script' not found.
test_script!!
a/test_script!!

Rubyのライブラリ読み込みの仕組みはとても単純です。

  1. $LOAD_PATH($:)を順に辿る
  2. 目的のファイルが見つかったら終了
  3. 最後まで来たらLoadError例外

C言語C++に慣れている人は、gccのインクルードパス(-Iオプション)と同じようなもの、と言うと分かりやすいかもしれません。

Rubyでは$LOAD_PATHを実行時に変更することが可能なため、C言語と比べて柔軟な動作をすることが出来ます。

require 'rubygems'すると、$LOAD_PATHにどのような変化があるか?

以下のプログラムを実行してみましょう。

# gemをrequireするとLOAD_PATHが変化する
# require 'rubywho' すると、 gems/rubywho-0.4.0/bin や gems/rubywho-0.4.0 が追加される
require 'rubygems'
require 'rubywho'
p $LOAD_PATH                    #=> ["/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/bin", "/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/lib", ...]

実行結果

$ ruby ./require_gem_test.rb
["/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/bin", "/opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/lib", "/opt/local/lib/ruby/site_ruby/1.8", "/opt/local/lib/ruby/site_ruby/1.8/i686-darwin10", "/opt/local/lib/ruby/site_ruby", "/opt/local/lib/ruby/vendor_ruby/1.8", "/opt/local/lib/ruby/vendor_ruby/1.8/i686-darwin10", "/opt/local/lib/ruby/vendor_ruby", "/opt/local/lib/ruby/1.8", "/opt/local/lib/ruby/1.8/i686-darwin10", "."]
  • /opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/bin
  • /opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/lib

の2つが$LOAD_PATHの先頭に追加されています。この状態で

require 'rubywho'

すると

  1. Ruby$LOAD_PATHを先頭から辿る
  2. /opt/local/lib/ruby/gems/1.8/gems/rubywho-0.4.0/lib/rubywho.rb が最初に見つかって読み込まれる。

つまりRubyGemsの仕事は「登録されているライブラリパスの中から適切なものを探してそれを$LOAD_PATHに追加する」ということのようです。

コードリーディング、rubygems/lib/rubygems/custom_require.rb

RubyGemsでは Kernel#require を独自のものに置き換えることで上記の仕事をしているようです。

rubygems/lib/rubygems/custom_require.rb (コメントは日本語に訳してあります)

require 'rubygems'

module Kernel
  ##
  # RubyGemsがロードされる前のKernel#require

  alias gem_original_require require

  ##
  # RubyGemsがrequireされた時、Kernel#requireは、必要な時にgemを読み込む独自の実装に置き換えられます。
  #
  # <tt>require 'x'</tt> した時、以下のことが起きる:
  # * もし現在の$LOAD_PATHからファイルが読み込める時は読み込む
  # * 見つからなければ、インストールされたgemsからマッチするファイルがあるか探す。
  #   もし 'y' gem から見つかれば、そのgemをアクティベートする ($LOAD_PATHに追加する)
  #
  # 通常<tt>require</tt>の'ファイルがすでにロード済みの時にfalseを返す機能'は維持している。
  #
  def require(path) # :doc:
    gem_original_require path
  rescue LoadError => load_error
    if load_error.message =~ /#{Regexp.escape path}\z/ and
       spec = Gem.searcher.find(path) then
      Gem.activate(spec.name, "= #{spec.version}")
      gem_original_require path
    else
      raise load_error
    end
  end

  private :require
  private :gem_original_require

end
  1. gem_original_require で元々の require を呼べるようにする
  2. 最初に$LOAD_PATHを探して見つかればそのまま読み込む
  3. 見つからなければインストールされたgemsからマッチするファイルがあるか探して、見つかればアクティベートして$LOAD_PATHに追加する
  4. gem_original_require して読み込み完了

はじめに$LOAD_PATHから探すのが肝です。
あるgemから2つ以上のファイルをrequireする場合、2回目以降は$LOAD_PATHから読み込まれることになります(アクティベートされるのは最初の一回目だけです)。
通常requireの「ファイルがすでにロード済みの時にfalseを返す」機能も維持出来る、シンプルで素晴らしい実装ですね。

gem本体を書き換えずに特定のアプリ上でのみ挙動を変更するには?

自分のアプリケーションが特定のgemを利用している時、一部の挙動に問題があることがあります。もちろんgemに対してパッチを送るのがベストですが、そのパッチが棄却されたり取り込まれたバージョンがなかなかリリースされないこともあるはずです。そんな時はどのように対処すればいいのでしょうか?gem本体を書き換えずに特定のアプリ上でのみ挙動の一部を書き換えることは可能なのでしょうか?

もちろん$LOAD_PATHを利用すれば可能です!!

半年程前、Milkodeというアプリケーションを作っていた時にarchive-zipというgemをzipファイルの展開に使うことにしました。gem自体は使いやすくて問題もなかったのですが、当時最新だった0.3ではRuby1.9で動かなかったことが問題でした(最新の0.4.0では修正済みです)。パッチを送ったもののすでに他のアプローチをとっていたため取り込まれず、Ruby1.9で動くバージョンが出るのも少し後になりそうでした。

そこで「1.9で動くパッチをmilkode内部でだけ当ててarchive-zipが対応したらそのパッチを外す」というアプローチをとることにしました。

まずは問題となったコードです。
lib/archive/support/io.rb at GitHub

require 'readbytes'

class IO
  # Returns +true+ if the seek method of this IO instance would succeed, +false+
  # otherwise.
  def seekable?
    begin
      pos
      true
    rescue SystemCallError
      false
    end
  end
end
require 'archive/support/io' 

というパスでrequireされます。

細かい説明は省略しますが'readbytes'というライブラリがRuby1.9では無くなっているのが原因でした。色々と考えた結果、archive/support/io.rb が以下のようになればRuby1.8, 1.9 の両方で動くことが分かりました。

# -*- coding: utf-8 -*-
#
# @file
# @brief archive-zip/lib/archive/support/io.rb patch, removed in the future.
# @author ongaeshi
# @date 2011/08/04

begin
  require 'readbytes'
rescue LoadError
  # for Ruby 1.9.2
  class TruncatedDataError<IOError
    def initialize(mesg, data) # :nodoc:
      @data = data
      super(mesg)
    end

    # The read portion of an IO#readbytes attempt.
    attr_reader :data
  end
  
  class IO
    # Reads exactly +n+ bytes.
    #
    # If the data read is nil an EOFError is raised.
    #
    # If the data read is too short a TruncatedDataError is raised and the read
    # data is obtainable via its #data method.
    def readbytes(n)
      str = read(n)
      if str == nil
        raise EOFError, "End of file reached"
      end
      if str.size < n
        raise TruncatedDataError.new("data truncated", str)
      end
      str
    end
  end
end
  
class IO
  # Returns +true+ if the seek method of this IO instance would succeed, +false+
  # otherwise.
  def seekable?
    begin
      pos
      true
    rescue SystemCallError
      false
    end
  end
end

ここからはmilkode本体を書き換えていきます。

  1. milkode/vendor/archive/support/io.rb を追加します。
  2. milkode/lib/milkode/common/archive-zip.rb というファイルを作ります。
  3. require 'archive/zip' している箇所を require 'milkode/common/archive-zip' に置き換えます。

これで準備は終わりです!!
lib/common/archive-zip.rb

# -*- coding: utf-8 -*-
#
# @file
# @brief archive-zipがRuby1.9.2に対応するまでのパッチ
# @author ongaeshi
# @date 2011/08/04

$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '../../../vendor')
require 'archive/zip'

File.join(File.dirname(__FILE__), 'path/to/dir') はスクリプト位置からの相対パス絶対パスに変換するための定型文です。milkode/lib/milkode/common/../../../vendorなので、結果としてmilkode/vendorに変換されます。
milkode/vendor$LOAD_PATHの先頭に挿入されるため、

  1. milkode/vendor 以下にファイルが存在する場合は優先して読み込み
  2. 見つからなかった場合は通常の読み込み処理へ

となります(先ほどのRubyGemsの読み込みルールを思い出して下さい)。

今回の場合 milkode/vendor/archive/support/io.rb があるので、

require 'archive/support/io'

の時、vendor/以下のファイルが通常のgemコードよりも優先して読み込まれます。これで目的の動作です!!

gem本体のバージョンが上がりパッチを当てる必要がなくなったら$LOAD_PATH.unshiftの部分をコメントアウトしましょう。
Commit c727625e7ba114b72d5ae5b0290e610ae6bb5fd1 to ongaeshi/milkode - GitHub

# 0.4.0になったため、必要なくなった。
# $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '../../../vendor')
require 'archive/zip'

Ruby Advent Calendar jp: 2011

この記事はRuby Advent Calendar jp: 2011の14日目の記事でした!!

よかったらこちらもどうぞ、面白い記事がたくさんあります。

参考文献