高見龍

iOS app/Ruby/Rails Developer & Instructor, 喜愛非主流的新玩具 :)

Ruby 語法放大鏡之「你知道 require 幫你做了什麼事嗎?」

Ruby 語法放大鏡」系列短文主要是針對在大家學習 Ruby 或 Rails 時看到一些神奇但不知道用途的語法介紹,希望可以藉由這一系列的短文幫大家更容易的了解到底 Ruby 或 Rails 是怎麼回事。

也許你曾在 Ruby 或 Rails 專案中寫過這行語法:

1
2
require "digest"
puts Digest::MD5.hexdigest("I love you")    # => "e4f58a805a6e1fd0f6bef58c86f9ceb3"

上面這段語法的大意是「引用 “digest” 模組,然後使用那個模組裡的某個方法產生 MD5 編碼字串」。但你知道這個 require 到底做了什麼事嗎? 以下將用我自己寫的一個名為 takami 的套件為例,它是一個完全沒功能的空包彈套件,純粹練功用途。

require 是怎麼運作的?

讓我們先打開終端機,直接試著印出 $PATH 這個變數來看看:

$ echo $PATH
/Users/user/.rvm/gems/ruby-2.3.1/bin:/Users/user/.rvm/gems/ruby-2.3.1@global/bin ...[略]...

在你電腦上的輸出結果可能跟我的不太一樣。當你想執行某個程式,例如 git log,系統會依照 $PATH 列出來的順序,一個一個的問「請問在這個路徑是不是有 git 這個程式?」,如果有,就執行它;如果都沒有,就會出現 command not found 的訊息。

在 Ruby 裡有個叫做 $LOAD_PATH 的全域變數(可簡寫成 $:)的功用跟 $PATH 類似,讓我們開 irb 來試試:

$ irb
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...

>> $:
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...

當你執行 require "takami" 之後,它會把這個 gem 加到 $LOAD_PATH 裡,並且把它加到另一個全域變數 $LOADED_FEATURES(可簡寫成 $") 裡:

$ irb
>> $LOAD_PATH.count
=> 9

>> $LOADED_FEATURES.count
=> 59

>> require "takami"
=> true

>> $LOAD_PATH.count
=> 10

>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib", ...[略]...

>> $LOADED_FEATURES
=> ["enumerator.so", "thread.rb",  ...[略] ... "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib/takami.rb"]

>> $LOADED_FEATURES.count
=> 61

在 require 之前 $LOAD_PATH 只有 9 個,require 之後變 10 個了,而 $LOADED_FEATURES 的數量也從原本的 59 變成 61 了。除了可以在 $LOADED_FEATURES 看到新增加跟 takami 套件有關的東西之外,也可以看的出來 takami 套件的確在 $LOAD_PATH 裡了,這樣一來就可以直接使用我那個空包彈的 Takami 套件了。

原始碼挖挖挖!

首先,require 並不是關鍵字,它在 Ruby 裡只是個一般的方法(定義在 Kernel 模組裡),所以就讓我們試著挖 Ruby 的原始碼出來看看它到底做了什麼事。

但在開挖之前,要先講一下歷史故事,免得挖錯地方。在 Ruby 1.8 時代,Ruby 的 require 就是普通的 require,在 Ruby 1.9 之後,require 的事情則是交給 rubygems 套件來管理,所以我們現在應該要去挖 rubygems 套件的原始碼才能挖得到 require 真正的行為。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# 檔案: rubygems/lib/rubygems/core_ext/kernel_require.rb
require 'monitor'

module Kernel

  RUBYGEMS_ACTIVATION_MONITOR = Monitor.new # :nodoc:

  if defined?(gem_original_require) then
    remove_method :require
  else
    alias gem_original_require require
    private :gem_original_require
  end

  def require path
    RUBYGEMS_ACTIVATION_MONITOR.enter

    path = path.to_path if path.respond_to? :to_path

    spec = Gem.find_unresolved_default_spec(path)
    if spec
      Gem.remove_unresolved_default_spec(spec)
      gem(spec.name)
    end

    if Gem::Specification.unresolved_deps.empty? then
      RUBYGEMS_ACTIVATION_MONITOR.exit
      return gem_original_require(path)
    end

    spec = Gem::Specification.find_active_stub_by_path path

    begin
      RUBYGEMS_ACTIVATION_MONITOR.exit
      return gem_original_require(path)
    end if spec

    found_specs = Gem::Specification.find_in_unresolved path

    if found_specs.empty? then
      found_specs = Gem::Specification.find_in_unresolved_tree path

      found_specs.each do |found_spec|
        found_spec.activate
      end

    else

      names = found_specs.map(&:name).uniq

      if names.size > 1 then
        RUBYGEMS_ACTIVATION_MONITOR.exit
        raise Gem::LoadError, "#{path} found in multiple gems: #{names.join ', '}"
      end

      valid = found_specs.reject { |s| s.has_conflicts? }.first

      unless valid then
        le = Gem::LoadError.new "unable to find a version of '#{names.first}' to activate"
        le.name = names.first
        RUBYGEMS_ACTIVATION_MONITOR.exit
        raise le
      end

      valid.activate
    end

    RUBYGEMS_ACTIVATION_MONITOR.exit
    return gem_original_require(path)
  rescue LoadError => load_error
    RUBYGEMS_ACTIVATION_MONITOR.enter

    begin
      if load_error.message.start_with?("Could not find") or
          (load_error.message.end_with?(path) and Gem.try_activate(path)) then
        require_again = true
      end
    ensure
      RUBYGEMS_ACTIVATION_MONITOR.exit
    end

    return gem_original_require(path) if require_again

    raise load_error
  end

  private :require
end

上面這段我把部份的註解拿掉省一點空間。當 rubygems 被 require 進來之後,原本 Kernel 模組的 require 方法就被換成 rubygems 裡的 require 方法,並且把原本 Kernel 模組定義的 require 方法 alias 成 gem_original_require

如果該套件本來就已經在 $LOAD_PATH 上的話,它會直接呼叫原本 Kernel 模組定義的 require 方法來載入套件;但如果在 $LOAD_PATH 上沒有找不到的話,它就會試著開始去找看看是不是在系統上有安裝這個套件,然後執行 activate 方法,將它加到 $LOAD_PATH 裡。

讓我們來玩一下分解動作:

$ irb
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", ...[略]...

# 原本只有 9 個
>> $LOAD_PATH.count
=> 9

# 找到我的套件
>> my_gem = Gem::Specification.find_by_name("takami")
=> #<Gem::Specification:0x3fdcf54346c8 takami-0.0.1>

# activate 方法會把這個 gem 加到 $LOAD_PATH 裡
>> my_gem.activate
=> true

# 套件現在加到 $LOAD_PATH 裡了
>> $LOAD_PATH
=> ["/Users/user/.rvm/gems/ruby-2.3.1@global/gems/did_you_mean-1.0.0/lib", "/Users/user/.rvm/gems/ruby-2.3.1/gems/takami-0.0.1/lib", ...[略]...

# 數量也變 10 個
>> $LOAD_PATH.count
=> 10

# 咦? 不是在 $LOAD_PATH 了嗎? 怎麼不行
>> Takimi
NameError: uninitialized constant Takimi
  from (irb):8
  from /Users/user/.rvm/rubies/ruby-2.3.1/bin/irb:11:in `<main>'

# 呼叫原本 Kernel 的 require 方法
>> gem_original_require 'takami'
=> true

# 可正常執行!
>> Takami
=> Takami

並不是有在 $LOAD_PATH 裡就可以直接使用,事實上它還是得靠原本 Kernel 模組的 require 方法把它加到 $LOADED_FEATURES 之後才能使用。所以說穿了,rubygems 其實只算是在幫你管理 $LOAD_PATH 的路徑而已(當然也沒這麼單純啦),真正 require 的行為還是在 Kernel 模組裡。

順帶一提,如果 require 是 Ruby 內建的標準函式庫(StdLib),例如本文一開始範例裡提到的 digest,$LOAD_PATH 本身並不會有任何變化(因為這些內建的本來就找得到了啊),但還是會把該模組加到 $LOADED_FEATURES 裡。

原本 Kernel 模組的 require

繼續挖 Ruby 原始碼來看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 檔案:load.c

VALUE
rb_require_safe(VALUE fname, int safe)
{
  int result = rb_require_internal(fname, safe);

  if (result > TAG_RETURN) {
    JUMP_TAG(result);
  }
  if (result < 0) {
    load_failed(fname);
  }

  return result ? Qtrue : Qfalse;
}

這段是在 Kernel 模組裡 require 方法的實作,真正載入的行為是在 rb_require_internal 這個 function 裡,有興趣的朋友可以繼續往下追。Ruby 會檢查那個檔案是不是已經在 $LOADED_FEATURES 裡,如果不在就會把它加進來。

另外,rb_require_internal 這個 function 的回傳結果可能有以下 4 種:

1
2
3
4
5
6
7
/*
 * returns
 *  0: if already loaded (false)
 *  1: successfully loaded (true)
 * <0: not found (LoadError)
 * >1: exception
 */

所以,如果 require 成功,rb_require_safe 這個 function 會回傳 true,但如果之前就已經 require 過的話則會回傳 false,如果什麼都找不到,則是直接產生 LoadError 的錯誤訊息:

$ irb
# 第一次 require,回傳 true
>> require "takami"
=> true

# 第二次 require 相同的套件,回傳 false
>> require "takami"
=> false

# require 一個找不到的 gem
>> require "the_gem_not_install"
LoadError: cannot load such file -- the_gem_not_install

小結

也許這個簡單的 require 指令一下子就跑完了,但當你知道它背後是怎麼運作之後,你對 Ruby 就可以有更深一層的了解。在挖原始碼的過程有點辛苦但也很有趣,除了可以看看大師們是怎麼設計的,還可以看到一些平常看不到的東西 :)

Comments